从理论到实践:TinyEngine低代码运行时渲染全攻略

编程达人挑战赛·第5期 10w+人浏览 128人参与

本文由TinyEngine运行时渲染解决方案贡献者龚昱帆同学原创。

前言

运行时渲染器用于在浏览器中直接渲染低代码 Schema,提供与“出码”并行的即时运行路径,可在设计阶段获得接近真实的交互与数据效果。

1.启动流程与案例讲解

下面用一个非常简单的示例页面,串联起从 Schema 到运行时渲染的完整流程。这个页面包含:

  • 一段提示文案;
  • 一个显示计数的按钮;
  • 点击按钮时,计数加一。

1.1 环境准备

  • 确保已拉取包含 runtime-renderer 包的新版本代码。
  • 在项目根目录执行:
    • pnpm install 安装依赖
    • pnpm run dev 启动项目
      或参考前后端联调文档视频来启动JAVA后端联调,获得更好的开发体验

1.2 配置页面 Schema

1). 创建页面 DemoA,并添加页面状态 state1
请添加图片描述

2). 在页面中拖入 Text 和 TinyButton 组件:

  • Text 文本内容为“[state测试]:点击增加button计数”;
  • TinyButton 的 text 绑定表达式 this.state.state1.button
  • TinyButton 的 onClick 绑定表达式 this.onClickNew1

请添加图片描述
请添加图片描述

3). 在“页面 JS”中添加方法 onClickNew1

请添加图片描述

1.3 运行时渲染链路

当点击“运行时渲染”按钮或直接访问 runtime 页面时,

请添加图片描述

runtime-renderer 会:
1). 解析 URL,得到 appId、tenant 以及当前路由信息。若当前正在编辑某页面,将自动路由至该页面,基于页面树中每个节点的 route 段,按祖先链拼接为 #/<a>/<b>/<c>,示例链接为 http://localhost:8090/runtime.html?id=1&tenant=1&platform=1#/demoa , 如果需要设计器内内容有更新的话则需要重新加载运行时页面以同步。
2). 通过 useAppSchema 拉取 App Schema,初始化应用配置。
3). 并找到 DemoA 对应的 page_content
4). RenderMain 使用该 page_content 构建页面上下文:

  • 初始化页面 state;
  • 解析方法 onClickNew1,并注入上下文;
  • 注入页面级 CSS Scope。

5).调用 renderer 按照 Schema 递归生成 VNode 树:

  • Text 节点直接渲染静态文案;
  • TinyButton 节点:
    • 解析 text 的 JSExpression,读取 this.state.state1.button,初始值为 1;
    • 解析 onClick 的 JSExpression,将其解析为 onClickNew1 函数引用。

6). Vue 将 VNode 树挂载到 DOM,用户看到的就是一个按钮显示“1”的页面。

当用户点击按钮时:

  • 绑定在 onClick 上的函数 onClickNew1 被执行;
  • 函数在当前页面上下文中运行,执行 this.state.state1.button++
  • Vue 响应式系统检测到 state 变化,触发 TinyButton 文本重新渲染;
  • 按钮上的数字从 1 变为 2、3、4……
    请添加图片描述
    请添加图片描述
    (本项目为开源之夏活动贡献,欢迎大家体验并使用)源码可参考:https://github.com/opentiny/tiny-engine/tree/ospp-2025/runtime-rendering

2.技术概述

在 TinyEngine 中,页面的结构、样式和交互逻辑都被描述成一份 JSON Schema。设计器负责让开发者以可视化方式编辑 Schema,而真正交付给浏览器的是由代码生成或运行时渲染出来的 Vue 应用。

runtime-renderer 的目标,是在浏览器中直接把 Schema 渲染成一个可交互的 Vue 应用,形成一条与出码并行的“即时运行路径”:

  • 同一份 Schema 同时服务于设计态画布、运行时渲染器和出码结果。
  • 支持应用级配置(物料包、i18n、数据源、工具函数等)。
  • 支持区块、循环、条件、插槽、状态与事件函数等完整能力。

3.整体架构:从 App Schema 到真实页面

从高层看,runtime-renderer 的核心链路可以概括为:

 URL 参数(appId)
 ↓
 加载 App Schema 和页面列表
 ↓
 初始化应用级环境(物料 / i18n / 数据源 / utils / 全局 CSS)
 ↓
 根据 pageId 选中页面 pageSchema
 ↓
 RenderMain 构建页面上下文并解析 state / methods
 ↓
 renderer 按 Schema 递归生成 Vue VNode 树
 ↓
 Vue 挂载到真实 DOM

