自动化构建

258 阅读10分钟

概述

自动化构建是前端工程化当中一个非常重要的组成部分。自动化,实际上指的就是我们通过机器去代替手工完成一些工作;构建,你可以把它理解为把一个东西转换成另外的一些东西。

总的来说开发行业当中的自动化构建就是把我们开发阶段写出来的源代码自动化的去转换成生产环境当中可以运行的代码或者程序,一般我们会把这样一个转换的过程称之为自动化构建工作流。它的作用就是让我们尽可能去脱离运行环境的种种问题在开发阶段去使用一些提高效率的语法、规范和标准。

最典型的应用场景就是开发网页应用时可以使用ECMAScript最新语法标准去提高编码效率和质量,利用Sass去增强CSS的可编程性,以及借助模板引擎,去抽象页面当中重复的HTML。这些用法大都不被浏览器直接支持,那这种情况下,自动化构建工具就可以派上用场了,通过自动化构建的方式,这些不被支持的代码特性,转换成能够直接运行的代码,那这样我们就可以尽情在我们开发过程当中,通过这些方式去提高们编码效率了。

NPM Scripts

  • 实现自动化构建工作流的最简方式
|css
|—— style.css
|—— style.css.map
|scss
|——mian.scss
|index.html
|package.json
"scripts": {
   "build": "sass scss/main.scss css/style.css --watch",
   "serve": "browser-sync . --files \"css/*.css\"",
   "start": "run-p build serve"
 },
"devDependencies": {
   "browser-sync": "^2.26.7",
   "npm-run-all": "^4.1.5",
   "sass": "^1.22.10"
  }

以上scripts命令可监听scss/main.scss变化自动构建,以及监听css文件的变化自动刷新浏览器,start命令同时执行build和server。

常用的自动化构建工具

NPM Scripts确实能解决一部分的自动化构建任务,但是对于相对复杂的构建过程就显得非常吃力,这时候就需要更为专业的构建工具。这里我们先对市面上几个常用的自动化构建工具去做一个大致的介绍,让大家先有一个整体的认识后面再去做具体的深入探究。

Grunt 算是最早的前端构建系统了,它的插件生态非常的完善,用官方的一句话来说,Grunt它的插件几乎可以帮你自动化地去完成任何你想要做的事情,但是由于它的工作过程是基于临时文件去实现的,所以构建速度相对较慢。例如我们使用它去完成项目当中sass文件的构建,一般会对sass文件先做编译操作,再去添加一些私有属性的前缀,最后再去压缩代码。这样一个过程Grunt每一步都会有磁盘读写操作,比如像sass文件编译完成过后,它就会将结果写入到一个临时的文件,然后下一个插件了它再去读取这个临时文件进行下一步,这样一来处理的环节越多文件读写的次数也就越多。对于超大型项目文件会非常多构建速度就会特别的慢,

Gulp 很好地解决了Grunt构建速度慢的一个问题,因为他是基于内存去实现的,也就是说,它对文件的处理环节都是在内存当中完成的。相对于磁盘读写速度自然就快了很多。另外它默认支持同时去执行多个任务,那效率自然大大提高,而且它的使用方式相对于Grunt更加直观易懂,插件生态也同样非常完善。所以说它后来居上目前更受欢迎,应该算是目前市面上最流行的前端构建系统了。

FIS 是百度的前端团队推出的一款构建系统。最早只是在他们团队内部去使用,后来开源了之后在国内快速流行,相对于前面两个构建系统,对于这种微内核的特点,FIS更像是一种捆绑套餐,它把我们在项目当中一些典型的需求尽可能都集中在内部了,例如我们在FIS当中就可以很轻松的去处理资源加载、模块化开发、代码部署,甚至是性能优化。正是因为这种大而全,所以在国内很多项目就流行开了。总体来说,如果是初学者的话可能FIS会更适合你,但是如果你的要求灵活多变的话,Grunt、Gulp应该是你更好的选择。

Grunt

基本使用

