如何设计一个属性面板

614 阅读13分钟

什么是属性面板

属性面板(property panel),顾名思义,是一种可以对属性进行展示和操作的‘物体’。那什么是属性呢?维基百科的解释说到:

属性是人类对于一个对象的抽象方面的刻画,它代表了这个对象所具有的性质和特点

这种描述太过于抽象和学术,让我们结合具体的案例来分别看看不同的领域对于这个用来进行属性展示和操作的‘物体’有哪些具体的实践

电源开关

电的发明给整个工业领域带来个长足的进步,与此同时,控制电源的开关(电闸)帮助我们很好的控制电的断开或者闭合,这就是对电的断开或者闭合这个属性进行展示和操作的‘物体’——属性面板

属性面板的存在帮助了我们更好地展示事物的状态以及执行相应的操作改变其状态

汽车仪表盘

再比如,我们每天上下班都不可避免的会接触到汽车,汽车仪表盘上的各种属性反映了汽车本身的状态,它可以帮助我们更好的了解,车速是多少?还剩多少油?水温有没有过高?系统是否正常?我们由于需要关注各种路面情况、后面是否有超车,左右侧是否有行人,已经不太可能去抽出精力对汽车本身的状态再进行详细的了解,通过仪表盘这种属性面板,我们不再需要去了解汽车本身复杂的知识,诸如此类的还有电梯按钮、键盘等等

在有悠久发展的工业领域,属性面板充当了重要的角色。将目光转向当下,近年来发展迅猛的计算机软件领域也出现了各种属性面板,比如说古老的前端可视化开发工具——dreamweaver,该软件下方的属性面板可以让开发人员更方便地对当前操作的模块的内容和属性进行调整

dreamweaver 编辑器

在设计领域,sketch、figma 均采用了属性面板,通过它,设计人员可以快速对画布上的元素进行控制,极大的提高了设计的效率

sketch 设计工具

搭建领域的属性面板

正如上文提到,在不同的领域中,「属性面板作为操作属性的载体可以帮助我们屏蔽掉那些复杂的底层知识」都具有了一定的实践,那么搭建领域如何去借鉴这种思想呢?

在 「web component」 的设计模式下,每个具有多种交互的页面都是由组件拼装而成的,而每个组件都具有定义的属性,这些属性反映了当前组件的各种状态,非常复杂。使用属性面板后就可以让我们更加方便的展示和编辑组件,那么这样的属性面板应该具有什么样的能力呢?

对于组件来说,面板应该是文档性、自描述的,即只通过属性面板上的文案、描述以及对应的操作区,开发者就可以明白一个组件的属性是什么,应该如何使用和编辑

需要解决哪些问题

从基础的目的出发,一个属性面板至少可以清楚地描述以下基本问题

  • label:「属性名称」,直接告诉开发者这个属性是做什么用的,比如 button 的 type 属性
  • description:「属性的描述信息」,当属性名称不能达到一眼可以识别意图的时候,使用该属性进行详细的阐述,比如一段用来描述 type 的文案、甚至可以加上一些 demo 演示
  • content:「属性渲染器」,用户可以使用其来进行属性的修改,最常见的有由 input、textarea、select、buttonGroup
  • error:「属性的校验信息」,当用输入了不合法的属性数据显示的错误信息,比如 type 要求输入字符串,而输入了数字

属性面板的本质

通过上述的基本原则描述,我们会发现承载这些内容最合适的途径是 form 表单,每一条属性就对应一条 formItem,想想我们每次填写表单时的场景吧 传统 form 表单

每一行都会包含我们上面提到的列项(这里由于属性名称都容易理解,因此没有标注出 description)

  • 通过 label 解释这条 formItem 是用来干什么的
  • 通过渲染内容 content 来告诉用户如何对属性进行操作
  • 一旦修改的数据不符合属性要求,则展示错误信息

此时,我们会发现属性面板的本质其实就是一个表单,如何设计和实现好这样的表单就是至关重要的

设计和实现

在进入实现之前,首当其冲的就是我们以什么样的数据结构去呈现上述梳理的每一条属性? JSON(JavaScript Object Notation)是一种 JS 对象的描述语法,具有简洁、可阅读性好的特点,是互联网服务间最常用的数据交换格式

