(译)用ES6构建新一代可复用 JS 模块

你是不是也在为可以使用ES6的新特性而兴奋,却不太确定应该从哪开始,或者如何开始?不止你一个人这样!我已经花了一年半的时间去解决这个幸福的难题。在这段时间里 JavaScript 工具链中有几个令人兴奋的突破。

这些突破让我们可以用ES6书写完全的JS模块,而不会为了一些基本的条件而妥协,比如testing,linting 和(最重要的)其他人可以轻易理解我们所写的代码。

在这篇文章中,我们集中精力在如何用ES6构建JS模块,并且无论你在你的网站或者app中使用CommonJS,AMD(asynchronous module definition)或者普通的网页script引入,这个模块都可以轻易被引用。

##The Tools

在这个系列文章的第一部分和第二部分,我们来看一下这些卓越的工具们。在这篇文章中,我们详细说明如何编写,编译,打包代码;而在第二篇文章会集中在linting,formatting 和 testing(利用 JSCS,ESLint,mocha,Chai,Karma 和 Istanbul)。让我们来看看在这篇文章中涉及到的工具:

  • Babel(刚刚度过了它的第一个生日)可以把ES6代码转化为ES5代码,不仅简单,而且优雅。
  • Webpack,webpack平寂了我们组里的“模块战争”,我们每个人都镇定得使用着webpack来应付一切(CommonJS,AMD 和 ES6)。它也在打包独立的ES6库方面做得非常棒——这是我们在过去一直渴望看到的。
  • Gulp一个强大的自动化构建工具。

##The Goal

###WRITE IN ES6, USE IN ES5

我们将要讨论的是书写客户端(client-side)ES6 libraries,而不是整个网站或者 app 。(无论是在你的开源项目里或者是在你工作中的软件项目,这是可以在不同的项目中可复用的代码。)”等一下!“,你可能会想:”这个难道不是在浏览器支持ES6之后才能实现的吗?“

你是对的!然而,我们利用上面提到的Babel可以把ES6代码转化为ES5代码,在大多数情况下现在就可以实现我们的目标。

###MAKE IT EASY FOR ANYONE TO CONSUME

我们目标的第二部分是写一个无论在什么模块规范下都可以使用的JS模块。AMD死忠饭?你会得到一个可用的模块。CommonJS 加 browserify 才是你的最爱?没问题!你会得到一个可用的模块。或者你对AMD和CommonJS不感冒,你只是想要在你的页面上加一个<script>引用并且成功运行?你也会得到一个可用的模块。Webpack会把我们的代码打包成UMD( universal module definition)模块规范,使我们的代码在任何代码规范中都可用。

##Setting Up Our Project

在接下来的几分钟,我们将要完成这些代码。我经常用src/spec/lib/文件夹来构建项目。在src/目录里,你会看到一个有趣的示例模块,这个模块是提供乐高电影里的乐高角色的随机语录。这个示例会用到ES6的classesmodulesconstdestructuringgenerator等–这些可以被安全转化为ES5代码的新特性。

这篇文章的主要目的是讨论如何利用 Babel 和 Webpack 来编译和打包 ES6 library。然而我还是想简要的介绍我们的示例代码以证明我们切实在用 ES6。

Note: 你如果是 ES6 新手,不必担心。这个示例足够简单到你们会看懂。

##The LegoCharacter Class

LegoCharacter.js 模块中,我们可以看到如下代码(查看注释了解更多):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// LegoCharacter.js
// Let's import only the getRandom method from utils.js
import { getRandom } from "./utils";

// the LegoCharacter class is the default export of the module, similar
// in concept to how many node module authors would export a single value
export default class LegoCharacter {
// We use destructuring to match properties on the object
// passed into separate variables for character and actor
constructor( { character, actor } ) {
this.actor = actor;
this.name = character;
this.sayings = [
"I haven't been given any funny quotes yet."
];
}
// shorthand method syntax, FOR THE WIN
// I've been making this typo for years, it's finally valid syntax :)
saySomething() {
return this.sayings[ getRandom( 0, this.sayings.length - 1 ) ];
}
}

这些代码本身很无聊–class意味着可以被继承,就像我们在 Emmet.js 模块里做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// emmet.js
import LegoCharacter from "./LegoCharacter";

