《互联网产品的客户端架构·端形态与体验基础》9.1 打包、分包与按需加载

从「回声」首包翻倍的发布事故出发,把模块依赖图、静态闭包、懒加载边界与共享模块提升写成可运行的 TypeScript 分包算法,给出按路由与按交互分包的判定表、六组真实测试输出,并说明首包大小为什么必须和任务路径一起判断,而不能只看一个总数。

首包是一个发布决策

「回声」的首页在某一版里从 180 KB 涨到 340 KB。来源不是新功能,而是三个团队各自“顺手”引用了一个日期库:内容流用它格式化时间,私信用它算相对时间,下单页用它校验预约日期。构建日志只有一个总数,没有人知道是谁带来的。复盘时最有用的一句话不是“以后注意体积”,而是“我们缺少一张能指认来源的图”。

用户下载的是产物,不是源码。源码里的 import 在执行前会被打包器改写,模块边界、加载时机和缓存粒度都在构建阶段定型。本章按“切、量、缓存”三步走:9.1 决定产物怎么分,9.2 决定怎么量、怎么判,9.3 决定切好的文件怎样在缓存里长期有效。顺序不能反——没有依赖图,分包只是按目录猜;没有体积归因,预算只是一条拍脑袋的红线;没有内容哈希,缓存策略会把旧资源和新页面绑死。

本节只解决“切”。文中出现的字节数与耗时都是案例假设值,只有测试输出是本机真实运行结果。

依赖图不是目录树

打包器的输入不是文件夹,而是一张有向图:节点是模块,边是依赖。目录结构只能表达“谁和谁放在一起”,不能表达“谁会一起被加载”。两个文件在同一个目录,可能一个进首包、一个进懒分包;两个文件隔着四层目录,也可能必须放在同一个产物里。

图上有两种边,语义完全不同:

  • 静态依赖(import ... from):父模块执行前,子模块必须已经加载。它会沿着边一路进入当前产物。
  • 动态依赖(import()):父模块执行到这里才去取子模块。它是一条分包边界,而不是普通依赖。

分包的第一个错误,是把动态导入写在了不会被执行的角落,然后以为分包生效了。第二个错误,是把路由组件静态导入到入口,再在配置里写一条 splitChunks,结果打包器只能把已经在首包里的代码再拆一次,首屏并没有变小。图必须先于配置。

图还需要做两项体检:悬空引用(引用了不存在的模块)和环。环在静态依赖里不一定报错,但会让闭包遍历重复访问,算法必须用集合去重,不能假设图是一棵树。

五条分包规则

下面的算法把“图 + 懒边界”变成“首包 + 懒分包 + 共享包”。规则只有五条:

  1. 从入口出发,沿静态边做闭包,得到首包。
  2. 每个 lazy 边界的目标,是某个懒分包的起点。
  3. 懒分包的闭包同样只走静态边,但跳过已经在首包里的模块。
  4. 同一个模块被两个以上懒分包引用时,提升到 shared,只下载一次。
  5. 懒目标如果已经出现在首包里,记录边界冲突——这次分包不会生效。

这五条不是打包器的全部规则,但覆盖了最常见的失效原因。尤其是第 5 条:它不抛异常,只把问题写进报告,因为构建仍然能成功,只有首包悄悄变大。

// file: src/build/module-graph.ts
// tier: L1
// verify: node --test test/build/module-graph.test.ts

export interface LazyBoundary {
  /** 懒加载入口模块 */
  target: string;
  /** 逻辑分包名,例如 route:order */
  chunk: string;
}

export interface ModuleNode {
  id: string;
  bytes: number;
  /** 静态依赖:加载本模块时一并下载 */
  imports?: string[];
  /** 动态导入:构成分包边界 */
  lazy?: LazyBoundary[];
}

export interface ModuleGraph {
  entry: string;
  modules: Record<string, ModuleNode>;
}

export interface Chunk {
  name: string;
  modules: string[];
  bytes: number;
}

export interface SplitPlan {
  chunks: Chunk[];
  initialBytes: number;
  lazyBytes: number;
  /** 懒加载目标又被静态依赖引用,分包不会生效 */
  boundaryViolations: string[];
  /** 多个懒分包共同依赖、被提升为 shared 的模块 */
  sharedModules: string[];
  /** 图里引用但不存在的模块 */
  missing: string[];
}

/** 找出图中所有悬空引用,包括入口本身。 */
export function validateGraph(graph: ModuleGraph): string[] {
  const missing = new Set<string>();
  if (!graph.modules[graph.entry]) missing.add(graph.entry);
  for (const node of Object.values(graph.modules)) {
    for (const dep of node.imports ?? []) {
      if (!graph.modules[dep]) missing.add(dep);
    }
    for (const boundary of node.lazy ?? []) {
      if (!graph.modules[boundary.target]) missing.add(boundary.target);
    }
  }
  return [...missing].sort();
}

