Grunt -- 最好的前端构建框架

每个前端开发工程师都会遇到前端文件打包、压缩的问题。

Shell -> Ant -> Jake

最开始,我是用 shell 脚本调用 yuicompressor、cssmin 来压缩文件,非常简单,就像这样:

1
2
3
4
5
6
7
#!/usr/bin/env bash

cat src/a.js src/b.js src/c.js > dst/main.js
yuicompressor main.js

cat src/a.css src/b.css src/c.css > dst/main.css
cssmin main.css

后来随着项目规模的逐渐增大,shell 脚本逐渐暴露出了很多问题,比如:

  • 不能自动下载依赖的外部命令,比如 yuicompressor/cssmin
  • 缺乏变量替换功能
  • 难以跨平台
  • 代码难以维护

因此 shell 脚本在到了近百行以后就被放弃了,取而代之的是 Ant。Ant 内置了一些常见任务,比如文件复制、合并、变量替换等,并且可以方便的通过编写自定义任务来扩展。得益于 Java 灵活的扩展性,Ant 任务可以非常方便的在各个项目之间分享,很好的解决了 shell 脚本代码难以维护和迁移的缺点。Ant 还可以结合 ivy 自动解决依赖,实现了完全的跨平台支持。

但是,Ant 有个缺点:它是 Java 写的。Ant 如果要执行 js 代码,就必须通过 rhino,速度比 nodejs 慢了不少,作为一个 JavaScript 工程师,我非常希望可以直接用 js 来写构建脚本。

最终让我放弃 Ant 的是一个新的项目。这个项目的特殊之处在于它用了 maven 作为 Java 的构建工具。之前用的各种 Ant 任务也就没有了用武之地。尽管我可以将 Ant 的任务迁移到 mavn 下面,但是我更希望能找到一个和后台语言完全无关的构建系统。

因此在今年年初,我找到了一个 nodejs 环境下的构建框架 Jake。Jake 的各种编译任务由 Jakefile 中定义(这一点很像 Make),Jakefile 实际上就是一个 js 文件,通过 nodejs 来执行压缩合并等常见任务。

得益于 nodejs,Jake 执行 js 代码压缩的速度非常快,而且开发调试也更加方便。但是有个和 shell 脚本类似的问题:不同项目间的 Jake 任务难以重用,项目大了以后 Jakefile 依然很难维护,面对一个上千行的 Jakefile,和面对一个几百行的 shell 脚本的感觉差不了太多。

以上就是我从 Shell 到 Ant 再到 Jake 的折腾经历。经历这么一圈之后,我发现一个好用的构建框架应该具有以下三个特点:

  • 跨平台(Ant, Jake)
  • 易维护、易迁移(Ant)
  • 开发简单(Shell,Jake)

grunt 就是同时具有以上三个特点的前端构建框架。

grunt

grunt 是一个开源的基于任务 (Task) 的前端构建框架。它除了有 Jake 的优点(跨平台、开发简单)以外,还有一套设计良好的 task 框架用来组织各种构建任务。grunt 内置了几个非常常见的构建任务:

  • concat - 组合各种文件
  • lint - 用 JSHint 检查代码
  • min - 用 UglifyJS 压缩代码
  • qunit - 跑 QUnit 单元测试
  • watch - 当源代码文件发生变化时自动执行任务

除此之外还可以通过 npm 来方便的获取几百个现成的 task,比如用 closure 而不是 UglifyJS 来压缩 js,或者用 less 来生成 css,又或者用 jslint 而不是 jshint 来检查语法等,这些任务都可以在 npm 上找到。如果这些任务无法满足你的需求,grunt 还允许你方便的添加自定任务,就像写 nodejs 代码一样简单。自定任务还可以发布到 npm 上,通过 npm 在多个项目中共享这些任务。[fenbi-grunt-tasks] 就是粉笔网自定的 js 模块合并、handlebars 模板预编译任务 ([grunt-tbf2e] 貌似是淘宝的自定任务)。

任务之间的组合也是 grunt 非常好用的一个特性,例如通过 watch 任务和 rsync 任务的结合,可以方便的实现当源码发生改变时,自动同步代码到服务器上。

每次 grunt 执行时,grunt 都会去读取当前目录下的 grunt.js (就像 make 命令去寻找 Makefile 那样),然后去读取其中的任务配置,例如源码目录等。grunt 最大的特点在于,配置文件中不包含任何的任务逻辑代码(OO 的开闭原则)。这一特性使得任务可以专心于“要做什么”而不是“要对什么做事情”,不再被特定的项目所绑架。

grunt 给我最大的感受是:原来天下有这么多码农都在为前端构建而奋斗!grunt 使得各个项目的构建脚本不再彼此孤立,使得打造整个公司的前端构建工具变的更加简单。

- FIN -

使用 zsh 的九个理由

