从零开始使用 webpack 搭建 typescript + react 项目

为了熟悉 webpack 相关工具链,考虑跟随官方文档,从零开始去创建一个 typescript + react 的项目以作为实践,其中尽量为所有配置项的配置和意义都给予描述,专注所以然而非其然。这里想达到下面的目标:

  • 最少的配置项,所有配置项都明确描述,尽量依赖默认配置项
  • 支持 typescript,tsx,集成 react
  • 提供和 create-react-app 一样的体验

啥是 webpack

简单来说,webpack 是一个静态的打包器,webpack 从特定的 js 文件(称为 entry)开始去构建对应的依赖树,其中包括该 js 文件依赖(import)的文本,css,其它 js 文件等,每个依赖树或 entry 都会打成一个包 bundle,也称为 chunk。

每个可以 import 的文件称为 module。

项目创建

首先创建一个新文件,执行npm init,一路 Enter,创建一个默认的 package.json 文件,然后安装相应依赖。

1
2
npm i -D webpack webpack-cli ts-loader css-loader style-loader ts-node typescript mini-css-extract-plugin html-webpack-plugin @types/node @types/react @types/react-dom @types/webpack  
npm i -S react react-dom react-scripts

编辑 package.json,移除 main 属性,添加 private 属性为 true。

然后初始化 tscconfig.json:

1
npx tsc -init

first step

先研究一下 webpack 的基础用法,考虑创建文件夹 src,创建两个 js 文件 index.js,Util.js:

1
2
3
4
5
6
7
// Util.js
export const add = (x, y) => x + y

// index.js
// 使用 import 去引入依赖
import {add} from './Util'
console.log(add(1, 2))

这时的项目结构:

1
2
3
4
5
6
dir
|- package.json
|- package-lock.json
|- /src
|- index.js
|- Util.js

然后执行npx webpack命令,能够发现它生成了dist/main.js,其中内容为:

1
(()=>{"use strict";console.log(3)})();

显然,webpack 将 index.js 和 Util.js 打包成了 main.js 文件,这就是 webpack 的默认行为——以src/index.js为 entry,输出到dist/main.js。因为 index.js 引入了 Util.js,因此 Util.js 被一并打包(并进行了优化)。这时候我们就可以在 dist 目录下添加 index.html 并引入 main.js 以查看效果。

但对其它文件呢?比如 jsx 文件?考虑编写一个简单的 jsx 文件并将其 import:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Hello.jsx
export default function Hello() {
return (
<div>
<p>Hello, Happy World!</p>
</div>
)
}

// index.js
import Hello from './Hello'
console.log(Hello())

再执行npx webpack,webpack 会试图找 js,json,wasm 后缀的 Hello 文件,找不到后抱怨Field 'browser' doesn't contain a valid alias configuration,这是 webpack 的默认配置,而这里显然需要自定义配置。

创建配置文件

在项目根目录(package.json 同级目录)下创建文件 webpack.config.ts(也可以是 js,但这里利用上 ts 的类型机制;webpack 默认不支持 ts,需要安装 ts-loader,ts-node 依赖),这个文件将是 webpack 的配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import path from 'path'
import { Configuration } from 'webpack'

// 添加 devServer 相关类型定义,没有这个 Configuration 中就没有 devServer 配置项了
import 'webpack-dev-server';

// Configuration 是 Webpack 的配置项类型
const conf: Configuration = {
mode: 'development', // 默认为 production 模式
entry: './src/index.ts', // 入口 js 文件,可以配置多个 entry
output: {
path: path.resolve(__dirname, 'dist'), // 输出路径
filename: 'main.js', // 输出文件名,具有一定动态性,如可配置成 [name].[chunkhash].js,其会被替换为名称和一个文件哈希值(以保证浏览器会重新加载,但这需要相关 plugin 去支持,后面再详述)
},
resolve: {
// webpack 将识别这些后缀文件为 module
// 这个配置是覆盖原配置的,因此应当给定所有后缀,否则使用第三方库时必然会出问题
extensions: [".wasm", ".ts", ".tsx", ".mjs", ".cjs", ".js", ".json"]
},
module: { // 关于 module 的配置
// 在这里配置 loader
},
}

