0%

自动化构建

一切重复性的工作都应该被自动化。

在前端工程化中,我们常常希望使用更有的模块进行项目开发,比如我们可能使用模板引擎进行页面渲染,使用sass来构建css,使用最新的ECMAScript语法或者ts进行业务逻辑开发。而以上的模块或者技能都只是一项选择,最终我们都需要转化为最基本的前端三件套:html + css + js。这其中的转换过程根据不同工具的使用而不同,但无疑都是重复性的工作,类似这些开发过程中的重复的工作,都应该使用自动化的思想为我们解决,我们只需要关注代码的业务逻辑和最终的准换结果,而中间的处理过程,都应该交给自动化构建工具。

除了基本的格式转换,自动化构建工具一般还具有压缩、合并、文件整理、启动服务、格式化代码等功能。

本文将会从三款前端自动构建工具gruntgulpfis的使用中感受自动化构建的特点和优势。

Grunt的使用

grunt是比较早的一款前端自动构建工具,插件也比较丰富,目前的使用人数仍然很多,但已被后起之秀gulp赶超,相比之下,gulp在使用上更简洁和高效,不过这些都因人而异,grunt同样是一款优秀的构建工具,我们有必要一探究竟。

快速入门

首先我们初始化一个目录,并新建package.json文件:

1
2
mkdir guruntDemo
npm init -y

安装grunt模块:

1
npm i grunt --dev

grunt工具可以看作是任务流的操作逻辑,比如html模板解析、sass转为css等自动任务都看作是一项一项的任务,我们在grunt中注册这些任务,并支持自由组合,然后使用npm run就能启动这些自动任务。

为了正常启动grunt任务,默认的入口配置文件为:gruntfile.jsgruntfile.coffe。(可以使用大写Gruntfile.js)。

gruntfile文件需要默认导出一个参数为grunt的函数,我们使用grunt.registerTask方法就可以进行任务的注册:

1
2
3
4
5
6
module.exports = (grunt) => {
// 参数说明:(任务名, [描述信息,] 任务列表|任务函数)
grunt.registerTask('html', '解析html模板', () =>{
console.log('渲染html...');
});
}

执行任务:(如果没有全局安装grunt-cli,我们需要手动修改package.json文件的scripts字段,让其支持npm run命令)

1
npm run grunt html

这样我们就能看到注册的html任务的执行结果。

使用default作为默认任务参数时,可以直接使用npm run grunt进行默认任务执行。

registerTask函数除了指定函数,一般我们都直接指定任务名即可,这个任务名就是我们已经注册的任务,或者是使用插件注册好的任务:

1
2
3
4
5
6
7
8
9
10
11
module.exports = (grunt) => {
grunt.registerTask('html', () =>{
console.log('渲染html...');
});

grunt.registerTask('sass', () =>{
console.log('sass文件转化...');
});

grunt.registerTask('default', ['html', 'sass']); // 任务组合
}

如果你创建了一个异步任务,那么你需要使用this.async()作为异步结束标识,否则任务将不会执行异步代码。

1
2
3
4
5
6
7
8
// 异步任务的注册
grunt.registerTask('asyncTask', function(){
const done = this.async(); // 强制任务进入异步模式,并获取“done”函数的句柄。
setTimeout(function(){
console.log('This is a Async Task.');
done(); // 异步任务结束标记
}, 1000)
});

任务失败标记

如果任务内部某些代码已经(或即将)崩溃,可能导致Grunt强行中止。我们可以手动标记该任务为失败任务,在任务队列中,这会终止后续任务队列的执行,如果遇到错误任务时需要强制执行需要加上--force命令。

标记失败任务的方法很简单,我们只需要让任务函数返回false即可,或者使用失败标记apigrunt.fail.warn或者grunt.fail.fatal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = (grunt) => {
grunt.registerTask('badTask', () => {
console.log('doing....')
return false; // 返回false,标记任务失败
});

grunt.registerTask('badTask2', () => {
console.log('doing....')
grunt.fail.warn('something wrong!') // 标记任务失败
console.log('keep going use --force') // 标记失败后,使用--force才能继续执行后续任务
});

grunt.registerTask('badTask3', () => {
console.log('doing....')
grunt.fail.fatal('something wrong!') // 标记任务失败
console.log('can not keep going use --force') // 使用fatal标记失败后,即使使用--force也无法继续执行后续任务
});
}

注:使用grunt.warngrunt.fatal是一样的效果。

如果是异步函数,也是一样的用法,如果不使用官方api,在done的参数中传入false即可。

Grunt的配置config

grunt.config可以从 Gruntfile 中获取针对当前项目的配置数据

初始化配置

使用grunt.config.initgrunt.initConfig进行配置初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = (grunt) => {
grunt.initConfig({
html: {
src: 'src/html/*.html',
out: 'dist/html'
}
})

grunt.registerTask('html', ()=> {
console.log('渲染路径:', grunt.config('html.src'))
console.log('输出路径:', grunt.config('html.out'))
})
}

配置的获取和设置