3.1 模块划分

按职责拆分,大致有以下几个模块:

  • useAppSchema

    • 拉取整个应用的 Schema(应用元信息 + 页面列表)。
    • 初始化物料包、依赖、数据源、工具函数、i18n 和全局 CSS。
    • 暴露获取页面列表、按 id 取 pageSchema 的接口。
  • app-function 相关模块

    • 封装物料包加载、importMap 处理、数据源初始化、工具函数初始化等通用逻辑。
    • 对外提供 getDataSource()getUtilsAll() 等查询接口。
  • RenderMain + PageRenderer

    • PageRenderer 是对外的高阶组件,外部只需传入 pageId
    • RenderMain 负责:
      • 基于 pageId 选择当前页面的 pageSchema
      • 构建页面上下文(state、route、router、stores、dataSourceMap、utils、cssScopeId 等);
      • 解析页面定义的 methods 和 state;
      • 调用 renderer 渲染页面。
  • renderer(render.ts)

    • 核心渲染器,把 schema 节点映射为真实组件 VNode。
    • 处理组件解析、属性解析、循环、条件、插槽、区块与 CSS 作用域等。
  • parser(parser.ts)

    • 配置解析引擎,把 JSExpression / JSFunction / i18n / 插槽等配置形式统一解析成运行时值或函数。
  • page-function 系列

    • 提供页面级 state 管理、CSS Scope 管理、Block 上下文等能力。

3.2 三层上下文

为了让表达式和函数在运行时拥有完整信息,runtime-renderer 构建了三层上下文:

  • 应用级上下文:物料组件、数据源集合 dataSourceMap、国际化配置、工具函数(utils)、应用级 CSS、router、stores 等。
  • 页面级上下文:页面 state、当前路由信息、page 级 CSS Scope Id、页面 methods 和生命周期配置。
  • 区块级上下文:区块自己的 state 和 CSS Scope,通过 getBlockContext / getBlockCssScopeId 生成。

所有 JSExpression / JSFunction、插槽函数都会在“局部作用域(如循环变量)→ 页面/区块上下文 → 应用级上下文”的组合环境下执行。

4.详细设计说明

4.1 应用级初始化

应用级初始化发生在运行时入口加载完成之后,主要包括以下几步。

4.1.1 从后端加载完整应用 Schema

runtime-renderer 会通过两个接口拉齐应用配置:

  • /app-center/v1/api/apps/schema/:appId

    • 返回应用元信息(包括全局变量globalState)、物料包 packages、组件映射 componentsMap、数据源 dataSource、国际化 i18n、工具函数 utils、全局 CSS 等。
  • /app-center/api/pages/list/:appId

    • 返回页面列表,每个页面都包含路由、标题及设计器保存的 page_content

useAppSchema 聚合这两部分数据,在内存中形成完整的 App Schema,后续所有页面渲染都基于这份数据。

4.1.2 初始化物料与依赖

物料与依赖的初始化,实际上分为两个层次:

1). 基础物料包(bundle.json)加载

  • useAppSchema 会优先从 /mock/bundle.json 中读取 data.materials.packages,得到一批基础物料包的配置;
  • 这些包通常是 TinyEngine 预置的常用物料(例如 TinyVue 组件库),会作为“基础环境”优先拉取;
  • loadPackageDependencys(packages) 负责按这些配置加载对应的 JS/CSS 资源。

2). 按组件映射加载具体物料组件

  • 根据 App Schema 中的 componentsMappackages,runtime-renderer 会生成组件依赖描述:
    • 每个组件对应哪个 npm 包;
    • 是默认导出还是具名导出,是否需要解构;
    • 包含哪些 JS 资源与 CSS 资源;
  • 然后通过 getComponents 逐个拉取这些组件实现,并配合 addStyle 注入样式。

整体上,是先按 bundle.json 约定拉取基础物料包,再根据 Schema 中的 componentsMap 精细加载具体组件。加载完成后,组件会被挂到全局对象(如 window.TinyLowcodeComponent / window.TinyComponentLibs),以便渲染阶段通过组件名查找对应实现。

4.1.3 初始化 importMap 与第三方依赖

对于在/mock/bundle.json中引入的包需要的子依赖和其他通过 CDN 引入的第三方库,runtime-renderer 使用 importMap 做统一映射:

  • import-map.json 中维护包名到实际 CDN 地址的映射;
  • 启动时将 importMap 注入到浏览器环境,使动态加载的模块可以直接用包名引用。
