【万字长文】通过grunt、gulp和fit,彻底搞懂前端的自动化构建

3,215 阅读11分钟

在上一篇文章中介绍了前端工程化中脚手架工具,这篇文章作为前端工程化的第二部分,自动化构建。

前端工程化===Webpack?No,我来告诉你什么是前端工程化!

简介

一切重复工作本应自动化。

自动化构建是前端工程化中一个重要的组成部分。

自动化就是通过机器代替人工,构建就是将源代码转换为生产代码。

机器自动将源代码转换为生产代码的过程就是自动化构建工作流。

自动化构建工作流会脱离运行环境兼容带来的问题,让我们能够在开发阶段使用提高效率的语法、规范和标准。

典型的一些应用有,编写 JavaScript 时,可以使用 ECMAScript Next 语法;编写 css 时,可以使用 Sass、Less 等语法;编写 Html 时,可以使用 pug 等模板引擎。这些用法基本上都是无法在浏览器中直接运行的。

使用自动化构建之后,我们可以构建转换那些不被支持的「特性」,从而很大程度上提高我们的开发效率。

体验

使用 sass 提高 css 编程性

使用 scss 来编写样式从而提高 css 的编程性,但是浏览器不支持 scss。下面通过将 scss 转换成 css 来体验自动化构建。

首先创建一个 index.html。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Demo</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    hello, sass
  </body>
</html>

然后创建一个 style.scss 文件。

$bg: #e2e2e2;
$color: #d0d;

body { background-color: bg;
  color:color; }

我们需要将 scss 转换为 css 就可以让页面正常使用样式。

编译 scss 需要使用官方提供的 sass 模块。

首先初始化 node 项目。

npm init -y

安装 sass。

npm i -D sass

然后就可以通过 npx 运行 sass 模块了。

npx sass style.scss style.css

npx 是 node5.2 版本之后推出的命令,可以帮助我们自动调用项目内部安装的模块。

安装完 sass 后,会在项目根目录的 node_modules 下的.bin 目录中多出来一个 sass 可执行脚本文件。

如果不使用 npx,就需要指定路径来执行。

node_modules/.bin/sass style.scss style.css

这样会很麻烦。

如果你使用 yarn 的话,可以使用 yarn 来执行 sass,这和 npx 很相似。

yarn sass style.scss style.css

sass 的两个参数分别是源文件和目标文件,执行过后会生成两个文件,一个是 style.css,另一个是 style.css.map。

css 文件就是我们要使用的生产代码,map 文件是将源代码和生产代码做出的映射,方便我们调试。

这时候在浏览器中打开 index.html 就可以看到正常应用样式的页面了。

NPM Scripts

但是如果这个项目让别人来接手时,他可能并不理解 sass 的编译命令。

这时可以通过 npm script 中定义命令来包装项目开发过程中需要的命令。

定义 npm script 的方式就是在 package.json 中添加 scripts 字段,并在其中添加一个属性。属性名是 scripts 的名称,值就是需要执行的命令。

{
  "scripts": {
    "build:style": "sass style.scss style.css"
  }
}

这样就可以通过npm run build:style来执行同样的操作。

npm run 的操作会自动在项目依赖中寻找命令,所以不需要再去写 npx 了,当然写了也不会报错。

添加开发服务器和自动化构建

直接在浏览器中打开文件的方式有很多弊端,最大的一个问题就是每当我们修改完代码后,都需要手动刷新浏览器,这样会大大降低开发效率。

我们可以安装一个开发服务器帮助我们完成热更新的功能。

npm i -D browser-sync

browser-sync 是一个非常流行的 web 开发服务器,它可以帮助我们实现很多开发阶段需要的功能。

在 package.json 中添加对应的脚本。

{
  "scripts": {
    "serve": "browser-sync . --files ./**/*.html ./**/*.css"
  }
}

第一个.是项目的根目录,--files 参数是监听哪些目录的变化,从而刷新浏览器。

这时可以修改 index.html 中的内容,浏览器会自动刷新。

但是修改 style.scss 的内容,浏览器不会自动应用样式。

pre hook

一般来说,在启动服务之前,我们都应该把代码编译一遍,这样保证服务启动后一切都是正常的。

npm scripts 提供了钩子机制,可以在脚本名前面添加 pre 和 post 来创建钩子脚本。

pre 前缀的脚本会在脚本执行之前执行,post 前缀的脚本会在脚本执行之后执行。

比如在 serve 运行之前执行 build。

{
  "scripts": {
    "build:style": "sass style.scss style.css",
    "preserve": "npm run build:style",
    "serve": "browser-sync . --files ./**/*.html ./**/*.css"
  }
}

这样就可以在每次启动服务之前都对样式文件进行编译了。

多任务并行执行

让 scss 文件变化后自动刷新浏览器也很简单,sass 提供了--watch 参数,每当 sass 文件修改,重新编译 css 文件。

但是 sass 的--watch 会阻塞当前的命令行窗口,导致后续的任务无法继续执行。

这时正常现象,大部分命令行运行平台默认都是继发执行,就是一个命令接一个命令的执行,只有当前一个命令执行成功,才会执行下一个命令。

可以使用&来让两个命令同时执行。

这样用的话,就需要修改脚本,将 preserve 删除掉。

npm run build:style & npm run serve

除了这种 bash 平台默认的功能以外,还可以借助 node 的多任务模块来实现。

安装 npm-run-all 模块。

npm i -D npm-run-all

修改脚本。

{
  "scripts": {
    "build:style": "npx sass style.scss style.css --watch",
    "serve": "browser-sync . --files ./**/*.html ./**/*.css",
    "start": "run-p build:style serve"
  }
}

这样就完成了一个简单的自动化构建工作流。

自动化构建工具简介

npm scripts 适合一些相对简单的构建任务。

当构建任务变得复杂时,npm scripts 就会显得非常吃力,这时就需要借助更加专业的构建工具。

