ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从拖拽到可运行代码:拆解 Blockly 可视化编程编辑器的完整架构

从拖拽到可运行代码:拆解 Blockly 可视化编程编辑器的完整架构

从拖拽到可运行代码:拆解 Blockly 可视化编程编辑器的完整架构

【免费下载链接】blocklyThe web-based visual programming editor.项目地址: https://gitcode.com/gh_mirrors/bloc/blockly

当你按住一块 if 积木拖到画布上的瞬间,Blockly——一个基于 Web 的可视化编程编辑器——在几毫秒内已经完成了一连串状态流转:手势识别接管指针、积木被挂到工作区(Workspace)树上、事件队列把整段变化压缩成一个可撤销的步骤、渲染管线重画 SVG 路径,而当你点击运行,生成器(Generator)再把积木树翻译成 JavaScript。这篇文章就顺着"拖动并放下积木"这一个动作,把这套架构里各模块的协作关系走一遍,再拆开三个最关键机制看源码。

一次拖动如何贯穿事件、模型、渲染与产物

先看整体链路,建立一张协作地图。

输入端:指针落下时,core/gesture.ts 里的手势(Gesture)类创建实例,记录起点落在哪块积木上,并区分点击和拖动。松手后,具体拖拽逻辑由 core/dragging/block_drag_strategy.ts 执行,更新积木位置和连接。

数据模型端:积木的逻辑本体是 core/block.ts 中的 Block 类,它不碰任何 DOM。位置、父子关系、顶层积木列表由 core/workspace.ts 的 Workspace 维护;连接是否合法交给 core/connection_checker.ts 检查,最近的空插座由 core/connection_db.ts 的ConnectionDB.searchForClosest()在空间索引里查找。

事件端:拖动过程产生 BlockDrag、BlockMove 等事件(core/events/ 目录),但它们不立刻分发,而是先进队列,下一帧统一派发。

渲染端:模型变化通过 core/render_management.ts 的queueRender()打上脏标记,当前帧内批量重画;真正的画线工作由渲染器(Renderer)完成,见下文。

产物端:demos/code/code.js 里演示了出口——generator.workspaceToCode(workspace)递归遍历积木树,输出文本代码。

五个环节各管一段,互不越界,这正是后面几个机制存在的原因。🧩

事件合并队列:拖动一百次为什么只记一步撤销

解决什么问题:一次拖动在几百毫秒内能触发上百次中间状态变化。如果每个变化都立刻派发给监听器、还逐个压入撤销栈,一次拖动就会污染整条操作历史,监听器也会被刷屏。

怎么实现:core/events/utils.ts 中的fire()只做一件事——入队。第一个事件入队时,才安排一次延迟派发;fireNow()被调用时才批量过滤、合并、分发:

// core/events/utils.ts(节选,简化) function fireInternal(event: Abstract) { if (!FIRE_QUEUE.length) { // 队列第一个事件入队时,把派发推迟到本帧渲染完成之后 requestAnimationFrame(() => setTimeout(fireNow, 0)); } FIRE_QUEUE.push(event); // 同一帧内的所有变化都先进队列合并 }

配合setGroup()给一组相关事件打上同一个组号。事件最终由 core/workspace.ts 的fireChangeListener()处理:带recordUndo标记的事件压入undoStack_;undo(redo)弹出时按组号把连续事件整体回滚,所以一次拖动无论产生多少个事件,撤销时是一步。

代码在哪:队列与合并在 core/events/utils.ts(fire/fireInternal/fireNow/setGroup);撤销栈在 core/workspace.ts(fireChangeListener/undo/undoStack_)。

渲染管线:脏标记加帧级批量重绘

解决什么问题:拖着一个由几十块积木组成的程序时,相关区域每个鼠标位移都可能要重新布局重画。逐事件全量重绘会卡掉,逐块独立重绘又会互相覆盖。

怎么实现:分两级。第一级是调度:core/render_management.ts 用dirtyBlocks集合记录脏积木,queueRender()用requestAnimationFrame把重绘延迟到帧边界,同一帧内所有脏积木(含祖先)一次性重画完毕。第二级是单块绘制:渲染器先由注册表创建(core/renderers/common/block_rendering.ts 的register/init),然后由 Drawer 把积木拆成外轮廓和内部结构两段 SVG 路径——core/renderers/common/drawer.ts 的Drawer.draw()生成outlinePath_和inlinePath_,交给 core/renderers/common/path_object.ts 写进<path>元素。仓库内置 geras、thrasos、zelos 三套实现(core/renderers/geras/ 等),按名称即可切换。

