路由设计:让低代码从"一个页面"升级成"一个应用"

1 阅读11分钟

本文补齐了路由这块拼图,使得引擎从"只能搭一个页面"推进到"能搭一个多页的应用(App)"。

术语约定

后文统一使用下列英文术语,避免多名词同意义等混用:

术语指什么说明
Designer设计器编辑 Schema 的环境;对应 DesignerRuntime
Preview预览运行 Schema 的环境;对应 PreviewRuntime
App被搭建的应用AppSchema 描述的多页产物
Host外部宿主嵌入已部署 App 的外层系统
dev-shellPreview 开发导航开发调试用

补充: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):

fe875447-b75b-47c4-80ef-dd274a8edbb2.png

各层在干什么:

  • AppSchema — 整本应用的「目录」:pages[] 列出所有页面,defaultPageId 指定默认页。
  • Router Service — 路由管理器,提供pushback 等功能。
  • 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.1Router 接口与 HashRouter 实现
5.2Runtime 注入 services.router
5.3Preview 按路由渲染当前页
5.4dev-shell 开发导航(Preview 里的切页效果)
5.5AppDocument 管理多页编辑
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 注入 nullRouterpush / back 空操作);Preview 注入 HashRouter

5.3 Preview 支持多页面

单页时代,Preview 启动后直接渲染一份 Schema。改成多页后,入口要做三件事:

  1. 读整本应用 — 从 localStorage 取出 AppSchema(里面有多页的 pages[]
  2. 看 URL 决定渲染哪一页router.current 和每页的 path 对上,就取那页的 schema
  3. 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-shellPreviewShell)——给开发者用的调试导航,不是应用本身的一部分

布局上拆成两个平级区域:

┌─────────────────────────────────────┐
│  dev-shell   [首页] [列表] [详情] …  │  ← 开发导航,不进 AppSchema
├─────────────────────────────────────┤
│  应用内容区                          │  ← 5.3 渲染的 page.schema
└─────────────────────────────────────┘

工作方式很简单:

  1. dev-shell 读 AppSchema.pages,把页面名列成 tab
  2. 点击 tab → 调 router.push(path),和应用内按钮跳转走同一条路
  3. 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

多页面管理.png


六、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 里搭导航,要么让 HostIRouter 驱动——路由能力只有一份,用法分场景。


七、设计取舍: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 包住 SchemaAppDocument 包住 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 这一层。这一步迈出去,后面真正的业务平台才有地方挂。