现代开发最为流行的工具还是 webpack。但是严格意义上来说,webpack 并不算是一款自动化构建工具,而是模块打包工具。当然 webpack 也可以借助 plugins 来完成一些构建任务。但是自定义程度不高,不如 grunt 和 gulp。

webpack 的成功,是一直跟随潮流。React 和 Vue 等单页面框架的成功,意味着模块化成为了必须解决的问题。

在最早的前端开发中,所谓的构建就是将 js 文件和 css 文件进行压缩混淆。那时最流行的方式就是在网上找到一些在线工具,通过代码上传,在线压缩,拷贝本地的方式来完成。当文件过多时,这将会是一项非常枯燥的工作。

07 年左右,雅虎推出了 YUI Build Tool,它可以结合 Java 的 ant 做一些代码处理工作,算是自动化构建最早的起点。

YUI Build Tool 的流行持续了好几年,之后由于 AMD、CMD 等 js 模块化的流行,演变出了 grunt 工具。grunt 曾经是最为流行、最为强大、生态圈最为繁华的前端构建工具,官方甚至自称可以使用 grunt 完成你所有想要完成的任务。

grunt 的做法是将构建文件存储在本地磁盘,当项目足够大的时候构建速度会非常慢,所以出现了 gulp,gulp 使用文件流的概念,将临时文件存储在内存中,大大提高了构建速度。

grunt 的很多插件的更新维护都停留在 15 年左右,所以目前来说,新项目中推荐使用 gulp 来取代 grunt。

fit 是百度推出的一款构建工具,由于在当时有很多先进的构建理念,开源后在国内非常流行。和 grunt、gulp 不同的是,它不像是一个构建平台,而是一个构建体统。使用它可以非常轻松的完成资源加载、模块化部署、性能优化等。相比较 gulp,fit 对新手更加友好,但是官方团队已经停止对 fit 的维护了。

关于构建工具的总结,就是一句话: 新手需要规则,老手渴望自由。 这正是由于这样,才使得一些小而美的工具流行开来。

Grunt 用法

基本用法

初始化项目:

npm init -y

安装 grunt:

npm i grunt -D

在项目根目录下创建 grunt 的入口文件:

touch gruntfile.js

gruntfile.js 默认导出一个函数,这个函数会有一个形式参数,就是 grunt。

通过 registerTask 来注册任务,第一个参数是任务名,第二个参数是任务的回调。

如果传递三个参数,那么第二个参数就可以是任务的描述。

module.exports = (grunt) => {
  grunt.registerTask("foo", "test task", () => {
    console.log("hello grunt~");
  });
};

通过 npx grunt --help 来查看任务描述。

npx grunt --help

通过 npx grunt --help 来执行任务。

npx grunt foo
默认任务

任务名称为 default 时,会作为 grunt 的默认任务。

module.exports = (grunt) => {
  grunt.registerTask("default", "default task", () => {
    console.log("default task~");
  });
};

这样在命令行中就不需要指定任务名。

npx grunt
组合任务

通常情况下,default 会组合一些其他任务。这时可以传递一个数组。

module.exports = (grunt) => {
  grunt.registerTask("foo", "foo task", () => {
    console.log("foo task~");
  });

  grunt.registerTask("bar", "bar task", () => {
    console.log("bar task~");
  });

  grunt.registerTask("default", ["foo", "bar"]);
};

这样运行 npx grunt 时,会先运行 foo 任务,再运行 bar 任务。

异步任务

grunt 默认支持同步任务,对于异步任务,需要手动标记完成。

grunt.registerTask("async", "async", function () {
  setTimeout(() => {
    console.log("async task~");
  }, 1000);
});

运行 npx grunt async,不会输出 async task~。

如果要正常输出,需要使用 this.async,返回一个函数,调用这个函数,就意味着这个异步任务完成了。

grunt.registerTask("async", "async", function () {
  const done = this.async();
  setTimeout(() => {
    console.log("async task~");
    done();
  }, 1000);
});

这样 grunt 会一直等待 done 函数的执行。

标记任务失败

任务返回 false 就代表任务失败。

grunt.registerTask("bad", "bad task", () => {
  console.log("bad task~");
  return false;
});

但是不会影响当前任务的执行。

bad task~
Warning: Task "bad" failed. Use --force to continue.

Aborted due to warnings.

如果一个任务执行失败的话,就会阻止后续的任务。

grunt.registerTask("default", "default task", ["foo", "bad", "bar"]);

如果是异步任务,标记失败可以给 done 函数传递一个 false 参数。

grunt.registerTask("async", "async", function () {
  const done = this.async();
  setTimeout(() => {
    console.log("async task~");
    done(false);
  }, 1000);
});

配置方法

grunt 提供了一个添加配置选项的 API,叫做 initConfig。

initConfig 可以方便我们把配置集中起来,在使用的时候更好的获取到配置项。

module.exports = (grunt) => {
  grunt.initConfig({
    foo: "bar",
    obj: {
      hello: "world",
    },
  });

  grunt.registerTask("foo", () => {
    console.log(grunt.config("foo"));
    // 如果是个对象,可以继续.
    console.log(grunt.config("obj.hello"));
  });
};

运行 npx grunt foo,控制台输出:

bar
world

多目标模式任务

多任务模式可以让任务根据配置形成多个任务。

注册多任务需要使用 grunt.registerMultiTask。

多任务的配置需要在 grunt.initConfig 中定义一个和任务同名的属性,这个属性必须是个对象。对象中的每个属性就是该任务下的子任务。

options 属性是例外,它会作为该任务的配置选项,而不会作为一个任务。

如果子任务中也存在 options 属性,会覆盖掉外层的 options。

示例代码如下:

