3.1 @ccclass / @property 与类型系统
卷一 6.3 我们让 mini2d 从 JSON 里读组件参数,走的是「手写序列化器 + 字段名约定」这条路,读者得自己维护字段表和默认值。Cocos Creator 把这一步做成了语言级设施:用装饰器把「类的身份」和「字段的类型」声明给引擎,编辑器据此生成属性面板,运行时据此反序列化场景与预制体。这一节把这两个装饰器的边界讲清楚,它们是卷二全部组件脚本的地基。
与站内 game/client/ 那批 Godot/Phaser 工程文的区别在于:那边讲的是「组件怎么写」,这里讲的是**「编辑器凭什么认识你写的组件」**——.scene 里存的不是你的字段名,而是 __type__ 加一串序列化后的值,中间那层翻译全靠本节的两个装饰器。
3.1.1 @ccclass 到底注册了什么
@ccclass 把普通 TS 类登记进 Cocos 的序列化器注册表。注册之后引擎才知道:这个类的名字是什么、它有哪些可序列化字段、字段类型是什么。没有这一步,.scene 里 "__type__": "BlockDashPlayer" 这一项就无法还原成对象。
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('BlockDashPlayer')
export class BlockDashPlayer extends Component {
@property
speed = 320;
}
@ccclass('BlockDashPlayer') 里的字符串就是注册名。它有两个必须记住的约束:
- 注册名全局唯一。两个不同目录下的脚本都叫
Player,后加载的会覆盖前者,症状是「属性面板上挂的是 A,运行时跑的是 B」。 - 不传名字时用类名,但类名会被压缩混淆(构建时的
--minify或引擎的压缩选项会改函数名),所以正式项目一律显式传名字。
还有一个容易踩的点:只有继承自 Component 的类才能被挂到节点上。继承自普通类(比如一个 LevelConfig)也可以用 @ccclass 注册,但那只是为了让它能被序列化成资源字段,不能 addComponent。
3.1.2 为什么必须显式标注 CCInteger / CCFloat
TypeScript 的类型是编译期概念,经过类型剥离后运行时完全看不到 number、boolean、string 的区分——它们在 JS 里就是 number、boolean、string 三个值,而 number 分不出整数和浮点。属性面板要渲染成「整数输入框」还是「滑动条」,必须由开发者显式告诉引擎,这就是四个 CC* 标记存在的理由:
import { _decorator, Component, CCInteger, CCFloat, CCBoolean, CCString } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('TypeDemo')
export class TypeDemo extends Component {
@property(CCInteger) hp = 3;
@property(CCFloat) speed = 320.5;
@property(CCBoolean) invincible = false;
@property(CCString) label = 'player';
}
| 标注 | 面板控件 | 不标的后果 |
|---|---|---|
CCInteger | 整数输入框 | 面板显示为普通数字框,可输入 3.7 |
CCFloat | 数字输入框 | 与整数框无法区分,滑动条步长失控 |
CCBoolean | 勾选框 | 被当成数字,面板显示 0 / 1 |
CCString | 单行文本框 | 被当成未知类型,字段直接不显示 |
一个反直觉的结论:不标类型不等于报错,而是「静默按默认推断」。推断规则是看默认值——默认值是 3 就当整数,是 'player' 就当字符串。所以「能跑」不代表「面板正确」,团队里最好把「所有 @property 必须带类型」写进代码评审清单。
3.1.3 引用类型、数组与内置值类型
引用类型(Node / Prefab / SpriteFrame / AudioClip)直接把类构造函数传给 @property,面板会渲染成一个拖拽槽;数组写成 { type: [X] },槽位可增删。Color、Vec3 这类内置值类型同样可以直接标注:
import { _decorator, Component, Node, Prefab, SpriteFrame, AudioClip, Color, Vec3 } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('RefDemo')
export class RefDemo extends Component {
@property(Node) groundCheck: Node | null = null;
@property(Prefab) coinPrefab: Prefab | null = null;
@property({ type: [SpriteFrame] }) frames: SpriteFrame[] = [];
@property({ type: [AudioClip] }) sfx: AudioClip[] = [];
@property(Color) tint = new Color(255, 255, 255, 255);
@property(Vec3) spawn = new Vec3(0, 0, 0);
}
三条实践约定,卷二全程遵守:
- 引用类型默认值一律写
null,别写new Node()。new Node()会真的造一个游离节点并序列化进场景,运行时多一个看不见的节点,还占用内存。 - 数组默认值写
[],不要写null。面板上的「加号」按钮只在数组类型上出现,null会让面板退化成单槽。 - 拖拽槽里的资源会被场景自动引用(引用计数 +1)。「首包只含启动场景与主 UI」这条约束(见 4.1 资源目录与 Bundle )的实现前提就是:关卡贴图不要拖进主场景的组件槽。
3.1.4 枚举:在 erasable syntax 下不要用 TS enum
卷二全书的 TS 限 erasable syntax(Node 26 只做类型剥离,不做代码转换),这意味着 enum、namespace、构造函数参数属性都不能用。Cocos 提供了替代品 Enum(),它在运行时生成一个带反向映射的普通对象,属性面板据此渲染成下拉框:
import { _decorator, Component, Enum } from 'cc';
const { ccclass, property } = _decorator;
export const Direction = Enum({
Left: 0,
Right: 1,
});
@ccclass('DirDemo')
export class DirDemo extends Component {
@property({ type: Enum(Direction) }) dir = Direction.Right;
}
如果不想引入 Enum,用 as const 对象加字面量联合类型也能通过类型检查,只是属性面板会退化成数字框:
export const Facing = { Left: 0, Right: 1 } as const;
export type FacingValue = typeof Facing[keyof typeof Facing];
选哪个取决于团队要不要「面板可读性」。卷一里我们用纯数字常量,卷二因为有策划要看面板,统一用 Enum()。
3.1.5 属性选项与属性面板
@property 接受一个选项对象,常用字段如下(全部来自 cc.d.ts 的 IExposedAttributes):
| 选项 | 作用 | 面板表现 |
|---|---|---|
type | 显式类型(含数组) | 决定控件种类 |
displayName | 面板显示名 | 替代字段名,可写中文 |
tooltip | 悬停提示 | 鼠标停留时弹出 |
range: [min, max, step] | 数值区间 | 出现滑动条 |
slide | 是否用滑动条 | 与 range 配合 |
min / max / step | 数值边界 | 输入越界被夹紧 |
visible | 是否显示 | 传 false 隐藏,传函数可动态控制 |
serializable | 是否参与序列化 | false 时构建后不存盘 |
formerlySerializedAs | 字段曾用名 | 迁移老场景数据用 |
editorOnly | 仅编辑器生效 | 构建后字段被剥离 |
import { _decorator, Component, CCInteger } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('OptionDemo')
export class OptionDemo extends Component {
@property({
type: CCInteger,
displayName: '空中跳跃次数',
tooltip: '落地前允许的二段跳次数',
range: [0, 3, 1],
slide: true,
min: 0,
max: 3,
step: 1,
formerlySerializedAs: 'jumpTimes',
})
airJumps = 1;
@property({ type: CCInteger, visible: false, serializable: true })
internalSeed = 0;
}
formerlySerializedAs 是卷二升级存档和场景时的救火工具:把 jumpTimes 改名成 airJumps 后,老 .scene 里存的仍是 jumpTimes,加上这一行引擎就会按旧名读取,避免「改个字段名,全部关卡预制体参数清零」。
3.1.6 在编辑器里挂组件(需编辑器,不可命令行复现)
本节涉及 .scene 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 下面是可照做的步骤:
- 在层级管理器选中
assets/scenes/main.scene里的Player节点。 - 属性检查器底部点「添加组件」→ 搜索
BlockDashPlayer→ 点击。 - 面板上应出现:
Speed(数字框,值320)、Air Jumps(滑动条,值1)、Ground Check(拖拽槽,空)。 - 把
Ground子节点拖进Ground Check槽。
保存后 main.scene 里该节点会出现对应的 _components 项。结构大致如下:
{
"__type__": "cc.Node",
"_name": "Player",
"_components": [
{ "__type__": "cc.UITransform" },
{ "__type__": "BlockDashPlayer", "speed": 320, "airJumps": 1 }
]
}
打开 main.scene 会看到:层级管理器里 Player 节点旁多了一个脚本图标,属性检查器里它的 BlockDashPlayer 分组下是 Speed、Air Jumps、Ground Check 三个字段。
注意 __type__ 用的是 @ccclass 注册名(编辑器里会转成脚本的 UUID 形式),不是文件名,也不是 TS 类名。这也是 3.1.1 强调「注册名唯一」的原因:改注册名等于改存盘格式,老场景会全部丢失组件引用。
3.1.7 一个完整组件:从字段到面板
把前面几节的规则合起来,就是《方块冲刺》里最基础的玩家组件。它同时用到基础类型、引用类型、数组、枚举和选项对象:
import { _decorator, Component, Node, Prefab, AudioClip, CCInteger, CCFloat, Enum, SpriteFrame } from 'cc';
const { ccclass, property, menu, disallowMultiple } = _decorator;
export const MoveState = Enum({ Idle: 0, Run: 1, Jump: 2 });
@ccclass('PlayerController')
@menu('BlockDash/PlayerController')
@disallowMultiple
export class PlayerController extends Component {
@property({ type: CCFloat, range: [200, 600, 10], slide: true })
runSpeed = 320;
@property({ type: CCInteger, displayName: '二段跳次数', min: 0, max: 2 })
airJumps = 1;
@property(Node) groundCheck: Node | null = null;
@property({ type: [SpriteFrame], tooltip: '跑动序列帧' })
runFrames: SpriteFrame[] = [];
@property({ type: AudioClip }) jumpSfx: AudioClip | null = null;
@property({ type: Prefab }) dustPrefab: Prefab | null = null;
@property({ type: Enum(MoveState) }) state = MoveState.Idle;
}
在编辑器里挂上之后,属性面板从上到下应显示:Run Speed(滑动条,值 320,范围 200–600)、二段跳次数(整数框,值 1)、Ground Check(空拖拽槽)、Run Frames(空数组,带加号)、Jump Sfx(空拖拽槽)、Dust Prefab(空拖拽槽)、State(下拉框,选中 Idle)。
检查清单(写完组件后逐条过):
| 检查项 | 通过标准 |
|---|---|
每个 @property 都有类型 | 面板控件正确,不是「按默认值猜」 |
引用类型默认值是 null | 不会造出游离节点 |
数组默认值是 [] | 面板有加号,可增删槽位 |
@ccclass 传了显式名字 | 构建压缩后 __type__ 不丢 |
没有 enum / namespace | 满足 erasable syntax |
3.1.8 类型系统即门禁
本卷所有 import ... from 'cc' 的片段都会被抽出来过一遍 tsc --noEmit,类型声明来自真实的 @cocos/creator-types@3.8.8。这意味着引擎里不存在的符号会当场编译失败——cc.d.ts 是唯一真相,任何凭记忆写出来的 API 名都要先查证。这也解释了为什么本节每个例子都写全 import:片段是可独立编译的,读者可以直接粘进工程。
另一个副作用是:@property({ type: X }) 里的 X 必须是 cc.d.ts 里真实导出的构造器。写 @property({ type: LevelConfig }) 而 LevelConfig 是自定义类时,只要那个类被 @ccclass 注册过就没问题;写 @property({ type: 'SpriteFrame' }) 这种字符串形式虽然也支持,但拼错时不会有类型报错,属于要避免的写法。
下一节我们让这些组件真正「活」起来,讲清 onLoad / start / update 的调用顺序,以及引擎一帧里到底按什么次序调度它们。
阅读导航:上一节:目录 · 下一节:3.2 生命周期与执行顺序 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。