1.3 从 cc.d.ts 读引擎 API 版图
前两节讲了引擎怎么分层、主循环怎么跑。这一节换个角度:你怎么知道引擎到底提供了什么。
答案是一份文件——@cocos/creator-types 里的 engine/cc.d.ts。它是引擎 API 的权威声明,也是你在编辑器里写 import { X } from 'cc' 时补全和类型检查的来源。本卷反复强调「核不到的不写」,核对的终点就是它。本节教你三件事:这份文件长什么样、怎么快速查一个符号、怎么从它的结构反推引擎的能力边界。
1.3.1 cc.d.ts 是什么、从哪来
cc.d.ts 不是手写的文档,而是从引擎源码自动生成的类型声明。它的版本与引擎版本严格对应:Cocos Creator 3.8.8 对应 @cocos/creator-types@3.8.8,本节所有行号与统计都以这份 75874 行的声明为准。
这一点很重要:声明里有的,运行时一定有;声明里没有的,别指望它存在。所以当你看到某篇博客写了个你没见过的 API,第一反应不该是照抄,而是去 cc.d.ts 里 grep 一下。查得到才敢用。
声明文件的生成是引擎构建流程的一部分,你不需要手工维护它——升级引擎版本时,配套的声明也会一起更新。这正是本卷能放心地把「3.8.8 的声明」当成版图的原因:它与同版本的运行时是一体的,不存在「文档写了但代码没实现」的错位。
1.3.2 顶层骨架:declare module “cc”
整个文件被一个模块声明包住,第 1 行就是:
declare module "cc" {
// ... 数千个 export ...
}
你在脚本里写的 import { director } from 'cc',导入的正是这个模块声明里的导出。模块体缩进 4 空格,所有顶层导出都以 export 开头——这个缩进就是「是不是顶层导出」的判据,1.3.7 的扫描器会用到。
文件末尾还有一个 __private 命名空间(约在第 61433 行),里面是引擎的内部实现类型。看到 __private 前缀的类型,就当它是私有的:可以读,但不要在你的脚本里 import,因为它不保证跨版本稳定。你在 1.2 里看到的 updateInvoker、IPropertyOptions 这类名字,很多最终都指向 __private。
再强调一次缩进规则,因为它决定了 1.3.7 扫描器的正确性:
export class Node ← 4 空格 = 顶层导出,可 import { Node } from 'cc'
export const ccclass ← 8 空格 = 命名空间成员,不能单独 import
顶层的 Node 可以直接 import { Node } from 'cc';而 _decorator 里的 ccclass 不能单独 import,必须先 import { _decorator } 再解构。「能不能单独 import」这条界线,就是缩进界线。
1.3.3 API 版图:导出形态普查
把 3.8.8 的 cc.d.ts 里所有 4 空格缩进的顶层 export 语句数一遍,得到这张版图(共 617 条):
| 导出形态 | 条数 | 代表 | 你怎么用 |
|---|---|---|---|
class | 252(含 3 个 abstract) | Node、Director、Component | new 或继承 |
import(别名) | 105 | 命名空间再导出 | 一般不直接用 |
const | 72 | director、game、sys、macro | 单例,直接读 |
function | 50 | instantiate、tween、log | 直接调用 |
namespace | 48 | _decorator、sys | 取成员 |
interface | 36 | ISchedulable、Macro | 只作类型 |
enum | 33 | NodeEventType、Platform | 取枚举值 |
type | 16 | 类型别名 | 只作类型 |
let | 3 | 可变导出 | 少用 |
这张表本身就是结论:引擎给用户用的大部分是「类 + 单例常量」(252 个类、72 个常量),函数式 API 只占一小块。所以 Cocos 的编程模型是「面向对象 + 全局单例」,而不是函数式——你在卷二写组件时的直觉,根源就在这里。
1.3.4 可复现的查法
查一个符号存不存在,用两条命令就够。先精确匹配声明行:
grep -nE '^\s{4}export (declare )?(abstract )?(class|interface|type|function|const|let|var|enum|namespace) 符号名\b' \
engine/cc.d.ts
再用宽松匹配看所有提到它的地方:
grep -nE 'export.*\b符号名\b' engine/cc.d.ts
实际查几个例子:
grep -nE '^\s{4}export (class|const|function) (director|game|sys|tween|instantiate)\b' engine/cc.d.ts
输出会是 export const director: Director;、export const game: Game;、export const sys: {...} 这样的行。能 grep 到声明行,就说明符号存在——这比搜索引擎可靠,因为它是版本的唯一事实来源。
查一个类有哪些成员,用另一种正则。例如确认 1.2 里引用过的 Director 方法:
grep -nE '^\s+(public |protected |private )?(tick|getTotalFrames|getScheduler)\s*\(' engine/cc.d.ts
会看到 tick(dt: number): void;、getTotalFrames(): number;、getScheduler(): Scheduler; 三行。先 grep 到声明、再写进正文,这就是本卷对每个 cc 符号的做法;你写业务代码时也该这样,尤其是方法名容易记混的地方(getTotalFrames 不是 getFrameCount)。
如果本机装了 Cocos Creator 3.8,cc.d.ts 在编辑器的资源目录里;如果没装,可以 npm i -D @cocos/creator-types@3.8.8,声明文件就在 node_modules/@cocos/creator-types/engine/cc.d.ts。本卷的 L2 代码就是用后者做 tsc --noEmit 类型检查的。
1.3.5 导出形态决定用法
同一个名字,形态不同,用法完全不同。这是初学者最容易踩的坑:
| 你看到的形态 | 含义 | 正确用法 | 错误用法 |
|---|---|---|---|
export class Node | 类 | new Node() / 继承 | 当函数调 |
export const director | 单例 | director.tick(...) | new director() |
export function tween | 函数 | tween(node) | tween.xxx |
export interface Macro | 纯类型 | const m: Macro | 运行时 Macro.xxx |
export enum NodeEventType | 枚举 | NodeEventType.TOUCH_START | 当字符串拼 |
export namespace sys | 命名空间 | sys.platform | new sys() |
最容易出错的是接口:export interface Macro 只是类型,编译后不存在,运行时访问会直接报 undefined。类型和值的分界线,就是「它是不是 interface / type」。
1.3.6 _decorator:唯一的装饰器命名空间
_decorator 是一个特殊成员——它是个 namespace,装着你写组件脚本时用到的所有装饰器。它的成员在 cc.d.ts 的 export namespace _decorator 里能数全:ccclass、property、requireComponent、executionOrder、disallowMultiple、executeInEditMode、menu、playOnFocus、inspector、icon、help、type、integer、float、boolean、string、uniquelyReferenced。
标准写法是先解构再使用:
import { _decorator, Component, Node } from 'cc';
const { ccclass, property, executionOrder, menu } = _decorator;
@ccclass('EnemySpawner')
@executionOrder(-1)
@menu('BlockDash/EnemySpawner')
export class EnemySpawner extends Component {
@property({ type: Node })
spawnPoint: Node | null = null;
@property({ type: Number, tooltip: '每秒生成数量', min: 0 })
ratePerSecond = 2;
}
@property 的选项不是猜的——它在 cc.d.ts 里对应 IExposedAttributes,成员包括 type、visible、displayName、displayOrder、tooltip、group、min、max、step、range、slide、readonly 等。你写 @property({ tooltip: ... }) 能过类型检查,就是因为这个接口里有 tooltip。要加新选项,先去这个接口里确认它存在。
1.3.7 写一个导出扫描器(L1)
既然「顶层导出」可以用「4 空格缩进」判定,就能把 1.3.4 的查法写成代码。下面这个纯 TS 扫描器不依赖 cc,输入一段声明文本,输出所有顶层导出的名字与形态:
// dts-scan.ts —— 纯逻辑,无 cc 依赖
export type ExportKind =
| 'class' | 'interface' | 'function' | 'const'
| 'namespace' | 'type' | 'enum';
// 只匹配 4 空格缩进的顶层导出;更深缩进的成员不收录
const LINE = /^ {4}export\s+(?:declare\s+)?(?:abstract\s+)?(class|interface|function|const|let|var|namespace|type|enum)\s+([A-Za-z_$][\w$]*)/;
export interface ExportedSymbol {
kind: ExportKind;
name: string;
}
export function scanExports(source: string): ExportedSymbol[] {
const out: ExportedSymbol[] = [];
for (const line of source.split('\n')) {
const m = LINE.exec(line);
if (!m) continue;
const raw = m[1];
const kind: ExportKind = raw === 'let' || raw === 'var' ? 'const' : (raw as ExportKind);
out.push({ kind, name: m[2] });
}
return out;
}
配 vitest 断言。关键是用小样本 fixture 验证「只收顶层」这条规则,而不是去读 3.3 MB 的大文件:
// dts-scan.test.ts
import { describe, expect, it } from 'vitest';
import { scanExports } from './dts-scan';
const FIXTURE = [
'declare module "cc" {',
' export class Node extends CCObject {',
' export interface ISchedulable {',
' export function log(...data: unknown[]): void;',
' export const director: Director;',
' export type Feature = string;',
// 下面这行缩进 8 空格,模拟命名空间内部的成员,不应被当成顶层导出
' export const ccclass: ClassDecorator;',
'}',
].join('\n');
describe('scanExports', () => {
it('只收录顶层 4 空格缩进的导出', () => {
const names = scanExports(FIXTURE).map((s) => s.name);
expect(names).toEqual(['Node', 'ISchedulable', 'log', 'director', 'Feature']);
});
it('缩进更深的成员不会被当成顶层导出', () => {
const names = scanExports(FIXTURE).map((s) => s.name);
expect(names).not.toContain('ccclass');
});
it('正确识别导出形态', () => {
const byName = Object.fromEntries(scanExports(FIXTURE).map((s) => [s.name, s.kind]));
expect(byName['Node']).toBe('class');
expect(byName['ISchedulable']).toBe('interface');
expect(byName['director']).toBe('const');
expect(byName['Feature']).toBe('type');
});
});
把真实的 cc.d.ts 喂给它,你会得到 1.3.3 那张表——统计不是背下来的,是跑出来的。这就是「可复现查法」的含义。
1.3.8 从枚举反推能力边界
枚举是最有价值的一类导出,因为枚举成员往往就是「引擎支持什么」的清单。三个例子:
sys.Platform:二十余个成员,从DESKTOP_BROWSER到WECHAT_GAME、BYTEDANCE_MINI_GAME、HUAWEI_QUICK_GAME——这就是 3.8.8 官方支持的发布目标全集。NodeEventType:TOUCH_START、TOUCH_MOVE、TOUCH_END、TOUCH_CANCEL、MOUSE_DOWN……这就是节点能监听的事件全集。_decorator里的property选项枚举:min/max/step决定了数值属性的编辑器表现。
看枚举的另一个用处是版本考古:当你在一份旧代码里看到某个枚举成员,去 3.8.8 的 cc.d.ts 里搜不到,就知道它在升级中被移除了——这比对着 changelog 逐条读快得多。第 9 章讲升级时会再回到这个方法。
枚举之外,const 形态的 macro 也值得一看:macro.REPEAT_FOREVER、macro.ENABLE_MULTI_TOUCH 这类常量决定了定时器与输入的行为开关。常量表就是引擎的「默认值清单」,调参时先查它有没有现成的开关,比在代码里硬编码数字更稳。
1.3.9 工程决策:把查 d.ts 变成习惯
cc.d.ts 的用法不复杂,但要把三件事变成肌肉记忆:
- 写任何
cc符号前先 grep。本卷的写作规范就是「每个cc符号必须先查再写」,你在项目里也该这样——尤其在用 AI 补全时,AI 可能给你一个看起来合理但根本不存在的 API 名。 - 遇到「类型不对」先看接口定义。
@property的选项、sys的成员、事件回调签名,答案都在声明里,不在博客里。 - 升级前先 diff 声明。把两个版本的
cc.d.ts各跑一遍 1.3.7 的扫描器,对比导出清单的差集,就是你的迁移风险清单。
最后给一个落地建议:把 1.3.4 的两条 grep 命令存成 shell 别名,或者在项目里放一个 scripts/check-cc-api.sh。当你或同事想用一个不确定的 API 时,先跑一遍——三十秒的确认,能省下半小时的「运行时 undefined」排查。本卷后面每一章引用 cc 符号时,用的都是同一套动作。
下一章开始进入渲染——那是引擎最粗、也最常被误解的一条主线。我们会先看清默认管线怎么组织,再看可插入点在哪。
阅读导航:上一节:1.2 启动流程与主循环 · 下一节:2.1 渲染管线与自定义管线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。