一、自动化构建自述

自动化构建是前端工程化当中非常重要的组成部分。自动化指的是机器代替手工的一些工作,构建可以把它理解成转换。
image.png
总的来说,开发行业当中的自动化构建就是把开发阶段写出来的源代码自动化地装换成生产环境中可以运行的代码或者程序。一般我们把这个转换的过程称作自动化构建工作流,它的作用就是让我们尽可能脱离运行环境兼容带来的问题,在开发阶段使用提高效率的语法、规范和标准。

二、常用的自动化构建工具

NPM Scripts 是实现自动化构建工作流的最简方式,它虽然能解决一部分自动化构建任务,但是对于相对复杂的构建过程就显得非常吃力,此时我们就需要更为专业的构建工具。
目前市面上开发者使用最多的几个工具主要就是 GruntGulp以及FIS,可能会有人问:Webpack去哪了? 严格来说,Webpack实际上是一个模块打包工具,所以不在我们的讨论范围内。
image.png
这些工具都可以帮助解决重复、无聊的工作从而实现自动化,用法上它们也大体相同,都是先通过一些简单的代码去组织一些插件的使用,然后就可以使用这些工具去帮你执行各种各样重复的工作了。
Grunt 可以算是最早的前端构建系统了,它的插件生态非常的完善,用官方的一句话来说就是

Grunt的插件几乎可以帮你自动化地去完成任何你想要做的事情。

但是由于它的工作过程是基于临时文件去实现的,所以它的构建速度会相对比较慢,且处理的环节越多,文件的读写次数越多。因此对于超大型项目,文件会非常多,它的构建速度就会非常慢。
Gulp 很好的解决了 Grunt 中构建速度慢的问题,因为它是基于内存去实现的,也就是说它对于文件的处理环节都是在内存当中完成的,相对于磁盘读写速度自然就快了很多。另外它支持同时去执行多个任务,效率大大提高,且使用方式相比于 Grunt 更加直观易懂插件生态同样非常完善,应该算是目前市面上最流行的前端构建系统了。
FIS 是百度的前端团队推出的一款构建系统,最早只在团队内部使用,后来开源过后在国内快速流行。相对于前两个构建系统这种微内核的特点,FIS跟像是一种捆绑套餐,它把我们在项目中一些典型的需求尽可能地都集成在内部了,例如资源加载、模块化开发、代码部署甚至是性能优化,正是因为这种大而全,所以在国内很多项目中就流行开来。

三、自动化构建工具Grunt

