注:这是本系列的第二篇文章。第一篇是:交互的本源 —— 对渐进式交互优化路径的初步探索

近几年,随着 TypeScript 的逐步流行,类型系统逐渐被前端这个群体重视起来,也逐渐在一些组件库中被深度采用。但是,我们可以发现,如果从使用类型系统的几个层级去划分:

  • 类型不友好
  • 类型友好
  • 类型优先

几乎所有组件库都处于前两个层级,并未达到类型优先的程度。那么,什么是类型优先,它有什么好处,本文尝试结合一些具体案例,给出说明。

交互体系的语义化

我们在之前的文章中提到,可以构建一套语义化的交互体系。交互的语义化有巨大的价值,它至少能够带来以下几个好处:

  • 使得交互体系与业务的组合过程完全解耦,交互体系独立于业务逻辑而存在,可以独立维护和升级,对可维护性有很大价值。
  • 使得在工程链路上,设计师的产出拥有了更容易度量的标准,并且,从设计结果到前端实现之间的等价性可以维持不变,有利于从设计结果到最终代码的可复用性。
  • 使得业务的跨端、跨平台能够拥有更直观的迁移路径。

那么,如何让交互体系更语义化呢?一般来说,包括但不限于以下方面:

  • 描述单个交互的行为
  • 描述单个交互在整个交互集中的约束
  • 描述复杂交互的状态控制过程
  • 描述复合交互的组合过程
  • 提供更好的与业务解耦的能力

这些目标都是很诱人的,但做起来并不简单,尤其是大部分“组件库”,其设计体系过于零散,也没有去追求在工程链路中的更良好结构,因此常常在某些方面有所欠缺。

交互的泛型化

对于一个原子交互而言,它很明显可以表达为对某种、或者某些数据类型的操作。例如:

  • 开关:可以用来表达对布尔值的切换
  • 文本输入框:可以用来输入字符串或者数字
  • 下拉选择框:可以用来选择枚举或者多对一关系

如果一个交互,可以被用来表达对某种数据类型及其派生类型的操作,我们就可以以此为依据,将其表达为一个泛型交互,因此它的实现代码,也就是一个泛型组件。在某些语言和框架中,我们是可以写出这种组件的,例如:

  1. type InputProps<T extends number | string = unknown> = {
  2. value?: T
  3. onChange?: (v: T) => void
  4. }
  5. const Input = <T extends number | string = unknown>(props: InputProps<T>) => {
  6. // implementations
  7. }

这样,这个组件就被声明为兼容 number 和 string 的泛型输入组件了,而使用这个组件的时候,我们也可以写出类型更友好的用法:

  1. <Input<number> value={111} />
  2. <Input<string> value={'hello'} />

借助这样的实现,我们可以在某种程度上,既明确标识了本交互兼容的类型,又对参数能够施加简单的约束。

另外一个需要简单提及的东西是布局组件。如果按照之前我们的从数据类型入手的划分,会发现布局组件并不需要接受这类参数,因此,它只需要考虑自身的配置属性,以及基于特定上下文的参数。

交互的兼容链

所谓交互类型的兼容链,指的是:在交互所表达数据类型的兼容链路上,如果存在能够更精确对应指定业务模型的交互,则默认使用这个交互;如果不存在,则使用与其兼容的交互。

例如,在业务上,基于 Object 扩展的领域模型 Person:

  • 假定 Object 类型的数据,其默认呈现方式为表单
  • 假定 Person 类型的数据,其默认呈现方式为 Profile

当我们碰到一条 Person 数据的时候,首选交互可以设置为 Profile 组件,而当碰到一条订单数据的时候,因为没有指定订单这个业务模型的精确交互,可以退而使用 Object 的默认交互表单来表达,那就是表单。

这种扩展链路,能够让交互设计与技术实现的路径更一致。当新业务中暂时没有更精确组件的时候,通过类型的降级系统,先找到最接近的那个,以此作为占位。与此同时,设计师与技术人员共同创建新的组件,再替换上去。

通过这种方式表达的泛型组件,其形态大致如下:

Form 组件:

  1. type FormProps<T extends Object = unknown> = {
  2. value?: T
  3. onChange?: (v: T) => void
  4. }
  5. const Form = <T extends Object = unknown>(props: FormProps<T>) => {
  6. // implementations
  7. }

Profile 组件:

  1. type ProfileProps<T extends Person = unknown> = {
  2. value?: T
  3. onChange?: (v: T) => void
  4. }
  5. const Profile = <T extends Person = unknown>(props: ProfileProps<T>) => {
  6. // implementations
  7. }

使用的时候:

  1. const Demo = () => {
  2. return (
  3. <>
  4. // 使用精确的交互来表达 Person 实体
  5. <Profile<Person> value={tom} />
  6. // 使用泛化的交互来表达 Person 实体
  7. <Form<Person> value={tom} />
  8. // 使用泛化的交互来表达定单实体
  9. <Form<Order> value={myOrder} />
  10. </>
  11. )
  12. }

