本节是为了BFF层设计的前端基建内容,在做前端基建之前,还是要再次复习一下我们之前的BFF服务端架构设计,方便我们更好的配合它做基建。
这套工程化是根据当前的BFF需求(下图所示)而设计的。
这套架构提供统一的入口,通过多页面入口来分发不同的系统(应用)。
本文内容较长,核心内容使用👍🏻(👍)的标志标注,补充说明某个概念使用双分割线包裹。
这一节要干啥
设计BFF是想解耦纯后端和页面访问,BFF层做一部分请求的工作,更好的服务页面的接口需求。
而上一章节的服务端设计,是通过封装koa,实现了一套egg.js的架构。用一系列的中间件、loader去实现bff层的底座。

仔细看bff层的业务逻辑中的核心有一个页面模版,也就是我们koa-core要去访问、解析tpl文件,展示层在通过koa-router的路由访问对应的页面。
而这里的模版就是我们本节要去实现的,它必然是动态生成的。要把/app/pages下的对应页面解析成对应的页面模版。

但是实际开发中我们会有很多资源依赖,可能会用react或者vue开发,可能会用less\sass。
所以要用打包工具去处理。
这一节会做两件事:
- 前端工程化
- 为了让开发过程更加高效,做一部分前端基础建设。
综上所述,这一节要完成下图中间部分:
当做完解析后,输出的产物,koa服务端就可以直接访问了。
前端工程化
前端工程化主要打包工具来实现,至于你用vite还是webpack,还是xxx,都不重要,重要的是从工程化的思路去处理,不同环境下该做什么,每种情况处理的逻辑基本都是相通的。
我选了webpack5来实践。
在回顾一下这张图:
解析引擎要做三件事:
- 解析编译
- 模块分包
- 压缩优化
不同的打包工具,api使用上不同,但是核心逻辑都是这些,继续细化一下。
解析编译
- 通过入口(entry),进行依赖分析,遇到import和其他引用的资源,都认为是module
- 然后module按照不同的rules通过合适的loader进行处理
- 最终生成output产物。
👍 loader怎么寻找的
1
2
3
4
5
6
7
8
9
10
11
|
module: {
rules: [
//? 例如webpack怎么找到vue-loader
{
test: /\.vue$/,
use: {
loader: 'vue-loader'
}
},
]
}
|
在 webpack 的模块解析机制中,loader 的查找遵循规则:从当前目录向上找。
举个🌰:
当执行 node ./app/webpack/dev.js 时
,node_modules 查找顺序:
- /Users/wushao/wushaoDev/FE-stuty/nova/app/webpack/node_modules ❌
- /Users/wushao/wushaoDev/FE-stuty/nova/app/node_modules ❌
- /Users/wushao/wushaoDev/FE-stuty/nova/node_modules ✅ (正确位置)
路径解析伪代码逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 伪代码演示 webpack 内部查找过程
function resolveLoader(loaderName) {
// 1. 检查是否绝对路径
if (path.isAbsolute(loaderName)) return loaderName
// 2. 相对路径解析(从当前文件所在目录)
if (loaderName.startsWith('./')) {
return path.join(__dirname, loaderName)
}
// 3. 从 node_modules 向上递归查找(当前项目的 node_modules)
return require.resolve(loaderName)
}
|
如果要自定义 loader 解析路径,可以修改配置(但不推荐):
1
2
3
4
5
6
7
|
// 在 module.exports 中添加:
resolveLoader: {
modules: [
'node_modules', // 默认值
'/global/node_modules' // 自定义全局路径(不推荐)
]
}
|
解析编译的前两步的一些基础操作,开发环境和生成环境都需要,可以使用base.js来抽象,至于产物,生产和开发环境差别很大,要单独处理。
在最终的构建阶段会通过配置插件,在打包的不同生命周期中生效,去改变打包的结果,例如要生成html或者tpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
plugins: [
// 把vue中的script、style标签应用 modules.rules的对应规则
new VueLoaderPlugin(),
// 把第三方库暴露到window context下
new webpack.ProvidePlugin({
Vue: 'vue',
axios: 'axios',
'_': 'lodash',
}),
//! 定义全局常量,例如vue相关的
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true', // 支持options api
__VUE_PROD_DEVTOOLS__: 'false', // 禁用调试工具,打包时不需要这个了
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', // 禁用生产环境显示水合信息
}),
// 构造最终的页面模版
new HtmlWebpackPlugin({
filename: '', // 把文件生成到哪
template: path.resolve(process.cwd(), "./app/view/entry.tpl"), // 使用的原始模版
chunks: [entryName],
})
],
|
可以看到插件中:__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', // 禁用生产环境显示水合信息
在 Vue 和 React 这类前端框架里,“水合”(Hydration)是个关键概念,“水合信息”就和水合过程产生的数据或者状态有关。
水合的概念
在服务器端渲染(SSR)或者静态站点生成(SSG)的场景中,服务端先把 HTML 渲染好,然后发送到客户端。客户端拿到这些 HTML 后,要把它变成可交互的应用程序,这个过程就是水合。
简单来说,水合就是把静态的 HTML 和框架里的 JavaScript 代码关联起来,让页面具有交互性。
水合信息的本质:
水合信息指的是在水合过程中用到的、和组件状态、事件监听器等相关的数据。具体包含以下内容:
- 组件状态:像组件里的变量、数据这些状态信息。在水合的时候,客户端要把这些状态恢复到和服务端渲染时一样的状态。
- 事件监听器:比如
click
、input
这类事件的监听器。水合过程会把这些事件监听器添加到对应的 DOM 元素上,这样页面才能响应用户的操作。
- 生命周期钩子:组件的生命周期钩子函数在水合的时候会被触发,从而保证组件能正确地初始化和渲染。
下面是 Vue 和 React 中关于水合的简单示例代码:
Vue 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
<template>
<div>
<button @click="increment">点击: {{ count }}</button>
</div>
</template>
<script>
export default {
data() {
return {
count: 0
};
},
methods: {
increment() {
this.count++;
}
}
};
</script>
|
在 Vue 的 SSR 中,服务端会渲染出初始的 HTML,包含 count
的初始值 0
。客户端进行水合时,会恢复 count
的状态,并且给按钮添加 click
事件监听器,这样点击按钮就能更新 count
的值。
React 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import React, { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>点击: {count}</button>
</div>
);
};
export default Counter;
|
在 React 的 SSR 里,服务端会渲染出初始的 HTML,客户端水合时会恢复 count
的状态,同时给按钮添加 onClick
事件监听器,让按钮可以正常响应点击事件。
禁用生产环境显示水合信息的原因
在生产环境中禁用水合信息的显示,主要是为了提高性能和安全性。显示水合信息可能会带来额外的开销,而且有可能泄露一些敏感的应用程序状态信息。通常框架会默认在生产环境下关闭这些调试信息的显示。
上面构造最终的页面模版,因为涉及到多页面,不希望写多份入口文件和多个渲染模版:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// 多入口
entry: {
'entry.page1': './app/pages/page1/entry.page1.js',
'entry.page2': './app/pages/page2/entry.page2.js',
}
// 多个渲染模版
[
new HtmlWebpackPlugin({
filename: '', // 把文件生成到哪
template: path.resolve(process.cwd(), "./app/view/entry.tpl"), // 使用的原始模版
chunks: [entryName],
}),
new HtmlWebpackPlugin({
filename: '', // 把文件生成到哪
template: path.resolve(process.cwd(), "./app/view/entry.tpl"), // 使用的原始模版
chunks: [entryName],
}),
// ...
]
|
所以在base.js初始化时,动态构造多页面入口和多页面渲染模版:
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
26
27
28
29
30
31
32
33
34
|
const glob = require("glob");
const entryList = {}
const htmlWebpackPluginList = []
const pageList = path.resolve(process.cwd(), './app/pages/**/entry.*.js')
//!!! 借助glob获取所有的filePath
glob.sync(pageList).forEach(filePath => {
//这个filePath就是 相当于'xxx/app/pages/page1/entry.page1.js'
//去掉.js后缀,拿到前面的名字,例如entry.page1
const entryName = path.basename(filePath, '.js')
//!!! 构造多入口的对象
entryList[entryName] = filePath
// 相当于写死的这个配置
// {
// 'entry.page1': './app/pages/page1/entry.page1.js',
// 'entry.page2': './app/pages/page2/entry.page2.js',
// }
htmlWebpackPluginList.push(
//! 添加的是HtmlWebpackPlugin对象
new HtmlWebpackPlugin({
filename: path.resolve(
process.cwd(),
"./app/public/dist/",
`${entryName}.tpl`
),
template: path.resolve(process.cwd(), "./app/view/entry.tpl"),
chunks: [entryName],
})
);
});
|
👍模块分包
如果多个组件使用一个公共文件,或者都引用了vue,没有必要都打到一个entry文件中。
合理的分包,根据不同包的使用频率、改动频率,更好的利用浏览器的并发请求特性,也能更好的利用缓存。
例如vue库、某些ui库改的机会不多,单独打包出来。
更重要的是,webpack的运行时代码,如果不单独处理,打到entry文件中,当某个chunk内容改变,运行时代码不变,但hash值也会改变,影响缓存。
分离后,只有运行时代码改变才会影响运行时chunk的hash
webpack运行时代码包含:
- webpack的模块加载系统
- 模块缓存逻辑
- 异步chunk加载逻辑
配置分包策略,一般分为三种包:
- vendor包,代表第三方包,基本不会改动,除非依赖版本升级
- common,代表一些业务组件的公共部分,单独提取出来,改动较少
- entry.page1_3c02631e.bundle 包,代表不同的页面(业务)组件代码,经常改动
这样处理后,可以实现:
- 更好的缓存效果
- 减少主包体积
- 运行时代码从主包中抽离, runtime很少改变,可以长期缓存,runtime可以和其他chunk并行加载
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
26
27
28
|
optimization: {
splitChunks: {
chunks: 'all', // all 异步 同步都分割。例如动态导入(import())的代码只会在需要时候才加载,减少初始加载时间
// minSize: 20000, //生成 chunk 的最小体积(以 bytes 为单位)。
maxAsyncRequests: 10, //按需加载时(异步加载)的最大并行请求数
maxInitialRequests: 10, //入口点的最大并行请求数。
//! 具体的三个分包,缓存组
cacheGroups: {
vendor: {
//! 匹配node_modules目录,windows和Linux斜杠
test: /[\\/]node_modules[\\/]/,
name: 'vendor', //模块名称
priority: 20,
enforce: true, //强制执行
reuseExistingChunk: true, //重用之前页面已打出来的包
},
common: {
name: 'common',
minChunks: 2, // 被几处引用过,这里配置被两处引用就认为是公共模块
minSize: 1, //最小分割文件大小 byte
priority: 10,
reuseExistingChunk: true, //重用之前页面已打出来的包
}
}
},
// 将webpack运行时代码打包到runtime.js
runtimeChunk: true
}
|
👍🏻👍🏻👍🏻生产环境的优化
- 具体的output
1
2
3
4
5
6
7
8
9
|
output: {
filename: "js/[name]_[chunkhash:8].bundle.js",
// 自定义的文件路径
path: path.join(process.cwd(), "./app/public/dist/prod"),
//! 根路径, 注意要用绝对路径! /开头
publicPath: "/dist/prod",
//! 配置跨域
crossOriginLoading: "anonymous",
},
|
chunkhash和contenthash区别
- 生成规则:
- chunkhash基于 Chunk 内容生成,根据每个 chunk(代码块)的内容来生成哈希值。在 Webpack 打包过程中,代码会被分割成多个 chunk,chunkhash 会为每个 chunk 计算一个唯一的哈希值。
相同 Chunk 内容相同哈希:只要 chunk 的内容不变,生成的 chunkhash 就不会改变。例如,某个 JavaScript 文件属于一个特定的 chunk,当该文件内容没有发生变化时,其对应的 chunkhash 也不会改变。
- contenthash基于文件内容生成:contenthash 是根据每个文件的具体内容来生成哈希值。它会逐字节地比较文件内容,只要文件内容有任何改变,生成的 contenthash 就会不同。
文件独立性:每个文件都有自己独立的 contenthash,不受其他文件的影响。即使两个文件属于同一个 chunk,只要它们的内容不同,contenthash 也会不同。
- 使用场景
- chunkhash适用于代码分割场景:在大型项目中,通常会使用代码分割(如动态导入)将代码分割成多个 chunk,以实现按需加载。使用 chunkhash 可以确保当某个 chunk 的内容发生变化时,只有该 chunk 的文件名会改变,而其他 chunk 的文件名保持不变,从而避免不必要的缓存失效。
示例:假设一个项目有一个主 chunk 和多个动态导入的 chunk,当动态导入的 chunk 内容发生变化时,只有该 chunk 的文件名会更新,主 chunk 的文件名不变,用户在访问页面时,主 chunk 可以继续使用浏览器缓存,减少了不必要的网络请求。
- contenthash适用于 CSS 文件,使用 contenthash 可以确保当 CSS 文件内容发生变化时,其文件名也会改变,从而让浏览器重新下载更新后的 CSS 文件。由于 CSS 文件通常是独立于 JavaScript 文件进行处理的,使用 contenthash 可以更精确地控制 CSS 文件的缓存。
1
2
3
4
5
6
|
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css'
})
]
|
除了 CSS 文件,对于其他静态资源(如图片、字体等),也可以使用 contenthash 来确保资源内容更新时,文件名也会相应改变,从而使浏览器能够获取到最新的资源。
- 多线程build,使用terserPlugin
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
26
27
28
29
30
31
32
33
34
35
|
const TerserPlugin = require('terser-webpack-plugin')
const os = require('os')
// 取代happypack,用线程池来并行处理module,加快打包(构建)速度
// 只在处理耗时的 loader 前置添加这个loader:
// 例如适合处理计算密集型的加载器,像 babel-loader、less-loader 等。
// 对于一些轻量级的加载器,使用 thread-loader 可能不会带来明显的性能提升,
// 甚至可能由于进程间通信的开销而导致性能下降。
//! 必须放在加载器链的第一个位置,这样后续的加载器才能在独立的进程中执行。
//!!! 一个例外:MiniCssExtractPlugin.loader 要在它前面
// 因为:thread-loader会把后续的 loader 放到独立的 worker 进程里执行,
// 以此提升构建速度。
// 但 MiniCssExtractPlugin.loader 要在主进程里和 Webpack 构建流程紧密配合
// 所以不能放在 thread-loader 后面。
const threadLoader = require('thread-loader');
//! 预热工作线程池(worker):在启动时预先加载一些模块, 会增加初始启动时间
// 不过会减少后续的加载时间,所以要依据项目的规模和构建频率来决定是否启用。
threadLoader.warmup(
{
//!!! 配置池选项,这些选项必须与后续在 loader 中使用的选项一样
// 也就是说后面module.rules中的thread-loader的option必须和下面的一样,才能正确预热。
//! 设置worker数量,根据需求调整,默认cpu - 1
workers: os.cpus().length - 1,
workerParallelJobs: 50, // 每个工作线程的并行任务数
//! 额外的 Node.js 参数
workerNodeArgs: ['--max-old-space-size=2048'],
poolRespawn: false, // 是否允许重新生成死亡的工作线程池
poolTimeout: 2000, // 工作线程池空闲时的超时时间
poolParallelJobs: 50, // 工作线程池分配给每个工作线程的任务数
name:'nova-pool', // 工作线程池的名称
},
[
// 列出你想要预先加载到工作线程池中的模块
'babel-loader',
],
);
|
- 资源优化:
- 图片压缩、合适的小图用url编码,默认base64
- 字体处理,按需加载字体库,或者使用cdn字体库
- 构建(打包)流程优化
- 持久化本地缓存
- 并行处理耗时的那些loader,例如在babel-loader、css-loader等loader链头部使用thread-loader
- 去除无用代码:js代码用TreeShaking、PureCSS去除没有用到的css
- 打包输出的优化
1
2
3
4
5
6
|
new CleanWebpackPlugin(["public/dist"], {
root: path.resolve(process.cwd(), "./app/"),
exclude: [], //排除xxx
verbose: true, //去除日志
dry: false,
}),
|
- 提取css文件,可以更好的利用浏览器并行请求的特性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css'
})
]
};
|
- 安全性的插件
1
2
3
4
5
6
7
8
9
|
// 允许你在 Webpack 构建过程中,自动向 HTML 文件里的特定标签(如 <script>、<link> 等)添加自定义属性。
// 在你需要为这些标签添加额外的属性以满足特定需求时非常有用,
// 比如添加 defer、async 属性来控制脚本的加载行为,或者添加自定义的 data-* 属性用于前端脚本进行数据传递和交互。
const HtmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
// 浏览器请求资源时,不发送用户的身份凭证
new HtmlWebpackInjectAttributesPlugin({
crossorigin: 'anonymous'
})
|
crossorigin 是 HTML 标签(主要是 <script>、<link>、<img>
等)的一个属性,
用于控制跨域资源的请求方式,它有以下两个主要取值:
- anonymous:表示在请求跨域资源时,不会发送用户的凭证(如 cookie、HTTP 认证信息等)。
浏览器会发起一个跨域请求,但不会包含任何用户的身份信息。
- use-credentials:表示在请求跨域资源时,会发送用户的凭证。
如果服务器端没有正确配置 CORS(跨域资源共享)允许携带凭证,请求将会失败。
之前的分包策略,万一有的包太大,请求很慢呢?
借助performance发现
1
2
3
4
5
|
performance: {
hints: false,
maxAssetSize: 250000, // 限制单个资源文件最大250k
maxEntrypointSize: 400000, // 入口点所有资源文件大小
},
|
超了之后可以用警告或者错误提示,这一点提醒我们:在打包时要关注分包大小。
减小 Webpack 打包体积
1. 代码分割(Code Splitting)
代码分割能把大的代码包拆分成多个小的代码包,按需加载,从而减少初始加载的资源大小。Webpack 支持多种代码分割方式,如入口点分割、动态导入等。
动态导入示例:
1
2
3
4
5
|
// 在需要使用模块的地方动态导入
async function loadComponent() {
const { default: component } = await import('./path/to/component');
return component;
}
|
2. 压缩代码
使用插件对 JavaScript、CSS 等代码进行压缩,去除多余的空格、注释和不必要的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
optimization: {
minimize: true,
minimizer: [
//! 多线程压缩和混淆js,并移除console.
new TerserPlugin({
parallel: true, // 启用多核CPU加速
terserOptions: {
compress: {
drop_console: true, // 去除console
},
},
}),
// 压缩css
new CssMinimizerPlugin(),
],
},
|
3. 去除无用代码(Tree Shaking)
Tree Shaking 是一种消除未使用代码的技术。Webpack 在生产模式下默认支持 Tree Shaking,但需要确保代码使用 ES6 模块语法,并且使用支持 Tree Shaking 的打包工具和配置。
1
2
3
4
5
6
7
8
9
10
11
12
|
// utils.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// main.js
import { add } from './utils';
const result = add(1, 2);
|
在上述代码中,subtract
函数未被使用,在生产构建时会被 Tree Shaking 移除。
4. 图片优化
使用 image-webpack-loader
等插件对图片进行压缩和优化,减少图片文件的大小。
上面只是在处理模块阶段基本的优化,构建的阶段,要进行更细致的压缩image-minimizer-webpack-plugin。
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
26
27
28
29
30
31
32
|
npm install image-minimizer-webpack-plugin imagemin imagemin-gifsicle imagemin-mozjpeg imagemin-pngquant imagemin-svgo --save-dev
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
module.exports = {
module: {
rules: [
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'file-loader'
},
{
loader: ImageMinimizerPlugin.loader,
options: {
minimizer: {
implementation: ImageMinimizerPlugin.imageminMinify,
options: {
plugins: [
['gifsicle', { interlaced: true }],
['jpegtran', { progressive: true }],
['optipng', { optimizationLevel: 5 }]
]
}
}
}
}
]
}
]
}
};
|
5. 使用 CDN
对于一些常用的第三方库,如 React、Vue、jQuery 等,可以使用 CDN(内容分发网络)来加载,减少本地打包的资源大小。
1
2
3
|
<!-- 在 HTML 文件中使用 CDN 加载 React -->
<script src="https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"></script>
|
6. 配置 externals
如果使用了 CDN 加载某些库,可以通过 externals
配置告诉 Webpack 这些库不需要打包到最终的代码中。
1
2
3
4
5
6
|
module.exports = {
externals: {
react: 'React',
'react-dom': 'ReactDOM'
}
};
|
通过以上这些方法,可以有效地减小 Webpack 打包后资源的大小,提高应用的加载性能。
👍🏻👍🏻👍🏻通用优化思路
分析上面的一系列操作,可以总结出一些规律:无论什么打包工具,在做大型项目时,都有三类优化需要去做,根据对应打包工具的文档和社区做就行。
重要是:
- 要知道做什么能优化。
- 什么情形下该优化。
- 如何衡量优化后的效果。
这一节来解决这三个问题。
从性能优化、构建优化和代码质量优化三个方式来做优化。
1、性能优化(直接影响用户体验)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// webpack.prod.js
optimization: {
splitChunks: { // 代码分割
cacheGroups: {
vendor: { // 第三方包分离
test: /[\\/]node_modules[\\/]/,
name: 'vendor'
},
common: { // 公共模块分离
minChunks: 2
}
}
},
runtimeChunk: true // 运行时代码分离
},
plugins: [
new MiniCssExtractPlugin(), // CSS 提取
new PurgeCSSPlugin(), // 删除无用 CSS
new CssMinimizerPlugin(), // CSS 压缩
new TerserPlugin() // JS 压缩
]
|
目的是:
- 减少首屏资源体积(代码分割)
- 提升缓存利用率(合适的分包策略)
- 减少 CSS/JS 文件体积(压缩优化)
2、构建优化(开发体验优化)
1
2
3
4
5
6
7
|
// webpack.prod.js
{
loader: "thread-loader", // 线程池加速
options: {
workers: os.cpus().length - 1
}
}
|
目的是:
- 多进程并行构建(thread-loader)
- 预热线程池(warmup 配置)
- 开发环境热更新(HMR)
3、代码质量优化
1
2
3
4
5
6
|
// webpack.base.js
{
test: /\.js$/,
include: [path.resolve('./app/pages')], // 限定 Babel 编译范围
use: ['babel-loader']
}
|
预加载和预取:
1
2
3
4
5
6
7
|
// webpack.prod.js
plugins: [
new PreloadWebpackPlugin({
rel: 'preload',
include: 'initial'
})
]
|
目的是:
- 避免全量编译(精准 include 配置)
- 类型检查(通过 TS/Vue 模板编译)
- 各种lint校验
- 合适时机加载相关资源
优化类型 |
具体方案 |
适用场景 |
加载性能优化 |
代码分割 (SplitChunksPlugin) |
多路由/多入口应用 |
缓存优化 |
文件名哈希 (contenthash) |
长期缓存策略 |
资源压缩 |
图片压缩 (image-webpack-loader) |
图片资源较多的项目 |
构建速度优化 |
多进程构建 (thread-loader) |
大型项目构建加速 |
- Bundle 分析:
1
|
npx webpack-bundle-analyzer stats.json
|
👍🏻衡量优化效果
- 构建速度对比 (使用
speed-measure-webpack-plugin
)
1
|
npm install --save-dev speed-measure-webpack-plugin
|
1
2
3
4
|
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
module.exports = smp.wrap(webpackConfig) // 包裹原配置
|
输出示例:
1
2
3
|
LoaderA: 12.34s
LoaderB: 8.12s
Total build time: 38.56s (优化前 62.34s)
|
- 体积分析 (使用
webpack-bundle-analyzer
)
1
|
npm install --save-dev webpack-bundle-analyzer
|
1
2
3
4
5
6
7
|
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
// 在 plugins 数组中添加:
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html'
})
|
输出效果:
1
2
|
vendor.xxxx.js 1.2MB → 876KB (↓27%)
entry.page1.js 512KB → 324KB (↓36%)
|
还有一种生成静态文件的分析方法:在 Webpack 工程中生成stats.json
1
2
|
# 在项目根目录执行(MacOS)
webpack --profile --json > stats.json
|
或通过配置生成(推荐):
1
2
3
4
5
6
7
8
9
10
|
// 修改 webpack 配置
webpack(webpackConfig, (err, stats) => {
if (err) throw err
// 生成 stats.json
fs.writeFileSync(
path.resolve(__dirname, 'stats.json'),
JSON.stringify(stats.toJson('verbose'))
)
})
|
stats.json 包含的关键信息
分析维度 |
具体数据 |
优化方向案例 |
模块体积 |
每个模块的原始大小、gzip后大小 |
发现 2.8MB 的 moment.js → 改用 dayjs |
重复依赖 |
相同模块在不同 chunk 中的重复次数 |
lodash 在 5 个 chunk 重复 → 提取到公共 chunk |
入口依赖 |
首屏加载必须的核心资源列表 |
main.js 含 30+ 路由组件 → 改为动态导入 |
第三方占比 |
node_modules 代码在总包中的比例 |
占比 78% → 使用 CDN 加载 react 、vue 等 |
缓存失效 |
文件哈希值变化频率 |
vendor.js 每周都变 → 调整 splitChunks 策略 |
典型分析案例
结合自己的项目去分析,以我的代码片段为例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// stats.json 片段示例
{
"assets": [
{
"name": "vendor.1234.js",
"size": 1024000, // 1MB
"chunks": ["vendors~main"],
"chunkNames": ["vendors~main"]
},
{
"name": "src_pages_Home.5678.js",
"size": 256000, // 256KB
"chunks": ["src_pages_Home"]
}
]
}
|
对应的优化建议:
1
2
3
4
5
6
7
8
9
10
11
|
// 修改分包策略
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react-vendor' // 单独提取 React 相关
}
}
}
}
|
也可视化分析:
1
|
npx webpack-bundle-analyzer /xxx/stats.json
|
浏览器查看:
- 真实场景测试 (Chrome DevTools)
1
2
3
4
5
|
// 在项目入口添加性能监控
console.time('First Paint')
window.addEventListener('load', () => {
console.timeEnd('First Paint') // 优化前 3.2s → 优化后 1.8s
})
|
应该输出一份这个类似的报告作为结果:
优化类型 |
典型提升幅度 |
数据来源 |
代码分割 |
首屏体积↓30-50% |
Webpack 官方性能报告 |
多线程构建 |
构建时间↓20-40% |
Terser 官方基准测试 |
图片转 WebP |
图片体积↓25-35% |
Google 开发者文档 |
按需引入 (lodash) |
包体积↓40-60% |
Lodash 官方迁移指南 |
结论:
在系统优化中,通过代码分割和缓存策略,首屏资源体积减少了约40%,构建时间从平均90秒缩短到55秒左右。具体来说:
- 将
lodash
改为按需引入,减少 420KB 的包体积
- 使用
thread-loader
后,babel-loader
阶段的耗时减少 35%"
通过 webpack-bundle-analyzer
分析发现,node_modules
代码占比达78%。实施分包策略后:
- 首屏资源从 2.1MB 降至 1.4MB (↓33%)
- 二次加载因缓存命中率提升,加载时间从 3.2s 降至 1.1s
- 完整构建耗时从 86s 优化至 49s (↓43%)"
👍 开发环境优化
对比生产环境的优化,基本上除了webpack.base.js,额外做的优化不多,为了开发调试方便,会启用sourcemap
1
|
devtool: 'eval-cheap-module-source-map',
|
👍🏻sourcemap分类:
对于开发环境
以下选项非常适合开发环境,通常会从以下四种挑一个:

