背景
当我们决定要从 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 层 (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 库方案的对比
一般来说,会导致组件渲染发生变更的有两类事件:
UserOperation,即用户的交互操作
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
对比高亮部分,F/A 方案将 DOM 更新前(在 mvvm 框架内一般为 state / $data 更新)的一系列取值、条件判断、设值操作进行了拆分并归类(如上图),将所有最终对 dom 操作/更新相关的都抽出来放在 Adapter 里
Foundation functions 部分,负责交互行为逻辑,包括各种计算、判断分支等逻辑的行为组合。其中需要 dom 操作的部分会委派给 Adapter functions
Adapter functions 部分,负责所有跟 DOM 操作/更新相关的逻辑,通常会使用框架 api 进行 setState、getState、addListener、removeListener 等操作
此后我们的 Foundation 就变成了通用的、与前端框架无关、可复用的模块。
下图展示了一个组件从用户操作到 dom 更新所需要经过的函数调用链
代码示例
下面我们以 Collapse 折叠面板组件为例,结合代码实际分析以下组件层是如何实现 F/A 划分的
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,侵权必删