开发面板架构
面板 + 沙盒的分离设计、postMessage 通信协议和技术实现细节
ktr 开发面板采用面板 + 沙盒分离架构:面板是预构建的 React 静态应用,用户模板在 iframe 沙盒里渲染,两者通过 postMessage 通信,确保调试界面与模板渲染环境互不干扰。
架构设计
整体架构图
┌──────────────────────────────────────────────────────┐
│ Vite Dev Server (localhost:5180) │
│ │
│ ┌────────────────────┐ ┌─────────────────────┐ │
│ │ 面板中间件 │ │ 沙盒虚拟模块 │ │
│ │ /__ktr/panel │ │ virtual:ktr-sandbox │ │
│ │ (预构建静态应用) │ │ (动态生成) │ │
│ └────────────────────┘ └─────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Mock API (/__ktr/api) │ │
│ │ - GET /templates (模板列表) │ │
│ │ - GET /data (列表/读取数据) │ │
│ │ - PUT /data (保存 JSON) │ │
│ │ - DELETE /data (删除 JSON) │ │
│ │ - GET /stream (SSE 数据推送) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ WebSocket HMR │ │
│ │ - 模板源码热更新(沙盒内生效) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ 开发面板 │ │ 沙盒 iframe │
│ (React) │◄────────►│ (React 环境) │
│ │postMessage│ │
│ - 模板导航 │ │ - 动态 import 组件 │
│ - 数据选择/编辑 │ │ - React 渲染 │
│ - 主题构建器 │ │ - 尺寸测量上报 │
│ - 画布缩放/平移 │ │ - 错误边界 │
└──────────────────────┘ └──────────────────────┘分离设计的优势
| 方面 | 传统方案 | ktr 分离架构 |
|---|---|---|
| 样式隔离 | 面板样式污染模板 | iframe 完全隔离 |
| React 上下文 | 共享 context,易冲突 | 独立 root,互不干扰 |
| 错误隔离 | 模板错误影响面板 | 沙盒崩溃不影响面板 |
| 热更新 | 整体刷新 | 模板更新不刷新面板 |
面板前端
技术栈
- 框架:React 19 + HeroUI(
@heroui/react) - 样式:Tailwind CSS v4
- 路由:react-router-dom,模板地址
/templates/<板块>/<模板>?data=<数据名>,可分享、可刷新 - 画布:自研 GSAP 变换引擎(
panel/canvas/,滚轮光标锚点缩放、拖拽惯性平移、双击/快捷键适应) - 分发形式:随包预构建为静态资源(
panel/dist),dev server 的面板中间件挂在/__ktr/panel托管,不需要下游构建面板源码
核心功能模块
面板源码在 packages/core/panel,主要组件:
| 模块 | 组件 | 职责 |
|---|---|---|
| 模板导航 | App.tsx | 按板块分组展示模板树,路由同步当前选中项 |
| 数据选择 | DataFileSelector.tsx | 列出 TS mock / JSON mock / captured.json,切换数据源 |
| 数据编辑 | JsonEditor.tsx、MockDataEditorModal.tsx | 编辑并保存 JSON mock(TS mock 只读) |
| 主题 | ThemeBuilderPanel.tsx、PanelThemeSelect.tsx | 构建下发给模板的主题变量;切换面板自身明暗 |
| 预览画布 | PreviewPanel.tsx | 承载沙盒 iframe,缩放/平移/截图预览 |
加载模板列表
面板从 Mock API 读取模板清单(每次读取前 dev server 会先刷新 .ktr 注册表,新增模板无需重启):
interface TemplateMeta {
: string
?: string
?: string
}
export function () {
const [, ] = <TemplateMeta[]>([])
(() => {
// GET /__ktr/api/templates → { templates: [...] }
('/__ktr/api/templates')
.(() => .())
.(() => (.templates))
}, [])
return
}下发数据到沙盒
用户选择模板和数据后,面板把路由、数据和运行时上下文一起发给沙盒:
function (: string, : unknown) {
const = ()
.?.(
{
: 'ktr-panel',
: 'ktr:data',
: { , , : () }
},
..
)
}沙盒虚拟模块
生成逻辑
沙盒入口是 Vite 虚拟模块 virtual:ktr-sandbox,由 sandboxPlugin 在 load 阶段动态生成:约定扫描出全部模板路由后,逐路由生成 /@fs/ 动态导入,并引入 CSS 入口:
export async function (): <string> {
// 逐路由动态导入而不是整包导入 .ktr 注册表:
// 注册表是单模块,导入即同步拉起全部模板,无法按模块粒度上报进度
const = await (.)
const =
.(() => ` ['${.}', () => import('/@fs/${.(., .)}')]`)
.(',\n')
return `
import React from 'react'
import { createRoot } from 'react-dom/client'
const templateLoaders = [
${}
]
const templates = {}
// 逐个加载模板,每完成一个上报一次进度
for (const [route, load] of templateLoaders) {
templates[route] = (await load()).default
window.parent.postMessage({
source: 'ktr-sandbox',
type: 'ktr:register-progress',
payload: { loaded: Object.keys(templates).length, total: templateLoaders.length, path: route }
}, window.location.origin)
}
// 全部加载完,上报模板元信息
window.parent.postMessage({
source: 'ktr-sandbox',
type: 'ktr:ready',
payload: { templates: Object.entries(templates).map(([path, def]) => ({ path, name: def.name, description: def.description })) }
}, window.location.origin)
`
}渲染与主题
沙盒内维护当前选中的路由、数据和上下文,收到 ktr:data / ktr:theme 消息后重新渲染。主题变量同时写到 html 和 body 上,只注入面板显式提供的字段:
const = [
['--background', 'background'],
['--foreground', 'foreground'],
['--accent', 'accent'],
['--accent-foreground', 'accentForeground']
// 其余语义色同理
] as
function (: <ThemeContext>) {
const = . === 'dark' ? 'dark' : 'light'
...('dark', === 'dark')
... =
for (const [, ] of ) {
const = []
// 显式提供的写入,未提供的移除——框架不发明默认色,组件库自身主题生效
if (typeof === 'string' && ) {
...(, )
} else {
...()
}
}
}渲染出错时由错误边界接管:用固定尺寸的占位卡替换模板位置,画布的测量、缩放、拖拽行为与正常模板一致,同时向面板上报 ktr:error。
尺寸测量与上报
React 渲染是异步的,沙盒按帧轮询容器尺寸,连续几帧稳定后才上报 ktr:rendered,避免把半截高度发给面板:
function (: number) {
let = 0
let = 0
let = 0
const = () => {
const = .('container')!.()
const = .(1, .(.))
const = .(1, .(.))
= === && === ? + 1 : 0
=
=
if ( < 3) {
()
return
}
('ktr:rendered', {
: ,
: .(.() - ),
: { , }
})
}
()
}postMessage 通信协议
所有消息都带 source 字段('ktr-panel' / 'ktr-sandbox')区分方向,目标 origin 限定为当前窗口源。
消息类型定义
// ===== 面板 → 沙盒消息 =====
export type =
| { : 'ktr-panel'; : 'ktr:select'; : { : string } }
| { : 'ktr-panel'; : 'ktr:data'; : { : string; : unknown; ?: <string, unknown> } }
| { : 'ktr-panel'; : 'ktr:theme'; : { ?: <ThemeContext> } }
| { : 'ktr-panel'; : 'ktr:panel-theme'; : { : boolean } }
| { : 'ktr-panel'; : 'ktr:inspect'; : { : boolean } }
// ===== 沙盒 → 面板消息 =====
export type =
| { : 'ktr-sandbox'; : 'ktr:ready'; : { : <{ : string; ?: string; ?: string }> } }
| { : 'ktr-sandbox'; : 'ktr:register-progress'; : { : number; : number; : string } }
| { : 'ktr-sandbox'; : 'ktr:rendered'; : { : string; : number; ?: { : number; : number } } }
| { : 'ktr-sandbox'; : 'ktr:error'; : { ?: string; : string; ?: string } }
| { : 'ktr-sandbox'; : 'ktr:hmr'; : { : string } }ktr:panel-theme 控制的是 iframe 画布背板的明暗(来自面板外壳主题),与下发给模板的 ktr:theme 解耦。
通信流程
1. 初始化
面板加载
↓
创建 iframe(src="/__ktr/sandbox")
↓
iframe 加载 virtual:ktr-sandbox
↓
沙盒逐个 import 模板组件
↓
每加载一个,发送 ktr:register-progress
↓
全部加载完,发送 ktr:ready(携带模板元信息)
↓
面板收到 ready,展示模板树2. 渲染模板
用户点击模板
↓
面板发送 ktr:select { path }
↓
面板从 Mock API 加载数据列表,用户选择数据
↓
面板发送 ktr:data { path, data, ctx }
↓
沙盒渲染组件(错误边界兜底)
↓
沙盒等待尺寸稳定(连续 3 帧无变化)
↓
沙盒发送 ktr:rendered { path, elapsed, size }
↓
面板按上报尺寸调整画布3. 主题切换
用户在主题构建器中调整
↓
面板发送 ktr:theme { theme }
↓
沙盒把显式提供的变量写到 html/body,移除未提供的
↓
沙盒重新渲染当前模板
↓
沙盒发送 ktr:rendered { size }Mock API 设计
Mock API 是挂在 Vite dev server 上的中间件(/__ktr/api 前缀),不是独立的 Express 服务。
接口一览
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /__ktr/api/templates | 模板列表(读取前先刷新 .ktr 注册表) |
| GET | /__ktr/api/data?path=<路由> | 数据源列表 { entries: [{ name, source, readonly }] } |
| GET | /__ktr/api/data?path=<路由>&name=<名称> | 读取单条数据 |
| PUT | /__ktr/api/data?path=<路由>&name=<名称> | 保存 JSON mock |
| DELETE | /__ktr/api/data?path=<路由>&name=<名称> | 删除 JSON mock |
| GET | /__ktr/api/stream | 数据文件变更的 SSE 推送 |
读取单条数据时同名 JSON 优先于 TS mock;TS mock 在面板中只读(PUT 返回 409),因为它是类型安全的源码,应直接编辑文件。captured.json 是 { data, ctx } 完整快照,接口会解包后连同 ctx 一起下发,面板回放时能还原后端真实下发的主题等上下文。
export async function (: any, : any) {
const = new (.url, 'http://ktr.local')
const = ..('path')!
const = ..('name')!
// 1. 优先 JSON mock(面板可编辑)
const = .(., , 'data', `${}.json`)
if (.()) {
const = .(.(, 'utf-8'))
return (, 200, { , : 'json', : false, })
}
// 2. 回退 TS mock(面板只读)
const = (await ()).(() => . === )
if () {
return (, 200, { , : 'ts', : true, : . })
}
(, 404, { : 'Data entry not found' })
}SSE 数据推送
dev server 监听 mock 目录下的 data/*.json 变更(复用 Vite 的 watcher,按模板路由 100ms 去抖),通过 /__ktr/api/stream 的 SSE 通道推送给面板。事件名固定为 ktr:data-files-changed,负载为 { templatePath, file }:
export function (: ViteDevServer) {
..(.)
const = (: string) => {
// 只有各模板 data/ 子目录里的 JSON 才推送给面板
if (!.('.json') || !.('data')) return
// 去抖后广播:{ templatePath: 'hello/card', file: 'captured.json' }
({ : 'hello/card', : .('/').()! })
}
..('add', )
..('change', )
..('unlink', )
}面板侧用 EventSource 监听,captured.json 变更时自动刷新并选中——真实渲染一发生,面板里就能立刻看到这份数据:
export function () {
const = new ('/__ktr/api/stream')
.('ktr:data-files-changed', () => {
const { , } = .(( as ).)
(, )
})
return () => .()
}用 SSE 而不是 Vite 的 HMR WebSocket:Vite 8 的 HMR WebSocket 对浏览器同源连接强制校验 token,面板内手写 WS 客户端拿不到 token,SSE 通道不受此限制。
WebSocket HMR
模板源码的热更新走 Vite 自带的 HMR:模板文件保存后,沙盒 iframe 里的模块直接热替换并重新渲染(沙盒随后向面板上报 ktr:hmr)。面板自身不受影响,因为与沙盒隔离。