《Cocos 游戏开发高级编程》10.3 工程规范与静态检查

规范靠自觉必然失效,必须钉进工具。本节把 ESLint、TypeScript 严格模式与对 cc.d.ts 的类型门禁组成三道静态检查:用 no-restricted-imports 守住 core 不碰引擎、用严格编译项挡住空值与索引越界、用 tsc --noEmit 校验每个 cc 符号,最后给出三道门禁在 CI 里的落点与失败判定。

10.3 工程规范与静态检查

上一节把依赖方向定成了规则。但规则写在文档里等于没有——三个月后新来的同事不会读那份文档,读了的也记不住。唯一可靠的办法是把规则交给工具:违反就报错,报错就卡住提交。本节讲三道静态检查,它们各管一段,合起来把 10.2 那些纪律真正「钉死」。

卷二 16.1 已经搭起了类型门禁与单测这两道门。这一节不复述怎么装 @cocos/creator-types,而是往上加一层:讲清为什么 strict 还不够、怎么用 ESLint 守住分层边界、以及三道门禁在 CI 里怎么组合成一道不可绕过的关卡。

10.3.1 三道静态检查,各挡什么

静态检查的价值在于在代码运行前就发现问题。三道检查的分工是这样:

检查工具挡什么失败判定
代码风格与边界ESLint跨层 import、命名、未用变量规则报 error
类型正确性tsc 严格模式空值、索引越界、隐式 any编译错误
引擎 API 正确性tsc + cc.d.ts用了不存在的 cc 符号编译错误

三道里有两道都靠 tsc,但它们不是同一件事:严格模式管的是「你的代码类型对不对」,类型门禁管的是「你调的引擎 API 存不存在」。前者是 TypeScript 自身的类型系统,后者是 cc.d.ts 这份外部声明的核对。分开理解,才知道报错该往哪里修。

10.3.2 第一道:TypeScript 严格模式

strict: true 是一组开关的集合,但它并不包含全部有价值的检查。下面这份配置在 strict 之外又开了四项:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "noEmit": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "types": ["@cocos/creator-types/engine"]
  },
  "include": ["assets/scripts"]
}

四个附加开关各自挡住一类真实 bug:

开关挡住什么例子
noUncheckedIndexedAccess数组越界读成 undefinedviews[id] 可能是 undefined
noImplicitOverride覆写父类方法忘写 override组件里覆写 update 却没标注
noFallthroughCasesInSwitchswitch 分支忘了 break状态机漏穿透
exactOptionalPropertyTypes可选属性被赋 undefined把 undefined 写进 ?: 字段

第一项和 10.1 的代码直接相关:BulletView 里写 const node = this.views[id]; if (!node) continue;,正是为了满足 noUncheckedIndexedAccess——开启它之后,任何数组下标访问都返回「可能 undefined」,逼你在编译期就把判空写了。 这类检查越早开越好,中途开会让存量代码一次报出成百上千条错误,那就很难推下去了。

experimentalDecorators 必须开,因为 @ccclass / @property 是装饰器;skipLibCheck 让 tsc 跳过检查 cc.d.ts 自身,只查你的代码,能省大量时间。

10.3.3 第二道:ESLint 守住分层边界

TypeScript 管不了「谁 import 谁」——core 里 import { Vec3 } from 'cc' 是完全合法的类型代码,但违反 10.2 的分层纪律。这类「架构规则」得交给 ESLint 的 no-restricted-imports。

下面这份是 flat config(ESLint 9 的格式),它只对 core/ 层加了一条限制:

// eslint.config.mjs
import tseslint from 'typescript-eslint';

export default tseslint.config(
  ...tseslint.configs.recommended,
  {
    files: ['assets/scripts/core/**/*.ts'],
    rules: {
      'no-restricted-imports': ['error', {
        paths: [{
          name: 'cc',
          message: 'core/ 层禁止 import cc:它必须能在 Node 里单测(见 10.2)',
        }],
      }],
    },
  },
);

files 把规则只作用在 core/ 目录上——game/ ui/ platform/ 照样可以 import cc,只有 core/ 不行。这正是分层纪律的精确表达:同一条 import 语句,在 core/ 里是错误,在 game/ 里是正常。 规则报 error 时 ESLint 退出码非 0,CI 就红。

