给扩展的宿主接口:`Scratch.gandi`
扩展跑起来之后有两件事以前只能靠猜:当前跑在哪个编辑器里,和舞台的 DOM 在
哪。Scratch.gandi 就是这两件事的答案。
这一篇是给写扩展的人看的。
谁拿得到#
只有非沙箱扩展有这个命名空间:沙箱扩展跑在 worker 里,那里根本没有
document,给它一个操作 DOM 的接口只会造成误解。Gandi 3.0 从网址装进来的扩展
在页面里执行,所以拿得到。
Scratch.gandi 这个命名空间存在本身就说明这是 Gandi 3.0 系的编辑器。别的
编辑器里它是 undefined,所以每一次访问都要带上 ?.。
一、判断当前编辑器#
if (Scratch.gandi?.isGandi3) {
// 是 Gandi 3.0
}要按功能分支的话不要只看它,而要检查那个功能在不在:
if (typeof Scratch.gandi?.stage?.addOverlay === 'function') { /* … */ }能力探测不会随版本失效,所以这里没有版本号:要判断某个功能有没有,就检查那个 功能本身。
二、在舞台上盖一层自己的 HTML#
想在舞台上放 HTML(<iframe> 那一类)的扩展,过去只能遍历整个页面去判断哪个
<canvas> 是舞台、再猜它的父节点、再把自己插进去。编辑器布局一变这套判断就失效,
而且插错地方会把变量监视器压在底下,那是用户自己放的东西。
现在可以直接申请一层:
const layer = Scratch.gandi.stage.addOverlay({scale: true, interactive: true});
layer.appendChild(myIframe);
// 不用了:
Scratch.gandi.stage.removeOverlay(layer);拿到的是一个空 <div>,已经贴满舞台、排好层级。画布不动、监视器不动,也不需要
了解编辑器的 DOM 结构。
下面这张图里,橙色的卡片就是一层 overlay:它盖住了舞台上的角色,但盖不住右上角 那个变量监视器。

