[译] 用 ES6 构建新一代可复用 JS 模块

DemetriusNU 9年前

来自: https://segmentfault.com/a/1190000004419412

你是不是也在为可以 使用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 模块中,我们可以看到如下代码(查看注释了解更多):

// 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 模块里做的:

// 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 一个随机的语录:

// 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:

npm install -g babel

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

babel ./src -d ./lib/

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

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) ):

npm install -g webpack

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

npm install --save-dev babel-loader

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

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

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我们的代码,并且在命令行里输出如下的内容:

» 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规范的代码,就像下面的代码片段:

(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构造函数:

// 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运行时依赖完美。

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

<!-- 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库,看起来是这个样子:

// 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环境中使用,是这个样子:

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:

npm install -g gulp

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

// 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):

// 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 (我们给压缩文件重命名,这样就不会覆盖未压缩的原始文件),来完成代码压缩工作。我们添加它们到我们的项目中:

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

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

// 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 ,我们会看到如下输出:

» 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 使我们唯一使用的插件:

// 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 的配置信息:

// 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

</div>