在此理念之上,JSON Schema 出现了,它是对 JSON 数据结构的一种描述,不仅本身符合 JSON 格式,并且可以对JSON 数据进行校验,非常便捷(更多介绍见 JSON Schema 官网

{
  "title": "Product",
  "description": "A product in the catalog",
  "type": "number"
}

为数据匹配最合适的渲染器

对于 「label」、「description」、「error」 这些偏展示的信息来说,设计和实现起来都不算复杂,我们先把目光放在属性渲染器——给用户提供操作的地方

采用合适的渲染器来渲染 schema 是非常必要的,比如如下的类型声明

/**
 * @title 选中项
 */
size: "small" | "middle" | "large";

size 只有三种取值,语义类似枚举,适合用下拉选择来编辑

下拉选择渲染器

export const select = {
  renderType: 'select',
  Component: SelectRender,
  tester: (schema) => schema.enumOptions?.length > 2,
} as RenderRegisterCommand;

将这些对元信息的推测规则沉淀下来,我们就可以得到一份相对完备的渲染规则,然而有的 schema 可以有多个合适的渲染器来进行渲染,比如对于 number类型的数据来说,既可以通过 inputNumber 进行渲染

inputNumber 渲染器

export const inputNumber = {
  renderType: 'number',
  Component: InputNumberRender,
  tester: (schema) => schema.type === 'number',
} as RenderRegisterCommand;

当然也可以通过 slider 进行渲染

slider 渲染器

export const slider = {
  renderType: 'slider',
  Component: SliderRender,
  tester: (schema) => schema.type === 'number',
} as RenderRegisterCommand;

如何去解决可能有多个合适的渲染器的场景呢?我们后文会重点探讨,首先我们关注下每一个渲染器的RenderRegisterCommand 包括的几个重要部分

  • renderType:渲染器的唯一标识
  • Component:用来进行渲染的组件
  • tester:通过 tester 校验的 schema 就可以选择对应的 Comonent 来渲染

我们都清楚,在 JavaScript 中,一共有 7 中基本数据类型,包括 number、string、boolean、symbol、undefined、null、object

  • symbol 只是为了提供一个唯一值,在低代码平台完全可以屏蔽该类型,降低用户的使用成本
  • 而对于undefined,json 本身是不支持 这种数据类型的

此时我们需要考虑的数据类型只有 5 种,先来看看最常用的 number、string、boolean 可能渲染的方式

number

渲染器类型
inputNumberimage.png
sliderimage.png

string

渲染器类型
inputimage.png
textAreaimage.png
colorimage.png

boolean

渲染器类型
checkboximage.png
switch-

那么其他的两种复杂的数据类型应该如何处理和展示呢?

能够处理渲染父子层级

在 JavaScript 的世界中经常会有万物皆对象的说法,由此可以体现出这种数据类型的重要性,在设计的时候,object 也是比较复杂的类型,有非常多的交互细节值得考虑,比如说

  • 我们如何区分某些属性属于另一些属性,即属性之间的父子关系
  • 我们如何解决一个对象的属性太多时会占用过多的空间

在这里提供一个简单的设计思路

渲染父子层级

通过不同的背景色或者缩进来区分父子之间的关系,提供折叠功能来控制属性太多的问题。

如果子属性也是对象,即一个对象嵌套一个对象时,我们应该如何展示子属性中的对象呢?

嵌套渲染器

在这里,我们选用 array 中元素是对象的数据结构来进行探讨,因为在 JavaScript 中,array 是一个对象,自然覆盖了对象嵌套对象的场景,不仅如此,我们还需要用一种更直观、更便捷的方式来表达并对数组进行操作,比如说

  • 可以对数组的每一个元素进行位置移动
  • 可以方便的数组中的元素进行增删

通过拖拽的方式对每一项的顺序进行调整,然后通过 popover 展示具体的子对象的属性,就可以完成嵌套复杂结构的渲染

可关闭的属性

接下来就是对于 null 这种数据类型的处理,虽然不复杂,但是在通过渲染器进行描述以及修改的时候会遇到一些表述不够清晰的问题,比如说如何去很好的表述一个值到底是 null 还是字符串 'null'

从目前的使用场景来看,很少存在直接使用 null 的意图。因此,我们可以转向去设计了可关闭属性的 nullable,这种设计方式在目前的组件属性使用方式也可以得到很好的匹配

例如 Table 组件的分页属性,可以切换为对象来配置详细的分页数据,也可以整个切换为 false 来表达不使用分页

Table.pagination: false | Pagination

通过在 label 前面加一个 checkbox,来表示「如果开启该属性,我就会把对应的属性传入组件,否则就不会传入」 nullable

可拓展的渲染器设计

基础的设计到上面就已经完毕,接下来需要面对的就是重要的扩展问题。试想一下,当默认的属性渲染器不满足用户的需求时,我们该如何提供相应的机制展示用户自定义的渲染器呢?

export class Factory {
 	private static renderMap: Record<string, RenderRegisterCommand> = {};

	static register(commands: RenderRegisterCommand[]) {
    commands.forEach((command) => {
      this.renderMap[command.renderType ?? command.type] = command;
    });
  }
}

Factory.register([inputNumber, slider]);

通过工厂注册的方式

用户可以按照最初的 RenderRegisterCommand 约定的格式来实现自定义的渲染器,然后通过调用 Factory 工厂中暴露出去 register 函数来实现增加或者覆盖

对输入的数据进行校验

到这里,我们拥有了 schema 以及对 schema 进行渲染的渲染器,于是可以对这些渲染器进行操作以生成我们希望的数据,生成之后呢?没错,「校验」就成为了保证数据格式合法的重要手段

{
  title: "Todo",
  type: "object",
  properties: {
    title: {
      title: "Title",
      type: "string",
    },
    done: {
      title: "Done?",
      type: "boolean",
    },
  },
  required: ["title"],
}

JSON Schema 的规范本身已经清楚地描述了一套校验规则,搭配 ajv 来进行使用,就可以非常方便的得到当前数据是否是合法的,并且可以得到具体地错误信息

try {
  const result = ajv.validate(schema, value);
  if (result) {
    return null;
  }
  return ajv.errors
} catch (e) {
  console.error('[Ajv validator]:', e.message);
}

错误信息

可拓展的数据校验

当谈到 ajv 校验数据类型的时候,或许我们意识到,现在的操作都是基于 JavaScript 基本数据类型来完成的,如果当用户希望自定义一些非 JavaScript 基本数据类型时,该如何提供相应的能力以方便用户扩展呢?

Factory.registerDataType([
  {
    type: 'moment',
    validate: (value) => {
      if (!value.name.includes?.('w')) {
        return {
          message: 'name 属性必须包含字母 w',
        };
      }
    },
  },
]);

与上述 「可拓展的渲染器设计」类似,将当前渲染器生成的值传递给用户,让其做一些自定义的判断逻辑,返回相信的错误信息

const { message: errMsg } = dataTypeMap?.validate?.(data) || {};

之后,在校验的地方调用用户自定义的校验逻辑即可

自定义其他行动点

其实到这里,一个成型的属性面板就可以投入使用了,它不仅可以自动选择合适的渲染器,而且可以对输入后的数据都合法性进行校验

但不止于此,在「为数据匹配合适的渲染器」中,我们留下了一个重要的问题,如果有多个渲染器都可以作为渲染使用,需要怎么处理呢? 可切换的渲染器

在渲染器最后的符号以及弹出的面板中,我们将匹配到的所有合适的渲染器列举出来,让用户方便地选择自己希望使用的渲染方式

除此之外,如果每一条数据都希望有能力自定义类似切换的 action,比如说,上报这条数据到某个平台,跳转到某个链接,此时需要怎么处理?

Factory.registerAction({
  Component: ({ schema, value }) => {
    console.log('schema', schema, value);
    if (schema.type === 'array') {
      return <a style={{ fontSize: 12, position: 'absolute', top: 2, left: -80 }}>配置</a>;
    }
  },
});

将当前渲染器的 schema 和 value 传递给用户,用户自行进行判断和处理后返回 ReactNode,然后在合适的位置将返回的节点进行挂载 自定义操作区域

未来展望

至此,属性面板的设计告一段落,在这里简单梳理下设计中需要关注的核心点

覆盖更多的应用场景

文章中提到,属性面板的本质其实就是一个表单,如何让这个表单去适应足够多的场景?

  • 有的场景希望 label 和渲染器是水平布局;而有的希望是垂直布局
  • 有的场景希望整体是暗黑风格;而有的希望是白色风格
  • 有到场景空间不足,希望属性面板能够紧凑一点;而有的却希望可以大字号宽松一点

我们会发现其实就是在做的就是一个自带主题的表单,搭配不同的主题,就会有不同的能力,让其可以覆盖足够多的场景

极度开放插件的体系

覆盖了足够的场景只是属性面板走出去的第一步,面对不同用户的不同业务需求,如何能够让对方将自己希望的能力以最小的成本接入进来才是富有生命力的属性面板的核心体现

可操作性极强的渲染器

上述的渲染器设计都是一个阶段性的产物,除了最基础的 string、number、boolean 渲染方式,其他一些交互方式也正在面临一些挑战,文中的这些设计都偏向于前端工程师的视角,仍旧需要具体的数据或者指标去证明这样的设计是合理的,如何让他们更具有易用性、普适性是需要通过一些量化手段去不断打磨的