1. 架构约定,靠嘴说守不住
我之前写过两篇关于代码门禁的文章------Husky 管"什么时候拦",knip 管"有没有人在用"。这两篇都偏向代码质量的下层:格式对不对、死代码清没清。
但还有一个问题从来没拦住过:依赖方向。
随便举个例子。我项目里有个 src/utils/storage.ts,是操作 localStorage 的工具函数。有一天我发现它 import 了这样一行:
javascript
import { useTenantStore } from '@/stores/tenant'
utils 目录下的工具函数,依赖了 stores 目录下的 Pinia store。这意味着:你想用这个工具函数,必须先把 store 初始化好。工具函数失去了"纯函数"的独立性------测试变难、复用变难、哪天想把它抽到另一个项目直接就报错。
这种问题,Code Review 发现不了------reviewer 在看业务逻辑、看命名、看类型,不会逐条 import 检查依赖方向。文档守不住------"utils 不要依赖 stores"写进 Notion 里,三个月后没人记得。ESLint 也管不着------import/no-cycle 只检查循环依赖,不管分层方向。
架构约定靠嘴说守不住,得靠工具。
这就是 depcruise(dependency-cruiser)要解决的问题。它做的事很简单:扫描项目里所有 import 和 require 语句,构建一张完整的依赖图,然后按你写的规则检查------比如 utils 不能依赖 stores、components 不能直接调 api、不能有循环依赖------违规就报,CI 就红。
这篇文章以我自己的项目(一个 Vite + Vue 3 + TypeScript 的企业级多租户平台)为例,从头讲清楚 depcruise 怎么配、怎么用、怎么挂到 CI 上。和前两篇一样,不搞花活,务实解决问题。
2. depcruise 看什么,knip 看什么
前一篇写了 knip,这一篇写 depcruise,两个工具经常被放在一起比较。它们做的事确实有重叠------都能检测"没有被引用的文件"------但核心问题完全不同。
knip 问的是"能不能删"。 它从入口文件出发,顺着 import 链找到所有被引用的东西,剩下的就是"没人在用的"。目标是清理,让仓库变轻。
depcruise 问的是"方向对不对"。 不管你是不是在用,它看的是:A 依赖了 B,这个方向是否违反你定义的架构规则。目标是设防,让架构不腐化。
举个例子。假设项目的分层是 pages → components → utils,有个新人写了一个组件:
javascript
// src/components/MyButton.ts
import { formatDate } from '../pages/OrderList'
knip 的反应:formatDate 被引用了,没报。OrderList 里的其他东西可能被报"没人在用"。knip 不管引用方向,只管"用没用"。
depcruise 的反应:components/MyButton 依赖了 pages/OrderList,但你的规则里写了 components 不能依赖 pages------直接报违规。方向反了就是反了,和"用没用"没有关系。
| 维度 | knip | depcruise |
|---|---|---|
| 核心问题 | 这个文件/依赖/导出还有人在用吗? | 这个 import 方向符合架构约定吗? |
| 检测原理 | 从入口文件遍历引用链,标记可达 → 不可达的报出来 | 构建完整依赖图 → 逐条规则匹配 → 违规报出来 |
| 典型发现 | "这个 npm 包没人在用"、"这个文件是死代码" | "components 层引用了 pages 层,方向反了"、"A 和 B 互相依赖,形成循环" |
| 产出 | 一份"可以删的清单" | 一份"违规依赖清单" + 依赖图 |
| 时间维度 | 面向过去------清理历史遗留 | 面向未来------阻止新增违规 |
| 和 CI 的关系 | 跑一次清理一次,不宜挂 CI | 天然适合挂 CI------每次 commit 都检查依赖方向 |
所以门禁系列三篇的递进逻辑是:Husky 管"什么时候拦"(时机),knip 管"有没有人在用"(清理),depcruise 管"方向对不对"(架构)。三个工具各管一层,组合起来才是完整的代码门禁。
3. 它是怎么"看懂"你的代码的
在写规则之前,先搞清楚 depcruise 凭什么能理解 import { workflowApi } from '@/api' 这行代码。这个过程分三步。
第一步:解析 import 语句
depcruise 内置了 TypeScript 和 JavaScript 的解析器。它读你的 .vue、.ts、.tsx 文件,提取所有 import 和 require 语句,得到一张原始列表:
javascript
ApprovalActionDialog.vue:
import { workflowApi } from '@/api'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
storage.ts:
import { useTenantStore } from '@/stores/tenant'
第二步:解析路径------关键一步
from '@/api' 不是真实路径。depcruise 怎么知道 @/api 指向 src/api/index.ts?
答案:它用了 enhanced-resolve ------和 webpack 是同一个模块解析器。你的 tsconfig.json 里配了:
json
{
"compilerOptions": {
"paths": {
"@/*": ["src/*"]
}
}
}
depcruise 的配置里指定了 tsconfig 的位置:
css
options: {
tsConfig: { fileName: 'tsconfig.json' }
}
enhanced-resolve 读取这个映射,把 @/ 替换成 src/,然后按扩展名列表(.ts、.tsx、.vue、.js、.d.ts)依次尝试,找到 src/api/index.ts。
整个解析链:
javascript
import { workflowApi } from '@/api'
↓ tsconfig paths: "@/*" → "src/*"
↓ enhanced-resolve 替换前缀
↓ 尝试扩展名: .ts, .tsx, .vue, .js, .d.ts
↓ 找到 index.ts
src/api/index.ts ← depcruise 看到的"真实路径"
如果没配 tsconfig 或者 @/ 别名没配对,depcruise 解析失败(couldNotResolve),这条 import 就变成了一条"断掉的依赖"------这也是你可以配置规则去拦截的。
第三步:规则匹配
拿到所有依赖关系后,depcruise 逐条对规则。以 utils-no-store-deps 为例:
css
{
name: 'utils-no-store-deps',
from: { path: '^src/utils/' },
to: { path: '^src/stores/' }
}
depcruise 遍历每一条依赖,把源文件路径往 from.path 正则上套,目标文件路径往 to.path 上套:
bash
依赖: utils/storage.ts → stores/tenant.ts
from.path '^src/utils/' 匹配 'src/utils/storage.ts' ✅
to.path '^src/stores/' 匹配 'src/stores/tenant.ts' ✅
两者都命中 → 违规
from 和 to 必须同时命中才算违规。path 是正则表达式,^src/utils/ 匹配所有 src/utils/ 开头的文件,^src/stores/ 同理。
depcruise 是纯粹的规则引擎 。它没有预设规则------不会主动告诉你"utils 不应该依赖 stores",因为它不知道你的 utils 是什么、stores 是什么。你写什么规则,它就检查什么。你只写了一条 no-circular,它就只检查循环依赖。这在后面配规则的时候很重要:每条规则都代表一个架构决策,你确认了"这个方向不应该依赖那个方向",才写进去。
4. 实操:从一条规则到完整门禁
下面以我的项目为例,从当前只有一条规则开始,逐步加到完整配置。
4.1 起点:只有循环依赖检查
我的项目当前 .dependency-cruiser.cjs 长这样:
css
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'error',
from: {},
to: { circular: true }
}
],
options: {
doNotFollow: { path: 'node_modules' },
tsConfig: { fileName: 'tsconfig.json' },
tsPreCompilationDeps: true,
moduleSystems: ['es6', 'cjs'],
enhancedResolveOptions: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.json', '.d.ts']
},
exclude: { path: '(^|/)(node_modules|dist|coverage|html)(/|$)' }
}
}
跑一次:
scss
$ npx depcruise src --config .dependency-cruiser.cjs
✔ no dependency violations found (342 modules, 1229 dependencies cruised)
342 个模块、1229 条依赖关系,零违规。循环依赖没问题,但其他问题一条都没查------因为根本没配规则。
4.2 加第一条分层规则:utils 不能依赖 stores
css
{
name: 'utils-no-store-deps',
severity: 'error',
comment: '工具函数不应依赖 Pinia store,保持 utils 可独立复用',
from: { path: '^src/utils/' },
to: { path: '^src/stores/' }
}
加到 forbidden 数组里,再跑:
bash
✗ 1 dependency violations (1 errors, 0 warnings).
error utils-no-store-deps: src/utils/storage.ts → src/stores/tenant.ts
抓到了------utils/storage.ts 依赖了 stores/tenant.ts。exit code 变成 1,CI 会红。
这是值得修的。utils/storage.ts 的作用是封装 localStorage 操作,它不应该知道 Pinia store 的存在。把用到 store 的那部分逻辑提取到 composables 或 services 里,utils 恢复为纯工具函数。
4.3 分析项目,加更多规则
跑一次 depcruise 的 JSON 输出,能看到完整的依赖关系。分析之后,我发现了几个值得拦截的方向。但注意------depcruise 不会主动告诉你这些,你得自己分析依赖图,然后决定哪些方向该拦:
| 规则 | 违规数 | 判断 |
|---|---|---|
no-circular |
0 | ✅ 已有,循环依赖没问题 |
utils-no-store-deps |
1 | ✅ 值得加------工具函数不该依赖状态层 |
utils-no-config-deps |
3 | ✅ 值得加------utils 和 config 互相依赖,选一个方向禁止 |
utils-no-i18n-deps |
1 | ✅ 值得加------工具函数不该绑定国际化 |
services-no-router-deps |
1 | ✅ 值得加------服务层不该知道路由结构 |
components-no-api-direct |
7 | ❌ 不加------我的 API 层本身就是一层抽象,组件没有直接写 axios.post(),加这条只是多一层代理 |
最终加上 4 条分层规则 + 1 条孤儿文件检测:
css
forbidden: [
// 已有
{ name: 'no-circular', severity: 'error', from: {}, to: { circular: true } },
// 分层规则
{ name: 'utils-no-store-deps', severity: 'error',
from: { path: '^src/utils/' }, to: { path: '^src/stores/' } },
{ name: 'utils-no-config-deps', severity: 'error',
from: { path: '^src/utils/' }, to: { path: '^src/config/' } },
{ name: 'utils-no-i18n-deps', severity: 'error',
from: { path: '^src/utils/' }, to: { path: '^src/i18n/' } },
{ name: 'services-no-router-deps', severity: 'error',
from: { path: '^src/services/' }, to: { path: '^src/router/' } },
// 孤儿文件(warn,因为入口文件天然是孤儿)
{ name: 'no-orphans', severity: 'warn',
from: { orphan: true }, to: {} }
]
4.4 baseline:冻结存量违规
加完规则再跑,会报出 6 条违规。一次性修完不现实,但可以保证不再新增。
scss
# 生成 baseline:把当前所有违规冻结
npx depcruise src --config .dependency-cruiser.cjs --output-type baseline > .dependency-cruiser-known-violations.json
depcruise 会自动检测项目根目录下的 .dependency-cruiser-known-violations.json,不需要额外配置。下次跑 npm run depcruise 时带上 --ignore-known:
css
depcruise src --config .dependency-cruiser.cjs --ignore-known
效果:已存在的 6 条违规不报错,新引入的才报。而且 baseline 是活的------如果你修了一条旧违规,下次跑 depcruise 会提示"这条基线已修复,可以移除"。
4.5 SVG 依赖图
depcruise 还能生成可视化的依赖图(需先安装 Graphviz:brew install graphviz 或 apt install graphviz):
css
npx depcruise src --config .dependency-cruiser.cjs --output-type dot \
| dot -T svg > dependency-graph.svg
浏览器打开这个 SVG,能看到所有模块的依赖关系网。红色箭头是违规,蓝色是正常。放在团队文档里,比一页架构说明管用。
5. 规则详解:你可以"拦"到什么程度
上一节写了 5 条规则,每条都是 from.path + to.path 的组合。但 depcruise 的规则参数远不止这些。下面把常用的参数逐个讲清楚。
from / to:谁不能依赖谁
最核心的两个字段。from 是依赖的起点(谁在 import),to 是禁止的目标(import 了谁)。两者都支持:
| 参数 | 类型 | 说明 |
|---|---|---|
path |
正则 | 匹配文件路径 |
pathNot |
正则 | 排除文件路径,匹配到的跳过这条规则 |
orphan |
bool | 只匹配孤儿文件(用于 from) |
circular |
bool | 只匹配循环依赖(用于 to) |
dynamic |
bool | 只匹配 await import() 动态导入 |
couldNotResolve |
bool | 只匹配解析失败的 import |
dependencyTypes |
数组 | 只匹配指定类型的 import |
dependencyTypesNot |
数组 | 排除指定类型的 import |
moreThanOneDependencyType |
bool | 只匹配"不止一种 import 方式"的依赖 |
dependencyTypes:区分 import 的类型
同一个 import,depcruise 能分辨它是什么类型:
typescript
import { workflowApi } from '@/api' // local(项目内部模块)
import { ref } from 'vue' // npm(第三方包)
import type { WorkflowTimeline } from '@/api' // type-only(编译时擦除)
const { authApi } = await import('@/api') // dynamic-import(运行时按需加载)
这有什么用?循环依赖的检查可以区别对待。import type 在编译后完全擦除,不产生运行时模块加载,所以 A 和 B 互相 import type 不会造成运行时问题:
csharp
// 只拦截运行时循环,type-only 只警告
{
name: 'no-circular-runtime',
severity: 'error',
from: {},
to: {
circular: true,
dependencyTypesNot: ['type-only'] // 不检查 import type
}
}
moreThanOneDependencyType:检测"深度耦合"
当 A 依赖 B 时,用了不止一种 import 方式------比如既 import 了值又 import 了类型:
typescript
import { themeConfig } from '@/config/theme' // local(值)
import type { ThemeMode, ColorScheme } from '@/config/theme' // type-only(类型)
moreThanOneDependencyType: true 只匹配这种"双重依赖"------说明两个模块之间的耦合比单纯的"引用了一个类型"或"调用了一个函数"更深。
via:拦截"绕路"依赖
A 没有直接 import C,但 A import 了 B,B import 了 C:
bash
views/OrderList.vue → components/ProTable.vue → api/workflowApi.ts
from: views, to: api 检测不到这个------因为 views 没有直接 import api。加上 via:
css
{
name: 'views-no-api-even-via-components',
severity: 'error',
from: { path: '^src/views/' },
to: { path: '^src/api/' },
via: { path: '^src/components/' } // 即使经过 components 中转也不行
}
allowed:白名单放行
forbidden 是黑名单(默认允许一切,只禁止特定路径)。allowed 是白名单(默认禁止一切,只允许列出的)。如果某条 forbidden 规则里有一个具体的依赖是有意为之的,在 allowed 里加一条精准放行,不影响其他:
css
allowed: [
{
name: 'allow-storage-tenant',
from: { path: '^src/utils/storage\.ts$' },
to: { path: '^src/stores/tenant\.ts$' }
}
]
注意路径精确到了具体文件名------放行只放一个,不放开整条规则。
severity:error 还是 warn
error 会让 exit code 非零,CI 红。warn 只提示,不阻断。孤儿文件建议设 warn------入口文件天然是孤儿,你不想因为一个误报挡住 CI。类型导入的循环依赖也可以设 warn------运行时没影响,但值得关注。
几条可复用的规则模板
css
// 模板1:禁止 A 层依赖 B 层
{ from: { path: '^src/A/' }, to: { path: '^src/B/' } }
// 模板2:禁止跨业务模块引用
{ from: { path: '^src/views/order/' }, to: { path: '^src/views/user/' } }
// 模板3:白名单放行(只放一个具体文件)
{ from: { path: '^src/utils/storage\.ts$' }, to: { path: '^src/stores/tenant\.ts$' } }
// 模板4:检测孤儿文件(排除入口和测试)
{ from: { orphan: true, pathNot: '(main\.ts|App\.vue|\.test\.)' }, to: {} }
6. 几个容易踩的坑
坑一:规则越多越好
加了 utils-no-store-deps,又加 utils-no-config-deps,又加 utils-no-i18n-deps......加到 20 条规则,每次跑 depcruise 报 30 个违规,团队开始无视,最后 --no-verify 跳过。
正确做法:每条规则都是一个架构决策。加之前问自己:这个依赖方向,团队真的达成共识要禁止了吗?没共识的规则不加。
坑二:所有违规都设 error
error 会让 CI 红,warn 只提示不阻断。孤儿文件该设 warn------因为入口文件天然是孤儿,你不想因为一个误报挡住 CI。类型导入的循环依赖也该设 warn------运行时没影响,但值得关注。
坑三:以为 baseline 是藏污纳垢
baseline 不是"忽略"------它记录了每条违规的具体路径。你修了一条,下次跑 depcruise 会主动提醒"这条基线已修复,可以移除"。baseline 是"冻结存量,不新增",不是"假装不存在"。
坑四:以为 depcruise 能替代 knip
depcruise 看"方向对不对",knip 看"有没有在用"。depcruise 不会告诉你某个 npm 包没人在用,knip 不会告诉你 components 引用了 pages 方向反了。两个工具互补,不是替代。
坑五:depcruise 会拖慢 CI
我的项目 342 个模块、1229 条依赖,跑一次 depcruise 不到 2 秒。它不做类型检查、不运行代码,只解析 import 语句和匹配正则。放心挂 CI。
边界:depcruise 管不了的事
- 动态 require/import :
require(variable)或import(variable)是运行时决定的,静态分析无能为力 - CSS/SCSS 的 @import:depcruise 只管 JS/TS 的模块依赖,不管样式文件之间的引用
- depcruise 只看文件路径,不知道文件实际是干什么的 :
from: { path: '^src/utils/' }匹配的是路径字符串。如果有个文件叫src/utils/secretStore.ts,它路径在 utils 下但实际是个 store------depcruise 不会知道,它只看路径 - 类型层面的隐式耦合:两个模块都 import 了同一个 interface,但没有互相 import------depcruise 不会报。但它们在类型层面是耦合的,改 interface 两个都要改。这块靠 TypeScript 的类型检查来补
- monorepo 需要额外配置:如果不是单 package 项目,depcruise 需要配置来区分跨 package 依赖和外部 npm 依赖
7. 挂上 CI,门禁闭环
我的项目 package.json 里已经有了门禁流程:
json
{
"scripts": {
"verify": "npm run lint:check && npm run type-check && npx vitest run",
"knip": "knip",
"depcruise": "depcruise src --config .dependency-cruiser.cjs",
"quality-gate": "npm run verify && npm run knip && npm run depcruise",
"ci": "npm run quality-gate && npm run build"
}
}
depcruise 有违规时 exit code 非零,quality-gate 自动失败,ci 跟着失败。不需要额外配置 CI 文件。
一个细节:depcruise 检查的是全局依赖图,不是单文件。所以不建议放在 lint-staged 的 pre-commit 阶段------只检查改动的文件可能检测不到跨文件的循环依赖。放在 pre-push 或 CI 阶段跑全量更合适。
回过头看门禁体系三篇:
- Husky 管时机------什么时候拦(pre-commit、commit-msg、pre-push)
- knip 管清理------有没有人在用(死代码、死依赖)
- depcruise 管方向------import 对不对(依赖方向、循环依赖、架构边界)
三个工具各管一层,串在 quality-gate 一条命令里。每次 commit 或 push,格式、类型、测试、死代码、依赖方向全部检查一遍。不是"相信大家会遵守约定",而是"约定写进配置文件,机器替你执行"。
这就是门禁的意义。