Webpack を使って React が動くまでを理解する
動機
業務で webpack 周りを触ることになって、これまで webpack の理解はなんとなくで逃げ続けていたのでもっと理解を深めようと思った。
Create React App とか Vite とか使わずに React を動く環境を作ってみたら理解が深まるかなと思いやってみみる。
参考記事
似たようなことをやっている方がいらっしゃったので、以下の記事を参考にさせていただきました!
プロジェクトの作成
mkdir webpack-react-sandbox
cd webpack-react-sandbox
pnpm init
index.html
まずは一番基本なところでブラウザでページを開くための HTML を作成。
このファイルをブラウザで開くと Hello World!! が表示されるはず。
mkdir public
touch public/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>
React コンポーネントの作成
ミニマムで動かしたいので typescript は一旦未使用。
動作確認は webpack の設定をしてから。
pnpm i react react-dom
mkdir src
touch src/index.js src/App.jsx
import React from 'react';
import ReactDom from 'react-dom';
import App from './App';
ReactDom.render(<App />, document.getElementById('root'));
import React from 'react';
const App = () => {
return (
<div>
<h1>Hello Webpack React Sandbox</h1>
</div>
);
};
export default App;
Webpack の初期設定
pnpm i -D webpack webpack-cli webpack-dev-server
ここで疑問。
webpack.config.js がなくても webpack は実行できるのか?
pnpm webpack build
結果エラーは出たがビルド自体は実行された。
webpack のデフォルト設定でビルドされてる様子?
src/index.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'));
|
React コンポーネントに対応する
babel を利用することで、jsx を js に変換することが出来るらしい。
pnpm i -D @babel/preset-react babel-loader
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 が出力された。
ブラウザ上で動作確認する
webpack の serve コマンドを使うことで、web ページをホスティングすることが出来る。
pnpm webpack serve
成功すると http://localhost:8080/ でページがホスティングされているはず。
今の index.html だと bundle.js を読み込む設定になっていないので、script タグと React を mount する要素を作成する。
<!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 フォルダを見に行くようになっているのかな🤔
Hot Module Replacement
今のままだと更新のたびにビルドする必要があるので、自動で再ビルドが走るようにする。
module.exports = {
/* 省略 */
devServer: {
hot: true,
},
};
React のコンポーネントの文字列を適当に変更してみる。
しかし、コンソール上では再ビルドが走っているように見えるがブラウザ上には変更が反映されない、、
public/js/bundle.js を見ても変更が反映されていない模様。
どうやら React で HRM するにはライブラリを使う必要があるみたい。
README にしたがって設定してみる。
pnpm add -D @pmmmwh/react-refresh-webpack-plugin react-refresh
設定してみたがうまく動かなかったので保留、、
Typescript 対応
pnpm add -D typescript @types/react @types/react-dom ts-loader
pnpm tsc --init
tsconfig は以下の記事を参考にサバイバル Typescript をベースにしてみた。
{
"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 に対応出来るように設定を変更。
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 も同様に修正してビルドしたところ成功!
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 を使うのであれば上記のどれかインストールしてくれやってことらしい。
不足してた 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
バンドルサイズ
ここまでの状態で 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 が組み込まれているみたい!
ライブラリの 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