Skip to content

Commit 0e68431

Browse files
committed
build(app): add pure manualChunks vendor grouping
1 parent f059265 commit 0e68431

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

‎app/src/build/manualChunks.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* manualChunks.test.ts — vendor 分组规则的钉值测试(node 环境)。
3+
*
4+
* @ai-context: 为什么要有这个文件:分组规则一旦被子串匹配污染,懒加载边界会**静默失效**
5+
* (构建照样成功、gzip 照样下降一点、但 GSAP 与 react 混在一个 chunk 里,
6+
* 批 6 的「GSAP 独立 chunk 懒加载」当场落空)。这是本批唯一无法靠人工 review
7+
* 稳定发现的失效模式,必须机器钉住。
8+
* @ai-context: 副作用:读 app/package.json(只读)。边界:只覆盖
9+
* `dependencies`(devDependencies 不进浏览器产物)。
10+
*/
11+
import { readFileSync } from "node:fs";
12+
import { dirname, join } from "node:path";
13+
import { fileURLToPath } from "node:url";
14+
import { describe, expect, it } from "vitest";
15+
import { manualChunks, packageNameOf, vendorGroupOf, type VendorGroup } from "./manualChunks";
16+
17+
const HERE = dirname(fileURLToPath(import.meta.url));
18+
19+
describe("packageNameOf", () => {
20+
it("取最内层 node_modules 之后的包名(嵌套依赖不能解析成外层)", () => {
21+
expect(packageNameOf("/r/node_modules/katex/dist/katex.mjs")).toBe("katex");
22+
expect(packageNameOf("/r/node_modules/rehype-katex/node_modules/katex/dist/katex.mjs")).toBe("katex");
23+
expect(packageNameOf("/r/node_modules/@codemirror/view/dist/index.js")).toBe("@codemirror/view");
24+
expect(packageNameOf("/r/app/src/App.tsx")).toBeUndefined();
25+
expect(packageNameOf("D:\\r\\node_modules\\react-dom\\client.js")).toBe("react-dom");
26+
});
27+
});
28+
29+
describe("vendorGroupOf 钉值", () => {
30+
const cases: readonly (readonly [string, VendorGroup | undefined])[] = [
31+
["/r/node_modules/react/index.js", "vendor-react"],
32+
["/r/node_modules/react-dom/client.js", "vendor-react"],
33+
["/r/node_modules/scheduler/index.js", "vendor-react"],
34+
["/r/node_modules/react-markdown/lib/index.js", "vendor-md"],
35+
["/r/node_modules/@xyflow/react/dist/esm/index.js", "vendor-canvas"],
36+
["/r/node_modules/@xyflow/system/dist/esm/index.js", "vendor-canvas"],
37+
["/r/node_modules/d3-zoom/src/index.js", "vendor-canvas"],
38+
["/r/node_modules/zustand/esm/index.mjs", "vendor-canvas"],
39+
["/r/node_modules/use-sync-external-store/shim/index.js", "vendor-canvas"],
40+
["/r/node_modules/classcat/index.js", "vendor-canvas"],
41+
["/r/node_modules/gsap/index.js", "vendor-gsap"],
42+
["/r/node_modules/@gsap/react/dist/index.js", "vendor-gsap"],
43+
["/r/node_modules/katex/dist/katex.mjs", "vendor-katex"],
44+
["/r/node_modules/rehype-katex/lib/index.js", "vendor-katex"],
45+
["/r/node_modules/hast-util-to-text/lib/index.js", "vendor-katex"],
46+
["/r/node_modules/hast-util-from-dom/lib/index.js", "vendor-katex"],
47+
["/r/node_modules/hast-util-to-jsx-runtime/lib/index.js", "vendor-md"],
48+
["/r/node_modules/property-information/lib/index.js", "vendor-katex"],
49+
["/r/node_modules/hastscript/index.js", "vendor-katex"],
50+
["/r/node_modules/@tauri-apps/api/core.js", "vendor-tauri"],
51+
["/r/node_modules/@tauri-apps/plugin-dialog/dist-js/index.js", "vendor-tauri"],
52+
["/r/node_modules/codemirror/dist/index.js", "vendor-editor"],
53+
["/r/node_modules/@codemirror/lang-markdown/dist/index.js", "vendor-editor"],
54+
["/r/node_modules/@lezer/javascript/dist/index.js", "vendor-editor"],
55+
["/r/node_modules/@marijn/find-cluster-break/src/index.js", "vendor-editor"],
56+
["/r/node_modules/crelt/index.js", "vendor-editor"],
57+
["/r/node_modules/micromark-core-commonmark/dev/index.js", "vendor-md"],
58+
["/r/node_modules/mdast-util-to-hast/lib/index.js", "vendor-md"],
59+
["/r/node_modules/unist-util-visit/index.js", "vendor-md"],
60+
["/r/node_modules/vfile/index.js", "vendor-md"],
61+
["/r/node_modules/remark-gfm/index.js", "vendor-md"],
62+
["/r/node_modules/@ungap/structured-clone/esm/index.js", "vendor-md"],
63+
// 反例:应用源码与非 node_modules 路径一律交回 rollup 默认算法
64+
["/r/app/src/App.tsx", undefined],
65+
["/r/app/src/pages/NotesPage.tsx", undefined],
66+
["/r/vendor/node_modules_backup/react/index.js", undefined],
67+
["/r/node_modules/not-a-real-package/index.js", undefined],
68+
];
69+
for (const [id, expected] of cases) {
70+
it(`${id} → ${String(expected)}`, () => expect(vendorGroupOf(id)).toBe(expected));
71+
}
72+
});
73+
74+
describe("子串碰撞陷阱(批 1 六例假活教训的镜像)", () => {
75+
it("裸 includes('react') 会把 react-dom / react-markdown / @xyflow/react 一起吞掉", () => {
76+
// ⚠️ 判别式必须是**裸 token** `react`:`@xyflow/react` 的模块 id 里并**没有**连续子串
77+
// `node_modules/react`(中间隔着 `@xyflow/`)⇒ 用 `includes("node_modules/react")`
78+
// 做反例在这条 id 上恒为 undefined,守卫会变成假红(计划原文即此写法,实测 exit 1)。
79+
const naive = (id: string) => (id.includes("react") ? "vendor-react" : undefined);
80+
// 先证明「朴素口径确实会错」——否则这条守卫是空转的
81+
expect(naive("/r/node_modules/react-dom/client.js")).toBe("vendor-react");
82+
expect(naive("/r/node_modules/react-markdown/lib/index.js")).toBe("vendor-react");
83+
expect(naive("/r/node_modules/@xyflow/react/dist/index.js")).toBe("vendor-react");
84+
// 再证明本实现不会
85+
expect(vendorGroupOf("/r/node_modules/react-dom/client.js")).toBe("vendor-react"); // 同组,但走的是精确匹配
86+
expect(vendorGroupOf("/r/node_modules/react-markdown/lib/index.js")).toBe("vendor-md");
87+
expect(vendorGroupOf("/r/node_modules/@xyflow/react/dist/index.js")).toBe("vendor-canvas");
88+
});
89+
90+
it("manualChunks 是 vendorGroupOf 的直通(接线面不许有第二套逻辑)", () => {
91+
expect(manualChunks("/r/node_modules/gsap/index.js")).toBe("vendor-gsap");
92+
expect(manualChunks("/r/app/src/App.tsx")).toBeUndefined();
93+
});
94+
});
95+
96+
describe("覆盖闸:package.json 的每个运行时依赖都必须有归属", () => {
97+
it("dependencies 全部可归组(漏一个就会被 rollup 默认算法打散,懒加载边界失效)", () => {
98+
const pkg = JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")) as {
99+
dependencies?: Record<string, string>;
100+
};
101+
const deps = Object.keys(pkg.dependencies ?? {});
102+
expect(deps.length).toBeGreaterThan(0);
103+
const ungrouped = deps.filter((d) => vendorGroupOf(`/r/node_modules/${d}/index.js`) === undefined);
104+
expect(ungrouped).toEqual([]);
105+
});
106+
});

