Elpis Webpack 工程化实战:Vue 多页应用的构建体系搭建

33 阅读9分钟

这是 Elpis 框架系列的第二篇。上一篇拆解了 elpis-core 的服务端框架内核,这一篇聚焦前端——如何用 Webpack 为一个 Koa 全栈项目搭建 Vue 的完整构建体系。


一、整体架构

先看全貌,整个构建体系由三层组成:

graph TD
    subgraph 配置层
        A["webpack.base.js<br/>入口 / Loader / 插件 / 代码分割"]
        B["webpack.dev.js<br/>HMR / Source Map"]
        C["webpack.prod.js<br/>多线程 / 压缩 / CSS 抽离"]
    end

    subgraph 执行层
        D["dev.js<br/>Express DevServer"]
        E["prod.js<br/>构建脚本"]
    end

    subgraph 插件层
        F["MultiThreadPlugin<br/>多线程打包插件"]
    end

    A -->|merge| B
    A -->|merge| C
    B --> D
    C --> E
    C --> F

    style A fill:#fff3e0,stroke:#f57c00
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#fce4ec,stroke:#c62828
    style D fill:#e3f2fd,stroke:#1565c0
    style E fill:#fce4ec,stroke:#c62828
    style F fill:#f3e5f5,stroke:#6a1b9a

配置采用 Base + Dev + Prod 三层分离。webpack.base.js 放所有环境共用的配置,webpack.dev.jswebpack.prod.js 各自叠加环境专属的部分,通过 webpack-merge 合并。

这样做的好处是:通用配置只写一份,环境差异一目了然,改一个环境不会影响另一个。


二、入口(Entry):自动扫描,约定优于配置

Webpack 需要知道从哪些文件开始打包,这就是 Entry。传统做法是手动在配置里写死每个入口,每新增一个页面就要改配置。

这里用了另一种方式:用 glob 自动扫描目录。

graph LR
    A["glob 扫描<br/>app/pages/**/entry.*.js"] --> B["提取文件名<br/>entry.page1"]
    B --> C["生成 Entry 对象<br/>{ entry.page1: '文件路径' }"]
    B --> D["生成 HtmlWebpackPlugin<br/>每个入口 → 一个 .tpl 模板"]

    style A fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#e3f2fd,stroke:#1565c0
// webpack.base.js
const pageEntries = {};
const htmlWebpackPluginList = [];

glob
  .sync(path.resolve(process.cwd(), "./app/pages/**/entry.*.js"))
  .forEach((file) => {
    const entryName = path.basename(file, ".js");
    pageEntries[entryName] = file;
    htmlWebpackPluginList.push(
      new HtmlWebpackPlugin({
        filename: path.resolve(
          process.cwd(),
          `./app/public/dist/${entryName}.tpl`,
        ),
        template: path.resolve(process.cwd(), "./app/view/entry.tpl"),
        chunks: [entryName],
      }),
    );
  });

约定规则:在 app/pages/ 下任意目录,只要文件名符合 entry.{pageName}.js 的格式,就会被自动识别为入口。

每个入口同时会生成一个 .tpl 模板文件。这个模板是给 Koa 服务端用的——用户访问 /view/page1 时,Koa 通过 Nunjucks 渲染 entry.page1.tpl,Webpack 已经把打包后的 JS/CSS 注入到了这个模板里。

// app/controller/view.js — Koa 服务端渲染页面
async renderPage(ctx) {
  await ctx.render(`dist/entry.${ctx.params.page}`, {
    name: app.options?.name,
    env: app.env.get(),
  });
}

所以整个链路是:新建 entry.xxx.js → Webpack 自动识别 → 生成 .tpl → Koa 自动渲染。不需要改任何配置。


三、Loader:告诉 Webpack 怎么处理不同类型的文件

Webpack 本身只认识 JS。要处理 .vue.css.less、图片等文件,需要配置对应的 Loader。