跨层依赖(比如 core 依赖 game)也能用 no-restricted-imports 的 patterns 写进去,把 10.2 那张表逐行翻译成规则。能翻译成规则的纪律才是真纪律,翻译不了的,说明它还不够具体。

10.3.4 第三道:对 cc.d.ts 的类型门禁

这一道卷二 16.1 已经讲过原理:cc 的类型来自 @cocos/creator-types@3.8.8,tsc --noEmit 会拿 cc.d.ts 逐条核对代码里每个符号。版本必须与编辑器严格一致——用 3.8.8 的编辑器、装 3.7 的类型,会冒出「编辑器里能用、CI 报错」的假阳性。

这一节要补的是为什么它对 Cocos 特别重要。别的框架,API 名字写错通常在运行时报 undefined is not a function,堆栈直指问题行;而 Cocos 的组件是装饰器注册的,@property(Label) 里写个不存在的类型,错误可能拖到场景加载时才以一句模糊的警告出现。类型门禁把这类错误提前到了编译期,代价只是一次 tsc --noEmit。

它同时也是本书「每个 cc 符号先查 cc.d.ts」那条铁律的自动化版本:人可能凭记忆写错 API 名,cc.d.ts 不会。写正文时用 ccgrep.sh 查符号,落到工程里就是这道门禁在替你查。

10.3.5 写一个纯 TS 的分层边界检查器

ESLint 能管 import,但它读的是语法树,管不了「一个文件属于哪一层」这种工程语义。当你想检查「core 目录里有没有依赖 game」时,写一个读文件路径与依赖列表的纯函数反而更直接。它属于工程脚手架,不 import cc:

// scripts/analyze/layer-rules.ts —— 纯 TypeScript,不 import 'cc'
export type Layer = 'core' | 'game' | 'ui' | 'platform';

const FORBIDDEN: Record<Layer, Layer[]> = {
  core: ['game', 'ui', 'platform'],
  game: ['ui'],
  ui: [],
  platform: ['game', 'ui'],
};

export function layerOf(file: string): Layer | null {
  const match = /assets\/scripts\/(core|game|ui|platform)\//.exec(file);
  return match ? (match[1] as Layer) : null;
}

export function forbiddenImports(file: string, deps: string[]): string[] {
  const from = layerOf(file);
  if (!from) return [];
  return deps.filter((dep) => {
    const to = layerOf(dep);
    return to !== null && FORBIDDEN[from].includes(to);
  });
}