接下来在一个空项目当中看一下Grunt的具体用法。

$ mkdir grunt-sample
$ cd grunt-sample
$ yarn init --yes
$ yarn add grunt
$ touch gruntfile.js
  • gruntfile.js 是Grunt 的入口文件,用于定义一些需要 Grunt 自动执行的任务。此文件需要导出一个函数,此函数接收一个 grunt 的对象类型的形参,grunt 对象中提供一些创建任务时会用到的 API。
module.exports = grunt => {
  // 1.任务名 2.任务描述 3.具体任务的逻辑
  grunt.registerTask('foo', 'a sample task', () => {
    console.log('hello grunt')
})

$ yarn grunt foo

=> Running "foo" task
=> hello grunt
  • default 是默认任务名称,通过 grunt 执行时可以省略
grunt.registerTask('default', () => {
  console.log('default task')
})
$ yarn grunt

=> Running "default" task
=> default task
  • 第二个参数可以指定此任务的映射任务,这样执行 default 就相当于执行对应的任务,这里映射的任务会按顺序依次执行,不会同步执行
grunt.registerTask('default', ['foo', 'bar'])
$ yarn grunt

=> Running "foo" task
=> hello grunt

=> Running "bar" task
=> other task
  • 也可以在任务函数中执行其他任务
grunt.registerTask('run-other', () => {
  // foo 和 bar 会在当前任务执行完成过后自动依次执行
  grunt.task.run('foo', 'bar')
  console.log('current task runing~')
})
$ yarn grunt run-other

=> Running "run-other" task
=> current task runing~

=> Running "foo" task
=> hello grunt

=> Running "bar" task
=> other task
  • 默认 grunt 采用同步模式编码,如果需要异步可以使用 this.async() 方法创建回调函数
// 由于函数体中需要使用 this,所以这里不能使用箭头函数
  grunt.registerTask('async-task', function () {
    const done = this.async()
    setTimeout(() => {
      console.log('async task working~')
      done()
    }, 1000)
  })
$ yarn grunt async-task
=> Running "async-task" task
1s后 => async task working~

标记任务失败

  • 在构建任务的逻辑代码当中发生错误,比如我们需要的文件找不到了,那此时就可以将这个任务标记为一个失败的任务,具体实现方式可以通过在函数体中return false来实现。
module.exports = grunt => {
  // 任务函数执行过程中如果返回 false
  // 则意味着此任务执行失败
  grunt.registerTask('bad', () => {
    console.log('bad working~')
    return false
})
$ yarn grunt bad

=> Running "bad" task
=> bad working~
=> Warning: Task "bad" failed. Use --force to continue.
  • 如果一个任务列表中的某个任务执行失败,则后续任务默认不会运行,除非 grunt 运行时指定 --force 参数强制执行
grunt.registerTask('bad', () => {
  console.log('bad working~')
  return false
})

grunt.registerTask('foo', () => {
  console.log('foo working~')
})

grunt.registerTask('bar', () => {
  console.log('bar working~')
})

grunt.registerTask('default', ['foo', 'bad', 'bar'])
$ yarn grunt

=> Running "foo" task
=> foo working~

=> Running "bad" task
=> bad working~
=> Warning: Task "bad" failed. Use --force to continue.
$ yarn grunt --force

=> Running "foo" task
=> foo working~

=> Running "bad" task
=> bad working~
=> Warning: Task "bad" failed. Used --force, continuing.

=> Running "bar" task
=> bar working~
  • 异步函数中标记当前任务执行失败的方式是为回调函数指定一个 false 的实参
grunt.registerTask('bad-async', function () {
  const done = this.async()
  setTimeout(() => {
    console.log('async task working~')
    done(false)
  }, 1000)
})
$ yarn grunt bad-async

=> Running "bad-async" task
=> async task working~
=> Warning: Task "bad-async" failed. Use --force to continue.

配置方法

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

配置项可以是字符串,也可以是对象。

grunt.initConfig({
 foo: 'foo',
 bar:{
   name: '123',
 }
})
grunt.registerTask('foo', () => {
  console.log(grunt.config('foo'))
  console.log(grunt.config('bar.name'))
})
$ yarn grunt foo

=> Running "foo" task
=> foo
=> 123

多目标任务

除了普通的任务形式以外,Grunt还支持一种叫多目标模式的任务,那你可以把它理解成子任务的概念,这种形式的任务在我们后续具体去通过Grunt实现各种构建任务时非常有用。

module.exports = grunt => {
  // 多目标模式,可以让任务根据配置形成多个子任务
  grunt.initConfig({
    build: {
      options: {
        msg: 'task options'
      },
      foo: {
        options: { // 会覆盖之前的options
          msg: 'foo target options'
        }
      },
      bar: '456'
    }
  })

  grunt.registerMultiTask('build', function () {
    console.log(`task: build, target: ${this.target}, data: ${this.data},options: ${this.options()}`)
  })
}
$ yarn grunt build

=> Running "build:foo" (build) task
=> task: build, target: foo, data: [object Object],options: foo target options

=> Running "build:bar" (build) task
=> task: build, target: bar, data: 456,options: task options

插件的使用

插件机制是Grunt的核心,它存在的原因也非常简单,因为很多构建都是通用的,例如你在你的项目当中需要去压缩代码,别人的项目当中同样也会需要,所以说,社区当中就出现了很多预设的插件,这些插件内部都封装了一些通用的构建任务。一般情况下,我们的构建过程都是由这些通用的构建任务组成的。

使用插件过程

  1. 通过 npm 去安装这个插件
  2. 在 gruntfile.js 收载入这个插件提供的一些任务
  3. 根据插件的文档去完成相关的配套选项
$ yarn add grunt-contrib-clean # 清除临时文件的一个插件
module.exports = grunt => {
  grunt.initConfig({
    clean: {
      temp: 'temp/**'
    }
  })
  
  grunt.loadNpmTasks('grunt-contrib-clean')
}
$ yarn grunt clean

=> temp下所有文件都被删除了

Grunt 常用插件

  • sass
$ yarn add grunt-sass sass --dev
const sass = require('sass')

module.exports = grunt => {
  grunt.initConfig({
    sass: {
      options: {
        sourceMap: true,
        implementation: sass
      },
    main: {
        files: {
          'dist/css/main.css': 'src/scss/main.scss'
        }
      }
    },
  })
  grunt.loadNpmTasks('grunt-sass')
}
  • babel
yarn add grunt-babel @babel/core @babel/preset-env --dev

随着task增多,可以安装 load-grunt-tasks 自动加载所有的 grunt 插件中的任务

$ yarn add load-grunt-tasks --dev
const sass = require('sass')
const loadGruntTasks = require('load-grunt-tasks')

module.exports = grunt => {
  grunt.initConfig({
    sass: {
      options: {
        sourceMap: true,
        implementation: sass
      },
      main: {
        files: {
          'dist/css/main.css': 'src/scss/main.scss'
        }
      }
    },
    babel: {
      options: {
        sourceMap: true,
        presets: ['@babel/preset-env']
      },
      main: {
        files: {
          'dist/js/app.js': 'src/js/app.js'
        }
      }
    },
  })

  // grunt.loadNpmTasks('grunt-sass')
  loadGruntTasks(grunt) // 自动加载所有的 grunt 插件中的任务
  • watch 文件内容改变时自动编译
$ yarn add grunt-contrib-watch --dev
const sass = require('sass')
const loadGruntTasks = require('load-grunt-tasks')

module.exports = grunt => {
  grunt.initConfig({
    sass: {
      options: {
        sourceMap: true,
        implementation: sass
      },
      main: {
        files: {
          'dist/css/main.css': 'src/scss/main.scss'
        }
      }
    },
    babel: {
      options: {
        sourceMap: true,
        presets: ['@babel/preset-env']
      },
      main: {
        files: {
          'dist/js/app.js': 'src/js/app.js'
        }
      }
    },
    watch: {
      js: {
        files: ['src/js/*.js'],
        tasks: ['babel']
      },
      css: {
        files: ['src/scss/*.scss'],
        tasks: ['sass']
      }
    }
  })

  // grunt.loadNpmTasks('grunt-sass')
  loadGruntTasks(grunt) // 自动加载所有的 grunt 插件中的任务

  grunt.registerTask('default', ['sass', 'babel', 'watch']) // 启动时运行
}

Gulp

基本使用

接下来在一个空项目当中看一下Gulp的具体用法。

$ mkdir gulp-sample
$ cd gulp-sample
$ yarn init --yes
$ yarn add gulp --dev
$ touch gulpfile.js

gulpfile.js 定义构建任务,是Gulp的入口文件

// 导出的函数都会作为 gulp 任务
exports.foo = () => {
  console.log('foo task working~')
}
$ yarn gulp foo

=> Starting 'foo'...
foo task working~
=> The following tasks did not complete: foo
=> Did you forget to signal async completion?

出现问题的原因是 gulp 的任务函数都是异步的,可以通过调用回调函数标识任务完成

exports.foo = done => {
  console.log('foo task working~')
  done() // 标识任务执行完成
}
$ yarn gulp foo
=> Starting 'foo'...
foo task working~
=> Finished 'foo' after 1.23 ms

default 是默认任务,在运行是可以省略任务名参数

exports.default = done => {
  console.log('default task working~')
  done()
}
$ yarn gulp

=> Starting 'default'...
default task working~
=> Finished 'default' after 1.15 ms

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

const gulp = require('gulp')

gulp.task('bar', done => {
  console.log('bar task working~')
  done()
})

组合任务

const task1 = done => {
  setTimeout(() => {
    console.log('task1 working~')
    done()
  }, 1000)
}

const task2 = done => {
  setTimeout(() => {
    console.log('task2 working~')
    done()
  }, 1000)  
}

const task3 = done => {
  setTimeout(() => {
    console.log('task3 working~')
    done()
  }, 1000)  
}
  • 让多个任务按照顺序依次执行
const { series } = require('gulp')
exports.foo = series(task1, task2, task3)
  • 让多个任务同时执行
const { parallel } = require('gulp')
exports.bar = parallel(task1, task2, task3)

异步任务

  • 通过回调
exports.callback = done => {
  console.log('callback task')
  done()
}

exports.callback_error = done => {
  console.log('callback task')
  done(new Error('task failed'))
}
  • 通过Promise
exports.promise = () => {
  console.log('promise task')
  return Promise.resolve()
}

exports.promise_error = () => {
  console.log('promise task')
  return Promise.reject(new Error('task failed'))
}
  • async
const timeout = time => {
  return new Promise(resolve => {
    setTimeout(resolve, time)
  })
}

exports.async = async () => {
  await timeout(1000)
  console.log('async task')
}
  • stream
exports.stream = () => {
  const read = fs.createReadStream('yarn.lock')
  const write = fs.createWriteStream('a.txt')
  read.pipe(write)
  return read
}

// exports.stream = done => {
//   const read = fs.createReadStream('yarn.lock')
//   const write = fs.createWriteStream('a.txt')
//   read.pipe(write)
//   read.on('end', () => {
//     done()
//   })
// }

构建过程核心工作原理

构建过程大多数情况下都是将文件读出来,然后进行一些转换最后写入到另外一个位置。那可以想象在没有构建系统情况下,也是人工按照这样一个过程去做的。例如我们去压缩一个文件,我们需要把代码复制出来,然后到一个压缩工具当中去压缩一下,最后将压缩过后的结果粘贴到一个新的文件当中,这是一个手动的过程。其实通过代码的方式去解决也是类似的。那接下来就通过最原始的底层Node.js的文件流API去模拟实现一下这样一个过程。

gulpfile.js

const fs = require('fs')
const { Transform } = require('stream')

exports.default = () => {
  // 文件读取流
  const readStream = fs.createReadStream('normalize.css')

  // 文件写入流
const writeStream = fs.createWriteStream('normalize.min.css')

// 文件转换流
const transformStream = new Transform({
  // 核心转换过程
  transform: (chunk, encoding, callback) => {
    const input = chunk.toString()
    const output = input.replace(/\s+/g, '').replace(/\/\*.+?\*\//g, '')
    callback(null, output)
  }
})

return readStream
  .pipe(transformStream) // 转换
  .pipe(writeStream) // 写入
}

上述代码图解过程:

文件操作API

提供读入流src、写入流dest,相比fs,支持通配符匹配文件

const { src, dest } = require('gulp')
// 压缩插件
const cleanCSS = require('gulp-clean-css')
// 重命名插件
const rename = require('gulp-rename')

exports.default = () => {
  return src('src/*.css')
    .pipe(cleanCSS())
    .pipe(rename({ extname: '.min.css' }))
    .pipe(dest('dist'))
}

自动化构建案例

以下是案例的项目结构

|public
|——favicon.ico
|src
|——assets
   |——fonts
   |——images
   |——scripts
   |——styles
|——layouts
   |——basic.html
|index.html

样式编译

$ yarn add gulp-sass --dev

gulpfile.js

const { src, dest } = require('gulp')
const sass = required('gulp-sass')

const style = () => {
  return src('src/assets/styles/*.scss', { base: 'src' })
    .pipe(sass({ outputStyle: 'expanded' }))
    .pipe(dest('dist'))
}

module.exports = {
  style
}

编译后在dist/src/assets/styles/下有编译好的css文件。

脚本编译

$ yarn add gulp-sass --dev
$ yarn add @babel/core @babel/preset-env --dev

gulpfile.js

const { src, dest } = require('gulp')
const babel = required('gulp-sass')

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

module.exports = {
  script
}

编译后在dist/src/assets/scripts/下有编译好的js文件。

页面模板编译

$ yarn add gulp-swig --dev

gulpfile.js

const { src, dest } = require('gulp')
const swig = required('gulp-swig')

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

module.exports = {
  page
}

编译后在dist/src/下有通过模板引擎编译好的html文件。

将以上三个任务并行执行

const { src, dest, parallel } = require('gulp')

const compile = parallel(style, script, page)

module.exports = {
  compile
}

图片和字体文件转换

  • 图片
$ yarn add gulp-imagemin --dev

gulpfile.js

const { src, dest } = require('gulp')
const imagemin = required('gulp-sass')

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

module.exports = {
  image
}

编译后在dist/src/assets/images/下有压缩过后的图片。

  • 字体 不需要处理,直接拷贝。如有.svg格式的文件也可采用imagemin处理
const font = () => {
  return src('src/assets/fonts/**', { base: 'src' })
    .pipe(imagemin())
    .pipe(dest('dist'))
}
module.exports = {
  font
}

把src下的编辑全部放在compile中

const compile = parallel(style, script, page, image, font)

其他文件及文件清除

  • 对于其他文件(public目录)直接拷贝到dist
const extra = () => {
  return src('public/**', { base: 'public' })
    .pipe(dest('dist'))
}

const build = paraller(compile, extra)

module.exports = {
  compile,
  build
}
  • 文件清除 dist目录对编译结果采用覆盖的模式,所以可能会有其他不需要的文件产生,这是就需要清除一下dist
$ yarn add del --dev
const clean = () => {
  return del('dist')
}

自动加载插件

$ yarn add gulp-load-plugins --dev
const loadPlugins = require('gulp-load-plugins')

const plugins = loadPlugins()
// 可直接使用 plugins.sass() plugins.babel()插件等

开发服务器

$ yarn add browser-sync --dev

browser-sync提供开发服务器,相比于普通express等创建的服务器,browser-sync有更强大的功能,它支持代码修改后自动热更新到浏览器当中,让我们可以及时看到最新的页面效果。

const browserSync = require('browser-sync')
const bs = browserSync.create()

const serve = () => {
  bs.init({
    server: {
      baseDir:'dist',
    }
  })
}
module.exports = {
  server
}

监听dist文件刷新浏览器

bs.init({
  server: {
    baseDir:'dist',
    files:'dist/**'
  }
})

我们实际开发都是在src下,所以应该监听src

监视变化以及构建优化

const { src, dest, parallel, series, watch } = require('gulp')

const serve = () => {
  watch('src/assets/styles/*.scss', style)
  watch('src/assets/scripts/*.js', script)
  watch('src/*.html', page)
  // watch('src/assets/images/**', image)
  // watch('src/assets/fonts/**', font)
  // watch('public/**', extra)
  
  // 以下变化时浏览器也可自动刷新,用reload代替files
  watch([
    'src/assets/images/**',
    'src/assets/fonts/**',
    'public/**'
  ], bs.reload)

  bs.init({
    notify: false,
    port: 2080,
    // open: false,
    // files: 'dist/**',
    server: {
      baseDir: ['dist', 'src', 'public'],
    }
  })
}

图片、字体以及其他文件应该在发布前再压缩,因为在开发时监听这些文件变化后进行构建,会浪费构建的时间。

开发阶段构建任务可将compile与serve串行

const develop = series(compile, serve)

上线之前执行的任务

const build =  series(
  clean,
  parallel(
    series(compile, useref),
    image,
    font,
    extra
  )
)

文件压缩

$ yarn add gulp-htmlmin gulp-clean-css gulp-uglify gulp-if --dev
// userfe 对文件引用的处理,根据注释build到相应目录
const useref = () => {
  return src('temp/*.html', { base: 'temp' })
    .pipe(plugins.useref({ searchPath: ['temp', '.'] }))
    // html js css
    .pipe(plugins.if(/\.js$/, plugins.uglify()))
    .pipe(plugins.if(/\.css$/, plugins.cleanCss()))
    .pipe(plugins.if(/\.html$/, plugins.htmlmin({
      collapseWhitespace: true,
      minifyCSS: true,
      minifyJS: true
    })))
    .pipe(dest('dist'))
}

把构建任务合到 NPM Scripts 命令

"scripts": {
  "clean": "gulp clean",
  "build": "gulp build",
  "develop": "gulp develop"
},

封装自动化构建流

我们希望在同类型的项目中都可以使用以上的构建流,所以后面封装、打包发布

准备

新建项目xxx-pages,基本目录结构

|lib
  |——index.js
|.gitignore
|CHANGELOG.md
|package.json
|README.md

提取 gulpfile

首先将原项目package.json中的开发依赖拷贝到xxx-pages的依赖中(devDependencies => dependencies),这样在安装pages时会自动安装dependencies中的依赖。

为了调试方便,先把pages link到全局

$ yarn link

然后原项目可通过 $ yarn link "pages" ,则node_modules下就出现了pages,后面有一个小图标表示是软链接

原项目gulpfile.js

module.exports = required('pages')

process.cwd() // 返回当前命令行的工作目录

抽象路径配置

根据配置文件读取原项目的路径

包装Gulp CLI

以上使用xxx-pages工作流需要在原项目中创建gulpfile导出任务显得有点冗余,gulp提供指定gulpfile路径的方式运行,

$ yarn gulp build -gulpfile './node_modules/xxx-pages/lib/gulpfile.js --cwd //--cwd 保证仍在当前目录运行

以上命令有些长,所以接下来包装一下 xxx-pages下新建bin/xxx-pages.js

process.argv.push('--cwd')
process.argv.push(process.cwd())
process.argv.push('--gulpfile')
process.argv.push(require.resolve('..'))

require('gulp/bin/gulp')

package.json中添加字段

"bin": "bin/zce-pages.js",

发布并使用模块

需要把bin、lib目录都发布 packages.json中配置

"files": [
    "lib",
    "bin"
  ],
$ yarn publish --registry https://registry.yarnpkg.com