使用grunt,config.get(prop)可以获取配置的数据。

使用grunt,config.set(prop, value)可以获取配置的数据。

当然,你可以使用grunt.config(prop [, value])可以进行设置或者获取操作。

值得注意的是,prop参数可以传入html.src这种以.分割的字符串,这样,需要数据的时候将会逐级查找。

更多配置相关的api可参考官方说明

多目标任务(复合任务)

这里的多目标任务和上面说到的任务队列有所区别。复合任务是指在不指定目标(target)时,将依次执行其所包含的所有已命名的子属性(sub-properties) (也就是 目标) 。大多数的contrib任务,包括 jshint taskconcat taskuglify task 都是复合任务。

多目标任务需要配合配置grunt.config来使用,就比如我们上面定义的html任务:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = (grunt) => {
grunt.initConfig({
html: {
src: 'src/html/*.html',
out: 'dist/html'
}
})

grunt.registerMultiTask('html', function() {
grunt.log.writeln(this.target + ': ' + this.data);
})
}

执行yarn grunt html就可以看到下面的效果:

1
2
3
4
5
6
7
8
9
$ grunt html
Running "html:src" (html) task
src: src/html/*.html

Running "html:out" (html) task
out: dist/html

Done.
Done in 0.42s.

配置项html中的srcout就是多目标任务html的目标项,将会逐一执行注册的多目标任务函数。

使用this.target可以获取到当前目前的键,使用this.data可以获取到当前目标对象配置项的值。

值得注意的一点是,options是一个特别的配置键,一般用于存储一些通用选项,而不会当做目标指向,我们可以使用this.options拿到这些数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = (grunt) => {
grunt.initConfig({
html: {
options: {
foo: 'foo',
bar: 'bar',
},
src: {
options: {
foo: 'src_foo',
},
file: 'src/html/*.html',
},
out: 'dist/html'
}
})

grunt.registerMultiTask('html', function() {
console.log(this.options())
grunt.log.writeln(this.target + ': ' + this.data);
})
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
$ grunt html
Running "html:src" (html) task
{ foo: 'src_foo', bar: 'bar' }
src: [object Object]

Running "html:out" (html) task
{ foo: 'foo', bar: 'bar' }
out: dist/html

Done.
Done in 0.44s.

插件的使用

大多数使用我们都是使用插件执行自动化任务工作的,而很少手动使用registerTask注册任务。

grunt中使用插件,一般有三个步骤:

  • 首先是安装这个插件模块到本地
  • 在配置数据中更具插件说明配置相关配置项
  • 使用loadNpmTasks注册插件提供的任务。

比如我们使用一款文件清理插件:grunt-contrib-clean

首先,安装:

1
npm i grunt-contrib-clean --dev

然后,配置相关插件配置项,并注册插件任务:

1
2
3
4
5
6
7
module.exports = (grunt) => {
grunt.initConfig({
clean: ['temp/**', 'dist/html/**']
})

grunt.loadNpmTasks('grunt-contrib-clean');
}

点击这里访问插件官方提供的插件列表

常用的一些插件

load-grunt-tasks

当使用的插件增多时,我们需要多次使用grunt.loadNpmTasks来注册插件任务,我们可以借助这个插件来自动注册配置项里的插件

安装完成后:

1
2
3
4
5
6
7
8
9
10
const loadGruntTasks =  require('load-grunt-tasks');

module.exports = function(grunt) {
grunt.initConfig({
clean: {
temp: 'temp/'
}
})
loadGruntTasks(grunt);
}

grunt-sass

grunt-sass需要node-sass的支持,所以安装时需要多安装一个模块:

1
npm install --save-dev node-sass grunt-sass

使用案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const loadGruntTasks =  require('load-grunt-tasks');
const sass = require('sass');

module.exports = function(grunt) {
grunt.initConfig({
sass: {
options: {
implementation: sass,
sourceMap: true
},
dist: {
files: {
'dist/css/main.css': 'src/sass/main.scss',
'dist/css/footer.css': 'src/sass/footer.scss'
}
}
}
})
loadGruntTasks(grunt);
}

grunt-babel

安装:

1
$ yarn add --dev grunt-babel @babel/core @babel/preset-env

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const loadGruntTasks =  require('load-grunt-tasks');

module.exports = function(grunt) {
grunt.initConfig({
babel: {
options: {
sourceMap: true,
presets: ['@babel/preset-env']
},
dist: {
files: {
'dist/js/main.js': 'src/js/main.js'
}
}
}
})
loadGruntTasks(grunt);
}

grunt-contrib-watch

安装:

1
npm install grunt-contrib-watch --save-dev

使用:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const loadGruntTasks =  require('load-grunt-tasks');
const sass = require('sass');

module.exports = function(grunt) {
grunt.initConfig({
clean: ['dist'],
sass: {
options: {
implementation: sass,
sourceMap: true
},
dist: {
files: {
'dist/css/main.css': 'src/sass/main.scss',
'dist/css/footer.css': 'src/sass/footer.scss'
}
}
},
babel: {
options: {
sourceMap: true,
presets: ['@babel/preset-env']
},
dist: {
files: {
'dist/js/main.js': 'src/js/main.js'
}
}
},
watch: {
css: {
files: ['src/sass/**.scss'],
tasks: ['clean', 'sass'],
},
scripts: {
files: ['src/js/**.js'],
tasks: ['clean', 'babel'],
options: {
spawn: false,
},
},
},
})
loadGruntTasks(grunt);
}

grunt-contrib-connect

安装:

1
npm install grunt-contrib-connect --save-dev

结合watch的使用:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
const loadGruntTasks =  require('load-grunt-tasks');
const sass = require('sass');

module.exports = function(grunt) {
grunt.initConfig({
clean: {
css: 'dist/css',
js: 'dist/js'
},
sass: {
options: {
implementation: sass,
sourceMap: true
},
dist: {
files: {
'dist/css/main.css': 'src/sass/main.scss',
'dist/css/footer.css': 'src/sass/footer.scss'
}
}
},
babel: {
options: {
sourceMap: true,
presets: ['@babel/preset-env']
},
dist: {
files: {
'dist/js/main.js': 'src/js/main.js'
}
}
},
watch: {
options: {
livereload: true,
},
css: {
files: ['src/sass/**.scss'],
tasks: ['clean:css', 'sass'],
},
scripts: {
files: ['src/js/**.js'],
tasks: ['clean:js', 'babel'],
options: {
spawn: false,
},
},
},
connect: {
server: {
options: {
port: 8000,
// keepalive: true,
open: true,
base: 'dist',
index: 'index.html',
livereload: true,
}
}
}
})
loadGruntTasks(grunt);
grunt.registerTask('default', ['connect', 'watch']);
}

Gulp的使用

gulp 将开发流程中让人痛苦或耗时的任务自动化,从而减少你所浪费的时间、创造更大价值。

使用之下发现,gulp相较于grunt来说更灵活易用,grunt是通过注册一个一个的任务来完成自动化操作的,而gulp则是基于流(stream)操作来实现的。

快速入门

首先安装的gulp模块:

1
2
npm init -y
npm install gulp --dev

创建gulp的入口文件:gulpfile.js

1
2
3
4
5
6
7
function aTask() {
console.log('this is a task...')
}

module.exports = {
aTask
}

grunt不同的是,我们只需要导出模块函数,这个函数就会被注册为一个任务,我们试着执行一下:

1
yarn gulp aTask

可以看到打印结果:

1
2
3
4
5
6
7
8
9
yarn run v1.22.5
$ gulp aTask
[08:26:21] Using gulpfile D:\Test\gulpDemo\gulpfile.js
[08:26:21] Starting 'aTask'...
this is a task...
[08:26:21] The following tasks did not complete: aTask
[08:26:21] Did you forget to signal async completion?
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

我们发现,任务正常执行了,但是有报错提示Did you forget to signal async completion? 。这是因为,gulp中的任务都必须是异步的,因此,我们需要返回异步结束标记来标记任务正常结束,我们可以使用任务函数的参数done来处理,也可以直接返回Promise.resolve来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
function aTask(done) {
console.log('this is a task...')
done()
}
function aTask2() {
console.log('this is a task...')
return Promise.resolve()
}

module.exports = {
aTask,
sTask2
}

这样就能正常执行了。

早期版本注册一个任务有点类似grunt,我们可以使用require('gulp').task(taskName, taskFun)来实现,但目前我们只需要直接当做普通函数导出模块即可自动完成注册。

组合任务和异步任务

grunt中通过grunt.registerTaskgrunt.registerMultiTask 的api,使用任务列表的模式进行任务组合,但有一个问题就是异步函数会阻断往往会暂停任务列表的执行,而在gulp中就显得更加灵活。

gulp中,有两种任务组合的方式:队列(同步)执行任务(series)和异步执行任务(parallel)。

队列执行就是,安装排列的任务顺序依次执行,上一个执行结束后再执行下一个任务。而异步执行任务就是,多个任务异步执行,不会相互阻塞。

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
const {series, parallel} = require('gulp')

function task1(done) {
console.log('task1 runing...')
done()
}
function task2(done) {
console.log('task2 runing...')
done()
}
function task1_async(done) {
setTimeout(function(){
console.log('task1_async runing...')
done()
}, 1000)
}

const task_list = series(task1, task1_async, task2) // 同步执行队列:就算存在异步模式也并不会阻断后续任务的执行
const async_task_list = parallel(task1, task1_async, task2) // 异步执行队列,任务同时执行


module.exports = {
task_list,
async_task_list
}

我们知道,gulp中的任务都被定义为异步任务,如过想要任务执行失败错误,可以使用:done(new Error())或者return Promise.reject()的形式。

需要注意的是,一旦某个任务执行失败,不管是用series还是parallel来组合任务,都会导致任务队列终止运行。

核心工作原理:文件流模式(The Streaming Building System)

正如grunt的使用过程一样,不管是我们需要编译模板文件,编译sass文件,还是别的什么文件,都是在对文件做处理,比如*.scss输出为*.css文件。

gulp的核心工作原理也是这个逻辑:文件流构建模式:**输入流 → 转换流(加工)→ 输出流 **。

输入流就是读取文件内容,输出流就是写入文件,这些操作都可以使用fs模块实现,而转换流就是内容加工的过程,我们可以借助一个第三方模块:stream中的Transform类来实现。

下面我们使用文件流模式来实现压缩css的过程:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const fs = require('fs')
const {Transform} = require('stream')

const minCss = (done) =>{
// 输入流:读取文件
const read = fs.createReadStream('main.css');

// 转换流:文件加工、转换的过程
const transform = new Transform({
transform: function(chunk, encoding, callback){
// 转换流的核心处理逻辑
const input = chunk.toString();
// css的压缩逻辑
const output = input
.replace(/\s+\{/g, '{') // {前后的空白字符
.replace(/\{\s+/g, '{')
.replace(/\s+\}/g, '}') // }前后的空白字符
.replace(/\}\s+/g, '}')
.replace(/;\}/g, '}')
.replace(/\s+:/g, ':') // :前后的空白字符
.replace(/:\s+/g, ':')
.replace(/\/\*.+?\*\//g, ''); // 注释
callback(null, output);
}
})

// 输出流:写入文件
const write = fs.createWriteStream('main.min.css')

// 使用管道pipe来控制文件流
read
.pipe(transform)
.pipe(write)

// 异步执行结束标记
// read.on('end', done);
return read;
};


module.exports = {
minCss,
}

这也是gulp最核心的文件操作过程。

gulp中提供了文件流操作的api,这样我们就不需要通过fs模块来实现了,而gulp中提供的插件相当于转换流插件,这样,我们就能通过流模式就像简洁高效地开发啦。

gulp提供的读取流使用src对象,写入流使用dest对象,而转换流则是相应的插件来实现。

比如上面我们实现css文件压缩任务,用gulp中的api和插件来实现则是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
const { src, dest } = require('gulp')
const cleanCss = require('gulp-clean-css')

const minCss = () => {
return src('*.css') // 读取css文件,可使用通配符
.pipe(cleanCss()) // 转换流
.pipe(dest('mincss')) // 写入流,参数为目录
}

module.exports = {
minCss
}

之后的案例基本都是这个不变的模式,区别在于插件的选择而已,所以使用起来十分方便。

使用grunt的感觉就是在找插件,写配置文件,其实使用gulp的感觉也是如此,找到适合的插件,写配置,传入pipe管道中作为转换流调用,虽然在使用上都是在找插件的过程,但gulp采用的这种流模式更清晰,更高效,也更灵活,我们可以在管道流中自由使用自己想要加工的效果插件。

样式编译

这里以sass文件为例,编译为css文件,我们需要使用gulp-sass这个插件。

在开始之前,我们需要准备好一些测试文件,比如在项目下创建这些样式文件:

1
2
3
4
5
6
└───src
├───assets
├───styles
└───_icons.scss
└───_variables.scss
└───main.scss

下面是最基本的使用:

1
2
3
4
5
6
7
8
9
10
11
12
const {src, dest} = require('gulp')
const transform_sass = require('gulp-sass')

const style = () => {
return src('src/assets/styles/*.scss')
.pipe(transform_sass())
.pipe(dest('dist'))
}

module.exports = {
style
}

执行编译命令后,我们发现,在dist目录下多了一个文件:

1
2
└───dist
└───main.css

我们发现,_icons.scss_variables.scss未编译,这是因为一般来说,我们都把_开头的样式文件作为其他样式文件的依赖,而不会单独存在,所以没必要编译出来,sass会自动过滤这些文件。

有时候我们希望保留编译前的目录结构。这需要我们修改一些配置项来改变:

1
2
3
4
5
const style = () => {
return src('src/assets/styles/*.scss', { base: 'src' })// 读取流指定base选项可以保留写入流的目录结构
.pipe(transform_sass({ outputStyle: 'expanded' }))
.pipe(dest('dist'))
}

脚本编译

有时候我们希望使用最新的js语法,就需要使用babel进行编译。或者说我们是用ts等语言进行开发的,也需要进行编译。

安装gulp-babel

1
2
3
4
5
# Babel 7
$ npm install --save-dev gulp-babel @babel/core @babel/preset-env

# Babel 6
$ npm install --save-dev gulp-babel@7 babel-core babel-preset-env

用法:

1
2
3
4
5
6
7
const _babel = require('gulp-babel')

const script = () => {
return src('src/assets/scripts/*.js', { base: 'src' })
.pipe(_babel({ presets: ['@babel/preset-env'] }))
.pipe(dest('dist'))
}

如果你使用了ts进行开发,你需要安装gulp-typescript模块:

1
yarn add gulp-typescript typescript --dev 

用法:

1
2
3
4
5
6
7
const _ts = require('gulp-typescript')

const script_ts = () => {
return src('src/assets/scripts/*.ts', { base: 'src' })
.pipe(_ts())
.pipe(dest('dist'))
}

模板引擎编译

模板引擎有多种,需要更具你自己使用的模板语法来选择对应的语法。我们使用到的插件是gulp-swig

安装:

1
yarn add gulp-swig --dev

用法:

1
2
3
4
5
6
7
const pageData = {}; // 需要根据你编写的模板文件传入相应数据

const page = () => {
return src('src/**/*.html', { base: 'src' })
.pipe(_swig({ data: pageDate }))
.pipe(dest('dist'))
}