module.exports = (grunt) => {
  grunt.initConfig({
    build: {
      options: {
        env: "es6",
      },
      weapp: "foo",
      h5: {
        options: {
          target: "chrome",
        },
      },
    },
  });

  grunt.registerMultiTask("build", function () {
    console.log(
      `target: ${this.target}, data: ${this.data}, options: ${JSON.stringify(
        this.options()
      )}`
    );
  });
};

这样就模拟注册了一个叫做 build 的任务。build 任务下还有两个任务,分别是微信平台的 weapp 和 web 平台的 h5。

在 build 任务的回调函数中,可以通过 this.target 拿到当前执行任务的属性名,通过 this.data 拿到当前执行任务的属性值,通过 this.options 函数调用拿到当前执行任务的配置选项。

现在这个 build 命令就有了三种运行方式。

两个平台同时编译。

npx grunt build

只编译微信平台。

npx grunt build:weapp

只编译 h5 平台。

npx grunt build:h5

插件用法

插件是 grunt 的核心机制。有很多构建任务是通用的,和业务无关,只和特定技术有关。

使用插件的过程比较简单,通常做法有 3 步。

  1. 安装插件。
  2. 导入插件。
  3. 配置插件。

标准的插件命名规范是 grunt-contrib-xx。

下面演示一下 grunt-contrib-clean 插件的用法。

clean 插件的作用是清除掉临时目录。

假设项目下有一个 temp 文件夹是用于存放临时目录的,我们要通过 clean 命令删除掉它。

你可以在项目根目录下创建一个 temp 目录,并在其中创建一些文件。

首先安装插件。

npm i -D grunt-contrib-clean

编写配置:

module.exports = (grunt) => {
  grunt.initConfig({
    clean: {
      temp: "temp",
    },
  });

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

然后运行 npx grunt clean,就可以将 temp 文件夹删除掉了。

常用插件

实际上大部分自动化构建工具的插件核心包都是相同的。

比如编译 sass 所用到的 sass、编译 ES Next 语法所用到的 babel、检查 js 代码的 eslint 等。

下面演示下 sass 和 babel 的用法。

首先安装 sass。

npm i -D sass grunt-sass

再安装 babel 所需要的依赖。

npm i -D @babel/core @babel/preset-env grunt-babel

然后编写配置文件。

const sass = require("sass");

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

  grunt.loadNpmTasks("grunt-sass");
  grunt.loadNpmTasks("grunt-babel");
};

这时虽然可以编译文件,但是修改了源代码后并不会重新编译。这时候可以使用一个 grunt-contrib-watch 插件来监听源代码的变化,从而自动执行编译。

npm i -D grunt-contrib-watch

在配置文件中添加 watch 的任务配置。

grunt.initConfig({
  // ... other code
  watch: {
    js: {
      files: ["src/js/*.js"],
      tasks: ["babel"],
    },
    css: {
      files: ["src/scss/*.scss"],
      tasks: ["sass"],
    },
  },
});

grunt.loadNpmTasks("grunt-contrib-watch");

由于 watch 只是会监听文件变化,才会触发对应的任务。所以应该创建一个组合命令,先执行 sass 和 babel 的编译,再执行文件监听。

grunt.registerTask("default", ["sass", "babel", "watch"]);

这时会有一个问题,当任务过多时,需要执行很多遍 grunt.loadNpmTasks。我们可以通过 load-grunt-tasks 库来解决这个问题。

npm i -D load-grunt-tasks

安装完成后修改代码。

module.exports = (grunt) => {
  // ... other code

  require("load-grunt-tasks")(grunt);
  // 不再需要 grunt.loadNpmTasks
  // grunt.loadNpmTasks('grunt-sass')
  // grunt.loadNpmTasks('grunt-babel')
};

目前来说,grunt 已经退出历史舞台,目前它的替代者是 gulp。但是仍然在一些老项目中会碰到 grunt。

学习 grunt 的目的有两点,第一是当碰到老项目时可以有能力去修改编译配置。第二是为接下来学习 gulp 做铺垫。

Gulp 用法

gulp 作为目前最流行的构建工具,其特点是高效、易用、使用简单。

基本用法

首先在项目中安装 gulp。

npm i -D gulp

安装 gulp 时会自动安装一个 gulp-cli 模块。

然后在项目的根目录下编写 gulp 配置文件,配置文件约定的名字是 gulpfile.js。

gulpfile 遵循 nodejs 规范,所以需要使用 CommonJs 规范。

exports.foo = () => {
  console.log("foo task working~");
};

上面的代码就是创建了一个 foo 任务。

运行 foo 任务。

npx gulp foo

会得到以下日志:

[22:16:18] Using gulpfile ~/Documents/project-t/test-project/gulpfile.js
[22:16:18] Starting 'foo'...
foo task working~
[22:16:18] The following tasks did not complete: foo
[22:16:18] Did you forget to signal async completion?

最新版本的 gulp 中的任务都是异步任务,而非同步任务。所以在每个任务执行结束之后,需要手动标记该任务已结束。

标记的方法也很简单,每个任务函数都会传递一个默认参数,该参数就是一个函数,调用该函数就意味着任务完成。

exports.foo = (done) => {
  console.log("foo task working~");
  done();
};

这样 foo 任务的运行就不会再有问题了。

默认任务

如果 exports 的属性名是 default 的话,就可以作为默认任务。

exports.default = (done) => {
  console.log("default task working~");
  done();
};

这样在运行时只需要运行npx gulp,而不需要指定任务名。

gulp 4.x 之前的任务写法

在 gulp4.0 之前,注册任务的写法与现在不同。

const gulp = require("gulp");

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

组合任务

组合任务又分为并行任务和串行任务,概念上和电路图是一回事。

串行任务是指多个任务依次执行。

并行任务是指多个任务同时执行。

它们分别由 series 和 parallel 来创建。

下面模拟三个任务来观察它们的区别。

const { series, parallel } = require("gulp");

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);
};

exports.foo = series(task1, task2, task3);
exports.bar = parallel(task1, task2, task3);

