从零开始配置 react + typescript(三):webpack

核心提示本篇为 从零开始配置 react + typescript 系列第三篇,将带大家完成模板项目的 webpack 配置。整个项目的配置我力求达到以下目标:灵活: 我在配置 eslint 是选择使用 js 格式而不是 json,就是为了灵活性,

本篇为 从零开始配置 react + typescript 系列第三篇,将带大家完成模板项目的 webpack 配置。整个项目的配置我力求达到以下目标:灵活: 我在配置 eslint 是选择使用 js 格式而不是 json,就是为了灵活性,使用 js 文件可以让你使用导入其它模块,根据开发环境动态配置,充分发挥 js 语言的能力。

新潮: 我觉得时刻保持对新事物的关注和尝试去使用它是一个优秀的素质。

当然,追新很容易碰到坑,但是,没关系,我已经帮你们踩过了,踩不过去我也不会写出来 。从我 eslint parserOptions.ecmaVersion 设置为 2020, 还有经常来一发 yarn upgrade --latest 都可以体现出来。严格: 就像我平时判断相等性我大多数情况都是使用严格等 ===,而不是非严格等 ==,我觉得越严格,分析起来就越清晰,越早能发现问题。

例如我么后面会使用一些 webpack 插件来严格检查模块大小写,检查是否有循环依赖。

安逸: 项目中会尽量集成当前前端生态界实用的和能提高开发愉悦性的(换个词就是花里胡哨)工具。生产 ready:配置的时候针对不同的打包环境针对性优化,并确保能够投入生产环境使用。本篇将分三大部分介绍:

  1. dev server
  2. 开发环境优化
  3. 生产环境优化
如果读者是初次看到这篇文章,建议先看下前两篇:
  1. 从零开始配置 react + typescript:dotfiles
  2. 从零开始配置 react + typescript:linters 和 formatter
项目地址:react-typescript-boilerplatedev server想当初我刚开始学前端框架的那时候,也是被 webpack 折磨的欲仙欲死,我是先自学的 node 才开始写前端,写 nodejs 很方便,自带的模块化方案 commonjs,写前端项目就要配置打包工具。

当时最火的打包工具已经是 webpack 了,其次就是 gulp。配置 webpack 总是记不住 webpack 配置有哪些字段,还要扯到一堆相关的工具像 ES6 编译器 babel,CSS 预处理器 sass/less,CSS 后处理器 postcss,以及各种 webpack 的 loader 和 plugin。

然后嫌麻烦就有一段时间都是用官方的脚手架,react 就用 cra,也就是 create-react-app,vue 就用 vue-cli

其实也挺好用的,不过说实话,我个人觉得,cravue-cli 设计的好,无论是易用性和扩展性都完败,cra 不方便用户修改 webpack 配置,vue-cli 不但易于用户修改 webpack 配置,还能让用户保存模板以及自带插件系统。我感觉 react 官方也意识到了这点,所以官方声称近期将会重点优化相关工具链。现在的话,如果我新建一个前端项目,我会选择自己配,不会去采用官方的 cli,因为我觉得我自己已经相当熟悉前端各种构建工具了,等我上半年忙完毕业和找工作的事情我应该会将一些常用的配置抽成一个 npm 包,现在每次写一个项目都 copy 改太累了,一个项目的构建配置有优化点,其它项目都要手动同步一下,效率太低。

技术选型Typescript 作为静态类型语言,相对于 js 而言,在类型提示上带来的提升无疑是巨大的。借助 IDE 的类型提示和代码补全,我们需要知道 webpack 配置对象有哪些字段就不用去查官方文档了,而且还不会敲错,很安逸,所以开发语言就选择 Typescript。官方文档上有专门一节 Configuration Languages 介绍 webpack 命令行工具怎么使用 ts 格式的配置文件 ,我觉得 webpack-dev-server 命令行工具应该是一样的。但是我不打算使用官方文档介绍的方式,我压根不打算使用命令行工具,用 node API 才是最灵活的配置方式。

