基于 Webpack 和 ES6 打造 JavaScript 类库

[译] 基于 Webpack 和 ES6 打造 JavaScript 类库

Two months ago I published a starter pack for React based on webpack. Today I found out that I need almost the same thing but without the React bit. This simplifies the setup but there are still some tricky parts. So, I made a brand new repository webpack-library-starter and placed all the stuff that we need for creating a JavaScript library.

两个月前,我曾发布了一篇基于 webpack 的 React 起步教程。你眼前的这篇文章跟那一篇差不多,只不过不包含 React 那一块。这篇教程稍微简单一些,但仍然会有一些棘手的部分。因此,我特意建了一个全新的代码仓库 webpack-library-starter,把创建一个 JavaScript 类库所需的所有素材都放了进去。

First of all, what I meant by saying “library”

首先,我们说的 “类库” 是指什么

My definition for library in the context of JavaScript is a piece of code that provides specific functionality. It does one thing and it is doing it well. In the ideal case should not depend on another library or framework. A good example for library is jQuery. React and Vue.js could be also considered a library.

在 JavaScript 语境中,我对类库的定义是 “提供了特定功能的一段代段”。一个类库只做一件事,并且把这件事做好。在理想情况下,它不依赖其它类库或框架。jQuery 就是一个很好的例子。React 或者 Vue.js 也可以认为是一个类库。

The library should:

一个类库应该:

  • Be available for in-browser use. Understand including the library via <script> tag.
  • Be accessible through npm
  • Be compatible with ES6(ES2015) module system, commonjs and amd specifications.
  • 可以在浏览器环境下使用。也就是说,可以通过 <script> 标签来引入这个类库。
  • 可以通过 npm 来安装。
  • 兼容 ES6(ES2015) 的模块系统、CommonJS 和 AMD 模块规范。

It doesn’t matter what is used for developing the library. What is important is the file that is distributed. It should match the above requirements. I prefer to see libraries written in vanilla JavaScript though. It simply makes the contribution easier.

用什么来开发这个类库并不重要,重要的是我们最终产出的文件。它只要满足上述要求就行。尽管如此,我还是比较喜欢用原生 JavaScript 写成的类库,因为这样更方便其它人贡献代码。

Directory structure

目录结构

I choose the following directory structure:

我一般选择如下的目录结构:

+-- lib
|   +-- library.js
|   +-- library.min.js
+-- src
|   +-- index.js
+-- test

Where src contains the source files and lib the final compiled version. This means that the entry point of the library is the file under lib and not src.

其中 src 目录用于存放源码文件,而 lib 目录用于存放最终编译的结果。这意味着类库的入口文件应该放在 lib 目录下,而不是 src 目录下。

The starter

起步动作

I really enjoy the new ES6 specification. The bad thing is that there is some significant tooling around it. Some day we’ll probably write such JavaScript without the need of transpiler but today that’s not the case. Usually we need some sort of Babel integration. Babel can convert our ES6 files to ES5 format but it is not meant to create bundles. Or in other words, if we have the following files:

我确实很喜欢最新的 ES6 规范。但坏消息是它身上绑了一堆的附加工序。也许将来某一天我们可以摆脱转译过程,所写即所得;但现在还不行。通常我们需要用到 Babel 来完成转译这件事。Babel 可以把我们的 ES6 文件转换为 ES5 格式,但它并不打算处理打包事宜。或者换句话说,如果我们有以下文件:

+-- lib
+-- src
    +-- index.js (es6)
    +-- helpers.js (es6)

And we apply Babel we’ll get:

然后我们用上 Babel,那我们将会得到:

+-- lib
|   +-- index.js (es5)
|   +-- helpers.js (es5)
+-- src
    +-- index.js (es6)
    +-- helpers.js (es6)

Or in other words Babel do not resolve the imports/requires. So we need a bundler and as you may guess my choice for that is webpack. What I want to achieve at the end is:

或者再换句话说,Babel 并不解析代码中的 import 或 require 指令。因此,我们需要一个打包工具,而你应该已经猜到了,我的选择正是 webpack。最终我想达到的效果是这样的:

+-- lib
|   +-- library.js (es5)
|   +-- library.min.js (es5)
+-- src
    +-- index.js (es6)
    +-- helpers.js (es6)

npm commands

npm 命令

npm provides a nice mechanism for running tasks – scripts. There should be at least three of those registered:

在运行任务方面,npm 提供了一套不错的机制——scripts(脚本)。我们至少需要注册以下三个脚本:

"scripts": {
  "build": "...",
  "dev": "...",
  "test": "..."
}
  • npm run build – this should produce a final minified version of our library
  • npm run dev – the same as build but do not minify the result and keeps working in a watching mode
  • npm run test – runs the tests
  • npm run build – 这个脚本用来生成这个类库的最终压缩版文件。
  • npm run dev – 跟 build 类似,但它并不压缩代码;此外还需要启动一个监视进程。
  • npm run test – 用来运行测试。

