depcruise 实战:把架构约定变成可执行的检查

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)要解决的问题。它做的事很简单:扫描项目里所有 importrequire 语句,构建一张完整的依赖图,然后按你写的规则检查------比如 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 文件,提取所有 importrequire 语句,得到一张原始列表:

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'   ✅
  两者都命中 → 违规

fromto 必须同时命中才算违规。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 的那部分逻辑提取到 composablesservices 里,utils 恢复为纯工具函数。

4.3 分析项目,加更多规则

跑一次 depcruise 的 JSON 输出,能看到完整的依赖关系。分析之后,我发现了几个值得拦截的方向。但注意------depcruise 不会主动告诉你这些,你得自己分析依赖图,然后决定哪些方向该拦:

规则 违规数 判断
no-circular 0 ✅ 已有,循环依赖没问题
utils-no-store-deps 1 ✅ 值得加------工具函数不该依赖状态层
utils-no-config-deps 3 ✅ 值得加------utilsconfig 互相依赖,选一个方向禁止
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 graphvizapt 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 管不了的事

  1. 动态 require/importrequire(variable)import(variable) 是运行时决定的,静态分析无能为力
  2. CSS/SCSS 的 @import:depcruise 只管 JS/TS 的模块依赖,不管样式文件之间的引用
  3. depcruise 只看文件路径,不知道文件实际是干什么的from: { path: '^src/utils/' } 匹配的是路径字符串。如果有个文件叫 src/utils/secretStore.ts,它路径在 utils 下但实际是个 store------depcruise 不会知道,它只看路径
  4. 类型层面的隐式耦合:两个模块都 import 了同一个 interface,但没有互相 import------depcruise 不会报。但它们在类型层面是耦合的,改 interface 两个都要改。这块靠 TypeScript 的类型检查来补
  5. 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,格式、类型、测试、死代码、依赖方向全部检查一遍。不是"相信大家会遵守约定",而是"约定写进配置文件,机器替你执行"。

这就是门禁的意义。

相关推荐
paopaokaka_luck12 分钟前
基于springboot3+vue3+uniapp的河南非遗数字图谱小程序(协同过滤算法、数字图谱展示、ECharts 图形化分析)
java·前端·spring boot·学习·小程序·uni-app·echarts
合橱瑰13 分钟前
Vue3 与 ElementPlus 前端常见错误修复实践
前端·vue.js
PedroQue9914 分钟前
v1.4.0:新增 defineUniPage 宏声明页面配置,架构全面重构
前端·vite
晴天1622 分钟前
Node.js 中 `npm install` 命令分析-Day30
前端·npm·node.js
风之舞_yjf24 分钟前
Vue基础(35)_全局事件总线(GlobalEventBus)
前端·vue.js
程序员小八77733 分钟前
后端转全栈:前端思维转变(Vue 视角)
前端·vue.js·状态模式
RD_daoyi36 分钟前
谷歌改写了76%的标题:超60字符的,95%会被谷歌自己重写
大数据·服务器·开发语言·前端·搜索引擎·html
IMPYLH44 分钟前
HTML 的 <map> 元素
前端·html
宿6741 小时前
vue3-vite
前端·vue.js