(譯)用ES6構建新一代可複用 JS 模組

MrDream24發表於2019-02-25

你是不是也在為可以使用ES6的新特性而興奮,卻不太確定應該從哪開始,或者如何開始?不止你一個人這樣!我已經花了一年半的時間去解決這個幸福的難題。在這段時間裡 JavaScript 工具鏈中有幾個令人興奮的突破。

這些突破讓我們可以用ES6書寫完全的JS模組,而不會為了一些基本的條件而妥協,比如testing,linting 和(最重要的)其他人可以輕易理解我們所寫的程式碼。

(譯)用ES6構建新一代可複用 JS 模組

在這篇文章中,我們集中精力在如何用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-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.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

相關文章