export default conf

配置 ts 支持

webpack 加载文件是通过 loader 去进行的,特定的 loader 加载特定类型的文件(毕竟要能够打包到 js 文件中,因此必须要将这些文件变成 js 代码格式),比如 raw-loader 加载文本文件,ts-loader 加载 typescript 文件……

在配置中的 module.rule 项便可以配置 loader 以及相应的后缀,这里配置 tsx 和 ts:

1
2
3
4
5
6
7
8
9
10
11
12
{
// ...
module: { // 关于 module 的配置
rules: [
{
test: /\.tsx?$/, // ts 或 tsx
use: 'ts-loader',
exclude: /node_modules/ // 排除 node_module 下的 ts 文件
},
]
},
}

配置了这项后,便可以去直接导入 ts 和 tsx 文件了,index 文件也可以改成 ts。tsx 文件需要在 tsconfig.json 中添加一项配置(毕竟 typescript 需要知道要的究竟是哪个 tsx 实现):

1
"jsx": "react-jsx",    /* Specify what JSX code is generated. */

配置 sourceMap

如果仅做了上面的配置,当代码中出现未捕获的异常中,控制台中无法看到异常出现的位置,这在开发中是无法容忍的,这将通过配置sourceMap来解决:

1
2
3
4
{
// ...
devtool: 'inline-source-map'
}

配置绝对路径导入

使用相对路径去 import 可读性不好,且重构的时候也不方便,实现一个绝对路径的导入是比较重要的。

绝对路径的配置需要配置两个部分——webpack 和 tsconfig,前者是为了让 webpack 去确定路径,后者为了让 ts 编译器确定路径。这里我们想让@去代替 src 目录,因此我们可以使用形如@/util的形式去导入。

对于 webpack,添加 resolve.alias 配置:

1
2
3
4
5
6
7
8
9
{
// ...
resolve: {
// ...
alias: {
'@': path.join(__dirname, '/src/') // 配置@为 src 目录,使不需要每次都使用相对路径去 import
}
},
}

对于 tsconfig,添加下面的配置:

1
2
3
4
5
6
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": {
"@/*": [
"src/*"
],
}, /* Specify a set of entries that re-map imports to additional lookup locations. */

这里的 baseUrl 似乎是默认配置,但不给定这个配置的话 vscode 不会补全路径

既让 webpack 满意,也能让 tsc 满意,目的已经达到了。

需注意,tsc 编译时不会去转换 paths,因此使用 ts-node 去运行,或者使用 tsc 编译后使用 node 去运行时会报错,这在 webpack 环境下不是问题,但倘若将服务端的代码也放在这就会有问题了,解决方案是使用 tsconfig-paths,这里先不研究这个。

编写 react 示例

好玩的地方来了,在上面的基础上创建一个 react 示例。

首先需要在 dist 下去创建一个文件 index.html,编辑其 body,添加一个根元素并引入 main.js:

1
2
3
4
<body>
<div id="root"></div>
<script src="./main.js" defer></script>
</body>

然后编写 index.tsx 和 App.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// index.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App'

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);

root.render(<App />)

// App.tsx
import React, { Fragment } from "react";
import { useReducer } from "react";

export default function App() {
const [counter, plus] = useReducer(c => c + 1, 0)
return (
<Fragment>
<p>Hello, Fucking World</p>
<button onClick={plus}>Counter: {counter}</button>
</Fragment>
)
}

执行npx webpack,在 dist 目录下执行http-server,访问127.0.0.1:8080,bingo!

快结束了,但这里仍有几个问题:

  1. 每次修改源代码都需要重新编译,费时费力
  2. 可能有缓存问题(即 main.js 更新,但浏览器仍旧用的旧的 main.js)
  3. css 等重要的资源未提供通过 js 的导入方式
  4. 未提供方便命令去进行编译,打包等操作

配置 dev server 和 http-plugin

解决问题 1,2 需要一个合适的热更新机制,webpack 提供了相应机制,即 webpack-dev-server,其会监测源代码的改变并进行热更新。

