Skip to content

数据绑定

Designer 里的数据绑定,本质上是在做一件很朴素的事:把模板里的某个属性,连到运行时数据里的某条路径上。

如果你先把“设计时字段树”和“运行时数据”分开理解,这一层就不会绕。

字段树定义

Designer 接收的是 DataSourceDescriptor[]。它决定左侧数据源面板长什么样,也决定用户能拖哪些字段。

ts
import type { DataSourceDescriptor } from '@easyink/designer'

const dataSources: DataSourceDescriptor[] = [
  {
    id: 'order',
    name: '订单数据',
    fields: [
      { name: 'orderNo', path: 'orderNo', title: '订单号', use: 'text' },
      {
        name: 'customer',
        path: 'customer',
        title: '客户',
        fields: [
          { name: 'name', path: 'customer/name', title: '客户名称', use: 'text' },
          { name: 'phone', path: 'customer/phone', title: '联系电话', use: 'text' },
        ],
      },
      {
        name: 'items',
        path: 'items',
        title: '商品列表',
        tag: 'collection',
        fields: [
          { name: 'name', path: 'items/name', title: '商品名称', use: 'text' },
          { name: 'qty', path: 'items/qty', title: '数量', use: 'text' },
        ],
      },
    ],
  },
]

再把它传给 Designer:

vue
<EasyInkDesigner
  v-model:schema="schema"
  :data-sources="dataSources"
/>

上面这一步只是把字段树交给 Designer。它还没有产生任何运行时值。

哪些字段能拖拽

当前 Designer 的字段树有一个明确规则:叶子字段可以拖拽;带 union 的分组字段也可以拖拽。

ts
const dataSources: DataSourceDescriptor[] = [
  {
    id: 'order',
    name: '订单数据',
    fields: [
      {
        name: 'items',
        path: 'items',
        title: '商品列表',
        fields: [
          { name: 'name', path: 'items/name', title: '商品名称', use: 'text' },
        ],
      },
      {
        name: 'summary',
        path: 'summary',
        title: '摘要组合',
        fields: [
          { name: 'orderNo', path: 'orderNo', title: '订单号', use: 'text' },
          { name: 'grandTotal', path: 'grandTotal', title: '合计金额', use: 'text' },
        ],
        union: [
          { path: 'orderNo', title: '订单号', use: 'text' },
          { path: 'grandTotal', title: '合计金额', use: 'text', offsetY: 8 },
        ],
      },
    ],
  },
]

上面这个例子里,items 只是分组节点,默认负责展开子字段;summary 虽然也可以带子语义,但因为配置了 union,Designer 会允许它作为“一次拖拽生成多个绑定节点”的入口。

拖拽处理

先别急着想 Viewer。用户在画布里拖一个字段时,Designer 做的其实是把绑定信息写进模板。

可以把过程理解成这样:

text
字段树里的一个 field
  -> 拖到元素上
  -> Designer 把 binding 写进对应节点
  -> schema 持久化下来

这也是为什么绑定是模板的一部分,而不是运行时的一次性状态。

fieldPath 约定

到了运行时,Viewer 会统一解析节点上的绑定。

这里最重要的结论是:Viewer 按 fieldPath 从传入的 data 根对象里取值,不会再去看 Designer 的字段树。

先看一个最小心智模型:

ts
const data = {
  customer: {
    name: 'Ada',
  },
}

如果节点上的绑定路径是 customer/name,Viewer 就会去读这条路径对应的值。

你可能会注意到绑定里还有 sourceIdsourceName 之类的信息。它们有用,但作用主要在设计时元数据和界面提示,不参与运行时根数据选择。

fieldPath 使用 / 分隔。解析时会从运行时 data 根对象开始逐段读取,也会避开 __proto__constructor 这类危险路径。

稳定绑定约定

写模板时,Designer 关心的是:

  • 字段树怎么展示
  • 字段拖到哪里
  • 绑定信息怎么写回 Schema

渲染模板时,Viewer 关心的是:

  • 当前节点有哪些绑定
  • 这些绑定的 fieldPath 指向哪里
  • 运行时 data 里能不能取到值

这意味着一件很重要的事:预览、打印和导出时,不要再把 dataSources 传给 Viewer。

常用字段

第一次接入时,最常用的是这些:

字段作用
name内部名称
path绑定路径
title面板显示名称
fields子字段
use推荐的物料用途
tag集合等语义提示

你当然还能继续往里放 formatbindIndexunion 之类更细的能力,但如果你现在只是先把绑定跑通,这些基础字段就足够了。

