本文以一例介绍我上手 Gulp 之过程。与之前的文章初识 Sass | SCSS相同,这篇文章也是开发 VOID 主题过程的技术笔记。
我一直都为一件事情感到头疼:前端项目的资源缓存问题。页面有所变化后,理论上对应的静态资源例如 JS、CSS 也应该立即更新,但实际上并不那么简单。现代浏览器都有缓存机制,使浏览器不会立即拉取最新的静态文件,以节省网络开销;若站点启用了 CDN,CDN 层面也有缓存机制,而且一般会更长,导致请求不能及时回源。结果就是页面是新页面,样式与业务逻辑却是老的。
一种解决方式是通过后缀告知浏览器应该刷新资源:
<link rel="stylesheet" href="/style/main.css?v=1.123">
其中的 ?v=1.123
代表了静态资源的版本号,如果浏览器足够聪明的话,一旦该版本号有所变化就会重新请求资源,但实际上不同浏览器的处理方式不同。而且许多 CDN 厂商对 v 参数是忽略的。
另一种解决方法是把版本号写入到文件路径中:
<link rel="stylesheet" href="/style/main.1.123.css">
在 RAW 主题与 VOID 的初期几个版本中我就是用的这种方法。这是一种可行的解决方法,它无视浏览器的缓存机制,因为一旦版本号变换就相当于资源路径变化,这样会强制浏览器刷新。但有一点不足是一旦项目版本变化,不论静态资源本身有没有改动,其路径都有改变,这就带来了不必要的请求开销,没有充分利用浏览器缓存。
目前广泛采用的解决方式是将文本摘要算法引入到静态资源的版本管理中,页面中引入的静态文件路径形如:
<link rel="stylesheet" href="/style/main-{{hash}}.css">
其中 {{hash}}
指的是文件的哈希值。哈希作为文件的摘要,与文件内容对应,文件改变哈希改变、文件不变哈希不变。这种方法既解决了资源更新的问题,也能充分利用浏览器与 CDN 缓存。实际上,一旦采用这种方式,可以为资源设置很长的缓存过期时间。
我最初接触 Gulp 正是为了自动化地实现这一过程。
什么是 Gulp
Gulp 是一个自动化构建工具,开发者可以通过制定一系列规则(gulpfile.js)来搭建一个处理的工作流(workflow),自动化某些重复性工作:例如 CSS 预处理、压缩、JS 混淆加密等等。同时 Gulp 提供了大量的插件帮助开发者完成这一过程。
首先安装 NodeJS,之后在项目目录下使用 npm init
初始化一个项目。之后安装 Gulp:
npm install gulp --global
npm install gulp --save-dev
这条命令会在当前目录安装 gulp,并且将此信息保存到 package.json 中作为开发依赖。在当前目录下新建一个 gulpfile.js,在其中键入代码:
var gulp = require('gulp');
gulp.task('defualt', function(){
console.log('Hello World!');
})
然后在终端中运行 gulp
。gulp.task()
声明了一个名为 default
的任务,它的作用是在控制台输出 Hello World,这就是一个最简单的示例,我们要做的便是根据需要自己搭建 function(){}
中的内容。
Gulp 中有管道(pipe)的概念,它面向的是一个文件流(stream)。一个典型的 Gulp 任务包含了这样的过程:读取源文件 → 执行操作 1 → 执行操作 2 → ··· → 执行操作 n → 输出。
其中,读取源文件使用 gulp.src
完成:
gulp.src(['src/assets/libs/**/*.js']);
这行代码将 src/assets/libs 目录及其子目录下的所有 JS 文件读了进来,然后什么也没干。传入 gulp.src()
的是一个字符串数组,可以使用多种匹配模式。例如:
gulp.src(['src/assets/libs/**/*.js', '!src/assets/libs/mathjax', '!src/assets/libs/mathjax/**/*'])
这行代码除了加载 src/assets/libs 目录及其子目录下的所有 JS 文件,还从中剔除了 src/assets/libs/mathjax 子目录及其下的文件。
执行操作使用 .pipe()
方法串联,例如:
gulp.src(['src/assets/libs/**/*.js'])
.pipe(task());
传入 pipe()
的是某个操作。这个操作可以自己定义,也可以通过安装 Gulp 插件获得。
输出文件也属于一种操作,因此也被包裹在 pipe()
方法中:
gulp.src(['src/assets/libs/**/*.js'])
.pipe(task())
.pipe(gulp.dest('build/'));
最终的结果便是将那些 JS 处理后输出到 build 文件夹下。
以上是对 Gulp 的大概了解,接下来看看如何使用 Gulp 为静态文件加上哈希戳,并且同时替换页面中的引用路径。
利用 Gulp 为静态文件加戳
Gulp 社区是很强大的,它提供了数量巨大的插件供开发者使用。本例中,用到了 gulp-rev
与 gulp-rev-collector
两个插件。
首先安装 gulp 与这两个插件,在项目目录下:
npm install gulp-rev gulp-rev-collector --save-dev
为了清除旧版文件与删除临时文件,再安装一个用于删除的插件 del
:
npm install del --save-dev
设有如下的目录结构:
dev
|-- app.js
|-- app.css
|-- index.html
build
目的是将 app.js,app.css 加哈希戳,输出到 build 目录下,并替换 index.html 中对这两个文件的引用地址为加戳后的路径,将替换后的 index.html 也输出到 build 目录下,如此实现了开发目录与构建目录的分离。
容易看出,这是两个步骤。一是计算静态文件的哈希,并重命名它们;二是替换 index.html 中的引用。实际上 Gulp 也是这么处理的。
首先在 gulpfile.js 中引入 Gulp 与对应插件。
var gulp = require('gulp');
var rev = require('gulp-rev');
var revCollector = require('gulp-rev-collector');
var del = require('del');
声明一个清理原 build 文件夹的任务:
gulp.task('clean:build', function (){
return del(['build']);
});
声明一个加戳的任务:
gulp.task('md5', function(){
return gulp.src(['dev/*.js', 'dev/*.css'])
.pipe(rev())
.pipe(gulp.dest('build/'))
.pipe(rev.manifest())
.pipe(gulp.dest('temp/'));
});
尝试使用 gulp md5
执行 md5
这个任务,可以看到初步有一些小变化,目录变成了(省略了一些不重要的目录):
dev
|-- app.js
|-- app.css
|-- index.html
build
|-- app-d41d8cd98f.js
|-- app-d41d8cd98f.css
temp
|-- rev-manifest.json
一者,build 目录下已经生成了加戳的静态文件,二者,多出了 temp/rev-manifest.json 这个文件。打开该文件,可以看到如下内容:
{
"app.css": "app-d41d8cd98f.css",
"app.js": "app-d41d8cd98f.js"
}
实际上这个文件以键值对的形式存储了文件名的映射关系。正是因为有这个文件我们才能进行下一步:静态资源引用路径替换。
在 gulpfile.js 中声明一个替换任务:
gulp.task('replace', function(){
return gulp.src(['temp/*.json', 'dev/index.html'])
.pipe(revCollector())
.pipe(gulp.dest('build/'));
});
执行这个任务:gulp replace
,目录结构变更为:
dev
|-- app.js
|-- app.css
|-- index.html
build
|-- app-d41d8cd98f.js
|-- app-d41d8cd98f.css
|-- index.html
temp
|-- rev-manifest.json
dev/index.html
中的引用形如:
<link rel="stylesheet" href="./app.css">
<script src="./app.js"></script>
而 build/index.html
中的内容形如:
<link rel="stylesheet" href="./app-d41d8cd98f.css">
<script src="./app-d41d8cd98f.js"></script>
可见替换已经成功了。
以上通过手动执行每个任务看到了整个过程。是时候将它们串联起来了。
Gulp 任务串行与并行
以上的任务是一个明显的串行关系:清理 build 目录 → 文件加戳输出 → 引用路径替换输出。Gulp 默认对所有的任务以最大并发的方式执行,若要得到串行的任务关系,需要显式地指定。
Gulp 4 之前可以通过这样的方式将任务串起来:
gulp.task('build', ['task_1', 'task_2']); // task_1 与 task_2 中需要将文件流 return
Gulp 4 起以上方式不再可行。用以替换的,Gulp 提供了两个方法用以显式地指定并发与串行关系:
gulp.parallel('task_1', 'task_2', 'task_3'); // 并发
gulp.series('task_1', 'task_2', 'task_3'); // 串行
它们可以嵌套:
gulp.series('task_1', gulp.parallel('task_2', 'task_3'), 'task_4');
效果是先执行 task_1,然后并发执行 task_2、task_3,然后执行 task_4。
对本文中的例子,声明一个总的 build 任务:
gulp.task('build', gulp.series('clean:build', 'md5', 'replace'));
直接执行该任务即可看到效果。
监视更改
使用 gulp.watch()
可以实现监视文件,当文件更改时立即执行任务。
gulp.task('watch', function(){
return gulp.watch('dev/*', gulp.series('clean:build', 'md5', 'replace'));
});
当 dev 目录下的文件更改时,立即执行 'clean:build', 'md5', 'replace'
。
拷贝文件
其实没必要单独拎出来说,但其中有一个 trick,记录一下。若有这样的目录结构:
dev
|-- vendor
|-- lib1
|-- lib1.js
|-- lib1.css
|-- lib2
|-- lib2.js
|-- lib2.css
|-- lib3
|-- lib3.js
|-- lib3.css
|-- app.js
|-- app.css
|-- index.html
build
目的是将 dev/vendor 中的所有文件夹完整拷贝至 build 目录。实际上,Gulp 的拷贝就是不执行任何中间操作的 src 与 dest,因此很容易:
gulp.task('move', function(){
return gulp.src('dev/vendor/**/*')
.pipe(gulp.dest('build/'));
})
但是,若要仅把 dev/vendor/lib1 与 dev/vendor/lib2 文件夹拷贝至 build 目录,这样的代码是错误的:
gulp.task('move', function(){
return gulp.src(['dev/vendor/lib1/*', 'dev/vendor/lib2/*'])
.pipe(gulp.dest('build/')); // 丢失目录结构
})
因为你会看到这样的结果:
build
|-- lib1.js
|-- lib1.css
|-- lib2.js
|-- lib2.css
很正常的,丢失了目录结构。要解决这个问题,需要用到 gulp.src()
的 base
参数:
gulp.task('move', function(){
return gulp.src(['dev/vendor/lib1/*', 'dev/vendor/lib2/*'], {
base: 'dev/vendor/'
})
.pipe(gulp.dest('build/')); // 保持目录结构
})
则可以得到正确的结果:
build
|-- lib1
|-- lib1.js
|-- lib1.css
|-- lib2
|-- lib2.js
|-- lib2.css
总结
以上介绍了我上手 Gulp 的一些过程。VOID 主题现在已经使用 Gulp 自动化构建,效果妙不可言。其使用的 gulpfile.js 见 GitHub,除了静态文件加戳,还有 CSS 预处理、压缩、合并,JS 混淆、压缩、合并等流程,若有错漏还请赐教。
昨天晚上到今天上午我一直在研究 Gulp,目前只是初步的上手而已。这样的工具,相见恨晚啊。