-
Notifications
You must be signed in to change notification settings - Fork 727
About Foundation Adapter
WIP
(Feishu Doc Version: https://bytedance.feishu.cn/docs/doccnTgc0iGOVPubHZkwPpxXSNh#4vWuu5)
当我们决定要从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回调
- 收起浮层
- 卸载键盘事件监听
当我们将组件交互拆解后会发现,无论使用什么前端框架实现,以上场景的交互响应逻辑都是固定的,动作流程是可复用的
有所不同的仅仅是前端框架中,对于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等操作
一般来说,会导致组件渲染发生变更的有两类事件:一是UserOperation,即用户的交互操作 二是Component LifeCycle Change,由于事件流转或者Props更新等导致的组件的生命周期发生变化 在常见的UI组件库方案、F/A方案中这两类事件的处理流程有所不同
UserOperation、Component LifeCycle Change
- => EventHandler A / EventHandler B / EventHandler C + ……(一系列函数调用,每个function 一般包括取值、计算/判断逻辑、设值的组合操作。在常见的组件库实现里,ii 这类UI逻辑代码常与i、iii 这些框架api代码深深耦合在一起,所以针对不同框架进行移植的时候往往成本很大)
- getCurrentState
- Computed / Judge Logic
- setState
- => Dom Update
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等
}
}
完整代码可以查阅
- https://github.com/DouyinFE/semi-design/blob/main/packages/semi-foundation/collapse/foundation.ts
- https://github.com/DouyinFE/semi-design/blob/main/packages/semi-ui/collapse/index.tsx
当我们需要实现其他框架版本的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的方式实现
以上便是Semi 的组件架构分层设计,与传统方案相比,F/A方案会对代码拆分的要求更高,整体代码量也有所增加,但同时也带来了更好的移植性。
✅ 优点 | ❌ 缺点 |
---|---|
视图层与逻辑层分离,视图层逻辑更少,只包含必要的render函数。结构清晰,代码可维护性更好 | 前期代码划分,需做好抽象。 |
可移植性强。将通用的组件逻辑都抽象出来在Foundation(即semi-foundation)中复用,适配不同前端框架只需要对已实现好的版本接口,写多套不同的Adapter声明视图层即可。在复杂组件(如DatePicker、Tree、Calendar、Table……)移植上可以明显节省开发时间。低成本实现设计语言在不同前端技术栈的跃迁。 | 整体代码量会有所略微增加(即一个handler函数至少需拆分为 Foundation function + Adapter function两部分) |
目前我们实现了 Adapter 的 React 版本,你可以直接通过引入 semi-ui (https://semi.design)来使用我们的组件. 在未来,我们可以通过完全复用Foundation,对照React体系的Adapter,快速复制出不同框架版本的Semi 组件库。