结合上面的样式编译和脚本编译,就可以组合为一个编译任务:

1
2
3
const { parallel } = require('gulp')

const compile = parallel(style, script, page)

图片字体转换

对于图片、字体等文件,一般来说不需要转译,只需要压缩等操作即可,我们使用gulp-imagemin模块。

安装:

1
yarn add gulp-imagemin --dev

使用:

1
2
3
4
5
6
7
8
9
10
11
12
const _imagemin = require('gulp-imagemin')

const image = () => {
return src('src/assets/images/**', { base: 'src' })
.pipe(_imagemin())
.pipe(dest('dist'))
}
const font = () => {
return src('src/assets/fonts/**', { base: 'src' })
.pipe(_imagemin())
.pipe(dest('dist'))
}

其他文件的处理

比如文件的拷贝、清除等。

这里清除文件可以使用gulp提供的gulp-clean,由于清除文件不需要输出文件,这里也可以使用第三方模块,不走文件流模式,这里我们选择del这个模块。

1
2
3
4
5
6
7
const clean = () => {
return _del(['dist'])
}
const extra = () => {
return src('public/**', { base: 'public' })
.pipe(dest('dist'))
}

结合之前写好的编译任务,组合为最终的构建任务build

1
2
const compile = parallel(style, script, page, image, font) // 编译任务
const build = series(clean, parallel(compile, extra)) // 构建任务

