一、前言
webpack对于前端工程师来说非常重要,包括日常的开发,发版上线构建效率,还有跳槽面试来说都必不可少,对于react框架有默认的create-react-app作为通用方案,但像一些个性化需求,比如移动端转rem等等,它并没有提供配置入口,而且需要熟悉它对应的API进行开发,create-react-app文档,网上通用的方案是git提交之后npm run eject或者使用react-app-rewired进行覆盖,但灵活程度都不如直接使用webpack,原先对webpack4的配置有一定的了解和使用,借此机会升级,并对脚手架配置做一个总结。
二、现在开动
最终产出目录
基础配置
配置package.json
配置package.json:npm init y 按照提示填写即可,scripts下添加两句最重要的代码,运行和打包
"scripts": {
"build": "npx webpack --mode production",
"start": "npx webpack serve --mode development --open"
},
安装webpack相关依赖
安装:npm i webpack webpack-cli webpack-dev-server webpack-bundle-analyzer -D 新建webpack.config.js 查看最近版本命令: npm info webpack version
缓存:
Webpack的打包和构建分为三步:
-
初始化阶段:
1.初始化参数,读取文件配置包括webpack.config.js和shell参数
2.利用参数创建compiler对象
3.调用内置插件,加载用户配置插件并创建编译环境
4.调用compiler.run方法进行构建
5.根据配置中的entry找出所有的入口文件,调用 compilition.addEntry 将入口文件转换为 dependence对象(模块间的依赖关系)
-
编译阶段
1.根据entry生成的dependence对象生成不同的module对象,调用loader模块将module转成原生JS对象,然后调用acorn将原生JS转成AST,找出该模块的依赖模块,依次递归entry接口,找出所有相关依赖模块 2.生成模块依赖图 -- ModuleGraph 对象
-
生成阶段
1.按照模块依赖图,将所有的import转成require,分析代码运行时依赖
2.合并模块代码和运行时代码生成一个个包含module的chunk,每个chunk都输出成单独的文件,执行tree-shaking,并且根据output输出文件
-
总结: 在编译阶段,将module,chunk,ModuleGraph 对象都写入缓存,在下次构建开始时,尝试读入并恢复这些对象的状态,从而可以跳过loader,包括AST解析等耗时步骤,从而优化编译过程。
原先webpack4只能通过loader去缓存对应的模块,比如babel-loader和eslint-loader通过开启cache: true控制,webpack5通过持久化缓存,将构建结果存储在文件系统中,node_modules/.cache/webpack目录中,
将首次构建出的 Module、Chunk、ModuleGraph 等对象序列化后保存到硬盘中,后面再运行的时候就可以跳过一些耗时的解析,链接,编译动作,直接复用缓存信息,配置如下:
// webpack.config.js
cache: {
// 1. 将缓存类型!设置为文件系统(持久缓存)
type: "filesystem",
buildDependencies: {
// 2. 将你的 config 添加为 buildDependency,以便在改变 config 时获得缓存无效
// config: [path.join(__dirname, 'webpack.dll_config.js')],
},
}
资源管理
无需引入url-loader,raw-loader和file-loader,在webpack5中统一成资源类型,只需考虑如何正确写正则:
module: {
rules: [
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset',
parser: {
dataUrlCondition: { // 小于10MB打包成base64字符串
maxSize: 10 * 1024, // 10kb
},
},
generator: {
filename: 'img/[name][hash][ext][query]', // 打包到img文件夹中
},
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
]
}
配合(resolve.alias)实现在css中缩短路径:
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
}
}
css文件中:
background: url('@/images/test.jpg');
构建和打包
大多数项目都是多入口,当我们项目庞大到一定地步,对于entry和HtmlWebpackPlugin多个模板的维护让我们十分头大,那有没有解决办法呢?
- 在根目录下src下新建pages和index文件夹,如图:
- 在index.html和index.js中加入如下内容:
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>
</head>
<body>
<div id='root'></div>
</body>
</html>
index.js
console.log('in-------')
- 安装glob npm i glob -D 读取src/pages下所有文件夹,以文件名约定输出js和文件模板,在webpack.config.js中加入:
const glob = require("glob");
const getPageName = (filePath, isMPA) => {
const reg = /src\/pages\/([^/]*)/;
const match = filePath.match(reg);
return match ? match[1] : null;
};
const entryFiles = glob.sync(
path.resolve(__dirname, `./src/pages/*/index.js`)
);
// 记录入口对象
const entry = {};
// 记录模板插件数组
const htmlWebpackPlugins = [];
entryFiles.forEach((filePath) => {
const pageName = getPageName(filePath);
if (!pageName) {
throw new Error(`未找到${filePath}页面入口文件`);
}
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,
},
})
);
});
4.配置出口:
output: {
filename: 'js/[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true, // webpack5新加属性,相当于CleanWebpackPlugin,清除dist中旧文件
},
在命令行执行npm start 或者npm run build打包之后构建出如下路径,证明配置成功:
样式文件处理:
- 安装样式loader
npm install style-loader css-loader mini-css-extract-plugin -D
这里解释下三者的区别:
- css: 解析css文件
- style-loader: 将js中的import的样式文件抽离出来放入标签中
- MiniCssExtractPlugin.loader: 将js中import的样式文件单独打包成css文件,结合html-webpack-plugin,以link的方式插入到html中,这里注意:这个插件不支持HMR热更新,需要手动刷新页面才能看效果
module: {
rules: [
{
test: /.css$/i,
use: [isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'],
},
],
},
- 安装sass或者less
npm i sass sass-loader -D
这里sass或者less可自行看需要安装.
3.安装postcss
npm i postcss postcss-loader autoprefixer -D
postcss相当于一个平台,将css解析成语法树(AST),添加我们想要的代码,比如添加各种浏览器前缀,添加个性化前缀命名,将px转成rem或者vw,vh等实用功能。移动端有两种方案如下:
- lib-flexible + postcss-pxtorem 使用lib-flexible设置html的font-size作为基准值,并且处理一些窗口缩放问题,此方案不足点是小屏幕上字体会看不清,因为rem是按照html作为基准值来计算,屏幕太小导致字体也随之缩小了,而且还会有精度丢失问题。
npm i lib-flexible postcss-pxtorem -D
在src/pages/index/index.html中引入lib-flexible文件,添加如下代码:
<script> <%= require('raw-loader!/node_modules/lib-flexible/flexible.js') %> </script>
webpack.config.js中添加postcss-pxtorem和postcss-loader
module:{
rules: [
{
test: /\.scss$/,
use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader',
{
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: [
"postcss-preset-env",
["postcss-pxtorem", { rootValue: 75, // 375px屏幕像素 750物理像素 10rem宽度
propList: ["*"] }],
],
},
},
}
],
}
]
}
- postcss-px-to-viewport 将px直接转成vw或者vh,近几年随着支持度越来越高,慢慢演变为主流方案,但仍然有些情况是无法解决的。
npm i postcss-px-to-viewport -D
postcssOptions: {
plugins: [
require('postcss-px-to-viewport')({
unitToConvert: 'px',
viewportWidth: 375,
unitPrecision: 3,
viewportUnit: 'vw',
fontViewportUnit: 'vw',
minPixelValue: 1,
exclude: /(\/|\\)(node_modules)(\/|\\)/
}),
],
},
- 优化写法,这样写更好维护postcss的配置,比如添加autoprefixer,而且不必再次重启webpack 根目录下新建postcss.config.js,将所有postcss配置放入:
// webpack.config.js
module:{
rules: [
{
test: /\.scss$/,
use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader',
"postcss-loader"
],
}
]
}
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer'),
require('postcss-px-to-viewport')({
unitToConvert: 'px',
viewportWidth: 375,
unitPrecision: 3,
viewportUnit: 'vw',
fontViewportUnit: 'vw',
minPixelValue: 1,
exclude: /(\/|\\)(node_modules)(\/|\\)/
}),
]
}
添加babel
- 安装babel相关库:
npm i @babel/core @babel/preset-env babel-loader babel-plugin-import core-js@3.18.2 -D
解释下这几个库:
@babel/core:babel的核心api,对代码进行转译。
@babel/preset-env:官方的描述是一个智能预设,可以根据预设targets使用最新的js,而无需关心浏览器需要做哪些语法转换,这样可以让js打的包更小,其实preset-env就是个桥梁,可以检验目标环境,调用目标环境插件进行babel转译。
babel-loader:调用babel/core进行转换 core-js:javascript标准的polyfill库,模块化配合useBuiltIns: 'usage'按需引入,并且不会污染全局环境
- 根目录下添加babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
// 支持chrome 58+ 及 IE 11+
targets: {
chrome: '58',
ie: '11',
}
}
],
],
}
我在index.js中只引用了lodash,所以只有webpack对lodash的处理:
- 添加core-js和usage属性
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage', // 按需引入
corejs: '3.18.2', // 指定core-js版本
...
},
],
],
}
我们发现,core-js会自动引入Promise的polyfill,并且是按需引入,但是这样依旧有不足,因为几乎所有的polyfill都是通过修改js原型方法来打补丁,这样不仅会造成命名冲突,还会造成polyfill的函数多次声明。
- 安装babel的runtime库
npm i @babel/plugin-transform-runtime -D
npm i @babel/runtime-corejs3
引入runtime的好处是如果100个文件中有100个promise,它只是引用这些helpers,而不会重新声明promise,,而且这样做不会污染全局变量,推荐这个大佬的文章,讲的很棒:babel-runtime,最终效果:
- 最终的配置:
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage', // 按需引入
corejs: '3.18.2', // 指定core-js版本
// 支持chrome 58+ 及 IE 11+
targets: {
chrome: '58',
ie: '11',
}
},
],
],
plugins: [
['@babel/plugin-transform-runtime', {
corejs: 3,
}],
],
};
// webpack.config.js
module: {
rules: [
{
test: /\.(jsx?|tsx?)$/, // 这里为后面改写ts和添加react的jsx做准备
use: [
'babel-loader',
],
exclude: /node_modules/,
},
],
},
resolve: {
// 这里列出loader要解析的所有文件
extensions: [
'.ts',
'.tsx',
'.js',
'.jsx',
'.json',
'.css',
'.scss',
'.less',
],
}
拆分webpack配置
现在我们的目录仅有一个webpack.config.js,如果以后项目逐渐庞大,很不好维护,为此我们将webpack.config.js拆分为三个文件:
- build/webpack.base.js --> webpack基本配置
- build/webpack.dev.js --> webpack开发模式配置
- build/webpack.prod.js --> webpack生产模式配置 首先,我们把原先webpack.config.js的代码全都放入base里(这里一定要注意:检查下src和dist的路径,因为都是相对的,所以要注意下,不要打在build里了),然后安装生产环境所需的包
npm i css-minimizer-webpack-plugin mini-css-extract-plugin terser-webpack-plugin purgecss-webpack-plugin -D
介绍下这几个插件的用途:
1.css-minimizer-webpack-plugin:压缩css代码
2.mini-css-extract-plugin:将css文件单独抽离打包
3.terser-webpack-plugin:利用多进程加快打包速度,打包之后不会单独生成注释文件,在生产环境可以去除log日志
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserWebpackPlugin = require('terser-webpack-plugin');
const PurgeCSSPlugin = require('purgecss-webpack-plugin');
const path = require('path');
const glob = require('glob');
module.exports = {
mode: 'production', // 这里一定要写上production,webpack会有很多优化配置
plugins: [
// css文件单独抽离
new MiniCssExtractPlugin({
filename: 'css/[name]_[contenthash:8].css',
}),
// 擦除无用的css代码
new PurgeCSSPlugin({
paths: glob.sync(`${path.join(__dirname, '../src')}/**/*`, { nodir: true }),
}),
],
optimization: {
minimizer: [
// '...', // 继承默认的压缩器,比如压缩js的terser-webpack-plugin
new TerserWebpackPlugin({
parallel: true, // 多进程并行压缩
extractComments: false, // 不将注释提取到单独的文件,类似于 xxx.js.LICENSE.txt
terserOptions: { // 生产环境清除console
compress: { drop_console: true,drop_debugger: true },
},
}),
// 压缩css
new CssMinimizerPlugin(),
],
},
};
优化开发的配置:webpack.dev.js
const defaultUrls = [];
module.exports = {
mode: 'development',
devtool: 'eval-cheap-module-source-map',
// 避免额外的优化
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
},
devServer: {
static: '../dist',
open: true,
compress: true,
port: 3000,
host: 'api.com',
proxy: {
context: defaultUrls.map((iteUrl) => `/${iteUrl}`),
target: 'https://api.com',
changeOrigin: true,
secure: false,
}
},
};
这里说一下proxy的context用法:webpack配置跨域的方法,比如我们需要请求一个域名api.com ,首先把host写成需要请求的域名,一般的写法是这样
proxy: {
'/api': {
target: 'https://api.com',
secure: false,
},
},
如果我们的路径很多,后端添加一个路径就多代理一个,这样很不方便,webpack提供多代理的解决方案,可以参照:webpack.js.org/configurati… webpack.config.js将配置按照development和production合并:
const { 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.mode) {
case 'development':
return merge(baseConfig, developmentConfig);
case 'production':
return merge(baseConfig, productionConfig);
default:
throw new Error('No matching configuration was found!');
}
};
安装react
npm i react react-dom
npm i @babel/preset-react -D
在babel.config.js中加入preset
presets: [
[
'@babel/preset-env',
...
],
'@babel/preset-react',
],
在src/pages/index中将index.js改成jsx,然后新增App.jsx文件
import React from 'react';
function App() {
const [count, setCount] = React.useState(0);
return (
<div>
{count}
</div>
);
}
export default App;
修改index.jsx:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.querySelector('#root'));
添加jsx和tsx到入口文件类型:
const entryFiles = glob.sync(
path.resolve(__dirname, `../src/pages/*/index.{ts,jsx,tsx}`)
);
webpack官网提到的关于react的热更新方案 react-hot-loader,现在已经被react-fast-refresh所取代:React-Refresh-Plugin,安装:
npm i @pmmmwh/react-refresh-webpack-plugin -D
修改webpack.dev.js
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
// 在devServer中补充
devServer: {
hot: true,
},
plugins: [new ReactRefreshWebpackPlugin()]
代码风格和约束
eslint代码约束一共有三种规则可选分别是:
- ESLint + Airbnb
- ESLint + Standard
- ESLint + Prettier 因为原来的脚手架用的第一种,而且airbnb比较全面,大家也可以参考下:Airbnb 初始化eslint并安装相关依赖:
npx eslint --init
安装eslint-config-airbnb相关依赖
npx install-peerdeps --dev eslint-config-airbnb
将eslint引入webpack,原先使用eslint-loader,现在用eslint-webpack-plugin
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
// ...
plugins: [new ESLintPlugin({
extensions: ['js','jsx','ts','tsx'], // 需要lint的类型
failOnError: false, // 任何错误都会导致build失败,这里置为false
})],
// ...
};
.eslintrc文件,其中rules选项可以参考react-rules typescript-rules
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"airbnb",
"airbnb/hooks"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"settings":{
"react":{
"version":"17.0.2"
}
},
"rules": {} //这里写自己eslint的规则
}
最后在package.json加入lint命令
eslint --ext src .jsx,.js,.ts,.tsx
安装typescript相关的包:
npm i typescript @babel/preset-typescript -D
在babel中加入typescript
module.exports = {
presets: [
...
'@babel/preset-react',
'@babel/preset-typescript',
...
],
};
tsconfig.js文件
{
"compilerOptions": {
"experimentalDecorators": true, // 装饰器语法
"rootDir": "src", // 指定输入文件目录(用于输出)
"outDir": "dist", // 指定输出目录
"sourceMap": true, // 生成目标文件的 sourceMap
"noImplicitAny": false, // 不允许隐式的 any 类型
"module": "commonjs", // 生成代码的模块标准
"target": "es5", // 目标语言的版本
"jsx": "react", // react 支持jsx
"lib": ["es6","dom"], // TS 需要引用的库,即声明文件,es5 默认 "dom", "es5", "scripthost"
"allowSyntheticDefaultImports":true, // 导入的模块兼容
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"./src/**/*"
],
"exclude": [
"node_modules",
]
}
最后将App.jsx和index.jsx修改后缀变成tsx,然后随便添加一些eslint的错误看看能否报错
Webpack模块联邦
一般的webpack项目存在以下几个问题:
- 业务中相同逻辑的方法或者组件无法重用,需要直接copy到项目中,很麻烦
- 有的项目依赖的库,版本不同,不同版本之间不好管理
- AB测:同一个项目局部样式和表现不同,只能运行两个项目,分别查看 为此webpack5添加了模块联邦
远程模块:
output: {
publicPath: "http://localhost:3000/",
clean: true,
},
module: {},
plugins: [
new ModuleFederationPlugin({
name: "remote_app",
filename: "remoteEntry.js",
exposes: {
"./react": "react",
"./react-dom": "react-dom",
},
}),
],
请求模块:
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require('./package.json').dependencies;
plugins: [
new ModuleFederationPlugin({
name: "component_app", // 模块名称
filename: "remoteEntry.js", // 打包生成的文件名
exposes: {
"./Example": "./src/Example.js", // 对外暴露的模块
},
remotes: {
// 远程应用的访问别名
"remote-app": "remote_app@http://localhost:3000/remoteEntry.js",
},
shared: { // 它是一个分享池,比如app1添加了lodash,但app2没有,这时app2也可以用lodash,类似externals
react: {
requiredVersion: deps.react, // 引用当前应用的react
singleton: true, // 是否单例
},
}
}),
]
加载动态远程容器:
在一个容器中运行两个项目
function loadComponent(scope, module) {
return async () => {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
const res = await __webpack_init_sharing__("default");
const container = window[scope]; // or get the container somewhere else
// console.log('container----', container);
// Initialize the container, it may provide shared modules
await container.init(__webpack_share_scopes__.default);
const factory = await window[scope].get(module);
const Module = factory();
return Module;
};
}
模块联邦的局限性:模块联邦擅长的是公共逻辑和业务的抽离,并且只能在webpack5使用,不可以技术栈共享,比如A用了react,B用了vue,C用不了vue和react,除非将A和B都exposes出去。
再聊聊微前端
微前端 = 微应用生命周期管理 + 模块联邦(可选)
微前端有三种应用模式:
- 基座(容器)模式:代表有single-spa,乾坤(容器管理应用),飞冰
- 自由组织模式:Nginx路由分发,微件化
- 模块加载:模块联邦(串联方式,没有中心容器) 乾坤:基于single-spa进行了封装,加强了微应用的集成能力,摒弃了微模块的能力,适用于快速安全的集成项目,乾坤要做的是应用的集成工具。
single-spa:功能相对比较强大,让应用逻辑组件这些都成为可共享的微服务,但是使用起来会多一些入侵,需要了解整个的生命周期。(注册,开始,加载,卸载还有监听url变化)
模块联邦:解决的是微前端模块和方法复用的问题,在编译的时候获得依赖关系,只支持webpack5
System:都支持,但只能识别自己的js模块和UMD,在运行时确定依赖关系。
三、参考文章
四、总结
webpack脚手架的相关配置终于完成了,虽然相当枯燥,但总的来说收获还是很明显的:
-
权衡不同方案,筛选出最优解,加入自己的优化。
-
对于webpack5特性使用,通过网上的文章和文档,转化成自己的知识。
-
沉淀出自己在掘金第一篇文章,虽然有些地方还不到位,但万事开头难,以后督促自己坚持写下去。
-
最后贴上我的git地址webpack-project