// Here we use the extends keyword to make
// Emmet inherit from LegoCharacter
export default class Emmet extends LegoCharacter {
constructor() {
// super lets us call the LegoCharacter's constructor
super( { actor: "Chris Pratt", character: "Emmet" } );
this.sayings = [
"Introducing the double-decker couch!",
"So everyone can watch TV together and be buddies!",
"We're going to crash into the sun!",
"Hey, Abraham Lincoln, you bring your space chair right back!",
"Overpriced coffee! Yes!"
];
}
}

在我们的项目中,LegoCharacter.jsemmet.js 都是分开的单独的文件–这是我们示例代码中的典型例子。跟你之前写的 JavaScript 代码相比,我们的示例代码可能比较陌生。然而,在我们完成我们一系列的工作之后,我们将会得到一个 将这些代码打包到一起的‘built’版本。

##The index.js

我们项目中的另一个文件– index.js –是我们项目的主入口。在这个文件中 import 了一些 Lego 角色的类,生成他们的实例,并且提供了一个生成器函数(generator function),这个生成器函数来 yield 一个随机的语录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// index.js
// Notice that lodash isn't being imported via a relative path
// but all the other modules are. More on that in a bit :)
import _ from "lodash";
import Emmet from "./emmet";
import Wyldstyle from "./wyldstyle";
import Benny from "./benny";
import { getRandom } from "./utils";

// Taking advantage of new scope controls in ES6
// once a const is assigned, the reference cannot change.
// Of course, transpiling to ES5, this becomes a var, but
// a linter that understands ES6 can warn you if you
// attempt to re-assign a const value, which is useful.
const emmet = new Emmet();
const wyldstyle = new Wyldstyle();
const benny = new Benny();
const characters = { emmet, wyldstyle, benny };

// Pointless generator function that picks a random character
// and asks for a random quote and then yields it to the caller
function* randomQuote() {
const chars = _.values( characters );
const character = chars[ getRandom( 0, chars.length - 1 ) ];
yield `${character.name}: ${character.saySomething()}`;
}

// Using object literal shorthand syntax, FTW
export default {
characters,
getRandomQuote() {
return randomQuote().next().value;
}
};

在这个代码块中,index.js 引入了lodash,我们的三个Lego角色的类,和一个实用函数(utility function)。然后生成三个类的实例,导出(exports)这三个实例和getRandomQuote方法。一切都很完美,当代码被转化为ES5代码后依然会有一样的作用。

##OK. Now What?

我们已经运用了ES6的一些闪亮的新特性,那么如何才能转化为ES5的代码呢?首先,我们需要通过 npm来安装Babel:

1
npm install -g babel

在全局安装Babel会提供我们一个babel 命令行工具(command line interface (CLI) option)。如果在项目的根目录写下如下命令,我们可以编译我们的模块代码为ES5代码,并且把他们放到lib/目录:

1
babel ./src -d ./lib/

现在看一下lib/目录,我们将看到如下文件列表:

1
2
3
4
5
6
LegoCharacter.js
benny.js
emmet.js
index.js
utils.js
wyldstyle.js

还记得上面我们提到的吗?Babel把每一个模块代码转化为ES5代码,并且以同样的目录结构放入lib/目录。看一下这些文件可以告诉我们两个事情:

  • 首先,在node环境中只要依赖 babel/register运行时,这些文件就可以马上使用。在这篇文章结束之前,你会看到一个在node中运行的例子。
  • 第二,我们还有很多工作要做,以使这些文件打包进一个文件中,并且以UMD(universal module definition )规范打包,并且可以在浏览器环境中使用。

##Enter webpack

我打赌你已经听说过Webpack,它被描述为“一个JavaScript和其他静态资源打包工具”。Webpack的典型应用场景就是作为你的网站应用的加载器和打包器,可以打包你的JavaScript代码和其他静态资源,比如CSS文件和模板文件,将它们打包为一个(或者更多)文件。webpack有一个非常棒的生态系统,叫做“loaders”,它可以使webpack对你的代码进行一些变换。打包一个UMD规范的文件并不是webpack最用途广泛的应用,我们还可以用webpack loader将ES6代码转化为ES5代码,并且把我们的示例代码打包为一个输出文件。

###LOADERS

