Skip to content

About Foundation Adapter

pointhalo edited this page Nov 25, 2021 · 1 revision

English Version

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等操作

与常见UI库方案的对比

一般来说,会导致组件渲染发生变更的有两类事件:一是UserOperation,即用户的交互操作 二是Component LifeCycle Change,由于事件流转或者Props更新等导致的组件的生命周期发生变化 在常见的UI组件库方案、F/A方案中这两类事件的处理流程有所不同

常见方案
UserOperation、Component LifeCycle Change
  1. => EventHandler A / EventHandler B / EventHandler C + ……(一系列函数调用,每个function 一般包括取值、计算/判断逻辑、设值的组合操作。在常见的组件库实现里,ii 这类UI逻辑代码常与i、iii 这些框架api代码深深耦合在一起,所以针对不同框架进行移植的时候往往成本很大)
    1. getCurrentState
    2. Computed / Judge Logic
    3. setState
  2. => Dom Update
F/A方案
  UserOperation、Component LifeCycle Change
  1. => Foundation f(n)
  2. => Adapter f(n)
  3. => 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的方式实现

总结

以上便是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 组件库。