2020 年の瀬の JS ビルド&バンドルツールの検討

公開:2020/12/03
更新:2020/12/05
18 min読了の目安(約16900字TECH技術記事

本稿はZOZOテクノロジーズ #3 Advent Calendar 2020 4日目の記事です。

今年から ZOZO テクノロジーズの Web エンジニアになりました。@takewell です。

もう年の瀬になりました。一年は早いですがブラウザの寿命は存外長いです。
来年はきっと Internet Explorer のサポートブラウザから外すことができるかもしれない。来年はわざわざツールを介さずとも ECMAScript 202X が動くブラウザが世界中のみなさんに使われるようになるかもしれない。そう願ってなりませんが、現実はそうではありません。

こうした課題を少しでもマシにするために webpack (シェア 76%)を代表とする ESNext (ECMAScript Next Generation) なコードをレガシーブラウザにビルドしたり、コードを単一ファイルにバンドルしたりするツールが数多く存在します。(以降、ビルド&バンドルなどの事前変換処理をプリプロセスと表記します。)

これらプリプロセスツールに関して ”アプリには webpack、ライブラリには rollup.js” という常套句があります。しかし、この常套句は本当に妥当なのでしょうか?
また、今年は新たなるプロプロセスツールとして snowpackRome などが登場しました。こうしたツールの登場があっても、この常套句は今尚通用するのでしょうか?

本稿では、これらの疑問と 2020 年年末時点のプロプロセスツールを調べ、将来性や使い分けについて、それぞれ検討します。

Use webpack for apps, and rollup.js for libraries

まず、”アプリには webpack、ライブラリには rollup.js” すなわち Use webpack for apps, and rollup.js for libraries の元ネタはこちらの記事でほぼ間違いなさそうです。当時 webpack が革新的だった点として “Static assets” と “Code Splitting”が挙げられています。Static assets は CSS, Sass, PostCSS Image, SVG の圧縮やハッシュ付きのファイルを生成する機能です。Code Splitting は、ユーザのインタラクションに応じて非同期で、予め分けられたファイルを適宜読み込む機能です。これによって読み込みに時間がかかるサイズの大きい JS を小さく分割することで読み込みパフォーマンスを向上させることができます。しかしこれにはデメリットもありまして、複数ファイルを読み込むと通信のオーバーヘットやレイテンシーが発生してしまうためパフォーマンスにマイナスに働く面もあります。とはいえ、HTTP/2 などを導入していれば複数のリクエストを並列で混在させることもできるため、そこまでマイナスには働かないかもしれません。

つまり、こうした Web アプリケーション開発に便利な機能が webpack には搭載されているというわけです。これら以外にも数多くの細かい機能やプラグインの充実性が webpack をシェア 76% たらしめており、アプリケーションには webpack を使っておけと言われる所以です。

もう一つ rollup.js についてですが、webpack とは出自が異なります。rollup.js は元々 ES 2015 module(export, import) や Common JS (module.exports, require) など異なるモジュールシステム間でも利用できる JS ファイルを生成するために作られました。つまり、同じプリプロセスをする上でも目的が異なります。これは確かにライブラリにおいて便利です。

また、生成ファイルが小さくてシンプルなことも特徴です。自分はライブラリではないですが、既存環境と共存したレガシーブラウザ向け JS, CSS を作成する必要があり、ES5 で書くのがキツかったので ESNext をヒューマンリーダーブルな ES5 及び CSS をプリプロセスするために rollup.js を選択しました。rollup と webpack がそれぞれどういう JS を生成するのか比較するとイメージがつきやすいと思ったのでこれを例として以降説明していきます。

rollup.js 利用ケース : レガシーブラウザ向けの小さくリーダブルな JS を生成する

動く必要はないので、それっぽい謎コードを例にしてみます。

library.js
export const Utils = window.library.get('utils');
/**
* 外部ライブラリにイベントを送信する
* @param {object} eventName:
* @return {undefined}
*/
export const sendEvent = ({ eventName }) => {
  window['library'].push({
    type: 'event',
    eventName,
  })
}
/**
* 外部ライブラリの読み込みを待ってセレクタを指定を実行する
* @param {String} selector:
* @return {Promise} targetElement
*/
export const waitForElement = (selector) => {
  return new Promise(resolve => {
    setTimeout(() => {
      return resolve(document.querySelectorAll(selector))
    }, 1000)
  })
}
index.js
import { sendEvent, waitForElement } from "./library";
export const addBtnListener = (callback = () => {}) => {
  waitForElement("#Btn .trigger").then((favElem) => {
    const optTestObserver = new MutationObserver(() => {
       if (window.$("#Btn .trigger").hasClass("active")) {
          sendEvent({ eventName: "foo" });
       }
     });
     optTestObserver.observe(favElem, { attributes: true });
     callback(favElem);
  });
};

(() => {
  addBtnListener(() => {
    console.log('exec')
  });
})()

これを npx webpack --mode development をデフォルト設定の development モードで実行すると以下のようになります。

output.js
/*
* ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
* This devtool is not neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (() => { // webpackBootstrap
/******/  "use strict";
/******/  var __webpack_modules__ = ({
/***/ "./src/index.js":
/*!**********************!*\
!*** ./src/index.js ***!
\**********************/
/*! namespace exports */
/*! export addBtnListener \[provided\] [no usage info] [missing usage info prevents renaming] */
/*! other exports \[not provided\] [no usage info] */
/*! runtime requirements: __webpack_require__, __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"addBtnListener\": () => /* binding */ addBtnListener\n/* harmony export */ });\n/* harmony import */ var _library__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./library */ \"./src/library.js\");\n\n\nconst addBtnListener = (callback = () => {}) => {\n  (0,_library__WEBPACK_IMPORTED_MODULE_0__.waitForElement)(\"#Btn .trigger\").then((favElem) => {\n    const optTestObserver = new MutationObserver(() => {\n      if (window.$(\"#Btn .trigger\").hasClass(\"active\")) {\n        (0,_library__WEBPACK_IMPORTED_MODULE_0__.sendEvent)({ eventName: \"foo\" });\n      }\n    });\n    optTestObserver.observe(favElem, { attributes: true });\n    callback(favElem);\n  });\n};\n\n(() => {\n  addBtnListener(() => {\n    console.log('exec')\n  });\n})()\n\n\n//# sourceURL=webpack://webpack-demo/./src/index.js?");
/***/ }),
/***/ "./src/library.js":
/*!************************!*\
!*** ./src/library.js ***!
\************************/
/*! namespace exports */
/*! export Utils \[provided\] [no usage info] [missing usage info prevents renaming] */
/*! export sendEvent \[provided\] [no usage info] [missing usage info prevents renaming] */
/*! export waitForElement \[provided\] [no usage info] [missing usage info prevents renaming] */
/*! other exports \[not provided\] [no usage info] */
/*! runtime requirements: __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"Utils\": () => /* binding */ Utils,\n/* harmony export */   \"sendEvent\": () => /* binding */ sendEvent,\n/* harmony export */   \"waitForElement\": () => /* binding */ waitForElement\n/* harmony export */ });\nconst Utils = window.library.get('utils');\n\n/**\n * 外部ライブラリにイベントを送信する\n * @param {object} eventName:\n * @return {undefined}\n */\nconst sendEvent = ({ eventName }) => {\n  window['library'].push({\n    type: 'event',\n    eventName,\n  })\n}\n\n/**\n * 外部ライブラリの読み込みを待ってセレクタを指定を実行する\n * @param {String} selector:\n * @return {Promise} targetElement\n */\nconst waitForElement = (selector) => {\n\treturn new Promise(resolve => {\n\t\tsetTimeout(() => {\n      return resolve(document.getElementById(selector))\n\t\t}, 1000)\n\t})\t\n}\n\n\n//# sourceURL=webpack://webpack-demo/./src/library.js?");
/***/ })
/******/  });
/************************************************************************/
/******/  // The module cache
/******/  var __webpack_module_cache__ = {};
/******/  
/******/  // The require function
/******/  function __webpack_require__(moduleId) {
/******/    // Check if module is in cache
/******/    if(__webpack_module_cache__[moduleId]) {
/******/      return __webpack_module_cache__[moduleId].exports;
/******/    }
/******/    // Create a new module (and put it into the cache)
/******/    var module = __webpack_module_cache__[moduleId] = {
/******/      // no module.id needed
/******/      // no module.loaded needed
/******/      exports: {}
/******/    };
/******/  
/******/    // Execute the module function
/******/    __webpack_modules__\[moduleId\](module, module.exports, __webpack_require__);
/******/  
/******/    // Return the exports of the module
/******/    return module.exports;
/******/  }
/******/  
/************************************************************************/
/******/  /* webpack/runtime/define property getters */
/******/  (() => {
/******/    // define getter functions for harmony exports
/******/    __webpack_require__.d = (exports, definition) => {
/******/      for(var key in definition) {
/******/        if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/          Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/        }
/******/      }
/******/    };
/******/  })();
/******/  
/******/  /* webpack/runtime/hasOwnProperty shorthand */
/******/  (() => {
/******/    __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop)
/******/  })();
/******/  
/******/  /* webpack/runtime/make namespace object */
/******/  (() => {
/******/    // define __esModule on exports
/******/    __webpack_require__.r = (exports) => {
/******/      if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/        Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/      }
/******/      Object.defineProperty(exports, '__esModule', { value: true });
/******/    };
/******/  })();
/******/  
/************************************************************************/
/******/  // startup
/******/  // Load entry module
/******/  __webpack_require__("./src/index.js");
/******/  // This entry module used 'exports' so it can't be inlined
/******/ })()
;

