前端工程化

4 阅读2分钟

大家好啊,我是练习了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-mergemerge.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.jsentry.admin.js
  • 每个入口生成一条 pageEntries 记录,供 webpack 的 entry 配置使用
  • 每个入口对应一个 HtmlWebpackPlugin,产出独立的 .tpl 模板文件
  • chunks: [entryName] 确保每个 HTML 只注入自己的 chunk,不互相污染

三、环境区分

开发和生产环境通过 merge.smart(base, envConfig) 合并,各自关注点不同:

维度开发环境生产环境
modedevelopmentproduction
devtooleval-cheap-module-source-map无(不生成 source-map)
entry注入 webpack-hot-middleware/client原始入口,无 HMR
output.publicPathhttp://127.0.0.1:9002/public/dist/dev/(完整 URL)/dist/prod(相对路径)
CSS 处理style-loader 内联到 JSMiniCssExtractPlugin 提取独立 .css
JS 编译babel-loader 单线程happypack 多线程
压缩TerserPlugindrop_console)+ CSSMinimizerPlugin
清理产物CleanWebpackPlugin 每次 build 清空 dist

3.1 为什么开发环境不压缩?

  • 压缩耗时(Terser 是 CPU 密集型操作),拖慢迭代
  • 压缩后的代码无法调试,即使有 source-map 也不如原始代码直观
  • 开发环境的 bundle 只服务本地浏览器,体积不是瓶颈

3.2 为什么生产环境不生成 source-map?

  • .map 文件可能泄露源码,有安全风险
  • 构建时间和产物体积增加
  • 生产问题应通过日志系统定位,而非浏览器 source-map

四、分包策略(Code Splitting)

项目的分包策略在 webpack.base.jsoptimization.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.jswebpack 运行时频繁变更,不缓存
vendor.jsnode_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(新加载)

vendorcommon 在多个页面间共享缓存,只有首次访问对应页面时加载,后续全部命中 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 向浏览器推送 hashok 事件。浏览器端的 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