☰
Cocos Creator 编辑器扩展开发实战:从零写一个批量尺寸修改面板
2026/10/20 5:23:34 网站建设 项目流程

前一阵做 UI 密集型的项目,美术提了个需求:一堆节点要统一宽高、统一间距、批量改名,几十个节点在属性检查器里挨个点,整个人都快点麻了。当时我就想,这种重复劳动为什么不直接写个编辑器扩展面板收掉?于是花了一个周末研究 Cocos 扩展插件,做出了一个批量尺寸修改面板。这类事情如果你也遇到过,并且对 Cocos Creator 的编辑器扩展开发感兴趣,这篇文章应该能帮你少走不少弯路。

我会把整个思考过程、配置细节、完整代码、以及踩过的坑全部记录下来,从 package.json 的字段含义讲起,到消息机制、面板开发、场景脚本,再到构建钩子和右键菜单等进阶玩法。所有示例代码都是可以直接抄走改的,版本以 Cocos Creator 3.8 为基准。

1. 扩展插件的整体思路:编辑器到底给你留了哪些“缝”

1.1 扩展插件的本质:一个被编辑器托管的子应用

很多人第一次接触“扩展插件”这个概念,会被一堆术语吓住。其实往简单了说,Cocos Creator 编辑器本身是一个基于 Electron 的桌面应用,扩展插件就是往这个应用里塞进去的一个“小程序”。它有自己的入口文件、自己的界面、自己能处理的消息,只是生命周期和启动方式都由编辑器统一管理。

Cocos 的扩展插件不需要你重新编译编辑器,也不需要魔改引擎,你只要按约定放好目录结构、写好 package.json 和入口脚本,编辑器启动时就会自动扫描并加载。它背后其实是一个独立的进程,和编辑器主进程、场景进程之间通过消息系统通信。

想清楚这一点,很多问题就迎刃而解了。比如为什么插件代码改了有时候不生效?因为这是一个独立进程,你得重新加载扩展才能让它重新执行。为什么插件里直接操作 DOM 或者调用 Node API 这么自由?因为它本质上就是一个跑在 Electron 里的代码包。

1.2 扩展点:理解编辑器开放了哪些能力

搞清楚“能往哪里插”比急着写代码重要得多。Cocos 编辑器扩展系统虽然入口统一,但能扩展的位置其实非常多。我把常用的扩展点整理成了一张表,方便你对照自己的需求选方向。

扩展点配置位置典型用途
顶部菜单contributions.menu添加自定义菜单项,点击后触发消息
自定义面板contributions.panel做自己的工具窗口,比如批量处理、数据检查
消息方法contributions.messages声明插件对外可调用的方法,供菜单、面板、其他扩展调用
场景扩展contributions.scene场景节点右键菜单、场景内操作、执行场景脚本
资源扩展contributions.asset-db资源右键菜单、资源元数据、导入后处理
构建扩展contributions.builder构建流程钩子,构建前/构建后自动执行脚本
偏好设置contributions.preferences给插件加配置页,类似编辑器设置
控制台菜单contributions.console往编辑器控制台添加菜单项

大部分实用插件至少会用到菜单加面板的组合。菜单负责入口,面板负责交互。如果还涉及资源或构建流程,就再加上对应的扩展点。

1.3 我选择“批量尺寸修改面板”作为示例的理由

我把这个选题作为示例,不是因为它多酷,而是它正正好覆盖了扩展开发最核心的几条链路。

第一,它需要一个面板,这就用到了 panel 扩展点和面板前端开发能力。第二,它要操作当前场景里的节点,这就必须走 scene 消息和 execute-scene-script 场景脚本。第三,它要有菜单作为入口,这就涉及到 contributions.messages 的消息映射。一条完整的链路下来,你已经把扩展插件百分之八十的常规玩法都摸熟了。

此外,批量修改节点尺寸这件事本身非常常见。UI 界面调整、多分辨率适配、资源尺寸统一,都需要这类操作。做出来不是玩具,是真能提升日常开发效率的那种工具。

2. 先把地基打好:package.json 与消息机制

2.1 package.json 到底要怎么写:一份能跑的配置逐字段拆解