运行 foo 任务,会每当一个任务执行完成才会执行下一个任务,用时大约 3 秒多。

运行 bar 任务,三个任务会同时执行,大约 1 秒以后三个任务同时结束。

互不干扰的任务可以使用并行任务,可以提高任务整体的执行效率。比如编译 js 和编译 css。

相互依赖的任务只能使用串行任务,比如部署项目,要先编译再部署。

异步任务

gulp 的所有任务都是异步任务。

异步任务在函数内部是无法得知自身完成的,一般都是通过回调函数的方式通知外部这个函数执行完成。

gulp 最常用的异步任务方法有 3 种。

第一种是回调函数的形式。

exports.callback = (done) => {
  console.log("callback task");
  done();
};

这个 done 函数和 nodejs 的回调函数是相同的原则,都是错误优先。如果当一个任务发生异常,阻止其他任务继续执行,就可以在 done 函数的第一个参数的位置传递一个错误对象。

exports.callback = (done) => {
  console.log("callback task");
  done(new Error("task failed!"));
};

既然用到了回调函数,那么自然可以使用 Promise 来取代回调函数。这么做的好处是避免了回调函数嵌套过深的情况。

exports.promise = () => {
  console.log("promise task");
  return Promise.resolve();
};

直接返回一个 Promise.resolve,就意味着这个任务执行成功了。

如果任务执行失败,可以返回一个失败的 Promise。

exports.promise_error = () => {
  console.log("promise task");
  return Promise.reject(new Error("task failed"));
};

使用了 Promise ,那么也可以使用 async/await 的语法来编写代码。这么做的好处是让我们的代码更容易理解。

如果 nodejs 的环境是 8+,就可以使用 async/await 语法。

const timeout = (time) => {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
};

exports.async = async () => {
  await timeout(1000);
  console.log("async task");
};

除了上述的回调函数和异步两种方式,gulp 中最流行的还是 stream 的方式,因为构建过程中都是在操作文件。

const fs = require("fs");

exports.stream = () => {
  // 读取流
  const readStream = fs.createReadStream("package.json");
  // 写入流
  const writeStream = fs.createWriteStream("temp.txt");
  // 将读取流 导入到 写入流
  readStream.pipe(writeStream);
  // 返回这个读取流
  return readStream;
};

返回一个 stream 对象时,结束时机就是 stream 的 end 事件触发的时候。

实现原理很简单,我们可以模拟一下。

const fs = require("fs");

exports.stream = (done) => {
  const readStream = fs.createReadStream("package.json");
  const writeStream = fs.createWriteStream("temp.txt");
  readStream.pipe(writeStream);
  readStream.on("end", () => {
    done();
  });
};

可以看到,gulp 只是帮我们注册了一个 end 事件,并在回调函数中调用了 done 函数。

构建过程核心工作原理

构建过程的工作流程基本上都是读取文件、文件转换、写入文件三个步骤。

这个过程可以人工操作,但是当文件过多或者需要频繁操作时,人工就很难继续胜任这个工作。

输入(读取文件) -> 加工(压缩文件)-> 输出(写入文件)

构建过程核心工作原理示意图
构建过程核心工作原理示意图

下面使用原生的 nodejs 来模拟一下这个过程。

const fs = require("fs");
const { Transform } = require("stream");