Grunt 基本使用

  1. 初始化项目中的package.json

    1. yarn init
  2. 添加grunt模块

    1. yarn add grunt --dev
  3. 在项目根目录下添加gruntfile.js入口文件

  4. 在gruntfile.js中通过registerTask()添加任务

    1. // Grunt 的入口文件
    2. // 用于定义一些需要 Grunt 自动执行的任务
    3. // 需要导出一个函数
    4. // 此函数接收一个 grunt 的对象类型的形参
    5. // grunt 对象中提供一些创建任务时会用到的 API
    6. module.exports = grunt => {
    7. // 参数 任务名 [任务描述: grunt帮助信息中] 任务函数
    8. grunt.registerTask('foo', 'a sample task', () => {
    9. console.log('hello grunt')
    10. })
  5. 运行任务foo

    1. yarn grunt foo

    当然,在gruntfile.js中,你不仅仅可以添加一个任务,还可以添加更多的任务。除此之外如果在创建任务时,任务名称为 default 时,这个任务将会成为 grunt 的默认任务,在运行时就不需要指定任务的名称,grunt将自动调用default,一般我们会用default映射一些其他的任务。
    默认 grunt 采用同步模式编码,如果需要异步可以使用 this.async() 方法创建回调函数。

    1. // gruntfile.js
    2. module.exports = grunt => {
    3. // 第二个参数可以指定此任务的映射任务,
    4. // 这样执行 default 就相当于执行对应的任务
    5. // 这里映射的任务会按顺序依次执行,不会同步执行
    6. grunt.registerTask('default', ['foo', 'bar'])
    7. // 也可以在任务函数中执行其他任务
    8. grunt.registerTask('run-other', () => {
    9. // foo 和 bar 会在当前任务执行完成过后自动依次执行
    10. grunt.task.run('foo', 'bar')
    11. console.log('current task runing~')
    12. })
    13. // 默认 grunt 采用同步模式编码
    14. // 如果需要异步可以使用 this.async() 方法创建回调函数
    15. // grunt.registerTask('async-task', () => {
    16. // setTimeout(() => {
    17. // console.log('async task working~')
    18. // }, 1000)
    19. // })
    20. // 由于函数体中需要使用 this,所以这里不能使用箭头函数
    21. grunt.registerTask('async-task', function () {
    22. const done = this.async()
    23. setTimeout(() => {
    24. console.log('async task working~')
    25. done() // 标识任务已完成
    26. }, 1000)
    27. })
    28. }

    Grunt 标记任务失败

    如果你在构建任务的逻辑代码当中放生错误,例如我们需要的文件找不到了,此时我们就可以将这个任务标记为一个失败的任务,可以通过在函数体当中return false来实现。
    如果一个任务列表中的某个任务执行失败,则后续任务默认不会运行,除非 grunt 运行时指定 —force 参数强制执行。

    1. module.exports = grunt => {
    2. // 任务函数执行过程中如果返回 false
    3. // 则意味着此任务执行失败
    4. grunt.registerTask('bad', () => {
    5. console.log('bad working~')
    6. return false
    7. })
    8. grunt.registerTask('foo', () => {
    9. console.log('foo working~')
    10. })
    11. grunt.registerTask('bar', () => {
    12. console.log('bar working~')
    13. })
    14. // 如果一个任务列表中的某个任务执行失败
    15. // 则后续任务默认不会运行
    16. // 除非 grunt 运行时指定 --force 参数强制执行
    17. grunt.registerTask('default', ['foo', 'bad', 'bar'])
    18. // 异步函数中标记当前任务执行失败的方式是为回调函数指定一个 false 的实参
    19. grunt.registerTask('bad-async', function () {
    20. const done = this.async()
    21. setTimeout(() => {
    22. console.log('async task working~')
    23. done(false)
    24. }, 1000)
    25. })
    26. }

    Grunt 配置选项方法

    除了registerTask()方法之外,grunt还提供了一个用于添加一些配置选项的API,叫做initConfig(),例如我们去使用压缩文件时,就可以通过这种方式去配置我们需要压缩的文件路径。

    1. module.exports = grunt => {
    2. // grunt.initConfig() 用于为任务添加一些配置选项
    3. grunt.initConfig({
    4. // 键一般对应任务的名称
    5. // 值可以是任意类型的数据
    6. // foo: bar
    7. foo: {
    8. bar: 'baz'
    9. }
    10. })
    11. grunt.registerTask('foo', () => {
    12. // 任务中可以使用 grunt.config() 获取配置
    13. console.log(grunt.config('foo')) // 接收字符串参数
    14. // 如果属性值是对象的话,config 中可以使用点的方式定位对象中属性的值
    15. console.log(grunt.config('foo.bar'))
    16. })
    17. }

    Grunt 多目标任务

    除了普通的任务形式以外,grunt当中还支持一种叫做多目标模式的任务registerMultiTask(),可以理解成子任务。在我们通过grunt实现各种构建任务时非常有用。

    1. module.exports = grunt => {
    2. // 多目标模式,可以让任务根据配置形成多个子任务
    3. grunt.initConfig({
    4. build: { // 指定的每一个属性的键都会成为一个目标
    5. foo: 100,
    6. bar: '456'
    7. }
    8. })
    9. grunt.registerMultiTask('build', function () {
    10. console.log(`task: build, target: ${this.target}, data: ${this.data}`)
    11. })
    12. }

    可以选择运行其中一个目标

    1. yarn grunt build:foo // 得到的target就是foo data就100

    在build当中指定的每一个属性的键都会成为一个目标,除了指定options以外,在options中指定的信息会作为任务的配置选项出现。

    1. module.exports = grunt => {
    2. grunt.initConfig({
    3. build: {
    4. options: {
    5. msg: 'task options'
    6. },
    7. foo: { // 如果目标为对象也可以添加options 会覆盖掉对象中的options
    8. options: {
    9. msg: 'foo target options'
    10. }
    11. },
    12. bar: '456'
    13. }
    14. })
    15. grunt.registerMultiTask('build', function () {
    16. console.log(this.options())
    17. console.log(`task: build, target: ${this.target}, data: ${this.data}`)
    18. })
    19. }
    1. yarn grunt build // 结果没有一个target叫做options 会打印出其配置选项

    Grunt 插件的使用

    插件机制是grunt的核心,很多构建任务都是通用的。
    例如:你在你的项目中需要去压缩代码,别人的项目当中同样也会需要,所以社区中就出现了很多预设的插件,这些插件内部都封装了一些通用的构建任务,一帮情况下我们的构建过程都是由这些通用的构建任务组成的。
    使用插件的过程大体就是先通过npm去安装这个插件再到gruntfile当中载入这个插件提供的一些任务,最后根据这些插件的文档完成相关的配置选项。
    这里我们通过一个非常常见的插件尝试一下,插件叫做grunt-contrib-clean,它用来自动去清除我们在项目开发过程中产生的一些临时文件。
    1.首先使用命令行终端安装这个插件

    1. yarn add grunt-contrib-clean

    2.loadNpmTasks(‘grunt-contrib-[classname]’)方法加载插件提供的一些任务

    1. module.exports = grunt => {
    2. grunt.initConfig({
    3. clean: {
    4. temp: 'temp/**' // 通配符 或 temp/*.txt
    5. }
    6. })
    7. grunt.loadNpmTasks('grunt-contrib-clean')
    8. }

    3.运行这个命令

    1. yarn grunt clean // 会删除temp下的所有文件

    Grunt 常用插件及总结

  • grunt-sass

grunt官方也提供了一个sass模块,但是其需要本机安装Sass环境,使用起来很不方便,我们使用的grunt-sass是一个npm模块,内部会通过npm的形式去依赖sass,就不需要对机器有任何环境要求。

  1. yarn add grunt-sass sass --dev
  1. const sass = require('sass')
  2. module.exports = grunt => {
  3. grunt.initConfig({
  4. sass: {
  5. options: {
  6. sourceMap: true, // 自动生成对应的sourceMap文件
  7. implementation: sass // 用于指定grunt-sass中使用哪个模块去处理sass的编译
  8. },
  9. main: {
  10. files: {
  11. 'dist/css/main.css': 'src/scss/main.scss'
  12. }
  13. }
  14. }
  15. })
  16. grunt.loadNpmTasks('grunt-sass')
  17. }
  • grunt-babel

有时我们需要去编译ES6的语法,ES6语法编译器使用最多的就是Babel,在grunt中我们可以通过grunt-babel去使用Babel。

  1. yarn add grunt-babel @babel/core @babel/preset-env --dev
  • load-grunt-tasks

为了减少loadNpmTasks()模块的使用我们可以安装一个叫做load-grunt-task的模块。

  1. yarn add load-grunt-tasks --dev
  1. const sass = require('sass')
  2. const loadGruntTasks = require('load-grunt-tasks')
  3. module.exports = grunt => {
  4. grunt.initConfig({
  5. sass: {
  6. options: {
  7. sourceMap: true, // 自动生成对应的sourceMap文件
  8. implementation: sass // 用于指定grunt-sass中使用哪个模块去处理sass的编译
  9. },
  10. main: {
  11. files: {
  12. 'dist/css/main.css': 'src/scss/main.scss'
  13. }
  14. }
  15. },
  16. babel: {
  17. options: {
  18. sourceMap: true,
  19. presets: ['@babel/preset-env'] // 需要转换的特性
  20. },
  21. main: {
  22. files: {
  23. 'dist/js/app.js': 'src/js/app.js'
  24. }
  25. }
  26. }
  27. })
  28. // grunt.loadNpmTasks('grunt-sass')
  29. loadGruntTasks(grunt) // 自动加载所有的 grunt 插件中的任务
  30. }
  • grunt-contrib-watch

当文件修改过后我们需要自动的去编译

  1. yarn add grunt-contrib-watch --dev
  1. const sass = require('sass')
  2. const loadGruntTasks = require('load-grunt-tasks')
  3. module.exports = grunt => {
  4. grunt.initConfig({
  5. sass: {
  6. options: {
  7. sourceMap: true, // 自动生成对应的sourceMap文件
  8. implementation: sass // 用于指定grunt-sass中使用哪个模块去处理sass的编译
  9. },
  10. main: {
  11. files: {
  12. 'dist/css/main.css': 'src/scss/main.scss'
  13. }
  14. }
  15. },
  16. babel: {
  17. options: {
  18. sourceMap: true,
  19. presets: ['@babel/preset-env'] // 需要转换的特性
  20. },
  21. main: {
  22. files: {
  23. 'dist/js/app.js': 'src/js/app.js'
  24. }
  25. }
  26. },
  27. watch: { // 当文件修改过后我们需要自动的去编译
  28. js: { // js目标 监视js变化
  29. files: ['src/js/*.js'], // 监视的源文件
  30. tasks: ['babel'] // 改变过后需要执行的任务
  31. },
  32. css: {
  33. files: ['src/scss/*.scss'],
  34. tasks: ['sass']
  35. }
  36. }
  37. })
  38. // grunt.loadNpmTasks('grunt-sass')
  39. loadGruntTasks(grunt) // 自动加载所有的 grunt 插件中的任务
  40. // 添加一个默认任务 为watch做一个映射
  41. grunt.registerTask('default', ['sass', 'babel', 'watch'])
  42. }
  1. yarn grunt

四、自动化构建工具Gulp

Gulp 基本使用

核心特点:高效,易用
使用 gulp 的过程非常的简单:先在项目中安装一个叫做 gulp 的开发依赖,然后在项目的根目录(packge.json所在目录)中添加一个gulpfile.js文件用于编写需要gulp自动执行的一些构建任务,完成过后就可以在命令行终端使用gulp模块提供的CLI运行这些构建任务。

  1. 初始化项目中的package.json

    1. yarn init
  2. 添加gulp模块安装同时会安装gulp/cli模块

    1. yarn add gulp --dev
  3. 在项目根目录下添加gulpfile.js入口文件

  4. 在gulpfile.js中定义一个任务

    1. // 导出的函数都会作为 gulp 任务
    2. // exports.foo = () => {
    3. // console.log('foo task working~')
    4. // }
    5. // gulp 的任务函数都是异步的
    6. // 可以通过调用回调函数标识任务完成
    7. exports.foo = done => {
    8. console.log('foo task working~')
    9. done() // 标识任务执行完成
    10. }
  5. 运行任务foo

    1. yarn gulp foo

    与grunt相同,任务名称为 default 时,这个任务将会成为 gulp 的默认任务,在运行时就不需要指定任务的名称。

    1. // default 是默认任务
    2. // 在运行是可以省略任务名参数
    3. exports.default = done => {
    4. console.log('default task working~')
    5. done()
    6. }

    需要注意的是,v4.0 之前需要通过 gulp.task() 方法注册任务

    1. const gulp = require('gulp')
    2. gulp.task('bar', done => {
    3. console.log('bar task working~')
    4. done()
    5. })

    在 gulp v4.0 以后的版本中,虽然保留了这个API,但我们更推荐使用导出函数成员的方式定义guip任务。

    Gulp 组合任务

    除了创建普通的普通任务以外,gulp的新版本还提供了series() 和 parallel() 这两个用来创建组合任务的API,由此我们就可以很轻松的创建并行任务和串行任务。

    1. const { series, parallel } = require('gulp')
    2. const task1 = done => {
    3. setTimeout(() => {
    4. console.log('task1 working~')
    5. done()
    6. }, 1000)
    7. }
    8. const task2 = done => {
    9. setTimeout(() => {
    10. console.log('task2 working~')
    11. done()
    12. }, 1000)
    13. }
    14. const task3 = done => {
    15. setTimeout(() => {
    16. console.log('task3 working~')
    17. done()
    18. }, 1000)
    19. }
    20. // 让多个任务按照顺序依次执行
    21. exports.foo = series(task1, task2, task3)
    22. // 让多个任务同时执行
    23. exports.bar = parallel(task1, task2, task3)

    Gulp 异步任务

    正如我们所说的,gulp当中的任务都是异步任务,也就是我们在JS当中经常提到的异步函数。
    我们去调用一个异步函数时是没有办法直接去明确这个调用是否完全完成的,都是在函数内部通过回调或者事件的方式去通知外部这个函数执行完成。那我们在异步任务中同样面临这个如何去通知gulp我们的完成情况这样一个问题。
    针对于这个问题,gulp当中有很多解决方法:

  • 通过回调的方式解决

    1. exports.callback = done => {
    2. console.log('callback task')
    3. done()
    4. }

    这个函数中的回调函数与Node当中的回调函数是同样的标准,都是一种叫做错误优先的回调函数。
    也就是说,当我们想在执行过程当中报出一个错误去阻止剩下的任务执行的时候,可以通过给回调函数的第一个参数去指定一个错误对象就可以了。

    1. exports.callback_error = done => {
    2. console.log('callback task')
    3. done(new Error('task failed'))
    4. }

    有了回调函数,我们自然会联想到ES6当中提供的一个叫做Promise的方案。
    Promise是一个相对于回调来说比较好的替代方案,因为它避免了代码中回调嵌套过深的问题,在gulp当中同样支持Promise的方式。

  • 通过Promise的方式解决

    1. exports.promise = () => {
    2. console.log('promise task')
    3. return Promise.resolve()
    4. }
    5. exports.promise_error = () => {
    6. console.log('promise task')
    7. return Promise.reject(new Error('task failed'))
    8. }
  • 使用async/await的方式解决

用到了Promise过后我们自然会想到ES7当中提供的async和await,它们其实是Promise的语法糖,它可以让我们使用Promise的代码更加容易理解。
如果你的Node环境是8以上的版本则可以使用这种方式

  1. const timeout = time => {
  2. return new Promise(resolve => {
  3. setTimeout(resolve, time)
  4. })
  5. }
  6. exports.async = async () => {
  7. await timeout(1000)
  8. console.log('async task')
  9. }

除了以上几种方式以外,gulp还支持另外几种方式,其中,通过stream的方式是最为常见的,因为我们的构建系统大都是在处理文件,所以这种方式也是最常用到的一种。

  • 通过stream的方式解决
    1. const fs = require('fs')
    2. exports.stream = () => {
    3. const read = fs.createReadStream('yarn.lock')
    4. const write = fs.createWriteStream('a.txt')
    5. read.pipe(write)
    6. return read
    7. }
    通过命令行运行这个任务,发现这个任务也是可以正常开始正常结束的,它结束的时机就是这个 read end的时候,因为stream当中都有一个end事件,当读取文件的读取流读取完成过后就会触发end事件,从而gulp就知道这个任务已经完成了。
    我们也可以来模拟gulp当中做的事情:
    1. const fs = require('fs')
    2. exports.stream = done => {
    3. const read = fs.createReadStream('yarn.lock')
    4. const write = fs.createWriteStream('a.txt')
    5. read.pipe(write)
    6. // gulp中接收到stream过后为它注册一个end事件
    7. // 在end事件当中结束了任务的执行
    8. read.on('end', () => {
    9. done()
    10. })
    11. }

    Gulp 构建过程核心工作原理

    构建过程大多数情况下都是将文件读出来,然后进行一些转换,最后写入到另外一个位置。我们可以想象一下:在没有构建系统的情况下,我们也都是人工按照这样一个过程去做的。
    例如我们去压缩一个css文件,我们需要把代码复制出来,然后到一个压缩工具当中去压缩一下,最后将压缩过后的结果粘贴到一个新的文件当中,这是一个手动的过程。
    image.png
    其实,通过代码的方式解决也是类似的,接下来就来通过最原始的底层Node的文件流API去模拟实现下这样一个过程
    1. const fs = require('fs')
    2. const { Transform } = require('stream')
    3. exports.default = () => {
    4. // 文件读取流
    5. const readStream = fs.createReadStream('normalize.css')
    6. // 文件写入流
    7. const writeStream = fs.createWriteStream('normalize.min.css')
    8. // 文件转换流
    9. const transformStream = new Transform({
    10. // 核心转换过程
    11. transform: (chunk, encoding, callback) => {
    12. // chunk => 读取流中读取到的内容 (Buffer)
    13. const input = chunk.toString() // 将读取到的字节数组转换为字符串
    14. // replace 将空白字符替换掉 并替换掉其中的css注释
    15. const output = input.replace(/\s+/g, '').replace(/\/\*.+?\*\//g, '')
    16. callback(null, output) // 错误优先 第一个参数为错误对象 没有传入null
    17. }
    18. })
    19. return readStream
    20. .pipe(transformStream) // 转换
    21. .pipe(writeStream) // 写入
    22. }
    gulp官方的定义是

    The streaming build system

也就是说基于流的构建系统,至于在gulp当中构建过程为什么选用文件流的方式,是因为gulp希望实现一个构建管道的概念,这样我们在后续去做一些扩展插件的时候就可以有一个很统一的方式。

Gulp 文件操作 API

gulp中为我们提供了专门用于去构建读取流和写入流的API,相比于底层Node的API,gulp的API更强大、也更容易使用。
至于负责文件加工的转换流,绝大多数情况都是通过独立的插件来提供,这样的话,我们在实际去通过gulp创建构建任务时的流程就是:
先通过src操作创建一个读取流,然后在借助于插件提供的转换流来实现文件加工,最后再通过gulp提供的dest方法去创建一个写入流,从而写入到目标文件。

  1. const { src, dest } = require('gulp')
  2. exports.default = () => {
  3. return src('src/*.css') // 通过通配符匹配文件
  4. .pipe(dest('dist')) // 复制到dist目录
  5. }

如果需要完成文件的压缩转换,可以去安装一个叫做 gulp-clean-css 的插件,这个插件提供了压缩css代码的转换流

  1. yarn add gulp-clean-css --dev

如果还需要在这个过程中执行多个转换的话,可以继续在中间添加额外的pipe操作,例如我们再添加一个叫做 gulp-rename 的插件

  1. yarn add gulp-rename --dev
  1. const { src, dest } = require('gulp')
  2. const cleanCSS = require('gulp-clean-css')
  3. const rename = require('gulp-rename')
  4. exports.default = () => {
  5. return src('src/*.css') // 通过通配符匹配文件
  6. .pipe(cleanCSS()) // 压缩css代码
  7. .pipe(rename({ extname: '.min.css' })) // 重命名
  8. .pipe(dest('dist')) // 复制到dist目录
  9. }

五、封装自动化构建工作流

封装工作流 准备

当我们要开发多个同类型项目时,我们的自动化工作流应该是一样的,此时就涉及到我们需要在多个项目重复使用相同的构建任务,也就是复用gulpfile的问题。
那我们应该如何提取一个可复用的工作流呢?
image.png
我们可以通过创建一个新的模块去包装gulp,把这个自动化的构建工作流包装进去。gulp只是一个自动化构建工作流的一个平台,并不负责提供任何构建任务,你的构建任务需要通过你的gulpfile去定义
image.png
现在,我们有了gulpfile,也有了gulp,我们把二者通过一个模块结合在一起。以后就可以在同类型的项目当中使用这个模块去提供自动化的构建工作流了。
首先把我们需要初始化一个新的模块,并发布到仓库中

封装工作流 提取Gulpfile到模块

1.)我们先通过 VS Code 打开自动化构建项目 pages-gulp,然后通过

  1. code [sgh-pages文件路径] -a

将两部分代码同时打开(为了方便,后续将gulp构建流项目称为原始项目),将构建工具流提取到 sgh-pages 中:

  1. 先将 gulpfile.js 文件整体移动到 sgh-pages/lib/index.js 中作为项目的入口文件/
  2. 将构建任务需要依赖的模块作为依赖安装到 sgh-pages 中,以后在别的项目中使用到这个模块时就会自动安装这些依赖。
  3. 从原项目将开发依赖(devDependencies)放到 sgh-pages 的 package.json 的 dependencies 中。
  4. 使用 yarn 安装模块的依赖

2.)我们回到原始项目中,将gulp定义的这些工作流以及它的一些依赖删除掉,取而代之的是使用刚刚创建的新的模块提供自动化构建的工作流:

  1. 删除gulpfile.js文件的内容
  2. 删除package.json中devDependencies中的内容
  3. 删除node_modules文件(需要关闭VS Code)
  4. 删除 dist 和 temp 文件

