原創|阿裡巴巴淘系技術
一款IDE中,Tree 元件可能是所有視圖中出現機率最高的一種視圖形态,許多功能的基本互動形态也是落在 Tree 元件之中,其中不乏使用頻率較高的檔案樹、調試變量樹以及其他視圖中的各式各樣的樹元件,可以這麼說,Tree 元件的性能好壞會直接影響整個 IDE 的使用體驗,在共建項目中,先後經曆了兩次的 Tree 元件實作,本文将通過介紹最近的一次重構,為剖析目前 KAITIAN架構 中的一種高性能Tree元件的實作。
Tree元件長啥樣?
常見的Tree元件如下圖所示,主體結構一般是由普通的節點及可折疊的節點組成,除開主體,其他諸如一些選中狀态,描述資訊等,這些不同的裝飾(渲染)方式構成了Tree元件的多樣性。

以下介紹均以檔案樹為例
由此我們可以推導出,我們最終實作的Tree元件,應該為一個最基礎的Tree形态,上層通過不同的拼裝方式組合出真正使用的元件形态:
預期的資料結構
由上面的Tree結構,我們可以簡單的推導出我們的基礎資料結構如下(左側),而通過對基礎類型的資料進行拓展,我們便可以得到更多類型的Tree節點定義,以檔案樹為例:
這樣我們便構造出了我們需要的檔案樹元件中的節點定義,而通過這種組合,我們便可以通過上層複用底層Tree元件實作的能力,但也不會受限于底層Tree的實作,我們可以通過上層定義每個節點的實際渲染方式。
定義事件驅動模型
由上面的使用方式,我們自然得能夠想到我們需要定義好父子元件的通信方式,來保證父元件可以随時了解子元件狀态并介入某些節點操作。如圖所示:
基礎的事件發送/訂閱模型如下:
exportinterface ITreeWatcher {
// 監聽watcheEvent事件,如節點移動,建立,删除
onWatchEvent(path: string, callback: IWatcherCallback): IWatchTerminator;
// 監聽所有事件
on(event: TreeNodeEvent, callback: any);
// 觸發父節點即将變化事件
notifyWillChangeParent(target: ITreeNodeOrCompositeTreeNode, prevParent: ICompositeTreeNode, newParent: ICompositeTreeNode);
// 觸發父節點變化事件
notifyDidChangeParent(target: ITreeNodeOrCompositeTreeNode, prevParent: ICompositeTreeNode, newParent: ICompositeTreeNode);
// 觸發節點即将銷毀事件
notifyWillDispose(target: ITreeNodeOrCompositeTreeNode);
// 觸發節點銷毀事件
notifyDidDispose(target: ITreeNodeOrCompositeTreeNode);
// 觸發節點即将接收移動/建立/删除等事件(主要在檔案樹中使用)
notifyWillProcessWatchEvent(target: ICompositeTreeNode, event: IWatcherEvent);
// 觸發節點接收移動/建立/删除等事件(主要在檔案樹中使用)
notifyDidProcessWatchEvent(target: ICompositeTreeNode, event: IWatcherEvent);
// 觸發節點折疊狀态即将變化事件
notifyWillChangeExpansionState(target: ICompositeTreeNode, nowExpanded: boolean);
// 觸發節點折疊狀态即将變化事件
notifyDidChangeExpansionState(target: ICompositeTreeNode, nowExpanded: boolean);
// 觸發節點子節點即将變化事件
notifyWillResolveChildren(target: ICompositeTreeNode, nowExpanded: boolean);
// 觸發節點子節點變化事件
notifyDidResolveChildren(target: ICompositeTreeNode, nowExpanded: boolean);
// 觸發節點路徑變化事件
notifyDidChangePath(target: ITreeNodeOrCompositeTreeNode);
// 觸發節點補充資訊變化事件
notifyDidChangeMetadata(target: ITreeNodeOrCompositeTreeNode, change: IMetadataChange);
// 觸發節點分支變化事件
notifyDidUpdateBranch();
// 銷毀函數
dispose: IDisposable;
}
所有節點中都包含監聽模型引用,通過在各個子節點粒度中埋上事件觸發點,這樣在根節點中我們便能通過一個
on
函數監聽到整顆樹的變化,進而去實作諸如快照、操作復原等進階的樹元件應用。
定義節點渲染模型
一個基礎的Tree元件我們可以看成一個長清單,基礎的節點渲染樣式如下:
通過控制不同層級關系下的縮進樣式,便可以變成“更像”一個Tree元件
小知識點:頁面中隻要有使用了css3中的 transform: translateY(0)
屬性便可開啟chrome浏覽器中的硬體加速能力,能獲得更加良好的運作性能。
對于一個可能有上千個節點的長清單,為了性能上的考慮,我們往往隻會選擇性的選取部分節點渲染到頁面上,為了達到更好的滾動效果,我們需要在前後預渲染部分節點(留給浏覽器渲染一些反應時間),如下所示:
在滾動到邊界的時候更新目前清單的初始渲染下标,結合
React的Diff
更新算法,進而實作節點回收的效果。
如何擷取清單資料
每個可折疊節點都需要具備擷取子節點資料的能力,故需要為每個節點定義一個
TreeService
用于擷取資料等能力,如圖所示:
檔案樹通過在定義
File
及
Directory
的過程中便可定義其獨有的資料擷取方式。
整顆檔案樹渲染的主要資料來源為根節點下的
FlattenedBranch
二維數組中,該資料是将所有子節點資訊平鋪後的計算結果,展開根節點後基礎資料結構示例如下:
注:代表了節點在渲染時的實際位置,如下标 0 代表渲染 Tree 元件時第一個節點;
下标
則代表了從資料源中擷取節點資料實體的唯一辨別,可以了解為有了ID後便能擷取到節點所有渲染的必要資訊。
節點ID
下标代表着Tree元件在清單中的渲染位置,而節點ID着對應着一個具有TreeNode資料結構的節點
假定這裡的節點101為一個可折疊節點,展開後根節點資料也會随之更新
折疊節點101後,對應的子節點将會重新存儲與101節點的
FlattenedBranch
中,用于下次展開時使用(達到緩存的效果):
取出節點101對應子節點資料:
存儲資料到節點101
通過這樣便可在一個計算複雜度為N的資料結構上去操作我們清單的渲染資料。
狀态模型
講完渲染,下一步便是對Tree元件狀态的記錄工作,我們需要定義一個狀态模型,用于記錄目前Tree元件的狀态,核心資料狀态如下:
通過在根節點綁定一個狀态模型,我們既可以随時随地擷取目前Tree的展示狀态
裝飾器模型
到這一步,實際上我們的Tree元件已經具備了初步的能力,可以展示,也可以折疊,但我們依舊需要一種節點裝飾手段來提供給開發者美化(裝飾)節點的能力,裝飾器模型便是來為我們提供這種裝飾能力的重要手段。
最終形态
綜上,我們可以得到我們Tree元件的最終形态如下:
編碼結構
我們最終實作檔案樹需要組裝的大體結構如下:
1.檔案樹節點定義:
exportclass Directory extends CompositeTreeNode {
.constructor(
tree: ITree,
publicreadonly parent: CompositeTreeNode | undefined,
public uri: URI = new URI(''),
public name: string = '',
public filestat: FileStat = { children: [], isDirectory: false, uri: '', lastModification: 0 },
public tooltip: string,
) {
super(tree, parent);
if (!parent) {
// 根節點預設展開節點 this.setExpanded();
}
}
}
exportclass File extends TreeNode {
constructor(
tree: ITree,
public readonly parent: CompositeTreeNode | undefined,
public uri: URI = new URI(''),
public name: string = '',
public filestat: FileStat = { children: [], isDirectory: false, uri: '', lastModification: 0 },
public tooltip: string,
) {
super(tree, parent);
}
}
2.Tree(Service) 定義:
定義Tree元件的資料擷取來源
resolveChildren
,以及定義節點排序邏輯
sortComparator
exportclass FileTreeService extends Tree {
async resolveChildren(parent?: Directory){
... 擷取子節點邏輯
}
sortComparator(a: ITreeNodeOrCompositeTreeNode, b: ITreeNodeOrCompositeTreeNode) {
... 節點排序邏輯
}
}
3.TreeModel 定義:
主要用于定義Tree渲染的資料模型,這裡的資料與視圖渲染可以是分離的兩個過程,視圖可選擇時機從資料中擷取對應位置的展示内容,而資料操作則是脫離于視圖的,存粹的資料操作,這在做一些資料預裝/預處理等能力的時候會十分好用。
exportclass FileTreeModel extends TreeModel {
constructor(@Optional() root: Directory) {
super();
// 還可以在這裡做一些其他初始化工作
}
}
4.節點渲染模型定義:
每個視圖可以自定義自己的節點渲染模型,如下面的
FileTreeNode
,基于這種靈活的組裝形式,可讓Tree元件具備更靈活的拓展性。
const renderFileTree = () => {
if (isReady) {
return<RecycleTree
height={filterMode ? height - FILTER_AREA_HEIGHT : height}
width={width}
itemHeight={FILE_TREE_NODE_HEIGHT}
onReady={handleTreeReady}
model={fileTreeModelService.treeModel}
filter={filter}
>
{(props: INodeRendererProps) => <FileTreeNode
item={props.item}
itemType={props.itemType}
decorationService={decorationService}
labelService={labelService}
dndService={fileTreeModelService.dndService}
decorations={fileTreeModelService.decorations.getDecorations(props.item)}
onClick={handleItemClicked}
onTwistierClick={handleTwistierClick}
onContextMenu={handlerContextMenu}
/>}
</RecycleTree>;
}
};
至此便可定義一個完整的檔案樹元件渲染及資料結構
性能對比
- 新檔案樹 VS 舊檔案樹 性能對比
2.新檔案樹 VS VSCode檔案樹 性能對比
再經曆二次Tree元件重構後,整體檔案樹性能得到了較大程度的性能優化,甚至在目前場景下得到了比VSCode檔案樹更加優異的性能表現。
綜合對比VSCode中的實作,目前帶來明顯性能差異點在于:
- 對比VSCode為直接的Dom操作,React的DOM Diff算法在大檔案場景下有一定性能優勢
- 扁平化資料模型,去遞歸化
- 在父元件中獨立定義渲染模型,所有渲染均是按需配置設定,而不必在基礎Tree元件中進行備援判斷
性能之外
除了性能上的提升外,Tree元件的設計還為我們提供了更多可能性:
- Tree元件的設計更重要的是為元件使用者提供了構造一個類樹元件的參考,通過固定的組裝模式便可産出對應的SearchTree、GitTree、TodoTree 等,一切僅需三步,定義Model、定義節點資料、擷取資料後渲染節點即可,更提供了如重新整理、定位、折疊全部等快捷功能調用,用簡單的資料結構描述來讓開發者從定義Tree的“地獄”中解放出來。
- 資料與渲染模型分離的設計能讓我們在頁面未加載前便準備好渲染資料,一切的資料操作可靜默完成,在渲染時則立即可用于渲染,這在優化二、三屏加載速度場景中将會十分好用。
- 結合DI,我們還可以實作繼續資料模型的多例實作,進而達到通過一個DI Token,産生一顆樹的能力。
總結
目前 KAITIAN架構 中的Tree元件改造僅經曆了第一階段的檔案樹改造,相關代碼仍處于不斷優化改進過程中,本文僅為大家分享一種可行的實作思路,後續我們将會在性能及功能穩定後将架構内的所有類Tree視圖進行替換,并孵化出更易用的Tree元件用于其他場景下的建設。