exports.default = () => {
  // 读取流
  const read = fs.createReadStream();
  // 写入流
  const write = fs.createReadStream();
  // 转换流
  const transform = new Transform({
    transform: (chunk, encoding, callback) => {
      // 核心转换过程
      // chunk 是读取流中的内容,默认是 Buffer 格式
      const input = chunk.toString();
      // 去除所有空格和注释
      const output = input.replace(/\s+/g, "").replace(/\/\*.+?\*\//g, "");
      callback(null, output);
    },
  });
  // 读取 -> 转换 -> 写入
  read.pipe(transform).pipe(write);
  return read;
};

gulp 的自述是 The streaming build system,意思就是基于流的构建系统。

为什么要使用流来实现构建过程呢?因为 gulp 希望整个构建过程像管道一样,这样可以方便添加各种扩展插件。

文件操作 API

gulp 为我们操作文件提供了一套 api,相比于 nodejs 原生的 api,功能更强大,也更易于使用。

首先通过 gulp 提供的 src 方法创建读取流,再通过插件提供的转换流来完成文件内容的加工,最后通过 gulp 提供的 dest 方法创建写入流,将内容写入新的文件。

下面是一个将 src 目录下的 css 文件拷贝到 dist 目录下的例子。

const { src, dest } = require("gulp");
exports.default = () => {
  return src("src/*.css").pipe(dest("dist"));
};

这个例子仅仅是使用读取流读取文件内容再使用写入流将内容写入到指定位置。

接下来再添加文件的转换流,来完成内容压缩和文件的重命名。

安装 gulp-clean-css 和 gulp-rename 插件。

npm i -D gulp-clean-css gulp-rename

将这两个转换流加入到任务流程中。

const { src, dest } = require("gulp");
const cleanCss = require("gulp-clean-css");
const rename = require("gulp-rename");

const css = () => {
  return src("src/*.css")
    .pipe(cleanCss())
    .pipe(rename({ extname: ".min.css" }))
    .pipe(dest("dist"));
};

exports.default = css;

这样就完成了 css 的简单构建。

Gulp 完整案例

下面演示一下使用 Gulp 来编译一个 React 项目的案例。

项目源码在 github 上面,可以使用 git clone 下载。

git 地址:git@github.com:luzhenqian/gulp-react-demo.git

完成效果如下:

效果图1
效果图1
效果图2
效果图2

项目的整体结构如下:

.
|____public
| |____favicon.ico
| |____index.html
|____src
| |____assets
| | |____images
| | | |____zhuangzhou.jpg
| | | |____laofuzi.jpg
| | | |____wuzetian.jpg
| | | |____damo.jpg
| | | |____miyue.jpg
| | | |____xiangyu.jpg
| | | |____yangyuhuan.jpg
| | | |____lvbu.jpg
| | |____styles
| | | |____index.scss
| | |____scripts
| | | |____index.jsx
| | |____fonts
| | | |____FiraCode-Light.ttf
|____package.json

其中 public 目录下放置的都是静态文件,无需编译。

src 目录下存放的是源代码,都需要经过编译后才可以使用。

简单起见,项目中并不会涉及到模块化的处理。所有的 React 代码都会放到同一个文件中。

准备工作是把 gulp 安装上。

npm i -D gulp

样式编译

const { src, dest } = require("gulp");
const style = () => {
  return src("src/assets/styles/*.scss").pipe(dest("dist"));
};

这样就可以将 /src/assets/styles 目录下的所有 scss 文件都拷贝到 dist 下面。

但是我们希望按照 src 目录下的目录结构来放置这些文件,所以需要在 src 函数中再添加一个参数用来指定基准路径。

const { src, dest } = require("gulp");
const style = () => {
  return src("src/assets/styles/*.scss", { base: "src" }).pipe(dest("dist"));
};

module.exports = { style };

这样就会将 src 之后的目录保留下来,放置到 dist 目录下。

这样就完成了输入和输出。

接来下就是文件的加工转换,将 scss 文件转换成浏览器可以运行的 css 文件。转换需要使用 gulp-sass 这个插件,它会自动安装 node-sass 插件。所以我们只需要安装它自己就可以。

npm i -D gulp-sass

在 style 任务中添加转换。

const { src, dest } = require("gulp");
const sass = require("gulp-sass");

const style = () => {
  return src("src/assets/styles/*.scss", { base: "src" })
    .pipe(
      sass({ /** 指定输出样式为尾括号单独占用一行 */ outputStyle: "expanded" })
    )
    .pipe(dest("dist"));
};

module.exports = { style };

这样就完成了 sass 转换成 css 的任务。

脚本编译

按照样式文件的任务照写一份,区别是匹配文件不同和转换插件不同。

脚本文件的转换需要依赖 gulp-babel 插件。

和 gulp-sass 不同的是,gulp-babel 插件不会帮助我们安装核心转换模块,所以需要手动安装。babel 的核心转换模块是@babel/core。但是 core 只是转换的平台,还需要告诉它具体按照哪些规则去转换,所以还要安装转换 ES6 语法的库 @babel/preset-env 和转换 React 语法的库 @babel/preset-react。

npm i -D gulp-babel @babel/core @babel/preset-react

最后编写 script 任务。

// ... other code
const script = () => {
  return src("src/**/*.js?(x)", { base: "src" })
    .pipe(babel({ presets: ["@babel/preset-env", "@babel/preset-react"] }))
    .pipe(dest("dist"));
};

module.exports = { style, script };

其中 src 的第一个参数中两个星号(*)代表匹配 src 目录下任意子目录。

页面模板编译

页面中使用到了 ejs 模板语法,所以要使用 ejs。

安装 ejs。

npm install --save-dev gulp-ejs

在编译样式和脚本的过程中,我们已经知道了输出的文件路径在哪里,所以可以通过编译过程中自动注入的形式来帮我们注入生成后的样式和脚本文件。

这里借助一个叫做 gulp-html-replace 的插件。

npm i -D gulp-html-replace

编写任务。

const ejs = require("ejs");
const htmlReplace = require("gulp-html-replace");
// ... other code

const html = () => {
  return src("public/index.html", { base: "public" })
    .pipe(ejs({ title: "王者荣耀 小站" }))
    .pipe(
      htmlReplace(
        {
          js: {
            src: "./assets/scripts/",
            tpl: '<script src="%s%f.js"></script>',
          },
          css: {
            src: "./assets/styles/",
            tpl: '<link rel="stylesheet" href="%s%f.css">',
          },
        },
        { resolvePaths: true }
      )
    )
    .pipe(dest("dist"));
};

htmlReplace 会自动识别 html 文件中的注释,并在注视的位置插入生成的文件引用。

<!-- build:css -->
<!-- endbuild -->

<!-- build:js -->
<!-- endbuild -->

组合页面、脚本、样式任务

完成页面、脚本、样式的编译任务后,每次执行时都需要执行不同的任务,非常麻烦,这时就可以将这三个任务组合成一个任务。

因为这三个任务之间没有依赖关系,可以并行执行。

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

// ... other code

const compile = parallel(style, script, html);

module.exports = { compile };

图片和字体文件转换

图片的转换只需要压缩,所以要借助 gulp-imagemin 插件。

npm i -D gulp-imagemin

图片的压缩会删除掉无用信息,但是不会影响最终呈现效果。svg 这种图片只是会格式化代码。

由于 font 一般是没办法压缩的,所以只需要拷贝过去,但是考虑到 font 中可能存在 svg,所以最好也经过 imagemin 处理一下。

// ... other code
const imagemin = require("gulp-imagemin");

const imageAndFont = () => {
  return src(["src/assets/fonts/**", "src/assets/images/**"], {
    base: "src",
  })
    .pipe(imagemin())
    .pipe(dest("dist"));
};

const compile = parallel(style, script, html, imageAndFont);

gulp 的 src 函数可以处理多个不同路径的文件,传入一个数组就可以。

最后把 imageAndFont 任务也加入到 compile 中去。

静态文件

public 目录下的文件是要被原封不动的拷贝过去的,但有个例外,就是 index.html。如果不过滤掉 index.html,就可能出现文件覆盖的问题。

因为 public 不需要编译,所以为了区分编译(compile)和构建(build),再添加一个 build 任务,来完成所有文件的构建工作。

const extra = () => {
  return src(["public/**", "!public/index.html"], { base: "public" }).pipe(
    dest("dist")
  );
};

const compile = parallel(style, script, html, imageAndFont);

const build = parallel(compile, extra);

module.exports = { compile, build };

文件清除

因为每次构建时不会清除上一次的构建结果,而是覆盖同名文件的方式。这样在开发过程中会产生很多无用的垃圾文件。

这时可以在每次构建之前删除掉上次的构建结果。

借助 del 这个插件。

npm i -D del

然后添加 clean 任务。

const del = require("del");

const clean = () => {
  return del(["dist"]);
};

del 函数返回的是一个 Promise,所以不需要手动标记任务结束,它会自动完成。

在每次编译之前,清除掉上一次的编译结果,这是一个同步任务。

改造 build 任务。

const { src, dest, parallel, series } = require("gulp");
// ... other code
const build = series(clean, parallel(compile, extra));

这样就完成了文件清理。

自动加载插件

随着构建任务的复杂度增加,所依赖的构建插件也越来越多,不利于我们阅读和回顾代码。

这时可以使用 gulp-load-plugins 来解决 require 过多的问题。

npm i -D gulp-load-plugins

接下来使用 load plugins。

const loadPlugins = require("gulp-load-plugins");
const plugins = loadPlugins();

这样所有的 gulp 插件都会被挂载到 plugins 对象上面。

对象属性的命名规则是去掉插件的前缀(gulp-),后面的名字转换为驼峰命名法。

比如 gulp-html-replace,会被转换成 plugins.htmlReplace

开发服务器

在开发阶段,我们需要一个支持热更新的开发服务器。这样在我们修改代码后,实时的对源代码进行编译并自动刷新浏览器。

npm i -D browser-sync

接下来在代码中使用 browser sync。

const browserSync = require("browser-sync");

// browser sync 提供了 create 方法,用来创建一个服务器
const bs = browserSync.create();

// ... other code

// 创建一个新任务 用于启动服务器
const serve = () => {
  bs.init({
    notify: false, // 是否显示 启动时提示
    port: 1998, // 端口
    // open: false,// 是否自动在浏览器打开
    files: "dist/**", // 监听哪些文件发生了变化,自动刷新浏览器
    server: {
      baseDir: "dist", // 基础目录
    },
  });
};

这样就可以实现一个简单的开发服务器。

监视变化

使用开发服务器启动的服务,只能监视生产代码,即 dist 目录下的代码发生变化,而不能监听到源代码发生的变化。

修改源代码,让浏览器自动刷新的思路是:监听源代码的变化,并且在源代码发生变化时,重新执行对应的构建任务,编译后的文件会覆盖掉 dist 下对应的文件,从而触发 browser sync 的监听,刷新浏览器页面。

源代码的变化,可以使用 gulp 提供的 watch 方法来实现。

watch 的用法很简单,传入两个参数,第一个是监听的文件列表,第二个是文件变化后执行的任务。

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

// ... other code

const serve = () => {
  // 添加 监听
  watch("src/assets/styles/*.scss", style);
  watch("src/**/*.js?(x)", script);
  watch("public/index.html", html);
  watch(["src/assets/fonts/**", "src/assets/images/**"], imageAndFont);
  watch(["public/**", "!public/index.html"], extra);
  bs.init({
    notify: false,
    port: 1998,
    // open: false,
    files: "dist/**",
    server: {
      baseDir: "dist",
    },
  });
};

这样就实现了修改源代码浏览器自动刷新。

构建优化

接下来再做一些优化。

因为图片、字体和 public 下的静态资源都是不需要编译的,它们的任务都是一些压缩工作。

这在上线部署的时候有意义,但是在开发阶段会增加无用的开销,反而会影响热更新构建速度。

但是又不能不管这些文件,因为开发阶段我们也有可能会修改这些文件。

这时的优化是修改 browser sync 的配置。

const serve = () => {
  watch("src/assets/styles/*.scss", style);
  watch("src/**/*.js?(x)", script);
  watch("public/index.html", html);
  // 删除下面两个任务
  // watch(['src/assets/fonts/**', 'src/assets/images/**'], imageAndFont)
  // watch(['public/**', '!public/index.html'], extra)
  // 修改为监听文件 刷新浏览器,而不是执行构建任务
  watch(
    [
      "src/assets/fonts/**",
      "src/assets/images/**",
      "public/**",
      "!public/index.html",
    ],
    bs.reload
  );
  bs.init({
    notify: false,
    port: 1998,
    files: "dist/**",
    server: {
      // 在baseDir 中添加 src 和 public 目录
      // 这样在 dist 中找不到时回去 后面的目录中继续找
      baseDir: ["dist", "src", "public"],
    },
  });
};

完成服务器的修改后,还需要添加一个任务。

因为目前启动开发服务器之前,没有进行编译。

所以添加一个 develop 任务,这个任务是一个同步的组合任务,先去编译,再去启动服务器。

const develop = series(compile, serve);

还可以把 compile 任务下的 imageAndFont 任务也拿到 build 任务中。

const compile = parallel(style, script, html);

const build = series(clean, parallel(compile, extra, imageAndFont));

这样就做到了以最小代价支持开发阶段所有构建而又不影响上线部署前的构建。

browser sync reload 的另一种写法

除了 files: "dist/**"这种配置写法以外,还有另外一种写法更为常见,就是在一个构建任务最后一步添加一个 pipe,在其中传入 bs.reload。

拿 style 举例,script 和 html 任务同样可以这样改造,改造完后要记得去掉 files 配置。

const style = () => {
  return src("src/assets/styles/*.scss", { base: "src" })
    .pipe(plugins.sass({ outputStyle: "expanded" }))
    .pipe(dest("dist"))
    .pipe(bs.reload({ stream: true /** 以流的方式执行 */ }));
};

这两种写法作用上是等效的,但是第二种写法可以减少构建次数。

useref 文件引用处理

目前核心的构建任务都已经完成了,但还存在一些小问题。

在 index.html 中使用的 bootstrap.css 是通过开发服务器的路由来映射的,在开发阶段没有任何问题,但是在生产代码的构建后就会丢失这个样式。

这个情况我们可以手动拷贝对应的 css,但是这种做法并不优雅,可以使用更加优雅的方式,借助 useref 插件。

npm i -D gulp-useref

因为在 index.html 中的 css 引用具有注释,useref 会自动找到这些标记,将它们抽取出来,并生成对应的文件,再插入到 index.html 中。

这里有一个需要注意的问题,整个 link 标签或者 script 标签只能占据一行,有些自动格式化的工具会将过长的标签折换成多行。如果一个标签占用多行时会出现生成文件后无法重新写入新标签的问题。

<!-- build:css assets/styles/vendor.css -->
    <link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.min.css" />
<!-- endbuild -->

编写 useref 任务。

const useref = () => {
  return src("dist/*.html", { base: "dist" })
    .pipe(plugins.useref({ searchPath: ["dist", "."] }))
    .pipe(dest("dist"));
};

由于 useref 可以帮助我们抽取资源并自动转换引用地址,所以我们可以改造 index.html 来将 react、react-dom 和 react-bootstrap 打包成一个 vendor.js。

改造后的 index.html:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title><%= title %></title>
    <!-- build:css assets/styles/vendor.css -->
    <link
      rel="stylesheet"
      href="/node_modules/bootstrap/dist/css/bootstrap.min.css"
    />
    <!-- endbuild -->
    <!-- build:css -->
    <!-- endbuild -->
  </head>
  <body>
    <div id="app"></div>

    <!-- build:js assets/scripts/vendor.js-->
    <script src="/node_modules/react/umd/react.production.min.js"></script>
    <script src="/node_modules/react-dom/umd/react-dom.production.min.js"></script>
    <script src="/node_modules/react-bootstrap/dist/react-bootstrap.min.js"></script>
    <!-- endbuild -->

    <!-- build:js -->
    <!-- endbuild -->
  </body>
</html>

由于 useref 会将 node_modules 中的通用 js 文件都抽取出来,所以也就不需要在 index.jsx 中导入对应的包了,这样会重复引用。

// 删除掉这三行
// import React from "react";
// import ReactDOM from "react-dom";
// import { Button } from 'react-bootstrap';

文件压缩

对生成文件进行压缩。

主要是对 html、css 和 js 进行压缩。

安装三种文件对应的压缩插件。

npm i gulp-htmlmin gulp-uglify gulp-clean-css -D

对代码压缩的位置可以在 useref 中进行。

useref 在处理完 html 的文件流中会把 html、js 和 css 文件传递到下一个流。所以我们需要判断一下不同类型的文件,再进行不同的处理,这里需要借助 gulp-if 插件。

npm i -D gulp-if

修改 useref 任务。

const useref = () => {
  return src("dist/*.html", { base: "dist" })
    .pipe(plugins.useref({ searchPath: ["dist", "."] }))
    .pipe(plugins.if(/\.js$/, plugins.uglify()))
    .pipe(plugins.if(/\.css$/, plugins.cleanCss()))
    .pipe(
      plugins.if(
        /\.html$/,
        plugins.htmlmin({
          collapseWhitespace: true,
          minifyCSS: true /** 内联 css 压缩 */,
          minifyJS: true /** 内联 js 压缩 */,
        })
      )
    )
    .pipe(dest("release"));
};

最终写入 release 的原因是同时读写 dist 目录会出现冲突,必须写到另一个不受影响的目录。

但是这样,原来构建规划的目录结构就被打破了。

重新规划构建过程

在 useref 之前构建的产物,其实都是临时产物,并不是最终部署上线的生产代码,所以我们可以把它们提取到一个 temp 目录下。

由于改动量比较大,这里直接把代码贴出来。

const { src, dest, parallel, series, watch } = require("gulp");
const del = require("del");
const loadPlugins = require("gulp-load-plugins");
const browserSync = require("browser-sync");

const plugins = loadPlugins();
const bs = browserSync.create();

const clean = () => {
  return del(["dist", "temp"]);
};

const style = () => {
  return src("src/assets/styles/*.scss", { base: "src" })
    .pipe(plugins.sass({ outputStyle: "expanded" }))
    .pipe(dest("temp"))
    .pipe(bs.reload({ stream: true }));
};

const script = () => {
  return src("src/**/*.js?(x)", { base: "src" })
    .pipe(
      plugins.babel({ presets: ["@babel/preset-react", "@babel/preset-env"] })
    )
    .pipe(
      plugins.browserify({
        insertGlobals: true,
      })
    )
    .pipe(dest("temp"))
    .pipe(bs.reload({ stream: true }));
};

const html = () => {
  return src("public/index.html", { base: "public" })
    .pipe(plugins.ejs({ title: "lzq site" }))
    .pipe(
      plugins.htmlReplace(
        {
          js: {
            src: "./assets/scripts/",
            tpl: '<script src="%s%f.js"></script>',
          },
          css: {
            src: "./assets/styles/",
            tpl: '<link rel="stylesheet" href="%s%f.css">',
          },
        },
        { resolvePaths: true }
      )
    )
    .pipe(dest("temp"))
    .pipe(bs.reload({ stream: true }));
};

const imageAndFont = () => {
  return src(["src/assets/fonts/**", "src/assets/images/**"], {
    base: "src",
  })
    .pipe(plugins.imagemin())
    .pipe(dest("dist"));
};

const extra = () => {
  return src(["public/**", "!public/index.html"], { base: "public" }).pipe(
    dest("dist")
  );
};

const useref = () => {
  return src("temp/**", { base: "temp" })
    .pipe(plugins.useref({ searchPath: ["temp", "."] }))
    .pipe(plugins.if(/\.js$/, plugins.uglify()))
    .pipe(plugins.if(/\.css$/, plugins.cleanCss()))
    .pipe(
      plugins.if(
        /\.html$/,
        plugins.htmlmin({
          collapseWhitespace: true,
          minifyCSS: true /** 内联 css 压缩 */,
          minifyJS: true /** 内联 js 压缩 */,
        })
      )
    )
    .pipe(dest("dist"));
};

const serve = () => {
  watch("src/assets/styles/*.scss", style);
  watch("src/**/*.js?(x)", script);
  watch("public/index.html", html);

  watch(
    [
      "src/assets/fonts/**",
      "src/assets/images/**",
      "public/**",
      "!public/index.html",
    ],
    bs.reload
  );
  bs.init({
    notify: false,
    port: 1998,
    // files: 'dist/**',
    server: {
      baseDir: ["temp", "src", "public"],
      routes: {
        "/node_modules": "node_modules",
      },
    },
  });
};

const compile = parallel(style, script, html);

const build = series(
  clean,
  parallel(series(compile, useref), extra, imageAndFont)
);

const develop = series(compile, serve);

module.exports = { compile, build, clean, serve, develop, useref };

到这里,基本的构建流程就已经结束了。

规整任务

在 gulp 中有非常多的任务,如果全部导出去会非常复杂。因为很多任务并不会单独使用。

分析一下,真正需要导出去的任务只有 build, clean 和 develop。

// ... other code
module.exports = { build, clean, develop };

在 package.json 中把它们添加到 scripts 下。

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

这样就可以直接通过 npm run build 的方式启动构建任务了。

FIS

fis 最新版本是 3.x,在 16 年左右非常流行,但是目前已经停止更新维护了。

学习 fis 的目的不是为了使用它,而是为了了解它的设计思想。

相比较于 grunt 和 gulp,fis 属于另一类构建系统。fis 的特点是高度集成,将前端日常开发中经常用到的构建任务和调试任务都集成到了内部,非常容易上手。

如果你的需求不高,甚至可以不需要定义任务,而是直接使用 fis 内部的任务。

基本使用

首先安装 fis,fis 的最新模块叫做 fis3,因为这个版本和之前的版本有很大的变化,所以特意起名 fis3。

npm i -D fis3

创建 src 目录,并在该目录下创建 3 个文件用于测试。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>fis3 demo</title>
    <link rel="stylesheet" href="./style.scss" />
  </head>
  <body>
    hello,fis3!
    <script src="./app.js"></script>
  </body>
</html>

app.js

(() => {
  console.log("welcome fis3");
})();

style.scss

$bg: rgb(245, 219, 245);
$color: #982d2d;

body { background-color: bg;
  color:color; }

fis 最常用的一个命令就是 release,-d 的意思是指定输出目录。

npx fis3 release -d output

这样就会在项目的根目录下生成一个 output 文件夹,里面存放了所有的文件。

但是 es6 语法和 scss 并没有编译,唯一发生变化的就是 html。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>fis3 demo</title>
    <link rel="stylesheet" href="/src/style.scss" />
  </head>
  <body>
    hello,fis3!
    <script src="/src/app.js"></script>
  </body>
</html>

里面的路径全部会被转换为绝对路径,这就是资源定位问题。

在之前的项目开发中,前端项目开发时所使用的相对路径在上线部署时可能并不适用。这个情况下很多后端程序员需要手动去修改路径。

fis 首要解决的问题是资源定位。

如果需要在部署时调整目录结构,可以通过 fis 的配置文件来实现。

fis 的配置文件默认是在项目根目录下的 fis-conf.js。

fis-conf.js 默认会有一个 fis 对象,可以使用它的 match 方法匹配文件,并在 match 函数的第二个参数中配置 release 的目录结构,其中$0 代表文件的原始路径。

fis.match("*.{js,scss}", {
  release: "/assets/$0",
});

这之后再进行编译,就会多出一层 assets 的文件夹包裹 js 文件和 scss 文件。

编译与压缩

在构建过程如果要进行编译操作,也需要在 fis 的配置文件中进行配置。

编译 scss

编译 scss 文件需要 fis-parser-node-sass。

npm i -D fis-parser-node-sass

编写 scss 的编译配置。

// ... other code

fis.match("**/*.scss", {
  // 使用 fis-parser-node-sass 插件,省略「fis-parser-」
  parser: fis.plugin("node-sass"),
  // 重命名后缀名
  rExt: "css",
});
编译 ES6 语法

fis 官方提供了一个 fis-parser-babel-6.x 的插件,因为 fis 已经停止维护了,所以这个插件所使用的 babel 版本还是 6.x。

npm i -D fis-parser-babel-6.x

编写 js 的编译配置。

// ... other code

fis.match("**/*.js", {
  parser: fis.plugin("babel-6.x"),
});
代码压缩

压缩文件在配置对象中添加一个 optimizer 属性,来指定使用什么插件进行压缩。

fis 内部提供了代码压缩的插件,css 的是 clean-css,js 的是 uglify-js。这两个插件都是集成在 fis3 内部的,不需要单独安装。

fis.match("*.{js,scss}", {
  release: "/assets/$0",
});

fis.match("**/*.scss", {
  // 使用 fis-parser-node-sass 插件,省略「fis-parser-」
  parser: fis.plugin("node-sass"),
  // 重命名后缀名
  rExt: "css",
  optimizer: fis.plugin("clean-css"),
});

fis.match("**/*.js", {
  parser: fis.plugin("babel-6.x"),
  optimizer: fis.plugin("uglify-js"),
});
查看编译信息

使用 fis3 inspect 命令可以查看编译过程中会编译哪些文件,使用哪些配置。

npx fis3 inspect

写在最后

自动化构建是前端工程化的第二块内容,到此讲解结束,接下来会发布另外几篇关于前端工程化的文章,敬请期待。

本文使用 mdnice 排版