3.)接下来我们使用新创建的模块提供自动化构建工作流,正常的流程我们需要先将 sgh-page 发布到npm仓库,再回到原始项目中安装它,但是现在是开发阶段,模块还需要调试,我们可以通过link的方式把这个模块link到原始项目的 node_module 当中:

  1. 先在命令行终端打开新创建的模块 sgh-pages 将它 link 到全局

    1. yarn link
  2. 回到原始项目命令行终端 link 到项目中

    1. yarn link "sgh-pages"
  3. 将原始项目中的 gulpfile.js 导出 sgh-pages 模块导入的内容

    1. module.exports = require('sgh-pages')
  4. 使用 yarn 安装被删除的项目dependencies依赖

4.)将 gulp 安装到原始项目中,后续真正将模块发布出去过后就不存在了,因为发布之后再安装,sgh-pages时会自动安装gulp模块,gulp模块就会出现在node_modules中。

  1. yarn add gulp-cli gulp --dev

封装工作流 解决模块中的问题

接下来我们将提取出来的公共模块当中不应该被提取的东西全部抽出来。
1.)首先是 data 的问题,可以通过约定大于配置的方式,我们在项目根目录下创建一个配置文件,然后在模块中尝试读取这个配置文件。
在原始项目根目录下创建一个名叫 pages.config.js 的文件,导出一个数据成员 data

  1. module.exports = {
  2. data: [从sgh-pages/lib/index.js 中提取data,并删除原data]
  3. }