export function coreImportsEngine(file: string, source: string): boolean {
  return layerOf(file) === 'core' && /from\s+['"]cc['"]/.test(source);
}

FORBIDDEN 这张表就是 10.2 那张依赖表的代码化。layerOf 从路径里认出层名,forbiddenImports 检查一组依赖里有没有违规的,coreImportsEngine 单独抓「core 碰引擎」这条最关键的。三端复用同一份规则:ESLint 里写一份给人看,这个检查器在构建脚本里再跑一遍兜底。

10.3.6 用 vitest 验证它

规则代码也要测试——不然检查器自己写错了,会误报或漏报。三条断言覆盖违规、合法、与 core 碰引擎三种情况:

import { describe, expect, it } from 'vitest';
import { coreImportsEngine, forbiddenImports } from '../scripts/analyze/layer-rules';

describe('layer-rules', () => {
  it('core 依赖 game 属于违规', () => {
    expect(forbiddenImports('assets/scripts/core/a.ts', ['../../game/b.ts']))
      .toEqual(['../../game/b.ts']);
  });

  it('ui 依赖 game 是合法的', () => {
    expect(forbiddenImports('assets/scripts/ui/c.ts', ['../game/b.ts'])).toEqual([]);
  });

  it('core 里 import cc 被判定违规', () => {
    const src = "import { Vec3 } from 'cc';";
    expect(coreImportsEngine('assets/scripts/core/math.ts', src)).toBe(true);
    expect(coreImportsEngine('assets/scripts/game/player.ts', src)).toBe(false);
  });
});

这三条跑在 Node 里,几毫秒出结果。把它们并进单测套件,规则本身也有了回归保护——哪天有人改 FORBIDDEN 表改错了,测试会先炸。

10.3.7 一段能过三道门的组件脚本

把三道门禁合起来,一段合格的引擎脚本长这样:

import { _decorator, Component, Label, Vec3 } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('HudView')
export class HudView extends Component {
  @property(Label)
  scoreLabel: Label | null = null;

  private readonly tmp = new Vec3();

  setScore(value: number): void {
    const label = this.scoreLabel;
    if (!label) {
      return;
    }
    label.string = `得分 ${value}`;
  }

  moveBy(dx: number, dy: number): void {
    this.tmp.set(dx, dy, 0);
    this.node.setPosition(this.tmp);
  }
}

它过了三道门:Label、Vec3、setPosition 都在 cc.d.ts 里存在(类型门禁);scoreLabel 声明为 Label | null 并做了判空(严格模式);tmp 复用而不是每帧 new(对应 7.1 的分配纪律)。注意 moveBy 用的是成员 tmp 而不是临时对象——严格模式管不了这个,它靠的是人记住「热路径别分配」,这正是下一节 11 章要横向对比的、各引擎都需要开发者自觉的一类纪律。

10.3.8 把三道门禁落进 CI

三道检查写进 CI,合并前自动跑:

name: quality
on:
  pull_request:
    paths: ["assets/**", "scripts/**", "tsconfig.json", "eslint.config.mjs", "package.json"]
jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: lint(分层边界)
        run: npx eslint "assets/scripts/**/*.ts"
      - name: typecheck(对 cc.d.ts)
        run: npx tsc --noEmit
      - name: test(L1 纯逻辑)
        run: npx vitest run --reporter=dot

三个 step 独立判定:lint 挂了不会短路后面的 typecheck,三个都跑完才给结论,PR 检查项里能一眼看出坏在哪一道。paths 过滤让只在改代码或配置时触发,文档改动不跑。

失败判定就是进程退出码:ESLint 有 error 退出码非 0,tsc 有类型错误非 0,vitest 有断言失败非 0。三者任一非 0,job 变红,PR 不允许合并。这套机制没有任何「可绕过」的余地——这才是「钉死」的含义。

10.3.9 本地门禁与提交钩子

只在 CI 跑是不够的:开发者要等 CI 回来才知道错了,反馈太慢。本地也挂一道,用同一组命令:

# package.json 里定义统一入口
npm run lint        # eslint "assets/scripts/**/*.ts"
npm run typecheck   # tsc --noEmit
npm run test        # vitest run

# 提交前自动跑(husky + lint-staged 示例)
# 任一命令失败则拒绝提交

两条纪律:一是本地与 CI 用同一组命令,否则会出现「本地过、CI 挂」;二是格式交给 Prettier、规则交给 ESLint,别让两者互相打架——Prettier 管缩进引号,ESLint 管逻辑与边界,各司其职。再加一份 .editorconfig 统一换行与缩进,跨编辑器协作时少一类无意义的 diff。

10.3.10 工程决策

  • 规范必须由工具强制,不能靠自觉。 文档里的规则等于没有,CI 里的规则才算数。
  • strict 只是起点。 再开 noUncheckedIndexedAccess 等四项,能在编译期挡住一批运行时崩溃。
  • 分层边界用 ESLint 精确到目录。 同一条 import 在 core/ 报错、在 game/ 放行。
  • 类型门禁必须锁死版本。 @cocos/creator-types 与编辑器版本一致,否则假阳性不断。
  • 本地与 CI 同一组命令。 反馈越快,规则越容易被接受。
  • 规则代码也要测。 检查器自己写错会误报,把它当业务代码一样配单测。

三节下来,架构这一章讲完了:10.1 划了数据层与视图层的边界,10.2 定了模块之间的依赖方向,10.3 把这些边界与方向钉进了工具。从「知道该怎么分」到「分了之后不会退化」,靠的就是这一章的三件东西。

下一章是本卷的收官。我们会把 Cocos 和 Godot、Phaser、Defold 摆在一起横向对照——同样一件事,四款引擎分别怎么做、代价在哪、什么时候该换。

阅读导航:上一节:10.2 大型项目模块化 · 下一节:11.1 跨引擎对照:Cocos / Godot / Phaser / Defold 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画