在所有的代码设计中,我们都可以渐进增强的思考模式,去指导我们的开发。渐进增强的核心原则是:基础的内容和功能开始,逐层添加表现层和行为层,确保每一层的增强都不会破坏基础层的功能。当增强功能不可用时,核心功能仍然可以正常工作。这其实就是现代 Ssr 等技术的核心支撑。先保证 Html 的正常渲染可用,再填充 css 的样式增强,以及 js 的功能增强。

我们的技术思考点,就可以按照以下思路去设计

  1. 核心交互的闭环: 在开始实现之前,我们优先考虑的是使用 mouse 事件 or drag 事件。

    • 从”渐进”的底线(兼容性)考虑:drag 事件在不同的浏览器上差异巨大,会带来额外的维护成本
    • 从”渐进”的上限(控制力)考虑:drag 事件 API 是个黑盒,在未来我我们需要细粒度的”增强”,比如拖拽过程中指示器的实时反馈,构建虚拟区域计算碰撞边缘。

    由于处理 mousetouch 的差异,同样很复杂,我使用 interact.js 作为 mouse 事件的代理,interact.js (在我们的 engine/index.ts 中使用) 完美地封装了底层的 mousetouchpointer 事件,同时又暴露了像 dragstart, dragmove, drop 这样语义化的、可被我们完全自定义的钩子。

    于是我们的”底线”就是: 利用 interact.js 注册 draggabledrapzone,最小化实现拖拽相关逻辑。

    • drag-start:只记录 dragItem.dragId
    • drag-move:暂时什么都不做
    • drag-end:清理 dragID
    • drop.ts:最关键的,它能收到 interact.js 的事件,知道“谁”被丢到了“哪里”,然后立刻调用 params.onInsert 这个回调。 这时,我们就有了一个功能:物料可以被拖拽、并且能被正确插入到数据中。这是我们最稳固的 底线
  2. 视觉反馈提供交互增强:如何让用户知道自己拓展了哪个物料,鼠标松开时又会落在哪里。这些涉及到用户友好性的增强。 - drag-start 中,不再只是记录 ID。我们会动态创建一个”幽灵元素”作为拖拽预览,并且把光标设为 grabbing。告知用户拖拽的是具体哪一个物料以增强用户反馈。 - drag-move 中,这个高频事件现在有了第一个职责:根据 interact.js 提供的 deltaXdeltaY,实时更新 overlayDomtransform 属性,让“幽灵”跟随鼠标。 - drag-end 中,负责清理,把 overlayDom 移除,恢复光标。 做到这些,我们功能,就从基本”可用”变为了“友好”

  3. 精准插入的功能增强:在上面的过程中,物料只能被丢进一个大容器,但无法精准插入到“容器中两个元素之间”。于是我们需要实现的是,精准的碰撞检测和插入指示。 在 drag-move 中,我们遍历所有可放置的子元素 dom,判断鼠标悬停在哪个元素上。

    • 调用指示器: 一旦找到,我们就调用 createIndicator 在那个元素旁边显示“蓝线”。
    • drag-end.tsdrag-end 时,我们会记录 insertPayload.current(当前“蓝线”所在的位置),并在 drop 成功后,insertCallback 会使用这个 payload,实现精确插入
  4. 性能增强:“功能完备”后,我们迎来了最大的挑战:性能。 当画布上有 1000 个组件时,上面那个“朴素实现”的 drag-move(在 mousemove 里遍历 1000 个 DOM)会导致页面卡顿到无法使用

    于是我们不再在 drag-move 中查询 dom,而是抽象成 generageAreas 方法,在 drag-start 时。

    • 这个方法会“层序遍历”一次 DOM,预先计算出所有可放置点(nodeAreas)。
    • 这些 nodeAreas纯粹的、轻量的 JavaScript 坐标对象,并被缓存

    于是 drag-move 的逻辑就彻底变化了

    • 它不遍历 DOM
    • 它只遍历那个轻量的 nodeAreas 数组(纯 Js 计算)。 这种空间换时间的操作,即使是复杂页面,drag-move 也不会有什么性能问题
  5. 边缘功能增强:在“功能完备”且”高性能”后。就可以考虑一些“边缘体验”

    • 当用户拖拽到页面边缘时,页面应该自动滚动。