まったく大したことをやっていないコードの変換がこれでは困ります。
次に npx webpack --mode production デフォルト設定でプロダクションビルドをすると以下のような感じになります。

output.js
 (()=>{"use strict";window.library.get("utils"),((e=(()=>{}))=>{("#Btn .trigger",new Promise((e=>{setTimeout((()=>e(document.querySelectorAll("#Btn .trigger"))),1e3)}))).then((t=>{new MutationObserver((()=>{window.$("#Btn .trigger").hasClass("active")&&(({eventName:e})=>{window.library.push({type:"event",eventName:e})})({eventName:"foo"})})).observe(t,{attributes:!0}),e(t)}))})((()=>{console.log("exec")}))})();

これも余計なコードは除かれましたが、ヒューマンリーダルなコードとは言いにくいでしょう。
これらはデフォルト設定で、ターゲットブラウザの設定をしてませんので ESNext がそのまま ESNext でビルドされていますが一旦それは無視してください。

一方 rollup で試してみます。
ターゲットブラウザの設定は以下です。

package.json
"browserslist": [
	"last 2 versions",
	"ie >= 10",
	"Android >= 4"
],

設定ファイルは以下の状態でrollup --config を実行します。
同一ディレクトリの rollup.config.js が実行され、この設定ファイルそのものも ESNext で書けます。以下設定ファイルを参考までに貼っておきます。