扩展的 package.json 是编辑器和扩展之间的“合同”。它的写法和 npm 包类似,但多了一些 Cocos 专属字段。我第一次写的时候照着官方文档抄,结果菜单没出现,折腾了半天才发现是 creator.version 写错了。下面这份是我现在用的模板,标注了每个字段的实际作用。

{ "name": "batch-size-tool", "version": "1.0.0", "creator": { "version": "3.8.0" }, "description": "批量修改选中节点尺寸", "main": "./main.js", "contributions": { "messages": [ "batch-size-tool:open", "batch-size-tool:applySize" ], "menu": [ { "path": "开发/批量尺寸工具", "message": "batch-size-tool:open" } ], "panel": { "main-panel": { "title": "批量尺寸工具", "type": "dockable", "main": "./panel/index.js" } } } }

name 是扩展的唯一标识,必须全英文小写短横线风格,不能和内置扩展重名。creator.version 决定了这个扩展能跑在哪个版本的编辑器上,我写 3.8.0,如果你用的是 3.6 或 3.7,建议把版本改成对应数字,否则编辑器可能因为版本不匹配拒绝加载。

main 指向入口脚本,这个文件和 package.json 同级。contributions 是核心部分,里面声明了扩展能做什么。特别是 messages 数组,很多新手会漏掉:你想在菜单里触发消息,想在其他地方调用扩展的方法,都要先把消息名在这里注册一遍。

2.2 扩展名与消息名的映射规则

Cocos 3.x 的消息系统有一个约定:消息名格式是“扩展名:方法名”,当你在 contributions.messages 里声明了batch-size-tool:open,编辑器收到这条消息时,会去 main.js 的 exports.methods 里找同名的方法来处理。

具体到代码里,入口文件的写法是这样:

exports.methods = { open() { Editor.Panel.open('batch-size-tool.main-panel'); }, async applySize(uuids, width, height) { try { const changedCount = await Editor.Message.request('scene', 'execute-scene-script', { name: 'editor-scripts/scene-size-ops', method: 'batchSetSize', args: [uuids, width, height] }); return changedCount; } catch (error) { console.error('批量修改尺寸失败:', error); return 0; } } };

你注意到没有,open这个方法对应的就是消息batch-size-tool:open。菜单点击后发送这条消息,入口脚本执行 open,打开面板。面板要执行批量修改时,也不需要自己去查场景节点,而是请求扩展进程里的 applySize 方法,再由它去和场景进程通信。这种分层设计让整个链路非常清晰。

2.3 消息机制:扩展和编辑器之间只有一种对话方式

Cocos 3.x 的消息系统是理解扩展开发的关键,我一开始没搞透,写了很多弯路。简单总结就是三条 API。

Editor.Message.request是“请求-响应”式通信。你发出一条消息,然后等对方返回结果,适合需要拿数据回来的场景。比如请求场景的选中节点、执行完批量操作后拿修改数量。Editor.Message.send是“发了就不管”,适合通知类消息,比如告诉场景刷新界面。Editor.Message.broadcast是广播,发给所有监听者,适合跨扩展同步状态。

消息的 target 参数也很直观。Editor.Message.request('scene', 'query-node', 'selection')意思是向 scene 这个扩展请求查询节点,查询条件是 selection,返回当前选中的节点 uuid 数组。Editor.Message.request('batch-size-tool', 'applySize', ...)意思是向自己的扩展请求执行 applySize 方法。

我再强调一次这条链路的感觉:扩展主进程能跑 Node 环境,能读文件、能发请求,但它不能直接碰场景里的节点。场景进程有场景数据,但它不知道你的面板上填了什么数字。两边各管一段,通过消息来协作。把这条逻辑理清之后,你就不会再犯“在 main.js 里直接操作节点”这种错误了。

2.4 2.x 和 3.x 的差异,别拿旧思路套新版本

网上搜扩展插件教程,你会发现很多文章还停留在 2.x 时代。Cocos Creator 3.x 的扩展系统和 2.x 差别非常大。2.x 是 plugin 目录加 plugin.json,3.x 改成了 extensions 目录加 package.json,而且消息接口从Editor.remote变成了Editor.Message。

如果你直接照着 2.x 的教程写,大概率连加载都加载不出来。所以不管看什么资料,先确认它是针对哪个版本写的。本篇文章的代码只保证在 3.8 上可用,3.6 和 3.7 应该也没问题,再早的版本就要多留个心眼了。

