webpack不同环境配置
webpack 配置的三层继承架构:
-
webpack.base.js 是公共部分,包含所有环境共有的配置:entry 入口、loader 规则、路径别名、VueLoaderPlugin、splitChunks 分包策略等
-
webpack.dev.js 和 webpack.prod.js 分别通过
webpack-merge.smart继承 base 配置,再叠加各自环境特有的配置:- dev 叠加:
mode: 'development'、source-map、HMR 客户端注入、HotModuleReplacementPlugin - prod 叠加:
mode: 'production'、CSS 提取与压缩、HappyPack 多线程、Terser 压缩、CleanWebpackPlugin
- dev 叠加:
基础配置"] -->|webpack-merge.smart| B["webpack.dev.js
开发环境"] A -->|webpack-merge.smart| C["webpack.prod.js
生产环境"] B --> D["dev.js
Express + HMR 服务器"] C --> E["prod.js
命令行构建脚本"] style A fill:#4a9eff,color:#fff style B fill:#52c41a,color:#fff style C fill:#fa541c,color:#fff
构建流程总览
markdown
整体的实现流程,前端源码vue组件,通过webpack不同的**loader**进行编译分包,注入到模板tpl文件中,wbepack进行打包编译成**dist**(html/css/js源码),最后通过后端koa进行渲染tpl文件,浏览器展示结果
vue-loader / babel-loader / css-loader..."] Loaders --> Split["splitChunks 分包
vendor / common / page"] Split --> HTML["HtmlWebpackPlugin
注入到 .tpl 模板"] end subgraph 产物["构建产物 (public/dist)"] TPL["*.tpl 模板文件"] JS["js/vendor_*.bundle.js
js/common_*.bundle.js
js/entry.*_*.bundle.js"] CSS["css/*.bundle.css"] end subgraph 后端["后端 Koa"] Koa["nunjucks 渲染 .tpl"] Browser["浏览器"] end 源码 --> Webpack --> 产物 产物 --> Koa --> Browser
分阶段实施步骤
1. 基础配置搭建
目标: vue3文件加载进webpack,跑通 webpack 基本打包流程,产出可运行的页面。
入口配置
手动配置每个页面的 entry:
css
entry: {
'entry.page1': './app/pages/page1/entry.page1.js',
'entry.page2': './app/pages/page2/entry.page2.js',
}
模块解析(module.rules)
(仅 app/pages)"] R1 -->|".css"| L3["css-loader
→ style-loader"] R1 -->|".less"| L4["less-loader
→ css-loader
→ style-loader"] R1 -->|".png/.jpg/.gif"| L5["url-loader
"] R1 -->|".eot/.svg/.ttf/.woff"| L6["file-loader"] L1 --> out["webpack bundle"] L2 --> out L3 --> out L4 --> out L5 --> out L6 --> out
路径别名
css
resolve: {
alias: {
$page: ".../app/pages";
}
}
核心插件
- VueLoaderPlugin --- 必须,使 vue-loader 生效
- ProvidePlugin --- 将
Vue暴露为全局变量 - DefinePlugin --- 定义 Vue3 编译时常量(
__VUE_OPTIONS_API__等) - HtmlWebpackPlugin --- 将打包产物注入到 Nunjucks 模板
.tpl文件中,实现多页面模板生成
核心知识点
多页面应用(MPA)vs 单页面应用(SPA)
| 对比维度 | SPA(单页面) | MPA(多页面)--- 本项目 |
|---|---|---|
| entry 数量 | 1 个 | 多个,每个页面一个 |
| 页面切换 | 前端路由(Vue Router)无刷新切换 | 浏览器整页跳转,每次加载独立的 .tpl 模板 |
| HTML 模板 | 1 个 index.html,所有路由共享 |
每个 entry 对应生成 1 个 .tpl 文件 |
| 资源加载 | 首次加载全量 JS,后续路由切换无网络请求 | 只加载当前页面需要的 bundle,按需更轻量 |
MPA 的核心机制 --- 每个 entry 需要配套一个 HtmlWebpackPlugin 实例,将该 entry 产出的 bundle 注入到对应的 .tpl 模板:
产物输出
css
output: {
filename: 'js/[name]_[chunkhash:8].bundle.js',
path: './app/public/dist/prod',
publicPath: '/dist/prod',
}
2.工程化完善
入口文件自动发现
用 glob 扫描 app/pages/**/entry.*.js,自动构造 entry 和对应的 HtmlWebpackPlugin 实例,新增页面只需按约定创建文件,无需修改配置:
app/pages/**/entry.*.js"] --> B["遍历匹配文件"] B --> C["解析文件名
basename → entryName"] C --> D["构造 pageEntries
entryName → filePath"] C --> E["构造 HtmlWebpackPlugin
filename → entryName.tpl
chunks → entryName"] D --> F["module.exports.entry = pageEntries"] E --> G["plugins: [...htmlWebpackPluginList]"] style A fill:#4a9eff,color:#fff style F fill:#52c41a,color:#fff style G fill:#52c41a,color:#fff
具体实现
js
const entryList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(entryList).forEach(file => {
const entryName = path.basename(file, '.js');
pageEntries[entryName] = file;
htmlWebpackPluginList.push(new HTMLWebpackPlugin({ ... }));
});
2. 代码分割(splitChunks)
将 JS 产物分为三类,优化浏览器缓存利用率:
(node_modules)"] P2 --> V1 P2 --> V2["echarts (node_modules)"] end subgraph splitChunks 分包产物 direction TB V["vendor.bundle.js
所有 node_modules"] CM["common.bundle.js
被 2+ 页面引用的公共代码"] EP1["entry.page1.bundle.js
page1 独有代码"] EP2["entry.page2.bundle.js
page2 独有代码"] RT["runtime.bundle.js
webpack 运行时"] end V1 --> V V2 --> V C1 --> CM P1 --> EP1 P2 --> EP2 style V fill:#722ed1,color:#fff style CM fill:#1890ff,color:#fff style EP1 fill:#52c41a,color:#fff style EP2 fill:#52c41a,color:#fff style RT fill:#faad14,color:#fff
| 分包 | 内容 | 变更频率 |
|---|---|---|
vendor |
node_modules 第三方依赖 |
极低,仅升级版本时 |
common |
被 2+ 页面引用的业务公共代码 | 较低 |
entry.{page} |
各页面独有的业务代码 | 高频 |
核心知识点
分包策略优化 每个页面都会把所有依赖打包到一个 bundle 里导致:
- 重复代码:多个页面都用到 Vue、axios、lodash,每个 bundle 里都有一份
- 缓存失效 :业务代码改动一个字,整个 bundle 的 hash 就变了,第三方库也得重新下载 因此需要进行分包策略优化,避免重复引用重复代码,缓存失效的问题。 因此在分包策略中分成两个部分结构,主要分为 vendor 和common 。
- vendor 第三方库,Vue、axios、lodash 等第三方库被打包到一个独立的
vendor.js,只有升级依赖版本时 hash 才会变。 - common 业务公共代码,
app/pages/common/、app/pages/widget/等被多个页面共享的业务组件被打包到common.js。
- vendor 第三方库,Vue、axios、lodash 等第三方库被打包到一个独立的
关键配置:
js
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: { test: /[\\/]node_modules[\\/]/, priority: 20, enforce: true },
common: { minChunks: 2, minSize: 1, priority: 10 },
}
},
runtimeChunk: true // 拆离 webpack runtime
3. 环境分离:dev vs prod
采用 webpack-merge 的 merge.smart 策略,在 base 配置上叠加环境差异:
CSS 提取"] P3["TerserWebpackPlugin
JS 压缩 + 多核"] P4["HappyPack
多线程编译"] P5["CleanWebpackPlugin
清空 dist"] P6["drop_console: true"] end BASE["webpack.base.js
基础配置"] --> D1 & D2 & D3 & D4 & D5 BASE --> P1 & P2 & P3 & P4 & P5 & P6
开发环境配置
HMR 工作原理
GET /__webpack_hmr Dev->>FS: 修改 .vue 文件 FS->>WM: 文件变更通知 WM->>Webpack: 触发重新编译 Webpack->>Webpack: 增量编译变更模块 Webpack->>HM: 编译完成,生成 hash HM->>Browser: SSE 推送 {action:"built", hash} Browser->>WM: 请求更新的 chunk WM-->>Browser: 返回更新模块 Browser->>Browser: HMR Runtime 热替换模块
无需整页刷新
HMR(Hot Module Replacement)热模块替换由客户端 和服务端两部分协同完成。
1. 服务端:dev.js 启动中间件链
js
const compiler = webpack(webpackConfig); // webpack 编译器
// 中间件 1:devMiddleware 监听文件变化,触发增量编译,产物保存在内存
app.use(
devMiddleware(compiler, {
writeToDisk: (filePath) => filePath.endsWith(".tpl"), // 仅 .tpl 落地磁盘(后端渲染需要)
publicPath: webpackConfig.output.publicPath,
headers: { "Access-Control-Allow-Origin": "*" }, // 跨域
}),
);
// 中间件 2:hotMiddleware 建立 SSE 长连接通道,向浏览器推送编译状态
app.use(
hotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`, // 路由约定:/__webpack_hmr
log: () => {},
}),
);
2. 客户端:HMR 入口注入(webpack.dev.js)
js
Object.keys(baseConfig.entry).forEach((v) => {
if (v !== "vendor") {
baseConfig.entry[v] = [
baseConfig.entry[v], // 原始入口
// 注入 HMR 客户端,建立 SSE 长连接,接收服务器推送
`webpack-hot-middleware/client?path=http://127.0.0.1:9002/__webpack_hmr&timeout=2000&reload=true`,
];
}
});
注入后,浏览器的 JS bundle 末尾会包含一段 HMR Client 代码,它:
- 解析参数中的
path,向 devServer 建立EventSource(SSE)长连接 - 监听服务器推送的编译状态事件
- 收到
{ action: 'built', hash: 'xxx' }后,向 devServer 请求更新模块 - 用
module.hot.accept()API 局部替换模块,无需整页刷新
3. 核心插件:HotModuleReplacementPlugin(webpack.dev.js)
js
plugins: [
new webpack.HotModuleReplacementPlugin({
multiStep: false, // 一次推送完成整个更新流程
}),
];
这个插件是 HMR 的基础设施:它修改了 webpack compiler 的行为,使编译产物中包含模块热替换所需的运行时补丁代码。没有这个插件,HMR Client 无法知道哪些模块需要替换。
HMR 工作流程分步说明
| 步骤 | 发生了什么 | 关键代码位置 |
|---|---|---|
| ① 启动 | HMR Client 在 bundle 末尾执行,向 devServer 建立 SSE 连接 | webpack-hot-middleware/client?... |
| ② 修改文件 | 开发者保存 .vue 或 .js 文件,fs.watch 触发回调 |
--- |
| ③ 重新编译 | devMiddleware 调用 compiler.watch(),webpack 增量编译变更的模块 |
dev.js 第 24 行 |
| ④ SSE 推送 | hotMiddleware 监听 compiler.plugin('done'),通过 SSE 向浏览器推送 { action: 'built', hash: '...' } |
hot-middleware 内部 |
| ⑤ 拉取模块 | HMR Client 收到 hash,向 devServer 发送 GET /entry.page1/?hash=xxx,devMiddleware 返回更新后的 chunk |
--- |
| ⑥ 模块替换 | HMR Runtime 调用 module.hot.accept(),将新模块注入,Vue 组件重新 render |
--- |
| ⑦ 失败回退 | 若 HMR 失败(模块没有定义 accept),HMR Client 自动刷新页面 |
reload=true 参数 |
关键思考点
reload=true 的作用
js
// 注入的 URL 中带 reload=true
`webpack-hot-middleware/client?timeout=2000&reload=true`;
reload=true:当 HMR 更新失败时(如某个模块的accept回调抛出异常),自动执行location.reload()整页刷新,避免开发者卡在过时的页面状态reload=false:不刷新,适合需要完全自定义更新行为的场景
为什么 .tpl 需要 writeToDisk
js
writeToDisk: (filePath) => filePath.endsWith(".tpl");
devServer 通过 public/dist/dev/ 提供静态文件(供浏览器访问),但 Koa 后端的模板渲染是从磁盘 读取 .tpl 文件再用 nunjucks 渲染。如果 .tpl 只存在内存中,Koa 读取不到,页面就会渲染失败。因此 .tpl 必须写入磁盘,而 JS/CSS 等资源保持在内存中供浏览器快速访问。
source-map中还有什么配置
devtool 各选项对比:
| 选项 | 构建速度 | 重新构建速度 | 质量 | 适用场景 |
|---|---|---|---|---|
eval |
极快 | 极快 | 转换后的代码 | 不关心源码映射 |
eval-cheap-module-source-map |
快 | 快 | 原始源码(行级) | 开发环境首选 |
cheap-module-source-map |
较慢 | 一般 | 原始源码(行级) | 需要独立 .map 文件 |
source-map |
最慢 | 最慢 | 原始源码(精确到列) | 生产环境调试 |
hidden-source-map |
最慢 | 最慢 | 不暴露 .map 文件 | 生产错误上报 |
生产环境配置
js
const merge = require("webpack-merge");
const os = require("os");
const Happypack = require("happypack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CleanWebpackPlugin = require("clean-webpack-plugin");
const CSSMinimizerPlugin = require("css-minimizer-webpack-plugin");
const HtmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
const TerserWebpackPlugin = require("terser-webpack-plugin");
// HappyPack 线程池:线程数 = CPU 核心数
const happypackCommonConfig = {
debug: false,
threadPool: Happypack.ThreadPool({ size: os.cpus().length }),
};
生产配置合并
js
const webpackConfig = merge.smart(baseConfig, {
mode: 'production',
output: {
filename: 'js/[name]_[chunkhash:8].bundle.js',
path: path.join(process.cwd(), './app/public/dist/prod'),
publicPath: '/dist/prod',
// crossOriginLoading: 匿名加载资源,配合 SRI(子资源完整性)使用
// 当 script 标签有 crossorigin 属性时,浏览器会发送无凭证的 CORS 请求
crossOriginLoading: 'anonymous',
},
chunkhash vs contenthash:
chunkhash:基于 chunk 内容生成 hash。同一 chunk 中任何模块变化都会改变 hashcontenthash:基于文件内容生成 hash。只有文件本身内容变化才会改变(CSS 用contenthash更精确)
生产环境 Loader 覆盖
js
module: {
rules: [
// 覆盖 base 的 CSS 规则:用 MiniCssExtractPlugin.loader 替代 style-loader
// style-loader 把 CSS 注入 <style> 标签(开发时方便热替换)
// MiniCssExtractPlugin.loader 把 CSS 提取为独立 .css 文件(生产时利于缓存)
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 提取 CSS 到独立文件
'happypack/loader?id=css', // 通过 HappyPack 多线程处理
]
},
// 覆盖 base 的 JS 规则:用 HappyPack 多线程处理
{
test: /\.js$/,
include: [path.join(process.cwd(), './app/pages')],
use: { loader: 'happypack/loader?id=js' }
}
]
},
merge.smart 的作用在此处体现: base 中有 test: /\.css$/ 的规则(style-loader + css-loader),prod 中也有 test: /\.css$/。merge.smart 按 test 匹配合并,而不是整个数组覆盖,确保不会丢失其他文件类型的 loader 规则。
生产插件
js
plugins: [
// ① CleanWebpackPlugin --- 构建前清空输出目录,防止旧文件残留
new CleanWebpackPlugin(['public/dist'], {
root: path.resolve(process.cwd(), './app/'),
exclude: [],
verbose: true,
dry: false,
}),
// ② MiniCssExtractPlugin --- 将 CSS 提取为独立文件
// 使用 contenthash 精确控制缓存:CSS 内容不变,hash 不变
new MiniCssExtractPlugin({
chunkFilename: 'css/[name]_[contenthash:8].bundle.css',
}),
// ③ CSSMinimizerPlugin --- 压缩 CSS(移除空格、注释、合并规则等)
new CSSMinimizerPlugin(),
// ④ HappyPack(JS 处理)--- 多线程并行转译 JS
new Happypack({
...happypackCommonConfig,
id: 'js',
loaders: [`babel-loader?${JSON.stringify({
presets: ['@babel/preset-env'], // 按目标环境做语法降级
plugins: ['@babel/plugin-transform-runtime'] // 复用 Babel 辅助函数,避免重复注入
})}`]
}),
// ⑤ HappyPack(CSS 处理)--- 多线程并行处理 CSS
// 注意:这里 id 也写的 'js',实际应该是 'css',这是一个 bug
new Happypack({
...happypackCommonConfig,
id: 'js', // ⚠️ 应改为 'css'
loaders: [{
path: 'css-loader',
options: { importLoaders: 1 } // 在 css-loader 之前还有 1 个 loader(即 MiniCssExtractPlugin.loader)
}]
}),
// ⑥ HtmlWebpackInjectAttributesPlugin --- 为 <script> 标签添加 crossorigin 属性
// 与 output.crossOriginLoading 配合,实现 CORS 匿名加载
// 好处:当 CDN 资源被篡改时,浏览器可配合 SRI 检测拒绝加载
new HtmlWebpackInjectAttributesPlugin({
crossorigin: 'anonymous',
})
],
JS 压缩优化
js
optimization: {
minimize: true,
minimizer: [
new TerserWebpackPlugin({
parallel: true, // 多核并行压缩(线程数 = CPU 核心数 - 1)
cache: true, // 启用文件缓存,二次构建时跳过未变更文件的压缩
terserOptions: {
compress: {
drop_console: true, // 移除所有 console.* 语句
},
},
}),
],
}
});
TerserWebpackPlugin 的优化效果:
parallel: true--- 利用多核 CPU 并行压缩,耗时约为单线程的 1/Ncache: true--- 增量构建时,只有变更的文件会被重新压缩drop_console: true--- 生产环境移除调试日志,减小包体积
除了 happypack 是否有更好的加速打包工具 (具体实现配置方式需看官方文档)
- thread-loader:webpack 内置生态,专门用于多线程处理 loader
- esbuild-loader :用 Go 编写的 esbuild 做编译,比 babel-loader 快 10-100 倍
- SWC-loader:Rust 编写,速度接近 esbuild,但兼容 Babel 插件的能力更强
为什么使用for循环不用forEach 性能更好? 在大多数场景下,for 循环比 forEach 快 约 2-10 倍,具体取决于数据量和引擎优化。
for 循环:
- 编译后就是纯粹的跳转指令,没有函数调用开销
- 引擎可以做各种优化(内联、逃逸分析等)
forEach:
- 每次迭代都会创建一个函数调用栈(压栈、弹栈)
- 无法被
break/continue中断,引擎优化空间更小 - 每次回调都会产生闭包上下文(即使箭头函数)
4.关键设计决策总结
- 多页面架构 --- 每个页面独立 entry,通过 glob 自动发现,产物为
.tpl模板供后端 Koa 渲染 - 环境配置分离 --- base/dev/prod 三层配置,webpack-merge 智能合并
- 分包策略 --- vendor / common / page 三级分包,最大化浏览器缓存命中
- 开发体验 --- 自建 Express devServer + HMR,
.tpl文件写入磁盘以配合后端模板渲染 - 生产优化 --- 多线程编译(HappyPack)、并行压缩(Terser)、CSS 提取与压缩、source-map 关闭、console 移除
- 应用启动统一 --- boot.js 抽象应用初始化流程,降低页面接入成本