一、问题是怎么冒出来的

最近有个朋友跟我抱怨,说他们团队用 Nx 管理一个大型前端项目,明明在 nx.json 里给所有项目都配了统一的构建脚本,结果一到某个子项目就变味了。构建步骤被莫名跳过,执行顺序完全对不上,有时候删掉 dist 重新构建还是老样子。他翻遍 Nx 文档,感觉字都认识,但就是不知道哪里出了问题。

其实这种场景特别常见,尤其是当项目数量变多,配置文件越写越复杂以后。问题往往不是 Nx 本身装错了,而是我们对“全局配置”和“项目配置”之间的继承与覆盖规则没摸透。你以为 nx.json 是唯一的老大,但 project.json 里的某些字段会悄悄覆盖它,甚至影响任务的依赖顺序。今天我就陪你把这块硬骨头啃下来,从配置合并讲到任务图验证,确保下次再遇到类似问题,你知道该从哪里下手。

二、先认清两个文件的分工

2.1 nx.json 是“中央大纲”

nx.json 是 Nx 工作空间的顶级配置文件,它负责定义全局默认值。比如默认的缓存策略、默认的依赖关系、默认的 pipeline(任务执行管道)等等。你可以把它理解成公司的大纲:所有人默认一天上八小时班,默认早上九点开会,默认代码合入前必须跑测试。

{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build", "lint"],
      "cache": true
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}

上面这个配置意思很明白:每个项目的 build 任务默认依赖“它所依赖项目的 build”以及“当前项目的 lint”;test 默认依赖当前项目的 build。这是全局铁律。

2.2 project.json 是“项目特批”

project.json 则属于某个具体项目,它只对这个项目生效。你可以为它单独配置 targets,这些目标会覆盖或补充 nx.jsontargetDefaults 的设定。

{
  "name": "my-app",
  "targets": {
    "build": {
      "dependsOn": ["lint"],
      "executor": "@nx/vite:build",
      "options": {
        "outputPath": "dist/my-app"
      }
    }
  }
}

注意,这里 builddependsOn 变成了只依赖 lint,原本 nx.json 里的 ^build(依赖项目的构建)被它覆盖掉了。如果别的项目需要先构建 my-app 的依赖,那么执行顺序就会错乱。这就是问题根源之一。

三、覆盖规则到底怎么运作

3.1 不是全部覆盖,是精确合并

Nx 对配置的合并并不是把整个 targetDefaults 一刀切掉,而是做了一个相对聪明的合并:对于同一目标(比如 build),如果 project.json 里只写了 dependsOn,那么它不会去动 cache 字段,也不会动 executor 字段(除非你写了)。真正危险的是数组中带 ^ 的依赖,因为它们代表上游依赖项目的任务链。

// 技术栈:Nx + TypeScript,演示配置合并逻辑
// 下面代码只是表达 Nx 内部概念,不是真正源码,但能帮你理解
interface TargetConfig {
  dependsOn?: string[];
  cache?: boolean;
  executor?: string;
  options?: Record<string, unknown>;
}

/**
 * 合并 globalConfig 与 projectConfig
 * 这里仅模拟浅合并,实际上 Nx 对数组是整体替换,不是按位合并
 */
function mergeTargetConfig(
  globalConfig: TargetConfig,
  projectConfig: TargetConfig
): TargetConfig {
  // 全局配置里的 cache 会保留,除非项目显式写了 cache
  const merged: TargetConfig = {
    ...globalConfig,
    ...projectConfig, // projectConfig 内的字段会整体覆盖
    // 注意:如果你在 project.json 写了 dependsOn,整个数组都被替换
  };

  // 举个实际例子:
  // 全局 build.dependsOn = ["^build", "lint"]
  // 项目 build.dependsOn = ["prebuild"]
  // 合并结果是 dependsOn = ["prebuild"]
  // 原来依赖项目的构建和 lint 全没了,执行顺序自然异常
  return merged;
}

3.2 数组是“整体替换”,不是“合并”

很多人第一次踩坑就是栽在这里。他们以为在 project.json 里写 dependsOn: ["prebuild"] 会跟全局的 ["^build", "lint"] 合并成 ["^build", "lint", "prebuild"]。实际上不是这么回事。Nx 的数组覆盖是整体替换。这个细节一旦忽略,整个任务的执行顺序就会变得“莫名其妙”。这也是为什么你明明在全局配好了 ^build,但在某个项目里却不执行依赖构建。

3.3 继承作用域的边界