function staticClosure(
  graph: ModuleGraph,
  start: string,
  skip: Set<string>,
): Set<string> {
  const seen = new Set<string>();
  const stack = [start];
  while (stack.length > 0) {
    const id = stack.pop()!;
    if (seen.has(id) || skip.has(id) || !graph.modules[id]) continue;
    seen.add(id);
    for (const dep of graph.modules[id].imports ?? []) {
      if (!seen.has(dep)) stack.push(dep);
    }
  }
  return seen;
}

function toChunk(name: string, ids: Iterable<string>, graph: ModuleGraph): Chunk {
  const modules = [...ids].sort();
  const bytes = modules.reduce((sum, id) => sum + graph.modules[id].bytes, 0);
  return { name, modules, bytes };
}

/**
 * 以入口的静态闭包作为首包,以 lazy 目标作为懒分包起点。
 * 同一个模块被两个以上懒分包引用时提升到 shared,避免重复计入。
 */
export function splitBundle(graph: ModuleGraph): SplitPlan {
  const missing = validateGraph(graph);
  const initial = staticClosure(graph, graph.entry, new Set());

  const lazies = new Map<string, Set<string>>();
  for (const node of Object.values(graph.modules)) {
    for (const boundary of node.lazy ?? []) {
      if (!graph.modules[boundary.target]) continue;
      const bucket = lazies.get(boundary.chunk) ?? new Set<string>();
      bucket.add(boundary.target);
      lazies.set(boundary.chunk, bucket);
    }
  }

  const boundaryViolations: string[] = [];
  for (const [chunk, targets] of lazies) {
    for (const target of targets) {
      if (initial.has(target)) boundaryViolations.push(`${chunk}:${target}`);
    }
  }
  boundaryViolations.sort();

  const owners = new Map<string, Set<string>>();
  for (const [chunk, targets] of lazies) {
    const closure = new Set<string>();
    for (const target of targets) {
      for (const id of staticClosure(graph, target, initial)) closure.add(id);
    }
    for (const id of closure) {
      const set = owners.get(id) ?? new Set<string>();
      set.add(chunk);
      owners.set(id, set);
    }
  }

  const sharedModules = [...owners]
    .filter(([, chunks]) => chunks.size > 1)
    .map(([id]) => id)
    .sort();

  const chunks: Chunk[] = [toChunk("initial", initial, graph)];
  if (sharedModules.length > 0) {
    chunks.push(toChunk("shared", sharedModules, graph));
  }
  for (const chunk of [...lazies.keys()].sort()) {
    const ids = [...owners]
      .filter(([id, hits]) => hits.has(chunk) && !sharedModules.includes(id))
      .map(([id]) => id);
    if (ids.length > 0) chunks.push(toChunk(chunk, ids, graph));
  }

  const initialBytes = chunks[0].bytes;
  const lazyBytes = chunks.slice(1).reduce((sum, chunk) => sum + chunk.bytes, 0);
  return { chunks, initialBytes, lazyBytes, boundaryViolations, sharedModules, missing };
}

算法刻意保持“按模块计字节”,不模拟 tree shaking、CSS 提取、压缩和协议开销。它回答的是结构问题:哪些模块会一起到达、哪些边界是假的。真实构建还需要把这些结构结果交给打包器,再用 9.2 的归因核对实际产物。

首包与懒加载的边界

首包不是越小越好。把登录态、路由表和设计系统 token 也拆出去,首屏会先白一下再补样式,用户感知到的不是“包小了”,而是“页面抖了”。判断归属的单位应该是任务路径,而不是模块大小。

模块什么时候需要归属
应用壳、路由表任意页面进入前initial
设计系统 token 与基础组件首屏就要绘制initial
下单表单与校验进入下单页route:order
富文本编辑器用户点击回复interaction:composer
设置页与帮助中心用户主动进入route:settings
低频图表库打开统计面板interaction:stats

这张表的读法是:首包装“进入产品就必须存在的东西”,懒分包装“某个任务开始后才存在的东西”。介于两者之间的模块,问一句“不做这件事的用户会不会为它付出下载成本”,答案通常是会,那就拆出去。

共享模块要单独算账。shared 不是免费的:它多一次请求,也把两个懒分包的发布绑在一起。只有当两个分包在同一个会话里确实都会被访问时,提升才划算。测试里的 12 字节只是演示,真实项目要拿访问路径的命中率来算。

测试与真实输出

测试覆盖六种情况:首包闭包、共享提升、边界冲突、悬空引用、静态成环、字节守恒。完整测试文件如下:

// file: test/build/module-graph.test.ts
// tier: L1
// verify: node --test test/build/module-graph.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { splitBundle, validateGraph } from "../../src/build/module-graph.ts";
import type { ModuleGraph } from "../../src/build/module-graph.ts";

