前言
1. 为什么不直接使用create-react-app?
首先,从功能的丰富程度及设计的通用层面,create-react-app
(简称CRA
)都称得的上是react世界里最优秀的脚手架,但同时它也有一些缺点,比如它由于对webpack基础配置封装的很深,导致开发者必须按CRA
的文档API(与webpack完全不同)来进行配置。文档里的配置一旦不能满足需求,就需要执行eject
操作,暴露原始的webpack配置,但这一步是不可逆的,导致之后想升级版本会变得非常困难,而且暴露出的webpack原始配置有时候改起来也不是很方便,(ps:也许是笔者太菜,改不动!)。
为了解决这些问题,社区甚至出现了不少类似于CRACO
这样的方案,专门用来对CRA
进行二次配置。这让问题变得更加复杂,开发者大部分时候只想要一款足够灵活简单的脚手架,把流行的功能比如eslint
、hmr
、typescript
、px2rem
等都内置好,如果想自定义配置,仅需要用自己已掌握的webpack知识对源码直接修改就可以了。所以,这也是本文的最大意义所在,创建一款足够灵活简单的脚手架配置。
2.webpack v5相比v4有哪些重要的改变?
- 更方便的配置体验。之前使用频率很高的loaders or plugins: 比如
url-loader、file-loader、clean-webpack-plugin
等都不再必须,提供了内置支持。 - 非常多的第三方loader,都发生了断层更新。比如现在在webpack4里去默认去安装最新的less-loader,使用options穿参时,会直接报错
this.getOptions is not function
。 - 更加强大的持久化缓存,cache选项
- 联邦模块。
正文
1. 新建项目
mkdir build-react-app
cd build-react-app
npm init -y
2. 安装webpack
npm i webpack webpack-cli -D //推荐本地安装,版本更可控
3. 初体验
新建src/index.js
文件,写入一些内容
const text = `hello webpack`;
document.write(text)
由于采用本地安装的方式,所以不能直接使用全局命令 webpack xxx
,但可以这样运行
./node_modules/.bin/webpack ./src/index.js //路径也可以省略,因为默认就是这个
如果npm v6+,可以运行npx webpack
。
打包成功,不放心的话,可以再次查验dist/main.js
(默认这个输出路径),看打包出的内容是否正常。
./node_modules/.bin/webpack
这样稍显麻烦,可以采用npm script
的简洁方式:
打开 package.json
,新建以下命令
"scripts": {
"build":"webpack ./src/index.js"
},
直接运行 npm run build
即可。
4. 解析css、图片、字体资源
新建webpack.config.js(webpack默认读取此文件内的配置)
4.1 解析css
npm i style-loader css-loader -D
module.exports = {
entry:'./src/index.js',
module:{
rules:[
{
test:/\.css$/,
use:[
'style-loader',
'css-loader'
]
}
]
}
}
现代开发中,优先使用less、sass这种预处理器比较多
npm install sass-loader sass --save-dev //最新版的sass-loader终于不再依赖用起来怀疑人生的node-sass了,喜大普奔
npm install less-loader less --save-dev
{
test: /\.scss$/,
use: ["style-loader", "css-loader", "sass-loader"],
},
{
test: /\.less$/,
use: ["style-loader", "css-loader", "less-loader"],
},
4.2 css 抽离
npm i mini-css-extract-plugin -D
mini-css-extract-plugin
相比之前经常使用的extract-text-webpack-plugin
会有许多优势,详情见这里。
另外,style-loader
与MiniCssExtractPlugin.loader
同时使用是冲突的,这其实很好理解,一个是以style标签的方式插在页面head里,一个是以link引用的方式抽离成一个单独的样式文件,这是个单选题。一般是开发时候选前者,生产环境选后者。
webpack5里区分环境, 推荐使用process.env,NODE_ENV
来区分,可以使用webpack-cli
里的node-env设置方式。
package.json
"scripts": {
"dev": "webpack serve --node-env development",
"build": "webpack --node-env production"
},
webpack.config.js
...
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const isProd = process.env.NODE_ENV === 'production';
...
module.exports = {
...
module:{
mode: isProd ? "production" : "development",
rules:[
...
{
test: /\.css$/,
use: [isProd ? MiniCssExtractPlugin.loader : "style-loader", "css-loader"],
},
{
test: /\.scss$/,
use: [isProd ? MiniCssExtractPlugin.loader : "style-loader", "css-loader", "sass-loader"],
},
{
test: /\.less$/,
use: [isProd ? MiniCssExtractPlugin.loader : "style-loader", "css-loader", "less-loader"],
},
]
...
},
plugins: [
// css文件单独抽离
isProd && new MiniCssExtractPlugin({
filename: 'css/[name]_[contenthash:8].css',
}),
].filter(Boolean),
...
...
}
4.3 解析图片
// 解析图片资源
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10 * 1024, // 10kb
},
},
generator: {
filename: 'img/[name][hash][ext][query]',
},
},
在webpack5里内置asset module用来处理文件类型的模块,也就是说url-loader、file-loader
都不需要了,指定一个资源 type
,webpack会自动帮你处理。这里选用asset
,因为它可以帮你在asset/inline
资源内联与asset/resource
原封不动移动文件之间,根据传入的资源大小限制,自动帮你做出选择。
4.4 解析字体文件
// 解析字体资源
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
5. 使用 babel 降级 js语法
5.1 安装babel
npm install @babel/core @babel/cli -D
5.2 babel-loader
babel在webpack是通过babel-loader
的方式使用的
npm install babel-loader -D
module.exports = {
entry: "./src/index.js",
module: {
rules: [
{
test: /\.js$/,
use: ["babel-loader"],
exclude: /node_modules/,
},
...
],
},
};
5.3 babel配置
@babel/preset-env
是一个智能预设,可以针对目标环境智能的预设需要哪些语法转换以及polyfill。这使开发者更轻松,也使得JavaScript包更小!
npm i @babel/preset-env -D
接下来,还需要一个目标环境配置,因为browserslist除了babel以外,也可以被其他的很多工具(如- postcss-preset-env、Autoprefixer)读取,所以建议提到一个公共的地方。我这里选择在package.json里进行配置,也可以选择单独的 .browserslistrc
文件(笔者不想项目根目录存在一大堆的单独配置文件)。
package.json
...
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8",
"Android >= 4.0"
]
...
5.5 按需polyfill
实现polyfill,一般采用core-js
,并且将@babel/preset-env
预设里的useBuiltIns
设为usage
,最后别忘记指定你安装的corejs版本(很重要)。
npm i core-js -D
5.6 babel的配置文件
有多种方式可选, 笔者习惯选用js的方式,因为格式或注释相比json更灵活,同时相比.babelrc的方式,可以加入一些js逻辑判断,更加方便。
新建babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: '3.18.2',
},
],
]
};
6. 清除上次构建产物
module.exports = {
...
output: {
filename: 'js/[name]_[chunkhash:8].js',
path: path.resolve(__dirname, '../dist'),
clean: true, // 相当于CleanWebpackPlugin的作用
}
...
};
7. react相关
npm i react react-dom
解析jsx
webpack.config.js
{
test: /\.jsx?$/,
use: ["babel-loader"],
exclude: /node_modules/,
},
npm i @babel/preset-react -D
babel.config.js
module.exports = {
presets: [
...
"@babel/preset-react",
],
};
8. 静态资源自动插入html模版
npm i html-webpack-plugin -D
plugins:[
new HtmlWebpackPlugin({
template: path.resolve(__dirname, `./src/index.html`),
filename: `index.html`,
inject: 'body',
chunks: 'all',
minify: {
// html5:true,
minifyJS: true,
},
})
]
9. 开发服务器
npm i webpack-dev-server -D
区分开发和生产命令
"scripts": {
"dev": "webpack serve",
"build": "webpack"
},
webpack.config.js
...
devServer: {
static: './dist',
open: true,
compress: true,
port: 3000,
https: true,
hot: true,
},
...
10. HMR 热更新
HMR是(模块热更替)的英文缩写。首先,一些人对HMR长期以来存在误解,以为模块热更新,只是简单的监听代码改动,自动刷新浏览器而已,但其实恰恰相反,它的作用是在尽量不刷新页面的情况下,只替换本次修改的模块。
举个简单的例子,你在debugger一个长表单时候,从上到下录入了一堆测试数据,定位到是其中某一个字段组件的问题后,你修改了一行代码,并保存,这时,悲剧发生了,页面刷新,所有状态都丢失,又要从头到尾重新填写,看有没有改好!
js的HMR,webpack默认支持,但在前端场景下目前用到最多的其实是.vue .jsx等的组件文件的热更新。处理起来还要多一些步骤,比如在组件模块替换完成时,需要触发对应组件重新渲染,其他组件的状态尽量保持不变。所以框架层面的HMR实现,还需要框架开发者或者对应的框架社区来配合webpack的一些module.hot
钩子来共同实现。在react世界,目前主流的实现有两种:
- 第一种基于loader的react-hot-loader,其在触发局部组件重渲染方面是采用hoc的方式去做的,对代码有一定的侵入;
- 第二种是官方支持的react-refresh,由官方直接在框架层面做HMR,可以轻松做到对开发代码的零侵入,这是现在最推荐的一种方式。以下是社区基于官方api的实现。
npm install @pmmmwh/react-refresh-webpack-plugin react-refresh -D
webpack.config.js
pulgins:[
...
!isProd && new ReactRefreshPlugin(),
...
].filter(Boolean)
babel.config.js
const isDev = process.env.NODE_ENV === "development";
module.exports = {
...
plugins:[
isDev && 'react-refresh/babel'
].filter(Boolean)
};
11. typescript
这里有一点需要注意,typecript的一项重要能力是能够将ts代码(tsc)编译为生产环境中能识别运行的js代码,这其实是非常现实的一步,毕竟现在无论浏览器还是Node环境都无法直接运行TS代码,但这项功能其实这和babel的功能是有重叠的。早些时候是先将TS代码传递给TypeScript转换为JS,然后再将这份JS代码传递给Babel转换为低版本JS代码,因此我们需要配置两个编译器,并且每次做了一点更改,都会经过两次编译,相当低效。ts团队很早就意识到这个问题。可以从这篇文章找到更多细节[译] TypeScript 牵手 Babel:一场美丽的婚姻,@babel/preset-typescript
就是ts团队专门找babel团队合作一年的成果。所以目前主流的做法,是让ts更多的作为提升开发体验的“工具”,让开发者充分享受由它带来的那些静态优势,编译这一步交给功能更强大的babel来负责,也就是鼓励使用babel-loader + @babel/preset-typescript 完全替代 awesome-typescript-loader和ts-loader,详情见ts官网对这种混合技术的介绍Babel 用于转译,tsc 用于类型
安装他们
npm i typescript @babel/preset-typescript -D
别忘记咱们是个react项目,所以提前将有关包的type类型装上
npm install @types/react @types/react-dom -D
新建tsconfig.json,也可以自动生成这个文件,但上边typecript是选择的局部安装,所以没有全局命令,需要这样执行tsc命令./node_modules/.bin/tsc --init
{
"compilerOptions": {
"allowSyntheticDefaultImports":true,//允许从没有设置默认导出的模块中默认导入
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"sourceMap": true,
"noImplicitAny": true,
"module": "commonjs",
"target": "es5",
"jsx": "react",
"lib": ["es6","dom"],
"paths": {
"@/*": ["./src/*"]
},
},
"include": [
"./src/**/*",
],
"exclude": [
"node_modules",
]
}
接下来就改改webpack的配置
webpack.config.js
module.exports = {
...
entry: "/src/index.tsx",
...
resolve: { // 顺便加个@别名,方便引用文件
alias: {
'@': path.resolve(__dirname, '../src'),
},
extensions: [//文件扩展名。默认只支持js及json,添加ts文件的支持
'.ts',
'.tsx',
'.js',
'.jsx',
'.json',
'.css',
'.scss',
'.less',
],
},
module: {
rules: [
{
test: /\.(jsx?|tsx?)$/,
use: ["babel-loader"],
exclude: /node_modules/,
},
...
],
},
}
babel.config.js
module.exports = {
presets: [
[
...
"@babel/preset-typescript"
],
...
};
12. eslint
安装
npm install eslint --save-dev
配置文档
新建eslint配置文件,这里通过npx eslint --init
自动创建。
自动生成如下文件及内容
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 13,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
}
};
接下来就是自定义一些rules
了,业界一般公认airbnb他们家的规范是做的比较好的,这里直接用他们的。
npx install-peerdeps --dev eslint-config-airbnb
.eslintrc
{
...
"extends": [
"airbnb",
"airbnb/hooks"
],
...
}
完整的.eslintrc配置如下:
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"airbnb",
"airbnb/hooks"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"react"
],
"settings":{
"react":{
"version":"17.0.2"
}
},
"rules":{}
}
在webpack里开启eslint之前是通过eslint-loader
的方式,现在最新最推荐的是使用eslint-webpack-plugin
;
npm i eslint-webpack-plugin -D
webpack.config.js
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
// ...
plugins: [new ESLintPlugin({
extensions: ['ts', 'tsx', 'js'],
failOnError:false,
})],
// ...
};
启动项目,写点错误代码,出现以下提示,就是配置成功了。
13. 多页构建(约定式)
简介:
- MPA: 是相对于SPA来说的,用单独的页面来承载独立的业务模块,这在开发中很常见的模式。
- 约定式: 是利用文件目录上的约定,(比如页面都放src/pages下),更方便的配置多entry、多个HtmlWebpackPlugin,实现多页应用的零配置,开箱即用。 webpack.config.js
/**
* 获取多页面打包的入口及htmlPlugin
*/
const getMPA = () => {
const entryFiles = glob.sync(
path.resolve(__dirname, "./src/pages/*/index.{ts,tsx}")
);
const entry = {};
const htmlWebpackPlugins = [];
// 获取页面名称
const getPageName = (filePath) => {
const match = filePath.match(/src\/pages\/([^/]*)/);
return match ? match[1] : null;
};
entryFiles.forEach((filePath) => {
const pageName = getPageName(filePath);
if (!pageName) {
throw new Error("多页的文件组织按“/src/pages/{pageName}/index.ts”约定");
}
entry[pageName] = filePath;
htmlWebpackPlugins.push(
new HtmlWebpackPlugin({
template: path.resolve(
__dirname,
`./src/pages/${pageName}/index.html`
),
filename: `${pageName}.html`,
inject: "body",
chunks: [pageName],
minify: {
// html5:true,
minifyJS: true,
},
})
);
});
return {
entry,
htmlWebpackPlugins,
};
};
// 配置多页
const { entry, htmlWebpackPlugins } = getMPA();
module.exports = {
...
entry,
output: {
filename: "js/[name]_[chunkhash:8].js",
path: path.resolve(__dirname, "./dist"),
clean: true,
},
...
plugins: [
...
...htmlWebpackPlugins,
...
};
14. 移动端适配
14.1 采用淘宝lib-flexible方案
自动模版填充script,拒绝手动粘贴复制
npm i raw-loader@0.5.1 -D //确保安装此版本,不然会有报错
修改模版 src/pages/index/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width">
<title>index</title>
<script> <%= require('raw-loader!../../../node_modules/lib-flexible/flexible.js') %> </script>
</head>
<body>
<div id='root'></div>
</body>
</html>
14.2 px转换rem
这里有两种方案,一种是postcss插件,另一种是px2rem-loader,这里选第一种,因为postcss的功能更加强大:
npm i postcss postcss-loader postcss-pxtorem -D
因为项目里采用了sass、less来处理样式文件,他们的loader配置其实是有很大的复用性的,所以抽取取一个通用处理函数。
/**
* 获取样式文件的loaders
* @param {*} preprocessor 采用何种预处理器
*/
const getStyleFileLoaders = (preprocessor) => {
const styleFileLoaders = [
isProd ? MiniCssExtractPlugin.loader : 'style-loader', // css生产环境抽离
'css-loader',
{
loader: 'postcss-loader',// postcss-loader解析时机必须在预处理之后
options: {
postcssOptions: {
plugins: ['postcss-preset-env',['postcss-pxtorem', { rootValue: 75, propList: ['*'] }]],
},
},
},
];
preprocessor && styleFileLoaders.push(preprocessor);// 预处理器
return styleFileLoaders;
};
14.3 自动补全浏览器样式前缀
一般使用 autoprefixer 插件来解决,但postcss提供了更智能的选择,postcss-preset-env
,会根据项目里配置的browserslist,自动进行补全浏览器兼容前缀。
npm i postcss-preset-env -D
webpack.config.js 加在上边的postcssOptions里就可以了。
plugins: [
'postcss-preset-env',
['postcss-pxtorem', { rootValue: 75, propList: ['*'] }]
],
15. 构建速度优化
15.1 多进程打包
这里大家可能听说happy-pack比较多,但这个包以后不再维护了,webpack官方目前提供的方案是thread-loader。
优点是提升打包速度,缺点是每个进程的开启和交流都会有开销,所以小项目不建议使用,有可能会负优化。(babel-loader消耗时间最久,所以使用thread-loader针对其进行优化)
{
test: /\.(jsx?|tsx?)$/,
use: ["thread-loader","babel-loader"],
exclude: /node_modules/,
},
15.2 并行压缩
webpack默认在mode为production时,会启用terser-webpack-plugin压缩js代码,这里将它的parallel设为true,可以开启多进行并行压缩。
webpack.config.js
const TerserWebpackPlugin = require('terser-webpack-plugin');
module.exports = {
...
optimization: {
minimizer: [
// '...', // 继承默认的压缩器,比如压缩js的terser-webpack-plugin
new TerserWebpackPlugin({
parallel: true, // 多进程并行压缩,这个其实是默认开启,这里只是明确写出来了
extractComments: false, // 不将注释提取到单独的文件,类似于 xxx.js.LICENSE.txt
}),
...
],
},
...
}
15.3 缓存构建,提升二次构建速度
在webpack4里,这项功能一般是loader层面来做的,比如开启babel-loader的缓存
{
loader:"babel-loader",
options:{
cacheDirectory: true, // 开启缓存
}
}
可以看到,缓存成功。
在webpack5中,提供了全局的支持。
webpack.config.js
cache: { // 开启构建结果缓存
type: isProd ? 'filesystem' : 'memory',
},
可以看到开启后,首次构建花了10s,再次构建仅花了600ms,对比效果还是很残暴的。
16 生产环境优化
webpack在mode设为production
,开启生产模式之后,默认就会做大量的优化,但这里也明确列举一下,方便有个印象。
16.1 treeShaking
去掉无用的js导入,只支持静态的es6 import/export。
16.2 cope hoisting
构建后的代码存在⼤量闭包代码,这个开启后,会尽量让各个模块代码在同一个作用域下,变量冲突通过重命名等方式解决。
16.3 压缩css
npm i css-minimizer-webpack-plugin -D
webpack.config.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
...
module.exports = {
...
optimization: {
minimizer: [
...
// 压缩css
new CssMinimizerPlugin(),
],
},
...
}
16.4 擦除无用的css代码
npm i purgecss-webpack-plugin glob -D
webpack.config.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const path = require('path');
const glob = require('glob');
...
module.exports = {
...
plugins: [
...
// 擦除无用的css代码
new PurgeCSSPlugin({
paths: glob.sync(`${path.join(__dirname, './src')}/**/*`, { nodir: true }),
}),
],
...
}
16.5 线上缓存优化
react技术栈,或者其他的一些不经常改动的包,可以进行单独拆包,提高页面的二次加载速度。也可以用external
做npm包的cdn抽离,这在跨项目的缓存优化是很有意义的,但同时也会限制死每个项目需要安装的包版本,不然可能会出现线上包与本地包版本不一致导致的bug。因为现在的线上静态资源大多数情况是cdn托管的,本身访问也足够快的,所以这里直接用splitChunks
单独抽取本项目的公共包即可,也可以避免external
造成的开发生产版本不一致问题。
optimization: {
splitChunks: {
minSize: 5000,
cacheGroups: {
// react技术栈相关的
reactVendor: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
name: 'reactVendor',
chunks: 'all',
priority: 1,
},
// node_modules
defaultVendor: {
test: /[\\/]node_modules[\\/]/,
name: 'defaultVendor',
chunks: 'all',
// minChunks: 1,
priority: 0,
},
},
},
},
17. 拆分webpack配置
随着配置项越来越繁多,配置里处处用isDev、isProd等的表达式来区分不同mode下需要的配置,所以需要对整个webpack.config.js进行更清晰的拆分。
webpack-cli命令区分NodeEnv环境,并且根据环境区分不同的配置文件
"scripts": {
"dev": "webpack serve --node-env development",
"build": "webpack --node-env production"
},
新建build
目录,存放配置文件
- build/webpack.dev.js 开发配置
- build/webpack.prod.js 生产配置
- build/webpack.base.js 公共配置
- build/webpack.utils.js 抽取出的可复用方法
- webpack.config.js 配置入口(webpack默认指定的文件)
使用
webpack-merge
这个包来解决基础配置与特定环境配置的合并。
onst { merge } = require('webpack-merge');
const baseConfig = require('./build/webpack.base');
const developmentConfig = require('./build/webpack.dev');
const productionConfig = require('./build/webpack.prod');
module.exports = (env, argv) => {
switch (argv.nodeEnv) {
case 'development':
return merge(baseConfig, developmentConfig);
case 'production':
return merge(baseConfig, productionConfig);
default:
throw new Error('No matching configuration was found!');
}
};
尾巴
这样一个包含主流功能的react脚手架就基本完成了。webpack的配置总体是没有任何技术含量的,对自己的锻炼主要在于:
- 踩坑,babel、各种loader、eslint、typescript等的版本及api的踩坑。
- 不同的解决方案之间的对比
- 文档信息收集能力
总体是一项耗时、枯燥、易忘的工作。笔者本篇文章的主要目的,也是想把这个完整的配置过程做一个记录,之后有用到的时候,可以快速查阅。
项目github地址,本文完整的配置已上传,有问题或者交流可以随时提issue。