cssmagic/blog

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

cssmagic opened this issue · 32 comments

[译] 基于 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) 的模块系统、CommonJSAMD 模块规范。

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 并不解析代码中的 importrequire 指令。因此,我们需要一个打包工具,而你应该已经猜到了,我的选择正是 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 library, libraryTarget and umdNamedDefine properties. First I tried without using them and the output of the library was something like this:

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

(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 library, libraryTarget and umdNamedDefine makes webpack injecting a really nice snippet at the top:

光是得到这样一个打包文件,并没有满足我们在文章开头所提到的所有需求,因为我们还没有导出任何东西。这个文件的运行结果在网页中必定会被丢弃。不过,如果我们加上 librarylibraryTargetumdNamedDefine,就可以让 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.

我通常采用 MochaChai 来运行测试——测试环节是这篇起步教程特有的内容。这里同样存在一个棘手的问题,就是如何让 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 项目的一个简要解说,讲解了代码之外的背景知识。

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


本文在 “CSS魔法” 微信公众号首发,扫码立即订阅:

weixin-qrcode


© Creative Commons BY-NC-ND 4.0   |   我要订阅   |   我要打赏

(占楼备用)

xCss commented

(@ο@) 哇~
赞一个~
第一次这么前

学习了,赞!

hax commented

“library”译为“库”或“代码库”可能比“类库”更准确些,因为传统上js库可能并不以“类”的形式导出。

@hax
“类库” 叫习惯了。如果是以命名空间的方式导出,理解为静态类即可 😉

👍

Nice

Good

icepy commented

试了一下,我发现最终出来的结果虽然是umd的,但是比如在浏览器上访问的时候,本来预期Wind应该是一个类,但是Wind.default才是,不知是否是要设置什么属性么?

@icepy
用 Babel 转 es6 的模块语法的话,就是这个结果吧

icepy commented

@leozdgao 不应该啊,不然那用ES6来写就没意义了,API或者接口不统一啊,每一个都要添加一个default。不知道翻译作者,试没试呢 @cssmagic

这里我没表示清楚,不是用ES6来写没有意义,而是采用ES6的export和default来导出模块,转译的结果没有直接表示出我的意图。

icepy commented

@leozdgao 我早就试过了,跟我预期的结果不一样。

let Wind = function(){
    alert(123)
}

export default Wind

在浏览器中预期的结果是

期望的结果:

window.Wind() // alert 123

实际的结果:

window.Wind()  //Uncaught TypeError: Wine is not a function

在控制台中,看了下Wind,得到的结果:

{
    default:function Wind(){}
}

所以访问时需要:window.Wind.default() // alert 123 成功。

所以我才觉得这样不应该,想来询问是否是少了某个属性?webpack 打包的

@icepy

好吧,我应该明白你的意思了,之前是我会错意了。

由于之前遇到过类似的坑,不清楚有没有改变 Babel 编译结果的可能,我目前统一使用 commonjs 的模块导出语法。由于我清楚 import ... from ... 的编译结果,所以有的时候为了方便我模块导入语法用的时 ES6 的语法。

@icepy
赞同 @leozdgao 的回答。我展开聊一下,如有错漏,也请大家指正。

主要问题在于 ES6 和 CommonJS 的模块特性并不是完全对应的,在相互转换的过程中难免出现一些信息丢失或错位。如果要给浏览器用,则需要经历 ES6 → CommonJS → UMD 这样的转换和包装过程,你在 ES6 源码中的意图无法准确传达到浏览器环境。

不应该啊,不然那用ES6来写就没意义了,API或者接口不统一啊,每一个都要添加一个default

你可能是用 ES6 的默认导出方式(export default class MyClass {})来导出这个类的,Babel 会把 MyClass 这个默认接口转换为 CommonJS 模块的 exports 上的 default 属性,然后整个 exports 会并被 UMD 赋给 window 的一个属性(你给这个属性起的名字是 Wind)。

如果讨厌 default 这个名字,可以采用 ES6 的具名导出方式(export class MyClass {})。此时 MyClass 会被 Babel 转换为 CommonJS 的 exports.MyClass,然后 exports 被 UMD 赋给 window.Wind。在浏览器中实际使用时,需要用 Wind.MyClass 来调用你写的类。

如果觉得后者还是不理想,则只有用 CommonJS 模块来导出你的类(module.exports = class MyClass {}),这样 UMD 会把 MyClass 用你指定的名字(比如 Wind)直接挂到 window 上。此时在浏览器中,全局的 Wind 就是你写的类了。

在最后一种方法中,虽然你写的是 CommonJS 模块,但你在这个模块内仍然可以使用 ES6 语法(包括 class 等)。只不过在这种情况下 Babel 只需要编译 ES6 语法,不需要转换模块格式。

@icepy
……想来询问是否是少了某个属性?webpack 打包的

你看一下 Babel 对 ES6 模块的转译结果,以及 UMD 对 CommonJS 模块的包装原理,应该就明白了。

icepy commented

@cssmagic 是换成了CommonJS的语法,没用export和default,才和我预期的结果一致,我以为Babel有什么属性可以设置,直接转译成module.exports = Wind,然后UMD包装成root['Wind'] = factory(),这个factory中return 的就是从__webpack包装里取出来的Wind,谢谢啦。

icepy commented

@leozdgao 我和你的思路一样,也是改成了commonjs的语法。

@icepy 前段时间刚在微博上讨论过 babel 从 ES6 module 转到 CommonJS 模块的话题,在这里刚好复制一下,可以解答你的疑问

  1. Babel 的这个特性是 by design 的,理由可以看这篇文章。大概就是说 babel 5- 的行为(模块中有且仅有 export default 语句时把它转换为 module.exports =)与其他情况下的表现不具有一致性、如果模块后续增加了具名导出的话容易在开发者未察觉的情况下破坏接口一致性,等等
  2. 官方 issue 的链接是这个
  3. 如果非得要用的话可以引入插件 babel-plugin-add-module-exports

@sodatea
👍 谢谢分享。我昨天下午就在找有没有插件可以实现,一时没找到。原来真有。

babel-plugin-add-module-exports 好物 👍

@sodatea 感谢分享 👍

icepy commented

@sodatea 感谢分享 ^_^

simaQ commented

🌹 感谢~

找了好久,终于看到这么清晰明了的配置了

现在我有一个依赖 jQuery 的库:

import $ from 'jquery'

...

我在作者原文下找到 krasimir/webpack-library-starter#3 ,所以我这样写了 externals:

externals: {
    'jquery': 'jQuery'
}

这样的话,我的库就很小了。但是,当我使用我的库并且用 webpack 打包的时候,发现我的库和jquery打包在了一起。

这种情况怎么解?

当我使用我的库并且用 webpack 打包的时候,发现我的库和jquery打包在了一起。

简单说一下我的猜测:使用你的库的时候(此时你的库已经不再是主项目,而是和 jQuery 一样是依赖),Webpack 的配置同样需要加上这段 externals

感谢。

赞一个

感谢,最近在找这个打包功能~

非感谢译者,刚好在找这方面的文章

謝謝大佬啊,找了一個下午的資料,看到這個文章太棒了真的!