跳到主内容

UI组件库的分层设计理念 - Semi Design框架

· 15分钟阅读

背景

当我们决定要从 0 到 1 打造一个设计系统的时候,面临的首要问题必然是技术选型。在当前阶段,前端组件库选型要回答的第一个问题:我要基于哪个 JS 框架来实现?这一问题的答案往往与前端团队本身已有技术栈选型,团队成员偏好等强相关。

但就 Design System 设计语言本身而言,是与技术实现无关的。所以我们会看到诸如无论是 material design、ant design 除了官方提供的 React、Angular 版本外,社区还衍生出了 Vue、Svelte、Blazor 等多个基于不同技术栈的实现版本,本质上属于同一种设计语言,在不同技术栈上的表达。

在 JS GUI library 的实现历史上,无论是 jQuery 时代,还是当前 mvvm 类框架时代,大多数组件的交互形式并没有发生根本性的变化,仅仅是基于不同的语法重新实现逻辑封装。那么是否有一种 GUI 实现方式,write once,run anywhere,兼容不同的框架呢?

我们首先想到的自然是 WebComponent,得益于自定义元素,shadow dom 等特性,它在打造组件库方向,具备天然的优势,特别是作为 library 被不同框架的应用层所消费时,具备更强的通用性,无需关注宿主应用的前端框架选型。然而当前阶段,无论是浏览器兼容性还是上下游工具链,WebComponent 都不够成熟,尚无法大规模在生产环境中直接应用。从面向未来的角度看,WebComponent 的生态早晚会走向成熟,当它逐渐稳定后,如果我们又需要重新将所有组件逻辑实现一遍的话,其实是在做重复工作。

如果我们现阶段就将抽象程度进一步提高,以可跨框架作为要求,将所有实现逻辑拆分为框架强相关 + 框架无关,分离 render function 与 handler function。是否可以显著降低我们在不同框架间迁移的工作量,从而低成本地实现多框架支持呢?

交互示例

我们以 Select 选择器组件为例子分析一下它的交互行为

交互示例

场景 A:Select 的初始化

  • 收集 optionList/children,构造出候选项列表

  • 根据 defaultValue/value,计算出哪个候选项该被高亮选中,若两者均为空,则直接展示 placeholder

场景 B:点击 Trigger(图中蓝框部分)时

  • 根据 Trigger DOM 宽度,计算得出浮层宽度

  • 展开浮层,展示所有候选项

  • 注册键盘事件监听(↑/↓/Esc/Enter)

场景 C:点击选中一个选项后

  • 判断当前选项是否 disabled

  • 更新浮层中选项列表勾选状态,更新 Trigger 处回显内容

  • 触发 onChange 回调

  • 收起浮层

  • 卸载键盘事件监听

tip

当我们将组件交互拆解后会发现,无论使用什么前端框架实现,以上场景的交互响应逻辑都是固定的,动作流程是可复用的

有所不同的仅仅是前端框架中,对于 dom 结构声明,事件绑定,内部状态的读写操作,驱动渲染更新的方式都有一套各自指定的语法。

在简单组件中,这类流程动作占比并不高,复用价值不明显。但假如我们当前维护的是超高复杂度的组件,例如 Table 表格、Datepicker 日期选择器等组件时,此类交互流程逻辑往往能达到数千行以上,复用就显得意义巨大。

方案

Semi Desigm 的跨 JS 框架方案正是以上思想的实践:将每个组件的 Javascript 拆分为两部分:Foundation (与框架无关)和 Adapter(与框架强相关),以下简称 F/A 方案。

这使得我们可以通过仅重新实现 Adapter 部分来跨框架重用 Foundation 代码,例如 React 、Vue、Svelte 或者 WebComponent,快速打造不同平台上的通用组件库。

Foundation架构

  • Foundation 层 (semi-foundation)

Foundation 包含最能代表 Semi Design 组件交互的业务逻辑,包括 UI 行为触发后的各种计算、分支判断等逻辑,它并不直接操作或者引用 DOM,任意需要 DOM 操作,驱动组件渲染更新的部分会委派给 Adapter 执行。

  • Adapter 层 (semi-ui)

Adapter 是一个接口,具有 Foundation 实现 Semi Design 业务逻辑所需的所有方法,并负责 1. 组件 DOM 结构声明 2.负责所有跟 DOM 操作/更新相关的逻辑,通常会使用框架 API 进行 setState、getState、addEventListener、removeListener 等操作