默认显示格式

如果某个字段每次拖出去都应该按同一种方式展示,可以直接在字段上写 format

ts
const dataSources: DataSourceDescriptor[] = [
  {
    id: 'invoice',
    name: '发票数据',
    fields: [
      {
        name: 'grandTotal',
        path: 'grandTotal',
        title: '合计金额',
        use: 'text',
        format: {
          prefix: '¥',
          fallback: '--',
          mode: 'preset',
          preset: {
            type: 'number',
            minimumFractionDigits: 2,
            maximumFractionDigits: 2,
          },
        },
      },
    ],
  },
]

用户把 grandTotal 拖到文本、表格单元格或其他可绑定物料上时,Designer 会把这份格式复制到绑定里。

这里的关键词是“复制”。后面用户在属性面板里改显示格式,改的是模板里的 BindingRef.format,不会反写你的字段树。

字段函数模板

有时你不想直接给字段写死 format,而是想改变“自定义函数”里的默认示例。比如金额字段默认给一个金额函数,日期字段默认给一个中文日期函数。

这时把模板函数放到具体字段的 displayFormat.customTemplates 里:

ts
const dataSources: DataSourceDescriptor[] = [
  {
    id: 'invoice',
    name: '发票数据',
    fields: [
      {
        name: 'grandTotal',
        path: 'grandTotal',
        title: '合计金额',
        use: 'text',
        displayFormat: {
          defaultCustomTemplateId: 'invoice-money',
          customTemplates: [
            {
              id: 'invoice-money',
              label: '发票金额',
              hint: '金额保留两位小数并添加人民币符号',
              source: `function transform(value, data) {
  var num = Number(value)
  if (isNaN(num)) return ''
  return '¥' + num.toFixed(2)
}`,
            },
            {
              id: 'invoice-money-total',
              label: '合计金额',
              hint: '金额保留两位小数并加上合计前缀',
              source: `function transform(value, data) {
  var num = Number(value)
  if (isNaN(num)) return ''
  return '合计 ¥' + num.toFixed(2)
}`,
            },
          ],
        },
      },
      {
        name: 'date',
        path: 'invoice/date',
        title: '开票日期',
        use: 'text',
        displayFormat: {
          defaultCustomTemplateId: 'invoice-date-cn',
          customTemplates: [
            {
              id: 'invoice-date-cn',
              label: '中文开票日期',
              source: `function transform(value, data) {
  if (value == null || value === '') return ''
  var text = String(value)
  var match = text.match(/^(\\d{4})-(\\d{2})-(\\d{2})/)
  if (!match) return text
  return match[1] + '年' + match[2] + '月' + match[3] + '日'
}`,
            },
          ],
        },
      },
    ],
  },
]

用户选中 grandTotal 这个字段产生的绑定,再打开“显示格式”,切到“自定义”时,默认示例会被字段自己的函数替换。字段可以只有一个模板,也可以有多个模板;有多个时,Designer 会优先采用 defaultCustomTemplateId 指向的那一个。自定义函数接收两个参数:value 是当前字段值,data 是当前 Viewer 正在消费的完整运行时数据。

原来的内置示例不会全部消失。字段模板只替换“默认转换函数”这一类入口,后面的“原始值转字符串”“数值格式化为货币”“日期格式化 YYYY-MM-DD”等示例仍然保留,用户随时可以切回去。

你可能会有个疑问:为什么保存的是函数内容,而不是模板 ID?

原因是模板要能脱离 Designer 和字段树独立渲染。用户点确定后,最终写进 Schema 的仍然是 BindingRef.format.custom.source。之后字段树里的模板函数变了,已经保存的模板不会被悄悄改写。

在线试一下

Playground 默认示例里的“流式发票”已经给发票金额、开票日期、税率这些字段加了各自的函数模板。打开示例后选中对应绑定字段,在属性面板里进入“显示格式”并切到“自定义”,就能看到字段自己的默认示例,同时还能看到其余内置示例。

绑定判断标准

当你调试绑定问题时,可以按这个顺序查:

  1. Designer 里字段树是不是正确展示了。
  2. 拖拽之后,Schema 里是不是写进了绑定信息。
  3. 运行时传给 Viewer 的 data,是不是能命中相同的 fieldPath

如果第三步对不上,再怎么改 Designer 侧字段树都不会生效。

关于数据绑定,目前先知道这些就够用了。接下来你可以继续看 Viewer 概述自动保存