大家好啊,我是练习了2年半的前端小菜鸡,时隔一个月,第二篇前端工程化来了!
上一篇我们主要了解到了elpis-core 的服务端框架内核,这一篇我们走到前端工程化。
Elpis 前端 Webpack 工程化实践
基于 Vue 3 + 多页应用的 Webpack 构建体系,从环境隔离、分包策略到热更新的全链路设计。
一、整体架构
项目采用 "1 个基础 + 2 个环境" 的配置结构:
app/webpack/
├── config/
│ ├── webpack.base.js ← 公共基础配置(entry/alias/module/optimization)
│ ├── webpack.dev.js ← 开发环境增量配置
│ └── webpack.prod.js ← 生产环境增量配置
├── dev.js ← Express 开发服务器入口
└── prod.js ← 生产打包入口
基础配置通过 webpack-merge 的 merge.smart() 与各自环境的配置合并,避免重复定义,确保同一份 base 在开发和生产中行为一致。
二、多页面入口的动态构建
2.1 约定优于配置
项目采用文件名约定的方式识别多页面入口:
// webpack.base.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({
filename: `./app/public/dist/prod/${entryName}.tpl`,
template: './app/view/entry.tpl',
chunks: [entryName],
inject: 'body',
}));
});
核心逻辑:
- 扫描
app/pages下所有entry.*.js文件(如entry.app.js、entry.admin.js) - 每个入口生成一条
pageEntries记录,供 webpack 的entry配置使用 - 每个入口对应一个
HtmlWebpackPlugin,产出独立的.tpl模板文件 chunks: [entryName]确保每个 HTML 只注入自己的 chunk,不互相污染
三、环境区分
开发和生产环境通过 merge.smart(base, envConfig) 合并,各自关注点不同:
| 维度 | 开发环境 | 生产环境 |
|---|---|---|
| mode | development | production |
| devtool | eval-cheap-module-source-map | 无(不生成 source-map) |
| entry | 注入 webpack-hot-middleware/client | 原始入口,无 HMR |
| output.publicPath | http://127.0.0.1:9002/public/dist/dev/(完整 URL) | /dist/prod(相对路径) |
| CSS 处理 | style-loader 内联到 JS | MiniCssExtractPlugin 提取独立 .css |
| JS 编译 | babel-loader 单线程 | happypack 多线程 |
| 压缩 | 无 | TerserPlugin(drop_console)+ CSSMinimizerPlugin |
| 清理产物 | 无 | CleanWebpackPlugin 每次 build 清空 dist |
3.1 为什么开发环境不压缩?
- 压缩耗时(Terser 是 CPU 密集型操作),拖慢迭代
- 压缩后的代码无法调试,即使有 source-map 也不如原始代码直观
- 开发环境的 bundle 只服务本地浏览器,体积不是瓶颈
3.2 为什么生产环境不生成 source-map?
.map文件可能泄露源码,有安全风险- 构建时间和产物体积增加
- 生产问题应通过日志系统定位,而非浏览器 source-map
四、分包策略(Code Splitting)
项目的分包策略在 webpack.base.js 的 optimization.splitChunks 中统一定义:
splitChunks: {
chunks: 'all', // 同步 + 异步模块都参与分割
maxAsyncRequests: 10, // 最大异步请求数
maxInitialRequests: 10, // 最大初始请求数
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 20, // 最高优先级,先打包
enforce: true,
reuseExistingChunk: true,
},
common: {
name: 'common',
minChunks: 2, // 被 2 个以上 entry 引用才抽离
minSize: 1,
priority: 10,
reuseExistingChunk: true,
},
},
},
runtimeChunk: true, // webpack 运行时单独打包
4.1 最终产物结构
每个页面最终加载的 JS 文件:
| 文件 | 内容 | 缓存策略 |
|---|---|---|
runtime.js | webpack 运行时 | 频繁变更,不缓存 |
vendor.js | node_modules 第三方库 | 极少变更,长期缓存 |
common.js | 多个 entry 共享的业务代码 | 低频变更,长期缓存 |
entry.app.js | 页面自身业务代码 | 随需求变更 |
4.2 缓存命中逻辑
首次访问 Page A:
runtime.js + vendor.js + common.js + entry.app.js
↓
切换到 Page B:
runtime.js + vendor.js + common.js(全部命中缓存)+ entry.admin.js(新加载)
vendor 和 common 在多个页面间共享缓存,只有首次访问对应页面时加载,后续全部命中 HTTP 缓存。
4.3 runtimeChunk: true 的作用
webpack 会把模块映射关系、异步加载逻辑等运行时信息嵌入 bundle。开启 runtimeChunk 后:
- 运行时信息抽离到独立的
runtime.js - 业务代码变更不会导致
vendor.js的 contenthash 改变 - 第三方库变更不会影响
common.js的缓存
不抽离的后果:改了某行业务代码,vendor 的 hash 也跟着变了,用户的 vendor 缓存失效,重新下载整个第三方库。
五、模块解析配置
resolve: {
extensions: ['.js', '.vue', '.less', '.css'],
alias: {
$pages: './app/pages',
$common: './app/pages/common',
$widgets: './app/pages/widgets',
$store: './app/pages/store',
},
},
- extensions:引入文件时可省略后缀,
import App from '$pages/app'等价于import App from '$pages/app.js' - alias:避免深层相对路径,
import xxx from '../../../../common/xxx'→import xxx from '$common/xxx'
六、热更新(HMR)全链路
6.1 架构组成
dev.js ────────────────────────────────────────────────┐
│ │
├── express.static('public/dist') │ 磁盘:.tpl 模板
├── devMiddleware(compiler) │ 内存:JS/CSS bundle
└── hotMiddleware(compiler, { path: '/__webpack_hmr' })│ SSE 推送通道
│
webpack.dev.js ────────────────────────────────────────┐│
entry[v] = [原始入口, 'webpack-hot-middleware/client?path=...'] │
plugins: [new webpack.HotModuleReplacementPlugin()] │
│
浏览器 ◄─────────────────────────────────────────────────┘
HMR Client ← SSE 事件 → HMR Runtime → module.hot.accept()
6.2 热更新四步流程
① 文件变更检测
app.use(devMiddleware(compiler, { ... }));
webpack-dev-middleware 启动 webpack watch 模式,监控 app/pages 下所有源文件。保存文件后,webpack 重新编译变更模块及其依赖链。
② 内存编译,不落盘
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
编译结果全部存在内存中,只有 .tpl 模板写磁盘。避免磁盘 I/O 瓶颈,这也是 devServer 快的核心原因。
③ SSE 推送编译完成通知
app.use(hotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`, // /__webpack_hmr
}));
编译完成后,通过 Server-Sent Events 向浏览器推送 hash 和 ok 事件。浏览器端的 client 脚本(在 webpack.dev.js 中注入到每个 entry)收到通知。
④ 请求热更新文件 + 模块替换
浏览器收到 SSE 通知后,HMR Runtime 自动发起请求:
GET /public/dist/dev/<chunk>.hot-update.json ← 哪些模块变了
GET /public/dist/dev/<chunk>.hot-update.js ← 新模块代码
加载新模块后,执行 module.hot.accept() 回调完成替换。如果某个模块没有定义 accept,更新向上冒泡,最终兜底执行全量刷新(配置中 reload=true)。
6.3 为什么需要 HotModuleReplacementPlugin?
这个插件为 bundle 注入 HMR Runtime——浏览器端负责接收更新、加载新模块、执行 accept 回调的代码。没有它,即使 SSE 推送了通知,浏览器也不知道如何处理热更新文件。
七、生产环境构建优化
7.1 多线程编译
new HappyPack({
threadPool: HappyPack.ThreadPool({ size: os.cpus().length }),
id: 'js',
loaders: [`babel-loader?${JSON.stringify({...})}`],
}),
利用多核 CPU 并行处理 babel 编译,每个文件独立编译后汇总。
7.2 CSS 提取
开发环境用 style-loader 将 CSS 注入 <style> 标签(热更新友好),生产环境用 MiniCssExtractPlugin 提取为独立的 .css 文件:
new MiniCssExtractPlugin({
chunkFilename: 'css/[name]_[contenthash:8].bundle.css',
}),
7.3 代码压缩
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // 清除 console.log/warn/error
}
}
}),
new CSSMinimizerPlugin(), // CSS 压缩
八、全局常量注入
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true',
__VUE_PROD_DEVTOOLS__: 'false',
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
}),
new webpack.ProvidePlugin({
Vue: 'vue',
axios: 'axios',
_: 'lodash',
}),
- DefinePlugin:编译时替换全局常量,生产环境关闭 Vue DevTools 和 hydration 细节
- ProvidePlugin:自动引入常用库,代码中无需
import Vue from 'vue'即可直接使用
九、总结
这套工程化体系的核心设计原则:
| 原则 | 体现 |
|---|---|
| 约定优于配置 | entry.*.js 命名约定自动识别多页面入口 |
| 环境隔离 | base/dev/prod 三层配置,各自关注点清晰 |
| 缓存优先 | 四层分包(runtime/vendor/common/entry)最大化 HTTP 缓存命中率 |
| 开发体验 | 内存编译 + SSE 热更新,改代码不刷新 |
| 生产性能 | 多线程编译 + CSS 提取 + 代码压缩 + 清除 console |