4.1.4 初始化国际化配置

应用级 Schema 中的 i18n 部分包含多语言文案:

  • 运行时遍历各 locale 的文案条目;
  • 将它们合并到国际化实例(如 i18n.global)中;
  • parser 在执行表达式时,如果检测到 this.i18nt( 的使用,会自动把翻译函数注入到上下文中。
4.1.5 初始化工具函数

工具函数 utils 以配置形式存在于 App Schema 中,目前支持两类来源:

1). NPM 包工具函数(type: ‘npm’)

  • 在 Schema 中约定包名、版本号、导出名、是否解构、子字段 subName 等;
  • 运行时通过 CDN(如 https://unpkg.com/<package>@<version>)动态 import 该包;
  • 根据配置选择默认导出或具名导出;
  • 这样可以在不改动运行时代码的前提下,引入第三方 NPM 包作为工具函数使用。

2). 函数型工具函数(type: ‘function’)

  • 以 JSFunction 形式写在 Schema 中;
  • 运行时通过 parseJSFunction 解析为真实函数并缓存。

所有解析出来的工具函数都会统一挂到一个工具函数集合中,通过 getUtilsAll() 暴露,页面上下文再以 utils 形式注入,表达式和方法可以通过 this.utils.xxx 调用这些工具。

4.1.6 初始化数据源

数据源配置 dataSource 描述了应用中可用的远程或本地数据源。初始化过程会:

  • 把每个数据源封装为可直接调用的对象;
  • 统一挂到 dataSourceMap 下,例如 this.dataSourceMap.tableTest1.load(params)
  • 按设计器的 dataHandler 约定处理后端返回结构,尽量统一为形如 { items, total } 的通用格式,方便表格使用。

页面级函数和生命周期可以通过 this.dataSourceMap 使用这些数据源。

4.1.7 加载区块 Schema

区块(Block)是一种可复用的页面片段,runtime-renderer 会通过 /material-center/api/blocks 拉取区块列表:

  • 将区块按 label 组织成映射,例如 window.blocks['Group1Test1'] = { schema, meta }
  • 渲染时,如果发现 componentName 对应某个区块 label,就把它当作 Block 组件处理:
    • 使用区块自身的 schema;
    • 生成独立的 Block 上下文和 CSS Scope;
    • 内部递归渲染其 children。
4.1.8 初始化全局变量

runtime-renderer 基于 Pinia 来管理运行时的全局变量,即 stores:

  • 启动入口 initRuntimeRenderer 中,会先调用 generateStoresConfig(),根据 App Schema 中的全局状态配置等生成一份标准的 stores 配置;
  • 然后创建 Pinia 实例,并通过 createStores(storesConfig, pinia) 将这些配置注册为实际的 Pinia store;
  • 最后把得到的 stores 对象通过 app.provide('stores', stores) 注入整个应用,在页面组件中可以通过依赖注入的方式拿到;
  • RenderMain 在构建页面上下文时,会把这份 stores 注入到 context 中,表达式和方法可以通过 this.stores.xxx 访问对应的 store。

这样,设计器可以通过配置的方式声明全局状态切片,而运行时则统一落在 Pinia 的实现之上,享受其响应式和开发者工具生态。

4.1.9 初始化路由(vue-router)

runtime-renderer 使用 vue-router 来管理页面级导航:

  • createAppRouter 中,会从 useAppSchema().pages 读取所有页面配置,根据每个页面的 routeidparentIdisHomeisDefault 等信息生成路由表;
  • 每个页面都会变成一条 routepath 来自 page.routecomponent 统一指向惰性加载的 PageRenderer,并通过 props: { pageId: page.id } 把页面 id 透传进去;
  • 通过 parentId 字段拼出嵌套路由结构,并根据 isDefault 在父级上设置默认子路由重定向,根据 isHome 生成从 / 到首页的重定向;
  • 最后基于这些动态生成的 routes 调用 createRouter({ history: createWebHashHistory('/runtime.html'), routes }) 得到 router,启动入口 initRuntimeRenderer 会把它挂到应用上,使页面可以通过 hash 路由进行切换。

4.2 页面级渲染入口

页面级渲染的核心是两个组件:对外暴露的 PageRenderer,以及真正做事的 RenderMain

4.2.1 PageRenderer:对外形态

对使用方来说,只需要:

<PageRenderer :pageId="currentPageId" />

PageRenderer 内部会把 pageId 透传给 RenderMain,对外隐藏所有与 Schema 解析和上下文构建相关的细节。

4.2.2 从 pageId 到 pageSchema

RenderMain 在 setup 中会:

  • 通过 useAppSchema().getPageById(pageId) 找到对应页面对象;
  • 从中取出 page_content 作为当前页面的 schema;
  • computed 包装,确保后续 Schema 更新可以被捕捉;
  • page_content 做一次深拷贝,避免渲染过程中意外修改原始数据。

随后使用 watch 监听当前 schema:

  • 首次进入页面时立即执行一次,调用 setSchema 完成初始化;
  • 后续如果设计器更新了该页面并同步到运行时,再次触发 setSchema,实现设计态 → 运行态的实时联动。
4.2.3 页面上下文的构建

setSchema 是 RenderMain 的关键逻辑,它会基于当前 pageSchema 构建出页面级上下文:

  • 从路由系统获取 routerouter
  • 通过依赖注入拿到全局 stores
  • 通过 app-function 获取 dataSourceMaputils
  • 使用 useState 初始化页面级 statesetState
  • 生成当前页面的 cssScopeId,例如 data-te-page-<pageId>

这些信息被组合成 contextData,在 setSchema 开头通过 setContext(contextData, true) 注入运行时上下文:

  • true 表示清空旧上下文,避免页面切换或 Schema 更新时残留状态。
  • 后续解析 methods 和 state 时,都会在这个上下文中执行。
4.2.4 方法与状态的初始化顺序

setSchema 内部,初始化顺序大致为:

1). 设置上下文环境:先调用 setContext(contextData, true),确保 this.statethis.storesthis.dataSourceMapthis.utils 等在之后解析中都可用。
2). 解析并注入 methods:对 schema 中的 methods 逐项执行 parseData

  • 将 JSFunction 字符串解析为真实函数;
  • 使用 generateFn 包装,让其在执行时带上完整上下文并具备异常兜底;
  • 放入 methods 容器,并合入 context。

3). 初始化 state:调用 setState(newSchema.state, true)

  • 根据 defaultValue 填充 state;
  • 对带 accessor 的字段记录 getter / setter 行为;
  • 在很多场景下,state 中的表达式会依赖 props、utils、stores、methods,因此需要放在 methods 之后。

4). 注入页面级 CSS:调用 setPageCss(pageSchema.css, cssScopeId)

  • 为当前页面注入带 [data-te-page-<id>] 前缀的样式;
  • renderer 渲染节点时会自动附加该 attribute,实现样式隔离。

这样的顺序可以保证上下文完整,避免出现“方法或状态在解析时访问不到依赖”的情况。

4.2.5 Render 函数中的根容器

RenderMain 的 render 函数不会直接把 pageSchema.children 交给 renderer,而是先构造一个根容器:

const rootChildrenSchema = {
	componentName: 'div',
	props: { ...(pageSchema.props || {}) },
	children: pageSchema.children
}
  • 这样能与“出码”的根结构保持一致,也便于统一挂载页面级样式和属性。
  • pageSchema.children 非空,则渲染:
h(renderer, { schema: rootChildrenSchema, parent: pageSchema })
  • 若 children 为空,则渲染一个 Loading 组件,避免页面完全空白。

4.3 核心渲染器:从 Schema 到 VNode

renderer 负责把 Schema 节点转成 Vue VNode,parser 负责把各种配置数据解析成运行时值,两者协同完成渲染。

4.3.1 组件解析

根据节点的 componentName,renderer 会按以下顺序查找对应实现:

1). 内置 Canvas 系列组件映射(如 TextImgRouterLinkCollection 等)。
2). 运行时加载的 TinyVue 组件和 window.TinyLowcodeComponent 中注册的物料组件。
3). 自定义元素(Web Components),通过 customElements 映射表预留扩展点。
4). 原生 HTML 标签:如果 componentName 是合法 HTML 标签,直接作为标签名使用。
5). 区块组件:如果在 window.blocks 中找到同名 block,则:

  • 动态创建一个 Vue 组件;
  • 在组件内部基于 block 的 schema 和 block 上下文递归渲染 children;
  • 使用 block 独立的 CSS Scope Id。

若以上都未命中,则使用占位组件(如 CanvasPlaceholder)兜底,保证渲染不因单个节点错误而中断。

4.3.2 属性解析与 CSS Scope

