webpack 5 Node.js polyfill

webpack 4 以前では Node.js core module の polyfill が webpack 自体に同梱されていて、bundle したい module が 1 つでも core module の機能を使っていれば自動的にその polyfill が追加されていました。 しかし webpack 5 からはこの自動 polyfill が廃止され、真に必要な場合にのみユーザが手動で polyfill を追加するというようになりました 1

具体例を挙げると、react-markdown は unified 2 を通じて vfile というパッケージに依存しており 3、vfile は path module に依存している 4 ので、webpack 5 では何も polyfill の設定をしないと react-markdown を利用したコードのビルドに失敗します。

小さな例で実験してみましょう。

webpack v4 の場合

package.json

{
  "scripts": {
    "build": "webpack --mode production"
  },
  "devDependencies": {
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "ts-loader": "^8.0.11",
    "typescript": "^4.1.2",
    "webpack": "^4.44.2",
    "webpack-cli": "^4.2.0"
  },
  "dependencies": {
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "react-markdown": "^5.0.3"
  }
}

webpack.config.js

const path = require("path");

module.exports = {
  entry: path.resolve(__dirname, "src/index.tsx"),

  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },

  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: {
          loader: "ts-loader",
        }
      },
    ],
  },

  resolve: {
    extensions: [".ts", ".tsx", ".js"],
  },
}

tsconfig.json

{
  "compilerOptions": {
    "target": "es2020",
    "module": "esnext",
    "moduleResolution": "node",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true
  }
}

src/index.tsx

import ReactDOM from "react-dom";
import { Markdown } from "./components/Markdown";

ReactDOM.render(<Markdown></Markdown>, document.getElementById("root"));

src/components/Markdown.tsx

import ReactMarkdown from "react-markdown";

export const Markdown = () => {
  const body = `
## Hello, Markdown!

Hello! react-markdown is [here](https://github.com/remarkjs/react-markdown).
`;

  return (
    <>
      <ReactMarkdown children={body}></ReactMarkdown>
    </>
  );
};

dist/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <body>
    <div id="root"></div>
    <script src="bundle.js"></script>
  </body>
</html>

この状態で npm run build を実行するとビルドは正常に完了し、 dist/index.html をブラウザで開けば想像通りの画面が表示されます (ちなみにこの状態で bundle.js のサイズは 210 KiB でした)。

webpack v5 の場合

しかし上記の状態で webpack を最新バージョン(執筆時点では v5.9.0)に上げると:

-    "webpack": "^4.44.2",
+    "webpack": "^5.9.0",

npm run buildpath を解決できずに失敗します:

ERROR in ./node_modules/vfile/core.js 3:11-26
Module not found: Error: Can't resolve 'path' in '/<path-to-the-current-directory>/node_modules/vfile'

BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.

If you want to include a polyfill, you need to:
    - add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
    - install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
    resolve.fallback: { "path": false }
 @ ./node_modules/vfile/index.js 4:12-32
 @ ./node_modules/unified/index.js 8:12-28
 @ ./node_modules/react-markdown/lib/react-markdown.js 5:14-32
 @ ./src/components/Markdown.tsx 2:0-43 9:45-58
 @ ./src/index.tsx 3:0-49 4:21-29

そこで、エラーメッセージに従って path-browserify をインストールし resolve.fallback の設定 5 を行います:

package.json

   "devDependencies": {
     "@types/react": "^17.0.0",
     "@types/react-dom": "^17.0.0",
+    "path-browserify": "^1.0.1",
     "ts-loader": "^8.0.11",
     "typescript": "^4.1.2",
     "webpack": "^5.9.0",

webpack.config.js

   resolve: {
     extensions: [".ts", ".tsx", ".js"],
+    fallback: {
+      "path": require.resolve("path-browserify"),
+    }
   },
 }

すると一応 npm run build は成功するようになります。

が、 dist/index.html をブラウザで開くと画面には何も表示されず、コンソールに ReferenceError: process is not defined というエラーが表示されるでしょう。これは実は vfile が process という名のグローバルオブジェクトに依存しているためです 6 。 Node.js では process はグローバルな文脈に存在します 7 が、ブラウザ環境では process の shim は手で行わなければなりません。そういった特定のグローバル変数の shim を行うために imports-loader という loader が存在します:

github.com

こいつの仕事は主に test でマッチしたファイルの先頭に何らかのモジュールを import することです。

そこで

% npm i -D imports-loader process

の後 imports-loader の設定 8 を行い

webpack.config.js

           loader: "ts-loader",
         }
       },
+      {
+        test: /node_modules\/vfile\/core\.js/,
+        use: {
+          loader: "imports-loader",
+          options: {
+            type: "commonjs",
+            imports: ["single process/browser process"],
+          },
+        },
+      },
     ],
   },

改めて npm run build を実行すれば、晴れて react-markdown が使えるようになります。 ちなみにこの状態で bundle.js のサイズは 208 KiB でした。きめ細かく polyfill を設定できるようになったことで若干バンドルサイズも小さくなり、嬉しいですね。