(譯)用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

相關文章