基于 React 打造高自由度的 IDE 布局系统
文末福利:开发者藏经阁
NO.1
背景介绍
IDE 的 Layout 系统需要整合不同的功能视图模块,向用户提供一个完整易用的最终集成开发环境。业界现有 IDE 产品的实现基本上都是只提供一个最终的成品 IDE,视图本身具有一定的拓展性,但是不需要具备可强自定义的能力,所以只需要预定义好一套视图系统,其他视图模块开发时按照约定的规范来进行贡献即可。KAITIAN 的定位与业界的 IDE 产品有所不同,目的是提供一个强大且可拓展可定制的 IDE 底层,基于这个 IDE 底层,我们需要满足集成方的各式各样的布局定制需求,不同 BU 产出的 IDE 产品最终在视图效果上可能是截然不同的,这就必然对我们的 IDE Layout 层提出了新的要求。
图 CloudIDE、支付宝小程序IDE和DEF IDE不同的IDE布局需求
在共建之初我们基于 Phosphor.js 打造了一版 Layout 系统,走的是传统 IDE 布局的路,我们 预定义好了一套视图 ,在视图上挖出了一些 插槽(下称 Slot),功能模块的视图在上层通过 视图配置(下称LayoutConfig)映射到各个 Slot 内,不同 Slot 对视图的渲染方式是预定义好的,比如左侧是 ActivityBar(vscode 的侧边栏实现),底部是 TabPanel (vscode 的底部栏实现)等。在这一套布局系统中,我们把定制能力限定在了 配置化 这一层,除了提供视图和插槽的映射关系之外,我们在 LayoutConfig 上还开了很多配置的口子,比如右侧是否要展示,左侧面板的默认尺寸,左侧面板的全屏能力等等。但是随着参与共建的三方都开始集成自己的 IDE 产品,不同布局需求接踵而至,配置越开越多,Layout 越来越重,已经到了难以维护的程度。所以我们决定对 Layout 系统进行一轮彻底的重构。
NO.2
设计思路
首先我们需要再明确一下重构的目的,作为 IDE 底层能力提供方,仅通过配置化提供自定义能力是无法满足越来越多的集成方的自定义需求的,既然我们无法做到收敛,那么不如就把原来 预定义的布局系统实现 这一层给完全开放出去,让集成方自己来划分他们自己的布局:
图 新旧版布局系统对比,布局实现全部开放
这个思路是没问题的,问题是怎么在开放性的基础上,保证集成成本不至于太高呢?由上图可以看出来,在新的系统下,我们是等于把 Layout 的核心功能基本上全部都开放出去了,那么难道需要每个集成方都需要自己开发一套 Layout?答案显然是否定的,考虑到成本问题,作为底层框架方,我们需要通过合理的抽象,只开放用户关心的部分——视图部分。经过设计,我们把视图的实现划分为了一下两个部分:插槽模板和插槽渲染器(下称 LayoutComponent 和 SlotRenderer ),其功能如下图所示:
图 视图实现的两层抽象
经过抽象之后,布局系统的实现只需要两个视图部分即可。但是 IDE 的布局系统有一定的复杂度,比如第一部分的 LayoutComponent 不仅仅是把插槽给划分出来,还需要支持 resize 等基础能力;SlotRenderer 需要支持多个视图的管理能力,与视图容器层也有一定的交互,不可能让集成方裸写,成本依然很高。所以在这里我们需要把 Layout 的底层设计的足够强大,保证集成方可以通过简单的拼装实现他的定制需求。所以我们的技术方案也就呼之欲出了,React 的 JSX 语法和组件化思想在解决这个问题上非常合适,框架方提供好布局系统需要用到的基础组件,集成方通过 JSX 按照定义的规范完成拼装即可。LayoutComponent 拼装的过程可能如下(伪代码)。
<BoxPanel>
<SlotRenderer slot="top" />
<SplitPanel direction="left-to-right">
<TabbarRender slot="left"></TabbarRender>
<SplitPanel direction="top-to-bottom">
<SlotRenderer slot="main" />
<SlotRenderer slot="bottom" />
</SplitPanel>
<TabbarRender slot="right"></TabbarRender>
</SplitPanel>
<SlotRenderer slot="bottom-bar" />
</BoxPanel>
LayoutComponent + LayoutConfig 完成了插槽定义和插槽视图映射的功能,但是一个插槽是对应多个视图的(比如侧边栏和底部栏),插槽如何消费(渲染)LayoutConfig 贡献进来的多个视图呢?SlotRenderer 插槽渲染器就是解决这个问题的,它定义了多个视图在插槽内的渲染方式,可以是常规的依次渲染,也可以是按照我们熟悉的多 Tab 方式进行管理的方式,集成方可自行决定。
在 vscode 中,一个视图容器称为 ViewContainer,在一个视图容器中可能有多个子视图 View,为了能够适配 vscode 插件,我们在这一层规范上需要与它保持一致,所以我们的单个视图面板还会映射到多个子视图。所以最终整体的视图映射关系如下图所示:
图 视图与子视图
NO.3
底层组件划分
经过 第一版 Layout 的实践,我们可以梳理出需要的核心组件包括:
BoxPanel 组件,普通 Flex 布局组件,支持不同方向的 Flex 布局
SplitPanel 组件,支持鼠标拖移 resize 的 BoxPanel
Accordion 组件,手风琴组件,支持 SplitPanel 的所有能力,同时支持子视图面板的折叠展开控制
TabBar 组件,多 tab 管理组件,支持视图的激活、折叠、展开、切换,支持tab拖拽
TabPanel组件,tab 渲染组件,侧边栏为 Panel Title + Accordion,底部栏为普通 React 视图
图 IDE 布局基础组件
下面介绍一下集成方需要关注的几个核心组件的实现方式。他们分别在不同的位置发挥作用。
LayoutComponent 组件
BoxPanel
BoxPanel 是个非常简单但非常基础的组件,本身只是一个 Flex 容器,对底下的 Children 按照一个方向进行布局:
export const BoxPanel: React.FC<{
children?: ChildComponent | ChildComponent[];
className?: string;
direction?: Layout.direction;
flex?: number;
}> = (({ className, children = [], direction = 'left-to-right', ...restProps }) => {
// convert children to list
const arrayChildren = React.Children.toArray(children);
return (
<div
{...restProps}
className={clsx(styles['box-panel'], className)}
style={{flexDirection: Layout.getFlexDirection(direction)}}>
{
arrayChildren.map((child, index) => (
<div
key={index}
className={clsx(styles.wrapper)}
style={child['props'] ? {
flex: child['props'].flex,
overflow: child['props']?.overflow,
zIndex: child['props']?.zIndex,
} : {}}>
{child}
</div>
))
}
</div>
);
});
SplitPanel
SplitPanel 本质上也是一个布局容器,只是在 BoxPanel 的基础上,需要支持用户通过鼠标拖动进行 Resize 的能力,同时 Resize 时需要满足相邻容器的最小、最大尺寸条件。下面的代码会渲染出一个按照从上到下,初始比例 2:1,最小 resize 尺寸分别为 200 和 160 的视图
<SplitPanel direction='top-to-bottom'>
<div flex={2} flexGrow={1} minResize={200} />
<div flex={1} minResize={160} />
</SplitPanel>
首先介绍一下 Resize 的实现,SplitPanel 在布局相邻元素时,会在其中间插入一个 ResizeHandle,这个 ResizeHandle 在初始化时会获取其相邻元素的 DOM 对象,在用户通过鼠标拖动时,我们会动态的去改变其相邻元素的尺寸,在尺寸的定义上,我们支持了百分比宽度和 flex 布局两种模式,在百分比宽度模式中,我们直接修改相邻元素的百分比尺寸,逻辑较清晰,可适应各种场合,缺点是浏览器 resize 时绝对尺寸会变化;在 flex 宽度模式中,我们通过 flexGrow 属性来指定需要支持弹性布局的容器,用户 resize 时只改变其相邻的绝对尺寸容器,性能较好,实现也比较简单,缺点是不支持两个弹性的容器相邻,适用于两层或三层结构、中间容器弹性的 resize 实现。
图 两种resize实现(by @吭头)
除了注入 ResizeHandle,SplitPanel 还要负责向其内部子视图分发 Resize 事件(resize event),透传尺寸控制器(resizeHandler)以支持子视图获取、控制自己所属容器尺寸,控制 Resize 的最大最小尺寸(min、max resizeLock),控制动态的相邻容器获取(相邻视图已不可 resize 需要 resize 下下一个容器)等,可以满足符合 IDE resize 交互的布局需求。
基于 BoxPanel 和 SplitPanel,我们已经可以 compose 出一个实际可用的 LayoutComponent ,下面就是 KAITIAN 提供的默认 Layout,这个 Layout 划分出了下图所示的一个 LayoutComponent(暂时忽略其中的 SlotRenderer)
export function DefaultLayout() {
return <BoxPanel direction='top-to-bottom'>
<SlotRenderer slot='top' />
<SplitPanel overflow='hidden' id='main-horizontal' flex={1}>
<SlotRenderer slot='left' defaultSize={310} minResize={204} minSize={49} />
<SplitPanel id='main-vertical' minResize={300} flexGrow={1} direction='top-to-bottom'>
<SlotRenderer flex={2} flexGrow={1} minResize={200} slot='main' />
<SlotRenderer flex={1} minResize={160} slot='bottom' />
</SplitPanel>
<SlotRenderer slot='right' defaultSize={310} minResize={200} minSize={41} />
</SplitPanel>
<SlotRenderer slot='statusBar' />
</BoxPanel>;
}
图 通过 BoxPanel+SplitPanel 拼装出来的LayoutComponent
那么距离一个完整可用的 Layout 我们还差 插槽渲染器 SlotRenderer 的实现。
SlotRenderer 组件
一个插槽内会接收一个或多个视图容器组件,SlotRenderer 决定这多个视图在该插槽位置是如何渲染的。默认情况下,我们会将插槽内的视图按照顺序从上到下依次渲染,默认的 SlotRenderer 代码如下所示:
// 默认的 SlotRenderer,多个视图依次渲染
function DefaultRenderer({ components }: RendererProps) {
return components && <ErrorBoundary>
{
components.map((componentInfo, index: number) => {
// 默认的只渲染一个
const Component = componentInfo.views[0].component!;
return <Component {...(componentInfo.options && componentInfo.options.initialProps)} key={`${Component.name}-${index}`} />;
})
}
</ErrorBoundary>;
}
在左右侧边栏、底部栏这几个插槽位置处,多视图的管理还需要支持切换,折叠展开,右键隐藏,拖放重新排序等特性。虽然视图表现有所不同,但内部的管理逻辑都是一致的,从行为上来看都属于我们熟悉的多 Tab 管理组件,所以我们封装了两个基础的组件来支持这一类 SlotRenderer 的拼装。
图 通用的多Tab管理组件
Tabbar
不同的 Tabbar 管理逻辑在 IDE 内是一致的,有区别的部分只在视图层面,不同的 Tab 视图可能会影响到折叠展开视图的尺寸计算,溢出 Tab 隐藏(超出视区时收起到更多菜单内)的尺寸计算。所以我们封装的 TabbarViewBase
提供了下列参数:
<TabbarViewBase
tabSize={44} // 单个Tab的尺寸,用于计算当前tab内容是否超出视区
MoreTabView={IconElipses} // tab内容超出视区后显示的菜单按钮,默认是一个...图标
TabView={IconTabView} // 单个Tab的渲染组件,框架提供了IconTab和TextTab两种实现
barSize={40} // Tabbar的尺寸,用于计算Tabbar收起时插槽的尺寸
panelBorderSize={1} // Tabbar与Tabpanel之间的边框
/>
在组件内部我们主要封装了以下逻辑:
tab 切换时的外层插槽容器的尺寸控制
tab 排序,拖放改变顺序
菜单控制,包括右键隐藏菜单和溢出更多菜单
tab 激活快捷键绑定
视图状态的持久化与恢复
TabPanel
TabPanel 处理的事情会更简单,只需要根据当前激活的Tab来将目标视图置顶就可以了,核心逻辑可以直接看代码:
const { currentContainerId } = tabbarService;
const panelVisible = { zIndex: 1, display: 'block' };
const panelInVisible = { zIndex: -1, display: 'none' };
return (
<div className={styles.tab_panel}>
{tabbarService.visibleContainers.map((component) => {
const containerId = component.options!.containerId;
const titleMenu = tabbarService.getTitleToolbarMenu(containerId);
return <div
key={containerId}
style={currentContainerId === containerId ? panelVisible : panelInVisible}>
<ErrorBoundary>
<PanelView titleMenu={titleMenu} side={side} component={component} />
</ErrorBoundary>
</div>;
})}
</div>
);
组件提供了 PanelView 参数来决定单个 Panel 的渲染行为。框架内部提供了带多子视图支持的 ContainerView 实现和单一视图支持的 PanelView 实现。
Accordion
手风琴组件是一个与 vscode 交互对齐的多子视图管理组件,提供多视图面板的渲染能力,同时要支持视图的折叠展开,鼠标拖动 resize,尺寸记录,菜单 Toolbar 等等特性,Accordion 组件使用起来非常简单,只需要给它传递需要渲染的多个视图信息,以及所属的视图容器ID信息就可以了:
<AccordionContainer
views={component.views}
containerId={component.options!.containerId}
alignment="vertical" // TODO
/>
手风琴组件的 Resize 能力基于 SplitPanel 来实现,由于视图数量不确定,我们这里采用了百分比模式的 Resize 支持。折叠展开能力基于原生 DOM 来实现,展开一个面板的逻辑如下:
没有其他展开项时,撑满可用空间
可用空间定义:容器高度 - visible section headers’ height
已有展开项时,展开新的项目
只有一个视图展开的情况不记录尺寸信息
若未记录上次的展开尺寸,则取视图的最小尺寸展开
若有记录,则取上次的视图尺寸展开
展开时将相邻的视图往下挪对应位置,若相邻视图已达最小值,则再挪其相邻位置
折叠面板的逻辑也是类似的,最后实现出来的效果与 vscode 的行为基本一致。
图 KAITIAN手风琴与vscode对比
除了处理折叠展开和 Resize 的逻辑,手风琴组件也跟 TabbarViewBase
一样需要处理多视图的管理逻辑,包括视图的右键隐藏,dispose,状态恢复等,逻辑比较类似。
通过 TabbarViewBase
和 TabPanelViewBase
,结合 AccordionContainer
的能力,我们已经可以实现一个带多视图管理能力的 SlotRenderer 了,下面是目前的左侧边栏的 SlotRenderer 实现:
export const LeftTabbarRenderer: React.FC = () => {
const { side } = React.useContext(TabbarConfig);
const layoutService = useInjectable<IMainLayoutService>(IMainLayoutService);
const tabbarService: TabbarService = useInjectable(TabbarServiceFactory)(side);
return (<div className={styles.left_tab_bar} onContextMenu={tabbarService.handleContextMenu}>
<TabbarViewBase tabSize={48} MoreTabView={IconElipses} TabView={IconTabView} barSize={48} margin={90} panelBorderSize={1} />
<InlineMenuBar className={styles.vertical_icons} menus={layoutService.getExtraMenu()} />
</div>);
};
const ContainerView: React.FC<{
component: ComponentRegistryInfo; side: string; titleMenu: IMenu;
}> = (({ component, titleMenu, side }) => {
return (
<div className={styles.view_container}>
<TitleBar title={title!} menubar={<InlineActionBar menus={titleMenu} />} />
<AccordionContainer views={component.views} containerId={component.options!.containerId} />
</div>
);
});
export const LeftTabPanelRenderer: React.FC = () => <BaseTabPanelView PanelView={ContainerView} />;
export const LeftTabRenderer = ({className, components}: {className: string, components: ComponentRegistryInfo[]}) => (
<TabRendererBase side='left' direction='left-to-right' className={clsx(className, 'left-slot')} components={components} TabbarView={LeftTabbarRenderer} TabpanelView={LeftTabPanelRenderer} />
);
@Domain(SlotRendererContribution)
export class MainLayoutModuleContribution implements SlotRendererContribution {
registerRenderer(registry: SlotRendererRegistry) {
registry.registerSlotRenderer('left', LeftTabRenderer);
}
}
至此,我们 Layout 的全部功能就已经实现完毕了。通过 IDE 视图实现层面的开放,我们可以实现任意程度的 IDE 视图定制;通过视图模板(LayoutComponent)、视图配置(LayoutConfig)、插槽渲染器(SlotRenderer)的三层抽象,我们可以轻松实现一个富交互的全功能 IDE 视图。
NO.4
总结
基于该 Layout 方案,目前我们已经满足了阿里经济体内的支付宝小程序 IDE、蚂蚁 CloudIDE 和 DEF IDE 的定制需求。相信未来开源到社区内会有更多的 IDE 视图的实践方案产出。另一方面,目前面向 IDE 集成方开放的布局定制能力就场景而言会相对有限,未来我们会基于 KAITIAN 的插件体系提供更加收敛、成本更低的布局定制方案,让 IDE 的业务方也能够轻松定制自己的 IDE 布局,实现自己的产品需求。
推荐阅读
文末福利
关注后回复“藏经阁”
喜欢就点在看哦〜