这里的“作用域边界”就是指:哪一层配置对当前任务生效,覆盖的粒度是什么。Nx 里配置生效顺序从低到高大致是:nx.jsontargetDefaults → 某个 project.json 里的 targets → 命令行里临时传入的参数。每个层级只能影响它管辖范围内的部分字段。如果你想知道某个任务最终执行时到底用的什么配置,不能靠猜,要主动去查。

# 查看某个任务的具体配置,这是最直接的方式之一
nx show project my-app --json | jq '.targets.build'
{
  "executor": "@nx/vite:build",
  "dependsOn": ["prebuild"],
  "options": {
    "outputPath": "dist/my-app"
  }
}

看到没有,dependsOn 只有一个 prebuild,全局的 ^buildlint 都被丢掉了。如果你原本预期是在依赖项目构建完后再构建 my-app,这里就已经出了大问题。

四、排查步骤:从配置合并到执行顺序

4.1 先定位“嫌疑任务”

遇到执行顺序异常,先别急着改代码。我们把它当成一个侦探游戏。第一步要确定是哪个任务的顺序不对。比如大家可以看命令输出,或者用 Nx 的图来查看。我先用示例演示一下常见情形。

// 技术栈:Nx + TypeScript
// 文件:apps/demo/project.json(完整配置示例)
{
  "name": "demo",
  "projectType": "application",
  "targets": {
    "build": {
      // 注意:这里只写了依赖 prebuild,完全无视全局配置
      "dependsOn": ["prebuild"],
      "executor": "@nx/vite:build",
      "options": {
        "outputPath": "dist/demo"
      }
    },
    "prebuild": {
      "executor": "nx:run-commands",
      "options": {
        "command": "echo 'running prebuild'"
      }
    }
  }
}

看起来这个 demo 项目在构建前要跑一个 prebuild,这没问题。问题在于,如果 demo 依赖了一个共享库 shared-lib,而 shared-lib 也需要先构建才能被 demo 引用,那这里的 dependsOn 就应该包含 "^build"。现在没有包含,Nx 就不会保证 shared-lib:build 先执行,执行顺序自然就乱了。

4.2 第二步:手动梳理配置继承链

我通常会在本地写一个脚本来模拟合并结果,并且把这个脚本放在工作区根目录的 tools/scripts/print-effective-targets.ts,方便反复使用。脚本内容可以输出每个目标最终生效的 dependsOn,这样你就知道现场到底发生了什么。

// 技术栈:Nx + TypeScript
// 工具脚本:tools/scripts/print-effective-targets.ts
import { readFileSync, existsSync } from 'fs';
import * as path from 'path';

interface TargetDefaults {
  [targetName: string]: {
    dependsOn?: string[];
    cache?: boolean;
    executor?: string;
    options?: Record<string, unknown>;
  };
}

/**
 * 读取 nx.json 中的 targetDefaults
 */
function loadGlobalTargetDefaults(): TargetDefaults {
  const nxJsonPath = path.join(process.cwd(), 'nx.json');
  if (!existsSync(nxJsonPath)) {
    console.error('找不到 nx.json');
    process.exit(1);
  }
  const nxJson = JSON.parse(readFileSync(nxJsonPath, 'utf-8'));
  return nxJson.targetDefaults || {};
}

/**
 * 加载某个项目下的 project.json,返回 targets 配置
 */
function loadProjectTargets(projectName: string): TargetDefaults {
  const projectJsonPath = path.join(process.cwd(), 'projects', projectName, 'project.json');
  if (!existsSync(projectJsonPath)) {
    console.error(`找不到 ${projectName} 的 project.json`);
    process.exit(1);
  }
  const projectJson = JSON.parse(readFileSync(projectJsonPath, 'utf-8'));
  return projectJson.targets || {};
}

/**
 * 合并全局与项目配置,输出最终结果
 */
function printEffectiveTargets(projectName: string) {
  const globals = loadGlobalTargetDefaults();
  const locals = loadProjectTargets(projectName);

  const merged: TargetDefaults = {...globals};

  for (const [targetName, localConfig] of Object.entries(locals)) {
    // 这里执行“整体覆盖”逻辑,尤其注意 dependsOn 数组
    merged[targetName] = {
      ...(merged[targetName] || {}),
      ...localConfig,
    };
    // 打印结果
    console.log(`\n>>> ${projectName}:${targetName} final dependsOn:`);
    console.log(merged[targetName]?.dependsOn ?? '未定义,即不依赖任何东西');
  }
}

// 用法示例:node tools/scripts/print-effective-targets.ts demo
const projectName = process.argv[2];
if (!projectName) {
  console.error('请指定项目名,例如:node tools/scripts/print-effective-targets.ts demo');
  process.exit(1);
}
printEffectiveTargets(projectName);

