SaaS 后台的 DSL 改造(五):渲染引擎——一份 DSL 生成整个后台
前四篇讲了:为什么推翻分支方案、框架底座、BFF 防御体系、DSL 的双层模型和合并算法。到上一篇为止,券商的完整 DSL 已经能按 projKey 下发到前端了。这一篇讲最后一段路:DSL 到了浏览器里,怎么变成真实的表格、表单和详情页,以及那 10% DSL 兜不住的需求怎么接入。
照例,代码按真实实现精简过,结构是真的,细节不全。
1. 渲染管线:先按 moduleType 分个流
dashboard 页面加载时做两件事:拿 projKey 请求合并后的 DSL,根据当前菜单项的 moduleType 决定把这个菜单交给谁渲染:
const pathMap: Record<ModuleType, string> = {
[ModuleType.SCHEMA]: '/schema', // DSL 生成的标准页面
[ModuleType.IFRAME]: '/iframe', // iframe 嵌入
[ModuleType.SIDEBAR]: '/sider', // 带侧边栏的复合页面
[ModuleType.CUSTOM]: menu.customConfig?.path, // 完全手写的页面
}flowchart TD
DSL[合并后的券商 DSL] --> MT{moduleType}
MT -->|SCHEMA 约九成| SV[schemaView 渲染引擎]
MT -->|IFRAME| IV[iframeView]
MT -->|CUSTOM| CV[手写页面]
MT -->|SIDEBAR| SD[侧边栏布局<br/>子菜单继续按 moduleType 分发]
绝大多数菜单走 SCHEMA,这一篇的主角就是它。SIDEBAR 只是套一层侧边布局,子菜单递归回到这个分发逻辑。IFRAME 和 CUSTOM 是逃生舱,最后一节讲。
2. 一份 schema 要喂五个视图
SCHEMA 页面的难点在这:同一个字段,在不同视图里的待遇完全不同。
id 字段,表格里要隐藏,表单里不能编辑,但它是详情的唯一标识。name 字段,表格里是普通一列,搜索栏里是下拉框,编辑表单里是带校验的输入框。如果给每个视图各写一份配置,同一份信息写五遍,基线一改动五处——回到了老路上。
所以 schema 里的每个字段只写一次,视图相关的配置以后缀属性的方式挂在字段上:
name: {
type: 'string',
label: '商品名称',
// 每个 xxxOption 对应一个视图的专属配置 没写就是默认行为
searchOption: { componentType: 'select', options: [/* ... */] },
componentOption: {
editFormOption: {
componentType: 'input',
required: true,
validateSchema: z.toJSONSchema(z.string().min(2).max(20)),
validateErrorMessage: { minLength: '最小字符长度为2' },
},
},
}3. buildDTOSchema:把后缀配置拍平成视图专属 DTO
视图组件不关心别的视图的配置。表格组件要的只是"每个字段的表格配置",搜索栏要的只是"每个字段的搜索配置"。中间靠一个拍平函数转换:
function buildDTOSchema(schema, optionField /* 'table' | 'search' | 'component' */) {
const DTOSchema = { type: 'object', properties: {} }
Object.entries(schema.properties).forEach(([key, value]) => {
DTOSchema.properties[key] = Object.keys(value).reduce((props, cur) => {
// 剥掉所有 xxxOption 后缀属性 只留通用字段
if (!cur.includes('Option'))
props[cur] = value[cur]
return props
}, {
// 当前视图的那份 option 提到固定位置
option: { ...value[`${optionField}Option`] },
})
})
return DTOSchema
}一份 schema 进,五份 DTO 出:表格拿 table 版,搜索栏拿 search 版,三个动态组件各拿自己的 component 版。每个视图只看到自己的配置,字段级信息依然只维护一份。
搜索栏在这个基础上还多一步过滤:只保留写了 searchOption 的字段。没声明搜索配置的字段不出现在搜索栏,这样"哪些字段可搜索"就是配置的自然结果,不用另外维护清单。
要点:
- 字段信息写一份,视图配置用
xxxOption后缀挂在字段上 buildDTOSchema按视图拍平,各取所需- 没写 option 就是默认行为,搜索栏只出现声明过的字段
4. 动态表单:一套 widget 协议
动态表单是整套渲染引擎里协议设计最集中的地方,值得掰开讲。它分三层:最底层是一个个表单项组件(input、select、inputNumber),中间是 schemaForm 负责按配置把它们渲染出来并聚合成一个表单,最上面还有一层注册表决定 componentType 对应哪个组件。
4.1 widget 协议:props 三个,expose 两个
所有表单项组件遵守同一套协议。入参三个:
defineProps<{
schemaKey: string // 字段名 提交时用它当 key
schema: DTOProperty<'component'> // 这个字段拍平后的配置 含 option
model?: unknown // 回显值 编辑场景由上层传入
}>()对外暴露两个方法:
defineExpose({
validate: () => boolean, // 校验自己 返回是否通过
getValue: () => Record<string, unknown>, // 以 { [schemaKey]: value } 的形式交出自己的值
})协议的关键设计是字段自治:每个 widget 自己持有输入值和错误信息,自己知道怎么校验自己,上层不需要理解任何字段的内部逻辑。schemaForm 面对的永远只是 validate() 和 getValue() 两个方法,至于是 input 还是 select 还是以后扩展的日期范围,协议不变。
4.2 一个最小实现:input
拿最简单的 input 看一遍协议的落地,重点是 validate 的三段逻辑:
const modelValue = ref('')
const errorMessage = ref('')
const ajv = injectLocal<Ajv>('ajv') // 校验器由 schemaForm 统一注入
function validate() {
errorMessage.value = ''
const { required, validateSchema, validateErrorMessage } = props.schema.option ?? {}
// 第一段: 必填检查 空值直接失败
if (required && !modelValue.value) {
errorMessage.value = validateErrorMessage?.required ?? `请输入${props.schema.label}`
return false
}
// 第二段: 非必填且为空 放行 不跑规则
if (!modelValue.value)
return true
// 第三段: ajv 编译 DSL 下发的 JSON Schema 校验
const validator = ajv?.compile(validateSchema)
const isValid = validator?.(modelValue.value)
if (!isValid && validator?.errors?.length) {
const { keyword, message } = validator.errors[0]
// 错误文案按 ajv 的 keyword 从 DSL 配置里取 取不到用 ajv 默认文案
errorMessage.value = validateErrorMessage?.[keyword] ?? message ?? ''
return false
}
return isValid
}
function getValue() {
return { [props.schemaKey]: modelValue.value }
}
defineExpose({ getValue, validate })几个细节。必填和规则校验分开:必填管"有没有",JSON Schema 管"对不对",空值在非必填时不该触发规则报错。错误文案按 keyword 映射:ajv 报错给出 minLength、maxLength 这样的 keyword,DSL 里 validateErrorMessage 按 keyword 配置文案,后端定义规则的人同时定义了前端用户看到的提示——这就是上一段说的"校验规则后端写一遍"的完整链路:zod 规则 z.toJSONSchema() 序列化进 DSL,前端 ajv 执行,文案随规则一起下发。
交互上,blur 时校验、focus 时清错,都在 widget 内部完成,上层无感。
4.3 schemaForm:渲染靠注册表,聚合靠 ref
schemaForm 拿到拍平后的 schema,做两件事:把每个字段渲染成对应的 widget,把所有 widget 聚合成一个表单。
// 渲染: componentType 查注册表 没注册的字段直接跳过
const availableProperties = computed(() =>
Object.fromEntries(Object.entries(schema.properties).filter(([_, value]) =>
formItemConfig[value.option?.componentType]
))
)<component
:is="formItemConfig[item.option?.componentType]?.component"
v-for="(item, key) in availableProperties"
:key="key"
:ref="(el) => setFormItemRef(key, el)"
:schema-key="key"
:schema="item"
:model="model?.[key]"
/>聚合就是对着 ref 集合调用协议方法,校验取与,取值合并:
function validate() {
return Object.values(formItemRefs.value).every((item) => item.validate())
}
function getValue() {
return Object.values(formItemRefs.value).reduce(
(acc, item) => ({ ...acc, ...item.getValue() }), {}
)
}
defineExpose({ validate, getValue }) // schemaForm 自己对上层也遵守同样的协议注意最后那行:schemaForm 对外暴露的也是 validate 和 getValue。协议是递归的,弹窗组件(editForm 们)调 schemaForm 的方式,和 schemaForm 调 widget 的方式一模一样。
4.4 扩展一个新控件的三步
回到本节开头的问题:怎么给动态 form 扩展组件。步骤是固定的三步:
- 写一个 Vue 组件,遵守 widget 协议:接收
schemaKey / schema / model三个 props,exposevalidate / getValue两个方法,值和错误内部自治。 - 注册进
formItemConfig:给它起个componentType名字,映射到组件。 - DSL 里把字段的
componentType指过去:componentOption.editFormOption.componentType: 'dateRange'。
写完这三步,这个控件就对所有券商的所有页面可用了——基线 model 里任何字段都能用它,不需要动渲染引擎的任何一行。协议是边界,注册表是入口,这就是"自定义插槽"在表单层的具体形态。
5. 动态组件:弹窗也是配置
表格上方和行内的按钮,DSL 里只声明两件事:点了之后干什么(eventKey),参数是什么(eventOption):
{ name: '编辑', eventKey: 'showComponent', eventOption: { componentName: 'editForm' } }
{ name: '删除', eventKey: 'remove', needConfirm: true }remove 这种内置事件好说,showComponent 引出的弹窗机制值得细讲。
5.1 组件注册表:异步加载,标记弹窗
所有可被 DSL 引用的动态组件集中在一个注册表里:
export const componentsMap = {
createForm: {
component: defineAsyncComponent(() => import('./createForm/index.vue')),
isDialog: true, // 弹窗组件 走命令式打开 不参与声明式渲染
},
editForm: { component: defineAsyncComponent(() => import('./editForm/index.vue')), isDialog: true },
detailPanel:{ component: defineAsyncComponent(() => import('./detailPanel/index.vue')), isDialog: true },
}两个设计。defineAsyncComponent 让这些组件按需加载:券商 A 的页面用不到 detailPanel,它的代码就不进这次页面的加载。isDialog 区分两种挂载方式:弹窗组件由按钮触发、命令式打开;非弹窗组件(如果有)才会进入 schemaView 的声明式渲染列表。
5.2 useDialog:命令式挂载的全过程
弹窗为什么不写成模板里的 <edit-form v-if="visible" />?因为触发源是 DSL 驱动的按钮,弹窗的种类和数量都是运行时才知道的,声明式写不死。所以用 h + render 手动挂载,这是 useDialog 的全过程:
export function useDialog(DialogComponent) {
const mountPoint = document.createElement('div')
const componentRef = ref(null)
let vNode = null
function renderComponent() {
const node = h(DialogComponent, componentProps)
node.appContext = appContext // 关键: 手动挂载的组件树脱离了应用上下文 必须显式接回
render(node, mountPoint)
componentRef.value = node.component?.exposed // 拿到组件 expose 的协议方法
return node
}
function showDialog(props, opts) {
if (!vNode) {
updateProps(props)
vNode = renderComponent()
document.body.appendChild(mountPoint) // 首次才真正挂到 body
} else if (props) {
Object.assign(componentProps, props) // 再次打开 只更新 props 复用组件
}
nextTick(() => {
componentRef.value?.toggleDialogVisible?.(true) // 通过协议方法打开
})
}
// 组件卸载时清理 DOM 避免泄漏
tryOnScopeDispose(() => {
render(null, mountPoint)
document.body.removeChild(mountPoint)
})
return { showDialog, /* ... */ }
}三个容易踩的坑都在这了。appContext 必须显式传:手动 render 的组件树不在应用的组件树里,不传的话弹窗里用 Element Plus 组件、pinia store 全部报错,这是手动挂载最常踩的坑。再次打开复用实例:第二次 showDialog 不重新挂载,只更新 props 再调打开方法,避免反复创建销毁。scope dispose 时清理:宿主页面的响应式作用域销毁时,同步卸载组件、移除 DOM,不留泄漏。
5.3 弹窗组件的协议
弹窗组件要被命令式驱动,自己也要遵守一套小协议:
// 必须 expose 的方法: useDialog 靠它控制开关
defineExpose({
toggleDialogVisible: (visible: boolean) => void
})
// 必须遵守的事件: 需要联动外部时 emit command
emit('command', 'refresh')入参则是一组约定 props:title、model(行内按钮带过来的当前行数据)、uniqueKey(详情和提交用的主键字段名)、schema(这个组件专属的拍平后 DTO)、schemaData(整个页面注入的上下文,里面有 api 等)。弹窗拿到这些,就拥有了独立工作的全部信息。
5.4 editForm 走一遍完整流程
把协议串起来,看 editForm 从点击到保存的完整链路:
// 1. 抽屉打开时 用 uniqueKey 拉详情回显
async function onOpen() {
const { res } = await getCaptured(schemaData.api, {
[props.uniqueKey]: props.model[props.uniqueKey],
})
detail.value = res.data
}
// 2. 点保存: 协议调用表单 -> 拼主键提交 -> 通知刷新 -> 关抽屉
async function onEdit() {
const isValid = formRef.value?.validate() // schemaForm 的协议方法
if (!isValid) return
await putCaptured(schemaData.api, {
[props.uniqueKey]: props.model[props.uniqueKey],
...formRef.value?.getValue(), // 各 widget 的值已合并好
})
emit('command', 'refresh') // 通知表格重新拉数据
drawer.value?.toggleDialogVisible(false)
}
defineExpose({ toggleDialogVisible }) // 遵守弹窗协议模板里它只负责组装:z-drawer 包住 schema-form,schema 来自 DSL,model 来自拉回来的详情。整个 editForm 没有一个写死的字段——换一个菜单、换一个券商,同一个组件渲染完全不同的表单。
最后看事件是怎么回到表格的。schemaView 里维护一个事件映射:
const evtMap = {
showComponent, // 打开弹窗
remove, // 删除
default: () => {},
}
function onComponentCommand(command, ...args) {
if (command === 'refresh')
tablePanelRef.value?.getList() // 弹窗保存成功 -> 表格刷新
else if (command === 'buttonEvent')
onCommand(...args) // 弹窗里再触发按钮事件 -> 回到事件映射
}弹窗和表格之间不直接引用,全靠 command 消息。新增一个动态组件不需要碰表格,新增一种按钮行为只需在 evtMap 注册一个 handler。行为和组件都是注册表,不是 if-else。
要点:
- 动态组件注册表:
defineAsyncComponent按需加载,isDialog区分挂载方式 - 弹窗协议:expose
toggleDialogVisible,联动靠emit('command', ...) - useDialog 三坑:appContext 要显式传、复用实例不重复挂载、scope dispose 清理 DOM
- 事件走消息映射,弹窗与表格零引用
6. 逃生舱:那 10% 的手写部分怎么进
DSL 的表达力有上限,我们从第一天就承认这一点,所以留了三道口子:
iframe 模块。 菜单项声明 moduleType: IFRAME 和 iframeConfig.path,页面整块渲染一个 iframe。券商 D 那种"把我们现成的内部系统嵌进来"的需求走这里,一行配置接入,双方零耦合。
custom 模块。 moduleType: CUSTOM 加 customConfig.path,指向一个完全手写的 Vue 页面。路由、状态、接口全都自己写,只是从后台的菜单体系里能进去。DSL 完全管不了的复杂页面走这里。
组件级扩展。 介于两者之间的情况:页面整体是 DSL 生成的,但某个表单项要特殊控件,或者要加一个新的弹窗。第 4、5 节已经讲了机制——控件遵守 widget 协议注册进 formItemConfig,弹窗遵守弹窗协议注册进 componentsMap,DSL 里把 componentType 或 componentName 指过去。手写组件就此嵌入生成的页面,和其他部分无缝协作。
原则是统一的:手写部分被关在这些口子里,以配置的方式被引用,不污染 DSL 生成的主干。90% 和 10% 的比例能维持住,靠的就是口子够小、边界够硬。
7. 构建与分发
最后一步是把各家的东西送到各家。构建时按券商维度产出多份静态产物,每份打进对应券商合并后的配置和主题;nginx 按域名把不同券商导到各自的产物目录。券商之间在产物层面完全隔离,一家出问题不影响别家,主题差异也在构建期固化,不需要运行时判断。
这块没有太多可讲的,标准的多页构建加 nginx 分流,就不展开了。
8. 系列总结:这套方案不适合谁
五篇讲完了,最后负责任地说说边界。这套东西对我们有效,是因为我们的场景恰好踩在它的甜区上:
页面形态高度收敛。 后台管理就是表格、搜索、表单、详情的排列组合,DSL 的表达力罩得住。如果你的产品页面形态天马行空,DSL 会迅速膨胀成一门烂语言,还不如写代码。
差异确实多。 我们商户数量到了维护分支比开发还贵的临界点,改造才划算。就两三家客户,一商户一分支依然是更简单正确的方案,别为了架构而架构。
团队能接受范式切换。 开发方式从"写页面"变成"写配置加写渲染器",初期效率是降的,学习成本和不适感都真实存在。撑不过阵痛期的团队不要上。
另外有两个代价是长期要付的:DSL 每加一种能力,渲染引擎就要跟着长,框架本身的维护是持续成本;排查问题时多了一层间接,页面不对要先判断是 DSL 的问题、合并的问题还是渲染引擎的问题。这些都真实存在,我们只是算过账之后觉得值。
小结
这一篇讲了渲染侧:按 moduleType 分流的渲染管线、一份 schema 用 xxxOption 后缀喂五个视图、widget 协议和动态表单的扩展机制、弹窗协议和 useDialog 命令式挂载的实现细节,以及 iframe、custom、组件级扩展三条逃生舱。到这里整个系列完结:从分支地狱出发,经过框架、BFF、DSL 合并、渲染引擎,最后把可复用的部分和定制化的部分各归其位。希望这个改造过程对同样被多租户定制化折磨的同学有参考价值。