这是 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.js 和 webpack.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 需要三个东西配合:
- 一个能监控文件变更并重新编译的服务(
webpack-dev-middleware)
- 一个能把"有更新"这个消息推送给浏览器的通道(
webpack-hot-middleware,基于 SSE)
- 一个运行在浏览器里的客户端,接收消息后拉取新模块并替换(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 类型 |
示例 |
为什么 |
| JS |
chunkhash |
page1_a1b2c3d4.bundle.js |
同一 chunk 内容不变则 hash 不变 |
| CSS |
contenthash |
common_e5f6g7h8.css |
CSS 和 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
十四、开发环境与生产环境配置对比
| 维度 |
开发环境 |
生产环境 |
| mode |
development |
production |
| Source Map |
eval-cheap-module-source-map |
不生成 |
| CSS 处理 |
style-loader 注入 DOM |
MiniCssExtract 抽离文件 + 压缩 |
| JS 压缩 |
不压缩 |
TerserPlugin 压缩 + 去 console |
| 多线程 |
不启用 |
MultiThreadPlugin |
| HMR |
开启 |
不需要 |
| 产物存储 |
内存(DevServer) |
磁盘 |
| 清理旧产物 |
不需要 |
CleanWebpackPlugin |
| publicPath |
DevServer 完整 URL |
相对路径 /dist/prod |