一次「只改一个包」的发布
某客户端产品把 Web、iOS、Android 与桌面端的共享代码收进同一个仓库,包数量从 6 个涨到 47 个。一次只改了 ui-kit 里一个按钮的圆角,发布却花了 22 分钟:CI 把所有包重新构建了一遍;更糟的是,预览环境启动后白屏,报错指向 @shop/ui-kit/src/internal/theme。
这条路径在代码评审里看起来「只是引用一个常量」,但它穿过了包的公开入口,直接读到了内部实现。构建没有失败,测试也没有失败,因为两边都能解析到文件。真正失效的是边界:仓库把代码放在了一起,却没有定义什么可以跨过去。
边界不是目录,是依赖声明
把包放进 packages/ 只完成了物理分层。可执行的边界至少要回答四个问题:
| 问题 | 落点 | 失效后的表现 |
|---|---|---|
| 谁依赖谁 | dependencies | 构建顺序靠猜,改动影响面算不清 |
| 能不能依赖 | public / internal | 公开包把内部实现带进发布产物 |
| 从哪里进入 | exports | 深路径引用绕过入口,重构即破坏 |
| 有没有环 | 依赖图 | 构建顺序不存在,只能全量重编 |
前三项是声明,第四项是声明之后必须计算的属性。很多团队只做第一项,于是 monorepo 退化成一个「很大的仓库」:目录很整齐,依赖仍然靠口头约定。
公开与内部的区分最容易写错。公开包是发布给其他团队或外部应用使用的,它的 API 变更需要版本与公告;内部包只服务于本仓库的构建、工具或实验,可以随时重构。规则只有一条:公开包不得依赖内部包。反过来允许,因为内部工具可以站在稳定 API 上。
exports 是第二条容易被忽略的边界。即使两个包都在仓库内,@shop/ui-kit/src/internal/theme 这种深路径也应该被拒绝;它绕过了入口,入口处的兼容层、副作用顺序与类型收敛全部失效。允许的引用只有两种:包名本身,以及 exports 中显式列出的子路径。
依赖图的模型
代码里的图由五类信息组成:
| 字段 | 含义 | 例子 |
|---|---|---|
name | 包的唯一标识 | @shop/http |
dir | 包在仓库中的位置 | packages/http |
visibility | 对外可见还是仅仓库内可见 | public / internal |
dependencies | 允许引用的包名 | ["@shop/core"] |
exports | 允许进入的子路径 | [".", "./retry"] |
诊断也只需要四类:重复包名、依赖自身、依赖不存在的包、公开包依赖内部包。环检测是图的属性,不属于单个包,所以单独计算。把声明与计算分开,CI 才能在改动一个包时只重跑受影响的子图,而不是全量构建。
把依赖图写成代码
下面的模块不读文件系统,也不解析 package.json;它接收已经加载的包声明,输出诊断、环、构建顺序与引用判定。这样同一套规则既能给 CI 用,也能给编辑器的快速修复用。
// file: src/build/package-graph.ts
// tier: L1
// verify: node --test test/build/package-graph.test.ts
export type Visibility = "public" | "internal";
export type PackageNode = {
name: string;
dir: string;
visibility: Visibility;
dependencies: string[];
exports: string[];
};
export type GraphDiagnostic = {
code:
| "duplicate"
| "self-dependency"
| "unknown-dependency"
| "visibility";
from: string;
to?: string;
detail: string;
};
export type ReferenceReason =
| "ok"
| "unknown-source"
| "unknown-target"
| "self-reference"
| "undeclared"
| "visibility"
| "deep-import";
export type ReferenceResult = {
ok: boolean;
reason: ReferenceReason;
detail: string;
};
export function packageNames(packages: PackageNode[]): string[] {
return packages.map((pkg) => pkg.name);
}
export function validateGraph(packages: PackageNode[]): GraphDiagnostic[] {
const diagnostics: GraphDiagnostic[] = [];
const firstSeen = new Map<string, number>();
for (const pkg of packages) {
if (firstSeen.has(pkg.name)) {
diagnostics.push({
code: "duplicate",
from: pkg.name,
detail: `重复的包名 ${pkg.name},首次出现在下标 ${firstSeen.get(pkg.name)}`,
});
} else {
firstSeen.set(pkg.name, firstSeen.size);
}
}
const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
for (const pkg of packages) {
for (const dep of pkg.dependencies) {
if (dep === pkg.name) {
diagnostics.push({
code: "self-dependency",
from: pkg.name,
to: dep,
detail: `${pkg.name} 声明依赖自身`,
});
continue;
}
const target = byName.get(dep);
if (!target) {
diagnostics.push({
code: "unknown-dependency",
from: pkg.name,
to: dep,
detail: `${pkg.name} 依赖了不存在的包 ${dep}`,
});
continue;
}
if (pkg.visibility === "public" && target.visibility === "internal") {
diagnostics.push({
code: "visibility",
from: pkg.name,
to: dep,
detail: `公开包 ${pkg.name} 不得依赖内部包 ${dep}`,
});
}
}
}
return diagnostics;
}
function canonicalCycle(cycle: string[]): { key: string; cycle: string[] } {
let pivot = 0;
for (let i = 1; i < cycle.length; i += 1) {
if (cycle[i] < cycle[pivot]) pivot = i;
}
const rotated = [...cycle.slice(pivot), ...cycle.slice(0, pivot)];
return { key: rotated.join(" -> "), cycle: rotated };
}
export function detectCycles(packages: PackageNode[]): string[][] {
const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
const state = new Map<string, 0 | 1 | 2>();
const stack: string[] = [];
const found = new Map<string, string[]>();
const visit = (name: string): void => {
state.set(name, 1);
stack.push(name);
for (const dep of byName.get(name)?.dependencies ?? []) {
if (dep === name || !byName.has(dep)) continue;
const depState = state.get(dep) ?? 0;
if (depState === 0) {
visit(dep);
} else if (depState === 1) {
const start = stack.indexOf(dep);
const { key, cycle } = canonicalCycle(stack.slice(start));
found.set(key, cycle);
}
}
stack.pop();
state.set(name, 2);
};
for (const pkg of packages) {
if ((state.get(pkg.name) ?? 0) === 0) visit(pkg.name);
}
return [...found.keys()].sort().map((key) => found.get(key) as string[]);
}
export function buildOrder(packages: PackageNode[]): {
order: string[];
cycles: string[][];
} {
const names = [...new Set(packages.map((pkg) => pkg.name))].sort();
const known = new Set(names);
const indegree = new Map(names.map((name) => [name, 0]));
const dependents = new Map<string, string[]>(
names.map((name) => [name, []]),
);
for (const pkg of packages) {
if (!known.has(pkg.name)) continue;
const deps = [...new Set(pkg.dependencies)].filter(
(dep) => known.has(dep) && dep !== pkg.name,
);
indegree.set(pkg.name, deps.length);
for (const dep of deps) {
dependents.get(dep)?.push(pkg.name);
}
}
const ready = names.filter((name) => indegree.get(name) === 0).sort();
const order: string[] = [];
while (ready.length > 0) {
const name = ready.shift() as string;
order.push(name);
for (const dependent of dependents.get(name) ?? []) {
const left = (indegree.get(dependent) ?? 0) - 1;
indegree.set(dependent, left);
if (left === 0) {
ready.push(dependent);
ready.sort();
}
}
}
return { order, cycles: detectCycles(packages) };
}
export function allowedReference(
packages: PackageNode[],
from: string,
to: string,
importPath: string,
): ReferenceResult {
const source = packages.find((pkg) => pkg.name === from);
if (!source) {
return {
ok: false,
reason: "unknown-source",
detail: `未知来源包 ${from}`,
};
}
const target = packages.find((pkg) => pkg.name === to);
if (!target) {
return {
ok: false,
reason: "unknown-target",
detail: `未知目标包 ${to}`,
};
}
if (from === to) {
return {
ok: false,
reason: "self-reference",
detail: `同一包内应使用相对路径,而不是包名 ${to}`,
};
}
if (!source.dependencies.includes(to)) {
return {
ok: false,
reason: "undeclared",
detail: `${from} 未在依赖中声明 ${to}`,
};
}
if (source.visibility === "public" && target.visibility === "internal") {
return {
ok: false,
reason: "visibility",
detail: `公开包 ${from} 不得引用内部包 ${to}`,
};
}
if (importPath !== to && !importPath.startsWith(`${to}/`)) {
return {
ok: false,
reason: "deep-import",
detail: `引用路径 ${importPath} 不以包名 ${to} 开头`,
};
}
const subpath =
importPath === to ? "." : `./${importPath.slice(to.length + 1)}`;
if (!target.exports.includes(subpath)) {
return {
ok: false,
reason: "deep-import",
detail: `${to} 未导出子路径 ${subpath}`,
};
}
return { ok: true, reason: "ok", detail: `${importPath} 允许引用` };
}
export function transitiveDependents(
packages: PackageNode[],
name: string,
): string[] {
const reverse = new Map<string, Set<string>>();
for (const pkg of packages) {
for (const dep of pkg.dependencies) {
if (!reverse.has(dep)) reverse.set(dep, new Set());
reverse.get(dep)?.add(pkg.name);
}
}
const seen = new Set<string>();
const queue = [...(reverse.get(name) ?? [])];
while (queue.length > 0) {
const current = queue.shift() as string;
if (seen.has(current)) continue;
seen.add(current);
for (const dependent of reverse.get(current) ?? []) queue.push(dependent);
}
return [...seen].sort();
}
allowedReference 的检查顺序是刻意的:先确认来源与目标存在,再拒绝同包名引用,然后检查依赖声明、可见性与子路径。顺序决定错误信息,也决定修复建议;如果把可见性放在依赖声明之前,一个未声明的引用会被误报成边界违规,开发者会去改可见性而不是补依赖。
buildOrder 使用 Kahn 算法,并在每一轮对就绪集合排序。排序不是为了正确性,而是为了可重现:同样的输入必须得到同样的构建顺序,否则缓存键、日志与并行调度都会抖动。
测试与真实输出
在实验工程根目录执行:
node --test test/build/package-graph.test.ts
本机一次运行的真实输出末尾如下,耗时随机器变化:
✔ 无环图没有环 (0.051208ms)
✔ 拓扑顺序满足依赖先后 (0.184791ms)
✔ 有环时拓扑序排除环内包 (0.075542ms)
✔ 传递依赖方按名字排序 (0.101875ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 161.523667
二十个用例覆盖了诊断、环、拓扑序与引用判定。它们固定的是规则本身;package.json 的读取、工作区链接与打包器解析属于集成层,需要另外的端到端测试。
判据与反例
判断一个 monorepo 是否有边界,不看目录树,看下面五条能否自动回答:
| 判据 | 通过的样子 | 反例 |
|---|---|---|
| 依赖可枚举 | 每个包的依赖都在声明里 | 代码能 import,声明里没有 |
| 环可发现 | CI 输出具体环路径 | 构建偶发失败,没人知道为什么 |
| 顺序可重现 | 同一提交得到同一拓扑序 | 并行调度导致产物顺序抖动 |
| 可见性可执行 | 公开包引用内部包直接失败 | 只在评审规范里写了一句 |
| 入口可收敛 | 深路径引用被拒绝 | src/internal/... 散落在业务代码 |
最常见的反例是「能跑就算合法」。打包器确实能解析深路径,TypeScript 也能找到类型,于是评审通过;直到内部文件重命名,十几个业务包同时编译失败。边界的作用不是阻止一次构建,而是让未来的重构有退路。
第二个反例是把环当成风格问题。两个包互相引用时,构建工具可能通过全量编译「绕过去」,但增量构建、缓存与按包发布都会失效。环不是慢,而是不可预测。
第三个反例是只检查直接依赖。app -> http -> core 中,app 没有直接声明 core,却可能通过 http 的入口间接拿到 core 的类型。幽灵依赖让依赖图看起来干净,实际影响面仍然存在。判据要能沿着传递闭包回答「改 core 会影响到谁」。
小结
Monorepo 的价值不是「代码在一起」,而是「边界可计算」。
- 包边界由依赖、可见性与导出子路径三件事组成,缺一项就退化成目录约定。
- 环检测与拓扑排序是图的属性,必须由工具计算,不能靠人肉评审。
- 引用判定要给出具体原因,开发者才知道该补依赖、改入口还是拆包。
先让非法引用在本地失败,再谈构建加速。顺序反了,加速只会让错误更快地进入产物。
阅读导航:上一节:5.3 故障演练与恢复验证 · 下一节:6.2 产物治理与缓存 。
常见错误与边界
- 只按目录划分包,不声明依赖。构建顺序与实际引用脱节,改动影响面无法计算。
- 允许公开包依赖内部包。内部实现一旦进入公开 API,后续重构都要考虑外部消费者。
- 用
exports只做发布配置,不做仓库内约束。深路径引用仍然能绕过入口。 - 把环依赖留给打包器「自动处理」。全量构建能过,增量构建与按包发布都会失效。
- 只检查直接依赖,忽略传递依赖。幽灵依赖会让影响面分析偏小。
- 拓扑排序不排序就绪集合。同样的提交得到不同构建顺序,缓存与日志无法比较。
- 诊断只返回布尔值。开发者不知道是缺声明、越可见性还是深路径,修复只能靠猜。
- 把边界规则写进文档但不进 CI。第一次赶工期,深路径引用就会重新出现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。