在webpack中,loaders可以做很多事情,比如转化ES6代码为ES5,把LESS编译为CSS,加载JSON文件,加载模板文件,等等。Loaders为将要转化的文件一个test模式。很多loaders也有自己额外的配置信息。(好奇有多少loaders存在?看这个列表

我们首先在全局环境安装webpack(它将给我们一个webpack命令行工具(CLI)):

1
npm install -g webpack

接下来为我们本地项目安装babel-loader。这个loader可以加载我们的ES6模块并且把它们转化为ES5。我们可以在开发模式安装它,它将出现在package.json文件的devDependencies中:

1
npm install --save-dev babel-loader

在我们开始使用webpack之前,我们需要生成一个webpack的配置文件,以告诉webpack我们希望它对我们的文件做些什么工作。这个文件经常被命名为webpack.config.js,它是一个node模块格式的文件,输出一系列我们需要webpack怎么做的配置信息。

下面是初始化的webpack.config.js,我已经做了很多注释,我们也会讨论一些重要的细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
module.exports = {
// entry is the "main" source file we want to include/import
entry: "./src/index.js",
// output tells webpack where to put the bundle it creates
output: {
// in the case of a "plain global browser library", this
// will be used as the reference to our module that is
// hung off of the window object.
library: "legoQuotes",
// We want webpack to build a UMD wrapper for our module
libraryTarget: "umd",
// the destination file name
filename: "lib/legoQuotes.js"
},
// externals let you tell webpack about external dependencies
// that shouldn't be resolved by webpack.
externals: [
{
// We're not only webpack that lodash should be an
// external dependency, but we're also specifying how
// lodash should be loaded in different scenarios
// (more on that below)
lodash: {
root: "_",
commonjs: "lodash",
commonjs2: "lodash",
amd: "lodash"
}
}
],
module: {
loaders: [
// babel loader, testing for files that have a .js extension
// (except for files in our node_modules folder!).
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel",
query: {
compact: false // because I want readable output
}
}
]
}
};

让我们来看一些关键的配置信息。

###Output

一个wenpack的配置文件应该有一个output对象,来描述webpack如何build 和 package我们的代码。在上面的例子中,我们需要打包一个UMD规范的文件到lib/目录中。

###Externals

你应该注意到我们的示例中使用了lodash。我们从外部引入依赖lodash用来更好的构建我们的项目,而不是直接在output中include进来lodash本身。externals选项让我们具体声明一个外部依赖。在lodash的例子中,它的global property key(_)跟它的名字(”lodash“)是不一样的,所以我们上面的配置告诉webpack如何在不同的规范中依赖lodash(CommonJS, AMD and browser root)。

##The Babel Loader

你可能注意到我们把 babel-loader 直接写成了“babel”。这是webpack的命名规范:如果插件命名为“myLoaderName-loader”格式,那么我们在用的时候就可以直接写做”myLoaderName“。

除了在node_modules/目录下的.js文件,loader会作用到任何其他.js文件。compact选项中的配置表示我们不需要压缩编译过的文件,因为我想要我的代码具有可读性(一会我们会压缩我们的代码)。

如果我们在项目根目录中运行webpack命令,它将根据webpack.config.js文件来build我们的代码,并且在命令行里输出如下的内容:

1
2
3
4
5
6
7
» webpack
Hash: f33a1067ef2c63b81060
Version: webpack 1.12.1
Time: 758ms
Asset Size Chunks Chunk Names
lib/legoQuotes.js 12.5 kB 0 [emitted] main
+ 7 hidden modules

现在如果我们查看lib/目录,我们会发现一个崭新的legoQuotes.js文件,并且它是符合webpack的UMD规范的代码,就像下面的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("lodash"));
else if(typeof define === 'function' && define.amd)
define(["lodash"], factory);
else if(typeof exports === 'object')
exports["legoQuotes"] = factory(require("lodash"));
else
root["legoQuotes"] = factory(root["_"]);
})(this, function(__WEBPACK_EXTERNAL_MODULE_1__) {

// MODULE CODE HERE

});

UMD规范首先检查是否是CommonJS规范,然后再检查是否是AMD规范,然后再检查另一种CommonJS规范,最后回落到纯浏览器引用。你可以发现首先在CommonJS或者AMD环境中检查是否以“lodash”加载lodash,然后在浏览器中是否以_代表lodash。

##What Happened, Exactly?

当我们在命令行里运行webpack命令,它首先去寻找配置文件的默认名字(webpack.config.js),然后阅读这些配置信息。它会发现src/index.js是主入口文件,然后开始加载这个文件和这个文件的依赖项(除了lodash,我们已经告诉webpack这是外部依赖)。每一个依赖文件都是.js文件,所以babel loader会作用在每一个文件,把他们从ES6代码转化为ES5。然后所有的文件打包成为一个输出文件,legoQuotes.js,然后把它放到lib目录中。

