Closed11

Webpack を使って React が動くまでを理解する

つねみ@tocomiつねみ@tocomi

動機

業務で webpack 周りを触ることになって、これまで webpack の理解はなんとなくで逃げ続けていたのでもっと理解を深めようと思った。

Create React App とか Vite とか使わずに React を動く環境を作ってみたら理解が深まるかなと思いやってみみる。

参考記事

似たようなことをやっている方がいらっしゃったので、以下の記事を参考にさせていただきました!

https://www.luku.work/babel-react-preset
https://qiita.com/Mr_ozin/items/b6749e60b185a26b97f0
https://dev-k.hatenablog.com/entry/building-react-with-webpack-for-beginners

つねみ@tocomiつねみ@tocomi

index.html

まずは一番基本なところでブラウザでページを開くための HTML を作成。
このファイルをブラウザで開くと Hello World!! が表示されるはず。

mkdir public
touch public/index.html
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Webpack React Sandbox</title>
  </head>
  <body>
    <h1>Hello World!!</h1>
  </body>
</html>
つねみ@tocomiつねみ@tocomi

React コンポーネントの作成

ミニマムで動かしたいので typescript は一旦未使用。
動作確認は webpack の設定をしてから。

pnpm i react react-dom

mkdir src
touch src/index.js src/App.jsx
index.js
import React from 'react';
import ReactDom from 'react-dom';
import App from './App';

ReactDom.render(<App />, document.getElementById('root'));
App.jsx
import React from 'react';

const App = () => {
  return (
    <div>
      <h1>Hello Webpack React Sandbox</h1>
    </div>
  );
};

export default App;
つねみ@tocomiつねみ@tocomi

Webpack の初期設定

pnpm i -D webpack webpack-cli webpack-dev-server

ここで疑問。
webpack.config.js がなくても webpack は実行できるのか?

pnpm webpack build

結果エラーは出たがビルド自体は実行された。
webpack のデフォルト設定でビルドされてる様子?
src/index.js がデフォルトのエンドポイントのように見えた。
ファイル名を変えるとビルド以前の部分でエラーが出たため。

あらためて、webpack.config.js を設定していく。
公式サイトや参考ページを見つつ最低限必要そうなプロパティを設定。

webpack.config.js
const path = require('path');

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src/index.js'),
  output: {
    path: path.resolve(__dirname, 'public/js'),
    filename: 'bundle.js',
  },
  resolve: {
    extensions: ['.js', '.jsx'],
  },
  target: ['web', 'es6'],
};
pnpm webpack build

結果エラーが発生。
エラー箇所から JSX を解釈出来ていない模様。

ERROR in ./src/index.js 5:16
Module parse failed: Unexpected token (5:16)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| import App from './App';
|
> ReactDom.render(<App />, document.getElementById('root'));
|
つねみ@tocomiつねみ@tocomi

React コンポーネントに対応する

babel を利用することで、jsx を js に変換することが出来るらしい。

https://babeljs.io/docs/babel-preset-react

pnpm i -D @babel/preset-react babel-loader
webpack.config.js
module.exports = {
  /* 省略 */
  module: {
    rules: [
      // .jsまたは.jsxファイルをBabelでトランスパイルする
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: ['babel-loader'],
      },
    ],
  },
};
pnpm webpack build