这个脚本虽然简单,但非常直观。跑一遍之后你会立刻看到 demo:builddependsOn 已经变成了 ["prebuild"],全局配置被覆盖掉了。如果你期望它保留全局的 ^build,那这一步就能帮你锁定嫌疑。

4.3 第三步:用任务图验证全局顺序

配置合并只是第一步,真正影响执行顺序的是“任务图”。Nx 会根据每个任务的 dependsOn 构建一张有向无环图,然后按拓扑顺序执行。有时候配置看起来合理,但图标出来以后你会发现有环,或者有意外断链。

# 生成真实的任务图,不是示意图,是命令输出
nx graph --focus=my-app --file=my-app-graph.html

打开生成的 HTML 文件,你能看到 my-app 周围的所有任务连线。如果 my-app:buildshared-lib:build 之间没有箭头,那说明 dependsOn 里确实少了 ^build。这一步比查配置更有说服力,因为它是最终的执行依据。

4.4 第四步:模拟一次执行,观察实际顺序

光看图还不够,我们要跑一次真实的干跑,让 Nx 告诉我们它打算怎么执行。

# 使用 --dry-run 查看执行计划,不会真正执行命令
nx build my-app --dry-run

输出会列出所有任务以及它们的执行顺序。比如:

? 1. nx run shared-lib:build
? 2. nx run my-app:prebuild
? 3. nx run my-app:build

如果 shared-lib:build 没有出现在列表里,那就说明 my-app:build 根本没有依赖它。你就能确定问题是在 dependsOn 的配置上。利用这个干跑机制,你可以快速验证修改后的效果,而不用每次真的跑一遍完整构建,省时省力。

五、作用域边界的全面验证方法论

5.1 边界一:全局默认值的边界

targetDefaults 只对“未在项目里显式覆盖的字段”生效。如果你在 project.json 里写了 dependsOn,那么整个数组字段都会被项目覆盖。其他字段比如 cache,如果没有在项目里写,还会沿用全局的。所以作用域边界分两种情况:字段级覆盖,以及数组整体覆盖。建议在项目里尽量用“展开符”来保留全局依赖,比如这样写:

{
  "targets": {
    "build": {
      "dependsOn": ["^build", "lint", "prebuild"]
    }
  }
}

这样写等于手动把全局依赖重新声明一遍,虽然啰嗦,但避免了覆盖的坑。这个做法适合你能完全掌控全局依赖场景的团队。

5.2 边界二:项目间依赖的传播

Nx 项目之间的依赖关系在 package.json 或者 project.jsonimplicitDependencies 里声明。^build 的含义是“遍历当前项目直接或间接依赖的项目的 build 任务”。如果你没有正确声明项目依赖,那么哪怕 dependsOn 里有 ^build,也不会正确触发上游构建。所以查配置的同时还要检查依赖声明是否正确。

{
  "name": "my-app",
  "implicitDependencies": ["shared-lib"]
}

上面的配置表示 my-app 隐式依赖 shared-lib。如果这个依赖写错了,比如写成了 shared-lib2,那么 Nx 不知道 my-app 依赖了 shared-lib,自然也就不会把 shared-lib:build 放进任务图。这种情况同样会造成执行顺序异常。

5.3 边界三:命令行覆盖

即使 project.json 配置正确,命令行参数也可以临时改变行为。比如 nx run my-app:build --skip-nx-cache 不会影响依赖关系,但 nx run my-app:build --watch 可能隐含了一个先决条件。更常见的是 nx affected:build --base=main,它只跑受影响项目的构建,不会执行未受影响项目的构建。所以作用域边界还包括“你从哪个入口调用任务”。

# 只构建所有受影响的项目,这会影响任务图的根节点
nx affected:build --base=origin/main --head=HEAD

这句话意味着只有 git 变更波及到的项目会被执行。假设 shared-lib 没有变化,my-app 变了,那么只构建 my-app 及其依赖。这个“依赖”依然是依据任务图来的,如果 my-app:build 没有依赖 shared-lib:build,那么 Nx 就真的不会构建 shared-lib,哪怕它本身很需要。

5.4 边界四:缓存作用域

Nx 的远程缓存与本地缓存也会影响“执行”还是“跳过”。如果你看到某个任务被跳过,不一定是依赖顺序问题,可能是它符合缓存条件,直接复用了上一次的结果。在排查顺序异常时,记得先关掉缓存看看真实行为。

# 跳过所有缓存,强制重新执行
nx build my-app --skip-nx-cache

如果跳过缓存后顺序恢复正常,那其实不是配置覆盖问题,而是缓存命中让某些任务“看起来”没执行。但反过来,如果你改了 dependsOn,缓存键里包含了 dependsOn 相关内容,所以不用担心旧缓存污染新配置。只要配置变了,任务图也会相应变化。

