你是不是也在為可以使用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 ) ];
}
}複製程式碼
這些程式碼本身很無聊–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.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;
}
};複製程式碼
在這個程式碼塊中,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-streamgulp 外掛。這個外掛可以讓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.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 )
]
};複製程式碼
(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