3. 从零做一个批量尺寸修改面板:代码直接抄

3.1 目录结构搭建:别放错位置了

扩展插件的目录有严格约定,位置放错编辑器根本不认。项目根目录下必须有extensions文件夹,你的扩展就放在这个文件夹的子目录里。我习惯每个工具一个子目录,目录名和 package.json 里的 name 保持一致。

项目根目录/ extensions/ batch-size-tool/ package.json main.js panel/ index.js template.html style.css

assets 目录下还需要放一个场景脚本,这个脚本是给 execute-scene-script 调用的,我建议单独建个子目录,避免和业务脚本混在一起。

assets/ editor-scripts/ scene-size-ops.ts

补充一点:assets 里的脚本虽然不参与运行时逻辑,但它还是会跟着项目走。如果非常介意,可以在构建时排除,但作为编辑器工具脚本,我目前这么做没有遇到过打进包里影响运行的情况。关键是别把它挂到任何场景或组件上。

3.2 入口 main.js:菜单、消息转发的核心

main.js 是扩展的入口,编辑器加载扩展时会执行它。我们这里不需要在加载时做太多事情,主要就是把方法注册到 exports.methods 里,让消息系统能找到对应的处理函数。

前面已经给出了 main.js 的完整代码,我再补充几个实际开发中会踩到的细节。exports.methods 里的方法名一定要和 package.json 里声明的消息名保持一致,去掉扩展名前缀。如果你声明了batch-size-tool:applySize,那方法名就是applySize,一个字母都不能差。

Editor.Panel.open的参数是“扩展名.面板key”。package.json 里 panel 节点下的 key 是main-panel,所以这里就是batch-size-tool.main-panel。写错这个字符串最常见的结果就是点了菜单没反应,控制台还报一个找不到面板的错。

3.3 面板 index.js:读选中节点、刷新列表、发起修改

面板脚本是一个 CommonJS 模块,它导出一个对象,里面有 template、style、$、methods、ready 这些字段。编辑器会把 template 加载成面板的 DOM,把 style 注入样式,然后调用 ready 方法。

这是完整的 panel/index.js:

const { readFileSync } = require('fs'); const { join } = require('path'); module.exports = { template: readFileSync(join(__dirname, 'template.html'), 'utf-8'), style: readFileSync(join(__dirname, 'style.css'), 'utf-8'), $: { nodeList: '#nodeList', refreshBtn: '#refreshBtn', widthInput: '#widthInput', heightInput: '#heightInput', applyBtn: '#applyBtn', status: '#status' }, data() { return { selectedUuids: [] }; }, methods: { async refreshSelection() { const uuids = await Editor.Message.request('scene', 'query-node', 'selection'); this.selectedUuids = uuids || []; if (this.selectedUuids.length === 0) { this.$.nodeList.innerHTML = '<div class="empty">未选中任何节点</div>'; this.$.status.textContent = '共 0 个节点'; return; } const infos = []; for (const uuid of this.selectedUuids) { const dump = await Editor.Message.request('scene', 'query-node', uuid); if (dump && dump.name) { infos.push(dump.name); } else { infos.push(uuid.slice(0, 8)); } } this.$.nodeList.innerHTML = infos .map((name) => `<div class="node-item">${name}</div>`) .join(''); this.$.status.textContent = `已选中 ${this.selectedUuids.length} 个节点`; }, async apply() { const width = parseFloat(this.$.widthInput.value); const height = parseFloat(this.$.heightInput.value); if (!width || !height || this.selectedUuids.length === 0) { this.$.status.textContent = '请先输入尺寸并选中节点'; return; } const changedCount = await Editor.Message.request( 'batch-size-tool', 'applySize', this.selectedUuids, width, height ); this.$.status.textContent = `已修改 ${changedCount} 个节点`; this.refreshSelection(); } }, ready() { this.$.refreshBtn.addEventListener('click', () => this.refreshSelection()); this.$.applyBtn.addEventListener('click', () => this.apply()); this.refreshSelection(); }, close() { this.selectedUuids = []; } };