graph LR
    S["源文件"] --> A[".vue"]
    S --> C[".js"]
    S --> E[".css"]
    S --> G[".less"]
    S --> I["图片"]
    S --> K["字体"]

    A -->|vue-loader| B["解析 SFC 单文件组件<br/>template / script / style 拆分"]
    C -->|babel-loader| D["ES6+ 转译为 ES5"]
    E -->|css-loader + style-loader| F["解析 CSS → 注入 DOM"]
    G -->|less-loader + css-loader + style-loader| H["Less 编译 → 解析 → 注入"]
    I -->|url-loader| J["小于 300B 转 Base64<br/>否则输出文件"]
    K -->|file-loader| L["直接输出文件"]

    style S fill:#f5f5f5,stroke:#9e9e9e
    style A fill:#e8f5e9,stroke:#2e7d32
    style C fill:#fff3e0,stroke:#f57c00
    style E fill:#e3f2fd,stroke:#1565c0
    style G fill:#f3e5f5,stroke:#6a1b9a
    style I fill:#fff8e1,stroke:#f9a825
    style K fill:#efebe9,stroke:#4e342e

几个关键点:

vue-loader 的作用不只是处理 .vue 文件。它会把 .vue 中的 <script> 交给 babel-loader 处理,<style> 交给 css-loader 处理。它需要配合 VueLoaderPlugin 使用,这个插件的职责就是把你定义的其他 Loader 规则"复制"到 .vue 文件的各个块中。

babel-loader 通过 include 限定只处理 app/pages 目录:

{
  test: /\.js$/,
  include: [path.resolve(process.cwd(), "./app/pages")],
  use: { loader: "babel-loader" },
}

不加 include 的话,Webpack 会对 node_modules 里的 JS 也跑 Babel 转译,几千个包全过一遍,构建会非常慢。node_modules 里的包通常已经是编译好的,不需要再转。

url-loader 设置了 300 字节的阈值。小于这个值的图片会被转成 Base64 内联到 JS 中,减少一次 HTTP 请求;大于的则输出为独立文件。


四、Resolve:模块解析规则

当代码里写 import xxx from '$common/curl' 时,Webpack 需要知道 $common 指向哪个目录。这就是 resolve.alias 的作用。

resolve: {
  extensions: [".js", ".vue", ".less", ".css"],
  alias: {
    $pages:   path.resolve(process.cwd(), "./app/pages"),
    $common:  path.resolve(process.cwd(), "./app/pages/common"),
    $weights: path.resolve(process.cwd(), "./app/pages/weights"),
    $store:   path.resolve(process.cwd(), "./app/pages/store"),
  },
}

extensions 的作用是:import boot from '$pages/boot' 不需要写 .js 后缀,Webpack 会按照数组顺序依次尝试 .js.vue.less.css


五、代码分割(splitChunks):按变更频率拆包

如果不做代码分割,所有代码会打成一个巨大的 JS 文件。任何一行代码改动,用户都要重新下载整个文件,浏览器缓存完全失效。

代码分割的核心思路是:把变更频率不同的代码拆到不同的文件里

graph TD
    A["所有代码"] --> B{"splitChunks 分析"}
    B -->|来自 node_modules| C["vendor.js<br/>第三方库<br/>Vue / ElementPlus / Lodash<br/>版本不升级就不变"]
    B -->|被 ≥ 2 个入口引用| D["common.js<br/>公共业务模块<br/>偶尔变动"]
    B -->|页面独有| E["entry.page1.js<br/>页面业务代码<br/>频繁变动"]
    A --> F["runtime.js<br/>Webpack 模块加载运行时"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#f57c00
    style E fill:#e3f2fd,stroke:#1565c0
    style F fill:#f3e5f5,stroke:#6a1b9a
optimization: {
  splitChunks: {
    chunks: "all",
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: "vendor",
        priority: 20,
        enforce: true,
        reuseExistingChunk: true,
      },
      common: {
        name: "common",
        minChunks: 2,
        minSize: 1,
        priority: 10,
        reuseExistingChunk: true,
      },
    },
  },
  runtimeChunk: true,
}

逐个解释:

  • chunks: "all":对同步和异步引入的模块都做分割
  • vendor:匹配 node_modules 下的所有包,打成一个文件。priority: 20 表示优先级最高,一个模块同时满足 vendor 和 common 条件时,归入 vendor
  • common:被 2 个以上入口引用的模块提取出来。minSize: 1 表示哪怕只有 1 字节也提取
  • reuseExistingChunk: true:如果一个模块已经被提取到某个 chunk 中,不会重复提取
  • runtimeChunk: true:把 Webpack 自身的模块加载代码(__webpack_require__ 等)单独打包。这段代码每次构建都可能变,独立出来避免污染业务 chunk 的 hash