沿着上面的例子继续,在 Person 类型已经指定了呈现方式为 Profile 的情况下,某个具体业务场景中,存在一种对 Person 的扩展类型:PetKeeper。在没有给 PetKeeper 指定更精确交互的情况下,它沿着类型的扩展链一路上溯:

PetKeeper -> Person -> Object

在 Person 这个位置找到了预置的默认交互,于是自动采用了这种交互。此后,设计师给 PetKeeper 单独设计了交互组件,把用户及其宠物的头像聚合在一起,然后注册到交互系统中,于是,业务中的 PetKeeper 实体就可以使用这个新交互了。

因此,我们拥有了一套渐进式优化交互的路径,它的可替代路径,是与类型的兼容链同步的,这也就是类型优先的含义之一。

交互组件的元类型

需要注意的是,在前面部分,我们提到的类型都是可能会被构建过程擦除的类型,它通常能在类似 TypeScript 这类体系中,对编写代码起一些约束作用,但是在运行时就不起作用了。

如果想要保留运行时约束,就需要为组件增加元数据。在 JavaScript 社区中,存在 prop-types 这类补充组件元类型的库,React 官方示例中给出了使用说明:https://reactjs.org/docs/typechecking-with-proptypes.html

  1. import PropTypes from 'prop-types';
  2. class MyComponent extends React.Component {
  3. render() {
  4. // This must be exactly one element or it will warn.
  5. const children = this.props.children;
  6. return (
  7. <div>
  8. {children}
  9. </div>
  10. );
  11. }
  12. }
  13. MyComponent.propTypes = {
  14. children: PropTypes.element.isRequired
  15. };

例如,这个简单的例子中,就使用 PropTypes 的 element 类型,约束了 MyComponent 组件 children 的非空行为。除此之外,我们也可以看到各类简单类型、函数、数组等等组合约束方式。

但是,在通常写代码的时候,一般并没有人写这些东西,并不会影响组件的使用,这是为什么呢?因为现在很大一部分人,放弃了运行时的这种校验,而是使用开发态的类型约束来规范自己的代码。

同样是上面的例子,可能在 TypeScript 中,会使用下面的方式来表达:

  1. const MyComponent = ({ children }: React.PropsWithChildren<{}>) => {
  2. return <div>{children}</div>
  3. }

这就是假定开发态的类型能够覆盖一些低级错误,一般对于业务来说是足够的,但在不少时候,还是不够精确,比如这里,在使用 MyComponent 的时候,就检测不出没有写 children。

因此,为最基础的组件提供元类型,是更完善稳妥的做法,它不再受限于某种编程语言的表达能力,而是可以借助更完善的类型描述,甚至是注入的上下文校验规则,来提供更完备的运行时检测能力。

比如说,要描述这类规则,就必须借助额外的元类型与校验函数:

  • 放在表格行内的按钮,size 不能是 large
  • Form 与 Input 的上下文链路之间,有且仅有一个 FormField 层级

另外一个角度,当我们致力于打造从设计到开发的一体化工具的时候,需要赋予工具更多能力,否则它仍然过于依赖使用者自己对细枝末节的理解与记忆,而工具的能力是完全来自组件体系提供的元信息的,因此,需要从源头,在交互的设计阶段,就把整套体系建立起来。

建立元数据类型的方式不止 PropTypes 这一种,如果想要建立贯穿前后端工程链路、跨部署架构、跨编程语言和框架的元数据推导系统,也可以借助更中立的表达方式,比如 JSON Schema 之类。

篇幅所限,本文不展开探讨元类型的定义方式,以及元类型与编程语言类型的动态转换,相关内容会在其他文章中详细叙述。

交互与数据的结合

需要注意的是,原子交互本身是不包含对数据源的定义的,它只能通过某些方式,消费数据源。又比如,对数据的迭代、拆解过程,都是广泛存在的。在一个致力于让交互与程序实现拥有相等可组合性的体系中,这类不可见、只表达数据操作的组件,也是必须要考虑的。

如果要重现从原子交互到业务组合的全过程,我们就需要把状态与控制的“逻辑组件”抽象起来。实际上,在之前表达原子交互的时候,就已经有这个过程的最简形式了:

  1. const PrimitiveDemo = () => {
  2. return (
  3. <Primitive<Boolean>>
  4. <Switch />
  5. <CheckBox />
  6. </Primitive>
  7. )
  8. }

我们把 value 和 onChange 包装在 Primitive 组件内部,Primitive 就持有了这部分状态,并且提供了对状态的操作,以此将原子交互纯化。