像大部分 *nix 用户,我之前用 bash 很多年,期间也有过小的不爽,但一直都忍过来,或者是说没想过这些不爽的地方能解决,比如 cd 到一个深目录时得哐哐猛敲 <TAB>。这么多年里我也尝试过其他 shell。比如 ksh/tcsh 以及今天要说的 zsh,但最终都没坚持下去,因为心中始终还是认为 bash 是最正统的 shell,不愿意去主动深入学习其他 shell。直到前几天逛 github,发现 排名第 6 的开源项目 oh-my-zsh,下来试用了一把,顿时觉得 bash 各种操作不爽到无法忍受。

放弃 bash 的各种内牛满面的理由

这里有个 youtube 上的视频,短短 4 分钟就已经抛出了几十个让 bash 用户切换到 zsh 中的理由。视频链接

理由 0:zsh 兼容 bash

兼容 bash 意味着我不需要太多学习成本就可以切换过来,意味着我以前在 bash 下积累的 shell 语法、基本操作都不会荒废。在我心里 bash 还是最通用和标准的 shell 环境,因此兼容 bash 让我切换到 zsh 时没有太多后顾之忧。

理由 1:zsh 的补全模式更方便

zsh 中按两下 tab 键可以触发 zsh 的补全,所有待补全项都可以通过键盘方向键或者 <Ctrl-n/p/f/b> 来选择。

理由 2:zsh 支持命令选项补全

zsh 除了支持目录的补全,还支持命令选项的补全,例如 ls -<TAB><TAB> 会直接列出所有 ls 的参数,再也不会出现一个命令打到一半,忘记参数导致重开一个 terminal man 一把。

理由 3:zsh 支持命令参数补全

以前想 kill 掉一个进程,我的做法是 ps aux | grep "进程名" 然后记下 id,再 kill id。在 zsh 下,只需要 kill 进程名<TAB>zsh 就会自动补全进程的 pid。

其余我常用的补全还有:

  • ssh <TAB><TAB> 时 zsh 会自动列出你访问过的主机和用户名来补全 ssh 的参数。
  • brew install <TAB><TAB> 来补全软件包名,除了 homebrew 以外,同样支持 port/apt-get 等其他包管理器。

理由 4:zsh 支持更加聪明的目录补全

以前比如想进入一个比较深的目录,比如 /Users/pw/workspace/project/src/main/webapps/static/js,就得在 bash 下面打半天,不停的 tab 去补全一个正确的路径出来。在 zsh 下,只需要输入每个路径的头字母然后 tab 一下: cd /u/p/w/p/s/m/w/s/j<TAB>

理由 5:zsh 强大的快速目录切换

以前最苦逼的事情莫过于频繁在两个工作目录下切换,总要打一长串 cd 路径。也尝试过 popdpushd 来解决这个问题,但往往是目录已经切换了才想起来没用 pushd。而 zsh 会记住你每一次切换的路径,然后通过 1 来切换到你上一次访问的路径,2 切换到上上次……一直到 9,还可以通过 d 查看目录访问历史。

zsh 还可以配合 autojump 一起使用,autojump 会记录下每一个你访问过的目录,然后通过 j 来快速跳转。

理由 6:zsh 支持全局 alias 和后缀名 alias

bash 的 alias 只能做命令的缩写,而 zsh 更进一步,使 alias 可以缩写命令的一部分,例如参数或环境变量设置。

1
2
3
4
$ alias -s log=less
$ ~/package/tomcat/log/catalina.log # 相当于 less ~/package/tomcat/log/catalina.log
$ alias -g PR=http_proxy=127.0.0.1:8087
$ PR curl https://twitter.com # 相当于 http_proxy=127.0.0.1:8087 curl https://twitter.com

理由 7:zsh 有着丰富多彩的命令行提示符

bash 下通过设置 $PS1 已经可以实现很丰富的提示符了,而 zsh 更进一步,可以实现诸如多行提示符、提示符右对齐等功能。oh-my-zsh 配置文件中提供了非常丰富的提示符 theme 供选择,我使用的是 gentoo 主题,比较简洁,还可以显示当前 git 仓库的状态。

理由 8:zsh 有更多优雅的语法

例如修改 PATH,bash 下设置 $PATH 要求所有路径都要写在一行里,目录多了以后看起来就很难看。zsh 支持更加符合程序员审美观的设置方式。

1
2
3
4
5
path=(
    ~/bin
    $path
    ~/package/smartsprites/bin
)

安装 zsh

Linux 用户通过各自发行版的包管理器直接安装即可。

Mac 自带一个 4.x.x 版本的 zsh,可以直接使用,也可以通过 homebrew 安装最近刚刚发布的 5.0.0 版本。推荐使用最新的 5.0 版本,对多字节字符提供了完整的支持,这一点对于国内用户来说很重要。详细的 release note

设置为默认 shell

