Blog Post

elpis 实现笔记(二):前端工程化里的热更新与分包策略

解释 elpis 如何在 Koa 继续托管页面入口的前提下,做出可用的热更新链路,并提前划清多页面应用的分包边界。

2026-02-2510 分钟阅读 · 2821webpack / vue / elpis / 前端工程化
文章目录

前言

elpis 这一版前端工程化真正难的,不是“把 Vue 编起来”,而是在 Koa 继续控制页面入口的前提下,同时拿到可用的热更新体验和稳定的缓存边界。当前方案用“.tpl 落盘、bundle 走内存”解决开发态问题,用 vendor / common / entry / runtime 四层分工解决生产态缓存问题。

本文默认你已经熟悉 Koa、webpack 和 Vue 单文件组件。
如果你关心的是页面组件怎么拆、状态怎么组织,这篇不会展开;这里更关注工程骨架本身。

问题是什么

elpis 的前端不是一个独立部署的 SPA。页面入口仍然由 Koa 控制,服务端负责模板渲染、环境注入和路由分发。
这会带来两个很具体的问题:

  1. 本地开发时,Koa 需要真实存在的模板文件,但前端又希望保留现代 HMR 体验。
  2. 页面一多,公共依赖、业务公共代码和页面入口如果混在一起,缓存命中率会迅速变差。

所以这篇只回答两个问题:

  1. 服务端托管页面入口时,热更新应该怎么接?
  2. 多页面应用里,分包策略应该优先解决什么?

方案概览

先看整体结论,再看细节:

  • 页面入口统一约定为 app/pages/**/entry.*.js
  • webpack 不只产出 JS/CSS,还产出供 Koa 读取的 .tpl
  • 开发环境里模板落盘、静态资源走内存,HMR client 注入到每个页面入口
  • 生产环境里把第三方依赖、公共业务代码、页面入口和 runtime 分开

如果把它压成一句话,这套方案的核心就是:不改变 Koa 页面分发方式,只把前端构建能力嵌进去。

页面如何接进 Koa

前端工程化的起点,不是 loader,而是“入口约定”和“模板输出”。

javascript
const pageEnties = {};
const htmlWebpackPluginList = [];
const entryList = glob.sync(
  path.resolve(process.cwd(), `./app/pages/**/entry.*.js`)
);
 
entryList.forEach(entry => {
  const entryName = path.basename(entry, '.js');
  pageEnties[entryName] = entry;
  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],
    })
  );
});

这段配置做了两件关键的事:

  1. entry.*.js 变成页面级约定,新增页面时不需要再手写 webpack entry。
  2. HtmlWebpackPlugin 输出 .tpl,而不是只输出纯静态 HTML。

也就是说,前端不是在“单独起一个站”,而是在给 Koa 生产可消费的页面产物。
页面负责产出 bundle 和模板,服务端负责按页面名渲染对应入口,这个职责划分很清楚。

热更新:难点不在 HMR,而在模板和资源不在同一层

纯 SPA 场景里,webpack dev server 基本是标准答案。
elpis 的情况更麻烦一点,因为 Koa 还要继续读模板文件。

这意味着开发环境要同时满足三件事:

  1. Koa 能拿到真实的 .tpl
  2. 浏览器尽量直接拿到最新编译结果
  3. 页面改动后优先做模块级更新,而不是整页刷新

真正决定这条链路是否成立的,是 webpack-dev-middleware 这段配置:

javascript
app.use(
  webpackDevMiddleware(compiler, {
    writeToDisk: filePath => filePath.endsWith('.tpl'),
    publicPath: webpackDevConfig.output.publicPath,
  })
);

这里最值得看的是 writeToDisk

它不是一个“顺手打开的选项”,而是整个开发态链路的关键开关:

  • JS 和 CSS 继续走内存编译,改动后立即可用。
  • 只有 .tpl 会被写到磁盘,因为 ctx.render() 最终读取的是文件系统。
  • 于是开发环境形成了一个混合模型:模板走磁盘,资源走内存。

这个设计非常贴合当前架构。
如果模板也留在内存里,Koa 接不住;如果 bundle 也强制落盘,本地开发速度又会显著下降。

真正让浏览器进入热更新模式的,则是注入到每个页面入口里的 HMR client:

javascript
webpackBaseConfig.entry[entry] = [
  webpackBaseConfig.entry[entry],
  `webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}?timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`,
];

这段代码的本质,是把每个页面入口都变成一个能够接收更新通知的客户端。
页面加载后会和 webpack-hot-middleware 保持连接;模块重新编译完成后,浏览器先尝试替换模块,替换失败再回退到 reload。

所以,这里的 HMR 价值并不是一句“改 Vue 文件可以秒生效”。
更准确的说法是:

  • 后端继续按原来的方式提供页面
  • 前端资源已经切到 dev server 的最新产物
  • 更新发生时,优先做模块替换,而不是回到传统的整页刷新开发方式

这才是这条链路真正解决的问题:它没有破坏既有 Koa 结构,却把前端开发体验提升到了现代工程化的水平。

分包策略:重点不是拆多少,而是边界怎么划

相比热更新,另一个真正影响长期体验的点是 splitChunks

javascript
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,

很多时候我们会把分包理解成“把一个大包拆成几个小包”,但这不是核心。
核心是先承认不同代码的变化频率完全不同,再把它们从一开始就放进不同的缓存层级里。

在这份配置里,实际被区分开的有四类内容:

  • vendor:第三方依赖,变化频率最低。
  • common:多个页面复用的业务模块,变化频率低于页面入口。
  • entry.pageX:页面自己的启动代码和局部逻辑,变化最频繁。
  • runtime:webpack 运行时代码,单独拆出去可以减少入口改动带来的连锁失效。

这背后的判断很重要:多页面应用的性能问题,往往不是某个文件太大,而是缓存边界没有被认真设计。

如果把第三方依赖、公共业务代码和页面逻辑全部打进一个入口里,会发生什么?

  • 页面改一行字,整包 hash 就会变
  • 用户重新访问时,公共依赖也跟着失效
  • 页面越多,重复下载越明显

而当前这版策略本质上是在避免这种连锁反应:

  • 第三方库单独出去,版本不变时尽量稳定命中缓存
  • 公共业务代码单独出去,让多个页面共享同一份缓存
  • 页面入口保持轻量,只承载页面自身强绑定的逻辑
  • runtime 单独抽离,减少 chunk 之间相互牵连

这套分法尤其适合后台型多页应用。
用户通常不会只访问一个页面,页面切换越频繁,公共 chunk 稳定带来的收益就越明显。

当然,分包也不是越细越好。
拆得太细会增加请求数、调度成本和运行时复杂度,所以当前只保留 vendorcommon 两层公共抽离,是一个比较稳妥的起点。

基础设施为什么这里只带过

boot.jsPiniaElement Pluscurl.js 这些当然重要,但它们在这篇里更像“运行时收口”:

  • boot.js 统一页面启动方式
  • Pinia 和 UI 库提供基础运行容器
  • curl.js 把请求校验规则、错误提示和超时处理集中到一处

这些能力决定了页面能不能顺手开发,但它们没有热更新链路和分包边界那么能体现工程化设计本身。
换句话说,它们是必要基础设施,但不是这篇最值得展开的主线。

取舍与限制

这套方案的优点很明确:

  • 不需要推翻既有 Koa 页面分发方式
  • 可以自然支持多页面应用继续增长
  • 本地开发体验不会退回到“改一次、刷一次”
  • 缓存策略在项目早期就已经被设计进去了

它的限制也同样明确:

  • 开发环境 output.filename 仍然使用 chunkhash,对 HMR 稳定性不够友好
  • ProvidePlugin 会削弱依赖来源的显式性,长期维护要更克制
  • 当前固定请求校验参数更像轻量门槛,而不是真正的密钥机制

所以,如果要评价这版工程化,我会说它的价值不在“配置很多”,而在于它已经把开发态效率生产态缓存边界这两个高杠杆问题优先解决了。

结语

elpis 这版前端工程化真正值得记下来的,不是某个 loader 或某个插件,而是两个判断:

  1. 在服务端继续托管页面入口的前提下,热更新依然值得认真设计。
  2. 多页面应用的分包策略,应该优先服务缓存边界,而不是追求拆包本身。

这两个问题一旦处理得当,后面的页面扩展、组件沉淀和业务迭代,才会建立在一个足够稳定的底座上。