本篇为 从零开始配置 react + typescript
系列第三篇,将带大家完成模板项目的 webpack 配置。整个项目的配置我力求达到以下目标:灵活: 我在配置 eslint 是选择使用 js 格式而不是 json,就是为了灵活性,使用 js 文件可以让你使用导入其它模块,根据开发环境动态配置,充分发挥 js 语言的能力。
新潮: 我觉得时刻保持对新事物的关注和尝试去使用它是一个优秀的素质。
当然,追新很容易碰到坑,但是,没关系,我已经帮你们踩过了,踩不过去我也不会写出来 。从我 eslint parserOptions.ecmaVersion
设置为 2020, 还有经常来一发 yarn upgrade --latest
都可以体现出来。严格: 就像我平时判断相等性我大多数情况都是使用严格等 ===
,而不是非严格等 ==
,我觉得越严格,分析起来就越清晰,越早能发现问题。
例如我么后面会使用一些 webpack 插件来严格检查模块大小写,检查是否有循环依赖。
安逸: 项目中会尽量集成当前前端生态界实用的和能提高开发愉悦性的(换个词就是花里胡哨)工具。生产 ready:配置的时候针对不同的打包环境针对性优化,并确保能够投入生产环境使用。本篇将分三大部分介绍:
- dev server
- 开发环境优化
- 生产环境优化
- 从零开始配置 react + typescript:dotfiles
- 从零开始配置 react + typescript:linters 和 formatter
commonjs
,写前端项目就要配置打包工具。当时最火的打包工具已经是 webpack 了,其次就是 gulp
。配置 webpack 总是记不住 webpack 配置有哪些字段,还要扯到一堆相关的工具像 ES6 编译器 babel
,CSS 预处理器 sass
/less
,CSS 后处理器 postcss
,以及各种 webpack 的 loader 和 plugin。
然后嫌麻烦就有一段时间都是用官方的脚手架,react 就用 cra
,也就是 create-react-app
,vue 就用 vue-cli
。
其实也挺好用的,不过说实话,我个人觉得,cra
没 vue-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
总结一下有以下方式:
webpack-dev-server
,这是最不灵活的方式,当然使用场景简单的情况下还是很方便的webpack-dev-server
node API,在 node 脚本里面调用web-dev-server
包提供的 node API 来启动 devServerexpress
+webpack devServer 相关中间件
,实际上webpack-dev-server
就是使用express
以及一些 devServer 相关的中间件开发的。在这种方式下, 各种中间件直接暴露出来了,我们可以灵活配置各个中间件的选项。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
中很多选项例如 target
和 module
是没有用的。启用 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 文件,目前存在比较流行的方案有三种:
- babel + @babel/preset-typescript
- ts-loader
- awesome-typescript-loader
需要指出的一点就是就是 babel 默认不会检查 Typescript 的类型,后面 webpack 插件部分我们会通过配置 cd && && 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 } }fork-ts-checker-webpack-plugin
来解决这个问题。添加 webpack 配置我们将把所有 node 脚本放到项目根目的 scripts
文件夹,因为 src
文件夹是前端项目,而 scripts
文件夹是 node 项目,我们应该分别配置 tsconfig.json
,通过下面的命令在其中生成初始的 tsconfig.json
文件:
我们调整成酱:
提几个需要注意的地方:
"target": "ES2019"
,其实编译级别你调的很低是没问题的,你用高级语法 tsc 就转码呗,缺点就是转码后代码体积一般会变大,执行效率也会降低,原生语法一般都是被优化过的。我喜欢调高一点,一般来说只要不用那些在代码运行平台还不支持的语法就没问题。自从 Typescript3.7 支持了可选链,我就开始尝试在 Typescript 使用它,但是问题来了,我之前编译级别一直都是调成最高,也就是
ESNext
,因为可选链在ES2020
已经是标准了,所以 tsc 对于可选链不会转码的。然后 node 12 还不支持可选链,就会报语法错误,于是我就降到ES2019
了。Strict Type-Checking Options
,这部分全开,既然上了 Typescript 的船,就用最严格的类型检查,拒绝 Anyscript
scripts/configs
文件夹,里面用来存放包括 webpack 的配置文件。在其中新建三个 webpack 的配置文件 webpack.common.ts
, webpack.dev.ts
和 webapck.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 -D
webpack-merge 是一个为 merge webpack 配置设计的 merge 工具,提供了一些高级的 merge 方式。不过我目前并没有用到那些高级的 merge 方式,就是当成普通的 merge 工具使用,后续可以探索一下这方面的优化。
为了编译 tsx,我们需要安装 // babel.config.js module . exports = function { api . cache ; const presets = [ '@babel/preset-typescript' ]; const plugins = []; return { presets , plugins , }; }; // 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/ , }, ], }, };babel-loader
和相关插件:yarn add babel-loader @babel/core @babel/preset-typescript -D
新建 babel 配置文件 babel.config.js
,现在我们只添加一个 Typescript preset:
添加 babel-loader 到 webpack.common.ts
:
我觉得这个 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 -D
webpack-dev-middleware 这个 express
中间件的主要作用:
- 作为一个静态文件服务器,使用内存文件系统托管 webpack 编译出的 bundle
- 如果文件被修改了,会延迟服务器的请求直到编译完成
- 配合 webpack-hot-middleware 实现热更新功能
webpack-hot-middleware/client.js
客户端补丁。这个前端代码会获取 devServer 的 Server Sent Events 连接,当有编译事件发生,devServer 会发布通知给这个客户端。客户端接受到通知后,会通过比对 hash 值判断本地代码是不是最新的,如果不是就会向 devServer 拉取更新补丁借助一些其它的工具例如 react-hot-loader 实现热更新。下面是我另外一个还在开发的 electron 项目修改了一行代码后, client 补丁发送的两次请求:第一次请求返回的那个 h 值动动脚趾头就能猜出来就是 hash 值,发现和本地的 hash 值比对不上后,再次请求更新补丁。我们新建文件 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 ; }scripts/start.ts
用来启动我们的 devServer:webpackHotMiddleware
的 overlay
选项是用于是否开启错误遮罩:webpack-dev-middleware
并不支持 webpack-dev-server
中的 historyFallback
和 proxy
功能,其实无所谓,我们可以通过 DIY 我们的 express server 来实现,我们甚至可以使用 express
来集成 mock
功能。
安装对应的两个中间件: 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 ; // package.json { "scripts" : { "start" : "cross-env-shell NODE_ENV=development ts-node --files -P ./scripts/tsconfig.json ./scripts/start.ts --open" , } }yarn add connect-history-api-fallback http-proxy-middleware @types/connect-history-api-fallback @types/http-proxy-middleware -D
connect-history-api-fallback
可以直接作为 express
中间件集成到 express server,封装一下 http-proxy-middleware
,可以在 proxyTable
中添加自己的代理配置:
为了启动 devServer,我们还需要安装两个命令行工具:yarn add ts-node cross-env -D
ts-node 可以让我们直接运行 Typescript 代码,cross-env 是一个跨操作系统的设置环境变量的工具,添加启动命令到 npm script:
cross-env 官方文档提到如果要在 windows 平台处理 node 信号例如 SIGINT
,也就是我们 ctrl + c
时触发的信号应该使用 cross-env-shell
命令而不是 cross-env
。ts-node 为了提高执行速度,默认不会读取 tsconfig.json
中的 files
, include
和 exclude
字段,而是基于模块依赖读取的。这会导致我们后面写的一些全局的 .d.ts
文件不会被读取,为此,我们需要指定 --files
参数,详情可以查看 help-my-types-are-missing。我们的 node 代码并不多,而且又不是经常性重启项目,直接让 ts-node 扫描整个 scripts
文件夹没多大影响。启动我们的 dev server,通过 ctrl + c 退出:npm start
显示打包进度webpack-dev-server
在打包时使用 --progress
参数会在控制台实时输出百分比表示当前的打包进度,但是从上面的图中可以看出只是输出了一些统计信息(stats)。想要实时显示打包进度我了解的有三种方式:
- webpack 内置的 webpack.ProgressPlugin 插件
- progress-bar-webpack-plugin
- 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
都没搜出来。 看了下 webpackbar
的 package.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
也就是工作路径的问题,官方文档。