Help us understand the problem. What is going on with this article?

TypeScriptパッケージをwebpack (Tree Shaking)とNode.js両方で使えるようにする(ES6 Modules / CommonJS)

モチベーション

私は今までwebpackのみの割とシンプルなプロジェクトを作っていました。

特にインポート・エクスポートについては、import だの export だの書いておけば動いたのですが、

  • monorepo構成で、共通モジュールをこまめに切り出し、
  • webpack / Node.js 双方から参照するパッケージを作った途端に、

Node.js 「共通モジュールにある export キーワードって何? そんなキーワードうちら知らんのやけど。」

と文句言われ始めたので、Node.jsからも文句を言われないパッケージを作りたいと思います。

※文中に書かれている ES2015 と ES6 は同じものを指しています。

プロジェクトの構成 (monorepo)

https://github.com/knjname/both-es6-commonjs-build にてソースコードを公開しています。)

下記のようなyarn workspaceによるmonorepoで構成していきます。別にyarnじゃなくても話は変わりません。

├── package.json
├── packages
│   ├── common            # 共通モジュールのプロジェクト  (@my/common)
│   │   └── package.json
│   ├── nodejs            # Node.jsのプロジェクト      (@my/nodejs)
│   │   └── package.json  # common を参照している
│   └── webpack           # webpackのプロジェクト      (@my/webpack)
│       └── package.json  # common を参照している
└── yarn.lock

今回は packages/common を webpack / NodeJS 双方から参照できるように ES6 Modules / CommonJS 双方対応のモジュールにしたいと思います。

そもそも、JSのモジュールのインポート・エクスポートはどうなっているのか?

JSで他のファイルをインポート・エクスポートする際の方式はいくつかあります。

ES6 (ES2015) Module

export / import ですね。

hello-world.js
// エクスポート
export const helloWorld = function () { console.log("Hello World")! }
// インポート
import { helloWorld } from "./hello-world.js"

helloWorld();

実際ソースコードでモジュールのエクスポート・インポートする際は、この書き方をすることが多いと思います。

書き方が抽象的なので、Tree Shaking(バンドル時無駄モジュールインポート削除) といった要望にも答えられます。要するにモジュールの参照関係がモジュールバンドラーにとって理解可能な形式ということです。

CommonJS

exports.** / require(**) ですね。

hello-world.js
// エクスポート
exports.helloWorld = function () { console.log("Hello World")! }
// インポート
require("hello-world").helloWorld();

古いライブラリはこの形式を使わないといけない場合が多いと思います。 Node.js がサポートしているのも基本的にはこの形式です。

Browserify / AMD

そんなものも過去にあったらしい。

実務として利用するJSの書き方

今どきは基本的にソースコードレベルではES6 Moduleの書き方をして、トランスパイルの時点でそれぞれのインポート方式に変換させるのが普通だと思います。

TypeScript では?

TypeScriptで記述する場合では、TypeScriptのトランスパイル時点で出力方法を選ぶことができます。 tsconfig.jsoncompilerOptions.module の記述に従うということです。

下記が出力の例です。(es5ターゲットで出力)

元のソース

src/hello.ts
export const helloWorld = function () { console.log("Hello") }
src/index.ts
import {helloWorld} from "./hello"

helloWorld()

"module": "es6" の場合 (import/export方式)

build/hello.js
export var helloWorld = function () { console.log("Hello"); };
build/index.js
import { helloWorld } from "./hello";
helloWorld();

上記をNode.jsで実行すると、失敗します。

$ node build/index.js
import { helloWorld } from "./hello";
       ^