六、应用场景与优缺点分析

6.1 适合用全局默认值的场景

当你拥有几十个结构相似的项目,并且希望所有项目遵循统一的构建纪律时,targetDefaults 非常有用。比如统一所有项目的 build 都要先跑 linttypecheck,你只需在 nx.json 里写一次,新项目加入后自动生效。优点是维护成本低,减少重复配置。

{
  "targetDefaults": {
    "build": {
      "dependsOn": ["lint", "typecheck"],
      "cache": true
    }
  }
}

这时候项目里不需要写 dependsOn,继承全局即可。这特别适合基础一致、没有特殊构建顺序要求的项目群。

6.2 适合用项目级覆盖的场景

当你有一个特别的项目,它的构建流程与其他项目差异非常大,无法复用全局配置时,才应该考虑项目级覆盖。比如某个项目需要先执行一个外部代码生成器,再执行构建。你可以在 project.json 里单独写 prebuild 并覆盖 dependsOn。但这样做有一个明显缺点:全局规则失效,很容易出现“其他项目都统一,就它特殊”的维护难题。覆盖不是不能用,而是要尽量缩小影响范围。

{
  "targets": {
    "build": {
      "dependsOn": ["codegen", "^build"],
      "cache": false
    }
  }
}

上面的写法保留了 ^build,只是在它前面加了 codegen,这算是比较友好的覆盖方式。它只改变了任务的顺序,没有把全局的依赖链删掉。

6.3 优缺点总结

全局默认配置的最大优点是统一性,缺点是最小调整可能引发大面积影响。项目级覆盖的优点是灵活,缺点是容易被误用导致执行顺序不可控。正确的姿势是尽量在全局维护基础规则,在项目中使用“展开+追加”的方式,比如显式重写 ^build,而不是完全丢掉它。这样既保留灵活性,又不破坏依赖链。

七、注意事项与实用建议

7.1 不要凭记忆判断,用命令查证

每次遇到顺序异常,第一反应不要去猜 dependsOn 里写了什么,而是直接用 nx show projectnx graph 去看。记忆会出错,但任务图不会。

7.2 写一个小工具来检查规则

我建议每个 Nx 工作空房里都放一个类似前面 print-effective-targets.ts 的工具,并且把它加入 CI 流程。如果某个项目的 build.dependsOn 不包含全局要求的必要依赖,就让构建失败。这能提前拦截配置覆盖错误。

# 在 CI 里运行检查脚本,如果项目配置不符合预期则报错
node tools/scripts/print-effective-targets.ts my-app | grep '^build'

7.3 保持 project.json 的简洁

当你发现 project.json 里的 targets 越来越多时,警惕配置冗余。很多时候你不需要重复定义 build,除非你确实要覆盖某些字段。如果只是修改输出目录,用 options 就够了,不要动 dependsOn

7.4 团队内部形成规范

在团队文档里明确写下“何时该动 nx.json,何时该动 project.json”。比如追加一个全局步骤,必须先讨论影响范围;修改某个项目的 dependsOn,必须说明为什么要破坏宿命规则。规范的目的是让“特殊”变得可追踪,而不是藏在某个文件里。

7.5 善用 dry-run 和缓存标志

在排查问题阶段,多使用 --dry-run--skip-nx-cache,它们能让你看到最真实的执行计划和顺序。等确认无误后再启用默认缓存,可以大幅提升开发效率。

八、总结

nx.json 的配置是“全局意志”,project.json 的配置是“局部特权”。这个特权的边界并没有想象中那么宽松,尤其是在数组字段上,你一旦写了 dependsOn,整个数组都会被替换,而不是合并。这个“整体替换”规则是很多执行顺序异常的核心来源。

排查的时候,要学会沿着“配置合并 → 项目依赖声明 → 任务图 → 实际执行”这条链路逐步验证。先用 show project 看结果,再用 graph 看连线,再用 dry-run 看计划,最后用 skip-nx-cache 排除干扰。每一步都有具体的命令可以查证,不需要瞎猜。

另外,我们还要记住,配置继承不是单纯的上线覆盖下线,而是分字段、分粒度的精确覆盖。理解了作用域边界,你会发现自己对任务的掌控力提升了不止一个档次。以后再看到哪个项目的构建乱序,你会迅速定位到是哪个文件、哪个字段在作怪,然后冷静地改掉它。

其实这类问题往往不复杂,难就难在“你以为你懂,但沈默的证据不这么认为”。希望这篇文章能让你在遇到类似问题时少走些弯路,多一搜查证手段,把执行顺序牢牢握在手里。