方法一览#
| 方法 | 作用 |
|---|---|
Scratch.gandi.isGandi3 |
布尔值。是不是跑在 Gandi 3.0 里。 |
Scratch.gandi.stage.addOverlay(options?) |
一层贴满舞台的 <div>;没有舞台服务时返回 null。 |
Scratch.gandi.stage.removeOverlay(element) |
收掉一层。返回真表示确实收掉了。 |
Scratch.gandi.stage.getCanvas() |
舞台画布。量尺寸、读像素可以,不要改它的样式,也不要把它搬走。 |
Scratch.gandi.stage.getContainer() |
画布和监视器共处的那个盒子。同样只读。 |
addOverlay 的选项:
| 选项 | 默认 | 含义 |
|---|---|---|
scale |
false |
真:这一层的内部坐标系固定成舞台原生尺寸(480×360),缩放交给编辑器。按舞台坐标摆元素即可,不必自己监听尺寸变化。 |
interactive |
false |
真:这一层接收鼠标事件。注意它会挡住盖住的那块舞台,所以默认是穿透的。 |
name |
无 | 写到元素的 data-overlay 上,便于排查某一层是谁加的。 |
作品换了屏幕比例时,scale 说的「原生尺寸」跟着变,不一定是 480×360。
行为细节#
舞台面板此刻没挂载(用户把它关了)也照样返回元素,等舞台回来会自动接上去; 换布局、进出全屏时,这一层跟着画布走,不需要额外处理。
编辑器没提供舞台服务时(无头播放器、单测、别家编辑器),这几个方法返回
null,只告警一次,不抛异常:一个用来探测环境的调用不应该让作品崩溃。层级是这样叠的(
.stage-stack里从下往上):canvas.stage-canvas 最底下 .stage-overlay z-index 2 ← addOverlay 给的 .monitor-layer z-index 3 .stage-crossfade z-index 5 .stage-drop-hint z-index 6 .stage-question z-index 10监视器那条
z-index: 3就是为这件事写的:扩展加的层永远在画布之上、监视器之下。
三、让积木进编译器:compile 字段#
编译模式下,扩展积木是「调一下扩展的函数」:脚本本身编成了 JS,碰到扩展积木就去调
getInfo() 里那个方法。这一步已经很快(每次调用几十纳秒),但编译器不知道三件事:这块
积木会不会让出、返回什么类型、函数体里是什么。知道了就能把它当原生积木处理。
这三件事写在积木定义的 compile 字段上,三档,每一档都是可选的。不写的积木照旧,
解释器根本不看这个字段。
第一档:声明#
{
opcode: 'hyp', blockType: Scratch.BlockType.REPORTER, text: '√([A]² + [B]²)',
arguments: {A: {type: Scratch.ArgumentType.NUMBER}, B: {type: Scratch.ArgumentType.NUMBER}},
compile: {sync: true, returns: 'number', pure: true}
}| 字段 | 你承诺的事 | 编译器拿它做什么 |
|---|---|---|
sync |
函数不返回 Promise,不调 util.yield / yieldTick / startBranch,不停线程。 |
含这块积木的自制积木可以内联;生成的代码不再检查让出和 Promise。 |
returns |
'number' / 'string' / 'boolean',返回值一定是这个类型。 |
返回值不再二次转换;「把变量设为 (积木)」的变量可以进数值槽。 |
pure |
结果只由参数决定,没有副作用,不读扩展自己的状态。 | 参数全是常量时编译期直接算出结果。 |
compile: 'sync' 是 {sync: true} 的简写。
承诺错了不会崩:声明了 sync 却返回 Promise,编译后当作空串、控制台警告一次并点名
积木;真的让出了,线程状态被改回运行继续走。returns 写错了按声明的类型强转。
第二档:模板#
把积木本体写成一行 JS,编译器把它原地放进生成的代码,函数不再被调用:
{
opcode: 'hyp', …,
compile: {returns: 'number', pure: true, js: 'Math.hypot($A, $B)'}
}
{
opcode: 'setSpeed', blockType: Scratch.BlockType.COMMAND, …,
compile: {js: '$$.speed[$target.id] = $V'}
}模板里能用的只有这几样:
| 占位符 | 是什么 |
|---|---|
$A、$B … |
该积木的参数,已按 ArgumentType 转成数字 / 字符串 / 布尔;没声明类型的原样给。菜单和字段是字符串常量。 |
$$ |
扩展实例,就是写 getInfo 的那个对象。 |
$target $stage $runtime $thread |
当前角色、舞台、运行时、线程,和积木函数里的 util.target 等是同一个东西。 |
reporter 和 boolean 的模板是一个表达式;command 的模板是一段语句。模板里没有
util:要 util.stackFrame、要让出、要启动帽子的积木不适合写模板,留给函数就好,
一个扩展里两种可以混用。同一个参数在模板里用了几次,也只算一次。
raw: true 让参数不经转换、按函数收到的原样代进来,给把现有函数体逐字抄成模板
的场合用(比如原函数自己写了 Number(x))。
模板要按参数分支时,js 可以是函数。它收到的是参数的 JS 源码,返回一段源码:
compile: {
returns: 'string',
js: ({S, N}, c) => {
const n = c.constant('N'); // 编译期常量,不是常量时 undefined
return n === undefined ? `${S}.slice(0, ${N})` : `${S}.slice(0, ${n})`;
}
}c 上只有 constant(名)、type(名)、local()(要一个临时变量名)、substack(名)。
返回的源码里还可以用 $ 占位符。函数形式只有页面里加载的扩展能用(Gandi 3.0 的都是)。
第三档:条件和循环#
自定义的「重复」「如果」积木,模板里用 $SUBSTACK(第二个口是 $SUBSTACK2)放
编译好的积木堆,用 $YIELD 放这条脚本该有的让出。编出来的代码和原生「重复执行」一样:
{
opcode: 'forEachItem', blockType: Scratch.BlockType.LOOP, text: '对列表 [LIST] 的每一项', …,
compile: {js: 'for (const it of $$.items($LIST)) { $$.current = it; $SUBSTACK $YIELD }'}
}
{
opcode: 'ifChance', blockType: Scratch.BlockType.CONDITIONAL, branchCount: 2, …,
compile: {js: 'if (Math.random() < $P) { $SUBSTACK } else { $SUBSTACK2 }'}
}$YIELD 在普通脚本里是让出一次,在「不刷新屏幕」的脚本里是卡住了才让出;循环模板
不写 $YIELD 就是一个不刷新屏幕的循环。
出错时#
注册扩展时每块积木的 compile 都会检查:占位符必须是参数名或上表里的名字,字符串模板
要能解析。不过的那一块在控制台警告一条,然后当没写,走普通路径;扩展永远装得上。
想一次关掉所有扩展的编译字段,控制台里 JSGenerator.extensionCompile = false。
快多少#
node 里的 warp 循环,每次迭代一个 reporter 加一个 command,纳秒:不写 40,只写声明 29, 写模板 3。扩展库里「更多比较」「字符串处理」「位运算」三个扩展的积木用模板重写后, 单次调用从 240 到 415 纳秒降到 11 到 48 纳秒。
没人维护的扩展#
三档写的都是数据,所以不必只能由扩展作者写。扩展库里停更但常用的扩展,编辑器自己
带一份配置(src/vm/compileProfiles/),装扩展时按 id 合并到积木上;扩展自己写了
compile 的以扩展为准。配置只写和原函数逐字等价的纯函数积木。
四、渐变色积木:colorGradient#
getInfo() 里本来有 color1 / color2 / color3 三个颜色,积木是纯色的。
再给一个 colorGradient,这个扩展的积木就从 color1 渐变到它:
getInfo () {
return {
id: 'myext',
name: '我的扩展',
color1: '#12B0C9',
color2: '#0F9DB4',
color3: '#0D8A9E',
// 可选:积木从 color1 斜着渐变到这个颜色
colorGradient: '#1C78B5',
blocks: [/* … */]
};
}不写就还是纯色,和以前一字不差。
几条要知道的:
- 方向是左上到右下的对角线,按每块积木自己的尺寸缩放,所以长积木和短积木 看起来是一套。方向是全局定死的,不能按扩展改 —— 一个作品里各扩展的积木朝向 不一样会很乱。
- 只影响积木本体。阴影积木、发光时的高亮、输入框的凹槽还是纯色:那几样本来 就是靠和本体的色差被认出来的。
- 要轻。 两个颜色差太多,积木栏里会很吵;差一点点(比如同一色相压暗一档、 或者往邻近色偏一点)就够了。
- 自己写了
color1的那几块积木不渐变,用自己的纯色。 - 同一对颜色全局只建一份渐变定义,一千块积木也只有一个
<linearGradient>。
五、C 型积木上能拖出来的参数#
「对列表的每一项」这类循环积木,身体里要用到「这一轮的那一项」。做法和带参数的 hat
一样:参数声明成 Scratch.ArgumentType.CCW_HAT_PARAMETER,积木上就有一颗椭圆,拖出来
放进身体里,读到的是这一轮的值。