ビルドが成功した!
webpack.config.js で指定した通り、public/js フォルダに bundle.js が出力されている。
bundle.js を覗いてみると、createElement(\"h1\", null, \"Hello React\")); みたいな感じで jsx が js に変換されている部分が見てとれる。

ちなみに、webpack.config.js で output を指定しなかった場合は dist/main.js が出力された。

つねみ@tocomiつねみ@tocomi

ブラウザ上で動作確認する

webpack の serve コマンドを使うことで、web ページをホスティングすることが出来る。

pnpm webpack serve

成功すると http://localhost:8080/ でページがホスティングされているはず。
今の index.html だと bundle.js を読み込む設定になっていないので、script タグと React を mount する要素を作成する。

index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Webpack React Sandbox</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="/js/bundle.js"></script>
  </body>
</html>

再度ビルドをしてからブラウザを更新すると、React のコンポーネントに書いた Hello Webpack React Sandbox が表示される。

なんで index.html が読み込まれる?

webpack.config.js には index.html の情報は渡してないのにどうして読み込んでくれるのだろう?
試しに public フォルダの名前を変えてみるとページが読み込まれなくなった。
デフォルトで public フォルダを見に行くようになっているのかな🤔

つねみ@tocomiつねみ@tocomi

Hot Module Replacement

今のままだと更新のたびにビルドする必要があるので、自動で再ビルドが走るようにする。

webpack.config.js
module.exports = {
  /* 省略 */
  devServer: {
    hot: true,
  },
};

React のコンポーネントの文字列を適当に変更してみる。
しかし、コンソール上では再ビルドが走っているように見えるがブラウザ上には変更が反映されない、、
public/js/bundle.js を見ても変更が反映されていない模様。

どうやら React で HRM するにはライブラリを使う必要があるみたい。

https://github.com/pmmmwh/react-refresh-webpack-plugin

README にしたがって設定してみる。

pnpm add -D @pmmmwh/react-refresh-webpack-plugin react-refresh

設定してみたがうまく動かなかったので保留、、

つねみ@tocomiつねみ@tocomi

Typescript 対応

pnpm add -D typescript @types/react @types/react-dom ts-loader
pnpm tsc --init

tsconfig は以下の記事を参考にサバイバル Typescript をベースにしてみた。

https://zenn.dev/t_keshi/scraps/9ddb388bc6975d

tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "lib": ["es2020", "dom"],
    "jsx": "react-jsx",
    "sourceMap": true,
    "outDir": "./public",
    "rootDir": "./src",
    "strict": true,
    "moduleResolution": "node",
    "baseUrl": "src",
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["public", "node_modules"],
  "compileOnSave": false
}

React のファイルの拡張子を .ts, .tsx に変更。
webpack.config.js でも .ts, .tsx に対応出来るように設定を変更。

webpack.config.js
const path = require('path');

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src/index.ts'),
  output: {
    path: path.resolve(__dirname, 'public/js'),
    filename: 'bundle.js',
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
  },
  target: ['web', 'es6'],
  module: {
    rules: [
      {
        test: /\.([jt]sx?)$/,
        exclude: /node_modules/,
        use: ['babel-loader', 'ts-loader'],
      },
    ],
  },
  devServer: {
    hot: true,
  },
};

ビルドすると以下のようなエラーが出た。
JSX で <App /> となるところが、バンドル後に /> となってしまっている。

❯ pnpm webpack build
asset bundle.js 9.61 KiB [emitted] (name: main)
./src/index.ts 39 bytes [built] [code generated] [4 errors]

ERROR in ./src/index.ts
Module build failed (from ./node_modules/babel-loader/lib/index.js):
SyntaxError: /Users/tocomi/develop/personal/webpack/webpack-react-sandbox/src/index.ts: Unterminated regular expression. (7:28)

  5 | Object.defineProperty(exports, "__esModule", { value: true });
  6 | const react_dom_1 = __importDefault(require("react-dom"));
> 7 | react_dom_1.default.render(/>, document.getElementById('root')););

ここで、そもそも ts 拡張子で JSX 記法を使っていることがおかしいなと思い、index.tsx に変更して webpack の entry も同様に修正してビルドしたところ成功!

つねみ@tocomiつねみ@tocomi

webpack.config も ts にする

とりあえず拡張子を ts に変えてビルドしてみる。

❯ pnpm webpack build
[webpack-cli] Unable load '/Users/tocomi/develop/personal/webpack/webpack-react-sandbox/webpack.config.ts'
[webpack-cli] Unable to use specified module loaders for ".ts".
[webpack-cli] Cannot find module 'ts-node/register' from '/Users/tocomi/develop/personal/webpack/webpack-react-sandbox'
[webpack-cli] Cannot find module 'sucrase/register/ts' from '/Users/tocomi/develop/personal/webpack/webpack-react-sandbox'
[webpack-cli] Cannot find module '@babel/register' from '/Users/tocomi/develop/personal/webpack/webpack-react-sandbox'
[webpack-cli] Cannot find module 'esbuild-register/dist/node' from '/Users/tocomi/develop/personal/webpack/webpack-react-sandbox'
[webpack-cli] Cannot find module '@swc/register' from '/Users/tocomi/develop/personal/webpack/webpack-react-sandbox'
[webpack-cli] Please install one of them