在sgh-pages/lib/index.js中定义一个cwd变量,它会返回当前命令行所在的工作目录(也就是原始项目目录)

  1. const cwd = process.cwd()
  2. let config = {
  3. // default config
  4. }
  5. try {
  6. const loadConfig = require(`${cwd}/pages.config.js`)
  7. config = Object.assign({}, config, loadConfig)
  8. } catch (e) {}

将index.js后面使用到data的page任务中的data属性更改为config.data

  1. const page = () => {
  2. return src('src/*.html', { base: 'src' })
  3. .pipe(plugins.swig({ data: config.data, defaults: { cache: false } })) // 编译html,并将数据对象中的变量注入模板,不缓存
  4. .pipe(dest('temp'))
  5. }

2.)修改index.js中的script任务中的presets,因为node_modules的路径改变为sgh-pages/node_modules,不能再引用到,因此我们更改为require的方式载入,它会依次往上层找。

  1. const script = () => {
  2. return src('src/assets/scripts/*.js', { base: 'src' })
  3. // 只是去唤醒babel/core这个模块当中的转换过程
  4. // babel作为一个平台不做任何事情,只是提供一个环境
  5. // presets 就是插件的集合
  6. .pipe(plugins.babel({ presets: [require('@babel/preset-env')] }))
  7. .pipe(dest('temp'))
  8. }

