前端精读周刊:可视化搭建框架内置 API 设计指南——从「必须有」到「建议有」的取舍之道
前端精读周刊:可视化搭建框架内置 API 设计指南——从「必须有」到「建议有」的取舍之道
可视化搭建框架在完成画布渲染与数据流设计后,还需提供一套方便易用的内置状态与方法,才能真正让业务侧"开箱即用"。本篇以本仓库《可视化搭建》系列为背景,系统拆解内置 API 的完整清单:哪些状态与方法必须有、哪些建议有,以及它们各自的类型签名、设计动机与底层实现思路(含 O(1) 时间复杂度优化)。读完本文,你将掌握一套可直接复用的可视化搭建 API 设计范式,理解"以组件树为核心、以组件 ID 为操作入参、内部转化为树操作"的通用原则,并能据此评估或设计自己的搭建框架。
一、为什么画布设计完成后还需要内置 API
在设计好画布与组件数据流体系后(参见 画布与组件元信息数据流),理论上主体功能已经完成:组件树描述结构、组件元信息描述渲染、数据流打通 UI 与元信息。但开发者在搭建业务时,仍需频繁执行"增删改查组件、获取选中项、撤销重做、获取最终 props"等通用操作——如果这些能力全部由业务各自实现,不仅重复造轮子,还会导致插件、自定义组件无法以统一标准读取框架状态。
因此需要内置一些状态与方法。但内置的边界必须克制:
- 内置状态与方法必须寻求业务的最大公约数,极具抽象性;
- 添加需慎重,一旦内置了不合理的 key,会成为框架的历史包袱。
接下来的核心问题是:哪些 API 是"必须有"的,哪些是"建议有"的?这构成了本文的主线。在深入清单之前,先明确这两类 API 的引用方式,因为它是理解所有 API 语义的前提。
二、状态与方法的两种引用方式
2.1 状态:可变数据,两种引用途径
状态是可变的,引用方式有两种:
方式一:任意 React 组件内通过 useDesigner 访问。当状态变化时会触发所在组件重渲染:
const { componentTree } = useDesigner((state) => ({
componentTree: state.componentTree,
}));
useDesigner 还支持第二个参数 compare 自定义对比函数,默认使用 shallowEqual(详见 画布与组件元信息数据流):
useDesigner(
(state) => ({
componentTree: state.componentTree,
}),
isEqual
);
方式二:任意组件元信息内通过 selector 访问。当状态变化时会触发不同行为——在 runtimeProps 中会触发组件重渲染,在 fetcher 中会触发重新查询:
const tableMeta = {
/** ... */
runtimeProps: ({ selector }) => {
const { componentTree } = selector(({ state }) => ({
componentTree: state.componentTree,
}));
return { componentTree };
},
};
值得注意的是,selector 内部拿到的 state 或 props 其实都是 Proxy 代理对象,框架借此记录依赖关系,实现按需重执行(详见 自动批处理与冻结 中的性能优化设计)。
2.2 方法:不可变引用,两种引用途径
方法引用不可变,引用方式同样有两种:
方式一:任意 React 组件内通过 useDesigner 访问。因为方法引用恒定,所以不会导致组件重渲染:
const { addComponent } = useDesigner();
方式二:任意组件元信息内通过回调直接访问:
const tableMeta = {
/** ... */
runtimeProps: ({ addComponent }) => {},
};
由于方法引用不变,元信息回调(如 runtimeProps)可以直接解构使用,不会带来额外的重渲染开销。
三、内置状态清单
3.1 componentTree —— 必须有
- 类型:
ComponentInstance - 评价:必须有
描述完整组件树 JSON 结构。这里有一个关键设计:非受控模式下组件树存储在 <Designer /> 实例内部;受控模式下组件树存储在外部状态。但框架允许两种模式都通过 componentTree 状态访问:
这样在开发可视化搭建应用的过程中,就不用关心受控或非受控模式了,即一套代码同时兼容受控与非受控模式。
这得益于数据流层对受控/非受控的统一抽象(见 画布与组件元信息数据流):受控模式通过 <Designer actions={{...}} state={{...}} /> 对接外部 Redux/Dva/Zustand,非受控模式通过 createMiddleware 直接以 Designer 数据流为项目数据流,两种模式对外暴露的状态与方法完全一致。
3.2 selectedComponentIds —— 建议有
- 类型:
string[] - 评价:建议有
定义当前选中组件实例 id 列表。虽然业务也能自行定义选中状态,但"选中组件"是可视化搭建中的常见行为:
以后定义插件、自定义组件也许都会读取当前选中的组件,如果框架定义了此通用 key,那么插件和自定义组件就可无缝结合到任意业务代码里。反之如果在业务层定义该状态,插件或者自定义组件也不知道如何标准的读取到当前选中的组件。
这是"通用 key 即协议"的典型体现——框架内置该 key 的价值不在于省一行代码,而在于让插件生态拥有可对接的标准。
3.3 canUndo、canRedo —— 建议有
- 类型:
boolean - 评价:建议有
描述当前状态是否能撤销或重做。它们需要与内置方法 undo()、redo() 一起提供,属于"有了更好"的状态。但作者也明确指出其局限:
比如你的应用分了多个 sheet,每个 sheet 内是一个画布实例,而你希望撤销重做可以跨 sheet,那就不适合用单实例提供的方法了。
这提示 API 设计者:内置状态与方法应以单画布实例为粒度,跨实例的复合能力应交由业务层基于基础 API 自行组装。
四、内置方法清单(上):必须有的核心方法
4.1 状态基元:getState() 与 setState()
getState(): State // 必须有
setState(state: State): void // 必须有
getState():获取应用全部状态,包括内置与业务自定义。setState():更新应用全部状态,包括内置与业务自定义。
在 画布与组件元信息数据流 中明确说明:Designer 内部采用最朴素的 Redux 管理状态,提供最基础的 getState 与 setState 获取与修改状态,基于它们封装业务函数即可。这也解释了为什么 createMiddleware 的第二个参数能拿到 setState 来封装自定义 hooks(如 setUserName)——所有高层 API 最终都收敛到这两个基元。
4.2 组件树专属:getComponentTree() 与 setComponentTree()
getComponentTree(): () => ComponentInstance // 必须有
setComponentTree(callback: (now: ComponentInstance) => ComponentInstance): boolean // 必须有
为什么有了 componentTree 状态还要 getComponentTree() 方法? 因为很多回调函数并不依赖组件树重渲染,而仅仅在触发时获取其瞬时值,此时必须调用该方法。虽然一定程度上可用 getState().componentTree 代替,但作者点明了两者的微妙差异:
在受控模式下,
getState().componentTree不一定等价于getComponentTree(),因为前者是从<Designer />拿组件树,而后者直接请求外部状态最新的组件树,当组件树受控模式没有及时触发渲染同步时,后者值会比前者更新。
setComponentTree() 与 setState() 的区别同样体现在受控模式:在非受控模式下两者等价于修改 componentTree;但在受控模式下,setComponentTree() 会直接透传到外部状态,直接修改一手组件树,极端情况下表现更稳定。
setComponentTree 采用"回调接收当前组件树、返回新组件树"的函数式签名,天然适合基于 Immutable 的数据流(详见 组件注册与画布渲染 中组件树的 JSON 序列化约束)。
4.3 组件增删改查四件套
这组方法是业务使用频率最高的 API,也是"组件树操作"能力的最小闭环:
addComponent(componentInstance, parentIdPath?, index?, position?): void // 必须有
deleteComponent(componentIdPath: string): boolean // 必须有
getComponent(componentIdPath: string): ComponentInstance // 必须有
setComponent(componentIdPath, callback): boolean // 必须有
addComponent() 参数详解(基于 setComponentTree() 实现,但因为太常见且意图复杂,抽成独立函数):
componentInstance:必选,默认把组件实例添加到根节点的children位置;parentIdPath:可选,描述要添加到的父节点 ID。当父节点没有定义组件 ID 时,也可以用例如children.0这种组件树路径代替,所以名称不叫parentId而是parentIdPath;index:可选,描述要添加到父节点子元素下标(如children的第几项);position:可选,描述要添加到父节点children还是props.header等位置——因为组件实例并不只有children一个容器位置(这一点与 容器组件设计 中"任意 props key 都可以是子组件实例"的理念一致)。
deleteComponent() 的 O(1) 设计:componentIdPath 可传组件 ID,也可传组件树路径,真正删除要从树上删。框架内部为了快速从组件 ID 定位到 treePath,维护了一张映射表,因此无论何时调用都是 O(1) 时间复杂度。
getComponent() 与 setComponent():分别基于 getComponentTree() 与 setComponentTree() 实现——"增删"都有了,"查改"自然补全。
4.4 父子关系:getParentId() 与 setParent()
getParentId(componentIdPath: string): string // 必须有
setParent(componentIdPath, parentIdPath, index, position): boolean // 必须有
getParentId():获取组件的父组件 ID。因为 componentTree 是树状结构,组件实例上找不到父节点,所以提供快速找父节点的函数非常必要。内部实现同样不采用遍历,而是在解析组件树时就建立好关联映射表,保证所有内置方法 O(1)。
setParent():调整某个组件的父节点。参数与 addComponent() 很像,只是第一个参数从"组件实例"改为"组件 ID",参数含义相同。当画布涉及组件跨父节点移动时,这个方法十分关键。作者特别解释了为什么必须内置它:
当组件跨节点移动时,在组件树上操作还是比较复杂的,因为移除 + 添加无论先做哪个,都会导致组件树变化,从而导致后一个操作位置可能错误。如果每次都重新寻址性能会较差,如果想用聪明的方法绕过,逻辑还是比较复杂的,因此有必要内置该方法。
4.5 组件元信息:setComponentMeta() 与 getComponentMeta()
setComponentMeta(componentName: string, componentMeta: ComponentMeta): void // 必须有
getComponentMeta(componentName: string): ComponentMeta // 必须有
setComponentMeta():更新组件元信息。作者提醒,提供这个方法对框架挑战较大:在提供很多生命周期(如runtimeProps、fetcher、init等)的情况下,随时可能发生组件实例的更新,要保证整体逻辑符合预期,需要仔细设计。getComponentMeta():获取组件元信息。注意:通过<Designer />受控/非受控模式注册,或直接调用setComponentMeta注册的组件元信息,都应该可以正常获取到——这是受控与非受控统一抽象的另一处体现。
五、内置方法清单(下):建议有的便利方法
5.1 组件树遍历类
getComponents(): ComponentInstance[] // 建议有:获取全量组件实例数组(打平形式)
getTreePath(componentIdPath: string): string // 建议有:根据组件 ID 查找组件树上的路径
getParentBy(componentIdPath: string, finder: (parent: ComponentInstance) => boolean): string // 建议有
getComponents():因为组件树是树状结构,业务除了递归遍历外,还可以用这种打平形式的组件数组以备不时之需。getTreePath():业务若想自己操作组件树,框架提供"组件 ID → 树路径"的转换方法很合适(其内部实现与deleteComponent的 ID→路径映射表同源)。getParentBy():基于getParentId()实现,一直向上寻找父节点,直到finder返回true,方便业务向上寻找符合条件的祖先节点。
5.2 props 快捷操作类
setProps(componentIdPath, callback): boolean // 建议有:修改组件实例的 props
getProps(componentIdPath): any // 建议有:获取组件实例的 props
getMergedProps(componentIdPath): any // 建议有:获取组件最终混合后的 props
setProps()基于setComponent()实现——修改组件 props 比修改整个组件实例常见得多。getProps()基于getComponent()实现,调用频率同样更高。getMergedProps()与getProps()的关键区别:组件 props 可能来自组件树,也可能来自runtimeProps处理后的结果。为了防止"傻傻分不清":
规定
getProps()仅获取组件树上序列化的 props,而getMergedProps()获取了包含runtimeProps处理后的最终 props。
这与 组件注册与画布渲染 中 runtimeProps 注入函数、且优先级高于组件树同名 key 的设计一脉相承。
5.3 选中与撤销重做
setSelectedComponentIds(ids: string[]): void // 建议有:修改内置状态 selectedComponentIds
undo(): void // 建议有:撤销
redo(): void // 建议有:重做
- 既然提供了
selectedComponentIds内置状态,就强烈建议配套setSelectedComponentIds()修改方法(虽然也可通过setState()更新该 key 实现)。 - 同理,如果提供了
canUndo、canRedo内置状态,那么一定要提供undo()、redo()内置函数,二者成对出现。
5.4 DOM 相关
getComponentDom(componentIdPath: string): HTMLElement // 建议有
afterDomRender(componentIdPath: string, callback: () => void): Promise // 建议有
getComponentDom():根据组件 ID 获取 DOM 实例。框架最好通过一些技巧(如createPortal、ref 注入等)让组件即便不用forwardRef也能拿到 DOM,只要组件存在 DOM 即可通过该方法拿到,非常方便。afterDomRender():因为组件 DOM 依赖渲染,不能保证调用getComponentDom时 DOM 真的完成了渲染,因此将时机放在afterDomRender()回调后,保证一定可以拿到 DOM。
5.5 组件元信息批量操作
getComponentMetas(): ComponentMeta[] // 建议有:批量获取所有已注册的组件元信息
clearComponentMetas(): void // 建议有:清空所有组件元信息
作者对两者的评价很朴素:"说不定业务会有什么特别的用途,建议提供。"这体现了内置 API 的另一条取舍标准:实现成本低、潜在收益不确定但非负的 API 可以纳入"建议有"区间。
六、总结:内置 API 的三条设计原则
回顾本章设计,内置 API 的思路可以归纳为三条原则,它们共同保证了框架"核心极简、外围便利、整体稳定":
-
从组件树核心概念发散:围绕
componentTree这个核心概念设置必要 API,再补充"逻辑复杂或使用很方便"的推荐 API。整个清单的骨架是"状态基元(getState/setState)→ 组件树基元(getComponentTree/setComponentTree)→ 增删改查(add/delete/get/set + props 快捷)→ 关系与便利方法"。 -
组件 ID 作为统一操作入参,内部转化为树操作:虽然组件树是树状结构,但内置 API 考虑易用性,所有操作都以组件 ID(或 ID 路径)作为参数;内部实现时转化为组件树操作,并内置 O(1) 时间复杂度的优化(ID→treePath 映射表、父节点映射表均在组件树解析时预构建)。
-
核心 API 只有寥寥几个,其余皆以核心 API 为基础实现:
addComponent基于setComponentTree、getComponent基于getComponentTree、setProps基于setComponent、getMergedProps基于getComponent……这种"便利 API 全部建立在少量核心 API 之上"的做法,让框架核心更稳定,同时框架大部分 API 只定义实现规则,业务利用核心 API 拥有更大的实现自由。
这三条原则在后续章节中被反复验证:在 场景实战 中,上卷下钻、Tabs 组件、富文本内嵌组件、任意协议扩展都只用了 runtimeProps、valueRelates、setValue、onReadComponentMeta 等少量 API 组合;在 keepAlive 模式 与 ComponentLoader 与动态组件 中,框架仅通过新增渲染层(<ComponentLoader>、keepAlive 参数)就扩展了能力,而未动摇任何基础 API 的语义。
可以说,本章给出的不仅是一份 API 清单,更是一套可视化搭建框架的"最小可用协议":状态与方法统一抽象、组件树为核心、O(1) 映射优化、便利 API 收敛于核心 API——只要熟悉了这套规范,业务侧几乎可以仅根据搭建行为,一眼猜出底层是基于哪些 API 封装实现的,维护成本与理解成本随之大幅降低。