《Cocos 游戏开发高级编程》1.3 从 cc.d.ts 读引擎 API 版图

把 cc.d.ts 当权威字典用:讲清 declare module "cc" 的顶层骨架、617 条导出按形态的分布,给出可复现的 grep 查法,并用一段纯 TS 的导出扫描器把这套查法写成可单测的代码,最后演示如何从枚举反推引擎的能力边界。

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 条):

导出形态条数代表你怎么用
class252(含 3 个 abstract)Node、Director、Componentnew 或继承
import(别名)105命名空间再导出一般不直接用
const72director、game、sys、macro单例,直接读
function50instantiate、tween、log直接调用
namespace48_decorator、sys取成员
interface36ISchedulable、Macro只作类型
enum33NodeEventType、Platform取枚举值
type16类型别名只作类型
let3可变导出少用

这张表本身就是结论:引擎给用户用的大部分是「类 + 单例常量」(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.platformnew 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 的用法不复杂,但要把三件事变成肌肉记忆:

  1. 写任何 cc 符号前先 grep。本卷的写作规范就是「每个 cc 符号必须先查再写」,你在项目里也该这样——尤其在用 AI 补全时,AI 可能给你一个看起来合理但根本不存在的 API 名。
  2. 遇到「类型不对」先看接口定义。@property 的选项、sys 的成员、事件回调签名,答案都在声明里,不在博客里。
  3. 升级前先 diff 声明。把两个版本的 cc.d.ts 各跑一遍 1.3.7 的扫描器,对比导出清单的差集,就是你的迁移风险清单。

最后给一个落地建议:把 1.3.4 的两条 grep 命令存成 shell 别名,或者在项目里放一个 scripts/check-cc-api.sh。当你或同事想用一个不确定的 API 时,先跑一遍——三十秒的确认,能省下半小时的「运行时 undefined」排查。本卷后面每一章引用 cc 符号时,用的都是同一套动作。

下一章开始进入渲染——那是引擎最粗、也最常被误解的一条主线。我们会先看清默认管线怎么组织,再看可插入点在哪。

阅读导航:上一节:1.2 启动流程与主循环 · 下一节:2.1 渲染管线与自定义管线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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