此时当我们执行 yarn gulp build 时就可以正常工作了。

封装工作流 抽象路径配置

至此,我们这一个自动化构建工作流的模块就算是完成了,但是其中还有许多地方可以做一些深度的包装。对于代码中写死的路径,在使用的项目当中就可以看作是一个约定,约定固然好,但是有的时候提供可以配置的能力也很重要。因为在项目当中,如果要求项目的sec目录不再是sec,则可以通过配置的方式去覆盖,这样会更灵活一些。
接下来将这些灵活的配置抽象出来
我们回到项目的 index.js 文件,我们要做的就是将里面写死的路径抽象出来形成配置,就可以在约定的配置文件里面覆盖。
我们先在配置文件加一些默认的配置:

  1. let config = {
  2. // default config
  3. build: {
  4. src: 'src',
  5. dist: 'dist',
  6. temp: 'temp',
  7. public: 'public',
  8. paths: {
  9. styles: 'assets/styles/*.scss',
  10. scripts: 'assets/scripts/*.js',
  11. pages: '*.html',
  12. images: 'assets/images/**',
  13. fonts: 'assets/fonts/**'
  14. }
  15. }
  16. }

接下来将下面的写死的路径换成配置的属性

  1. // 实现这个项目的构建任务
  2. const { src, dest, parallel, series, watch } = require('gulp')
  3. const del = require('del')
  4. const browserSync = require('browser-sync')
  5. const loadPlugins = require('gulp-load-plugins')
  6. const plugins = loadPlugins()
  7. const bs = browserSync.create()
  8. const cwd = process.cwd()
  9. let config = {
  10. // default config
  11. build: {
  12. src: 'src',
  13. dist: 'dist',
  14. temp: 'temp',
  15. public: 'public',
  16. paths: {
  17. styles: 'assets/styles/*.scss',
  18. scripts: 'assets/scripts/*.js',
  19. pages: '*.html',
  20. images: 'assets/images/**',
  21. fonts: 'assets/fonts/**'
  22. }
  23. }
  24. }
  25. try {
  26. const loadConfig = require(`${cwd}/pages.config.js`)
  27. config = Object.assign({}, config, loadConfig)
  28. } catch (e) {}
  29. const clean = () => {
  30. return del([config.build.dist, config.build.temp])
  31. }
  32. const style = () => {
  33. // 通过src的选项参数base来确定转换过后的基准路径
  34. return src(config.build.paths.styles, { base: config.build.src, cwd: config.build.src })
  35. .pipe(plugins.sass({ outputStyle: 'expanded' })) // 完全展开构建后的代码
  36. .pipe(dest(config.build.temp))
  37. .pipe(bs.reload({ stream:true }))
  38. }
  39. const script = () => {
  40. return src(config.build.paths.scripts, { base: config.build.src, cwd: config.build.src })
  41. // 只是去唤醒babel/core这个模块当中的转换过程
  42. // babel作为一个平台不做任何事情,只是提供一个环境
  43. // presets 就是插件的集合
  44. .pipe(plugins.babel({ presets: [require('@babel/preset-env')] }))
  45. .pipe(dest(config.build.temp))
  46. .pipe(bs.reload({ stream:true }))
  47. }
  48. const page = () => {
  49. return src(config.build.paths.pages, { base: config.build.src, cwd: config.build.src })
  50. .pipe(plugins.swig({ data: config.data, defaults: { cache: false } })) // 编译html,并将数据对象中的变量注入模板,不缓存
  51. .pipe(dest(config.build.temp))
  52. .pipe(bs.reload({ stream:true }))
  53. }
  54. const image = () => {
  55. return src(config.build.paths.images, { base: config.build.src, cwd: config.build.src })
  56. .pipe(plugins.imagemin())
  57. .pipe(dest(config.build.dist))
  58. }
  59. const font = () => {
  60. return src(config.build.paths.fonts, { base: config.build.src, cwd: config.build.src })
  61. .pipe(plugins.imagemin())
  62. .pipe(dest(config.build.dist))
  63. }
  64. const extra = () => {
  65. return src('**', { base: config.build.public, cwd: config.build.public })
  66. .pipe(dest(config.build.dist))
  67. }
  68. const serve = () => {
  69. watch(config.build.paths.styles, { cwd: config.build.src }, style)
  70. watch(config.build.paths.scripts, { cwd: config.build.src }, script)
  71. watch(config.build.paths.pages, { cwd: config.build.src }, page)
  72. // watch('src/assets/images/**', image)
  73. // watch('src/assets/fonts/**', font)
  74. // watch('public/**', extra)
  75. watch([
  76. config.build.paths.images,
  77. config.build.paths.fonts,
  78. ], { cwd: config.build.src }, bs.reload)
  79. watch('**', { cwd: config.build.public }, bs.reload)
  80. bs.init({
  81. notify: false, // 是否提示
  82. port: 2080, // 端口
  83. open: true, // 自动打开页面 默认true
  84. // files: 'temp/**', // 启动后自动监听的文件
  85. server: {
  86. baseDir: [config.build.temp, config.build.dist, config.build.public],
  87. routes: { // 优先于baseDir
  88. '/node_modules': 'node_modules'
  89. }
  90. }
  91. })
  92. }
  93. const useref = () => {
  94. return src(config.build.paths.pages, { base: config.build.temp, cwd: config.build.temp})
  95. .pipe(plugins.useref({ searchPath: [config.build.temp, '.'] })) // dist->temp
  96. // html js css三种流
  97. // 压缩js文件
  98. .pipe(plugins.if(/\.js$/, plugins.uglify()))
  99. // 压缩css文件
  100. .pipe(plugins.if(/\.css$/, plugins.cleanCss()))
  101. // 压缩html文件
  102. .pipe(
  103. plugins.if(/\.html$/,plugins.htmlmin({ // 默认只压缩空白字符
  104. collapseWhitespace: true,
  105. minifyCSS: true,
  106. minifyJS: true
  107. })))
  108. .pipe(dest(config.build.dist))
  109. }
  110. const compile = parallel(style, script, page)
  111. // 上线之前执行的任务
  112. const build = series(
  113. extra,
  114. parallel(
  115. series(compile, useref),
  116. image,
  117. font,
  118. extra
  119. )
  120. )
  121. const develop = series(compile, serve)
  122. module.exports = {
  123. clean,
  124. build,
  125. develop
  126. }