rollup.config.js
// ES のトランスパイル
import babel from "rollup-plugin-babel";
import postcss from "rollup-plugin-postcss";
// vender prefix など css の前処理
import autoprefixer from "autoprefixer";
// css の minify
import cssnano from "cssnano";
// js の minify
import { terser } from "rollup-plugin-terser";

const settings = ({ fname }) => {
return {
input: fname,
output: [
  {
    file: fname,
    format: 'iife'
  },
],
plugins: [
  babel({
    exclude: "node_modules/**",
    runtimeHelpers: true,
    babelrc: true,
    plugins: [],
  }),
  process.env.NODE_ENV === "production" && terser(),
  postcss({
    extract: true,
    plugins:
      process.env.NODE_ENV === "production"
	? [autoprefixer(), cssnano()]
	: [autoprefixer()],
    writeDefinitions: true,
  }),
],
};
};

export default settings({fname: 'src/index.js'})

上記の設定ファイルから以下のファイルが生成されます。

output.js
    (function (exports) {
      'use strict';
      var Utils = window.library.get('utils');
      /**
       * 外部ライブラリにイベントを送信する
       * @param {object} eventName:
       * @return {undefined}
       */
      var sendEvent = function sendEvent(_ref) {
        var eventName = _ref.eventName;
        window['library'].push({
          type: 'event',
          eventName: eventName
        });
      };
      /**
       * 外部ライブラリの読み込みを待ってセレクタを指定を実行する
       * @param {String} selector:
       * @return {Promise} targetElement
       */
      var waitForElement = function waitForElement(selector) {
        return new Promise(function (resolve) {
          setTimeout(function () {
            return resolve(document.querySelectorAll(selector));
          }, 1000);
        });
      };
      var addBtnListener = function addBtnListener() {
        var callback = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : function () {};
        waitForElement("#Btn .trigger").then(function (favElem) {
          var optTestObserver = new MutationObserver(function () {
            if (window.$("#Btn .trigger").hasClass("active")) {
              sendEvent({
                eventName: "foo"
              });
            }
          });
          optTestObserver.observe(favElem, {
            attributes: true
          });
          callback(favElem);
        });
      };
      (function () {
        addBtnListener(function () {
          console.log('exec');
        });
      })();
      exports.addBtnListener = addBtnListener;
      return exports;
    }({}));