配置 webpack devServer 总结一下有以下方式:

  1. webpack-dev-server,这是最不灵活的方式,当然使用场景简单的情况下还是很方便的
  2. webpack-dev-server node API,在 node 脚本里面调用 web-dev-server 包提供的 node API 来启动 devServer
  3. express + webpack devServer 相关中间件,实际上 webpack-dev-server 就是使用 express 以及一些 devServer 相关的中间件开发的。在这种方式下, 各种中间件直接暴露出来了,我们可以灵活配置各个中间件的选项。
  4. koa + webpack devServer 相关中间件,我在 github 上还真的搜到了和 webpack devServer 相关的 webpack 中间件。其实 webpack devServer 就是一个 node server 嘛,用什么框架技术实现不重要,能实现我们需要的功能就行。

我最终采用 express + webpack devServer 相关中间件的方式,为什么不选择用 koa ?因为我觉得官方用的就是 express,用 express 肯定要比 koa 更成熟稳定,坑要少一些。实现最基本的打包功能从简到繁,我们先来实现最基本的打包功能使其能够打包 tsx 文件,在此基础上一步一步丰富,优化我们的配置。配置入口文件先安装 Typescript:

# 本地安装开发依赖 typescript

yarn add typescript -D
每个 Typescript 项目都需要有一个 tsconfig.json 配置文件,使用下面的命令在 src 目录下新建 tsconfig.json 文件:

cd

src

&&

npx tsc --init

&&

cd

..
我们暂时调整成这样:

{

"compilerOptions"

:

{

"jsx"

:

"react"

,

"isolatedModules"

:

true

,

"strict"

:

true

,

"noUnusedLocals"

:

true

,

"noUnusedParameters"

:

true

,

"noImplicitReturns"

:

true

,

"noFallthroughCasesInSwitch"

:

true

,

"moduleResolution"

:

"node"

,

"esModuleInterop"

:

true

,

"resolveJsonModule"

:

true

,

"baseUrl"

:

"./"

,

"paths"

:

{

// 配置模块路径映射

"@

"experimentalDecorators"

:

true

,

"emitDecoratormetadata"

:

true

,

"forceConsistentCasingInFileNames"

:

true

,

"skipLibCheck"

:

true

,

// 下面这些选项对 babel 编译 Typescript 没有作用但是可以让 VSCode 等编辑器正确提示错误

"target"

:

"ES2019"

,

"module"

:

"ESNext"

}

}

我们将使用 babel 去编译 Typescript,babel 在编译 Typescript 代码是直接去掉 Typescript 的类型,然后当成普通的 javascript 代码使用各种插件进行编译,tsc 并没有介入编译过程,因此 tsconfig.json 中很多选项例如 targetmodule 是没有用的。启用 isolatedModules 选项会在 babel 编译代码时提供一些额外的检查,esModuleInterop 这个选项是用来为了让没有 default 属性的模块也可以使用默认导入,举个简单的例子,如果这个选项没开启,那你导入 fs 模块只能像下面这样导入:

import

*

as

fs

from

'fs'

;

开启了以后,可以直接使用默认导入:

import

fs

from

'fs'

;

本质上 ESM 默认导入是导入模块的 default 属性:

import

fs

from

'fs'

;

// 等同于

import

*

as

__module__

from

'fs'

;

let

fs

=

__module__

.

default

;

但是 node 内建模块 fs 是没有 default 属性的,开启 isolatedModules 选项就会在没有 default 属性的情况下自动转换:

import

fs

,

{

resolve

}

from

'fs'

;

// 转换成

import

*

as

fs

from

'fs'

;

let

{

resolve

}

=

fs

;

我们添加一个入口文件 src/index.tsx,内容很简单:

import

plus

from

'./plus'

;

console

.

log

);

// => 2020

src/plus.ts 内容为:

export

default

function

plus

{

return

nums

.

reduce

=>

pre

+

current

,

0

);

}

编译 Typescript我们知道 webpack 默认的模块化系统只支持 js 文件,对于其它类型的文件如 jsx, ts, tsx, vue 以及图片字体等文件类型,我们需要安装对应的 loader。

对于 ts 文件,目前存在比较流行的方案有三种:

  1. babel + @babel/preset-typescript
  2. ts-loader
  3. awesome-typescript-loader
awesome-typescript-loader 就算了,作者已经放弃维护了。首先 babel 我们一定要用的,因为 babel 生态有很多实用的插件。虽然 babel 是可以和 ts-loader 一起用,ts-loader 官方给了一个例子 react-babel-karma-gulp,但是我觉得既然 babel 已经能够编译 Typescript 我们就没必要再加一个 ts-loader,所以我选择方案一。

需要指出的一点就是就是 babel 默认不会检查 Typescript 的类型,后面 webpack 插件部分我们会通过配置 fork-ts-checker-webpack-plugin 来解决这个问题。添加 webpack 配置我们将把所有 node 脚本放到项目根目的 scripts 文件夹,因为 src 文件夹是前端项目,而 scripts 文件夹是 node 项目,我们应该分别配置 tsconfig.json,通过下面的命令在其中生成初始的 tsconfig.json 文件:

cd

./scripts

&&

npx tsc --init

&&

cd

..
我们调整成酱:

// scripts/tsconfig.json

{

"compilerOptions"

:

{

"target"

:

"ES2019"

,

"module"

:

"commonjs"

,

"strict"

:

true

,

"noUnusedLocals"

:

true

,

"noUnusedParameters"

:

true

,

"noImplicitReturns"

:

true

,

"noFallthroughCasesInSwitch"

:

true

,

"moduleResolution"

:

"node"

,

"esModuleInterop"

:

true

,

"resolveJsonModule"

:

true

,

"experimentalDecorators"

:

true

,

"emitDecoratormetadata"

:

true

,

"forceConsistentCasingInFileNames"

:

true

,

"skipLibCheck"

:

true

}

}

提几个需要注意的地方:
  • "target": "ES2019",其实编译级别你调的很低是没问题的,你用高级语法 tsc 就转码呗,缺点就是转码后代码体积一般会变大,执行效率也会降低,原生语法一般都是被优化过的。我喜欢调高一点,一般来说只要不用那些在代码运行平台还不支持的语法就没问题。

    自从 Typescript3.7 支持了可选链,我就开始尝试在 Typescript 使用它,但是问题来了,我之前编译级别一直都是调成最高,也就是 ESNext,因为可选链在 ES2020 已经是标准了,所以 tsc 对于可选链不会转码的。然后 node 12 还不支持可选链,就会报语法错误,于是我就降到 ES2019 了。

  • Strict Type-Checking Options,这部分全开,既然上了 Typescript 的船,就用最严格的类型检查,拒绝 Anyscript
接着我们新建 scripts/configs文件夹,里面用来存放包括 webpack 的配置文件。在其中新建三个 webpack 的配置文件 webpack.common.tswebpack.dev.tswebapck.prod.ts

webpack.common.ts 保存一些公共的配置文件,webpack.dev.ts 是开发环境用的,会被 devServer 读取,webapck.prod.ts 是我们在构建生产环境的 bundle 时用的。我们接着安装 webpack 和 webpack-merge 以及它们的类型声明文件:yarn add webpack webpack-merge @types/webpack @types/webpack-merge -Dwebpack-merge 是一个为 merge webpack 配置设计的 merge 工具,提供了一些高级的 merge 方式。不过我目前并没有用到那些高级的 merge 方式,就是当成普通的 merge 工具使用,后续可以探索一下这方面的优化。