观察代码会发现ES6代码确实已经被转化为ES5.比如,LegoCharacter类中有一个ES5构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// around line 179
var LegoCharacter = (function () {
function LegoCharacter(_ref) {
var character = _ref.character;
var actor = _ref.actor;
_classCallCheck(this, LegoCharacter);
this.actor = actor;
this.name = character;
this.sayings = ["I haven't been given any funny quotes yet."];
}

_createClass(LegoCharacter, [{
key: "saySomething",
value: function saySomething() {
return this.sayings[(0, _utils.getRandom)(0, this.sayings.length - 1)];
}
}]);

return LegoCharacter;
})();

##It’s Usable!

这时我们就可以include这个打包好的文件到所有的浏览器(IE9+,当然~)中,也可以在node中运行完美,只要babel运行时依赖完美。

如果我们想在浏览器使用,它看起来会像下面的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Lego Quote Module Example</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<blockquote id="quote"></blockquote>
<button id="btnMore">Get Another Quote</button>
</div>
<script src="../node_modules/lodash/index.js"></script>
<script src="../node_modules/babel-core/browser-polyfill.js"></script>
<script src="../lib/legoQuotes.js"></script>
<script src="./main.js"></script>
</body>
</html>

你会看到我们已经依赖legoQuotes.js(就在babel的browser-polyfill.js下面),就像其他依赖一样使用<script>标签。我们的main.js使用了legoQuotes库,看起来是这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
// main.js
( function( legoQuotes ) {
var btn = document.getElementById( "btnMore" );
var quote = document.getElementById( "quote" );

function writeQuoteToDom() {
quote.innerHTML = legoQuotes.getRandomQuote();
}

btn.addEventListener( "click", writeQuoteToDom );
writeQuoteToDom();
} )( legoQuotes );

在node环境中使用,是这个样子:

1
2
3
4
require("babel/polyfill");
var lego = require("./lib/legoQuotes.js");
console.log(lego.getRandomQuote());
// > Wyldstyle: Come with me if you want to not die.

##Moving To Gulp

Babel和webpack的命令行工具都非常有用和高效,但是我更倾向于用类似于Gulp的自动化构建工具来执行其他类似的任务。如果你有很多项目,那么你会体会到构建命令一致性所带来的好处,我们只需要记住类似gulp someTaskName的命令,而不需要记很多其他命令。在大多数情况下,这无所谓对与错,如果你喜欢其他的命令行工具,就去使用它。在我看来使用Gulp是一个简单而高效的选择。

###SETTING UP A BUILD TASK

首先,我们要安装Gulp:

1
npm install -g gulp

接下来我们创建一个gulpfile配置文件。然后我们运行npm install --save-dev webpack-stream命令,来安装和使用webpack-streamgulp 插件。这个插件可以让webpack在gulp任务中完美运行。

1
2
3
4
5
6
7
8
9
// gulpfile.js
var gulp = require( "gulp" );
var webpack = require( "webpack-stream" );

gulp.task( "build", function() {
return gulp.src( "src/index.js" )
.pipe( webpack( require( "./webpack.config.js" ) ) )
.pipe( gulp.dest( "./lib" ) )
} );

现在我已经把index.js放到了gulp的src中并且写入了output目录,那么我需要修改webpack.config.js文件,我删除了entry并且更新了filename。我还添加了devtool配置,它的值为#inline-source-map(这将会在一个文件末尾写入一个source map):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// webpack.config.js
module.exports = {
output: {
library: "legoQuotes",
libraryTarget: "umd",
filename: "legoQuotes.js"
},
devtool: "#inline-source-map",
externals: [
{
lodash: {
root: "_",
commonjs: "lodash",
commonjs2: "lodash",
amd: "lodash"
}
}
],
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel",
query: {
compact: false
}
}
]
}
};

###WHAT ABOUT MINIFYING?

我很高兴你问了这个问题!我们用gulp-uglify,配合使用gulp-sourcemaps(给我们的min文件生成source map),gulp-rename(我们给压缩文件重命名,这样就不会覆盖未压缩的原始文件),来完成代码压缩工作。我们添加它们到我们的项目中:

1
npm install --save-dev gulp-uglify gulp-sourcemaps gulp-rename