到这里,自动构建的基本已经完成,如果有其他需要,可自行选择插件来自动完成你的任务。

自动加载插件

随着我们使用的gulp插件越来越多,我们每次使用都需要手动导入插件,这样其实很麻烦,在grunt的使用中,我们使用load-grunt-tasks为我们自动注册配置里的任务项,同样的,gulp中也有一个自动加载插件的插件:gulp-load-plugins。这是非官方插件,目前在gulp插件列表中无法搜索到,这个插件会自动加载你安装的gulp插件,这样我们拿到这个包含所有插件的对象,就能直接使用了。

整合上文使用到的所有任务:(需要注意的是命名问题,比如gulp-babel可以使用plugins.babel获取,而gulp-clean-css这样的插件需要使用plugins.cleanCss来获取)。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
const {src, dest, parallel, series} = require('gulp')
const _sass = require('gulp-sass')
const loadPlugins = require('gulp-load-plugins')
const _del = require('del')

const plugins = loadPlugins(); // 加载所有安装的gulp插件
const pageDate = {}; // 模板引擎数据

/**
* 样式编译
*/
const style = () => {
return src('src/assets/styles/*.scss', { base: 'src' })// 读取流指定base选项可以保留写入流的目录结构
.pipe(_sass({ outputStyle: 'expanded' }))
.pipe(dest('dist'))
}