SyntaxError: Unexpected token {
    at Module._compile (internal/modules/cjs/loader.js:703:23)
    以下略

ちなみにファイル名の *.js*.mjs に置き換えると、Node.js v13で実行できるようになります。

$ node build/index.mjs 
(node:67946) ExperimentalWarning: The ESM module loader is experimental.
Hello

"module": "commonjs" の場合 (require/exports方式)

build/hello.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.helloWorld = function () { console.log("Hello"); };
build/index.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var hello_1 = require("./hello");
hello_1.helloWorld();

上記はNode.jsで実行できます。

$ node build/index.js 
Hello

では、共用プロジェクト (@my/common) を双方対応にしよう

ユースケース

共用プロジェクトを利用する際に、下記のように利用することを想定します。

out-side-common.js
import { helloWorld } from "@my/common";

helloWorld();

packages/common/package.json

package.json"main""module" エントリにそれぞれ、CommonJSエントリ、ES6エントリを書いてあげればいいです。

{
    "name": "@my/common",
    "version": "1.0.0",
    "main": "commonjs/index.js",
    "module": "es6/index.js",
    "devDependencies": {
        "typescript": "^3.8.3"
    }
}

ソースのビルド (トランスパイル)

あとは @my/common のビルド時にそれぞれ下記の位置に、他モジュールをexportするindex.jsが出るようにしてあげればいいですね。

  • CommonJS用トランスパイル → commonjs/index.js
  • ES6用トランスパイル → es6/index.js

それぞれ用に2回トランスパイルしてあげればいいです。

$ yarn tsc --module ES6 --outDir es6
$ yarn tsc --module CommonJS --outDir commonjs

実際は package.jsonscripts に下記相当を書くでしょう。(このままだとWindowsでは動きません。)

    "scripts": {
        "build": "tsc --module ES6 --outDir es6 && tsc --module CommonJS --outDir commonjs"
    }

動作確認する

CommonJS (NodeJS)

package.json
{
    "name": "@my/nodejs",
    "version": "1.0.0",
    "dependencies": {
        "@my/common": "*"
    }
}
packages/nodejs/src/index.js
const { helloWorld } = require("@my/common")

helloWorld("nodejs");
$ node src/index.js 
Hello from nodejs

ちゃんと動作しました。

ES6 (Webpack)

そもそもWebpackさんは別にCommonJSは処理できるのですが、じゃあなぜES6 Modulesにこだわるかといえば、ちゃんと Tree Shaking される条件として、ES6 (ES2015) Modulesであることというのがあるからですね。

  • Use ES2015 module syntax (i.e. import and export).
  • Ensure no compilers transform your ES2015 module syntax into CommonJS modules (this is the default behavior of the popular Babel preset @babel/preset-env - see the documentation for more details).
  • Add a "sideEffects" property to your project's package.json file.
  • Use the production mode configuration option to enable various optimizations including minification and tree shaking.

productionモードで、ES2015 (ES6)モジュールを保ったままwebpackまでペロリンチョさせてくれたら、Tree Shakingしてあげるよ」ということを言っています。 (ちょろっと書かれているsideEffectsフラグについては今回やりません)

Tree Shakingを検証してみる

前準備

本当にTree Shakingされるでしょうか?

やってみましょう。Webpackの設定が面倒なので、create-react-appを使います。

$ cd packages

# npx create-react-app webpack でも可
$ yarn create react-app webpack

package.json の依存性を書き換えて、

packages/webpack/package.json
{
  "name": "@my/webpack",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@my/common": "*",
// 以下略

src/index.js を書き換えましょう。

packages/webpack/src/index.js
import {helloWorld} from "@my/common"

helloWorld("react");

また、 packages/common/src内に予めTree Shaking除外判定用のモジュールを追加しておきましょう。(ビルドも忘れずに行いましょう)

packages/common/src/dummy.js
export const dummy = function () { console.log(`I AM NOT SUPPOSED TO BE INCLUDED IN WEBPACK BUILD.`) }
packages/common/src/index.js
export * from "./hello"
export * from "./dummy"
検証

さて、ここまで来たら、webpackの方をビルドして、結果ファイルにTree Shakingで消えるはずのゴミがないかチェックしてみます。

$ yarn build

$ grep 'I AM NOT SUPPOSED TO BE INCLUDED IN WEBPACK BUILD.' -r build | wc -l
  0 # きちんとTree Shakingされている

$ grep 'Hello from' -r build | wc -l
  2

ちゃんと消えてますね。

逆に参照していたらどうでしょうか?

packages/webpack/src/index.js
import {helloWorld, dummy} from "@my/common"

helloWorld("react");
dummy()
$ yarn build && grep 'I AM NOT SUPPOSED TO BE INCLUDED IN WEBPACK BUILD.' -r build | wc -l
  2

出てきますね。

import だけならどうでしょうか?

packages/webpack/src/index.js
import {helloWorld, dummy} from "@my/common"

helloWorld("react");
// dummy()
$ yarn build && grep 'I AM NOT SUPPOSED TO BE INCLUDED IN WEBPACK BUILD.' -r build | wc -l
  0

消えますね。未使用のimportでも削除してくれるようです。優秀ですね。

CommonJSならTree Shakingは効かないのかな?

では、ES6 Modulesではなく、CommonJSならどうでしょうか? commonpackage.json からES6モジュールの記述を消して検証してみます。

packages/common/package.json
{
    "name": "@my/common",
    "version": "1.0.0",
    "main": "commonjs/index.js",
    // "module": "es6/index.js", ← ここを消す
$ yarn build && grep 'I AM NOT SUPPOSED TO BE INCLUDED IN WEBPACK BUILD.' -r build | wc -l
  2

出てきました。CommonJSではTree Shakingできないことがわかりました。

まとめ

  • webpack (Tree Shaking) には ES6 (ES2015) Module が必要
  • Node.js には CommonJS が必要
  • 共通プロジェクトで webpack (Tree Shaking) と Node.js を両立したければ、双方のためにそれぞれトランスパイル結果を生成しておく必要がある。

上記を踏まえてmonorepoでは共通プロジェクトを作っていきましょう。あ〜JSややこしい。

(今回はTypeScriptを2回トランスパイルする方式だけど、もうちょっとマシな方式は無いのか、本当はそれを記事にしたかったがタイムアップ…)

knjname
Zenn: https://zenn.dev/knjname
http://knjname.hateblo.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away