这样用户第一次访问时加载所有文件,之后只要第三方库没升级,vendor.js 就一直走浏览器缓存。日常开发改的业务代码只影响 entry.xxx.js,用户只需重新下载这一个小文件。


六、插件(Plugins):在构建流程中注入额外能力

Loader 处理单个文件,Plugin 则作用于整个构建流程。

plugins: [
  // 1. 必须:让 vue-loader 工作
  new VueLoaderPlugin(),

  // 2. 全局注入:业务代码中不需要 import 就能用 Vue、axios、lodash
  new webpack.ProvidePlugin({
    Vue: "vue",
    axios: "axios",
    _: "lodash",
  }),

  // 3. 定义编译时常量:Vue 3 的特性标志
  new webpack.DefinePlugin({
    __VUE_OPTIONS_API__: JSON.stringify(true),
    __VUE_PROD_DEVTOOLS__: JSON.stringify(false),
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false),
  }),

  // 4. 每个入口生成对应的 HTML 模板
  ...htmlWebpackPluginList,
];

ProvidePlugin 的原理是:当 Webpack 在代码中遇到 axios 这个自由变量时,自动在文件顶部插入 import axios from 'axios'。所以前端代码里可以直接写 axios.request(...) 而不需要手动 import。

DefinePlugin 是编译时替换,不是运行时。__VUE_OPTIONS_API__: true 表示保留 Options API 支持;设为 false 的话 Vue 会在打包时 Tree Shake 掉 Options API 相关代码,减小体积。__VUE_PROD_DEVTOOLS__: false 关闭生产环境的 Vue DevTools 支持。


七、HMR 热模块替换:修改代码不刷新页面

HMR(Hot Module Replacement)解决的问题是:开发时每次改代码都要手动刷新页面,页面状态(表单输入、滚动位置、组件状态)全部丢失。HMR 让修改后的模块在不刷新页面的情况下直接替换,保留应用状态。

7.1 HMR 需要什么