同理,我们可以对拥有相同状态与控制结构的交互们作类似的提炼:

  1. const SelectorDemo = <T extends Object = unknown>() => {
  2. return (
  3. <Selector<T> type="single">
  4. <Tabs />
  5. <Collapse />
  6. </Selector>
  7. )
  8. }

这里,我们就把可单选的选项卡组件与可折叠面板组件的控制过程抽象到同一个东西上了,这样,拥有相同语义的组件的表达方式就会更简单。

进一步去思考,我们还可以把一些常见数据结构的展开过程也抽象掉,比如:

  • Object
    • ObjectIterator
      • Field
  • List
    • ListIterator
      • ListItem

据此,其他的原子交互可以因而组装出更复杂的交互。比如我们尝试来写一个 Form 的迭代形态:

  1. const Form = <T extends Object = unknown>() => {
  2. return (
  3. <Object<T>>
  4. <ObjectIterator<T>>
  5. <Field />
  6. </ObjectIterator>
  7. </Object>
  8. )
  9. }

以上这几个层级都是不渲染视图,只处理数据的,在这几个层级之间,可以任意插入一些布局相关的东西,叶子节点 Field 下面,可以使用更精确的 Field 交互。

从这个角度出发,能够更干净地切割应用中的状态管理与渲染,使得渲染始终轻量化。再结合元数据类型,整个应用就成为了一个:基于元数据类型表达的数据驱动的渲染框架,在这个框架中可以集成各类交互,并且不局限其实现方式,也就是说,我们实质上获得了更好地让交互层成为一种服务的能力。

交互的组合

前面,我们已经提到,可以把承载交互和承载状态的组件分别抽象,它已经让业务的可组合性大为提高了,但在业务系统的建设过程中,仍然远远不够。思考一下这个问题:限制业务架构师(或者产品经理)的输出直接流向研发过程,在技术上的困难主要是什么?

最主要的困难,是这类产物离最终运行的形态有不小的差距,并且这个差距不是线性地补一些东西进去就可以的。比如说,设计产物是很难加逻辑和校验的,为了完成这些事情,在主流产品研发的方法论中,业务建模与交互设计总是有太强的耦合了,不正交的东西想要做拆解,是很难的。

但是我们回头来看,是什么导致这样呢?是语言、框架、还是方法论的不足,导致了这些耦合成为了必然?

还是从简单的业务例子来看一下。通常我们会有这样的场景:当表达一个用户的时候,会根据它的注册状态,选择不同的交互来填写数据。

  • 未注册用户:注册
  • 已注册用户:登录

从交互的角度看,这两者就是两套表现形式的可切换形态。那么,是什么表达了这个“切换”呢?通常我们会选择把这个“切换”理解为一个“动作”,因为主动触发了这个动作,然后再详细描述了切换到其中一个状态之后所包含的业务语义,这件事究竟因为必须这样,还是因为这只是因为我们缺乏某种基础设施所致?

不少人写过 TypeScript,下面给出了一个类似的示例:

  1. type A = {
  2. foo: true,
  3. a: string,
  4. }
  5. type B = {
  6. foo: false,
  7. b: string
  8. }
  9. type C = A | B
  10. const demo: C = {
  11. foo: true, // 当我们改变 foo 的值,下面这行的合法性会产生变化
  12. b: 'aaa'
  13. }

思考一下这个表达,与我们之前的这个“切换”,含义是不是相同的?为什么这里不需要显式的“切换”动作呢?简单来说,是类型系统根据当前的“输入”,结合在类型 C 上的描述,使得类型更精确推导到了 A 或者 B,去除了其中的“可选”语义,引发了业务模型描述的变更。

从这里引申开去,可以挖掘出元数据类型的更多、更深刻的表达方式,细节内容不在本文中阐述。

需要注意的是,类型的扩展是存在多种方式的。通常我们看到的扩展方式,一般是符合“继承”方法论的,但是这种方法论不够灵活。如果我们能从更深的层次去思考,就有机会从更多的类型运算上思考整个交互系统的可组合性。

小结

本文的内容,是沿着设计产物与技术人员的协作路径深入下去的。最近这些年,不少团队在谈“设计中台”这个问题,但是,很大程度上都停留在表面,对于如何深入推进,缺乏完整的工程方法论,比如:

  • 交互集包含怎样的原子组件?
  • 原子组件可以被怎样组合?
  • 设计产物如何只使用原子组件拼搭出来?
  • 设计产物如何与技术实现拥有同等的可组合性?

以上种种,都会导向一个问题:在我们对真实世界建模的时候,最重要的概念到底有哪些?

类型系统毫无疑问是其中之一,在整个工程链路中保持类型系统的运算过程,会有不可估量的价值,而如果我们选择从类型优先的角度构建组件库,并为它们附加必要的元数据,则会从另外一个角度大大提升工具的配合程度。再进一步,将它与业务建模过程打通,有机会让整个工程链路都获得较大提升。