[譯] 用 ES6 構建新一代可復用 JS 模塊
來自: 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的 classes , modules , const , destructuring , generator 等--這些可以被安全轉化為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 ) ]; } }</pre>
這些代碼本身很無聊--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!" ]; } }</pre>
在我們的項目中, LegoCharacter.js 和 emmet.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; } };</pre>
在這個代碼塊中, 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</pre>
現在如果我們查看 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
});</pre>
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; })();</pre>
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 );</pre>
在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" ) ) } );</pre>
現在我已經把 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/" ) ); } );</pre>
現在在命令行里運行 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.js , legoQuotes.min.js 和 legoQuotes.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 ) ] };</pre>( 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/" ) ); } );</pre>
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