我们的未压缩文件依然有行内的source map,但是gulp-sourcemaps的作用是为压缩文件生成一个单独的source map文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// gulpfile.js
var gulp = require( "gulp" );
var webpack = require( "webpack-stream" );
var sourcemaps = require( "gulp-sourcemaps" );
var rename = require( "gulp-rename" );
var uglify = require( "gulp-uglify" );

gulp.task( "build", function() {
return gulp.src( "src/index.js" )
.pipe( webpack( require( "./webpack.config.js" ) ) )
.pipe( gulp.dest( "./lib" ) )
.pipe( sourcemaps.init( { loadMaps: true } ) )
.pipe( uglify() )
.pipe( rename( "legoQuotes.min.js" ) )
.pipe( sourcemaps.write( "./" ) )
.pipe( gulp.dest( "lib/" ) );
} );

现在在命令行里运行gulp build,我们会看到如下输出:

1
2
3
4
5
6
7
» gulp build
[19:08:25] Using gulpfile ~/git/oss/next-gen-js/gulpfile.js
[19:08:25] Starting 'build'...
[19:08:26] Version: webpack 1.12.1
Asset Size Chunks Chunk Names
legoQuotes.js 23.3 kB 0 [emitted] main
[19:08:26] Finished 'build' after 1.28 s

现在在lib/目录里有三个文件:legoQuotes.jslegoQuotes.min.jslegoQuotes.min.js.map

##Webpack Banner Plugin

如果你需要在你打包好的文件头部添加licence等注释信息,webpack可以简单实现。我更新了webpack.config.js文件,添加了BannerPlugin。我不喜欢亲自去编辑这些注释信息,所以我引入了package.json文件来获取这些关于库的信息。我还把webpack.config.js写成了ES6的格式,可以使用新特性template string来书写这些信息。在webpack.config.js文件底部可以看到我们添加了plugins属性,目前BannerPlugin使我们唯一使用的插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// webpack.config.js
import webpack from "webpack";
import pkg from "./package.json";
var banner = `
${pkg.name} - ${pkg.description}
Author: ${pkg.author}
Version: v${pkg.version}
Url: ${pkg.homepage}
License(s): ${pkg.license}
`;


export default {
output: {
library: pkg.name,
libraryTarget: "umd",
filename: `${pkg.name}.js`
},
devtool: "#inline-source-map",
externals: [
{
lodash: {
root: "_",
commonjs: "lodash",
commonjs2: "lodash",
amd: "lodash"
}
}
],
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel",
query: {
compact: false
}
}
]
},
plugins: [
new webpack.BannerPlugin( banner )
]
};

(Note: 值得注意的是当我把webpack.config.js写成ES6,就不能再使用webpack命令行工具来运行它了。)

我们的gulpfile.js也做了两个更新:在第一行添加了babel register hook;我们传入了gulp-uglify 的配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// gulpfile.js
require("babel/register");
var gulp = require( "gulp" );
var webpack = require( "webpack-stream" );
var sourcemaps = require( "gulp-sourcemaps" );
var rename = require( "gulp-rename" );
var uglify = require( "gulp-uglify" );

gulp.task( "build", function() {
return gulp.src( "src/index.js" )
.pipe( webpack( require( "./webpack.config.js" ) ) )
.pipe( gulp.dest( "./lib" ) )
.pipe( sourcemaps.init( { loadMaps: true } ) )
.pipe( uglify( {
// This keeps the banner in the minified output
preserveComments: "license",
compress: {
// just a personal preference of mine
negate_iife: false
}
} ) )
.pipe( rename( "legoQuotes.min.js" ) )
.pipe( sourcemaps.write( "./" ) )
.pipe( gulp.dest( "lib/" ) );
} );

##What’s Next?

我们已经为我们的旅途开了个好头!!到目前为止我们已经用Babel 和 webpack命令行工具构建了我们的项目,然后我们用gulp(和相关插件)自动化构建打包我们的项目。这篇文章的代码包含了example/文件夹,在其中有浏览器端和node端的示例。在下一篇文章中,我们将用 ESLint 和 JSCS 来检查我们的代码,用 mocha 和 chai 来书写测试,用 Karma 来跑这些测试,用 istanbul 来计量测试的覆盖面。同时,你可以看另一篇非常棒的文章–Designing Better JavaScript APIs,它可以帮助你写出更好的模块代码。

译自Writing Next Generation Reusable JavaScript Modules in ECMAScript 6