比較的変換が読みやすいコードになっているのがわかるのではないでしょうか。
ちなみにプロダクションビルドは以下のような形になります。これは webpack と同じですね。
もちろん webpack でも設定ファイルを詳細に記述すれば同様のことができるかもしれません(軽く調べた感じなさそう?)、フェアな比較ではないという場合はご指摘いただければ幸いです。

output.js
!function(e){"use strict";window.library.get("utils");var n=function(e){var n=e.eventName;window.library.push({type:"event",eventName:n})},t=function(e){return new Promise((function(n){setTimeout((function(){return n(document.querySelectorAll(e))}),1e3)}))},i=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){};t("#Btn .trigger").then((function(t){new MutationObserver((function(){window.$("#Btn .trigger").hasClass("active")&&n({eventName:"foo"})})).observe(t,{attributes:!0}),e(t)}))};i((function(){console.log("exec")})),e.addBtnListener=i}({});

このように rollup.js はリーダブルなレガシーコードを生成することができます。
webpack を使うほどではないけれど、ESNext なコードを生成したい。ある一部分だけモジュール化できる JS を持ち込みたいときなどに便利かと思います。

ここまでで “Use webpack for apps, and rollup.js for libraries” は現在でも通用しそうだとわかりました。JS のプリプロセスツールは webpack, rollup でおおよそ 2,3 年デファクトが決まっていた現状がありますが、冒頭で記述した通り、新しいものも登場してきています。
次はそれらについてとりあげます。

Go や Rust などネイティブバイナリーで実装されたツール

これらツールの特徴は高速にビルドできる点です。webpack のビルドはソースが巨大になればなるほど時間がかかり生産性を落とします。この課題を解決するものとして以下にある JS 以外で実装されている言語もほらあります。しかし、Static Assets の機能やプラグイン機能が備わっていないためフロントエンド開発者としては現時点であまり乗り換えのメリットは感じませんでした。デベロッパーがプラグイン開発などをしやすいように同じ言語で実装する方がいいという意見もあるようです。

  • esbuild – Go CSS module など非対応, CSSに関する機能を現在開発中であり、来年には検討に乗るかもしれません。
  • swc – Rust コンパイラーのみで bundle 機能はなさそう?
  • pax - Rust
  • Google Closure Compiler – Java これについては 10 年ほど前からあるもののようで老舗です。

この中では esbuild が npm のダウンロード数などを見ても桁違いに多く、機能開発も進んでいるので将来性があると感じました。

新興プリプロセッサー

  • snowpack 上述の esbuild を内部的に使ったビルドツールです。いいと思い使おうとしたのですが IE 対応が甘かったので見送りました。
  • ncc Next.js の Vercel 社が開発 node.js のモジュールを単一ファイルに生成する、ライブラリ用のユースケース rollup.js を置き換えるものとして期待できるかもしれません。まだ v0.x です
  • Rome フロントエンドに数々の革新を起こした Facebook の新作 Rome Babel, ESLint, webpack, Prettier, Jest, などすべてをフルセットで置き換える構想があるとのことで期待されています。

Alt webpack が snowpack, Alt rollup.js が ncc といったところでしょうか、Rome はまだまだ途上という印象ですが Facebook 製なのでやはり期待してしまいます。rometool を一応フォローしておこうと思います。

まとめ

  • Use webpack for apps, and rollup.js for libraries は健在
  • Go, Rust で実装されたプリプロセスツールはあるが、まだプラグインやダウンロード数も少ないので上記の認識で問題ない。esbuild が CSS module に対応すれは試しておくと良さそう。
  • IE をサポートしなくていい場合は snowpack は使えるかもしれない。

ごちゃごちゃ書きましたが、大抵のフロントエンドは実は react 覚えて next.js 使って vercel にデプロイしとけば OK と思います。webpack も、細かい設定も必要ありません。
next.js 最適化機能もゴリゴリ入っていて早いですし、便利モジュールも続々追加されています。
内部的には webpack に依存していますが、webpack よりもより優れたツールが台頭すれば 内部的に置き換えてくれるかもしれませんし、あんまりプリプロセスツールに深入りしない方が良いかもしれません(笑) アプリケーションレイヤーでみんな webpack を使っていたなんてあの頃のフロントエンドは牧歌的だったねなんて言われる日も近いかもしれません。

おそらくプリプロセッサーを検討するなんて一年に一回で十分だと思うので、来年また再調査して書こうと思います。いいねいただければモチベ上がります!!

以上です。

明日は cozima0210 さんの OSSへの貢献 - Issueから始めるチーム活動 です

参考