项目:运营搭建平台(vue3-course) | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇选取三个核心文件做逐段带码走读:编辑器画布核心(useDashboardEditor.ts——历史栈、防回环、单例 scope)、发布链路(dashboard-stage.ts——白名单冻结与双重校验)、一个 EP 物料从注册到渲染的全程(elementPlusMetas.ts + ElementPlusRenderer.vue)。每个片段摘自当前源码(行号以文件实际为准),先码后解,每节末尾给一段「这段代码教会我们什么」的小结。机制的鸟瞰图与流程图在《02-页面设计》《05-核心流程》《07-模块详解》已有,本篇不重复画图,只做逐行解读;相关决策的来龙去脉见《16-决策记录ADR》对应条目。
文件:packages/work-front/src/pages/dashboard-editor/composables/useDashboardEditor.ts(1263 行)。它是看板编辑器的"唯一事实源":组件树、多选、页面状态与方法、布局模式全部在这里,三栏面板与画布 iframe 都读写它。本节走读其中最精巧的三段——历史栈的提交模型、防重复入栈、单例的 effectScope。
// —— 撤销 / 恢复历史栈 ——
// 快照为「提交时点的 components + 页面变量 + 页面方法 全量」(页面级扩展数据
// 随快照进栈,变量/方法增删可撤销)。离散操作(增/删)执行后立即提交;
// 连续变更(画布拖动、属性编辑)经 deep-watch + debounce 合并为一次提交。
// undo/redo 前会先 flush 未决提交,保证栈顶与当前状态一致。
const HISTORY_LIMIT = 30;
const HISTORY_COMMIT_DELAY = 300;
interface HistorySnapshot {
components: ComponentInstance[];
state: Record<string, unknown>;
methods: Record<string, string>;
layoutMode: CanvasLayoutMode;
background: string;
}
// 栈用 ref 包装:computed 需感知 length 变化(普通数组 length 非响应依赖)
const emptySnapshot = (): HistorySnapshot => ({
components: JSON.parse(JSON.stringify(components.value)),
state: {},
methods: {},
layoutMode: 'flow',
background: ''
});
const undoStack = ref<HistorySnapshot[]>([emptySnapshot()]);
const redoStack = ref<HistorySnapshot[]>([]);
let commitTimer: ReturnType<typeof setTimeout> | null = null;
const canUndo = computed(() => undoStack.value.length > 1);
const canRedo = computed(() => redoStack.value.length > 0);逐段解读。先看常量:HISTORY_LIMIT = 30 与 HISTORY_COMMIT_DELAY = 300 是两个直接决定编辑手感的数字——30 条上限让历史栈的内存有界(每条是全量 JSON 快照,30 × 看板大小即最坏内存),300ms 是"连续变更合并窗口"。注释里藏着三个设计判断:其一,快照是全量而非增量——HistorySnapshot 把 components、pageState、pageMethods、layoutMode、background 五样全打进去,这换来"任何操作的撤销语义天然一致"(01 篇的说法),代价是内存与深拷贝开销;其二,初始栈就压入一条空快照(undoStack = [emptySnapshot()]),这让 canUndo 的判定可以写成 length > 1——栈底那条是"基准态"而非"可撤销的操作",这个细节避免了"新建看板第一秒就能撤销到虚空"的边界 bug;其三,undoStack 用 ref 包装而注释解释了为什么——"computed 需感知 length 变化(普通数组 length 非响应依赖)",如果用裸数组 + canUndo computed,push/shift 之后按钮状态不会刷新,这是 Vue 3 响应式的经典陷阱(reactive 数组才有响应 length,ref 包住整组才稳妥)。emptySnapshot 里 state/methods 给空对象而非引用 pageState.value,是有意与"载入前的空态"对齐——组件树深拷贝,页面级字段直接用初始值。
设计意图:历史栈模型选了Memento 全量快照(01 篇设计模式表),这段代码就是这个模式的最小实现。值得注意的是它对"编辑器撤销"这个问题的拆解方式:把操作分成"离散操作"(增删粘贴,一条操作一条历史)与"连续变更"(拖动、属性编辑,300ms 内合并为一条),两类入口不同但都收敛到同一个 pushCommit——分类解决频率问题,收敛解决一致性问题。
function snapshotAll(): HistorySnapshot {
return {
components: JSON.parse(JSON.stringify(components.value)),
state: JSON.parse(JSON.stringify(pageState.value || {})),
methods: JSON.parse(JSON.stringify(pageMethods.value || {})),
layoutMode: layoutMode.value,
background: canvasBackground.value
};
}
function pushCommit() {
const snap = snapshotAll();
const stack = undoStack.value;
const top = stack[stack.length - 1];
// 与栈顶一致则不重复入栈(拖拽回弹、flush 空转等场景)
if (top && JSON.stringify(top) === JSON.stringify(snap)) return;
stack.push(snap);
if (stack.length > HISTORY_LIMIT) stack.shift();
redoStack.value.length = 0; // 产生新分支后 redo 失效
}
/** 立即提交当前状态为一条历史 */
function commitNow() {
if (commitTimer) {
clearTimeout(commitTimer);
commitTimer = null;
}
pushCommit();
}
/** 延迟合并提交(连续变更稳定后再记) */
function scheduleCommit() {
if (commitTimer) clearTimeout(commitTimer);
commitTimer = setTimeout(() => {
commitTimer = null;
pushCommit();
}, HISTORY_COMMIT_DELAY);
}逐段解读。snapshotAll 是快照工厂:五个字段全部深拷贝(JSON.parse(JSON.stringify(...))——10 篇第 1 节已指出这是千节点看板的隐性热点,packages/business/src/drag/drag.vue 61 行的 TODO 是同一问题的页面线版本)。pushCommit 是整个栈的唯一写入口,三行各有一个语义:第一行防重复——JSON.stringify(top) === JSON.stringify(snap) 全文比对栈顶,注释点名了两个真实场景"拖拽回弹、flush 空转"(拖拽结束又弹回原位时,300ms 后的定时提交拿到的状态与栈顶完全一样,不防重就会记一条无意义历史,用户按一次 Ctrl+Z 却"什么都没撤销");第二行限长——超限 shift() 掉最老的;第三行清 redo——产生新分支后 redoStack.value.length = 0,这是所有 undo 系统的标准语义(回到过去再行动,未来的分支作废),用"清数组"而不是"重新赋值空数组"是为了保持 ref 引用稳定。commitNow 与 scheduleCommit 是两个入口:前者先清定时器再立即入栈(flush 语义——离散操作执行时,把之前未决的 300ms 窗口一并结算),后者是标准的 debounce 重置。两者组合出的效果:连续拖动 5 秒,中间只产生一条历史(最后一个稳定态);拖完立刻按 Ctrl+Z,undo 内部的 commitNow() 先把最后一次变更结算入栈,再弹栈——不会丢最后一次编辑。
设计意图:这段代码对"撤销历史"的处理是把状态一致性放在实现简洁之前。防重的全文比对在千节点看板上并不便宜,但它买的 correctness 是硬的:栈里永远不会出现两条相同的历史。JSON.stringify 在这里被用了三次(快照、比对、恢复),全文没有引入任何深拷贝库——这是一个"承认代价、换取零依赖"的取舍,10 篇给出了结构共享的优化方向,但在组件数未到瓶颈前它是合理的欠账。
/**
* 画布/属性等无明确入口的连续变更统一走 debounce 提交。
* watcher 必须挂在独立 scope 上:本 composable 由模块级单例服务创建,
* 若跟随宿主组件作用域,离开编辑器路由时会被 Vue 停掉,
* 而单例不会重建 —— 之后所有编辑都不再进入历史,一次撤销直接回到初始态。
*/
effectScope(true).run(() => {
watch(components, scheduleCommit, { deep: true });
});逐段解读。只有四行,却是全文件最贵的一段注释。机制层面:useDashboardEditor() 是模块级单例(由 dashboard-service 创建一次、所有面板共享,02 篇第 3 节),Vue 的 watch 若在组件 setup 里直接调用会绑定到宿主组件的作用域——组件卸载时 watch 自动停止。而单例不会随路由离开而销毁,于是出现一个阴险的组合:宿主组件(编辑器壳)销毁了、单例还活着、但 watch 死了——之后所有编辑照常发生(组件树照常更新、画布照常渲染),只有历史栈沉默。用户感知是 12 篇第 4 节那条 FAQ:"撤销一次回到很久以前"——因为期间只记了极少数历史,一次 undo 直接跳回最初。修复就是把 watch 挂在 effectScope(true)(创建独立、不随宿主销毁的效应作用域)里。deep: true 是另一半:组件树任意深处的属性变更都要触发提交,这是"无明确入口的连续变更"的兜底通道。
设计意图:这是典型的生命周期错位 bug——单例的生命周期(模块级)与 watch 的默认生命周期(组件级)不一致。注释的价值在于把因果链完整写了下来("离开编辑器路由时会被 Vue 停掉,而单例不会重建——之后所有编辑都不再进入历史"),09 篇 840 行的 useDashboardEditor.test.ts 为此覆盖了历史栈行为,让这个修复有回归网。
/** 应用某历史快照(components + 派生 layout + 页面级数据),清空失效选中 */
function applySnapshot(snap: HistorySnapshot) {
components.value = JSON.parse(JSON.stringify(snap.components));
layout.value = components.value.map((item: ComponentInstance) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.w,
h: item.h
}));
selectedIds.value = selectedIds.value.filter((id: string) =>
Boolean(findComponentNode(id))
);
pageState.value = JSON.parse(JSON.stringify(snap.state || {}));
pageMethods.value = JSON.parse(JSON.stringify(snap.methods || {}));
layoutMode.value = snap.layoutMode || 'flow';
canvasBackground.value = snap.background || '';
}
function undo() {
commitNow();
const stack = undoStack.value;
if (stack.length <= 1) return;
const current = stack.pop()!;
redoStack.value.push(current);
applySnapshot(stack[stack.length - 1]);
}逐段解读。applySnapshot 是回放的唯一实现,五个动作各司其职:深拷贝回填组件树(从快照拷出而非直接引用——快照必须保持不可变,否则回放后再编辑会污染历史);从 components 派生重建 layout(栅格条目不进快照,每次回放重算——这是"单一事实源"原则的执行:layout 只是 components 的投影,存两份就会漂移);过滤失效选中(撤销删除后,被删组件的 id 已不在树上,选中集合要清洗,否则属性面板指向幽灵节点);页面级数据同批回放;layoutMode/背景带兜底值(旧快照可能没有这两个字段)。undo 的第一行 commitNow() 是注释里"undo/redo 前先 flush 未决提交"的落点——不 flush 就弹栈,最后一次未结算的编辑会"凭空消失"(它既不在栈里也已被覆盖);stack.length <= 1 与 1.1 的基准态呼应。
其一,撤销系统 = 状态快照 + 提交策略 + 生命周期管理三件事,缺一不可,而第三件最容易被忽略——watch 挂错作用域不会报任何错,只会静默地让功能退化。其二,单一写入口 + 全文比对防重是历史栈稳定的关键:所有变更收敛到 pushCommit,防重逻辑只需要写一处。其三,派生态不进快照(layout 每次重算)是"单一事实源"在 undo 场景的具体化。其四,注释写因果链而不是写结果——"挂在独立 scope 上"是结果,"否则离开路由后所有编辑都不再进入历史"才是下一个维护者需要的知识。这四点共同解释了 02 篇说的"面板改数据,画布只渲染,这条单向数据流是编辑器稳定性的根基"。
文件:packages/work-server/src/service/dashboard-stage.ts(310 行)。它是三阶段发布的服务端实现(流程见 05 篇流程二,决策见 ADR-003/005),本节走读四段:白名单的收集与脱敏、三阶段推进的主体、公开闸门、以及访客取数通道的终点校验。
/** 从看板 schema 收集组件绑定的数据源 id(去重保序) */
export function extractDataSourceIds(schemaJson: unknown): string[] {
const schema = parseJsonText(schemaJson as any);
const ids: string[] = [];
const components = Array.isArray(schema?.components) ? schema!.components : [];
for (const comp of components) {
const dsId = comp?.dataBinding?.dataSourceId;
if (dsId && !ids.includes(dsId)) {
ids.push(dsId);
}
}
return ids;
}
/** 生成脱敏白名单:仅 {id, name, type},config(含 mysql 密码等)不进发布产物 */
export async function buildPublishedDataSources(schemaJson: unknown) {
const ids = extractDataSourceIds(schemaJson);
const published: Array<{ id: string; name: string; type: string }> = [];
for (const id of ids) {
const ds = await datasourceModel.findDataSourceByUuid({ uuid: id });
if (ds) {
published.push({ id: ds.uuid, name: ds.name, type: ds.type });
}
}
return published;
}
function withPublishedDataSources(
schemaJson: string,
published: Array<{ id: string; name: string; type: string }>
): string {
const schema = parseJsonText(schemaJson) || {};
return JSON.stringify({ ...schema, publishedDataSources: published });
}逐段解读。三个函数构成"收集 → 补全 → 注入"三步。extractDataSourceIds 的防御姿态值得注意:输入是 unknown 类型(schemaJson 可能是字符串也可能是对象——库里 JSON 列读出形态不定,parseJsonText 统一规整,这是 07 篇第 7 节说的 server-kit 收敛历史),Array.isArray 逐层确认后才取值,任何一层缺省都安全落空——发布是写路径,解析防御意味着"脏草稿不会把发布流程打挂"。!ids.includes(dsId) 实现去重保序(多个组件绑同一数据源只记一次,顺序与组件顺序一致——白名单顺序稳定让发布产物可 diff)。buildPublishedDataSources 是"脱敏"的执行点:查回完整数据源行后只挑三个字段(id/name/type),config(含连接口令)物理上不进入返回值——白名单不是"标记哪些可见",而是"重新构造一个最小对象",这是比过滤更强的保证(过滤可能漏字段,构造从源头就没有)。被删数据源(查不到行)静默跳过——发布不应因草稿里的悬空绑定而失败。withPublishedDataSources 把白名单写进 schema 副本,注意它输出的是新 JSON 串,不改原草稿——发布快照与草稿从此分叉。
设计意图:白名单的哲学是 08 篇反复出现的"先收窄再使用":访客能触达什么,在 test 发布那一刻就被冻结成一份极小的、不含任何凭据的清单;之后草稿怎么改都不影响已发布内容。这里还能看到 04 篇 4.1 节说的"纵深防御的序列化边界"——publishedDataSources 字段本身在 shared 的类型里就有声明,前端拿到 prod schema 也读不到任何敏感键。
// prod:复制 pre → prod(版本在事务内行锁自增),并自动打 publish 快照
const pre = await dashboardStageModel.findStageByUuid('pre', uuid);
if (!pre) {
result.message = '请先发布到预发环境';
return result;
}
// 公开准入闸门(S1-②):只拦"进入公开面"的这一档 —— prod + public 且内容含页面方法。
// 校验对象取 **pre 的快照** 而不是草稿:prod 复制的是 pre,pre 才是真正要公开的内容;
// 若作者在推过 test/pre 之后才删掉草稿里的方法,草稿干净而 pre 仍带方法,只查草稿会放行。
// test/pre 不拦:dashboard_info_test/_pre 只有 work-server 编辑态(需登录)读得到。
const publicGate = findPageMethodsGateViolation({
schemaJson: pre.schemaJson,
accessMode: access.dashboard?.accessMode || 'public',
stage: 'prod'
});
if (publicGate) {
result.message = publicGate;
return result;
}
// 访问方式只注入模式,令牌不再进发布产物(portal 读作者态实时行判定,令牌不外泄)
const prodSchema = parseJsonText(pre.schemaJson) || {};
const schemaJson = JSON.stringify({
...prodSchema,
accessMode: access.dashboard?.accessMode || 'public'
});
const currentVersion = await runTransaction(async (query) => {
const nextVersion = await dashboardStageModel.nextProdVersion(uuid, query, true);
await dashboardStageModel.upsertStageInfo(
'prod',
{
uuid,
name: pre.name,
description: pre.description || '',
currentVersion: nextVersion,
schemaJson,
publishUserUuid: userData.uuid
},
query
);
// pre.schemaJson 从库中读出为对象,落快照表前重新序列化(同阶段表复制的规范化)
await snapshotModel.createDashboardSnapshot(
{
dashboardUuid: uuid,
name: `发布 v${nextVersion}`,
source: 'publish',
schemaJson:
typeof pre.schemaJson === 'string'
? pre.schemaJson || '{}'
: JSON.stringify(pre.schemaJson ?? {}),
userUuid: userData.uuid
},
query
);
return nextVersion;
});逐段解读。这段是 pushDashboardStage 的 prod 分支(test/pre 分支在 05 篇已概述)。第一步先确认 pre 存在——三阶段严格串行是表级快照模型的直接推论(pre 不存在时 prod 无物可复制)。第二步公开闸门:findPageMethodsGateViolation 的三个入参里最关键的是校验对象取 pre.schemaJson 而非草稿——注释把攻击路径写得很清楚:"先推 test/pre 再删草稿方法"时,草稿干净而 pre 仍带方法,只查草稿会放行一个含无沙箱作者 JS 的公开看板(ADR-006 的边界条款);注释同时解释了为什么 test/pre 不拦——那两张表只有登录态读得到,不构成公开暴露面。"逐层收窄到真正的暴露点"是这个闸门的设计纪律。第三步注入 accessMode:只注入模式不注入令牌(令牌是实时列,portal 每次请求读实时行判定,08 篇第 6 节)。第四步事务块:runTransaction 把三个写操作(行锁自增版本、upsert prod 行、打 publish 快照)绑成一个原子单元——nextProdVersion(uuid, query, true) 传入了事务连接,SELECT ... FOR UPDATE 语义的行锁保证并发发布不重号(15 篇"幂等与并发"注记的依据);快照的 schemaJson 做"字符串优先、对象兜底序列化"的规范化(行内注释解释了原因:库读出形态不定)。
/** portal 取数通道执行入口:白名单校验后走统一数据查询引擎(queryParams 固定空,防参数注入) */
export async function queryPortalDashboardData(params: {
uuid: string;
dataSourceId: string;
}): Promise<MyAPIResult> {
let prod: DashboardStageInfo | null = null;
try {
prod = await dashboardStageModel.findStageByUuid('prod', params.uuid);
} catch (err: any) {
// 该分支的响应会经 portal 转发给匿名访客,库层错误只进日志
console.error('[dashboard-stage] 读取 prod 发布行失败:', err);
return {
data: null,
success: false,
message: '数据查询暂不可用,请稍后重试'
};
}
if (!prod) {
return { data: null, success: false, message: '看板不存在或未发布' };
}
const whitelist = await getProdDataSourceWhitelist(params.uuid);
if (!whitelist.has(params.dataSourceId)) {
return { data: null, success: false, message: '数据源不在该看板发布白名单内' };
}
return queryPublishedDataSource({
dataSourceId: params.dataSourceId,
queryParams: {}
});
}逐段解读。这是 work-server 的 /api/post/dashboard/portal-data 端点的 service(内网通道终点,03 篇 2.2 节)。四个动作:读 prod 行的 try/catch 把库层异常与业务不存在分开——注释说明了理由:"该分支的响应会经 portal 转发给匿名访客",库层报错原文(含主机/账号)绝不能进访客可见的 message,所以只进日志、对外给中性文案(这是 03 篇第 5 节 internalFailure 原则在转发链路上的变体:因为响应要出公网,收敛得比内部接口更早更严);!prod 给出可读的"看板不存在或未发布";白名单校验重新查库(getProdDataSourceWhitelist 从 prod 行的 schema 里现读)——这就是 08 篇第 6 节说的双重校验的第二道:"work-server 终点不信 portal 的判断"(controller 注释原话),portal 侧已经校验过一次(第一道),但两道之间没有信任传递:portal 可能配错、可能被绕过、可能读到旧缓存,终点的裁决只认 prod 行本身;最后 queryParams: {} 写死空对象——访客不能注入查询参数(ADR-005 的"杜绝参数注入面")。
其一,发布是"构造最小副本"而不是"加可见标记":白名单从源头只含三个字段,令牌与 config 物理不在产物里——不可泄露的东西才最安全。其二,校验点的位置要贴着暴露面:公开闸门查 pre(因为 prod 复制的是 pre)、终点再校验(因为 portal 的判断不可信)、错误文案提前收敛(因为响应要出公网)——三处都体现了"在数据即将离开可信边界的前一刻做最后一次检查"。其三,事务边界对齐业务原子性:版本自增 + 落 prod + 打快照是一个不可分的事件,拆开执行会在并发下产生"版本有了但快照没有"的半状态。其四,注释记录攻击路径而非只记录规则——"先推 test/pre 再删草稿"这句让后来者不需要重新推演就能理解为什么校验对象是 pre。
物料体系分两端:注册端(元表,决定编辑器"认识"什么)与渲染端(运行时,决定"画"什么)。本节走读 packages/charts/src/meta/elementPlusMetas.ts 的 ElButton 物料与 packages/charts/src/components/ElementPlusRenderer.vue 的渲染管线,末尾带一句 resolveRenderer(packages/charts/src/runtime-renderer.ts)如何把两端接起来。
function str(name: string, label: string, defaultValue: string): PropSchemaItem {
return { name, label, type: 'string', defaultValue, widget: inputWidget() };
}
function sel(
name: string,
label: string,
options: Array<{ label: string; value: string }>,
defaultValue: string
): PropSchemaItem {
return { name, label, type: 'select', defaultValue, options, widget: selectWidget() };
}
function meta(
type: string,
label: string,
category: ComponentMeta['category'],
defaultProps: Record<string, any>,
propSchema: PropSchemaItem[],
defaultSize: { w: number; h: number } = { w: 6, h: 4 }
): ComponentMeta {
return { type, label, category, defaultSize, defaultProps, propSchema };
}
// …(elementPlusMetaList 数组内)
meta(
'ElButton',
'按钮',
'basic',
{ text: '按钮', type: 'primary', size: 'default', plain: false, round: false, disabled: false },
[
str('text', '按钮文字', '按钮'),
sel('type', '类型', [
{ label: '默认', value: 'default' },
{ label: '主要', value: 'primary' },
{ label: '成功', value: 'success' },
{ label: '警告', value: 'warning' },
{ label: '危险', value: 'danger' },
{ label: '信息', value: 'info' }
], 'primary'),
sel('size', '尺寸', [
{ label: '大', value: 'large' },
{ label: '默认', value: 'default' },
{ label: '小', value: 'small' }
], 'default'),
bool('plain', '朴素按钮', false),
bool('round', '圆角', false),
bool('disabled', '禁用', false)
],
{ w: 4, h: 3 }
),逐段解读。先看三个构造器:str/sel/bool(还有 num)把 PropSchemaItem 的构造压缩成一行一个属性,同时隐式绑定了配置器 widget——str 带 InputConfigurator、sel 带 SelectConfigurator、bool 带 SwitchConfigurator(文件头部的 widget 工厂函数)。这就是 ADR-007 说的"声明即配置"的物理基础:物料作者不需要知道配置器注册表的存在,选对构造器就自动获得正确的属性面板控件。再看 ElButton 的 meta 实体,五个字段的语义:type: 'ElButton' 必须与 Element Plus 真实组件同名(渲染端 resolveComponent 按此解析,头注释的硬约束一);defaultProps 与 propSchema 的 name 键一一对齐(硬约束二——历史上 name 错位导致属性写进 model[''] 空键,所有属性面板改动静默失效);propSchema 的每一项同时描述了"面板怎么渲染"(widget)、"值域是什么"(options)、"初始值是什么"(defaultValue)——面板的 select 下拉、默认值回填全部由此驱动;defaultSize: { w: 4, h: 3 } 是拖入画布时的初始栅格占位。meta() 构造器把 defaultSize 兜底为 6×4,避免每个物料都写全参数。
设计意图:这套元表是数据化的组件文档——一个 meta 就是一份"这个物料能配置什么、怎么配、默认是什么"的机器可读说明,编辑器的组件库面板、属性面板、画布默认尺寸三个消费者都从它取数。与 TinyEngine bundle.json 的 widget 化 schema 同构(ADR-007),但收敛为 TS 构造器函数而非 JSON 文件,换来类型检查(category 是字面量联合,写错编译即红)。
const type = computed(() => props.component.type);
const mergedProps = computed(() => props.component.props || {});
// 运行时注入(预览浮层 provide):有运行时且组件声明了 events 才挂接事件处理器;
// 编辑画布不注入,点击保持「选中」语义
const runtimeRef = inject(
CANVAS_RUNTIME_KEY as symbol,
null
) as Ref<CanvasRuntime | null> | null;
/** 事件名 → Vue onXxx prop(组件 events 声明 × 页面方法绑定) */
function buildEventProps(): Record<string, any> {
const runtime = runtimeRef?.value;
const events = props.component.events;
if (!runtime || !events) return {};
const result: Record<string, any> = {};
for (const [evt, methodName] of Object.entries(events)) {
if (!methodName || typeof runtime.methods[methodName] !== 'function') {
continue;
}
const key = `on${evt.charAt(0).toUpperCase()}${evt.slice(1)}`;
result[key] = () => invokePageMethod(runtime, methodName);
}
return result;
}逐段解读。inject(CANVAS_RUNTIME_KEY, null) 是三种渲染语境的开关(07 篇第 1 节第 3 条):编辑画布不注入运行时(runtimeRef 为 null),于是 buildEventProps 返回空对象——按钮在画布里点了不会触发方法,只触发选中;预览浮层与访客端 provide 了运行时,事件才挂接。buildEventProps 的三个细节:先做双重前提检查(无运行时或组件没声明 events 直接返回空——空对象意味着不挂任何事件 prop,Vue 渲染层零副作用);typeof runtime.methods[methodName] !== 'function' 过滤悬空绑定(组件绑的方法被删了,静默跳过而非运行时报错);事件名转 prop 用 on + 首字母大写 的 Vue 约定(click → onClick),把 schema 的声明式事件映射成 Vue 的事件 prop。invokePageMethod(packages/shared/src/runtime.ts)是真正的调用器:同步异常与 Promise 拒绝统一捕获、console.error 而非抛出——用户写的页面方法报错不能中断渲染。
<template>
<component :is="rendered" />
</template>模板只有一行:<component :is> 动态渲染。rendered 的取值经 safeResolve(t) = resolveComponent(t) || 'div'——解析不到组件名时回落 div(配合元表注册端的"type 必须真实存在"约束,双保险让未知组件显示占位而非白屏,07 篇第 2 节)。文件头注释解释了为什么不直接 import element-plus:"避免包边界 import 副作用",组件由宿主 app.use(ElementPlus) 全局注册后经 resolveComponent 按名解析——这让 charts 包保持"渲染协议实现者"而非"组件库消费者"的身份,element-plus 的引入与否由宿主决定(09 篇提到的按需动态 import 优化也因此有了干净的实施点)。
两端如何接起来:packages/charts/src/runtime-renderer.ts 的 resolveRenderer(type) 是单一分派点——图表类型走专用组件(LineChart 等),其余(El*、Native*)一律 ElementPlusRenderer;DashboardFlowNode.vue 递归渲染节点树,每个节点先问 resolveRenderer 要渲染器。注册端改元表、渲染端零改动;渲染端加特化、注册端零感知——两端只通过 type 字符串耦合。
其一,元数据驱动 UI 的本质是把"每个组件一段代码"变成"每个组件一份数据":53 个 EP 物料的属性面板没有一行 if 分支,全部由 meta 描述驱动——扩展成本从 O(n) 代码降到 O(1) 声明。其二,构造器函数是 schema 的类型安全层:str/sel/bool/num 让"widget 绑定正确、name 与 defaultProps 对齐"这两条纪律在构造时就成立,而不是靠 review 保证。其三,渲染语境用依赖注入而非 prop 区分:同一个渲染组件在画布/预览/访客三端行为不同(可选中半透明 / 可触发 / 可触发),靠 inject 的有无切换——比传布尔 prop 干净,因为"语境"天然是环境属性而非组件属性。其四,防御性渲染(resolveComponent 失败回落 div、悬空方法绑定静默跳过、页面方法报错只打日志)共同保证"一个坏节点不拖垮整树"——低代码平台的渲染端必须假设数据永远是脏的。
走读到这里可以回头看三个文件的共同气质。第一,都以一个纯数据结构为核心(历史快照数组、发布 schema 副本、物料元表),逻辑围绕数据结构的构造与变换展开,而不是围绕对象状态展开——这让每一处都可以被 09 篇的守护用例直接断言(useDashboardEditor.test.ts 840 行、dashboard-stage.test.ts 312 行、元表与注册表测试)。第二,都在关键处写下了"为什么":防回环、查 pre 不查草稿、不 import element-plus——这些注释短则一句长则一段,但都是因果链而非结果描述,16 篇把其中一部分追溯成了正式 ADR。第三,都把防御做在数据边界上:unknown 类型的 schema 解析、不信任 portal 的再校验、解析不到组件的 div 回落——三个文件的稳定性都来自"不信任输入"的一致姿态。如果要给这个仓库的代码风格提炼一句话,那就是:数据结构先行,边界防御内建,注释记录推理。