/**
* 脚本编译
*/
const script = () => {
return src('src/assets/scripts/*.js', { base: 'src' })
.pipe(plugins.babel({ presets: ['@babel/preset-env'] }))
.pipe(dest('dist'))
}
const script_ts = () => {
return src('src/assets/scripts/*.ts', { base: 'src' })
.pipe(plugins.typescript({
noImplicitAny: true,
}))
.pipe(dest('dist'))
}

/**
* 模板引擎编译
*/
const page = () => {
return src('src/**/*.html', { base: 'src' })
.pipe(plugins.swig({ data: pageDate }))
.pipe(dest('dist'))
}

/**
* 图片压缩
*/
const image = () => {
return src('src/assets/images/**', { base: 'src' })
.pipe(plugins.imagemin())
.pipe(dest('dist'))
}
const font = () => {
return src('src/assets/fonts/**', { base: 'src' })
.pipe(plugins.imagemin())
.pipe(dest('dist'))
}

/**
* 清除目录和文件
*/
const clean = () => {
return _del(['dist'])
}

/**
* 额外的任务:复制一些必要的文件等
*/
const extra = () => {
return src('public/**', { base: 'public' })
.pipe(dest('dist'))
}

const compile = parallel(style, script, page, image, font)
const build = series(clean, parallel(compile, extra))

module.exports = {
clean,
compile,
build,
}

启动服务器

node服务器插件有很多,这里我们可以使用gulp提供的gulp-live-server,也可以自由选择。因为不需要使用文件流模式。

使用:

1
2
3
4
5
const server = (done) => {
const live_server = plugins.liveServer.static('dist', 8080);
live_server.start();
done();
};