当我们抽象出来的配置没有问题之后,就可以尝试着在项目当中的配置文件里也添加一个build选项,此时就可以覆盖掉任何一个路径的结构了。

  1. module.exports = {
  2. build: {
  3. src: 'src',
  4. dist: 'dist',
  5. temp: 'temp',
  6. public: 'public',
  7. paths: {
  8. styles: 'assets/styles/*.scss',
  9. scripts: 'assets/scripts/*.js',
  10. pages: '*.html',
  11. images: 'assets/images/**',
  12. fonts: 'assets/fonts/**'
  13. }
  14. },
  15. data: {
  16. menus: [
  17. {
  18. name: 'Home',
  19. icon: 'aperture',
  20. link: 'index.html'
  21. },
  22. {
  23. name: 'Features',
  24. link: 'features.html'
  25. },
  26. {
  27. name: 'About',
  28. link: 'about.html'
  29. },
  30. {
  31. name: 'Contact',
  32. link: '#',
  33. children: [
  34. {
  35. name: 'Twitter',
  36. link: 'https://twitter.com/w_zce'
  37. },
  38. {
  39. name: 'About',
  40. link: 'https://weibo.com/zceme'
  41. },
  42. {
  43. name: 'divider'
  44. },
  45. {
  46. name: 'About',
  47. link: 'https://github.com/zce'
  48. }
  49. ]
  50. }
  51. ],
  52. pkg: require('./package.json'),
  53. date: new Date()
  54. }
  55. }

