我来聊聊配置驱动的视图开发

欧雷 发表于

0 条评论

我在平时上下班开车时,全凭身体记忆与条件反射,基本不用脑子,所以脑子就空出来胡思乱想了,东想想西想想。

某天早上忽然想到:最近几年,业界在开发时都讲究以「数据驱动」的方式更新视图,回想过去这几个月的工作内容,发现我们的视图层开发并不是单纯的数据驱动,而是「配置驱动」。

视图更新

让我们先来回顾一下以往以及现在,在视图层开发时一般是如何更新视图的吧——

在 React、Vue 等前端库/框架流行之前,基本以手动操作 DOM 的方式进行:

<form>
  <div>
    <span>是否已婚</span>
    <div>
      <label><input type="radio" name="married" value="true"></label>
      <label><input type="radio" name="married" value="false"></label>
    </div>
  </div>
  <div id="childrenCountField" style="display: none;">
    <label>孩子数量</label>
    <input type="text" name="childrenCount" value="">
  </div>
</form>

<script>
$('[name="married"]').on('change', function() {
  const $children = $('#childrenCountField');

  if ($(this).val() === 'true') {
    $children.show();
  }
  else {
    $children.hide();
  }
});
</script>

在 Vue 中使用的是数据绑定:

<template>
  <el-form>
    <el-form-item label="是否已婚">
      <el-radio-group v-model="married">
        <el-radio :label="true"></el-radio>
        <el-radio :label="false"></el-radio>
      </el-radio-group>
    </el-form-item>
    <el-form-item label="孩子数量" v-show="married">
      <el-input />
    </el-form-item>
  </el-form>
</template>

<script>
export default {
  data() {
    return {
      married: false
    };
  }
}
</script>

通过配置的方式来完成同样的事情:

<view widget="form">
  <field name="married" label="是否已婚" widget="radio" />
  <field name="childrenCount" label="孩子数量" widget="input" invisible="record.married !== true" />
</view>

有没有觉得最后一种很方便,并且可读性很强?

相较于手动操作 DOM,数据绑定相对更「智能」,这是一种数据驱动的开发方式。可单纯的数据驱动只解决了基本的数据显示问题,并没有任何视图层可扩展性上的支撑,比如同一个表格组件:

  • 在 A 模型下想显示 a、b、c 字段,在 B 模型下想显示 d、e、f 字段;
  • 在页面主体中时想显示列偏好设置、单元格文本密度调节,但在对话框中时不想要这些功能;
  • 在 A 应用中表头的边框是尖角且背景色是浅蓝色,在 B 应用中则是圆角的边框与淡紫的背景色。

在复杂多变的中后台业务场景中,要想使一个组件能够最大限度地复用,要想用一些组件快速搭建出一个中后台应用,就需要一套足够灵活、足够强大的可扩展体系。

视图配置

一个页面,或者说一个视图,可以进行配置的点主要有:模板、模型、逻辑、主题。

模板

在 web 开发中所使用的「模板」,大多是与 HTML 相符合且面向开发的,如:Vue 的模板、Pug(Jade)、Thymeleaf、FreeMarker、Velocity 等等。

然而,这里的「模板」与 HTML 没有直接关系,是对某个领域的视图结构、数据结构或逻辑结构的描述,是一种外部 DSL:

  • 描述数据容器的视图模板;
  • 描述搜索过滤器及操作符的搜索模板;
  • 描述整体布局的布局模板;
  • 描述纸张打印的打印模板;
  • 描述调研问卷的问卷模板。

这些模板遵从相同的设计原则,使用同一套解析器,解决不同领域问题。它们分别是一套标签集,只要有新的领域的问题要解决,就可以新增一套标签集。

模板不仅能让人一眼就看懂它所描述的信息,还能控制最终所呈现出的形态,详见我之前写的《我来聊聊面向模板的前端开发》。

模型

这里所说的「模型」主要是指元数据。什么是「元数据」?简单理解,就是「用来描述数据的数据」。

假如有一张个人信息表,需要填写如下信息:

  • 姓名
  • 出生日期
  • 年龄
  • 性别
  • 是否已婚
  • 孩子数量
  • 月收入
  • 兴趣爱好

试想一下,这些信息分别是什么数据类型?不要想当然地认为姓名就是字符串而不是长文本,年龄就是数字而不是字符串,性别就是布尔型而不是枚举……

为了使在进行数据处理时能够模式化,需要对要处理的数据进行描述,即使用元数据。

要描述的信息主要是数据类型及其要显示的文本标签,如果不是布尔型、数字、字符串等基本类型,最好描述其数据来源,比如枚举;根据需要还可以描述是否必填、是否只读等:

[
  {
    "name": "name",
    "label": "姓名",
    "type": "string",
    "required": true
  },
  {
    "name": "birthday",
    "label": "出生日期",
    "type": "date",
    "required": true
  },
  {
    "name": "age",
    "label": "年龄",
    "type": "integer",
    "required": true
  },
  {
    "name": "gender",
    "label": "性别",
    "type": "enum",
    "options": [],
    "required": true
  },
  {
    "name": "married",
    "label": "是否已婚",
    "type": "boolean",
    "required": true
  },
  {
    "name": "childrenCount",
    "label": "孩子数量",
    "type": "integer",
    "required": true
  },
  {
    "name": "monthlySalary",
    "label": "月收入",
    "type": "currency"
  },
  {
    "name": "hobbies",
    "label": "兴趣爱好",
    "type": "m2m",
    "options": "",
    "chosen": []
  }
]

元数据对视图的影响,主要是数据相关的,对视图形态没什么影响,如:要显示哪些字段(根据元数据生成视图模板)、字段的校验规则、字段的编辑状态、请求的参数等。

根据上述元数据所生成的视图模板大概长这样儿:

<view widget="form">
  <field name="name" label="姓名" required="true" />
  <field name="birthday" label="出生日期" required="true" />
  <field name="age" label="年龄" required="true" />
  <field name="gender" label="性别" required="true" />
  <field name="married" label="是否已婚" required="true" />
  <field name="childrenCount" label="孩子数量" required="true" />
  <field name="monthlySalary" label="月收入" />
  <field name="hobbies" label="兴趣爱好" />
</view>

在使用元数据时,最好后端能陪着一起玩儿,这么一来就省去了不少接口的设计、评审、联调等时间,取而代之的是后端定模型。如果只能前端自己玩儿,可以利用 JSON Schema 等工具。

逻辑

如果框架设计得合理,应该能够在不更改组件的内部实现的情况下与外部可配置的逻辑进行组合联动。根据逻辑的轻重与组合联动方式,可以大致分为动作与表达式这两种。

「动作」是一段完整逻辑的抽象,与函数相当,用来描述且只描述「做什么事」,不描述「长什么样」。一个可复用的动作应该是原子化的。

根据逻辑的定义、执行所在位置,可以分为客户端动作(广义)与服务端动作:客户端动作(广义)是定义并且执行在前端;服务端动作是定义并且执行在后端。

客户端动作(广义)根据具体场景的用途及特性,又可分为以下几种动作:

  • 路由动作
  • CRUD 动作
  • 客户端动作(狭义)
  • 组合动作

其中,路由动作的作用是进行页面跳转;CRUD 动作是对数据进行操作;客户端动作(狭义)是单纯的一段逻辑,可以简单理解为是一个 JS 函数;组合动作用于将其他类型的动作「打包」处理,就像一个调用了其他函数的函数。

服务端动作可以简单粗暴地理解为是非常规 CRUD 的后端接口。

表达式是一种轻逻辑,主要用于字段的值计算、备选项筛选、状态联动等运用简单逻辑的场景:

<view widget="form">
  ...
  <field name="married" label="是否已婚" required="true" />
  <!-- 「是否已婚」的值为 `true` 时才显示「孩子数量」,并且必填 -->
  <field name="childrenCount" label="孩子数量" required="record.married === true" invisible="record.married !== true" />
  ...
</view>

主题

相信看到「主题」这两个字,第一反应是改变字体、字色、背景色等特性的「皮肤」,然而在本文的语境中,不完全正确。

上面所提到的模板、模型、逻辑等都是较为底层的配置,从外部去影响组件的呈现;而主题则是更为上层的配置,从内部或者其本身去影响组件——样式、行为、组件及其所依赖的运行时。

「样式」不难理解,就是改变字体、字色、背景色等特性的「皮肤」,但「行为」是什么呢?看以下几种需求:

  • 布尔型字段在某个应用中想用 Switch 组件,在其他应用中想用 Checkbox、Radio 或 Select 等组件;
  • 表格在页面主体中时想显示列偏好设置、单元格文本密度调节,但在对话框中时不想要这些功能。

用来解决这类需求的配置,就是「行为」。

至于为什么组件及其所依赖的运行时也是配置,这是因为在这种体系下,主要业务逻辑被底层所接管了,组件内基本只剩属于本身的交互逻辑。所以,无论是用 Vue、React 还是其他的,又或者是几种混用,对实际业务的进展不会造成影响。

思想总结

在文章标题中使用的是「视图开发」而不是「前端开发」是因为全文的侧重点在视图层,基本没有提到其他层的事情,但不代表仅视图层是可以配置驱动的。

理论上,在一个能够快速响应业务变化的前端架构中,应该是整体可配置,各层都可被替换,但无法替换的是设计目标、设计思想与接口协议,这些是灵魂,只要它们在,架构就没变。