比如也换成使用比较多的:browser-sync

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const server = (done) => {
const browserServer = browserSync.create();
browserServer.init({
notify: false,
files: 'dist/**', // 监听文件热更新
port: 3000,
server: {
baseDir: "dist",
routes: {
"/node_modules": 'node_modules'
}
}
});
};

文件变化监听

gulp模块提供了文件监听的函数watch

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
26
27
28
/**
* 服务器
*/
const server = (done) => {
const bs = browserSync.create();
watch('src/assets/styles/*.scss', style);
watch('src/assets/scripts/*.js', script);
watch('src/**/*.html', page);
// 静态资源一般无需重新编译,只需要重启服务器即可
watch([
'src/assets/images/**',
'src/assets/fonts/**',
'public/**'
], bs.reload);

bs.init({
notify: false,
files: 'dist/**', // 监听文件
open: false,
port: 3000,
server: {
baseDir: ['dist', 'src', 'public'], // 会依次查找请求资源
routes: {
"/node_modules": 'node_modules'
}
}
});
};

这样,我们启动服务器时就能开启监听,实时编译。

这里有个细节问题,其实监听需要编译文件就可以了,服务器无需再监听dist文件,我们可以利用bs.reload重启服务器的方法来改进,类似这样:

1
watch('src/assets/styles/*.scss', series(style, bs.reload));

文件引用的处理

在html代码中,可能引用了一些node_modules目录下的css或者js,类似这样:

1
2
3
4
5
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.css">

<script src="/node_modules/jquery/dist/jquery.js"></script>
<script src="/node_modules/popper.js/dist/umd/popper.js"></script>
<script src="/node_modules/bootstrap/dist/js/bootstrap.js"></script>

当我们启动服务器时,如果没有配置路由,是无法访问到这个目录下的文件的。

但是,当我们打包上线的时候,node_modules是不存在的,这样同样无法访问到这些文件,一些办法是我们手动整理这些需要的引用文件,放到要打包的目录下,这样的效率是比较低的,实际上,我们可以使用gulp提供的一个插件来自动处理:gulp-useref

通过一定规则的html注释,这个插件能将注释包裹的文件引用下的资源自动打包到指定的目录下,这样,就避免了引文文件不存在而找不到文件的问题。

html写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- build:css assets/styles/vendor.css -->
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.css">
<!-- endbuild -->
<!-- build:css assets/styles/main.css -->
<link rel="stylesheet" href="assets/styles/main.css">
<!-- endbuild -->


<!-- build:js assets/scripts/vendor.js -->
<script src="/node_modules/jquery/dist/jquery.js"></script>
<script src="/node_modules/popper.js/dist/umd/popper.js"></script>
<script src="/node_modules/bootstrap/dist/js/bootstrap.js"></script>
<!-- endbuild -->
<!-- build:js assets/scripts/main.js -->
<script src="assets/scripts/main.js"></script>
<!-- endbuild -->

用法:

1
2
3
4
5
const useref = () =>{
return src('dist/*html', { base: 'dist' })
.pipe(plugins.useref({ searchPath: ['dist', '.'] })) // 设置文件查找目录
.pipe(dest('release'))
}

注意:上面的示例中我们把最终的文件放到了release目录下,这样因为如果文件读取流和写入流在同一个文件很可能导致写入失败,而且重写html文件之后,之前的特殊标记都消失了,需要重新编译才能使用useref

但是,使用release文件之后,其他很多页面静态编译资源都已经存在了dist目录,所以,当我们使用了useref,就需要使用两个目录进行文件整理:dist目录:一般为最终打包上线的目录,temp目录:缓存目录,开发阶段调试的目录。

首先,useref构建之后的文件是需要上线的,一般我们需要进行压缩代码的操作,但是引文文件包含:css、js还有html,不同的文件类型需要不同的压缩插件,所以我们需要借助gulp-if进行流判断操作:

1
2
3
4
5
6
7
8
9
10
11
12
const useref = () =>{
return src('dist/*html', { base: 'dist' })
.pipe(plugins.useref({ searchPath: ['dist', '.'] }))
.pipe(plugins.if(/\.js$/, plugins.uglify())) // js压缩
.pipe(plugins.if(/\.css$/, plugins.cleanCss())) // css压缩
.pipe(plugins.if(/\.html$/, plugins.htmlmin({ // html压缩
collapseWhitespace: true,
minifyCss: true,
minifyJs: true,
})))
.pipe(dest('release'))
}

下面是整个文件的目录重新整理:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
const {src, dest, parallel, series, watch} = require('gulp')
const _sass = require('gulp-sass')
const loadPlugins = require('gulp-load-plugins')
const _del = require('del')
const browserSync = require('browser-sync')

const plugins = loadPlugins(); // 加载所有安装的gulp插件
const pageDate = {}; // 模板引擎数据
const srcPath = 'src'; // 源文件目录
const tempPath = 'temp'; // 缓存目录,一般用于本地测试时使用
const distPath = 'dist'; // 打包发布目录

/**
* 清除目录和文件
*/
const clean = () => {
return _del([tempPath, distPath])
}