为了编译 tsx,我们需要安装 babel-loader 和相关插件:yarn add babel-loader @babel/core @babel/preset-typescript -D新建 babel 配置文件 babel.config.js,现在我们只添加一个 Typescript preset:

// babel.config.js

module

.

exports

=

function

{

api

.

cache

;

const

presets

=

[

'@babel/preset-typescript'

];

const

plugins

=

[];

return

{

presets

,

plugins

,

};

};

添加 babel-loader 到 webpack.common.ts

// webpack.common.ts`

import

{

Configuration

}

from

'webpack'

;

import

{

projectName

,

projectRoot

,

resolvePath

}

from

'../env'

;

const

commonConfig

:

Configuration

=

{

context

:

projectRoot

,

entry

:

resolvePath

,

output

:

{

publicPath

:

'/'

,

path

:

resolvePath

,

filename

:

'js/[name]-[hash].bundle.js'

,

// 加盐 hash

hashSalt

:

projectName

||

'react typescript boilerplate'

,

},

resolve

:

{

// 我们导入ts 等模块一般不写后缀名,webpack 会尝试使用这个数组提供的后缀名去导入

extensions

:

[

'.ts'

,

'.tsx'

,

'.js'

,

'.json'

],

},

module

:

{

rules

:

[

{

// 导入 jsx 的人少喝点

test

:

/.$/

,

loader

:

'babel-loader'

,

// 开启缓存

options

:

{

cacheDirectory

:

true

},

exclude

:

/node_modules/

,

},

],

},

};

我觉得这个 react + ts 项目不应该会出现 jsx 文件,如果导入了 jsx 文件 webpack 就会报错找不到对应的 loader,可以让我们及时处理掉这个有问题的文件。使用 express 开发 devServer我们先安装 express 以及和 webpack devServer 相关的一些中间件:yarn add express webpack-dev-middleware webpack-hot-middleware @types/express @types/webpack-dev-middleware @types/webpack-hot-middleware -Dwebpack-dev-middleware 这个 express 中间件的主要作用:
  1. 作为一个静态文件服务器,使用内存文件系统托管 webpack 编译出的 bundle
  2. 如果文件被修改了,会延迟服务器的请求直到编译完成
  3. 配合 webpack-hot-middleware 实现热更新功能
webpack-hot-middleware 这个 express 中间件会将自己注册为一个 webpack 插件,监听 webpack 的编译事件。 你哪个 entry 需要实现热更新,就要在那个 entry 中导入这个插件提供的 webpack-hot-middleware/client.js 客户端补丁。这个前端代码会获取 devServer 的 Server Sent Events 连接,当有编译事件发生,devServer 会发布通知给这个客户端。

客户端接受到通知后,会通过比对 hash 值判断本地代码是不是最新的,如果不是就会向 devServer 拉取更新补丁借助一些其它的工具例如 react-hot-loader 实现热更新。下面是我另外一个还在开发的 electron 项目修改了一行代码后, client 补丁发送的两次请求:第一次请求返回的那个 h 值动动脚趾头就能猜出来就是 hash 值,发现和本地的 hash 值比对不上后,再次请求更新补丁。我们新建文件 scripts/start.ts 用来启动我们的 devServer:

import

chalk

from

'chalk'

;

import

getPort

from

'get-port'

;

import

logSymbols

from

'log-symbols'

;

import

open

from

'open'

;

import

{

argv

}

from

'yargs'

;

import

express

,

{

Express

}

from

'express'

;

import

webpack

,

{

Compiler

,

Stats

}

from

'webpack'

;

import

historyFallback

from

'connect-history-api-fallback'

;

import

cors

from

'cors'

;

import

webpackDevMiddleware

from

'webpack-dev-middleware'

;

import

webpackHotMiddleware

from

'webpack-hot-middleware'

;

import

proxy

from

'./proxy'

;

import

devConfig

from

'./configs/webpack.dev'

;

import

{

hmrPath

}

from

'./env'

;

function

openBrowser