与常见 UI 库方案的对比

一般来说,会导致组件渲染发生变更的有两类事件:

  1. UserOperation,即用户的交互操作

  2. Component LifeCycle Change,由于事件流转或者 Props 更新等导致的组件的生命周期发生变化

在常见的 UI 组件库方案、F/A 方案中这两类事件的处理流程有所不同

常见方案

  • UserOperation、Component LifeCycle Change
  • EventHandler A / EventHandler B / EventHandler C + ……(一系列函数调用,每个 function 一般包括取值、计算/判断逻辑、设值的组合操作。
  • Dom Update

F/A 方案

  • UserOperation、Component LifeCycle Change
  • Foundation f(n)
  • Adapter f(n)
  • Dom Update

image

对比高亮部分,F/A 方案将 DOM 更新前(在 mvvm 框架内一般为 state / $data 更新)的一系列取值、条件判断、设值操作进行了拆分并归类(如上图),将所有最终对 dom 操作/更新相关的都抽出来放在 Adapter 里

  • Foundation functions 部分,负责交互行为逻辑,包括各种计算、判断分支等逻辑的行为组合。其中需要 dom 操作的部分会委派给 Adapter functions

  • Adapter functions 部分,负责所有跟 DOM 操作/更新相关的逻辑,通常会使用框架 api 进行 setState、getState、addListener、removeListener 等操作

image

此后我们的 Foundation 就变成了通用的、与前端框架无关、可复用的模块。

下图展示了一个组件从用户操作到 dom 更新所需要经过的函数调用链

image

代码示例

下面我们以 Collapse 折叠面板组件为例,结合代码实际分析以下组件层是如何实现 F/A 划分的

image

image

Collapse 的使用代码如上所示,每个 Panel 允许配置 header 与 children,itemKey 用于标识当前 panel。通过设置 defaultActiveKey 或者 activeKey 可以控制不同的 Panel 激活状态,点击右侧箭头 Icon 亦可切换折叠状态。

那么要实现这样一个折叠面板组件,在 F/A 架构方案下,我们需要如何组织代码呢?我们以 React 体系为例子

  • Adapter 层,即 Collapse.jsx

    • constructor 阶段, new CollapseFoudation(this.adapter) 并执行 foundation.init() 方法,根据所传 props,计算出初始值 activeSet,赋值给 this.state 进行初始化

    • 将事件回调 onClick、激活状态 activeSet 通过 context 传至 Panel。当 Panel 点击期望切换折叠状态时时,实际调用的是 this.foundation.handleChange

    • adapter getter 中,负责 foundation 所需调用涉及 props、state 读写的函数的具体实现:getState、getProps、handleChange、updateActiveKey

import { cssClasses, strings } from "@douyinfe/semi-foundation/collapse/constants";
import CollapseFoundation from "@douyinfe/semi-foundation/collapse";
import "@douyinfe/semi-foundation/collapse/collapse.scss";
import CollapseContext from "./collapse-context";
// ...

class Collapse extends BaseComponent {
static Panel = CollapsePanel;

static propTypes = {
activeKey: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
defaultActiveKey: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
onChange: PropTypes.func,
};

static defaultProps = {
defaultActiveKey: "",
};

constructor(props) {
super(props);
this.foundation = new CollapseFoundation(this.adapter);
const initKeys = this.foundation.init();
this.state = {
activeSet: new Set(initKeys),
};
this.onChange = this.onChange.bind(this);
}

get adapter() {
return {
getState: () => this.state,
getProps: () => this.props,
notifyChange: (...args) => this.props.onChange(...args),
updateActiveKey: (activeSet) => this.setState({ activeSet }),
};
}

static getDerivedStateFromProps(props, state) {
if (props.activeKey) {
const keys = Array.isArray(props.activeKey) ? props.activeKey : [props.activeKey];
const newSet = new Set(keys);
if (!isEqual(newSet, state.activeSet)) {
return {
...state,
activeSet: newSet,
};
}
return state;
}
return state;
}

componentWillUnmount() {
this.foundation.destroy();
}

onPanelClick = (activeKey, e) => {
this.foundation.handleChange(activeKey, e);
};

render() {
const {
defaultActiveKey,
accordion,
style,
motion,
className,
keepDOM,
expandIconPosition,
expandIcon,
collapseIcon,
children,
...rest
} = this.props;
const { activeSet } = this.state;
return (
<div className={className} style={style}>
<CollapseContext.Provider
value={{
activeSet,
expandIcon,
collapseIcon,
keepDOM,
expandIconPosition,
onClick: this.onPanelClick,
motion,
}}
>
{children}
</CollapseContext.Provider>
</div>
);
}
}
  • Foundation 层,即 CollapseFoundation.js

    • init:组件初始化时需执行的动作,负责根据 props 计算出所需 state

    • handleChange:折叠面板点击时需执行的事件回调,负责更新 state 以及触发对用户的回调

    • 需要读取 props 以及 state 时,通过调用 adapter.getState()、adapter.getProps() 进行实际获取,当需要更新 state 时,也需要通过 adapter 进行操作,而并非直接操作 state。 Foundation 只需关心调用 adapter 的名称,而无需关注内部具体实现

class CollapseFoundation extends BaseFoundation {
constructor(adapter) {
super({
...adapter,
});
}

// 组件初始化时(constructor或者didMount)进行调用,
init() {
const { defaultActiveKey, activeKey, accordion } = this._adapter.getProps();
let activeKeyList = activeKey ? activeKey : defaultActiveKey;
if (accordion) {
activeKeyList = Array.isArray(activeKeyList) ? activeKeyList[0] : activeKeyList;
}
if (activeKeyList && activeKeyList.length) {
activeKeyList = Array.isArray(activeKeyList) ? activeKeyList : [activeKeyList];
return activeKeyList;
}
return [];
}

// 用户点击操作时进行调用,计算出最新的 activeKey
handleChange(newKey, e) {
const { activeKey, accordion } = this._adapter.getProps();
const { activeSet } = this._adapter.getStates();
let newSet = new Set(activeSet);
if (newSet.has(newKey)) {
newSet.delete(newKey);
} else {
if (accordion) {
newSet = new Set([newKey]);
} else {
newSet.add(newKey);
}
}
this._adapter.notifyChange([...newSet.values()], e);
if (typeof activeKey === "undefined") {
this._adapter.updateActiveKey(newSet);
}
}

destroy() {
// 组件销毁时调用,一般用于销毁定时器,keyboardEvent等
}
}

完整代码可以查阅

如何迁移

当我们需要实现其他框架版本的 Collapse 组件时,可以做到完全无需关注 Foundation 中的细节逻辑,我们仅需要按以下原则,仅将 React 版本的 Adapter 层照搬重新实现一遍即可得到一份完全一致的库。以 Vue 组件库为例子,仅需要按照以下三个原则,对照 React 中的 Collapse.jsx 简单重写渲染层即可。此处不再进行赘述。

  • DOM:将 React 的 render 函数以 Vue 的语法实现,包括 dom 结构、classname 切换

  • ComponentState:将 React constructor state 声明、setState、this.state 替换为 Vue 的 this.data 读写

  • Event Handler:将 React 中的事件绑定切换至 Vue 的方式实现

分层设计的优点

  • 视图层与逻辑层分离,视图层逻辑更少,只包含必要的 render 函数。结构清晰,代码可维护性更好。

  • 可移植性强。将通用的组件逻辑都抽象出来在 Foundation(即 semi-foundation)中复用,适配不同前端框架只需要对已实现好的版本接口,写多套不同的 Adapter 声明视图层即可。在复杂组件(如 DatePicker、Tree、Calendar、Table……)移植上可以明显节省开发时间。低成本实现设计语言在不同前端技术栈的跃迁。

分层设计的缺点

  • 前期代码划分,需做好抽象。

  • 整体代码量会有所略微增加

(即一个 handler 函数至少需拆分为 Foundation function + Adapter function 两部分)

总结

以上便是 Semi 的组件架构分层设计,与传统方案相比,F/A 方案会对代码拆分的要求更高,整体代码量也有所增加,但同时也带来了更好的移植性。

目前我们实现了 Adapter 的 React 版本,你可以直接通过引入 semi-ui (https://semi.design)来使用我们的组件

在未来,我们可以通过完全复用 Foundation,对照 React 体系的 Adapter,快速复制出不同框架版本的 Semi 组件库。

文章转载

Semi Design - UI组件库如何分层设计,使其具备适配多种mvvm框架能力

如有侵权请告知340443366@qq.com,侵权必删