/**
* 样式编译
*/
const style = () => {
return src('src/assets/styles/*.scss', { base: srcPath })// 读取流指定base选项可以保留写入流的目录结构
.pipe(_sass({ outputStyle: 'expanded' }))
.pipe(dest(tempPath))
}

/**
* 脚本编译
*/
const script = () => {
return src('src/assets/scripts/*.js', { base: srcPath })
.pipe(plugins.babel({ presets: ['@babel/preset-env'] }))
.pipe(dest(tempPath))
}
const script_ts = () => {
return src('src/assets/scripts/*.ts', { base: srcPath })
.pipe(plugins.typescript({
noImplicitAny: true,
}))
.pipe(dest(tempPath))
}

/**
* 模板引擎编译
*/
const page = () => {
return src('src/**/*.html', { base: srcPath })
.pipe(plugins.swig({ data: pageDate, cache: false }))
.pipe(dest(tempPath))
}

/**
* 图片压缩
*/
const image = () => {
return src('src/assets/images/**', { base: srcPath })
.pipe(plugins.imagemin())
.pipe(dest(distPath))
}
const font = () => {
return src('src/assets/fonts/**', { base: srcPath })
.pipe(plugins.imagemin())
.pipe(dest(distPath))
}

/**
* 额外的任务:复制一些必要的文件等
*/
const extra = () => {
return src('public/**', { base: 'public' })
.pipe(dest(distPath))
}

/**
* 服务器
*/
const server = (done) => {
const bs = browserSync.create();
watch('src/assets/styles/*.scss', series(style, bs.reload));
watch('src/assets/scripts/*.js', series(script, bs.reload));
watch('src/**/*.html', series(page, bs.reload));
// 静态资源一般无需重新编译,只需要重启服务器即可
watch([
'src/assets/images/**',
'src/assets/fonts/**',
'public/**'
], bs.reload);

bs.init({
notify: false,
// files: 'dist/**', // 监听文件
open: false,
port: 3000,
server: {
baseDir: [tempPath, srcPath, 'public'], // 会依次查找请求资源
routes: {
"/node_modules": 'node_modules'
}
}
});
}

/**
* useref:文件引用处理
*/
const useref = () =>{
return src('temp/*html', { base: tempPath })
.pipe(plugins.useref({ searchPath: [tempPath, '.'] }))
.pipe(plugins.if(/\.js$/, plugins.uglify())) // js压缩
.pipe(plugins.if(/\.css$/, plugins.cleanCss())) // css压缩
.pipe(plugins.if(/\.html$/, plugins.htmlmin({ // html压缩
collapseWhitespace: true,
minifyCss: true,
minifyJs: true,
})))
.pipe(dest(distPath))
}

// 任务组合
const compile = parallel(style, script, page)
const develop = series(clean, compile, server)
const build = series(clean, parallel(compile, image, font, extra), useref)
module.exports = {
clean,
develop,
build,
}

为了方便测试,我们一般还需要在package.json的添加scripts信息:

1
2
3
4
5
"scripts": {
"clean": "gulp clean",
"develop": "gulp develop",
"build": "gulp build"
}

如果需要上传到git,需要忽略自动构建文件夹:disttemp

封装自动构建工作流脚手架

上面的自动化是比较常用的,我们可以封装为自己的脚手架,便于下次直接使用,我们可以创建一个基于yeoman之类的脚手架,也可以直接把整个文件放到git远程仓库。脚手架的好处是支持cli用户个性化设置,本质上也只是文件的复制工作,为了这个模块更通用,我们需要把gulpfile.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
const {src, dest, parallel, series, watch} = require('gulp')
const _del = require('del') // 文件移除模块
const { join } = require('path')
const loadPlugins = require('gulp-load-plugins')
const browserSync = require('browser-sync') // 服务器模块

const plugins = loadPlugins(); // 加载所有本地已安装的gulp插件
// 文件配置项(用户自定义)
const default_userConfig = {
build: { // 构建项目的路径配置
srcPath: 'src',
tempPath: 'temp',
distPath: 'dist',
publicPath: 'public',
srcPaths: {
styles: 'assets/styles/*.scss',
scripts: 'assets/scripts/*.js',
script_ts: 'assets/scripts/*.ts',
pages: '*.html',
images: 'assets/images/**',
fonts: 'assets/fonts/**',
public: '**', // cwd: publicPath
},
},
pageDate: {}, // 模板引擎数据
};
const userConfig = Object.assign({}, default_userConfig, require('./userConfig'));

const pathBuild = userConfig.build;
const srcPaths = pathBuild.srcPaths;

/**
* 样式编译
*/
const style = () => {
return src(srcPaths.styles, { base: pathBuild.srcPath, cwd: pathBuild.srcPath})
.pipe(plugins.sass({ outputStyle: 'expanded' }))
.pipe(dest(pathBuild.tempPath))
}