开始时期望看到源码,所以通过生成代码后的信息,选择后两种,再从build的速度,最终选择了eval-cheap-module-source-map
- eval - 每个模块都使用 eval() 执行,并且都有 //# sourceURL。此选项会非常快地构建。主要缺点是,由于会映射到转换后的代码,而不是映射到原始代码(没有从 loader 中获取 source map),所以不能正确的显示行数。
- eval-source-map - 每个模块使用 eval() 执行,并且 source map 转换为 DataUrl 后添加到 eval() 中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。
- eval-cheap-source-map - 类似 eval-source-map,每个模块使用 eval() 执行。这是 “cheap(低开销)” 的 source map,因为它没有生成列映射(column mapping),只是映射行数。它会忽略源自 loader 的 source map,并且仅显示转译后的代码,就像 eval devtool。
- eval-cheap-module-source-map - 类似 eval-cheap-source-map,并且,在这种情况下,源自 loader 的 source map 会得到更好的处理结果。然而,loader source map 会被简化为每行一个映射(mapping)。
官网详细分类解释
官网分类示例代码
开发时的动态代码不落盘,直接放到内存中,通过HMR做热更新。
主要是理解HMR原理。
👍👍👍热更新原理
要做热更新,先知道要做哪些事情,才能做到:本地代码变了,页面跟着刷新。
是不是需要一个服务能处理静态文件目录(static),能监听源码变化,同时他还能和页面进行通信,得让页面有可以双向通信的能力,将变化后的代码注入到页面中。
这个服务,就是所谓的devServer,他有三个能力:
- 监控源码变化
- 往浏览器注入一些代码,使浏览器具备双向通信的能力,本地代码变化了告诉浏览器,浏览器去拉新代码,刷新页面
- 开辟适量的内存空间,存储代码片段。
所以原理如图所示:

压缩优化完的代码走两个分支:
- 分支2:tpl模版直接落盘就好
- 分支1:模版依赖的资源,通过访问一个在线地址动态更新,而这个动态地址就指向内存中的代码片段。这个分支把每次优化后的代码注入到devServer的内存中即可。
实现上,用express做devServer,在用两个express能够使用的中间件:webpack-dev-middle、webpack-hot-middleware
,实现监控能力、双向通信的能力。
其中:
webpack-dev-middle
中间件使express服务器具备监控能力、将 webpack 编译输出托管到内存文件系统(分支1)、落地tpl文件(分支2)
1
2
3
4
5
|
// DevMiddleware 内部实现伪代码
compiler.outputFileSystem = new MemoryFileSystem()
compiler.watch({}, (err, stats) => {
// 将编译结果存入内存
})
|
webpack-hot-middleware
实现HMR
热模块替换
用代码描述的话:
- 在dev.js中要暴露devServer的配置(尤其注意官方要求的
HMR_PATH: '__webpack_hmr'
)
- 重写入口文件,除第三方包外需要做hmr 增加热更新的配置地址。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
// webpack.dev.js核心代码
// devServer配置
const DEV_SERVER_CONFIG = {
HOST: '127.0.0.1',
PORT: 9002,
HMR_PATH: '__webpack_hmr', // 官方规定
TIMEOUT: 20 * 1000, // 20秒
}
// 开发环境希望的是entry入口文件改了,就更新,所以肯定不是固定的入口路径
// 要通过热替换模块(HMR)来实现代码更改,通知页面更新
Object.keys(webpackBaseConfig.entry).forEach(entryName => {
//! 回忆一下三种分包策略,第三方包肯定不需要做hmr
if(entryName !== 'vendor') {
const {
HOST,
PORT,
HMR_PATH,
TIMEOUT,
} = DEV_SERVER_CONFIG
//! 重新给入口文件赋值
webpackBaseConfig.entry[entryName] = [
// 原来的主入口文件
webpackBaseConfig.entry[entryName],
// hmr官方入口,client后面的查询字符串部分是用来给客户端传递配置参数的
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}&timeout=${TIMEOUT}&reload=true`
]
}
})
const {
HOST,
PORT,
} = DEV_SERVER_CONFIG
// 开发环境webpack配置
const webpackConfig = merge.smart(webpackBaseConfig, {
mode: "development",
// 开启sourcemap,通过代码映射关系,方便开发环境调试代码
devtool: 'eval-cheap-module-source-map',
// 开发阶段的输出产物
output: {
filename: "js/[name]_[chunkhash:8].bundle.js",
// 文件放哪
path: path.resolve(process.cwd(), "./app/public/dist/dev/"),
//! 根路径, 想想开发环境这里应该填什么
// 应该用上面的devServer配置,组装成有效的publicPath
// 也就是说会把输出的产物放到上面devServer配置后的路径上
// 既然开发环境想用热更新,他就要是个可用的本地链接。
publicPath: `http://${HOST}:${PORT}/public/dist/dev/`,
globalObject: 'this',
},
// 开发阶段的插件,最重要的就是热更新插件
plugins: [
//! 开发阶段有了这个插件,才能让应用程序代码更新了,立马反应到页面变化上
new webpack.HotModuleReplacementPlugin({
// 默认值为 false。
// 当设置为 true 时,模块热替换分两步:先更新所有的模块,再重新构建依赖图;
// 而设置为 false 时,模块热替换会一次性完成所有操作。
// 通常来说,false 能加快热替换的速度,不过可能会消耗更多的内存。
multiStep: false,
})
]
});
module.exports = {
DEV_SERVER_CONFIG, //暴露给dev.js的开发服务器使用
webpackConfig
}
|
上面output的publicPath就是内存中的地址,当访问tpl时,动态加载的依赖就是他的路径:
如果你更新代码后,这个路径会变:

devServer实现上,用express来启动即可。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
|
/ 通过webpack.dev.js获取devServer配置和webpack开发环境配置
const {
DEV_SERVER_CONFIG,
webpackConfig //开发配置
} = require('./config/webpack.dev')
const {
PORT,
HMR_PATH,
} = DEV_SERVER_CONFIG
const app = express()
//! 获取开发环境下,webpack解析后的内容
const compiler = webpack(webpackConfig)
// 指定静态文件目录,注意后面结尾的斜杠
app.use(express.static(path.join(__dirname, '../public/dist/')))
// 使用devMiddleware中间件,监听原始文件的变化
app.use(devMiddleware(compiler, {
// 落到硬盘中的文件, 模板文件直接落盘就好
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
// 资源路径
publicPath: webpackConfig.output.publicPath,
// 跨域配置
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
},
stats: {
colors: true, //日志彩色
}
}))
// 使用hotMiddleware中间件,实现热更新通信
app.use(hotMiddleware(compiler, {
//! 热更新地址,记得前面加斜杠,跟路由的意思,最终会拼接成http://xxx:port/__webpack_hmr
path: `/${HMR_PATH}`,
log: () => {}
}))
|
关键配置对应关系
配置项 |
作用域 |
影响范围 |
publicPath |
DevMiddleware |
定义内存文件系统的访问路径 (http://localhost:9002/[publicPath]) |
path |
HotMiddleware |
WebSocket 服务端监听路径 (ws://localhost:9002/[path]) |
writeToDisk 回调 |
DevMiddleware |
控制哪些编译结果需要持久化到磁盘 |
headers 跨域配置 |
DevMiddleware |
解决开发时前后端分离导致的CORS问题 |
启动后,会发现一个本地 服务器:

更新代码时,代码更新,整个过程一直保持心跳:

至此,我们完成了手动实现热更新,也就是webpack开箱即用的devServer功能,完整的时序图:

基础建设
统一收拢页面启动的入口代码:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
import { createApp } from "vue";
import pinia from "$store";
import ElementPlus from "element-plus";
// import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/index.css'
import { createWebHashHistory, createRouter } from 'vue-router'
/**
* 初始化页面入口
* @param {import('vue').Component} pageComponent 页面组件实例
* @param {Object} options 配置项
* @param {import('vue-router').RouterOptions} [options.routes] 路由配置
* @param {Array<string>} [options.libs] 第三方库配置
*/
const initPage = (pageComponent, {routes = [], libs = []} = {}) => {
const app = createApp(pageComponent)
//! 在挂载渲染点之前,使用各种中间件
app.use(pinia)
app.use(ElementPlus)
// 注册各种第三方组件,例如echarts
if(libs && libs.length) {
for(let i = 0; i < libs.length; i++) {
app.use(libs[i])
}
}
if(routes && routes.length) {
const router = createRouter({
history: createWebHashHistory(),
routes,
})
app.use(router)
// 等所有的路由(包括异步路由)加载好,再挂载
router.isReady().then(() => {
app.mount('#root')
})
} else {
app.mount('#root')
}
}
export default initPage
|
这样每个页面的entry.page.js只需要引入即可:

请求适配器
通过适配器模式来封装请求模块,符合单一原则。
例如你现在用的axios请求库,后面想换成superagent,可以无缝衔接。
首先封装请求适配模块的基类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
/**
* 请求适配器基类
*/
export default class BaseRequestAdapter {
//!!! 可以认为是两个抽象方法,子类必须实现它。
/**
* 发送请求
* @param {Object} options 请求配置
* @returns {Promise} 请求结果
*/
request(options) {
throw new Error('Adapter must implement request method');
}
/**
* 处理响应
* @param {Object} response 响应数据
*/
handleResponse(response) {
throw new Error('Adapter must implement handleResponse method');
}
}
|
如果你用axios请求,你的实现类,必须实现上面的抽象方法:
1
2
3
4
5
6
7
8
9
10
11
12
|
import BaseRequestAdapter from './base';
export default class AxiosAdapter extends BaseRequestAdapter {
request(options) {
return axios.request(options);
}
handleResponse(response) {
return response.data;
}
}
|
假如某一天你想换一个请求库superagent,实现上面的抽象方法即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 举例,如果不打算用axios了,换一个适配器即可
import BaseRequestAdapter from './base';
import superagent from 'superagent';
export default class SuperagentAdapter extends BaseRequestAdapter {
request({ url, method, data, params, headers }) {
return superagent[method](url)
.query(params)
.send(data)
.set(headers);
}
handleResponse(response) {
return response.body;
}
}
|
再具体的请求实现那:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import AxiosAdapter from './request-adapters/axios';
// 默认使用 axios 适配器
let requestAdapter = new AxiosAdapter();
/**
* 设置请求适配器
* @param {BaseRequestAdapter} adapter 请求适配器实例
*/
export const setRequestAdapter = (adapter) => {
requestAdapter = adapter;
};
// 例如我们要切换到 superagent,只需要引入它,设置它即可
import { setRequestAdapter } from '$common/curl';
import SuperagentAdapter from '$common/request-adapters/superagent';
setRequestAdapter(new SuperagentAdapter());
|
而我们的真正请求只需要关心requestAdapter
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
26
27
28
29
30
31
32
33
34
35
|
const curl = ({
//...
}) => {
// 组装参数
const requestOptions = {
//...
}
return requestAdapter.request(requestOptions)
.then(response => {
return Promise.resolve({
//...
})
}).catch(err => {
// ...
return Promise.resolve(err)
})
}
export default curl
// 页面请求时
import $curl from "$common/curl";
onMounted(async () => {
const res = await $curl({
url: "/api/project/list",
method: "get",
query: {
page: 1,
pageSize: 2,
},
});
console.log("res is ", res);
});
|