{

if

{

let

hadOpened

=

false

;

// 编译完成时执行

compiler

.

hooks

.

done

.

tap

=>

{

// 没有打开过浏览器并且没有编译错误就打开浏览器

if

)

{

await

open

;

hadOpened

=

true

;

}

});

}

}

function

setupMiddlewares

{

const

publicPath

=

devConfig

.

output

!

.

publicPath

!

;

// 设置代理

proxy

;

// 使用 browserRouter 需要重定向所有 html 页面到首页

server

.

use

);

// 开发 chrome 扩展的时候可能需要开启跨域,参考:https://juejin.im/post/5e2027096fb9a02fe971f6b8

server

.

use

);

const

devMiddlewareOptions

:

webpackDevMiddleware

.

Options

=

{

// 保持和 webpack 中配置一致

publicPath

,

// 只在发生错误或有新的编译时输出

stats

:

'minimal'

,

// 需要输出文件到磁盘可以开启

// writeToDisk: true

};

server

.

use

);

const

hotMiddlewareOptions

:

webpackHotMiddleware

.

Options

=

{

// sse 路由

path

:

hmrPath

,

// 编译出错会在网页中显示出错信息遮罩

overlay

:

true

,

// webpack 卡住自动刷新页面

reload

:

true

,

};

server

.

use

);

}

async

function

start

{

const

HOST

=

'127.0.0.1'

;

// 4个备选端口,都被占用会使用随机端口

const

PORT

=

await

getPort

;

const

address

=

`http://

${

HOST

}

:

${

PORT

}

`

;

// 加载 webpack 配置

const

compiler

=

webpack

;

openBrowser

;

const

devServer

=

express

;

setupMiddlewares

;

const

httpServer

=

devServer

.

listen

{

console

.

error

;

return

;

}

// logSymbols.success 在 windows 平台渲染为 √ ,支持的平台会显示 ✔

console

.

log

}

${

logSymbols

.

success

}

`

,

);

});

// 我们监听了 node 信号,所以使用 cross-env-shell 而不是 cross-env

// 参考:https://github.com/kentcdodds/cross-env#cross-env-vs-cross-env-shell

[

'SIGINT'

,

'SIGTERM'

].

forEach

=>

{

process

.

on

=>

{

// 先关闭 devServer

httpServer

.

close

;

// 在 ctrl + c 的时候随机输出 'See you again' 和 'Goodbye'

console

.

log

>

0.5

'See you again'

:

'Goodbye'

}

!`

),

);

// 退出 node 进程

process

.

exit

;

});

});

}

// 写过 python 的人应该不会陌生这种写法

// require.main === module 判断这个模块是不是被直接运行的

if

{

start

;

}

webpackHotMiddlewareoverlay 选项是用于是否开启错误遮罩:webpack-dev-middleware 并不支持 webpack-dev-server 中的 historyFallbackproxy 功能,其实无所谓,我们可以通过 DIY 我们的 express server 来实现,我们甚至可以使用 express 来集成 mock 功能。

安装对应的两个中间件:yarn add connect-history-api-fallback http-proxy-middleware @types/connect-history-api-fallback @types/http-proxy-middleware -Dconnect-history-api-fallback 可以直接作为 express 中间件集成到 express server,封装一下 http-proxy-middleware,可以在 proxyTable 中添加自己的代理配置:

import

{

createProxyMiddleware

}

from

'http-proxy-middleware'

;

import

chalk

from

'chalk'

;

import

{

Express

}

from

'express'

;

import

{

Options

}

from

'http-proxy-middleware/dist/types'

;

interface

ProxyTable

{

[

path

:

string

]

:

Options

;

}

const

proxyTable

:

ProxyTable

=

{

// 示例配置

'/path_to_be_proxy'

:

{

target

:

'http://target.domain.com'

,

changeOrigin

:

true

},

};

// 修饰链接的辅助函数, 修改颜色并添加下划线