$对象是面板脚本里非常方便的一个机制,它把 template 中对应 id 的元素预先绑定好。比如$: { nodeList: '#nodeList' },之后this.$.nodeList就能直接拿到这个 DOM 节点,省去了一堆 querySelector。

refreshSelection 方法里用到了Editor.Message.request('scene', 'query-node', 'selection')来获取当前选中的节点列表。这个接口返回的是一个 uuid 数组。拿到 uuid 之后再逐个查询节点信息,主要是拿节点的 name 显示在面板里。这是个串行请求,节点多的时候会稍慢,但对编辑器工具来说完全够用。

3.4 template 与 style:把面板外观搭出来

面板的 HTML 结构和 css 都比较简单,但有几个细节值得说。template.html 的根节点必须包含一个 id 去对应 index.js 中的绑定,比如输入框的 id 是 widthInput,那么 index.js 里$: { widthInput: '#widthInput' }就能正确找到它。id 名和选择器必须严格对应,不然会拿到 null。

template.html:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>批量尺寸工具</title> </head> <body> <div class="container"> <div class="toolbar"> <button id="refreshBtn">刷新选中</button> </div> <div class="node-list" id="nodeList"></div> <div class="controls"> <label>宽度 <input id="widthInput" type="number" value="100" /></label> <label>高度 <input id="heightInput" type="number" value="100" /></label> <button id="applyBtn">应用</button> </div> <div class="status" id="status"></div> </div> </body> </html>

style.css:

.container { padding: 12px; display: flex; flex-direction: column; gap: 10px; } .toolbar { display: flex; justify-content: space-between; } .node-list { max-height: 220px; overflow-y: auto; background: #2b2b2b; border: 1px solid #444; border-radius: 4px; padding: 4px; } .node-item { padding: 4px 8px; font-size: 13px; border-bottom: 1px solid #3a3a3a; } .node-item:last-child { border-bottom: none; } .empty { color: #888; padding: 8px; } .controls { display: flex; gap: 8px; align-items: center; } .controls input { width: 70px; margin-right: 8px; } .status { color: #aaa; font-size: 12px; min-height: 18px; }

面板的样式不用刻意去匹配编辑器默认皮肤,编辑器会自动包一层主题样式,但你自己的控件最好用比较中性的颜色。如果你后续想用更漂亮的控件,可以研究一下编辑器自带控件库提供的组件,但原生控件已经能覆盖大部分工具场景,没必要上来就引入复杂组件。

3.5 场景脚本:真正执行批量修改的地方

前面说了,扩展进程不能直接操作场景节点,所以我们要写一个场景脚本,让场景进程去执行真正的修改逻辑。这个脚本放在 assets 目录下,通过 execute-scene-script 消息调用。

assets/editor-scripts/scene-size-ops.ts:

const { director, UITransform } = require('cc'); export const methods = { batchSetSize(uuids: string[], width: number, height: number) { const scene = director.getScene(); if (!scene) { return 0; } let changedCount = 0; for (const uuid of uuids) { const node = scene.getChildByUuid(uuid); if (!node) { continue; } const uiTransform = node.getComponent(UITransform); if (!uiTransform) { continue; } uiTransform.setContentSize(width, height); changedCount++; } return changedCount; } };

这个脚本的导出结构固定是methods对象,方法名要和 execute-scene-script 消息里传的 method 保持一致。director.getScene()拿到当前场景的根节点,getChildByUuid按 uuid 查节点,getComponent(UITransform)拿到 UI 变换组件,然后 setContentSize 设置宽高。

返回的 changedCount 会作为 execute-scene-script 的返回值,一路传回扩展进程,再到面板里显示。这个链路我实际跑过,表现稳定。

3.6 完整验证流程

代码写好之后,到编辑器里验证。先确保目录结构正确,重启编辑器,或者通过菜单“开发者 -> 重新加载扩展”重新加载扩展。然后在编辑器顶部菜单找到“开发 -> 批量尺寸工具”,点击后面板应该弹出来。

在场景里选中几个带 UITransform 组件的节点,回到面板点“刷新选中”,面板的列表里应该出现这些节点的名字。填一个宽高值,比如 200 和 80,点“应用”,属性检查器里对应节点的 Content Size 会发生变化,面板状态栏显示“已修改 n 个节点”。

这里有个细节:批量修改的是 UITransform 的 Content Size,不是节点的 Scale。很多第一次写的人会把尺寸和缩放搞混。Content Size 是 UI 布局里的尺寸概念,对 Label、Sprite、Layout 等组件都有效果;Scale 是变换概念,改它会导致图片拉伸变形。我们要改尺寸,动 Content Size 才是正确的做法。

4. 实操过程中的坑与排查实录

4.1 插件装上没反应:先从加载层排查

最气人的情况是代码看着没问题,编辑器里却什么都找不到。我整理了高频排查点,对照自查比乱试强。

现象可能原因解决办法
顶部菜单没有“批量尺寸工具”package.json 里 creator.version 和编辑器版本不匹配把版本改成当前编辑器版本,比如 3.8.0
菜单有但点击没反应消息名没在 contributions.messages 里声明补上声明,消息格式必须是“扩展名:方法名”
菜单点了报“找不到面板”Editor.Panel.open 参数写错检查参数是否为“扩展名.panelKey”格式
插件文件改了不生效扩展进程没有重新加载通过“开发者 -> 重新加载扩展”刷新
插件被编辑器忽略了扩展目录名和 package.json 的 name 不一致改成同名目录

注意,菜单 path 里的“开发/批量尺寸工具”会创建一级菜单“开发”,下面挂一个“批量尺寸工具”的菜单项。如果编辑器里已经有“开发”菜单,新插件会并进去。这个路径你可以随意改,比如改成“工具/批量尺寸”,别和内置菜单搞混就行。

4.2 面板白屏和消息不通的问题

面板没内容或者直接报错,十有八九是路径或模块加载问题。readFileSync 拼路径的时候,join(__dirname, 'template.html')的 __dirname 是当前文件所在目录,也就是 panel 目录。如果你改过目录结构,template 和 style 的路径会跟着变,千万别写死。

另外注意 template.html 根节点不要套太复杂的结构,比如 html 标签里加载外部资源,或者引入了不存在的 css 文件,都会导致白屏。面板脚本的执行模型和浏览器页面类似,一旦脚本抛了异常,整个面板就会白掉。遇到白屏,优先打开编辑器开发者工具看 Console 报错。

消息不通的问题多发在跨扩展调用时。比如面板调Editor.Message.request('batch-size-tool', 'applySize', ...)没反应,检查消息名里扩展名的大小写,Linux 和 macOS 上文件名大小写敏感,Windows 上虽然大小写不敏感但也别养成乱写的习惯。还要检查 main.js 里 exports.methods 确实有这个同名方法。

4.3 场景数据改了不刷新、不保存

场景脚本里 setContentSize 之后,如果编辑器属性检查器没有立即刷新,别慌,这不是你错了,而是场景进程和编辑器 UI 之间的同步机制导致的。通常在场景脚本执行完后,通过 scene 消息或者编辑器内部的事件机制,会触发一次数据同步。如果你希望立刻看到刷新效果,可以在 execute-scene-script 之后主动发送一个消息通知场景刷新,不过我在测试中发现大多数情况下属性面板会自动更新。

还有一个必须守住的底线:改完场景数据之后,一定要记得保存场景。扩展脚本操作场景不会自动帮你保存,用户按 Ctrl+S 才会存盘。如果忘了保存就关闭编辑器,所有修改都会丢掉。所以要么在插件里提示用户保存,要么自己在 execute-scene-script 执行完后发送一条保存场景的消息。

4.4 调试姿势:日志、断点、控制台

调试扩展插件的姿势和普通前端开发差不多,但有几个窍门。扩展主进程的日志用console.log会输出到哪里?它输出到扩展日志文件里,如果你在菜单点击后没看到面板打开,去编辑器的“控制台”面板看也没有?实际上编辑器控制台显示的是运行时报错,扩展进程的 console.log 要在启动编辑器的终端里才能看到完整输出。

面板里的 console.log 则直接输出到编辑器开发者工具的控制台。打开方式是在菜单栏点“开发者 -> 打开开发者工具”。面板出现白屏或者交互没反应时,第一步就是打开这个地方看报错。

节点监听:触发事件用 addEventListener,绑定事件如果没生效,检查一下是不是面板重新创建了旧引用没销毁。ready 方法里的事件绑定只会执行一次,如果面板被重复打开,this.$.refreshBtn的引用可能已经过期,最稳妥的办法是每次 ready 都重新绑定。

4.5 清理资源与性能注意

批量操作场景节点的循环逻辑很简单,但真正处理成百上千个节点时要留意性能。execute-scene-script 的脚本在场景进程执行,如果处理量极大,会导致编辑器卡顿。我通常会在脚本里加一个上限,比如单次最多处理 500 个节点,超出就返回提示。对编辑器工具来说,稳定性和响应速度比一次能处理多少更重要。

5. 再进一步:构建钩子、右键菜单与资源处理

5.1 在构建流程里“插一脚”

面板插件只是起步,扩展系统真正厉害的地方在于可以参与构建流程。你可以在构建前做资源检查,构建后做文件清理,甚至根据平台差异生成不同的配置。

package.json 里加一块 builder 配置:

{ "contributions": { "builder": { "hooks": { "onBeforeBuild": "batch-size-tool:beforeBuild", "onAfterBuild": "batch-size-tool:afterBuild" } } } }

然后在 main.js 里实现对应方法:

exports.methods = { beforeBuild(options) { console.log('构建前检查开始', options.platform); }, afterBuild(options) { console.log('构建完成', options.dest); } };

构建钩子的 options 里有 platfrom、dest、buildPath 等字段,具体以当前版本返回为准。这个功能非常适合做资源命名检查、目录规范检查、自动生成配置文件这类批量工作。注意构建钩子函数里不要做太耗时的操作,比如这里去请求网络或者触发大规模资源重导入,会把构建流程卡住。

5.2 给资源右键菜单加一个操作

右键菜单是另一个高频需求。比如你想在资源面板里右键某个资源,弹出一个菜单项,校验命名格式,或者批量处理选中资源。

package.json 里添加 asset-db 扩展点:

{ "contributions": { "asset-db": { "context-menu": [ { "path": "批量尺寸工具/检查命名规范", "message": "batch-size-tool:checkNaming", "condition": "asset-db:selection-length >= 1" } ] } } }

condition 字段是控制菜单显隐的,这里的意思是选中一个资源以上才显示菜单。然后 main.js 里加一个 checkNaming 方法处理资源信息。资源相关的操作经常要拿资源 uuid 去换资源路径,或者反过来。常用接口有 query-asset-by-uuid、query-url、query-uuid,具体参数可以在控制台里敲 Editor.Message.request('asset-db', ...) 时用 auto complete 查看。

5.3 数据持久化:记住用户上次的配置

工具类插件最好能记住用户上次输入的值,不然每次打开都要重新填一遍。Cocos 扩展系统提供了 Editor.Profile 做数据持久化。

// 保存 Editor.Profile.setConfig('batch-size-tool', { width: 200, height: 80 }); // 读取 const config = Editor.Profile.getConfig('batch-size-tool'); const width = config.width || 100;

第一参数是扩展名,第二参数是任意可序列化的对象。保存的时机我建议放在面板 close 或者点“应用”按钮的时候,避免每次输入都触发磁盘写入。读取则在面板 ready 里做一次,初始化输入框的值。

这个功能虽然小,但工具好不好用往往就体现在这种细节上。用户每次打开面板都是上一次的工作状态,使用意愿会高很多。

5.4 关于发布插件,提前说的话

如果插件做出来不仅自己用,还想分享给团队或者发到扩展中心,有几个工程化问题要提前考虑。

第一,版本管理。package.json 里的 version 字段每次更新要递增,不然扩展中心可能拒绝覆盖上传。第二,多版本兼容。如果团队里有多个不同小版本的编辑器,你的 creator.version 写死了 3.8.0,那 3.7 的编辑器就会加载失败。折中方案是声明一个比较宽的范围,或者最低支持版本。第三,文档与示例。分享给别人的时候,配套 README 说明用途和截图,能省你大量答疑时间。

最后多说一句:扩展插件的本质是解决自己或者团队的实际痛点,不是炫技。从最简单的面板做起,用几个月时间慢慢积累,你会发现自己每天都在编辑器上节省了不少重复劳动的时间。这比任何花哨但用不上的工具都更有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询