function fixture(): ModuleGraph {
  return {
    entry: "entry",
    modules: {
      entry: {
        id: "entry",
        bytes: 10,
        imports: ["shell"],
        lazy: [
          { target: "order", chunk: "route:order" },
          { target: "composer", chunk: "interaction:composer" },
        ],
      },
      shell: { id: "shell", bytes: 30, imports: ["ui"] },
      ui: { id: "ui", bytes: 20 },
      order: { id: "order", bytes: 40, imports: ["checkout", "form"] },
      checkout: { id: "checkout", bytes: 25, imports: ["form"] },
      composer: { id: "composer", bytes: 15, imports: ["form"] },
      form: { id: "form", bytes: 12 },
    },
  };
}

test("首包只包含入口的静态闭包", () => {
  const plan = splitBundle(fixture());
  const initial = plan.chunks[0];
  assert.equal(initial.name, "initial");
  assert.deepEqual(initial.modules, ["entry", "shell", "ui"]);
  assert.equal(initial.bytes, 60);
  assert.equal(plan.initialBytes, 60);
});

test("多个懒分包共同依赖的模块提升为 shared", () => {
  const plan = splitBundle(fixture());
  assert.deepEqual(plan.sharedModules, ["form"]);
  assert.deepEqual(
    plan.chunks.map((chunk) => chunk.name),
    ["initial", "shared", "interaction:composer", "route:order"],
  );
  const shared = plan.chunks.find((chunk) => chunk.name === "shared");
  assert.deepEqual(shared?.modules, ["form"]);
  assert.equal(shared?.bytes, 12);
});

test("懒目标被静态导入时记录边界冲突", () => {
  const graph = fixture();
  graph.modules.entry.imports = ["shell", "order"];
  const plan = splitBundle(graph);
  assert.deepEqual(plan.boundaryViolations, ["route:order:order"]);
  assert.ok(plan.chunks[0].modules.includes("order"));
});

test("悬空引用进入 missing,算法不崩溃", () => {
  const graph = fixture();
  graph.modules.checkout.imports = ["form", "ghost"];
  assert.deepEqual(validateGraph(graph), ["ghost"]);
  const plan = splitBundle(graph);
  assert.deepEqual(plan.missing, ["ghost"]);
  assert.equal(plan.initialBytes, 60);
});

test("静态依赖成环时闭包仍然终止", () => {
  const graph = fixture();
  graph.modules.checkout.imports = ["form", "order"];
  const plan = splitBundle(graph);
  const order = plan.chunks.find((chunk) => chunk.name === "route:order");
  assert.deepEqual(order?.modules, ["checkout", "order"]);
  assert.equal(order?.bytes, 65);
});

test("分包前后总字节守恒,每个模块只计一次", () => {
  const plan = splitBundle(fixture());
  const total = Object.values(fixture().modules).reduce(
    (sum, node) => sum + node.bytes,
    0,
  );
  assert.equal(plan.initialBytes + plan.lazyBytes, total);
  const seen = plan.chunks.flatMap((chunk) => chunk.modules);
  assert.equal(new Set(seen).size, seen.length);
});

在实验工程根目录执行:

node --test test/build/module-graph.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 首包只包含入口的静态闭包 (1.060792ms)
✔ 多个懒分包共同依赖的模块提升为 shared (0.152959ms)
✔ 懒目标被静态导入时记录边界冲突 (0.1605ms)
✔ 悬空引用进入 missing,算法不崩溃 (0.240416ms)
✔ 静态依赖成环时闭包仍然终止 (0.111542ms)
✔ 分包前后总字节守恒,每个模块只计一次 (0.101417ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 207.646334

最后一条测试值得单独说:它断言 initialBytes + lazyBytes 等于所有模块字节之和,并且所有分包里出现的模块 id 不重复。这条不变量能挡住两类回归——共享模块被重复计数,以及某个模块既没进首包也没进任何懒分包,从产物里静默消失。

小结

分包的本质是把依赖图按“何时需要”切成几块,而不是把目录按大小切碎。首包是入口的静态闭包,懒分包是动态导入目标的静态闭包,跨分包复用的模块提升为 shared,懒目标落进首包则是必须报告的边界冲突。

算法只处理结构,不替代打包器,但它能在构建之前回答三个问题:首包为什么这么大、某个路由到底有没有被拆出去、共享模块是不是被重复下载。下一节把“字节”变成可归因、可判定的预算。

阅读导航:上一节:8.3 可访问性与交互边界 · 下一节:9.2 产物分析与体积预算 。

常见错误与边界

  • 把目录结构当依赖图。同目录不代表同生命周期,跨目录也不代表必须分开。
  • 路由组件静态导入入口,再期待分包配置生效。边界冲突不会让构建失败,只会让首包变大。
  • 把 shared 当成默认优化。多一次请求、多一条发布耦合,命中率不高时是负收益。
  • 只统计压缩前字节。结构算法可以用原始字节,预算必须用实际上线口径。
  • 忽略悬空引用。图里少一个节点,闭包会静默变小,测试必须显式检查 missing。
  • 假设图是树。静态依赖可以成环,遍历必须用集合去重。
  • 用首包大小单独评价分包。用户感知的是任务完成时间,不是某一个文件的字节数。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查