声明#
{
opcode: 'forRange',
blockType: Scratch.BlockType.LOOP,
text: '对 [i] 从 [FROM] 到 [TO]',
arguments: {
i: {type: Scratch.ArgumentType.CCW_HAT_PARAMETER},
FROM: {type: Scratch.ArgumentType.NUMBER, defaultValue: 1},
TO: {type: Scratch.ArgumentType.NUMBER, defaultValue: 10}
}
}LOOP 和 CONDITIONAL 都可以,一块积木上可以有几颗。椭圆上显示的是参数名(这里是
i),这个名字也是取值用的键。
赋值#
开分支时把这一轮的值交给 util.startBranch 的第三个参数,键是参数名:
forRange (args, util) {
const frame = util.stackFrame; // 这一次执行这块积木的状态
if (frame.i === undefined) {
frame.i = Scratch.Cast.toNumber(args.FROM);
frame.to = Scratch.Cast.toNumber(args.TO);
} else {
frame.i++;
}
if (frame.i > frame.to) return; // 不开分支,循环结束
util.startBranch(1, true, {i: frame.i});
}不给第三个参数时沿用上一轮的值。
取值规则#
- 椭圆找包着它的那块声明了同名参数的 C 型积木,取它这一轮交的值;套了几层同名的, 取最里面一层。
- C 型积木自己输入格里的椭圆读的是外层的值,不是这块积木上一轮的值。
- 哪一层都没给(或者给的是
undefined/null),再看 hat 参数;都没有是空字符串。 - 值跟着线程走:同一段脚本在几个克隆体里同时跑、递归调用自己,各读各的。
编译#
编译模式下身体里的椭圆直接读这块积木这一轮交的值,不经过兼容层;C 型积木本身每一轮 同步调一次扩展函数,函数让出或返回 Promise 时才退回兼容层。套在「瞬时执行」里循环 10 万次、身体里把参数累加到变量,node 里约 9 ms;同样的事用「重复执行」加变量约 1 ms, 差的是每一轮调用扩展函数的开销,读参数本身几乎不花时间。
写了 compile 模板(第三档)的积木不经过 startBranch,用不了这里的参数。
示例#
完整的三块(单参数循环、双参数循环、只跑一次的 CONDITIONAL):
(function (Scratch) {
'use strict';
const {ArgumentType, BlockType, Cast} = Scratch;
class CBlockArgsExample {
getInfo () {
return {
id: 'cblockArgsExample',
name: 'C 型参数示例',
blocks: [
{opcode: 'forRange', blockType: BlockType.LOOP, text: '对 [i] 从 [FROM] 到 [TO]',
arguments: {i: {type: ArgumentType.CCW_HAT_PARAMETER},
FROM: {type: ArgumentType.NUMBER, defaultValue: 1},
TO: {type: ArgumentType.NUMBER, defaultValue: 10}}},
{opcode: 'forEachItem', blockType: BlockType.LOOP, text: '对 [item] [index] 逐项 [LIST]',
arguments: {item: {type: ArgumentType.CCW_HAT_PARAMETER},
index: {type: ArgumentType.CCW_HAT_PARAMETER},
LIST: {type: ArgumentType.STRING, defaultValue: 'a,b,c'}}},
{opcode: 'withValue', blockType: BlockType.CONDITIONAL, text: '令 [value] = [V]',
arguments: {value: {type: ArgumentType.CCW_HAT_PARAMETER},
V: {type: ArgumentType.STRING, defaultValue: 'x'}}}
]
};
}
forRange (args, util) {
const f = util.stackFrame;
if (f.i === undefined) {
f.i = Cast.toNumber(args.FROM);
f.to = Cast.toNumber(args.TO);
} else {
f.i++;
}
if (f.i > f.to) return;
util.startBranch(1, true, {i: f.i});
}
forEachItem (args, util) {
const f = util.stackFrame;
if (f.items === undefined) {
f.items = Cast.toString(args.LIST).split(',');
f.n = 0;
}
if (f.n >= f.items.length) return;
f.n++;
util.startBranch(1, true, {item: f.items[f.n - 1], index: f.n});
}
withValue (args, util) {
util.startBranch(1, false, {value: args.V}); // 不是循环:身体跑一次
}
}
Scratch.extensions.register(new CBlockArgsExample());
})(Scratch);社区扩展的老做法#
在这个接口之前,社区扩展(高级数据结构、SharkPool 的 JSON 等)是自己注册一种参数积木、
改写 ScratchBlocks.scratchBlocksUtils.isShadowArgumentReporter 让它能被拖出来复制。
Gandi 3.0 照样认这种改写,这些扩展不用改;新写的扩展用上面的声明即可,不必碰积木库。
Scratch 上还有什么#
这一篇只讲 gandi 那个命名空间。Scratch 本身还带着 ArgumentType、BlockType、
TargetType、Cast、Color、translate、Patcher,以及
Scratch.expandedSlots / Scratch.expandedValues(可扩展参数
那对 ➕➖ 的读值函数,沙箱扩展也有)。
积木文字要跟着界面语言换,就用 Scratch.translate:中文写在原地当默认值,英文放进
Scratch.translate.setup({'zh-cn': {}, en: {…}})。那张空的 zh-cn 表不能省,没有它
中文界面会退到英文那张。换语言时编辑器重新调 getInfo(),所以翻译要在 getInfo()
里调。
不要在积木实现里读 window.Scratch。 加载器装完一个扩展之后会把
global.Scratch 上的 vm / runtime / renderer 清成 null,装下一个扩展时还会
整个换掉 global.Scratch。要么像下面这样用闭包捕获,要么在构造函数里存下来:
(function (Scratch) {
'use strict';
class MyExt { /* 这里的 Scratch 永远是自己那一份 */ }
Scratch.extensions.register(new MyExt());
})(window.Scratch);示例扩展#
public/examples/gandi-showcase.js 是一份能跑的参考:Gandi 3.0 里写扩展能用到
的东西,每样都有一块最小的、真能跑的积木。要照着写的话先看这个。
积木栏里最上面露着最常用的几块,其余按能力分成八组,出厂都折着:
| 位置 | 演示什么 |
|---|---|
| 最上面 | 积木栏按钮、带参数的 EVENT hat(「当示例开始(第 n 次)」)、写了模板的「加」、判断 Gandi 3.0、C 形积木上拖出参数的「对 i 从 1 到 10」、舞台上的 HTML 面板 |
| 积木的种类 | 能勾选监视的 REPORTER、COMMAND、BOOLEAN、边沿触发的 HAT、没有下接口的 COMMAND(isTerminal)、单块换色和图标、积木栏里预搭的一段脚本(BlockType.XML) |
| 编译模式 | compile 三档:只声明、字符串模板、函数模板、循环模板、两个口的条件模板 |
| C 形积木 | LOOP、CONDITIONAL、两个口的 CONDITIONAL(text 写成数组)、点 ➕ 多一整条「否则如果 ⟨ ⟩ 那么 { }」的积木(能加口的 C 形积木);小节「能拖出来的参数」:两颗椭圆的循环、只跑一次的 CONDITIONAL、C 形 + 可扩展参数 + 拖出参数 |
| 可扩展参数 ➕➖ | 单输入、键值对、段夹在中间、一块上两段、逐槽选类型、参数个数和名字由用户定的 hat |
| 舞台覆盖层 | Scratch.gandi.stage 的每个方法:放卡片、放网页、收起、量尺寸、舞台面板开没开 |
| 参数类型与菜单 | 颜色、角度、音符、点阵、造型、声音、内联图片、布尔、allowDropAnywhere;字段菜单、能塞 reporter 的菜单、动态菜单;Scratch.Color |
| 运行时与存档 | 帧间隔、实际帧率、Scratch.Patcher、返回 Promise 的积木、存进作品的 extensionStorage(作品一份、角色一份)、只给角色的积木(filter)、界面语言 |
| 编辑器接口 | window.__gandi3Engine:顶栏抽屉里的一块面板、调试面板「引擎」页里的一张卡(见给扩展的引擎接口) |
另外还有:整个扩展的 colorGradient;积木文字全部走 Scratch.translate,中文和
英文界面各显示各的;三块 hideFromPalette 的老积木,老作品里照常跑。
它通篇只用 Scratch.*(编辑器接口那一组用宿主公开的 window.__gandi3Engine),
没引任何工具、没碰 Blockly、没遍历页面找 DOM,这正是重点:这些能力是平台给的。
装上之后积木栏里是这样,每一条标题都能点着折起来:

怎么装来试#
顶栏的「扩展库」→ 标签行里的「自定义扩展」→ 卡片上的「安装」,然后填地址。

开发服务器跑在 5181 端口时,示例扩展的地址就是:
http://localhost:5181/examples/gandi-showcase.js这个框三种都收:.js 网址、素材集市的扩展 ID、集市页面地址。装好之后卡片上的按钮
变成「已安装」,鼠标移上去变「卸载」。