Building the development version

构建开发版本

npm run dev should fire webpack and should produce lib/library.js file. We start from the webpack’s configuration file:

npm run dev 需要调用 webpack 并生成 lib/library.js 文件。我们从 webpack 的配置文件开始着手:

// webpack.config.js 
var webpack = require('webpack');
var path = require('path');
var libraryName = 'library';
var outputFile = libraryName + '.js';

var config = {
  entry: __dirname + '/src/index.js',
  devtool: 'source-map',
  output: {
    path: __dirname + '/lib',
    filename: outputFile,
    library: libraryName,
    libraryTarget: 'umd',
    umdNamedDefine: true
  },
  module: {
    loaders: [
      {
        test: /(\.jsx|\.js)$/,
        loader: 'babel',
        exclude: /(node_modules|bower_components)/
      },
      {
        test: /(\.jsx|\.js)$/,
        loader: "eslint-loader",
        exclude: /node_modules/
      }
    ]
  },
  resolve: {
    root: path.resolve('./src'),
    extensions: ['', '.js']
  }
};

module.exports = config;

Even if you don’t have experience with webpack you may say what is this config file doing. We define the input (entry) and the output (output) of the compilation. The module property says what should be applied against every file during processing. In our case this is babel and ESLint where ESLint is a used for checking the syntax and correctness of our code.

即使你还没有使用 webpack 的经验,你或许也可以看明白这个配置文件做了些什么。我们定义了这个编译过程的输入(entry)和输出(output)。那个 module 属性指定了每个文件在处理过程中将被哪些模块处理。在我们的这个例子中,需要用到 Babel 和 ESLint,其中 ESLint 用来校验代码的语法和正确性。

There is one tricky part where I spent couple of ours. It’s related to librarylibraryTarget and umdNamedDefine properties. First I tried without using them and the output of the library was something like this:

这里有一个坑,花了我不少的时间。这个坑是关于 librarylibraryTarget 和 umdNamedDefine 属性的。最开始我没有把它们写到配置中,结果编译结果就成了下面这个样子:

(function(modules) {
  var installedModules = {};

  function __webpack_require__(moduleId) {
    if(installedModules[moduleId]) return installedModules[moduleId].exports;

    var module = installedModules[moduleId] = {
      exports: {},
      id: moduleId,
      loaded: false
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.loaded = true;
    return module.exports;
  }

  __webpack_require__.m = modules;
  __webpack_require__.c = installedModules;
  __webpack_require__.p = "";

  return __webpack_require__(0);
})([
  function(module, exports) {
    // ... my code here
  }
]);

This is how every webpack compiled code looks like. It uses similar approach like browserify. There is a self-invoking function which receives all the modules used in our application. Every of them stays behind an index of the modules array. In the code above we have only one and __webpack_require__(0) effectively runs the code in our src/index.js file.

经过 webpack 编译之后的文件差不多都是这个样子。它采用的方式跟 Browserify 很类似。编译结果是一个自调用的函数,它会接收应用程序中所用到的所有模块。每个模块都被存放到到 modules 数组中。上面这段代码只包含了一个模块,而 __webpack_require__(0) 实际上相当于运行 src/index.js 文件中的代码。

Having a bundle like this one do not fulfill all the requirements mentioned in the beginning of this article because we do not export anything. The file is meant to be dropped in a web page. However, adding librarylibraryTarget and umdNamedDefine makes webpack injecting a really nice snippet at the top:

光是得到这样一个打包文件,并没有满足我们在文章开头所提到的所有需求,因为我们还没有导出任何东西。这个文件的运行结果在网页中必定会被丢弃。不过,如果我们加上 librarylibraryTarget 和 umdNamedDefine,就可以让 webpack 在文件顶部注入一小段非常漂亮的代码片断:

(function webpackUniversalModuleDefinition(root, factory) {
  if(typeof exports === 'object' && typeof module === 'object')
    module.exports = factory();
  else if(typeof define === 'function' && define.amd)
    define("library", [], factory);
  else if(typeof exports === 'object')
    exports["library"] = factory();
  else
    root["library"] = factory();
})(this, function() {
return (function(modules) {
 ...
 ...

Setting libraryTarget to umd means using universal module definition for the final result. And indeed, this piece of code recognizes the environment and provides a proper bootstrapping mechanism for our library.

把 libraryTarget 设定为 umd 表示采用 通用模块定义 来生成最终结果。而且这段代码确实可以识别不同的运行环境,并为我们的类库提供一个妥当的初始化机制。

Building production version

构建生产环境所需的版本

The only one difference between development and production mode for webpack is the minification. Running npm run build should produce a minified version – library.min.js. webpack has a nice build-in plugin for that:

对 webpack 来说,开发阶段与生产阶段之间唯一的区别在于压缩。运行 npm run build 应该生成一个压缩版——library.min.js。webpack 有一个不错的内置插件可以做到这一点:

// webpack.config.js 
...
var UglifyJsPlugin = webpack.optimize.UglifyJsPlugin;
var env = process.env.WEBPACK_ENV;

var libraryName = 'library';
var plugins = [], outputFile;

if (env === 'build') {
  plugins.push(new UglifyJsPlugin({ minimize: true }));
  outputFile = libraryName + '.min.js';
} else {
  outputFile = libraryName + '.js';
}

var config = {
  entry: __dirname + '/src/index.js',
  devtool: 'source-map',
  output: { ... },
  module: { ... },
  resolve: { ... },
  plugins: plugins
};

module.exports = config;

UglifyJsPlugin does the job if we add it to the plugins array. There is something else that we have to clarify. We need some conditional logic where we instruct webpack what kind of bundle to produce (production or development). One of the popular approaches is to define an environment variable and pass it from the command line. For example:

只要我们把 UglifyJsPlugin 加入到 plugins 数组中,它就可以完成这个任务。此外,还一些事情有待明确。我们还需要某种条件判断逻辑,来告诉 webpack 需要生成哪一种类型(“开发阶段” 还是 “生产阶段”)的打包文件。一个常见的做法是定义一个环境变量,并将它通过命令行传进去。比如这样:

// package.json 
"scripts": {
  "build": "WEBPACK_ENV=build webpack",
  "dev": "WEBPACK_ENV=dev webpack --progress --colors --watch"
}

(Notice the --watch option. It makes webpack continuously running and watching for changes)

(请留意 --watch 选项。它会让 webpack 监视文件变化并持续运行构建任务。)

Testing

测试

I’m usually using Mocha and Chai for testing and that’s what I added in the starter. Again there was a tricky part making Mocha understands ES6 files but thankfully to Babel the problem was resolved.

我通常采用 Mocha 和 Chai 来运行测试——测试环节是这篇起步教程特有的内容。这里同样存在一个棘手的问题,就是如何让 Mocha 正确识别用 ES6 写的测试文件。不过谢天谢地,Babel 再次解决了这个问题。

// package.json
"scripts": {
  ...
  "test": "mocha --compilers js:babel-core/register --colors -w ./test/*.spec.js"
}

The important bit is the --compilers option. It allows us to process the incoming file before running it.

这里最关键的部分在于 --compilers 这个选项。它允许我们在运行测试文件之前预先处理这个文件。

A few other configuration files

其它配置文件

Babel received some major changes in the newest version 6. We now have something called presets where we describe what kind of transformation we want. One of the easiest ways to configure that is with a .babelrc file:

在最新的 6.x 版本中,Babel 发生了一些重大的变化。现在,在指定哪些代码转换器将被启用时,我们需要面对一种叫作 presets 的东西。最简单配置的方法就是写一个 .babelrc 文件:

// .babelrc
{
  "presets": ["es2015"],
  "plugins": ["babel-plugin-add-module-exports"]
}

ESLint provides the same thing and we have .eslintrc:

ESLint 也需要一个类似的配置文件,叫作 .eslintrc

// .eslintrc
{
  "ecmaFeatures": {
    "globalReturn": true,
    "jsx": true,
    "modules": true
  },
  "env": {
    "browser": true,
    "es6": true,
    "node": true
  },
  "globals": {
    "document": false,
    "escape": false,
    "navigator": false,
    "unescape": false,
    "window": false,
    "describe": true,
    "before": true,
    "it": true,
    "expect": true,
    "sinon": true
  },
  "parser": "babel-eslint",
  "plugins": [],
  "rules": {
    // ... lots of lots of rules here
  }
}

Links

相关链接

The starter is available in GitHub here github.com/krasimir/webpack-library-starter.

这篇起步教程还可以在 GitHub 上找到:github.com/krasimir/webpack-library-starter

Used tools:

用到的项目如下:

Dependencies:

具体依赖如下:

// package.json
"devDependencies": {
  "babel": "6.3.13",
  "babel-core": "6.1.18",
  "babel-eslint": "4.1.3",
  "babel-loader": "6.1.0",
  "babel-plugin-add-module-exports": "0.1.2",
  "babel-preset-es2015": "6.3.13",
  "chai": "3.4.1",
  "eslint": "1.7.2",
  "eslint-loader": "1.1.0",
  "mocha": "2.3.4",
  "webpack": "1.12.9"
}

译者后记

是不是意犹未尽?其实准确来说,这篇文章是作者对 webpack-library-starter 项目的一个简要解说,讲解了代码之外的背景知识。

因此,作为学习者,光读文章是远远不够的,我们真正需要的是研读这个项目提供的源码,并且动手实际操作和演练,如此方能掌握要领。加油!

发表评论