代码在哪:调度在 core/render_management.ts;绘制在 core/renderers/common/ 与 core/renderers/geras/;尺寸颜色常量在各渲染器的constants.ts(ConstantProvider)。

表驱动的代码生成:一块积木多门语言

解决什么问题:同一棵积木树要能输出 JavaScript、Python、Lua 等多种语言,而每块积木的生成规则不能写死在积木定义里,否则加一门语言就要改所有积木。

怎么实现:core/generator.ts 的 CodeGenerator 持有一张forBlock表:积木类型名到生成函数的映射。生成时按顶层积木逐个递归:

// core/generator.ts(节选,简化) workspaceToCode(workspace?: Workspace): string { this.init(workspace); const blocks = workspace.getTopBlocks(true); // 取顶层积木列表 for (let i = 0, block; (block = blocks[i]); i++) { let line = this.blockToCode(block); // 递归积木树,查 forBlock 表 if (line) code.push(line); } // 拼接后经 finish() 补上辅助定义,再清理空白返回 return this.finish(codeString); }

值块(提供结果的积木)的生成函数返回[code, 优先级]元组,生成器用语言自己的优先级表决定是否补括号——比如 generators/javascript/javascript_generator.ts 里的Order枚举。具体某块积木的规则放在对应语言目录,如controls_if在 generators/javascript/logic.ts 中循环拼装 if / else if / else 字符串。

代码在哪:框架在 core/generator.ts;各语言实现在 generators/javascript/、generators/python/ 等目录,每块积木一个文件。🔍

设计取舍:三个"为什么这么做"

积木模型与视图分离。Block 是无头模型,BlockSvg(core/block_svg.ts)只是它在屏幕上的投影。代价是数据要双向流转、多一层间接;直接让积木就是 DOM 节点会更省代码。但正是无头化让序列化、代码生成、键盘可访问性都不依赖浏览器环境就能跑——这是后面所有功能的底座。

用 SVG 路径而非 Canvas 画积木。每块积木是真实 DOM 节点,缩放任意倍都锐利,屏幕读器和焦点导航(ARIA 属性)可以直接作用到积木上。代价是 DOM 节点开销大,所以才有路径批量拼合、分层管理(core/layer_manager.ts)这些配套手段。Canvas 节点更少,但可访问性和逐元素命中检测都得自己造。

事件延后一帧而非即时派发。上面合并队列的代价是监听器看到的状态比实际变化晚一帧;换来的是撤销粒度贴合"用户觉得的一步操作"。即时派发更直觉,但撤销栈会被逐像素的拖动事件灌满。

从读到用:新增一块积木的最小路径

内置积木(blocks/ 目录)和你自己的积木走同一条注册路径,两步就够:

// 第一步:JSON 声明积木外观,注册进系统(core/common.ts) Blockly.defineBlocksWithJsonArray([{ type: 'sensor_read', // 类型名,同时是生成器表的键 message0: '读取 %1 的值', args0: [{ type: 'field_dropdown', // 下拉字段 name: 'PORT', options: [['端口A', 'A'], ['端口B', 'B']], }], output: 'Number', // 可作为数值值连接 colour: 160, }]); // 第二步:为目标语言注册生成函数(generators/javascript/) javascript.forBlock['sensor_read'] = function(block) { const port = block.getFieldValue('PORT'); // 读下拉框当前值 return ['readPort(' + port + ')', javascript.Order.ATOMIC]; };

跑通这两步,你的积木就能在画布里连接、撤销、序列化,并被workspaceToCode()输出成目标语言——整条链路复用,零额外胶水代码。

📌

Blockly 的全部设计围绕一个事实展开:积木树是唯一真相,渲染、事件、撤销、代码都只是它的派生视图。把这条边界守住,新增语言、新增渲染风格、新增交互都退化成往对应注册表里加一项,而不是重构核心。这正是它十几年间能支撑教育到工业多场景的原因。

仓库地址(clone):https://gitcode.com/gh_mirrors/bloc/blockly

【免费下载链接】blocklyThe web-based visual programming editor.项目地址: https://gitcode.com/gh_mirrors/bloc/blockly

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进