本文补齐了路由这块拼图,使得引擎从"只能搭一个页面"推进到"能搭一个多页的应用(App)"。
术语约定
后文统一使用下列英文术语,避免多名词同意义等混用:
| 术语 | 指什么 | 说明 |
|---|---|---|
| Designer | 设计器 | 编辑 Schema 的环境;对应 DesignerRuntime |
| Preview | 预览 | 运行 Schema 的环境;对应 PreviewRuntime |
| App | 被搭建的应用 | 即 AppSchema 描述的多页产物 |
| Host | 外部宿主 | 嵌入已部署 App 的外层系统 |
| dev-shell | Preview 开发导航 | 开发调试用 |
补充:
AppDocument回调里的「上层入口 main.tsx」指 Designer 的装配层,与 Host 不是同一概念。
一、背景:从"一个页面"到"一个应用"的鸿沟
在此之前,我的低代码引擎已经具备了基础的单页能力:物料拖拽、属性配置、循环渲染、表达式解析、事件交互、绑定数据源、生命周期、Designer / Preview 双 Runtime 打通等。
但它始终困在一个边界里——只能描述"一个页面"。而真实业务里,哪怕最简单的系统也存在类似「登录 → 列表 → 详情 → 编辑」这样的多页流转能力。
所以缺的这块拼图,就是路由。:
那么当低代码要从"一个页面"升级成"一个应用",谁来管理"多个页面"?路由属于哪一层?页面切换又如何实现?
接下来我将逐层说明我的思路。
二、目标:完成路由闭环
对于当前版本,我的核心目标是:用户在 Designer 里管理多个页面(增删改切),在 Preview 里通过路由跳转页面,并渲染对应的内容。
三、架构设计:路由属于 App 级别
这是整个设计里最重要的一个原则。
平台现有的 Runtime 是页面级的——它负责把一份 Schema 解释成可渲染的节点树、管理页面上下文和事件。而路由是跨页面的,它天然应该高于单个页面。
所以我引入了一个新的层级——App 级:
App
├── Router Service # 路由服务,App 级别(V1:HashRouter)
├── PageManager # 页面列表管理,Designer 侧
└── Runtime # 页面级,保持不变
└── services.router # Runtime 持有 Router 引用,供方法跳转等使用
这个 App 级别的分层的好处是面向未来的:不只是路由,以后 History、AI 这些跨页面的能力,也会陆续"升"到 App 级。
运行时链路:从「应用配置」到「屏幕上的页面」
用一句话说清整条链路:Preview 启动时读整份应用配置,Router 决定当前该显示哪一页,Runtime 把这一页的 Schema 跑起来,Renderer 画到屏幕上。 用户点链接或改 URL 时,Router 先变,后面几步跟着重跑。
下面用「用户从首页切到列表页」举例(URL 从 /#/home → /#/list):
各层在干什么:
- AppSchema — 整本应用的「目录」:
pages[]列出所有页面,defaultPageId指定默认页。 - Router Service — 路由管理器,提供
push、back等功能。 - currentPage.schema — 当前页要渲染的内容,单页时代的 Schema。
- PreviewRuntime — 页面级运行时:解释 Schema,并通过
ctx.services.router把路由能力交给页内 methods 使用。 - RuntimeContext — 运行时的上下文:表达式解析、事件、
dataSource请求都在这里。 - Renderer — 最后一环,把解析结果画成 DOM。
和单页时代对比:以前 Preview 直接拿一份 Schema 就渲染;现在多了一层「Router 先选页,再拿该页 Schema」。URL 一变,从 Router 往下整条链路重跑一遍。
跳转直接通过 methods 调用 ctx.services.router.push(),不走 Command 体系。
因为路由跳转本质上不是“修改数据”,而是“切换视图”,不需要撤销。
四、协议设计:AppSchema 包住 Schema
多页能力在协议上只做一件事:在原有单页 Schema 外面套一层应用壳,不改动 Schema 内部字段
interface AppSchema {
pages: PageSchema[] // 应用包含哪些页面
defaultPageId: string // 首次进入默认打开哪一页
}
interface PageSchema {
id: string // 页面唯一标识,如 'page-list'
name: string // 显示名,如 '列表页'
path: string // 路由 path,如 '/list'
schema: Schema // 页面内容 —— 现有 Schema 结构原封不动
}
关键点:PageSchema.schema 就是现有的 Schema,一个字段都不改。AppSchema 只是多记录了「有哪些页、默认哪一页」。
Preview / Designer 统一读写 lowcode-app(单页时代的 lowcode-schema 升级为整份 AppSchema)。渲染、编辑、表达式等单页能力全部复用,相当于给原来的页面套了个「多页封面」。
五、技术方案:六步落地
Preview 一条线(5.1~5.4),Designer 一条线(5.5~5.6):
| 步骤 | 内容 |
|---|---|
| 5.1 | Router 接口与 HashRouter 实现 |
| 5.2 | Runtime 注入 services.router |
| 5.3 | Preview 按路由渲染当前页 |
| 5.4 | dev-shell 开发导航(Preview 里的切页效果) |
| 5.5 | AppDocument 管理多页编辑 |
| 5.6 | 页面管理面板 UI |
5.1 Router Service:IRouter 接口 + HashRouter 实现
路由这块我拆成两层:先定接口,再写实现。
上层(Runtime、页面里的 methods)只认 IRouter,不关心底层是 Hash 还是 History。V1 用 HashRouter——改 hash 就能跳转,以后可以扩展 HistoryRouter 实现同一套接口就行。
IRouter 只暴露四个能力:
interface IRouter {
current: string // 当前在哪个 path
params: Record<string, string> // URL 上的 query,如 { id: '123' }
push(path: string, params?: Record<string, string>): void // 路由跳转
back(): void // 返回上一页
}
V1 的 HashRouter 读写的都是 location.hash,地址长这样:/#/edit?id=123
class HashRouter implements IRouter {
get current(): string {
return location.hash.slice(1).split('?')[0] || '/'
}
get params(): Record<string, string> {
const query = location.hash.slice(1).split('?')[1] ?? ''
return Object.fromEntries(new URLSearchParams(query))
}
push(path: string, params?: Record<string, string>): void {
const query = params ? '?' + new URLSearchParams(params).toString() : ''
location.hash = path + query
}
back(): void {
history.back()
}
}
HashRouter 本身只管读写的逻辑,不监听 hashchange——监听放在 Preview 入口,这样 Router 类干净、也好单测。
5.2 Runtime 接入:services.router 沿作用域链继承
页面里的 methods 是这样跑的:编译成 function(ctx) { ... },调用时把 ctx 传进去。所以跳转是写 ctx.services.router.push(...)。
我把 router 挂在 RuntimeContext.services 上,规则和 methods 类似——子 ctx沿用根 ctx 那一份,loop 里的节点也能调到同一个 router:
export interface RuntimeServices {
router: IRouter
}
// 显式传入 > 继承父级 > 兜底空实现
this.services = init.services ?? parent?.services ?? { router: nullRouter }
设计态不需要真跳转,所以 DesignerRuntime 注入 nullRouter(push / back 空操作);Preview 注入 HashRouter。
5.3 Preview 支持多页面
单页时代,Preview 启动后直接渲染一份 Schema。改成多页后,入口要做三件事:
- 读整本应用 — 从
localStorage取出AppSchema(里面有多页的pages[]) - 看 URL 决定渲染哪一页 —
router.current和每页的path对上,就取那页的schema - URL 变了就重渲染 — 监听
hashchange,再跑一遍上面的逻辑
入口逻辑可以概括成下面这样:
const app = loadAppSchema() // 1. 读出应用:pages[] + defaultPageId
const router = new HashRouter()
function renderPage() {
const page = matchPage(app, router.current) // 2. 根据 URL 找到当前页
render(page.schema, { router }) // 3. 只渲染这一页的 Schema
}
renderPage() // 首次打开
onRouteChange(renderPage) // URL 变了 → 换页再渲染
就多了 matchPage(Router 选页) 和 onRouteChange(监听路由变化) 两步;渲染单页 Schema 的那套逻辑没动。
5.4 Preview 开发导航:dev-shell
5.3 解决的是「应用怎么按路由渲染」。开发 Preview 时还有一个实际问题:页面一多,得有个地方快速切页,总不能切页都在 Schema 里先搭导航。
所以 Preview 顶部加了一条 dev-shell(PreviewShell)——给开发者用的调试导航,不是应用本身的一部分。
布局上拆成两个平级区域:
┌─────────────────────────────────────┐
│ dev-shell [首页] [列表] [详情] … │ ← 开发导航,不进 AppSchema
├─────────────────────────────────────┤
│ 应用内容区 │ ← 5.3 渲染的 page.schema
└─────────────────────────────────────┘
工作方式很简单:
- dev-shell 读
AppSchema.pages,把页面名列成 tab - 点击 tab → 调
router.push(path),和应用内按钮跳转走同一条路 - URL 变了,外壳和应用各自重渲染——外壳更新高亮,应用区换页
// 外壳:只负责展示 + 点 tab 改路由
function PreviewShell({ app, router }) {
return app.pages.map(page =>
<Tab
active={page.path === router.current}
onClick={() => router.push(page.path)}
/>
)
}
dev-shell 的存在意义、和生产环境怎么导航,见第六节。
5.5 Designer 切页:AppDocument 管列表,Engine 管编辑
Preview 解决了「怎么按路由换页」,Designer 要解决「怎么管多页编辑」。
我的做法是加了一个 AppDocument,专门管页面清单(有哪些页、当前编辑哪一页)。它不取代原来的 DocumentModel——Engine 依然只认识「当前这一页的document」,单页时代的拖拽、改属性、撤销,都不用改。
切页时只做一件事:把目标页的 Schema 挂上去。
function switchPage(pageId) {
saveCurrentPage() // 切走前:保存编辑内容
engine.attachDocument(new DocumentModel(page)) // 挂上目标页,走引擎原有切文档逻辑
notifyHost() // 通知外层:渲染等
}
也就是说,多页编辑 = 页面列表(AppDocument)+ 单页编辑(DocumentModel) 两层分工,没有为路由单独再造一套编辑器。
5.6 页面管理面板:UI 插件,逻辑交给 AppDocument
左侧面板多了一个「页面管理区」,由 PageManagerPlugin 注入。用户能做的事很直观:
- 新建一页
- 点击切换当前编辑的页
- 改名称、改 path
- 删除(至少保留一页)
插件只管 UI 和交互,增删改查页面都交给 AppDocument,数据和 Preview 读的是同一份 AppSchema。
六、dev-shell 与生产导航:两套用法,一套路由契约
Preview 顶部的 dev-shell,只为开发期服务——让你在还没搭应用内菜单时,也能快速切页、联调路由。它不进 AppSchema,上线时裁掉,不会跟着应用部署。
容易混淆的一点是:dev-shell 展示了路由能力,但不是终端用户该看到的导航。到了生产环境,多页跳转靠下面两种方式,本质都是调 IRouter / ctx.services.router.push,和 dev-shell 走的是同一条契约。
方式一:App 自带导航(主路径)
App 在 Schema 里自己搭导航——例如用 Nav / Menu 物料,点击时调:
// 页面 methods 里
goList: "function(ctx) { ctx.services.router.push('/list') }"
导航写在 AppSchema 的页面内容里,随 App 一起发布。用户点菜单 → router 变 → 当前页重渲染。这是大多数场景的默认做法。
方式二:Host 驱动导航
App 被嵌进另一个系统(如 iframe 里),Host 想从外部控制 App 切页:
Host 的导航
│ 点击
▼
改 location.hash(或 postMessage → router.push)
│
▼
App hashchange → 重渲染对应页
Host 和 App 之间零私有耦合——dev-shell 在 Preview 里示范的正是这套模式:外壳只 router.push,接触不到引擎内部。
总结:dev-shell 是开发脚手架,生产要么在 App 里搭导航,要么让 Host 经 IRouter 驱动——路由能力只有一份,用法分场景。
七、设计取舍:AppDocument 切页后,谁来负责重挂 canvas ?
接入 AppDocument 时遇到一个典型的分层问题:切页在 App 层,canvas 重挂在 Designer 层,中间怎么衔接?
发生了什么
AppDocument.setActivePage 内部会调 engine.attachDocument(new DocumentModel(...)),把目标页的 Schema 挂到 Engine 上。
而 Engine 的 attachDocument 在换文档前,会先 detachCanvas() 卸掉旧画布——这是合理的,避免 DragManager、HoverManager 等资源泄漏。但它只负责卸,不会帮你重新挂上。
所以流程变成:
setActivePage(列表页)
→ attachDocument(列表页 DocumentModel)
→ Engine 内部 detachCanvas() ✓ 旧画布卸掉了
→ (没人 attachCanvas) ✗ 中间编辑区空白
为什么
attachDocument不顺便attachCanvas?
切文档 Engine 管,重挂 canvas 由 Designer 装配层管(CanvasOptions属于 designer-app,不该 Engine 去 new)。
为什么不把重挂写进 AppDocument
直觉做法是在 setActivePage 里顺手 attachCanvas()。但 canvas、拖拽、hover 都是 Designer 的事,不是「多页编排」该知道的:
| 层 | 该管什么 |
|---|---|
AppDocument | 页面列表、切页、Schema 写回 |
Engine | 挂/卸 DocumentModel、attach 时清理 canvas |
Designer 装配层(main.tsx) | 怎么重挂 canvas、什么时候持久化 |
如果把 attachCanvas 写进 AppDocument,它就不得不 import DragManager、依赖 designer-app——App 层和 Designer 耦合在一起,Preview 等场景复用 AppDocument 会很痛苦。
做法:回调把 Designer 副作用交还装配层
AppDocument 切页完成后,通过 onActivePageChanged 回调通知 Designer 装配层:「文档已换,后续你处理。」
const appDocument = new AppDocument(engine, appSchema, {
onActivePageChanged: () => {
attachCanvasForCurrentPage() // Designer:重挂 DragManager / HoverManager
saveApp(engine) // Designer:持久化 AppSchema
},
})
AppDocument.setActivePage
├── syncActivePageSchema()
├── engine.attachDocument(...) // Engine:换文档(会 detachCanvas)
└── onActivePageChanged?.() // 通知 Designer 装配层
│
▼
main.tsx:attachCanvas + saveApp
AppDocument 只管换页;canvas 重挂、保存Schema 这些是 Designer 特有的事,所以通过回调交还给装配层。 后续抽 @lowcode/app 时继续保留这一分界——副作用外置为 AppHost 接口,不写死在编排层里。
八、成果展示
完整演示主流程:
Designer 新建多页 → 各页独立编辑 → 保存 AppSchema
↓
Preview 打开 → dev-shell / 应用内按钮切页 → URL 与内容同步
↓
按钮 router.push + 传参 → 目标页渲染 → 刷新仍保持当前页
九、总结
这次升级做了什么
其实这次路由设计的升级主要是想清楚每样东西该放在哪一层:
- 路由升到 App 级 — Runtime 仍只管单页;跨页能力走
IRouter,以后 DataSource、AI 等也可以沿这条路往上层挂 - 协议是「包裹」不是「重写」 —
AppSchema包住Schema,AppDocument包住DocumentModel,单页时代的编辑、渲染、表达式完全不用改 - 边界要划清楚 — 跳转不走 Command;methods 经
ctx.services.router访问路由;dev-shell 只经IRouter通信,和应用内容隔离
慢慢感受到——低代码引擎的演进(甚至一个平台的演进),重要的是把功能放到对的位置,位置对了,才能稳。
后续方向
V1 是基础功能闭环,演进方向还有以下思考:
- App 层独立化 — 把
AppDocument抽成@lowcode/app,副作用经AppHost外置,让 Designer、Preview、Host 共用同一套多页编排,而不是绑死在 designer-app - Router 生产化 — 在
IRouter不变的前提下补 History 模式、动态参数、路由守卫,以及与 DataSource / 进页拉数据等联动 - 多页编排体验 — Designer 侧的页面排序、分组、嵌套路由等,降低复杂 App 的搭建成本
这一版的设计,让平台从「一个页面」到「一个应用」,是给引擎立起了 App 这一层。这一步迈出去,后面真正的业务平台才有地方挂。