2 分钟面试版

如果让我设计一个低代码平台的核心编排引擎,我会先把它理解成两件事:第一,用一套稳定的数据结构去描述页面;第二,用引擎把拖拽、配置、事件这些编辑行为,转换成对这套数据的修改。

整体上我会拆成 4 层。第一层是协议层,也就是 schema 或 DSL,定义页面、组件、容器、事件这些基础模型。第二层是渲染层,根据组件 type 动态匹配对应的 React 物料组件,把 schema 渲染成真实页面。第三层是编排层,也就是布局和拖拽引擎,这是低代码最核心的部分。比如给每个节点打上 data-node、data-container 这类标记,拖拽开始时收集可投放区域,拖拽过程中做碰撞检测,判断当前应该插到目标节点的上、下、左、右,最后转成组件树的插入或移动。这个过程可以基于 interactjs 实现,布局上优先支持 Flex 容器,后面再扩展 Grid 和自由布局。第四层是运行时层,主要负责事件编排和数据联动,比如按钮点击调用接口、表单提交后刷新列表、成功后跳转页面,这些都应该抽象成统一的事件流,而不是写死在组件里。

如果结合实现细节,我会把页面拆成组件树和组件属性表两部分,类似 blockTree 加 blocks。这样结构和属性解耦之后,插入、移动、删除、更新都会更清晰,后面做撤销重做、版本发布和性能优化也更容易。

我觉得低代码平台几个关键技术点主要是:第一,数据模型要稳定,方便存储、版本和回滚;第二,拖拽和渲染性能要好,避免复杂页面卡顿;第三,要有插件化机制,支持自定义物料和扩展能力;第四,编辑态和运行态要分离;第五,事件编排要可观测、可调试,不然后面页面复杂后很难排查问题。

可能的追问

  1. 为什么要用组件树加属性表这种结构? 答:因为组件层级和组件属性是两类不同的数据,拆开后移动节点只改树,改属性只改字典,更新粒度更细,也更适合做撤销重做和性能优化。

  2. 拖拽插入位置怎么判断? 答:一般会在拖拽开始时生成虚拟投放区域,拖拽过程中用鼠标位置和这些区域做碰撞检测,命中后得到 parentId、relativeBlockId 和 position,结束后再提交到 store。

  3. 为什么布局优先选 Flex,不是一开始就自由拖拽? 答:因为企业后台、表单、详情页这类场景大部分是结构化布局,Flex 更稳定,也更容易做响应式。自由布局更适合海报、大屏、看板,但复杂度更高。

  4. 动态组件加载怎么做? 答:做一个组件注册中心,每个物料注册自己的渲染器、属性面板、默认 schema、事件描述。渲染时按 type 去查注册表,这样新增组件不需要改核心逻辑。

  5. 事件编排为什么重要? 答:因为低代码不只是搭页面,更重要的是业务联动。页面搭完之后,按钮点了做什么、接口回来了怎么更新状态,这些都要靠事件引擎去编排。

  6. 怎么做撤销重做? 答:常见做法是命令模式或者状态快照。编辑器每次操作记录一条命令或一个 diff,撤销时回滚,重做时重放。

  7. 性能怎么保证? 答:一是组件局部渲染,避免整棵树刷新;二是拖拽碰撞检测做节流;三是大页面可以做虚拟化或分层渲染;四是 schema 更新尽量走最小变更。

  8. 编辑态和运行态为什么要分离? 答:编辑态需要选中、高亮、拖拽、配置面板这些能力,运行态只关心业务逻辑和页面表现。混在一起会让系统越来越难维护。

  9. 插件化机制怎么设计? 答:我会定义统一物料协议,包括元信息、渲染器、属性 schema、事件 schema、校验器。平台只认协议,不关心具体业务组件实现。

  10. 如果做企业级低代码,还要补什么? 答:权限体系、版本发布、多人协作、运行时监控、埋点、错误恢复、跨页面数据源管理,这些都是后面必须补齐的。