/**
* 脚本编译
*/
const script = () => {
return src(srcPaths.scripts, { base: pathBuild.srcPath, cwd: pathBuild.srcPath})
.pipe(plugins.babel({ presets: ['@babel/preset-env'] }))
.pipe(dest(pathBuild.tempPath))
}
const script_ts = () => {
return src(srcPaths.script_ts, { base: pathBuild.srcPath, cwd: pathBuild.srcPath})
.pipe(plugins.typescript( { noImplicitAny: true }))
.pipe(dest(pathBuild.tempPath))
}

/**
* 模板引擎编译
*/
const page = () => {
return src(srcPaths.pages, { base: pathBuild.srcPath, cwd: pathBuild.srcPath})
.pipe(plugins.swig({ data: userConfig.pageDate, cache: false }))
.pipe(dest(pathBuild.tempPath))
}

/**
* 图片压缩
*/
const image = () => {
return src(srcPaths.images, { base: pathBuild.srcPath, cwd: pathBuild.srcPath})
.pipe(plugins.imagemin())
.pipe(dest(pathBuild.distPath))
}
const font = () => {
return src(srcPaths.fonts, { base: pathBuild.srcPath, cwd: pathBuild.srcPath})
.pipe(plugins.imagemin())
.pipe(dest(pathBuild.distPath))
}

/**
* 额外的任务:复制一些必要的文件等
*/
const extra = () => {
return src(srcPaths.public, { base: pathBuild.publicPath, cwd: pathBuild.publicPath})
.pipe(dest(pathBuild.distPath))
}

/**
* 服务器
*/
const server = () => {
const bs = browserSync.create();
const opts = { cwd: pathBuild.srcPath };
// 编译文件监控
watch(srcPaths.styles, opts, series(style, bs.reload));
watch(srcPaths.scripts, opts, series(script, bs.reload));
watch(srcPaths.script_ts, opts, series(script_ts, bs.reload));
watch(srcPaths.pages, opts, series(page, bs.reload));
// 静态资源一般无需重新编译,只需要重启服务器即可
watch([
srcPaths.pages,
srcPaths.fonts,
srcPaths.public,
], opts , bs.reload);

bs.init({
notify: false,
open: true,
port: 3000,
server: {
baseDir: [pathBuild.tempPath, pathBuild.srcPath, pathBuild.publicPath], // 依次查找请求资源路径
routes: { // 路由
"/node_modules": 'node_modules',
}
}
});
}

/**
* 清除目录和文件
*/
const clean = () => {
return _del([pathBuild.tempPath, pathBuild.distPath])
}

/**
* useref:文件引用处理
*/
const useref = () =>{
return src(join(pathBuild.tempPath, srcPaths.pages), { base: pathBuild.tempPath})
.pipe(plugins.useref({ searchPath: [pathBuild.tempPath, '.'] }))
.pipe(plugins.if(/\.js$/, plugins.uglify())) // js压缩
.pipe(plugins.if(/\.css$/, plugins.cleanCss())) // css压缩
.pipe(plugins.if(/\.html$/, plugins.htmlmin({ // html压缩
collapseWhitespace: true,
minifyCss: true,
minifyJs: true,
})))
.pipe(dest(pathBuild.distPath))
}

// 任务组合
const compile = parallel(style, script, script_ts, page)
const develop = series(clean, compile, server)
const build = series(clean, parallel(compile, image, font, extra), useref)
module.exports = {
clean,
dev: develop,
build,
}

文件中引用的userConfig就是为了用户能更好地自定义文件目录,我们可以通过使用脚手架时初始化文件目录,以及这个配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
build: { // 构建项目的路径配置
srcPath: 'src',
tempPath: '.temp',
distPath: 'dist',
publicPath: 'public',
srcPaths: {
styles: 'assets/styles/*.scss',
scripts: 'assets/scripts/*.js',
script_ts: 'assets/scripts/*.ts',
pages: '*.html',
images: 'assets/images/**',
fonts: 'assets/fonts/**',
public: '**', // cwd: publicPath
},
},
pageDate: {}, // 模板引擎数据
};

如果你使用的是yeoman搭建的脚手架,可以参考下面的处理逻辑:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const generators = require('yeoman-generator');
const path = require('path')
const fs = require('fs')

module.exports = class extends generators {
// cli用户自定义配置(待优化)
prompting(){
return this.prompt([{
type: 'input',
name: 'name',
message : 'Your project name',
default : this.appname
}]).then(answers => {
this.answers = answers;
});
}

// 生成文件
writing() {
// 模板路径
const tempDir = path.join(__dirname, 'templates');
// 输出路径
const destDir = process.cwd();
// 模板上下文数据
const ejsData = {
title: this.answers['name'],
success: true
};
// 模板文件写入
fs.readdir(tempDir, (err, files) =>{
if(err) throw err;
files.forEach(temp => {
const input = path.join(tempDir, temp);
const output = path.join(destDir, temp);
this.fs.copyTpl(input, output, ejsData);
})
});
}
}

Fis的使用

百度团队开发的一款自动化构建工具,有兴趣的可参考:http://fis.baidu.com/

谢谢你请我吃糖!