封装工作流 包装 Gulp CLI

至此,sgh-pages自动化构建工作流的模块就算是完成了,我们还可以做一些操作让我们使用起来更加方便。我们来梳理一遍:当我们使用sgh-pages时,我们需要将它安装到我们的工作流当中,然后在项目中添加配置文件,配置文件是必要的;接下来我们需要在项目根目录下添加gulpfile.js把sgh-pages里面提供的工作流的任务导出,才可以通过gulp运行。
其实这个gulpfile.js对于我们这个项目来说存在的价值就是把提供的模块里的成员导出出去,这样显得有些冗余,每次都要做一个重复的操作,没有太大意义。我们就希望下项目的根目录下没有gulpfile,也可以正常工作。
我们先把gulpfile删除,我们再去运行yarn gulp命令,提示我们找不到gulpfile,就没有办法正常工作。但是gulp这个cli提供了一个命令行参数,可以让我们指定这个gulpfile所在的路径,同时需要制定当前目录为工作目录,否则会提示为我们工作目录被转换到lib下。

  1. yarn gulp [任务] --gulpfile ./node_modules/sgh-pages/lib/index.js [.]

但是此时传参就会比较复杂,那么,如果我们在sgh-pages里面也提供一个cli,这个cli自动传递这些参数,在内部去调用gulp-cli提供的可执行程序,这样,我们在外界使用的时候就不用再使用gulp了,就相当于把gulp完全包装再sgh-pages这个模块当中。
我们在sgh-pages下面添加一个cli的程序,新建一个bin目录,在里面创建一个sgh-pages.js文件,这个文件会作为cli的一个执行入口。
在package.json中添加一个bin字段

  1. {
  2. ...
  3. "files": [
  4. ...
  5. "bin"
  6. ...
  7. ],
  8. "bin": "bin/sgh-pages.js",
  9. ...
  10. }

在sgh-pages添加一个声明的注释,并将gulp-cli调用和那些复杂的参数里面

  1. #!/usr/bin/env node
  2. process.argv.push('--cwd')
  3. process.argv.push(process.cwd())
  4. process.argv.push('--gulpfile')
  5. process.argv.push(require.resolve('..'))
  6. require('gulp/bin/gulp')

回到 sgh-pages 终端重新link,使其cli注册到全局

  1. yarn unlink
  2. yarn link

此时我们在原始项目中执行

  1. sgh-pages [任务]

就可以顺利执行了,而且,如果你把sgh-pages的模块作为全局模块来安装的话,甚至在项目的本地都不需要去安装这个依赖,这样在后续去使用的时候就会更加方便。

封装工作流 发布并使用模块