SaaS 后台的 DSL 改造(五):渲染引擎——一份 DSL 生成整个后台

11 分钟阅读

前四篇讲了:为什么推翻分支方案、框架底座、BFF 防御体系、DSL 的双层模型和合并算法。到上一篇为止,券商的完整 DSL 已经能按 projKey 下发到前端了。这一篇讲最后一段路:DSL 到了浏览器里,怎么变成真实的表格、表单和详情页,以及那 10% DSL 兜不住的需求怎么接入。

照例,代码按真实实现精简过,结构是真的,细节不全。

1. 渲染管线:先按 moduleType 分个流

dashboard 页面加载时做两件事:拿 projKey 请求合并后的 DSL,根据当前菜单项的 moduleType 决定把这个菜单交给谁渲染:

typescript
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 里的每个字段只写一次,视图相关的配置以后缀属性的方式挂在字段上:

typescript
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

视图组件不关心别的视图的配置。表格组件要的只是"每个字段的表格配置",搜索栏要的只是"每个字段的搜索配置"。中间靠一个拍平函数转换:

typescript
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 两个

所有表单项组件遵守同一套协议。入参三个:

typescript
defineProps<{
  schemaKey: string                    // 字段名 提交时用它当 key
  schema: DTOProperty<'component'>     // 这个字段拍平后的配置 含 option
  model?: unknown                      // 回显值 编辑场景由上层传入
}>()

对外暴露两个方法:

typescript
defineExpose({
  validate: () => boolean,              // 校验自己 返回是否通过
  getValue: () => Record<string, unknown>,  // 以 { [schemaKey]: value } 的形式交出自己的值
})

协议的关键设计是字段自治:每个 widget 自己持有输入值和错误信息,自己知道怎么校验自己,上层不需要理解任何字段的内部逻辑。schemaForm 面对的永远只是 validate() 和 getValue() 两个方法,至于是 input 还是 select 还是以后扩展的日期范围,协议不变。

4.2 一个最小实现:input

拿最简单的 input 看一遍协议的落地,重点是 validate 的三段逻辑:

typescript
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 聚合成一个表单。

typescript
// 渲染: componentType 查注册表 没注册的字段直接跳过
const availableProperties = computed(() =>
  Object.fromEntries(Object.entries(schema.properties).filter(([_, value]) =>
    formItemConfig[value.option?.componentType]
  ))
)
vue
<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 集合调用协议方法,校验取与,取值合并:

typescript
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 扩展组件。步骤是固定的三步:

  1. 写一个 Vue 组件,遵守 widget 协议:接收 schemaKey / schema / model 三个 props,expose validate / getValue 两个方法,值和错误内部自治。
  2. 注册进 formItemConfig:给它起个 componentType 名字,映射到组件。
  3. DSL 里把字段的 componentType 指过去:componentOption.editFormOption.componentType: 'dateRange'。

写完这三步,这个控件就对所有券商的所有页面可用了——基线 model 里任何字段都能用它,不需要动渲染引擎的任何一行。协议是边界,注册表是入口,这就是"自定义插槽"在表单层的具体形态。

5. 动态组件:弹窗也是配置

表格上方和行内的按钮,DSL 里只声明两件事:点了之后干什么(eventKey),参数是什么(eventOption):

typescript
{ name: '编辑', eventKey: 'showComponent', eventOption: { componentName: 'editForm' } }
{ name: '删除', eventKey: 'remove', needConfirm: true }

remove 这种内置事件好说,showComponent 引出的弹窗机制值得细讲。

5.1 组件注册表:异步加载,标记弹窗

所有可被 DSL 引用的动态组件集中在一个注册表里:

typescript
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 的全过程:

typescript
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 弹窗组件的协议

弹窗组件要被命令式驱动,自己也要遵守一套小协议:

typescript
// 必须 expose 的方法: useDialog 靠它控制开关
defineExpose({
  toggleDialogVisible: (visible: boolean) => void
})

// 必须遵守的事件: 需要联动外部时 emit command
emit('command', 'refresh')

入参则是一组约定 props:title、model(行内按钮带过来的当前行数据)、uniqueKey(详情和提交用的主键字段名)、schema(这个组件专属的拍平后 DTO)、schemaData(整个页面注入的上下文,里面有 api 等)。弹窗拿到这些,就拥有了独立工作的全部信息。

5.4 editForm 走一遍完整流程

把协议串起来,看 editForm 从点击到保存的完整链路:

typescript
// 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 里维护一个事件映射:

typescript
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 合并、渲染引擎,最后把可复用的部分和定制化的部分各归其位。希望这个改造过程对同样被多租户定制化折磨的同学有参考价值。

系列 · SaaS 后台的 DSL 改造 · 第 5 篇 / 共 5 篇
01SaaS 后台的 DSL 改造(一):分支地狱——我们为什么推翻重写02SaaS 后台的 DSL 改造(二):框架底座——手写一个 egg 风格的 Koa 加载器03SaaS 后台的 DSL 改造(三):BFF 防御体系——zod 声明式校验、签名与责任链04SaaS 后台的 DSL 改造(四):一套代码服务 N 个券商——DSL 继承与合并算法
05SaaS 后台的 DSL 改造(五):渲染引擎——一份 DSL 生成整个后台本篇
评论