通过命令 chsh 修改默认登录 shell,需要注意的是,如果通过 homebrew 安装了最新版本的 zsh,则需要 sudo 编辑 /etc/shells 加入一行 /usr/local/bin/zsh。然后再通过 chsh 来修改默认 shell,否则会提示 /usr/local/bin/zsh 不是合法的 shell。

安装 oh-my-zsh 配置

对于每一个像我这样的 zsh 初级用户来说,oh-my-zsh 就是救人于水火中的大杀器,强烈建议使用此配置上手 zsh。

作者提供了傻瓜安装命令:

curl -L https://github.com/robbyrussell/oh-my-zsh/raw/master/tools/install.sh | sh

也可以手工安装,具体步骤

几个必备的插件

autojump

帮助快速目录跳转的小工具。首先要安装 autojump,然后在 .zshrc 中开启 autojump 插件。它会记录下来每个你进入过的目录,随后通过 j 目录名称的一部分 就可快速跳转到该目录。 Youtube 视频介绍

git

Git 命令补全,除了可以补全 git 的子命令、命令开关等常规补全项以外,还可以补全分支名等内容,用 git 必开的插件。

osx

提供一些与 Mac OSX 系统交互的命令,比如:

  • man-preview 通过 preview 程序查看一个命令的手册,例如 man-preview git
  • quick-look 快速预览文件
  • pfd 返回当前 finder 打开的文件夹的路径
  • cdf 切换到当前 finder 所在的目录

- FIN -

Mustache.js/Hogan.js 模板预编译

mustache.js粉笔网用的一个开源前端模板引擎,无逻辑的设计,简单好用,性能也不错。

一个简单的 mustache.js 渲染例子 demo.js
1
2
3
var template = "hello {name}}!"; // 因为代码高亮插件的 bug,这里 name 左边少了一个 {,实际代码中要加上
console.log(Mustache.render(template, {name: "foo"})); // hello foo!
console.log(Mustache.render(template, {name: "bar"})); // hello bar!

Mustache 在 render 一个模板时,首先会将这个模板编译成一个模板函数。比如上面例子里的 hello {{name}} 模板,会被编译成一个模板函数:

1
2
3
function anonymous(c, r) {
    return "" + "hello\u0020" + r._name("name", c, true) + "\u0021";
}

大规模应用时,模板的编译过程会花掉整个 render 过程中 30% 左右的时间。

使用 Hogan.js 预编译 Mustache 模板

Mustache 模板的这个问题已经被不少人遇到,也有很多解决办法。比如 twitter 发布的 Hogan.js。Hogan.js 是 Mustache 模板引擎的另一套实现,增加了预编译机制,使得模板字符串可以在打包阶段被预先处理成模板函数,这样浏览器就不必再重复去编译模板。

Hogan.js 同时提供了可以运行与浏览器端和 node.js 环境下的代码,node.js 负责打包时预编译,浏览器端负责用预编译后的代码渲染页面。

首先通过 npm 安装 hogan.js 的 node.js 环境:

1
$ npm install hogan.js

然后对源代码进行一些修改,在模板字符串的旁边加上一些标记,让打包脚本可以找到模板字符串:

修改后的例子 demo.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var Template = {
    _cache: {},

    // 所有的模板放在这个对象下
    _template: {
        hello: /*TMPL*/"hello {name}}!"/*TMPL*/ // 因为代码高亮插件的 bug,这里 name 左边少了一个 {,实际代码中要加上
    },

    // 这个适配函数会同时处理字符串模板和模板函数的情况
    render: function (name, data) {
        if (!this._cache[name]) {
            // 如果代码被预编译过,则不需要 compile
            if (typeof this._template[name] === 'function') {
                this._cache[name] = new Hogan.Template(this._template[name]);
            } else if (typeof this._template[name] === 'string') {
                this._cache[name] = Hogan.compile(this._template[name]);
            }
        }

        return this._cache[name].render(data);
    }
};

console.log(Template.render('hello', {name: "foo"})); // hello foo!
console.log(Template.render('hello', {name: "bar"})); // hello bar!
nodejs 环境中的预编译过程
1
2
3
4
5
6
7
8
9
var hogan = require("hogan.js");
var fs = require("fs");
var fileContent = fs.readFileSync("demo.js", "utf-8");
fileContent.replace(/\/\*TMPL\*\/"(.*?)"\/\*TMPL\*\//g, function ($0, $1) {
    return hogan.compile($1, {
        asString: true
    });
});
fs.writeFileSync("demo.js", fileContent, "utf-8");

源代码编译完之后,模板字符串就变成了模板函数:

1
2
3
4
5
6
/* ... */
    hello: function(c,p,i){var _=this;_.b(i=i||"");_.b("hello ");_.b(_.v(_.f("name",c,p,0)));_.b("!");return _.fl();;}
/* ... */

console.log(Template.render('hello', {name: "foo"})); // hello foo!
console.log(Template.render('hello', {name: "bar"})); // hello bar!

参考资料

- FIN -