HMR 需要三个东西配合:

  1. 一个能监控文件变更并重新编译的服务(webpack-dev-middleware
  2. 一个能把"有更新"这个消息推送给浏览器的通道(webpack-hot-middleware,基于 SSE)
  3. 一个运行在浏览器里的客户端,接收消息后拉取新模块并替换(HMR Client)
sequenceDiagram
    participant 编辑器
    participant DevMiddleware as webpack-dev-middleware<br/>编译 + 监控
    participant HotMiddleware as webpack-hot-middleware<br/>SSE 推送
    participant 浏览器 as 浏览器 HMR Client

    编辑器->>DevMiddleware: 保存文件,触发文件变更
    DevMiddleware->>DevMiddleware: 检测到变更,增量重编译
    DevMiddleware->>HotMiddleware: 编译完成,通知有更新
    HotMiddleware->>浏览器: 通过 SSE 推送更新通知
    浏览器->>DevMiddleware: 根据通知请求更新的模块(hot-update.js)
    DevMiddleware-->>浏览器: 返回新模块代码
    浏览器->>浏览器: 用新模块替换旧模块,页面不刷新

7.2 SSE 是什么

SSE(Server-Sent Events)是一种服务器向浏览器单向推送消息的技术。和 WebSocket 不同,SSE 是单向的(只能服务器推给浏览器),基于 HTTP,实现更简单。

webpack-hot-middleware/__webpack_hmr 路径上开了一个 SSE 端点。浏览器端的 HMR Client 连上这个端点后,服务器每次编译完成都会推送一条消息,告诉浏览器"有新的模块可以更新了"。

7.3 具体实现

第一步:入口注入 HMR Client

// webpack.dev.js
Object.keys(baseConfig.entry).forEach((v) => {
  if (v !== "vendor") {
    baseConfig.entry[v] = [
      baseConfig.entry[v],
      `webpack-hot-middleware/client?path=http://127.0.0.1:9002/__webpack_hmr&timeout=20000&reload=true`,
    ];
  }
});

把 HMR Client 脚本追加到每个业务入口中。这样打包后的 JS 里就包含了 HMR Client 代码,它会在浏览器中运行,负责和服务器建立 SSE 连接。

vendor 被排除了——第三方库不需要热更新,排除它减少 HMR 的处理范围。

参数说明:

  • path:SSE 端点地址
  • timeout=20000:20 秒没收到消息就重连
  • reload=true:如果 HMR 失败(某些模块不支持热替换),降级为整页刷新

第二步:启用 HotModuleReplacementPlugin

// webpack.dev.js
plugins: [
  new webpack.HotModuleReplacementPlugin({
    multiStep: false,
  }),
];

这个插件让 Webpack 在编译时生成 HMR 需要的额外代码(模块更新清单、更新后的模块代码)。multiStep: false 表示不分步编译,每次变更一次性编译完成。

第三步:启动 DevServer

// dev.js
const app = express();
const compiler = webpack(webpackConfig);

// 编译中间件:监控文件变更,增量编译,产物存在内存中
app.use(
  devMiddleware(compiler, {
    writeToDisk: (filePath) => filePath.endsWith(".tpl"),
    publicPath: webpackConfig.output.publicPath,
    headers: { "Access-Control-Allow-Origin": "*" },
  }),
);

// 热更新中间件:SSE 推送
app.use(
  hotMiddleware(compiler, {
    path: "/__webpack_hmr",
  }),
);

app.listen(9002);

这里单独用 Express 起了一个 DevServer(端口 9002),和 Koa 业务服务器(端口 8080)分开。

为什么要分开?因为职责不同:

  • Koa 负责页面路由和 API
  • Express DevServer 负责 Webpack 编译产物的分发和 HMR 推送

devMiddleware 把编译产物存在内存中,不写磁盘,读写速度更快。但 .tpl 模板文件例外——writeToDisk: (filePath) => filePath.endsWith(".tpl") 让模板文件落盘,因为 Koa 的 Nunjucks 引擎需要从文件系统读取模板。

headers 里设置了 CORS,因为页面从 Koa(:8080)加载,JS/CSS 资源从 DevServer(:9002)加载,属于跨域请求。

7.4 双服务器协作

graph LR
    A["浏览器"] -->|"页面 + API<br/>localhost:8080"| B["Koa 服务器 :8080"]
    A -->|"JS / CSS 资源<br/>127.0.0.1:9002"| C["Express DevServer :9002"]
    A <-->|"SSE 热更新<br/>/__webpack_hmr"| C

    B -->|读取| D[".tpl 模板文件"]
    C -->|落盘| D
    C -->|内存中| E["JS / CSS 产物"]

    style B fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e3f2fd,stroke:#1565c0

开发时的 publicPath 设置为 DevServer 的完整地址:

publicPath: `http://127.0.0.1:9002/public/dist/dev/`;

这样 .tpl 模板中注入的 <script> 标签的 src 会指向 DevServer,浏览器从 DevServer 拉取 JS/CSS。

7.5 CSS 的热更新

CSS 的热更新不需要额外配置。开发环境用的 style-loader 天然支持 HMR——它把 CSS 通过 <style> 标签注入到 DOM 中,更新时直接替换 <style> 标签的内容,不需要刷新页面。

这也是为什么开发环境用 style-loader,而不是 MiniCssExtractPlugin——后者把 CSS 抽成独立文件,无法做到热替换。


八、Source Map:开发时的调试支持

// webpack.dev.js
devtool: "eval-cheap-module-source-map",

Webpack 打包后的代码和源码差别很大,报错时看到的行号对不上。Source Map 建立了打包产物和源码之间的映射关系,让浏览器 DevTools 能显示原始源码。

eval-cheap-module-source-map 是一个折中选择:

  • eval:每个模块用 eval() 包裹,重编译速度快
  • cheap:只映射到行,不映射到列,生成速度更快
  • module:能映射到 Loader 处理前的源码(比如 .vue 文件的原始代码)

生产环境不配置 Source Map,避免暴露源码。


九、生产环境:多线程编译与压缩

9.1 MultiThreadPlugin:多线程打包

JS 的编译(Babel 转译)和 CSS 的处理是 CPU 密集型任务。默认情况下 Webpack 是单线程的,只用一个 CPU 核心。多线程方案把这些任务分发到多个 Worker 进程并行处理。

项目中把多线程方案封装成了一个 Webpack 插件,支持三种模式切换:

graph TD
    A["MultiThreadPlugin"] --> B{"mode 参数"}
    B -->|'thread-loader'| C["thread-loader<br/>Webpack 官方维护<br/>在 Loader 前插入"]
    B -->|'happypack'| D["HappyPack<br/>社区方案<br/>替换 Loader 为 happypack/loader"]
    B -->|'none'| E["不启用多线程<br/>用于排查问题"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#f57c00
    style E fill:#efebe9,stroke:#4e342e

使用方式:

// webpack.prod.js
new MultiThreadPlugin({ mode: "thread-loader" });

插件内部通过 Webpack 的 apply(compiler) 钩子,在编译开始前动态往 compiler.options.module.rules 里注入对应的 Loader 配置:

// thread-loader 模式下注入的规则
{
  test: /\.js$/,
  include: [path.resolve(process.cwd(), "./app/pages")],
  use: [
    {
      loader: "thread-loader",
      options: {
        workers: os.cpus().length - 1,  // 留一个核给主线程
        workerParallelJobs: 50,
        poolTimeout: 2000,  // 构建完成 2 秒后回收 Worker
      },
    },
    {
      loader: "babel-loader",
      options: {
        presets: ["@babel/preset-env"],
        plugins: ["@babel/plugin-transform-runtime"],
        cacheDirectory: true,  // 缓存转译结果
      },
    },
  ],
}

thread-loader 的原理:它放在其他 Loader 前面,把后面的 Loader 放到 Worker 池中运行。每个 Worker 是一个独立的 Node.js 进程,通过进程间通信传递数据。

workers: os.cpus().length - 1:Worker 数量设为 CPU 核数减 1,留一个核给 Webpack 主线程做模块依赖分析。

poolTimeout: 2000:生产构建完成后 2 秒回收 Worker 进程,释放系统资源。

9.2 JS 压缩

optimization: {
  minimize: true,
  minimizer: [
    new TerserPlugin({
      cache: true,
      parallel: true,
      terserOptions: {
        compress: {
          drop_console: true,
        },
      },
    }),
  ],
}
  • cache: true:缓存压缩结果,没有变更的模块不重复压缩
  • parallel: true:多进程并行压缩
  • drop_console: true:删除所有 console.log,减小体积,避免生产环境泄露调试信息

9.3 CSS 抽离与压缩

生产环境的 CSS 处理和开发环境完全不同:

graph LR
    subgraph 开发环境
        A1[".css"] --> B1["css-loader"] --> C1["style-loader<br/>注入 DOM 的 style 标签<br/>支持 HMR"]
    end

    subgraph 生产环境
        A2[".css"] --> B2["css-loader"] --> C2["MiniCssExtractPlugin.loader<br/>抽离为独立 .css 文件"]
        C2 --> D2["CSSMinimizerPlugin<br/>压缩"]
    end

    style C1 fill:#e3f2fd,stroke:#1565c0
    style C2 fill:#fce4ec,stroke:#c62828
    style D2 fill:#fce4ec,stroke:#c62828

为什么生产环境要抽离 CSS?

  • 独立的 CSS 文件可以被浏览器并行加载,不阻塞 JS 执行
  • CSS 文件使用 contenthash,内容不变 hash 不变,缓存更精准
  • 可以单独压缩优化
new MiniCssExtractPlugin({
  chunkFilename: "css/[name]_[contenthash:8].css",
}),
new CSSMinimizerPlugin(),

9.4 构建前清理

new CleanWebpackPlugin(["public/dist"], {
  root: path.resolve(process.cwd(), "./app/"),
});

每次生产构建前清空 dist 目录,避免旧文件残留。因为文件名带 hash,不清理的话旧文件会一直堆积。


十、Hash 策略:让浏览器缓存生效

文件名中的 hash 是缓存的关键。Webpack 提供三种 hash:

graph LR
    A["hash<br/>整个构建共享<br/>任何文件变 → 全部变"] ~~~ B["chunkhash<br/>按 chunk 计算<br/>chunk 内容变才变"]
    B ~~~ C["contenthash<br/>按文件内容计算<br/>文件内容变才变"]

    style A fill:#ffcdd2,stroke:#c62828
    style B fill:#fff9c4,stroke:#f9a825
    style C fill:#e8f5e9,stroke:#2e7d32

项目中的使用:

资源Hash 类型示例为什么
JSchunkhashpage1_a1b2c3d4.bundle.js同一 chunk 内容不变则 hash 不变
CSScontenthashcommon_e5f6g7h8.cssCSS 和 JS 独立计算,改 JS 不影响 CSS 的 hash

如果 CSS 也用 chunkhash,那改了 JS 代码,CSS 的 hash 也会变(因为它们在同一个 chunk 里),导致 CSS 缓存失效。用 contenthash 就不会有这个问题。


十一、前端应用启动器:boot.js

每个页面的入口文件只需要两行:

// app/pages/page1/entry.page1.js
import page1 from "./page1.vue";
import boot from "$pages/boot";
boot(page1, {});

boot.js 统一处理 Vue 应用的初始化:

// app/pages/boot.js
export default async (pageComponent, { routes = [], libs }) => {
  const app = createApp(pageComponent);
  app.use(ElementUI);
  app.use(pinia);

  if (libs?.length) {
    for (let i = 0; i < libs.length; ++i) {
      app.use(libs[i]);
    }
  }

  const router = createRouter({
    history: createWebHashHistory(),
    routes,
  });
  app.use(router);
  await router.isReady();

  app.mount("#root");
};

Vue、ElementPlus、Pinia、Router 的初始化全部收口在这里。每个页面只需要关心"用哪个组件"和"传什么路由",不需要重复写初始化代码。


十二、前后端签名通信

前端封装了统一的请求方法 curl.js,和后端的签名校验中间件配合:

sequenceDiagram
    participant 前端 as curl.js
    participant 后端 as Koa 中间件

    前端->>前端: 取当前时间戳 st
    前端->>前端: md5(signKey + "_" + st) 生成签名
    前端->>后端: headers 携带 s_t 和 s_sign
    后端->>后端: apiSignVerify:用同样的算法算签名,比对
    后端->>后端: 检查时间戳是否在 10 分钟内
    后端->>后端: apiParamsVerify:JSON Schema 校验参数
    后端->>后端: Controller → Service 处理业务
    后端-->>前端: { success, data, metadata }

前端和后端使用相同的密钥和算法生成签名。后端额外检查时间戳,超过 10 分钟的请求会被拒绝,防止请求被截获后重放。


十三、完整数据流

从写代码到用户看到页面,完整链路:

graph TD
    A["开发者编写<br/>app/pages/page1/page1.vue"] --> B["Webpack 编译<br/>vue-loader → babel-loader → 打包"]
    B --> C["产物<br/>entry.page1.tpl + JS + CSS"]
    D["用户访问 /view/page1"] --> E["Koa Router 匹配"]
    E --> F["ViewController 渲染模板"]
    F --> G["Nunjucks 输出 HTML<br/>(已注入 JS/CSS 引用)"]
    G --> H["浏览器加载 JS"]
    H --> I["boot.js 初始化 Vue 应用"]
    I --> J["页面渲染完成"]

    style A fill:#e3f2fd,stroke:#1565c0
    style D fill:#e8f5e9,stroke:#2e7d32
    style J fill:#e8f5e9,stroke:#2e7d32

十四、开发环境与生产环境配置对比

维度开发环境生产环境
modedevelopmentproduction
Source Mapeval-cheap-module-source-map不生成
CSS 处理style-loader 注入 DOMMiniCssExtract 抽离文件 + 压缩
JS 压缩不压缩TerserPlugin 压缩 + 去 console
多线程不启用MultiThreadPlugin
HMR开启不需要
产物存储内存(DevServer)磁盘
清理旧产物不需要CleanWebpackPlugin
publicPathDevServer 完整 URL相对路径 /dist/prod