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 | 数组越界读成 undefined | views[id] 可能是 undefined |
noImplicitOverride | 覆写父类方法忘写 override | 组件里覆写 update 却没标注 |
noFallthroughCasesInSwitch | switch 分支忘了 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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。