Schema 中的 props 可能包含多种形式:普通值、JSExpression、JSFunction、状态访问器、图标配置、插槽声明等。renderer 会通过 parseData 对其统一解析,生成“干净”的 props 对象:

  • JSExpression:在当前 scope + 上下文下执行表达式,得到最终值;
  • JSFunction:解析为真实函数并绑定上下文;
  • 状态访问器:按默认值或 getter 逻辑解析;
  • 插槽声明:根据配置生成对应的 Slot 函数;
  • 其他对象和数组属性:递归调用 parseData

在此基础上,renderer 会:

  • 根据 scope 或 context 中的 cssScopeId,给非 Block 组件自动添加形如 [data-te-page-xxx]: '' 的属性,用于样式作用域隔离;
  • 对 Canvas 和 Block 组件额外挂上 schema 字段,便于组件内部根据 Schema 进行渲染;
  • className 重命名为 class,避免覆盖组件内部样式约定。
4.3.3 循环、条件与作用域

循环和条件渲染通过 looploopArgscondition 三个字段来描述:

  • loop:通常是 JSExpression,返回一个数组;
  • loopArgs:描述 item 和 index 在表达式中的名称,例如 ['row', 'i']
  • condition:JSExpression,决定是否渲染该节点。

renderer 的流程是:
1). 使用 parseData(loop, scope, context) 得到循环数组。
2). 对每一个 item,调用 parseLoopArgs 生成局部作用域(如 { row, i })。
3). 合并到当前作用域,得到 mergeScope
4). 用 parseCondition(condition, mergeScope, context) 判断是否渲染该节点。
5). 在 mergeScope 下解析 children 和 props,生成对应 VNode。

如果没有配置 loop,则在当前 scope 下渲染一次节点即可。

4.3.4 children 与插槽

children 的处理有多种情况:

  • 若组件被标记为容器且 children 为空,会自动注入 CanvasPlaceholder,提升设计和调试体验。
  • 若 children 不是数组且本身是表达式,则直接调用 parseData(children, scope, context),常用于 Text / 简单插值场景。
  • 若 children 是普通数组且不包含 Template,则通过 renderGroup 递归渲染每个子节点。
  • 若 children 中包含 componentName: 'Template'
    • 使用 generateSlotGroup 按 slotName 分组;
    • 为每个 slot 生成形如 ($scope) => renderDefault(children, { ...scope, ...$scope }) 的函数;
    • 在创建组件 VNode 时作为 slots 传入,实现命名插槽效果。
  • 对 Web Components,renderer 会在需要时为子节点自动添加合适的 slot 属性,满足自定义元素插槽规范。
4.3.5 parser 的角色

parser 是一个“多类型配置解析器”,通过一张规则表将不同类型的数据转换为运行时值:

  • 通过不同的 type(data) 函数识别 JSExpression、JSFunction、JSSlot、i18n、状态访问器、Icon、字符串、数组、对象等;
  • 针对每种类型提供 parseFunc(data, scope, ctx),实现对应的解析逻辑;
  • 统一入口 parseData(data, scope, ctx) 根据第一个匹配的类型选择合适的解析函数。

renderer 在解析 props、children、loop、condition 时都会调用 parseData,从而在“不了解配置细节”的前提下获得正确的运行时值。

当前数据源和 Collection 组件在 Schema 层面并未做 parser 级别的特殊处理,它们在解析时与普通组件一致,数据源相关逻辑主要依赖上下文中的 dataSourceMap 和组件自身的协议约定来实现。

5.总结

runtime-renderer 把原本只在出码阶段才能完成的“Schema → 运行应用”的过程搬到了浏览器端:

  • 通过 useAppSchema 拉取并初始化 App Schema,搭建应用级运行环境;
  • 通过 RenderMain 构建页面级上下文,统一管理 state、methods、路由、数据源和样式;
  • 通过 renderer 和 parser 将 Schema 节点递归转换为 Vue VNode,并在多层上下文中安全执行表达式与函数。

对于设计器使用者来说,它提供了一条“所见即所得”的运行路径。

(本项目为开源之夏活动贡献,欢迎大家体验并使用)源码可参考:https://github.com/opentiny/tiny-engine/tree/ospp-2025/runtime-rendering

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:https://opentiny.design
OpenTiny 代码仓库:https://github.com/opentiny
TinyVue 源码:https://github.com/opentiny/tiny-vue
TinyEngine 源码: https://github.com/opentiny/tiny-engine
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~
如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值