devServer 在 webpack 配置文件中配置,只需要添加下面的配置即可:

1
2
3
4
devServer: {
open: true,
static: 'dist/' // devServer 将从这个位置去获取所有“静态”内容,即 html,js,css 等,这里应当修改为输出目录
},

配置此之后,执行npx webpack serve便可启动 devServer。

然后是解决缓存问题,这里配置输出 bundle 文件名为[name].[chunkhash].js,配置一个插件 HtmlWebpackPlugin,该插件会自动生成 html 文件,其中会自动引入相应 script 标签:

1
2
3
4
5
6
7
8
9
10
11
// ...
output: {
// ...
filename: '[name].[chunkhash].js',
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'index.html') // 指定 html “模板”文件
}),
new ProgressPlugin() // 展示进度
],

这里因为要创建一个根 div,且保留原有的 header,因此使用一个模板 html,内容为之前的 html 移除 script 标签的内容。此时启动服务器,查看网页的 header,能看到 js 文件被自动引入了,且原有的 header,body 被保留了。

导入 css 文件

现在我们希望能够导入 css 文件,但又不想去手动编辑 html 文件,如何去操作呢?当然仍旧是使用 loader 去实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
resolve: {
// ...
extensions: [".wasm", ".ts", ".tsx", ".mjs", ".cjs", ".js", ".json" , "css"], // 添加 css
},
module: { // 关于 module 的配置
// ...
rules: [
// ...
{ // 添加 rule
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
}

需要注意的是,cssloader 需要两个 loader,其中执行顺序为从后到前,即先执行 css-loader,再执行 style-loader。css-loader 的作用是处理 css 文件中的 import,style-loader 则是添加这样的 js 代码,即将 css 文件的内容在运行时添加到 header。

单这样问题就来了——css 文件全放到 js 里会大大增加 js 文件的大小,有什么方法可以规避的?可以使用 MiniCssExtractPlugin 这个插件使在编译时将文件复制到目标目录;配置 MiniCssExtractPlugin 需要配置 plugins 以及相应 loader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...
plugins: [
// ...
new MiniCssExtractPlugin()
],
module: { // 关于 module 的配置
rules: [
// ...
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
}

导入图片资源

导入 css 很容易,但若是想导入如图片等资源呢?我们可能会在配置文件中加入相应 loader,然后这样使用图片资源:

1
2
3
import imageUrl from '@/img/abc.png'
// ...
return <img src={imageUrl} />

但在 typescript 里,这样会有一个问题——tsc 不知道从这图片文件中究竟导出了什么玩意,这需要我们对 tsc 进行配置,方式是在源代码目录中添加一个 d.ts 文件,并进行相关定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// env.d.ts
declare module '*.gif' {
const src: string;
export default src;
}

declare module '*.jpg' {
const src: string;
export default src;
}

declare module '*.jpeg' {
const src: string;
export default src;
}

declare module '*.png' {
const src: string;
export default src;
}

然后在配置文件中添加下面的规则,这里使用了 webpack 5 对静态资源的处理方式 asset module,不需要额外的 loader,原始方法是使用 url-loaderfile-loader

1
2
3
4
{
test: /\.(png|jpe?g|gif)$/, // 图片文件按 url
type: 'asset/resource' // 导出 url,注意这里属性名不是 use
}

配置完毕后,typescript 就能正确识别导出的类型了。

添加启动命令

每次执行都输入一堆东西很麻烦?考虑在 package.json 中添加相应命令:

1
2
3
4
5
6
7
"scripts": {
"build": "webpack --mode=production --node-env=production",
"build:dev": "webpack --mode=development",
"build:prod": "webpack --mode=production --node-env=production",
"watch": "webpack --watch",
"serve": "webpack serve"
}

这里只是介绍最简单的使用,各种优化,开发和生产配置分离,loader 的配置,plugin,webpack 的整个生命周期,测试……有需要再去学习。之后也考虑在这篇笔记的基础上搭建 MERN 环境。

上述代码见

参考资料