‎app/src/build/manualChunks.ts‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* manualChunks — 生产构建的 vendor 分组规则(纯函数、可单测)。
3+
*
4+
* @ai-context: 批 2 包体治理的原子层。Vite 的 `build.rollupOptions.output.manualChunks`
5+
* 接收模块 id,返回 chunk 名;返回 `undefined` 表示「交回 rollup 默认算法」。
6+
* @ai-context: 为什么**必须按「解析出的包名」精确匹配**,而不是 `id.includes("node_modules/react")`:
7+
* 本仓实测的子串碰撞 —— `node_modules/react` 同时是
8+
* `react-dom` / `react-markdown` / `@xyflow/react` 的子串。用 includes 会把
9+
* markdown 栈与画布栈并进 `vendor-react`,懒加载边界**静默失效**
10+
* (构建照样成功、gzip 照样降一点,但批 6 的 GSAP 独立 chunk 承诺当场落空)。
11+
* 这是批 1 六例「裸 includes 判活」教训在构建配置上的镜像。
12+
* @ai-context: 为什么 GSAP 现在就在表里(而依赖还没装):规格 §13 风险表要求
13+
* 「GSAP 独立 chunk 懒加载」。本函数用**包名精确匹配**表达这条承诺,
14+
* 因此 gsap 未安装时规则**天然惰性**(没有任何模块 id 会解析出包名 `gsap`),
15+
* 装了之后**零改动自动生效**。切不可改写成对象形式
16+
* `{ "vendor-gsap": ["gsap"] }` —— 那会在 gsap 未安装时**直接让构建失败**。
17+
*
18+
* 副作用:无(纯函数)。
19+
* 边界:入参是 rollup 的模块 id(Windows 上可能含 `\`);不解析 tsconfig paths;不处理虚拟模块(`\0` 前缀)。
20+
*/
21+
22+
/** vendor 分组名(同时决定产物 chunk 的文件名前缀)。 */
23+
export type VendorGroup =
24+
| "vendor-gsap"
25+
| "vendor-react"
26+
| "vendor-tauri"
27+
| "vendor-katex"
28+
| "vendor-editor"
29+
| "vendor-canvas"
30+
| "vendor-md";
31+
32+
/** 精确包名 → 分组。**先于前缀族匹配**,因此 `rehype-katex` 不会被 `rehype-` 抢走。 */
33+
const EXACT: Readonly<Record<string, VendorGroup>> = {
34+
// 批 6 预留(本批不安装;规则惰性,见文件头注释)
35+
gsap: "vendor-gsap",
36+
"@gsap/react": "vendor-gsap",
37+
// React 运行时:必须同组,避免出现两份 React 实例
38+
react: "vendor-react",
39+
"react-dom": "vendor-react",
40+
scheduler: "vendor-react",
41+
// Tauri IPC
42+
"@tauri-apps/api": "vendor-tauri",
43+
"@tauri-apps/plugin-dialog": "vendor-tauri",
44+
// 数学渲染(katex 在 node_modules 里有两份:顶层 0.18.4 与 rehype-katex 嵌套的 0.16.47,
45+
// 但 `packageNameOf` 取最内层 ⇒ 两者都归到本组,不会出现「同名两 chunk」)
46+
katex: "vendor-katex",
47+
"rehype-katex": "vendor-katex",
48+
"hast-util-from-dom": "vendor-katex",
49+
"hast-util-to-text": "vendor-katex",
50+
"hast-util-from-html-isomorphic": "vendor-katex",
51+
"hast-util-is-element": "vendor-katex",
52+
"hast-util-parse-selector": "vendor-katex",
53+
hastscript: "vendor-katex",
54+
"property-information": "vendor-katex",
55+
"web-namespaces": "vendor-katex",
56+
// 编辑器(CodeMirror 6 无 scope 的几件)
57+
codemirror: "vendor-editor",
58+
crelt: "vendor-editor",
59+
"style-mod": "vendor-editor",
60+
"w3c-keyname": "vendor-editor",
61+
// 画布 / 图谱(无 scope 的几件)
62+
classcat: "vendor-canvas",
63+
zustand: "vendor-canvas",
64+
"use-sync-external-store": "vendor-canvas",
65+
// Markdown 管线
66+
"react-markdown": "vendor-md",
67+
unified: "vendor-md",
68+
bail: "vendor-md",
69+
trough: "vendor-md",
70+
vfile: "vendor-md",
71+
"vfile-message": "vendor-md",
72+
extend: "vendor-md",
73+
"is-plain-obj": "vendor-md",
74+
devlop: "vendor-md",
75+
zwitch: "vendor-md",
76+
ccount: "vendor-md",
77+
"longest-streak": "vendor-md",
78+
"markdown-table": "vendor-md",
79+
"trim-lines": "vendor-md",
80+
"escape-string-regexp": "vendor-md",
81+
"decode-named-character-reference": "vendor-md",
82+
"comma-separated-tokens": "vendor-md",
83+
"space-separated-tokens": "vendor-md",
84+
"html-url-attributes": "vendor-md",
85+
"style-to-js": "vendor-md",
86+
"style-to-object": "vendor-md",
87+
"inline-style-parser": "vendor-md",
88+
"estree-util-is-identifier-name": "vendor-md",
89+
"@ungap/structured-clone": "vendor-md",
90+
// 纯类型包(只含 `.d.ts`,**永不作为 rollup 模块 id 出现**)⇒ 本行是**惰性登记**,
91+
// 只为让覆盖闸保持全称量化(`app/package.json` 的 `dependencies` 里确实列了它,
92+
// 不登记则覆盖闸会对这条 package.json 分类异常假红)。若将来新增 `@types/*` 依赖,
93+
// 覆盖闸会再次变红,逼迫在「登记/挪去 devDependencies」之间做一次显式决定。
94+
"@types/katex": "vendor-katex",
95+
};
96+
97+
/** 前缀族 → 分组。**按数组顺序匹配,先到先得**;不得加宽到能吃掉上面 EXACT 的包。 */
98+
const PREFIX: readonly (readonly [RegExp, VendorGroup])[] = [
99+
[/^@codemirror\//, "vendor-editor"],
100+
[/^@lezer\//, "vendor-editor"],
101+
[/^@marijn\//, "vendor-editor"],
102+
[/^@xyflow\//, "vendor-canvas"],
103+
[/^d3-/, "vendor-canvas"],
104+
[/^micromark/, "vendor-md"],
105+
[/^mdast-util/, "vendor-md"],
106+
[/^hast-util/, "vendor-md"],
107+
[/^unist-util/, "vendor-md"],
108+
[/^remark-/, "vendor-md"],
109+
[/^rehype-/, "vendor-md"],
110+
[/^vfile/, "vendor-md"],
111+
];
112+
113+
/** 从 rollup 模块 id 解析出**最内层** `node_modules` 之后的包名。 */
114+
export function packageNameOf(moduleId: string): string | undefined {
115+
const id = moduleId.replace(/\\/g, "/");
116+
const marker = "/node_modules/";
117+
const at = id.lastIndexOf(marker);
118+
if (at < 0) return undefined;
119+
const seg = id.slice(at + marker.length).split("/");
120+
if (seg.length === 0 || seg[0] === "") return undefined;
121+
if (seg[0].startsWith("@")) return seg.length >= 2 && seg[1] !== "" ? `${seg[0]}/${seg[1]}` : undefined;
122+
return seg[0];
123+
}
124+
125+
/** 模块 id → vendor 分组;非 node_modules 或未登记包返回 undefined(交回 rollup)。 */
126+
export function vendorGroupOf(moduleId: string): VendorGroup | undefined {
127+
const name = packageNameOf(moduleId);
128+
if (name === undefined) return undefined;
129+
const exact = EXACT[name];
130+
if (exact !== undefined) return exact;
131+
for (const [re, group] of PREFIX) if (re.test(name)) return group;
132+
return undefined;
133+
}
134+
135+
/** 直接交给 `build.rollupOptions.output.manualChunks`(Task 4 接线)。 */
136+
export function manualChunks(moduleId: string): string | undefined {
137+
return vendorGroupOf(moduleId);
138+
}

0 commit comments

Comments
 (0)