function

renderlink

{

return

chalk

.

magenta

.

underline

;

}

function

proxy

{

Object

.

entries

.

forEach

=>

{

const

from

=

path

;

const

to

=

options

.

target

as

string

;

console

.

log

}

${

chalk

.

green

}

${

renderlink

}

`

);

// eslint-disable-next-line no-param-reassign

if

options

.

logLevel

=

'warn'

;

server

.

use

);

// 如果需要更灵活的定义方式,请在下面直接使用 server.use) 定义

});

process

.

stdout

.

write

;

}

export

default

proxy

;

为了启动 devServer,我们还需要安装两个命令行工具:yarn add ts-node cross-env -Dts-node 可以让我们直接运行 Typescript 代码,cross-env 是一个跨操作系统的设置环境变量的工具,添加启动命令到 npm script:

// package.json

{

"scripts"

:

{

"start"

:

"cross-env-shell NODE_ENV=development ts-node --files -P ./scripts/tsconfig.json ./scripts/start.ts --open"

,

}

}

cross-env 官方文档提到如果要在 windows 平台处理 node 信号例如 SIGINT,也就是我们 ctrl + c 时触发的信号应该使用 cross-env-shell 命令而不是 cross-env 。ts-node 为了提高执行速度,默认不会读取 tsconfig.json 中的 files, includeexclude 字段,而是基于模块依赖读取的。这会导致我们后面写的一些全局的 .d.ts 文件不会被读取,为此,我们需要指定 --files 参数,详情可以查看 help-my-types-are-missing。我们的 node 代码并不多,而且又不是经常性重启项目,直接让 ts-node 扫描整个 scripts 文件夹没多大影响。启动我们的 dev server,通过 ctrl + c 退出:npm start显示打包进度webpack-dev-server 在打包时使用 --progress 参数会在控制台实时输出百分比表示当前的打包进度,但是从上面的图中可以看出只是输出了一些统计信息(stats)。想要实时显示打包进度我了解的有三种方式:
  1. webpack 内置的 webpack.ProgressPlugin 插件
  2. progress-bar-webpack-plugin
  3. webpackbar
内置的 ProgressPlugig 非常的原始,你可以在回调函数获取当前进度,然后按照自己喜欢的格式去打印:

const

handler

=

=>

{

// e.g. Output each progress message directly to the console:

console

.

info

;

};

new

webpack

.

ProgressPlugin

;

progress-bar-webpack-plugin 这个插件不是显示百分比,而是显示一个用字符画出来的进度条:webpackbar 是 nuxt project 下的库,背靠 nuxt,质量绝对有保证。我之前有段时间用的是 progress-bar-webpack-plugin,因为我在 npm 官网搜索 webpack progress,综合看下来就它比较靠谱,webpackbar 都没搜出来。 看了下 webpackbarpackage.json,果然 keywords 都是空的。webpackBar 还是我在研究 ant design 的 webpack 配置看到它用了这个插件,才发现了这个宝藏:yarn add friendly-errors-webpack-plugin @types/friendly-errors-webpack-plugin -D// webpack.common.tsimport FriendlyErrorsPlugin from

'friendly-errors-webpack-plugin'

;

const commonConfig:

Configuration

=

{

plugins:

[

new FriendlyErrorsPlugin

]

,

}

;

构建通知我们使用 case-sensitive-paths-webpack-plugin 对路径进行严格的大小写检查:yarn add

case

-sensitive-paths-webpack-plugin @types/case-sensitive-paths-webpack-plugin -D// webpack.common.tsimport CaseSensitivePathsPlugin from

'case-sensitive-paths-webpack-plugin'

;

const commonConfig:

Configuration

=

{

plugins:

[

new CaseSensitivePathsPlugin

]

,

}

;

循环依赖检查这里顺便提一下 cwd 也就是工作路径的问题,官方文档。

 
友情链接
鄂ICP备19019357号-22