ts を使うのであれば上記のどれかインストールしてくれやってことらしい。

https://webpack.js.org/configuration/configuration-languages/

不足してた package をインストールする。

pnpm add -D ts-node @types/node @types/webpack

再度ビルドしたら成功!

わからないこと🤔

tsconfig.json の compilerOptions.module を commonjs から es2015 とかに変更するとエラーが出る。
import 文の解釈が出来ないみたいだけど、import/export は ES の記法だからなんで commonjs のときだけ成功するのかよくわからなかった。
なんとなく ts-node が怪しいなと思って調べてみたが問題の解消には至らず。

❯ pnpm webpack build
[webpack-cli] Failed to load '/Users/tocomi/develop/personal/webpack/webpack-react-sandbox/webpack.config.ts' config
[webpack-cli] /Users/tocomi/develop/personal/webpack/webpack-react-sandbox/webpack.config.ts:1
import path from 'path';
^^^^^^

SyntaxError: Cannot use import statement outside a module
つねみ@tocomiつねみ@tocomi

バンドルサイズ

ここまでの状態で production ビルドをしてバンドルサイズを見てみる。

❯ pnpm webpack build --mode production
asset bundle.js 139 KiB [compared for emit] [minimized] (name: main) 1 related asset

139 KiB だった。
bundle.js を見ると minify とかされてそうで、production ビルドの時点で一定の最適化はデフォでされるのかな。

以下のページを見ると、webpack5 はデフォルトで terser-webpack-plugin が組み込まれているみたい!
https://webpack.js.org/plugins/terser-webpack-plugin/

ライブラリの import

試しに lodash を import したコンポーネントを追加してみる。

cjs 一括 import

pnpm add lodash
pnpm add -D @types/lodash
import { useMemo } from 'react';
import lodash from 'lodash';

export const LibsUsingComponent = () => {
  const value = useMemo(() => {
    return lodash.sortBy([3, 2, 1]);
  }, []);

  return <div>{value}</div>;
};

バンドルサイズが 207KiB に増えた。

❯ pnpm webpack build --mode production
asset bundle.js 207 KiB [emitted] [minimized] (name: main) 1 related asset

cjs 関数指定 import

import { useMemo } from 'react';
import sortBy from 'lodash/sortBy';

export const LibsUsingComponent = () => {
  const value = useMemo(() => {
    return sortBy([3, 2, 1]);
  }, []);

  return <div>{value}</div>;
};

160KiB まで減少した。

❯ pnpm webpack build --mode production
asset bundle.js 160 KiB [emitted] [minimized] (name: main) 1 related asset

ESM 一括 import

lodash には ESM 版もあるのでそちらと比較してみる。

pnpm add lodash-es
pnpm add -D @types/lodash-es
import { useMemo } from 'react';
import lodash from 'lodash-es';

export const LibsUsingComponent = () => {
  const value = useMemo(() => {
    return lodash.sortBy([3, 2, 1]);
  }, []);

  return <div>{value}</div>;
};

この import の仕方だと、ESM だとしても tree shaking は効かない。

❯ pnpm webpack build --mode production
asset bundle.js 226 KiB [emitted] [minimized] (name: main) 1 related asset

ESM 特定関数 import

import { useMemo } from 'react';
import { sortBy } from 'lodash-es';

export const LibsUsingComponent = () => {
  const value = useMemo(() => {
    return sortBy([3, 2, 1]);
  }, []);

  return <div>{value}</div>;
};

tree shaking が効いてサイズが落ちると思ったら変わらなかった🤔
-> tsconfig の module を commonjs から esnext に変えたら tree shaking が効いた🙌

❯ pnpm webpack build --mode production
asset bundle.js 226 KiB [compared for emit] [minimized] (name: main) 1 related asset

ESM 関数指定 import

import { useMemo } from 'react';
import sortBy from 'lodash-es/sortBy';

export const LibsUsingComponent = () => {
  const value = useMemo(() => {
    return sortBy([3, 2, 1]);
  }, []);

  return <div>{value}</div>;
};

155KiB。cjs と同様にこれはバンドルサイズが小さくなる。

❯ pnpm webpack build --mode production
asset bundle.js 155 KiB [emitted] [minimized] (name: main) 1 related asset
このスクラップは2024/02/26にクローズされました