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](https://classic.yarnpkg.com/ja/docs/workspaces/)による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](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/import)

`export` / `import` ですね。

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

```js
// インポート
import { helloWorld } from "./hello-world.js"

helloWorld();
```

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

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

### [CommonJS](https://ja.wikipedia.org/wiki/CommonJS)

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

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

```js
// インポート
require("hello-world").helloWorld();
```

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

### Browserify / AMD

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

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

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

### TypeScript では？

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

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

#### 元のソース

```ts:src/hello.ts
export const helloWorld = function () { console.log("Hello") }
```

```ts:src/index.ts
import {helloWorld} from "./hello"

helloWorld()
```

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

```js:build/hello.js
export var helloWorld = function () { console.log("Hello"); };
```

```js:build/index.js
import { helloWorld } from "./hello";
helloWorld();
```

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

```sh
$ 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で実行できるようになります。

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

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

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

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

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

```sh
$ node build/index.js 
Hello
```

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

### ユースケース

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

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

helloWorld();
```

### `packages/common/package.json`

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

```js
{
    "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回トランスパイルしてあげればいいです。

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

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

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

## 動作確認する

### CommonJS (NodeJS)

```json:package.json
{
    "name": "@my/nodejs",
    "version": "1.0.0",
    "dependencies": {
        "@my/common": "*"
    }
}
```

```js:packages/nodejs/src/index.js
const { helloWorld } = require("@my/common")

helloWorld("nodejs");
```

```sh
$ node src/index.js 
Hello from nodejs
```

ちゃんと動作しました。

### ES6 (Webpack)

そもそもWebpackさんは別にCommonJSは処理できるのですが、じゃあなぜES6 Modulesにこだわるかといえば、ちゃんと Tree Shaking される条件として、[ES6 (ES2015) Modulesであること](https://webpack.js.org/guides/tree-shaking/)というのがあるからですね。

> - **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](https://github.com/facebook/create-react-app)を使います。

```sh
$ cd packages

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

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

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

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

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

helloWorld("react");
```

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

```js:packages/common/src/dummy.js
export const dummy = function () { console.log(`I AM NOT SUPPOSED TO BE INCLUDED IN WEBPACK BUILD.`) }
```

```js: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
```

ちゃんと消えてますね。

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

```js: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 だけならどうでしょうか？

```js: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ならどうでしょうか？ `common` の `package.json` からES6モジュールの記述を消して検証してみます。

```js:packages/common/package.json
{
    "name": "@my/common",
    "version": "1.0.0",
    "main": "commonjs/index.js",
    // "module": "es6/index.js", ← ここを消す
```

```sh
$ 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回トランスパイルする方式だけど、もうちょっとマシな方式は無いのか、本当はそれを記事にしたかったがタイムアップ…）