低代码平台里经常会听到一句话:Renderer 根据 Schema 把页面渲染出来。
这句话没有错,但太粗。真正做起来会发现,同一份 DSL 至少会在三种环境中运行:
编辑态预览态生产态
它们都消费同一份页面 DSL,但目标完全不同。
编辑态需要拖拽、选中、辅助线、组件边框、层级管理。预览态需要尽量模拟真实页面。生产态关心性能、稳定性、埋点、安全和 SEO。
如果用同一个 Renderer 同时承载三种职责,代码会很快变得混乱。因此低代码平台最好从一开始就区分:
Editor RendererPreview RendererRuntime Renderer
Renderer 的基础职责
最简单的 Renderer 可以这样写:
function Renderer({ schema }) { return schema.components.map(node => { const Component = componentMap[node.type]; return ( <Component key={node.id} {...node.props} /> ); });}
这个版本可以解释低代码渲染的本质:根据 DSL 找到组件,再把配置传进去。
但真实平台里,Renderer 还要做更多事情:
- 加载组件资源。
- 注入上下文变量。
- 解析表达式。
- 处理数据源。
- 绑定事件动作。
- 注册埋点。
- 处理组件错误。
- 区分编辑态和生产态能力。
所以 Renderer 更像一条运行管线,而不是一个简单 map。
编辑态 Renderer 关注可操作性
编辑态是用户搭建页面的环境。它不只要渲染组件,还要让组件可被编辑。
编辑态需要额外包一层编辑容器:
function EditorBlock({ node, children }) { return ( <div data-component-id={node.id} className="editor-block" > {children} </div> );}
这层容器承担很多编辑能力:
显示组件边框响应点击选中拖拽排序命中展示插入占位显示组件工具栏收集组件尺寸和位置信息
编辑态 Renderer 渲染出来的 DOM 不等于最终线上 DOM。它会有大量辅助节点和编辑状态。这些东西不能进入生产态。
编辑态还需要处理 iframe 通信。用户在 iframe 里点击组件时,预览页要把选中组件 ID 发给外层编辑器:
window.parent.postMessage({ type: "COMPONENT_SELECT", payload: { id: node.id }});
外层编辑器收到消息后,右侧属性面板展示对应组件配置。
预览态 Renderer 关注真实效果
预览态介于编辑态和生产态之间。
它不需要拖拽和属性编辑,但需要尽量接近真实页面。比如:
不显示组件边框不显示拖拽辅助线执行真实事件执行数据请求执行部分埋点模拟模拟移动端视口模拟不同渠道参数
预览态常用于发布前检查。它应该尽可能发现配置问题,但又不能真的产生线上副作用。
例如表单提交,在预览态可以走 mock:
预览态提交表单 -> 校验字段 -> 展示成功状态 -> 不真正写入 CRM
优惠券领取,在预览态也不应该真的发券:
预览态领取优惠券 -> 校验事件链路 -> 返回模拟成功 -> 不调用真实发券服务
这意味着 Renderer 需要接收运行模式:
type RenderMode = "edit" | "preview" | "runtime";
不同模式下,数据源、事件、埋点和副作用的执行策略不同。
生产态 Renderer 关注性能和稳定性
生产态 Renderer 是用户真正访问页面时运行的代码。它应该尽量轻。
生产态不需要:
- 拖拽逻辑。
- 选中态。
- 属性面板通信。
- 编辑器调试面板。
- Monaco Editor。
- dnd-kit 或 react-dnd。
- 组件库分类信息。
- 缩略图。
生产态只需要:
读取 DSL加载组件解析表达式执行数据请求渲染页面执行事件动作上报埋点处理异常
如果把编辑器依赖打进生产 Runtime,页面首屏会被严重拖慢。低代码平台尤其容易犯这个错误,因为编辑器和运行时都在消费同一份组件协议,很容易混到一个包里。
比较稳妥的方式是从包层面分离:
@lowcode/editor@lowcode/preview-renderer@lowcode/runtime-renderer@lowcode/shared-schema@lowcode/component-loader
共享的是协议和少量工具,不共享编辑器 UI。
Renderer 要支持组件按需加载
营销组件库会不断膨胀。一个活动页面可能只用了头图、优惠券和留资表单,但组件库里还有轮播、抽奖、直播预约、门店卡、视频、车型列表。
生产态 Renderer 不应该加载全量组件,而应该根据 DSL 按需加载:
const componentTypes = collectComponentTypes(schema);await Promise.all( componentTypes.map(type => loadComponent(type)));
更进一步,还可以区分首屏和非首屏:
首屏组件:页面初始化加载非首屏组件:滚动进入视口前加载交互组件:用户触发时加载
例如抽奖组件、视频组件、地图组件都不应该阻塞首屏。
编辑态和生产态的加载策略也不同。编辑态可以为了体验提前加载更多组件信息;生产态则应该只加载页面实际用到的资源。
Renderer 要处理表达式和数据上下文
组件 props 里可能不只是静态值:
{ "props": { "title": "{{ user.name }} 的专属优惠", "items": "{{ dataSources.productList.data }}" }}
Renderer 在渲染前需要解析这些表达式:
function resolveProps(rawProps, context) { return Object.fromEntries( Object.entries(rawProps).map(([key, value]) => { return [key, resolveExpression(value, context)]; }) );}
这里的 context 可以包含:
页面变量URL 参数用户信息数据源结果组件局部状态运行模式
表达式解析不能散落在组件内部。否则每个组件都要理解 {{ }},平台就没有统一的数据绑定模型。
Renderer 要有组件级错误隔离
低代码页面由多个组件组成,一个组件失败不应该拖垮整个页面。
生产态应该有组件级错误边界:
function SafeComponentRenderer({ node, Component, props }) { return ( <ComponentErrorBoundary componentId={node.id}> <Component {...props} /> </ComponentErrorBoundary> );}
错误类型也要区分:
组件资源加载失败组件渲染异常数据源请求失败表达式解析失败事件动作执行失败
不同错误对应不同降级:
| 错误 | 处理 |
|---|---|
| 非首屏组件加载失败 | 展示兜底或隐藏 |
| 首屏核心组件失败 | 展示兜底模块并上报 |
| 数据源失败 | 展示空态或重试按钮 |
| 表达式失败 | 使用默认值 |
| 事件失败 | 中断动作链或走失败分支 |
Renderer 要把异常控制在组件边界内,而不是让整页白屏。
SEO 场景需要静态化 Renderer
如果低代码页面只在客户端读取 DSL 再渲染,首屏和 SEO 都会受影响。
对于需要 SEO 或高性能首屏的页面,可以在发布时做静态化:
读取页面 DSL -> 拉取首屏数据 -> Runtime Renderer 服务端渲染 -> 生成 HTML -> 上传 CDN
动态 Schema 渲染和静态化发布不是二选一,而是两种模式:
| 模式 | 优点 | 缺点 |
|---|---|---|
| 动态 Schema 渲染 | 灵活,修改快 | 首屏依赖接口,SEO 弱 |
| 静态化发布 | 首屏快,SEO 好 | 发布链路更复杂 |
营销平台可以根据页面类型选择。临时活动页可能更看重快速生效,品牌专题页可能更看重首屏和 SEO。
小结
低代码 Renderer 不是一个简单的 schema.components.map。
同一份 DSL 在不同阶段有不同目标:
- 编辑态要让页面可操作。
- 预览态要模拟真实效果。
- 生产态要保证性能和稳定。
因此 Renderer 要在架构上拆分,而不是在一个组件里堆满 if (editMode)。真正稳定的低代码平台,会把编辑器能力、预览能力和生产运行时分开,让它们共享协议,但不共享不该共享的复杂度。