Webpack5 前端工程化建设

webpack不同环境配置

webpack 配置的三层继承架构

  • webpack.base.js 是公共部分,包含所有环境共有的配置:entry 入口、loader 规则、路径别名、VueLoaderPlugin、splitChunks 分包策略等

  • webpack.dev.jswebpack.prod.js 分别通过 webpack-merge.smart 继承 base 配置,再叠加各自环境特有的配置:

    • dev 叠加:mode: 'development'、source-map、HMR 客户端注入、HotModuleReplacementPlugin
    • prod 叠加:mode: 'production'、CSS 提取与压缩、HappyPack 多线程、Terser 压缩、CleanWebpackPlugin
graph TD A["webpack.base.js
基础配置"] -->|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文件,浏览器展示结果
flowchart LR subgraph 源码["源码 (app/pages)"] E1["entry.page1.js"] E2["entry.page2.js"] Vue["*.vue 组件"] end subgraph Webpack["Webpack5 构建"] direction TB Glob["glob 自动发现 entry"] --> Loaders["Loaders 编译
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)

flowchart LR src["源文件"] --> R1{"文件类型"} R1 -->|".vue"| L1["vue-loader"] R1 -->|".js"| L2["babel-loader
(仅 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 实例,新增页面只需按约定创建文件,无需修改配置:

flowchart TD A["glob 扫描
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 产物分为三类,优化浏览器缓存利用率:

graph TD subgraph 源码依赖关系 P1["page1.vue"] --> C1["common/utils.js"] P2["page2.vue"] --> C1 P1 --> V1["vue / axios / lodash
(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 就变了,第三方库也得重新下载 因此需要进行分包策略优化,避免重复引用重复代码,缓存失效的问题。 因此在分包策略中分成两个部分结构,主要分为 vendorcommon
    • vendor 第三方库,Vue、axios、lodash 等第三方库被打包到一个独立的 vendor.js,只有升级依赖版本时 hash 才会变。
    • common 业务公共代码,app/pages/common/app/pages/widget/ 等被多个页面共享的业务组件被打包到 common.js

关键配置:

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-mergemerge.smart 策略,在 base 配置上叠加环境差异:

graph LR subgraph 开发环境 direction TB D1["mode: development"] D2["devtool: eval-cheap-module-source-map"] D3["HMR 热更新"] D4["Express devServer"] D5[".tpl 写入磁盘"] end subgraph 生产环境 direction TB P1["mode: production"] P2["MiniCssExtractPlugin
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 工作原理

sequenceDiagram participant Dev as 开发者 participant FS as 文件系统 participant WM as webpack-dev-middleware participant Webpack as Webpack Compiler participant HM as webpack-hot-middleware participant Browser as 浏览器 (HMR Client) Note over Browser: 启动时建立 SSE 长连接
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 中任何模块变化都会改变 hash
  • contenthash:基于文件内容生成 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.smarttest 匹配合并,而不是整个数组覆盖,确保不会丢失其他文件类型的 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/N
  • cache: true --- 增量构建时,只有变更的文件会被重新压缩
  • drop_console: true --- 生产环境移除调试日志,减小包体积

除了 happypack 是否有更好的加速打包工具 (具体实现配置方式需看官方文档)

  1. thread-loader:webpack 内置生态,专门用于多线程处理 loader
  2. esbuild-loader :用 Go 编写的 esbuild 做编译,比 babel-loader 快 10-100 倍
  3. SWC-loader:Rust 编写,速度接近 esbuild,但兼容 Babel 插件的能力更强

为什么使用for循环不用forEach 性能更好? 在大多数场景下,for 循环比 forEach约 2-10 倍,具体取决于数据量和引擎优化。

for 循环

  • 编译后就是纯粹的跳转指令,没有函数调用开销
  • 引擎可以做各种优化(内联、逃逸分析等)

forEach

  • 每次迭代都会创建一个函数调用栈(压栈、弹栈)
  • 无法被 break/continue 中断,引擎优化空间更小
  • 每次回调都会产生闭包上下文(即使箭头函数)

4.关键设计决策总结

  1. 多页面架构 --- 每个页面独立 entry,通过 glob 自动发现,产物为 .tpl 模板供后端 Koa 渲染
  2. 环境配置分离 --- base/dev/prod 三层配置,webpack-merge 智能合并
  3. 分包策略 --- vendor / common / page 三级分包,最大化浏览器缓存命中
  4. 开发体验 --- 自建 Express devServer + HMR,.tpl 文件写入磁盘以配合后端模板渲染
  5. 生产优化 --- 多线程编译(HappyPack)、并行压缩(Terser)、CSS 提取与压缩、source-map 关闭、console 移除
  6. 应用启动统一 --- boot.js 抽象应用初始化流程,降低页面接入成本
相关推荐
A不落雨滴AI1 小时前
DKERP客户端重构纪实:4天自研控件库的“短命”教训,以及为什么我坚定选择原生Qt
前端
我叫黑大帅1 小时前
通过白名单解决 pnpm i 报错 Ignored build scripts
前端·javascript·面试
风止何安啊1 小时前
用 APP 背单词太无聊?我用 Trae Solo 移动端写个小游戏来准备 6级
前端·人工智能·trae
Summer不秃1 小时前
深入理解 Token 无感刷新:从并发雪崩到单例锁 + 请求队列的完整实现
前端·http
yingyima1 小时前
Git 实战:你必须掌握的 7 个常用命令
前端
次次皮2 小时前
代理启动前端dist包
java·前端·vue
星恒随风2 小时前
四天学完前端基础三件套(JavaScript篇)
开发语言·前端·javascript·笔记
guslegend3 小时前
第9节:前端工程与一键启动
前端·大模型·状态模式·ai编程
南囝coding3 小时前
Anthropic 内部数百个 Claude Code Skills,他们总结的这套方法值得看
前端·后端