Python3 已经发布了九年了,Python 社区却还在用 Python 2.7;而 JavaScript 社区正好相反,大家都已经开始把还没有实现的语言特性用到生产环境中了 (´_ゝ `)

虽然这种奇妙情况的形成与 JavaScript 自身早期的设计缺陷以及浏览器平台的特殊性质都有关系,但也确实能够体现出 JavaScript 社区的技术栈迭代是有多么屌快。如果你昏迷个一年半载再去看前端圈,可能社区的主流技术栈已经变得它妈都不认识了(如果你没什么实感,可以看看《在 2016 年学习 JavaScript 是一种怎样的体验》这篇文章,你会感受到的,你会的)。

JavaScript 模块化现状

随着 JavaScript 越来越广泛的应用,朝着单页应用(SPA)方向发展的网页与代码量的愈发庞大,社区需要一种更好的代码组织形式,这就是模块化:将你的一大坨代码分装为多个不同的模块。

但是在 ES6 标准出台之前,由于标准的缺失(连 CSS 都有 @import,JavaScript 却连个毛线都没),这几年里 JavaScript 社区里冒出了各种各样的模块化解决方案(群魔乱舞),懵到一种极致。主要的几种模块化方案举例如下:

CommonJS

主要用于服务端,模块同步加载(也因此不适合在浏览器中运行,不过也有 Browserify 之类的转换工具),Node.js 的模块化实现就是基于 CommonJS 规范的,通常用法像这样:

// index.js
const {bullshit} = require('./bullshit');
console.log(bullshit());

// bullshit.js
function someBullshit() {
  return "hafu hafu";
}

modules.export = {
  bullshit: someBullshit
};

而且 require() 是动态加载模块的,完全就是模块中 modules.export 变量的传送门,这也就意味着更好的灵活性(按条件加载模块,参数可为表达式 etc.)。

AMD

即异步模块定义(Asynchronous Module Definition),不是那个日常翻身的农企啦

主要用于浏览器端,模块异步加载(还是用的回调函数),可以给模块注入依赖、动态加载代码块等。具体实现有 RequireJS,代码大概长这样:

// index.js
require(['bullshit'], words => {
  console.log(words.bullshit());
});

// bullshit.js
define('bullshit', ['dep1', 'dep2'], (dep1, dep2) => {
  function someBullshit() {
    return "hafu hafu";
  }

  return { bullshit: someBullshit };
});

可惜不能在 Node.js 中直接使用,而且模块定义与加载也比较冗长。

ES6 Module🚀

在 ES6 模块标准出来之前,主要的模块化方案就是上述 CommonJS 和 AMD 两种了,一种用于服务器,一种用于浏览器。其他的规范还有:

  • 最古老的 IIFE(立即执行函数);
  • CMD(Common Module Definition,和 AMD 挺像的,可以参考:与 RequireJS 的异同);
  • UMD(Universal Module Definition,兼容 AMD 和 CommonJS 的语法糖规范);

等等,这里就按下不表。

ES6 的模块化代码大概长这样:

// index.js
import {bullshit} from './bullshit';
console.log(bullshit());

// bullshit.js
function someBullshit() {
  return "hafu hafu";
}

export {
  someBullshit as bullshit
};

那我们为啥应该使用 ES6 的模块化规范呢?

  • 这是 ECMAScript 官方标准(嗯);
  • 语义化的语法,清晰明了,同时支持服务器端和浏览器;
  • 静态 / 编译时加载(与上面俩规范的动态 / 运行时加载不同),可以做静态优化(比如下面提到的 tree-shaking),加载效率高(不过相应地灵活性也降低了,期待 import() 也成为规范);
  • 输出的是值的引用,可动态修改;

嗯,你说的都对,那我tm到底要怎样才能在生产环境中用上 ES6 的模块化特性呢?

很遗憾,你永远无法控制用户的浏览器版本,可能要等上一万年,你才能直接在生产环境中写 ES6 而不用提心吊胆地担心兼容性问题。因此,你还是需要各种各样杂七杂八的工具来转换你的代码:Babel、Webpack、Browserify、Gulp、Rollup.js、System.js ……

噢,我可去你妈的吧,这些东西都tm是干嘛的?我就是想用个模块化,我到底该用啥子?

我可去你妈的吧

本文正旨在列出几种可用的在生产环境中放心使用 ES6 模块化的方法,希望能帮到诸位后来者(这方面的中文资源实在是忒少了)。

问题分析

想要开心地写 ES6 的模块化代码,首先你需要一个转译器(Transpiler)来把你的 ES6 代码转换成大部分浏览器都支持的 ES5 代码。这里我们就选用最多人用的 Babel(我不久之前才知道原来 Babel 就是巴别塔里的「巴别」……)。

用了 Babel 后,我们的 ES6 模块化代码会被转换为 ES5 + CommonJS 模块规范的代码,这倒也没什么,毕竟我们写的还是 ES6 的模块,至于编译生成的结果,管它是个什么屌东西呢(笑)

所以我们需要另外一个打包工具来将我们的模块依赖给打包成一个 bundle 文件。目前来说,依赖打包应该是最好的方法了。不然,你也可以等上一万年,等你的用户把浏览器升级到全部支持 HTTP/2(支持连接复用后模块不打包反而比较好)以及 <script type="module" src="fuck.js"> 定义 ( ゚∀。)

所以我们整个工具链应该是这样的:

处理流程

而目前来看,主要可用的模块打包工具有这么几个:

  • Browserify
  • Webpack
  • Rollup.js

本来我还想讲一下 FIS3 的,结果去看了一下,人家竟然还没原生的支持 ES6 Modules,而且 fis3-hook-commonjs 插件也几万年没更新了,所以还是算了吧。至于 SystemJS 这类动态模块加载器本文也不会涉及,就像我上面说的一样,在目前这个时间点上还是先用模块打包工具比较好。

下面分别介绍这几个工具以及如何使用它们配合 Babel 实现 ES6 模块转译。

Browserify

Browserify 这个工具也是有些年头了,它通过打包所有的依赖来让你能够在浏览器中使用 CommonJS 的语法来 require('modules'),这样你就可以像在 Node.js 中一样在浏览器中使用 npm 包了,可以爽到。

既然 Babel 会把我们的 ES6 Modules 语法转换成 ES5 + CommonJS 规范的模块语法,那我们就可以直接用 Browserify 来解析 Babel 的转译生成物,然后把所有的依赖给打包成一个文件,岂不是美滋滋。

不过除了 Babel 和 Browserify 这俩工具外,我们还需要一个叫做 babelify 的东西……好吧好吧,这是最后一个了,真的。

那么,babelify 是拿来干嘛的呢?因为 Browserify 只看得懂 CommonJS 的模块代码,所以我们得把 ES6 模块代码转换成 CommonJS 规范的,再拿给 Browserify 去看:这一步就是 Babel 要干的事情了。但是 Browserify 人家是个模块打包工具啊,它是要去分析 AST(抽象语法树),把那些 reuqire() 的依赖文件给找出来再帮你打包的,你总不能把所有的源文件都给 Babel 转译了再交给 Browserify 吧?那太蠢了,我的朋友。

babelify (Browserify transform for Babel) 要做的事情,就是在所有 ES6 文件拿给 Browserify 看之前,先把它用 Babel 给转译一下(browserify().transform),这样 Browserify 就可以直接看得懂并打包依赖,避免了要用 Babel 先转译一万个文件的尴尬局面。

好吧,那我们要怎样把这些工具捣鼓成一个完整的工具链呢?下面就是喜闻乐见的依赖包安装环节:

# 我用的 yarn,你用 npm 也差不多
# gulp 也可以全局安装,方便一点
# babel-preset 记得选适合自己的
# 最后那俩是用来配合 gulp stream 的
$ yarn add --dev babel-cli babel-preset-env babelify browserify gulp vinyl-buffer vinyl-source-stream

这里我们用 Gulp 作为任务管理工具来实现自动化(什么,都 7012 年了你还不知道 Gulp?那为什么不去问问神奇海螺呢?),gulpfile.js 内容如下:

var gulp       = require('gulp'),
    browserify = require('browserify'),
    babelify   = require('babelify'),
    source     = require('vinyl-source-stream'),
    buffer     = require('vinyl-buffer');

gulp.task('build', function () {
    return browserify(['./src/index.js'])
        .transform(babelify)
        .bundle()
        .pipe(source('bundle.js'))
        .pipe(gulp.dest('dist'))
        .pipe(buffer());
});

相信诸位都能看得懂吧,browserify() 第一个参数是入口文件,可以是数组或者其他乱七八糟的,具体参数说明请自行参照 Browserify 文档。而且记得在根目录下创建 .babelrc 文件指定转译的 preset,或者在 gulpfile.js 中配置也可以,这里就不再赘述。

最后运行 gulp build,就可以生成能直接在浏览器中运行的打包文件了。

➜  browserify $ gulp build
[12:12:01] Using gulpfile E:\wwwroot\es6-module-test\browserify\gulpfile.js
[12:12:01] Starting 'build'...
[12:12:01] Finished 'build' after 720 ms

Browserify Result

Rollup.js

我记得这玩意最开始出来的时候号称为「下一代的模块打包工具」,并且自带了可大大减小打包体积的 tree-shaking 技术(DCE 无用代码移除的一种,运用了 ES6 静态分析语法树的特性,只打包那些用到了的代码),在当时很新鲜。

Rollup.js

但是现在 Webpack2+ 已经支持了 Tree Shaking 的情况下,我们又有什么特别的理由去使用 Rollup.js 呢?不过毕竟也是一种可行的方法,这里也提一提:

# 我也不知道为啥 Rollup.js 要依赖这个 external-helpers
$ yarn add --dev rollup rollup-plugin-babel babel-preset-env babel-plugin-external-helpers

然后修改根目录下的 rollup.config.js

import babel from 'rollup-plugin-babel';

export default {
  entry: 'src/index.js',
  format: 'esm',
  plugins: [
    babel({
      exclude: 'node_modules/**'
    })
  ],
  dest: 'dist/bundle.js'
};

还要修改 .babelrc 文件,把 Babel 转换 ES6 模块到 CommonJS 模块的转换给关掉,不然会导致 Rollup.js 处理不来:

{
  "presets": [
    ["env", {
      "modules": false
    }]
  ],
  "plugins": [
    "external-helpers"
  ]
}

然后在根目录下运行 rollup -c 即可打包依赖,也可以配合 Gulp 来使用,官方文档里就有,这里就不赘述了。可以看到,Tree Shaking 的效果还是很显著的,经测试,未使用的代码确实不会被打包进去,比起上面几个工具生成的结果要清爽多了:

Rollup.js Result

Webpack

对,Webpack,就是那个丧心病狂想要把啥玩意都给模块化的模块打包工具。既然人家已经到了 3.0.0 版本了,所以下面的都是基于 Webpack3 的。什么?现在还有搞前端的不知道 Webpack?神奇海螺以下略。

Webpack

喜闻乐见的依赖安装环节:

# webpack 也可以全局安装,方便一些
$ yarn add --dev babel-loader babel-core babel-preset-env webpack

然后配置 webpack.config.js

var path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['env']
          }
        }
      }
    ]
  }
};

差不多就是这么个配置,babel-loader 的其他 options 请参照文档,而且这个配置文件的括号嵌套也是说不出话,ZTMJLWC。

然后运行 webpack

➜  webpack $ webpack
Hash: 5c326572cf1440dbdf64
Version: webpack 3.0.0
Time: 1194ms
    Asset     Size  Chunks             Chunk Names
bundle.js  2.86 kB       0  [emitted]  main
   [0] ./src/index.js 106 bytes {0} [built]
   [1] ./src/bullshit.js 178 bytes {0} [built]

情况呢就是这么个情况:

Webpack Result

Tips: 关于 Webpack 的 Tree Shaking

Webpack 现在是自带 Tree-Shaking 的,不过需要你把 Babel 默认的转换 ES6 模块至 CommonJS 格式给关掉,就像上面 Rollup.js 那样在 .babelrc 中添加个 "modules": false。原因的话上面也提到过,tree-shaking 是基于 ES6 模块的静态语法分析的,如果交给 Webpack 的是已经被 Babel 转换成 CommonJS 的代码的话那就没戏了。

而且 Webpack 自带的 tree-shaking 只是把没用到的模块从 export 中去掉而已,之后还要再接一个 UglifyJS 之类的工具把冗余代码干掉才能达到 Rollup.js 那样的效果。

Webpack 也可以配合 Gulp 工作流让开发更嗨皮,有兴趣的可自行研究。目前来看,这三种方案中,我本人更倾向于使用 Webpack,不知道诸君会选用什么呢?

写在后面

前几天我在捣鼓 blessing-skin-server 那坨 shi 一样 JavaScript 代码的模块化的时候,打算试着使用一下 ES6 标准中的模块化方案,并找了 Google 大老师问 ES6 模块转译打包相关的资源,找了半天,几乎没有什么像样的中文资源。全是讲 ES6 模块是啥、有多好、为什么要用之类的,没几个是讲到底该怎么在生产环境中使用的(也有可能是我搜索姿势不对),说不出话。遂撰此文,希望能帮到后来人。

且本人水平有限,如果文中有什么错误,欢迎在下方评论区批评指出。

参考链接