初探富文字之基於虛擬滾動的大型文件效能最佳化方案

WindrunnerMax發表於2024-06-03

初探富文字之基於虛擬滾動的大型文件效能最佳化方案

虛擬滾動是一種最佳化長列表效能的技術,其透過按需渲染列表項來提高瀏覽器執行效率。具體來說,虛擬滾動只渲染使用者瀏覽器視口部分的文件資料,而不是整個文件結構,其核心實現根據可見區域高度和容器的滾動位置計算出需要渲染的列表項,同時不渲染額外的檢視內容。虛擬滾動的優勢在於可以大大減少DOM操作,從而降低渲染時間和記憶體佔用,解決頁面載入慢、卡頓等問題,改善使用者體驗。

描述

前段時間使用者向我們反饋了一個問題,其產品有比較多的大型文件在我們的文件編輯器上進行編輯,但是因為其文件內容過長且有大量表格,導致在整個編輯的過程中卡頓感比較明顯,而且在消費側展示的時候需要渲染比較長的時間,使用者體驗不是很好。於是我找了一篇比較大的文件測試了一下,由於這篇文件實在是過大,首屏的LCP達到了6896ms,即使在各類資源有快取的情況下FCP也需要4777ms,單獨拎出來首屏的編輯器渲染時間都有2505ms,整個應用的TTI更是達到了13343ms,在模擬極限快速輸入的情況下FPS僅僅能夠保持在5+DOM數量也達到了24k+,所以這個問題還是比較嚴重的,於是開始了漫長的調研與最佳化之路。

方案調研

在實際調研的過程中,我發現幾乎沒有關於線上文件編輯的效能最佳化方案文章,那麼對於我來說幾乎就是從零開始調研整個方案。當然社群還是有很多關於虛擬滾動的效能最佳化方案的,這對最終實現整個方案有很大的幫助。此外,我還在想把內容都放在一篇文件裡這個行為到底是否合適,這跟我們把程式碼都寫在一個檔案裡似乎沒什麼區別,總感覺組織形式上可能會有更好的方案,不過這就是另一個方向上的問題了,在這裡我們還是先關注於大型文件的效能問題。

  • 漸進式分頁載入方案: 透過資料驅動的方式,我們可以漸進式獲取分塊的資料,無論是逐頁請求還是SSE的方式都可以,然後逐步渲染到頁面上,這樣可以減少首屏渲染時間,緊接著在渲染的時候同樣也可以根據當前實際顯示的頁來進行渲染,這樣可以減少不必要的渲染從而提升效能。例如Notion就是完全由資料驅動的分頁載入方式,當然資料還是逐步載入的,並沒有實現按需載入資料,這裡需要注意的是按需載入和按需渲染是兩個概念。實際上這個方案非常看重文件本身的資料設計,如果是類似於JSON塊巢狀的表達結構,實現類似的方案會比較簡單一些,而如果是透過扁平的表達結構描述富文字,特別是又存在塊巢狀概念的情況下,這種方式就相對難以實現。
  • Canvas分頁渲染方案: 現在很多線上文件編輯器都是透過Canvas來進行渲染的,例如Google Docs、騰訊文件等,這樣可以減少DOM操作,Canvas的優勢在於可以自定義渲染邏輯,可以實現很多複雜的渲染效果與排版效果,但是缺點也很明顯,所有的東西都需要自行排版實現,這對於內容複雜的文件編輯器來說就變得沒有那麼靈活。實際上使用Canvas繪製文件很類似於Word的實現,初始化時按照頁數與固定高度構建純空白的佔位結構,在使用者滾動的時候才掛載分頁的Canvas渲染視口區域固定範圍的頁內容,從而實現按需渲染。
  • 行級虛擬滾動方案: 絕大部分基於DOM的線上文件編輯器都會存在行或者稱為段落的概念,例如飛書文件、石墨文件、語雀等,或者說由於DOM本身的結構表達,將內容分為段落是最自然的方式,這樣就可以實現行級虛擬滾動,即只渲染當前可見區域範圍的行,這樣可以減少不必要的渲染從來提升效能。通常我們都僅會在主文件的直屬子元素即行元素上進行虛擬滾動,而對於巢狀結構例如行記憶體在的程式碼塊中表達出的行內容則不會進行虛擬滾動,這樣可以減少虛擬滾動的複雜度,同時也可以保證渲染的效能。
  • 塊級虛擬滾動方案,從Notion開始帶動了文件編輯器Block化的趨勢,這種方式可以更好的組織文件內容,同時也可以更好的實現文件的塊結構複用與管理,那麼此時我們基於行的表達同樣也會是基於Block的表達,例如飛書文件同樣也是採用這種方式組織內容。在這種情況下,我們同樣可以基於行的概念實現塊級虛擬滾動,即只渲染當前可見區域範圍的塊,實際上如果獨立的塊比較大的時候還是有可能影響效能,所以這裡仍然存在最佳化空間,例如飛書文件就對程式碼塊做了特殊處理,即使在巢狀的情況下仍然存在虛擬滾動。那麼對於非Blocks表達的文件編輯器,塊級虛擬滾動方案仍然是不錯的選擇,此時我們將虛擬滾動的粒度提升到塊級,對於很多複雜的結構例如程式碼塊、表格、流程圖等塊結構做虛擬滾動,同樣可以有不錯的效能提升。

虛擬滾動

在具體實現之前我思考了一個比較有意思的事情,為什麼虛擬滾動能夠最佳化效能。我們在瀏覽器中進行DOM操作的時候,此時這個DOM是真正存在的嗎,或者說我們在PC上實現視窗管理的時候,這個視窗是真的存在的嗎。那麼答案實際上很明確,這些檢視、視窗、DOM等等都是透過圖形化模擬出來的,雖然我們可以透過系統或者瀏覽器提供的API來非常簡單地實現各種操作,但是實際上些內容是系統幫我們繪製出來的影像,本質上還是透過外部輸入裝置產生各種事件訊號,從而產生狀態與行為模擬,諸如碰撞檢測等等都是系統透過大量計算表現出的狀態而已。

那麼緊接著,在前段時間我想學習下Canvas的基本操作,於是我實現了一個非常基礎的圖形編輯器引擎。因為在瀏覽器的Canvas只提供了最基本的圖形操作,沒有那麼方便的DOM操作從而所有的互動事件都需要透過滑鼠與鍵盤事件自行模擬,這其中有一個非常重要的點是判斷兩個圖形是否相交,從而決定是否需要按需重新繪製這個圖形來提升效能。那麼我們設想一下,最簡單的判斷方式就是遍歷一遍所有圖形,從而判斷是否與即將要重新整理的圖形相交,那麼這其中就可能涉及比較複雜的計算,而如果我們能夠提前判斷某些圖形是不可能相交的話,就能夠省去很多不必要的計算。那麼在視口外的圖層就是類似的情況,如果我們能夠確定這個圖形是視口外的,我們就不需要判斷其相交性,而且本身其也不需要渲染,那麼虛擬滾動也是一樣,如果我們能夠減少DOM的數量就能夠減少很多計算,從而提升整個頁面的執行時效能,至於首屏效能就自不必多說,減少了DOM數量首屏的繪製一定會變快。

當然上邊只是我對於提升文件編輯時或者說執行時效能的思考,實際上關於虛擬滾動最佳化效能的點在社群上有很多討論了。諸如減少DOM數量可以減少瀏覽器需要渲染和維持的DOM元素數量,進而記憶體佔用也隨之減少,這使得瀏覽器可以更快地響應使用者操作。以及瀏覽器的reflow和重繪repaint操作通常是需要大量計算的,並且隨著DOM元素的增多而變得更加頻繁和複雜,透過虛擬滾動個減少需要管理的DOM數量,同樣可顯著提高渲染效能。此外虛擬滾動還有更快的首屏渲染時間,特別是大文件的全量渲染很容易導致首屏渲染時間過長,還能夠減少React維護元件狀態所帶來的Js效能消耗,特別是在存在Context的情況下,不特別關注就可能會存在效能劣化問題。

那麼在研究了虛擬滾動的優勢之後,我們就可以開始研究虛擬滾動的實現了,在進入到富文字編輯器的塊級虛擬滾動之前,我們可以先來研究一下虛擬滾動都是怎麼做的。那麼在這裡我們以ArcoDesignList元件為例來研究一下通用的虛擬滾動實現。在Arco給予的示例中我們可以看到其傳遞了height屬性,此時如果我們將這個屬性刪除的話虛擬列表是無法正常啟動的,那麼實際上Arco就是透過列表元素的數量與每個元素的高度,從而計算出了整個容器的高度,這裡要注意滾動容器實際上應該是虛擬列表的容器外的元素,而對於視口內的區域則可以透過transform: translateY(Npx)來做實際偏移,當我們滾動的時候,我們需要透過捲軸的實際滾動距離以及滾動容器的高度,配合我們配置的元素實際高度,就可以計算出來當前視口實際需要渲染的節點,而其他的節點並不實際渲染,從而實現虛擬滾動。當然實際上關於Arco虛擬列表的配置還有很多,在這裡就不完整展開了。

<List
  {/* ... */}
  virtualListProps={{
    height: 560,
  }}
  {/* ... */}
/>

透過簡單分析Arco的通用列表虛擬滾動,我們可以發現實現虛擬滾動似乎並沒有那麼難,然而在我們的線上文件場景中,實現虛擬滾動可能並不是簡單的事情。此處我們先來設一下在文件中圖片渲染的實現,通常在上傳圖片的時候,我們會記錄圖片的大小也就是寬高資訊,在實際渲染的時候會透過容器最大寬高以及object-fit: contain;來保證圖片比例,當渲染時即使圖片未實際載入完成,但是其高度佔位是已經固定的。然而回到我們的文件結構中,我們的塊高度是不固定的,特別是文字塊的高度,在不同的字型、瀏覽器寬度等情況下表現是不同的,我們無法在其渲染之前得到其高度,這就導致了我們無法像圖片一樣提前計算出其佔位高度,從而對於文件塊結構的虛擬滾動就必須要解決塊高度不固定的問題,由此我們需要實現動態高度的虛擬滾動排程策略來處理這個場景。而實際上如果僅僅是動態高度的虛擬滾動也並不是特別困難,社群已經有大量的實現方案,但是我們的文件編輯器是有很多複雜的模組在內的,例如選區模組、評論功能、錨點跳轉等等,要相容這些模組便是在文件本體虛擬滾動之外需要關注的功能實現。

模組設計

實際上富文字編輯器的具體實現有很多種方式,基於DOMCanvas繪製富文字的區別我們就不聊了,在這裡我們還是關注於基於DOM的富文字編輯器上,例如Quill是完全自行實現的檢視DOM繪製,而Slate是藉助於React實現的檢視層,這兩者對於檢視層的實現方式有很大的不同,在本文中是偏向於Slate的實現方式,也就是藉助於React來構建塊級別的虛擬滾動,當然實際上如果能夠完全控制檢視層的話,對於效能可最佳化的空間會更大,例如可以更方便地排程閒時渲染配合快取等策略,從而更好地最佳化快速滾動時的體驗。實際上無論是哪種方式,對於本文要講的核心內容差距並沒有那麼大,只要我們能夠保證富文字引擎本身控制的選區模組、高度計算模組、生命週期模組等正確排程,以及能夠控制實際渲染行為,無論是哪種編輯器引擎都是可以應用虛擬滾動方案的。

渲染模型

首先我們來構思一下整個文件的渲染模型,無論是基於塊模型的編輯器還是基於段落描述的編輯器都脫離不了行的概念,因為我們描述內容的時候通常都是由行來組成的一篇文件的,所以我們的文件渲染也都是以行為基準來描述的。當然這裡的行只是一個比較抽象的概念,這個行結構內巢狀的可能是個塊結構的表達例如程式碼塊、表格等等,而無論是如何巢狀塊,其最外層總會是需要包裹行結構的表達,即使是純Blocks的文件模型,我們也總能夠找到外層的塊容器DOM結構,所以我們在這裡需要明確定義行的概念。

實際上在此處我們所關注的行更傾向於主文件直屬的行描述,而如果在主文件的某個行中巢狀了程式碼塊結構,這個程式碼塊的整個塊結構是我們要關注的,而對於這個程式碼塊結構的內部我們先不做太多關注,當然這是可以進一步最佳化的方向,特別是對於超大程式碼塊的場景是有必要的,但是我們在這裡先不關注這部分結構最佳化。此外,對於Canvas繪製的文件或者是類似於分頁表達的文件同樣不在我們的關注範圍內,只要是能夠透過分頁表達的文章,我們直接透過頁的按需渲染即可,當然如果有需要的話同樣也可以進行段落級別的按需渲染,這同樣也可以算作是進一步的最佳化空間。

那麼我們可以很輕鬆地推斷出我們文件最終要渲染的結構,首先是佔位區域placeholder,這部分內容是不在視口的區域,所以會以佔位的方式存在;緊接著是buffer,這部分是提前渲染的內容,即雖然此區域不在視口區域,但是為了使用者在滾動時儘量避免出現短暫白屏的現象,由此提前載入部分檢視內容,通常這部分值可以取得視口高度的一半大小;接下來是viewport部分,這部分是真實在視口區域要渲染的內容;而在視口區域下我們同樣需要bufferplaceholder來作為預載入與佔位區域。

placeholder 
   |
 buffer
   | 
viewpoint 
   |
 buffer
   | 
placeholder

需要注意的是,在這裡的placeholder我們通常會選擇直接使用DOM進行佔位,可能大家會想著如果直接使用translate是更好的選擇,效率會高一些並且能觸發GPU加速,實際上對於普通的虛擬列表是沒什麼問題的,但是在文件結構中DOM結構會比較複雜,使用translate可能會出現一些預期之外的情況,特別是在複雜的樣式結構中,所以使用DOM進行佔位是比較簡單的方式。此外,因為選區模組的存在,在實現placeholder的時候還需要考慮使用者拖拽長選區的情況,也就是說如果使用者在進行選擇操作時將viewport的部分選擇並不斷滾動,然後直接將其拖拽到了placeholder區域,此時如果不特殊處理的話,這部分DOM會消失且會並作佔位DOM節點,此時選區則會出現問題無法對映到Model,所以我們需要在使用者選擇的時候保留這部分DOM節點,且在這裡使用DOM進行佔位會方便一些,使用translate適配起來相對就麻煩不少,因此此時的渲染模型如下所示。

  placeholder 
      |
selection.anchor 
      |
  placeholder 
      |
    buffer
      | 
   viewpoint 
      |
   buffer
      | 
  placeholder 
      |
selection.focus 
      |
  placeholder 

滾動排程

虛擬滾動的實現方式本質上就是在使用者滾動檢視時,根據視口的高度、滾動容器的滾動距離、行的高度等資訊計算出當前視口內需要渲染的行,然後在檢視層根據計算的狀態來決定是否要渲染。而在瀏覽器中關於虛擬滾動常用的兩個API就是Scroll EventIntersection Observer API,前者是透過監聽滾動事件來計算視口的位置,後者是透過觀察元素的可見性來判斷元素位置,基於這兩種API我們可以分別實現虛擬滾動的不同方案。

首先我們來看Scroll Event,這是最常見的滾動監聽方式,透過監聽滾動事件我們可以獲取到滾動容器的滾動距離,然後透過計算視口的高度與滾動距離來計算出當前視口內需要渲染的行,然後在檢視層根據計算的狀態來決定是否要渲染。實際上基於Scroll事件監聽來單純地實現虛擬滾動方案非常簡單,當然同樣的也更加容易出現效能問題,即使是標記為Passive Event可能仍然會存在卡頓問題。其核心思路是透過監聽滾動容器的滾動事件,當滾動事件觸發時,我們需要根據滾動的位置來計算當前視口內的節點,然後根據節點的高度來計算實際需要渲染的節點,從而實現虛擬滾動。

在前邊也提到了,針對於固定高度的虛擬滾動是比較容易實現的,然而我們的文件塊是動態高度的,在塊未實際渲染之前我們無法得到其真實高度。那麼動態高度的虛擬滾動與固定高度的虛擬滾動區別有什麼,首先是滾動容器的高度,我們在最開始不能夠知道滾動容器實際有多高,而是在不斷渲染的過程中才能知道實際高度;其次我們不能直接根據滾動的高度計算出當前需要渲染的節點,在固定高度時我們渲染的起始index遊標是直接根據滾動容器高度和列表所有節點總高度算出來的,而在動態高度的虛擬滾動中,我們無法獲得總高度,同樣的渲染節點的長度也是如此,我們無法得知本次渲染究竟需要渲染多少節點;再有我們不容易判斷節點距離滾動容器頂部的高度,也就是之前我們提到的translateY,我們需要使用這個高度來撐起滾動的區域,從而讓我們能夠實際做到滾動。

那麼我們說的這些數值都是無法計算的嘛,顯然不是這樣的,在我們沒有任何最佳化的情況下,這些資料都是可以強行遍歷計算的。那麼我們就來想辦法計算一下上述的內容,根據我們前邊聊的試想一下,對於文件來說無非就是基於塊的虛擬滾動罷了,那麼總高度我們可以直接透過所有的塊的高度相加即可,在這裡需要注意的是即使我們在未渲染的情況下無法得到其高度,但是我們卻是可以根據資料結構推算其大概高度,在實際渲染時糾正其高度即可。記得之前提到的我們是直接使用佔位塊的方式來撐起滾動區域,那麼此時我們就需要根據首尾遊標來計算具體佔位,具體的遊標值我們後邊再計算,現在我們先分別計算兩個佔位節點的高度值,並且將其渲染到佔位位置。

const startPlaceHolderHeight = useMemo(() => {
  return heightTable.slice(0, start).reduce((a, b) => a + b, 0);
}, [heightTable, start]);

const endPlaceHolderHeight = useMemo(() => {
  return heightTable.slice(end, heightTable.length).reduce((a, b) => a + b, 0);
}, [end, heightTable]);

return (
  <div
    style={{ height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
    onScroll={onScroll.run}
    ref={onUpdateInformation}
  >
    <div data-index={`0-${start}`} style={{ height: startPlaceHolderHeight }}></div>
    {/* ... */}
    <div data-index={`${end}-${list.length}`} style={{ height: endPlaceHolderHeight }}></div>
  </div>
);

那麼大概估算的總高度已經得到了,接下來處理首尾的遊標位置也就是實際要渲染塊的index,對於首部遊標我們直接根據滾動的高度來計算即可,遍歷到首個節點的高度大於滾動高度時,我們就可以認為此時的遊標就是我們需要渲染的首個節點,而對於尾部遊標我們需要根據首部遊標以及滾動容器的高度來計算,同樣也是遍歷到超出滾動容器高度的節點時,我們就可以認為此時的遊標就是我們需要渲染的尾部節點。當然,在這遊標的計算中別忘了我們的buffer資料,這是儘量避免滾動時出現空白區域的關鍵。此外,在這裡我們都是採用暴力的方式相加計算的,對於現代機器與瀏覽器來說,執行加法計算需要的效能消耗並不是很高,例如我們實現1萬次加法運算,實際上的時間消耗可能也只有不到1ms

const getStartIndex = (top: number) => {
  const topStart = top - buffer.current;
  let count = 0;
  let index = 0;
  while (count < topStart) {
    count = count + heightTable[index];
    index++;
  }
  return index;
};

const getEndIndex = (clientHeight: number, startIndex: number) => {
  const topEnd = clientHeight + buffer.current;
  let count = 0;
  let index = startIndex;
  while (count < topEnd) {
    count = count + heightTable[index];
    index++;
  }
  return index;
};

const onScroll = useThrottleFn(
  () => {
    if (!scroll) return void 0;
    const scrollTop = scroll.scrollTop;
    const clientHeight = scroll.clientHeight;
    const startIndex = getStartIndex(scrollTop);
    const endIndex = getEndIndex(clientHeight, startIndex);
    // ...
  },
);

在這裡我們聊的是虛擬滾動最基本的原理,所以在這裡的示例中基本沒有什麼最佳化,顯而易見的是我們對於高度的遍歷處理是比較低效的,即使進行萬次加法計算的消耗並不大,但是在大型應用中還是應該儘量避免做如此大量的計算,特別是Scroll Event實際上觸發頻率相當高的情況下。那麼顯而易見的一個最佳化方向是我們可以實現高度的快取,簡單來說就是對於已經計算過的高度我們可以快取下來,這樣在下次計算時就可以直接使用快取的高度,而不需要再次遍歷計算,而出現高度變化需要更新時,我們可以從當前節點到最新的快取節點之間,重新計算快取高度。而且這種方式相當於是遞增的有序陣列,還可以透過二分等方式解決查詢的問題,這樣就可以儘可能地避免大量的遍歷計算。

height: 10 20 30 40  50  60  ...
cache:  10 30 60 100 150 210 ...

IntersectionObserver現如今已經被標記為Baseline Widely Available,在March 2019之後釋出的瀏覽器都已經實現了該API現已並且非常成熟。接下來我們來看下Intersection Observer API的虛擬滾動實現方式,不過在具體實現之前我們先來看看IntersectionObserver具體的應用場景。根據名字我們可以看到IntersectionObserver兩個單詞,由此我們可以大概推斷這個API的主要目標是觀測目標的交叉狀態,而實際上IntersectionObserver就是用以非同步地觀察目標元素與其祖先元素或頂級文件視口的交叉狀態,這對判斷元素是否出現在視口範圍非常有用。

那麼在這裡我們需要關注一個問題,IntersectionObserver物件的應用場景是觀察目標元素與視口的交叉狀態,而我們的虛擬滾動核心概念是不渲染非視口區域的元素。所以這裡邊實際上出現了一個偏差,在虛擬滾動中目標元素都不存在或者說並未渲染,那麼此時是無法觀察其狀態的。所以為了配合IntersectionObserver的概念,我們需要渲染實際的佔位節點,例如10k個列表的節點,我們首先就需要渲染10k個佔位節點,實際上這也是一件合理的事,除非我們最開始就注意到文件的效能問題,而實際上大部分都是後期最佳化文件效能,特別是在複雜的場景下。假設原本有1w條資料,每條資料即使僅渲染3個節點,那麼此時我們如果僅渲染佔位節點的情況下還能將原本頁面30k個節點最佳化到大概10k個節點。這對於效能提升本身也是非常有意義的,且如果有需要的話還能繼續進行完整的效能最佳化。

當然如果不使用佔位節點的話實際上也是可以藉助Intersection Observer來實現虛擬滾動的,只不過這種情況下需要藉助Scroll Event來輔助實現強制重新整理的一些操作,整體實現起來還是比較麻煩的。所以接下來我們還是來實現一下基於IntersectionObserver的佔位節點虛擬滾動方案,首先需要建立IntersectionObserver,同樣的因為我們的滾動容器可能並不一定是window,所以我們需要在滾動容器上建立IntersectionObserver,此外根據前邊聊的我們會對視口區域做一層buffer,用來提前載入視口外的元素,這樣可以避免使用者滾動時出現空白區域,這個buffer的大小通常選擇當前視口高度的一半。

useLayoutEffect(() => {
  if (!scroll) return void 0;
  // 視口閾值 取滾動容器高度的一半
  const margin = scroll.clientHeight / 2;
  const current = new IntersectionObserver(onIntersect, {
    root: scroll,
    rootMargin: `${margin}px 0px`,
  });
  setObserver(current);
  return () => {
    current.disconnect();
  };
}, [onIntersect, scroll]);

接下來我們需要對佔位節點的狀態進行管理,因為我們此時有實際佔位,所以就不再需要預估整個容器的高度,而且只需要實際滾動到相關位置將節點渲染即可。我們為節點設定三個狀態,loading狀態即佔位狀態,此時節點只渲染空的佔位節點也可以渲染一個loading標識,此時我們還不知道這個節點的實際高度;viewport狀態即為節點真實渲染狀態,也就是說節點在邏輯視口內,此時我們可以記錄節點的真實高度;placeholder狀態為渲染後的佔位狀態,相當於節點從在視口內滾動到了視口外,此時節點的高度已經被記錄,我們可以將節點的高度設定為真實高度。

loading -> viewport <-> placeholder
type NodeState = {
  mode: "loading" | "placeholder" | "viewport";
  height: number;
};

public changeStatus = (mode: NodeState["mode"], height: number): void => {
  this.setState({ mode, height: height || this.state.height });
};

render() {
  return (
    <div ref={this.ref} data-state={this.state.mode}>
      {this.state.mode === "loading" && (
        <div style={{ height: this.state.height }}>loading...</div>
      )}
      {this.state.mode === "placeholder" && <div style={{ height: this.state.height }}></div>}
      {this.state.mode === "viewport" && this.props.content}
    </div>
  );
}

當然我們的Observer的觀察同樣需要配置,這裡需要注意的是IntersectionObserver的回撥函式只會攜帶target節點資訊,我們需要透過節點資訊找到我們實際的Node來管理節點狀態,所以此處我們藉助WeakMap來建立元素到節點的關係,從而方便我們處理。

export const ELEMENT_TO_NODE = new WeakMap<Element, Node>();

componentDidMount(): void {
  const el = this.ref.current;
  if (!el) return void 0;
  ELEMENT_TO_NODE.set(el, this);
  this.observer.observe(el);
}

componentWillUnmount(): void {
  const el = this.ref.current;
  if (!el) return void 0;
  this.observer.unobserve(el);
}

最後就是實際滾動排程了,當節點出現在視口時我們需要根據ELEMENT_TO_NODE獲取節點資訊,然後根據當前視口資訊來設定狀態,如果當前節點是進入視口的狀態我們就將節點狀態設定為viewport,如果此時是出視口的狀態則需要二次判斷當前狀態,如果不是初始的loading狀態則可以直接將高度與placeholder設定到節點狀態上,此時節點的高度就是實際高度。

const onIntersect = useMemoizedFn((entries: IntersectionObserverEntry[]) => {
  entries.forEach(entry => {
    const node = ELEMENT_TO_NODE.get(entry.target);
    if (!node) {
      console.warn("Node Not Found", entry.target);
      return void 0;
    }
    const rect = entry.boundingClientRect;
    if (entry.isIntersecting || entry.intersectionRatio > 0) {
      // 進入視口
      node.changeStatus("viewport", rect.height);
    } else {
      // 脫離視口
      if (node.state.mode !== "loading") {
        node.changeStatus("placeholder", rect.height);
      }
    }
  });
});

實際上在本文中繼續聊到的效能最佳化方式都是基於Intersection Observer API實現的的,在文件中每個塊可能會存在上百個節點,特別是在表格這種複雜的表達中,而且主文件下直屬的塊或者說行數量通常不會很多,所以這對於節點數量的最佳化是非常可觀的。在之前我在知乎上看到了一個問題,為什麼Python內建的Sort比自己寫的快速排序快100倍,以至於我每次看到Intersection Observer API都會想到這個問題,實際上這其中有個很大的原因是Python標準庫是用C/C++實現的,其執行效率本身就比Python這種解釋型指令碼語言要高得多,而Intersection Observer API也是同樣的問題,其是瀏覽器底層用C/C++實現的,執行效率比我們使用JS排程滾動要高不少,不過也許在JIT編譯的加持下可能差距沒那麼大。

狀態管理

在我們的文件編輯器中,虛擬滾動不僅僅是簡單的滾動渲染,還需要考慮到各種狀態的管理。通常我們的編輯器中是已經存在塊管理器的,也就是基於各種changes來管理整個Block Tree的狀態,實際上也就是對於樹結構的增刪改查,例如當觸發的opinsert { parentId: xxx, id: yyy }時我們就需要在xxx這個節點下加入新的yyy節點。實際上在這裡的的樹結構管理還是比較看具體業務實現的,如果編輯器為了undo/redo的方便而不實際在樹中刪除某個塊,僅僅是標記為已/未刪除的狀態,那麼這個塊管理器的狀態管理就變成了只增不刪,所以在這裡基於Block的管理器還是需要看具體編輯器引擎的實現。

那麼在這裡我們需要關注的是在這個Block Engine上的擴充,我們需要為其增加虛擬滾動的狀態,也就是為其擴充出新的狀態。當然如果僅僅是加新的狀態的話可能就只是個簡單的問題,在我們還需要關注塊結構巢狀的問題,為我們後邊的場景推演作下準備。在前邊提到過,我們當前關注的是主文件直屬的塊管理,那麼對於巢狀的結構來說,當直屬塊處於佔位狀態時,我們需要將其內部所有巢狀的塊都設定為佔位狀態。這本身會是個遞迴的檢查過程,且本身可能會存在大量呼叫,所以我們需要為其做一層快取來減少重複計算。

在這裡我們的思路是在每個節點都設定快取,這個快取儲存了所有的子樹節點的引用,是比較典型的空間換時間,當然因為儲存的是引用所以空間消耗也不大。這樣帶來的優勢是,例如使用者一直在修改某個塊子節點的結構,在每個節點進行快取僅會重新計算該節點的內容,而其他子節點則會直接取快取內容,不需要重新計算。在這裡需要注意的是,當對當前節點進行append或者remove子節點時,需要將該節點以及該節點所有父層節點鏈路上的所有快取清理掉,在下次呼叫時按需重新計算。實際上因為我們整個編輯器都是基於changes來排程的,所以做到細粒度的結構管理並不是非常困難的事。

public getFlatNode() {
  if (this.flatNodes) return this.flatNodes;
  const nodes: Node[] = [];
  this.children.forEach(node => {
    nodes.push(node);
    nodes.push(...node.getFlatNode());
  });
  this.flatNodes = nodes;
  return nodes;
}

public clearFlatNode() {
  this.flatNodes = null;
}

public clearFlatNodeOnLink() {
  this.clearFlatNode();
  let node: Node | null = this.parent;
  while (node) {
    node.clearFlatNode();
    node = node.parent;
  }
}

那麼我們現在已經有了完整的塊管理器,接下來我們需要思考如何排程控制渲染這個行為,如果我們的編輯器引擎是自研的檢視層,那麼可控性肯定是非常高的,無論是控制渲染行為還是實現渲染快取都不是什麼困難的事情,但是前邊我們也提到了在本身是更傾向於用React作為檢視層來實現排程,所以在這裡我們需要更通用的管理方案。實際上用React作為檢視層的優勢是可以藉助生態實現比較豐富的自定義檢視渲染,但是問題就是比較難以控制,在這裡不光指的是渲染的排程行為,還有Model <-> View對映與ContentEditable原地複用帶來的一些問題,不過這些不是本文要聊的重點,我們先來聊下比較通用的渲染控制方式。

首先我們來設想一下在React中應該如何控制DOM節點的渲染,很明顯我們可以透過State來管理渲染狀態,或者是透過ReactDOM.render/unmountComponentAtNode來控制渲染渲染,至於透過Ref來直接操作DOM這種方式會比較難以控制,可能並不是比較好的管理方式。我們先來看一下ReactDOM.render/unmountComponentAtNode,這個APIReact18被標記為deprecated了,後邊還有可能會變化,但是這不是主要問題,最主要的是使用render會導致無法直接共享Context,也就是其會脫離原本的React Tree,必須要重新將Context併入才可以,這樣的改造成本顯然是不合適的。

因此最終我們還是透過State來控制渲染狀態,那麼此時我們還需要文件全域性的管理器來控制所有塊節點的狀態,那麼在React中很明顯我們可以透過Context來完成這件事,透過全域性的狀態變化來影響各個ReactNode的狀態。但是這樣實際上將控制權交給了各個子節點來管理自身的狀態,我們可能是希望擁有一個全域性的控制器來管理所有的塊。那麼為了實現這一點,我們就實現LayoutModule模組來管理所有節點,而對於節點本身,我們需要為其包裹一層HOC,且為了方便我們選擇類元件來完成這件事,由此我們便可以透過LayoutModule模組來管理所有塊結構例項的狀態。

class LayoutModule{
  private instances: Map<string, HOC> = new Map();
  // ...
}

class HOC extends React.PureComponent<Props> {
  public readonly id: string;
  public readonly layout: LayoutModule;
  // ...
  constructor(props: Props) {
    // ...
    this.layout.add(this);
  }
  componentWillUnmount(): void {
    this.layout.remove(this);
    // ...
  }
  // ...
}

使用類元件的話,整個元件例項化之後就是物件,可以比較方便地寫函式呼叫以及狀態控制,當然這些實現透過函式元件也是可以做到的,只是用類元件會更方便些。那麼接下來我們就可以透過類方法控制其狀態,此外我們還需要透過ref來獲得當前元件需要觀察的節點。如果使用ReactDOM.findDOMNode(this)是可以在類元件中獲得DOM的引用的,但是同樣也被標記為deprecated了,所以還是不建議使用,所以在這裡我們還是透過包裹一層DOM並且觀察這層DOM來實現虛擬滾動。此外,要注意到實際上我們的DOM渲染是由React控制的,對於我們的應用來說是不可控的,所以我們還需要記錄prevRef來觀測到DOM引用發生變化時,將IntersectionObserver的觀察物件進行更新。

type NodeState = {
  mode: "loading" | "placeholder" | "viewport";
  height: number;
};

class HOC extends React.PureComponent<Props> {
  public prevRef: HTMLDivElement | null;
  public ref: React.RefObject<HTMLDivElement>;
  // ...
  componentDidUpdate(prevProps: Props, prevState: State): void {
    if (this.prevProps !== this.ref.current) {
      this.layout.updateObserveDOM(this.prevProps, this.ref.current);
      this.prevProps = this.ref.current;
    }
  }
  public changeStatus = (mode: NodeState["mode"], height: number): void => {
    this.setState({ mode, height: height || this.state.height });
  };
  // ...
  render() {
    return (
      <div ref={this.ref} data-state={this.state.mode}>
        {/* ... */}
      </div>
    );
  }
}

選區狀態

在選區模組中,我們需要保證檢視的狀態能夠正確對映到Model上,由於在虛擬滾動的過程中DOM可能並不會真正渲染到頁面上,而瀏覽器的選區表達則是需要anchorNode節點與focusNode節點共同確定的,所以我們就需要保證在使用者選中的過程中這兩個節點是正常表現在DOM樹結構中。實現這部分能力實際上並不複雜,只要我們理解瀏覽器的選區模型,並且由此保證anchorNode節點與focusNode節點是正常渲染的即可,透過保證節點正確渲染則我們就不需要在虛擬滾動的場景下去重新設計選區模型,據此我們來需要推演一些場景。

  • 視口內選擇: 當使用者在視口內選擇相關塊的時候,我們可以認為這部分選區在有無虛擬滾動的情況下都是正常處理的,不需要額外推演場景,保持原本的View Model對映邏輯即可。
  • 選區滾動到視口外: 當使用者選擇內容時正常在視口中選擇,此時選區是正常選擇,但是後來使用者將視口區域進行滾動,導致選區部分滾動到了視口外,此時我們需要保留選區狀態,否則當使用者滾動回來時會導致選區丟失。那麼在這種情況下我們就需要保證選區的anchorNode節點與focusNode節點正確渲染,如果粒度粗則保證其所在的塊是正常渲染即可。
  • 拖拽選擇長選區: 當使用者進行MouseDownanchorNode在視口內,此時使用者透過拖拽操作導致頁面滾動,從而將anchorNode拖拽到視口外部。同樣的,此時我們需要保證anchorNode所在的塊/節點即使不在視口區域也需要正常渲染,否則會導致選區丟失。
  • 觸發選區更新: 當因為某些操作導致選區中的內容更新時,例如透過編輯器的API操作了文件內容,此時將出現兩種情況,如果更新的內容不是anchorNode節點或者focusNode節點,那麼對於整體選區不會造成影響,否則我們需要在渲染完成後透過Model重新校正選區節點。
  • 全選操作: 對於全選操作我們可以認為是特殊的選區行為,我們需要保證文件的首尾的行/塊節點完整渲染,所以在這裡的流程是需要透過Model獲得首尾節點的狀態,然後強制將這兩部分渲染出來,由此保證anchorNode節點與focusNode節點正確渲染出來,接下來再走正常的選區對映邏輯即可。

實際上,還記得我們的Intersection Observer API通常是需要佔位節點來實現虛擬滾動的,那麼既然佔位節點本身都在這裡,如果我們並不特別注意DOM節點的數量的話,是可以在佔位的時候將Block的選區標識節點一併渲染出來的,這樣可以解決一些問題,例如全選的操作就可以不需要特殊處理。如果我們將範圍放的再寬泛一些的話,將文字塊以及Void/Embed結構\u200B節點在佔位的時候也一併渲染出來,只對於複雜塊進行渲染排程,這種情況下我們甚至可以不需要關心選區的問題,此時需要標記的選區對映節點都已經渲染出來了,我們只需要關注複雜塊虛擬滾動的排程即可。

視口鎖定

視口鎖定是比較重要的模組,對於虛擬滾動來說,如果我們每次開啟的時候都是從最列表內容的開始瀏覽,那麼通常是不需要進行視口鎖定的。但是對於我們的文件系統來說這個問題就不一樣了,讓我們來設想一個場景,當使用者A分享了一個帶錨點的連結給使用者B,使用者B此時開啟了超連結直接定位到了文件中的某個標題甚至是某個塊內容區域,此時如果使用者B進行向上滾動的操作就會出現問題。記得之前我們說的在我們實際渲染內容之前是無法得到塊的實際高度的,那麼當使用者向上滾動的時候,由於此時我們的佔位節點的高度和塊的實際高度存在差值,此時使用者向上滾動的時候就會存在視覺上跳躍的情況,而我們的視口鎖定便是為了解決這個問題,顧名思義是將使用者的視口鎖定在當前滾動的位置。

在研究具體的虛擬滾動之前,我們先來了解一下overflow-anchor這個屬性,實際上實現編輯器引擎的的困難之處有很大一部分就是在於各種瀏覽器的相容,透過這個屬性也可以看出來,即使是同為基於Webkit核心的ChromeSafari瀏覽器,Chrome就支援overflow-anchorSafari就不支援。回到overflow-anchor屬性,這個屬性就是為了解決上邊提到的調整滾動位置以最大程度地減少內容移動,也就是我們上邊說的視覺上跳躍的情況,這個屬性在支援的瀏覽器中會預設啟用。由於Safari瀏覽器不支援,並且在後邊也會提到我們實際上是需要這個跳躍的差值的,所以在這裡我們需要關閉預設的overflow-anchor行為,主動控制視口鎖定的能力。當然由於實際上在鎖定視口的時候不可避免地會出現獲取DOMRect資料,則人工干預視口鎖定會觸發更多的reflow/repaint行為。

class LayoutModule{
  private scroll: HTMLElement | Window;
  // ...
  public initLayoutModule() {
    // ...
    const dom = this.scroll instanceof Window ? document.body : this.scroll;
    dom.style.overflowAnchor = "none";
  }
}

除了overflow-anchor之外,我們還需要關注History.scrollRestoration這個屬性。我們可能會發現,當瀏覽到頁面的某個位置的時候,此時我們點選了超連結跳轉到了另一個頁面,然後我們回退的時候返回了原本的頁面地址,此時瀏覽器是能夠記住我們之前瀏覽的捲軸位置的。那麼在這裡由於我們的虛擬滾動存在,我們不希望由瀏覽器控制這個跳轉行為,因為其大機率是不準確的位置,現在滾動行為需要主動管理,所以我們需要關閉瀏覽器的這個行為。

class LayoutModule{
  // ...
  public initLayoutModule() {
    // ...
    if (history.scrollRestoration) {
      history.scrollRestoration = "manual";
    }
  }
}

那麼我們還需要思考一下還有什麼場景會影響到我們的視口鎖定行為,很明顯Resize的時候由於會導致容器寬度的變化,因此文字塊的高度也會跟隨發生變化,因此我們的視口鎖定還需要在此處進行調整。在這裡我們的調整策略也比較簡單,設想一下我們需要進行視口鎖定的狀態無非就是loading -> viewport時才需要調整,因為其他的狀態變化時其高度都是穩定的,因為我們的placeholder狀態是取得真實高度的。但是在Resize的場景不同,即使是placeholder也會存在需要重新進行視口鎖定,因為此時並不是要渲染的實際高度,因此我們的邏輯就是在Resize時將所有的placeholder
狀態的節點都重新進行視口鎖定標記。

class HOC extends React.PureComponent<Props> {
  public isNeedLockViewport = true;
  // ...
}

class LayoutModule {
  // ...
  private onResize = (event: EditorResizeEvent) => {
    const { prev, next } = event;
    if (prev.width === next.width) return void 0;
    for (const instance of Object.values(this.instances)) {
      if (instance.state.mode === "placeholder") {
        instance.isNeedLockViewport = true;
      }
    }
  };
}

接下來就是我們實際的視口鎖定方法了,實際的思路還是比較簡單的,當我們的元件發生渲染變更時,我們需要透過元件的狀態來獲取高度資訊,然後根據這個高度資料來取的變化的差值,透過這個差值來調整捲軸的位置。在這裡我們還需要取的滾動容器的資訊,當觀察的節點top值在滾動容器之上時,高度的變化就需要進行視口鎖定。在調整捲軸的位置時,我們不能使用smooth動畫而是需要明確的設定其值,以防止我們的視口鎖定失效,並且避免多次呼叫時取值出現問題。此外這裡需要注意的是,由於我們是實際取得了高度進行的計算,而使用margin可能會導致一系列的計算問題例如margin合併的問題,所以在這裡我們的原則是在表達塊時能用padding就用padding,儘量避免使用margin在塊結構上來做間距調整。

class LayoutModule {
  public offsetTop: number = 0;
  public bufferHeight: number = 0;
  private scroll: HTMLElement | Window;
  // ...
  public updateLayoutInfo() {
    // ...
    const rect = this.scroll instanceof Element && this.scroll.getBoundingClientRect();
    this.offsetTop = rect ? rect.top : 0;
    const viewportHeight = rect ? rect.height : window.innerHeight;
    this.bufferHeight = Math.max(viewportHeight / 2, 300);
  }
  // ...
  public scrollDeltaY(deltaY: number) {
    const scroll = this.scroll;
    if (scroll instanceof Window){
      scroll.scrollTo({ top: scroll.scrollY + deltaY });
    } else {
      const top = scroll.scrollTop + deltaY;
      scroll.scrollTop = top;
    }
  }
  // ...
}

class HOC extends React.PureComponent<Props> {
  public isNeedLockViewport = true;
  public ref: React.RefObject<HTMLDivElement>;
  // ...
  componentDidUpdate(prevProps: Props, prevState: State): void {
    // ...
    if (this.isNeedLockViewport && this.state.mode === "viewport" && this.ref.current) {
      this.isNeedLockViewport = false;
      const rect = this.ref.current.getBoundingClientRect();
      if (rect.height !== prevState.height && rect.top <= this.layout.offsetTop) {
        const deltaY = rect.height - prevState.height;
        this.layout.scrollDeltaY(deltaY);
      }
    }
  }
  // ...
}

快速滾動

當使用者進行快速滾動時,由於虛擬滾動的存在,則可能會出現短暫白屏的現象,為了儘可能避免這個問題,我們仍然需要一定的排程策略。我們之前在檢視層上設定的buffer就能一定程度上解決這個問題,但是在快速滾動的場景下還是不太夠。當然,實際上白屏的時間通常不會太長,而且在擁有佔位節點的情況下互動體驗上通常也是可以接受的,所以在這裡的最佳化策略還是需要看具體的使用者需求與反饋的,畢竟我們的虛擬滾動目標之一就是減少記憶體佔用,進行快速滾動時通常時需要排程滾動方向上的更多塊提前渲染,那麼這樣必定會導致記憶體佔用的增加,因此我們還是需要在滾動白屏和記憶體佔用中取得平衡。

先來想想我們的快速滾動策略,當使用者進行一次比較大範圍的滾動之後,很有可能會繼續向滾動方向進行滾動,因此我們可以定製滾動策略,當突發地出現大量塊渲染或者在一定時間切片內滾動距離大於N倍視口高度時,我們可以根據塊渲染的順序判斷滾動順序,然後在這個順序的基礎上進行提前渲染。提前渲染的範圍與渲染排程的時間間隔同樣需要進行排程,例如在兩次排程快速渲染的不能超過100ms,快速渲染持續的時間可以設定為500ms,最大渲染範圍定義為2000px或者取N倍視口長度等等,這個可以視業務需求而定。

此外,我們還可以透過閒時渲染策略排程來儘可能避免快速滾動的白屏現象,當使用者停止滾動時,我們可以藉助requestIdleCallback來進行閒時渲染,以及透過人工控制時間間隔來進行排程,也可以與快速滾動的排程策略類似,設定渲染時間間隔與渲染距離等等。如果檢視層能夠支援節點快取的話,我們甚至可以將檢視層優先快取起來,而實際上並不將其渲染到DOM結構上,當使用者滾動到相關位置時直接將其從記憶體中取出置於節點位置即可,此外即使檢視層的快取不支援,我們也可以嘗試對節點的狀態進行提前計算並快取,以渲染時計算的卡頓現象。不過同樣的這種方式會導致記憶體佔用的增加,所以還是需要取得效率與佔用空間的平衡。

placeholder 
    |
  buffer
    |
  block 1
    |
  block 2
    |
  buffer
    |
pre-render ... 
    |
placeholder

增量渲染

在前邊我們大部分都是討論塊的渲染問題,除了選區模組可能會比較涉及編輯時的狀態之外,其他的內容都更傾向於對於渲染狀態的控制,那麼在編輯的時候我們肯定是要有新的塊插入的,那麼這部分內容實際上也需要有管理機制,否則可能會造成一些預期外的問題。設想一個場景,當使用者透過工具欄或者快捷輸入的方式插入了程式碼塊,如果在不接入虛擬滾動的情況下,此時的游標應該是直接置入程式碼塊內部的,但是由於我們的虛擬滾動存在,首幀會置為佔位符的DOM,之後才會正常載入塊結構,那麼此時由於ContentEditable塊結構不存在,游標自然不能正確放置進去,這時通常會觸發選區兜底策略,則此時就出現了預期外的問題。

因此我們在插入節點的時候需要對其進行控制,對於這個這個問題的解決方案非常簡單,試想一下什麼時候會有插入操作呢,必然是整個編輯器都載入完成之後了,那麼插入的時候應該是什麼位置呢,大機率也是在視口區域進行編輯的,所以我們的方案就是在編輯器初次渲染完成之後,將Layout模組標記為載入完成,此時再置入的HOC初始狀態都認為是viewport即可。此外,很多時候我們還可能需要對HOC的順序作index標記,在某處插入的標記我們通常就需要藉助DOM來確定其index了。

class LayoutModule {
  public isEditorLoaded: boolean = false;
  // ...
  public initLayoutModule() {
    // ...
    this.editor.once("paint", () => {
      this.isEditorLoaded = true;
    });
  }
}

class HOC extends React.PureComponent<Props> {
  public index: number = 0;
  // ...
  constructor(props: Props) {
    // ...
    this.state = {
      mode: "loading"
      // ...
    }
    if (this.layout.isEditorLoaded) {
      this.state.mode = "viewport";
    }
  }
  // ...
}

實際上我們這裡的模組都是編輯器引擎需要提供的能力,那麼很多情況下我們都需要與外部主應用提供互動,例如評論、錨點、查詢替換等等,都需要獲取編輯器塊的狀態。舉個例子,我們的劃詞評論能力是比較常見的文件應用場景,在右側的評論皮膚通常需要取得我們劃詞文字的高度資訊用以展示位置,而因為虛擬滾動的存在這個DOM節點可能並不存在,所以評論的實際模組也會變成虛擬化的,也就是說隨著滾動漸進載入,因此我們需要有與外部應用互動的能力。實際上這部分能力還是比較簡單的,我們只需要實現一個事件機制,當編輯器塊狀態發生改變的時候通知主應用。此外除了塊狀態的管理之外,視口鎖定的高度值變化也是非常重要的,否則在評論皮膚中的定位會出現跳動問題。

class Event {
  public notifyAttachBlock = (changes: Nodes) => {
    if (!this.layout.isEditorLoaded) return void 0;
    const nodes = changes.filter(node => node.isActive());
    Promise.resolve().then(() => {
      this.emit("attach-block", nodes);
    });
  }

  public notifyDetachBlock = (changes: Nodes) => {
    if (!this.layout.isEditorLoaded) return void 0;
    const nodes = changes.filter(node => !node.isActive());
    Promise.resolve().then(() => {
      this.emit("detach-block", nodes);
    });
  }

  public notifyViewLock = (instance: HOC) => {
    this.emit("view-lock", instance);
  }
}

class HOC extends React.PureComponent<Props> {
  // ...
  componentDidUpdate(prevProps: Props, prevState: State): void {
    // ...
    if (prevState.mode !== "viewport" && this.state.mode === "viewport") {
      const changes = this.layout.blockManager.setBlockState(true);
      this.layout.event.notifyAttachBlock(changes);
    }
    if (prevState.mode !== "placeholder" && this.state.mode === "placeholder") {
      const changes = this.layout.blockManager.setBlockState(false);
      this.layout.event.notifyDetachBlock(changes);
    }
    if (this.isNeedLockViewport && this.state.mode === "viewport" && this.ref.current) {
      // ...
      this.layout.event.notifyViewLock(this);
    }
  }
  // ...
}

場景推演

在我們的文件編輯器中,很明顯單獨實現虛擬滾動是不夠的 必須要為其做各種API相容。實際上前邊敘述的模組設計部分也可以屬於場景推演的一部分,只不過前邊的內容更傾向於編輯器內部的功能模組設計,而我們的當前的場景推演則是傾向於編輯器與主應用的場景與互動場景。

錨點跳轉

錨點跳轉是我們的文件系統的基本能力,特別是使用者在分享連結的時候會用的比較多,甚至於某些使用者希望分享任意的文字位置也都是可以做到的。那麼類似於錨點跳轉的能力在我們虛擬滾動的時候就可能會出現問題,試想一下當使用者使用者的hash值是在某個塊中的,而顯然在虛擬滾動的情況下這個塊可能並不會實際渲染出來,因此無論是瀏覽器的預設策略或者是原本編輯器提供的能力都會失效。所以我們需要為錨點跳轉單獨適配場景,為類似需要定位到某個位置的場景獨立控制模組出來。

那麼我們可以明顯地判斷出來,在併入虛擬滾動之後,與先前的跳轉有差別的地方就在於塊結構可能還未被渲染出來,那麼在這種情況下我們只需要在頁面載入完成之後排程存在錨點的塊立即渲染,之後再排程原來的跳轉即可。那麼既然存在載入時跳轉的情況,當使用者跳轉到某個節點時,其上方的塊結構可能正在從loading轉移到viewport狀態,那麼這種情況下就需要我們在前文中描述的視口鎖定能力了,以此來保證使用者的視口不會在塊狀態發生變更的時候引起高度差異造成的視覺跳躍現象。

那麼在這裡我們來定義locateTo方法,在引數中我們需要明確需要搜尋的Hash Entry,也就是在富文字資料結構中表達錨點的結構,因為我們最終還是需要透過資料來檢索DOM節點的,在不傳遞blockId的情況下還需要根據Entry找到節點所屬的Block。在options中我們需要定義buffer用來留作滾動的位置偏移,由於可能出現DOM節點已經存在的情況,所以我們傳遞domKey來嘗試能否直接透過DOM跳轉到相關位置,最後如果我們能確定blockId的話,則會直接預渲染相關節點,否則需要根據key value從資料中查詢。

class Viewport {
  public async locateTo(
    key: string, 
    value: string, 
    options?: { buffer?: number; domKey?: string; blockId?: string }
  ) {
    const { buffer = 0, domKey = key, blockId } = options || {};
    const container = this.editor.getContainer();
    if (blockId) {
      await this.forceRenderBlock(blockId);
    }
    let dom: Element | null = null;
    if (domKey === "id"){
      dom = document.getElementById(value);
    } else {
      dom = container.querySelector(`[${domKey}="${value}"]`);
    }
    if (dom) {
      const rect = dom.getBoundingClientRect();
      const top = rect.top - buffer - this.layout.offsetTop;
      this.layout.scrollDeltaY(top);
      return void 0;
    }
    const entry = this.findEntry(key, value);
    if (entry) {
      await this.forceRenderBlock(entry.blockId);
      this.scrollToEntry(entry);
    }
  }
} 

實際上通常我們都是跳轉到標題位置的,甚至都不會跳轉到某個巢狀塊的標題,所以實際上在這種情況下我們甚至可以將Heading型別的塊獨立排程,也就是說其在HOC載入時即作為viewport狀態而不是loading狀態,這樣的話也可以一定程度上避免錨點的排程複雜性。當然實際上我們獨立的位置跳轉控制能力還是必須要有的,除了錨點之外還有很多其他的模組可能用得到。

class HOC extends React.PureComponent<Props> {
  constructor(props: Props) {
    // ...
    if (this.props.block.type === "HEADING") {
      this.state.mode = "viewport";
    }
  }
}

查詢替換

查詢替換同樣也是線上文件中比較常見的能力,通常是基於文件資料檢索然後在文件中標記相關位置,並且可以跳轉和替換的能力。由於查詢替換中存在文件檢索、虛擬圖層等功能需求,所以在虛擬滾動的情況下對於我們的控制排程依賴更大。首先查詢替換會存在跳轉的問題,那麼在跳轉的時候也會跟上述的錨點跳轉類似,我們需要在跳轉的時候將相關塊渲染出來,然後再進行跳轉。之後查詢替換還需要對接虛擬圖層VirtualLayer的渲染能力,當實際渲染塊的時候同樣需要將圖層一併渲染出來,也就是說我們的虛擬圖層模組同樣需要按需渲染。

那麼接下來我們需要對其適配相關API控制能力,首先是位置跳轉部分,在這裡由於我們的目標是透過檢索原本的資料結構得到的,所以我們不需要透過key value再度檢索Entry,我們可以直接組裝Entry資料,然後根據ModelView的對映找到與之對應的Text節點,之後藉助range獲取其位置資訊,最後跳轉到相關位置即可,當然這裡的節點資訊不一定是Text節點,也可以是Line節點等等,需要具體關注於編輯器引擎的實現。不過在這裡需要注意的是我們需要提前保證Block的渲染狀態,也就是在實際跳轉之前需要排程forceRenderBlock去渲染Block

class Viewport {
  public scrollTo(top: number) {
    this.layout.scrollDeltaY(top - this.layout.offsetTop);
  }
  public getRawRect(entry: Entry) {
    const start = entry.index;
    const blockId = entry.blockId;
    const { node, offset } = this.editor.reflect.getTextNode(start, blockId);
    // ...
    const range = new Range();
    range.setStart(node, offset);
    range.setEnd(node, offset);
    const rect = range.getBoundingClientRect();
    return rect;
  }
  public async locateToEntry(entry: Entry, buffer = 0) {
    await this.forceRenderBlock(entry.blockId);
    const rect = this.getRawRect({ ...entry, len: 0 });
    rect && this.scrollTo(rect.top - buffer);
  }
}

緊接著我們需要關注查詢替換的檢索本身的位置跳轉,通常查詢替換都會存在上一處下一處的按鈕,那麼在這種情況下我們需要思考一個問題,因為我們的Block是可能存在不被渲染的情況的,那麼此時我們不容易取得其高度資訊,因此上一處下一處的排程可能是不準確的。舉個例子,我們在文件的比較下方的位置有某個塊結構,這個塊結構之中巢狀了行和程式碼塊,如果在檢索的時候我們採用直接迭代所有狀態塊而不是遞迴地查詢的話,那麼就存在先跳轉完成塊內容之後再跳轉到程式碼塊的問題,所以我們在檢索的時候需要對高度先進行預測。還記得我們之前聊到我們是有佔位節點的,實際上透過佔位節點作為預估的高度值便可以解決這個問題,當然這裡還是需要先看查詢替換的具體演算法來決定,如果是遞迴查詢的話理論上不會需要類似的相容控制,本質上是要能夠保證塊渲染前後標記內容的順序一致。

class Viewport {
  public getObservableTop(entry: Entry) {
    const blockId = entry.blockId;
    let state: State | null = this.editor.getState(blockId);
    let node: HTMLElement | null = null
    while (state) {
      if (state.node && state.node.parentNode){
        node = state.node;
        break;
      }
      state = state.parent;
    }
    if (!node) return -999999;
    const rect = node.getBoundingClientRect();
    return rect.top;
  }
}

接下來我們還需要關注在文件本體的虛擬圖層渲染,也就是實際展示在文件中的標記。在前邊我們提到了我們在Layout模組中置入了Event模組,那麼接下來我們就需要藉助Event模組來完成虛擬圖層的渲染。實際上這部分邏輯還是比較簡單的,我們只需要在attach-block的時刻將將儲存好的虛擬圖層節點渲染到塊結構上,在detach-block的時刻將其移除即可。

class VirtualLayer {
  // ...
  private onAttachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.computeBlockEntriesRect(blockId);
      this.renderBlock(blockId);
    }
  }
  private onDetachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.removeBlock(blockId);
    }
  }
  // ...
}

劃詞評論

劃詞評論同樣是線上文件產品中常見的能力,那麼由於評論會存在各種跳轉的功能,例如同樣的上一處下一處、跳轉到首個評論、文件開啟時定位等等,所以我們也需要為其做適配。首先是評論的位置更新,設想一個場景,當我們開啟文件時無論是錨點跳轉還是文件的首屏評論定位等,都會導致文件直接滾動到相對應的位置,那麼此時如果使用者再向上滾動話,就會導致一個問題,由於視口鎖定能力的存在,此時捲軸是不斷調整的,而且塊結構的高度也會發生改變,此時就必須要同等地調整評論位置,否則就會發生評論和劃線偏移的現象。

同樣的,我們的評論也有可能會出現塊結構DOM不存在,從而導致無法正常獲取其高度的問題,所以實際上我們的評論內容也是需要按需渲染的,也就是滾動到塊結構的時候才正常展示評論內容。那麼同樣的我們只需要在虛擬滾動模組中註冊評論模組的回撥即可,我們可能會發現之前在實現虛擬滾動事件的時候,塊的掛載與解除安裝都是非同步通知的,而鎖定視口的通知事件是同步的,因為視口鎖定必須要立即執行,否則就會導致視覺上出現跳動的現象,此外評論卡片我們不能夠為其設定動畫,否則也可能導致視覺上的跳動,那麼就需要額外的排程策略解決這個問題。

class CommentModule {
  // ...
  private onLockView = (instance: HOC) => {
    this.computeBlockEntriesRect(instance.id);
    this.renderComments(instance.props.block.id);
  }
  private onAttachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.computeBlockEntriesRect(blockId);
      this.renderComments(blockId);
    }
  }
  private onDetachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.removeComments(blockId);
    }
  }
  // ...
}

實際上我們前邊的更新都可能會存在一個問題,設想一下當我們更新某個塊的內容時,那麼真的只會影響這個塊的高度嘛,很明顯不是這樣的。當我們的某個塊發生變化時,其很可能會影響當前塊之後的所有塊,因為我們的排版引擎就是由上至下的,某個塊的高度變更大機率是要影響到其他塊的。那麼如果我們全量更新位置資訊的話就可能會造成比較大的效能消耗,所以這裡我們可以考慮HOC的影響範圍由此來確定更新範圍,甚至由於鎖視口造成的高度變更值我們是明確的,因此每個位置高度我們都可以按需更新。對於當前塊的評論我們需要全量更新,而對於當前塊之後的塊我們只需要更新其高度即可,我們這裡的策略是透過HOCindex來確定影響範圍的,所以我們需要在變更的維護HOCindex範圍。

class CommentModule {
  // ...
  private onLockView = (instance: HOC, delta: number) => {
    this.computeBlockEntriesRect(instance.id);
    this.renderComments(instance.props.block.id);
    const effects = this.layout.instances.filter(it => it.index > instance.index);
    for (const effect of effects) {
      const comments = this.getComments(effect.block.id);
      comments.forEach(comment => {
        comment.top = comment.top + delta;
        comment.update();
      });
    }
  }
  // ...
}

實際上在前邊我們提到過很多次我們不能透過smooth的平滑排程來處理滾動,因為我們需要明確的高度值以及視口鎖定排程,那麼我們同樣可以思考一下這個問題,由於我們相當於完全接管了文件的滾動行為,那麼明確的高度值我們只需要將其放置於變數中即可,那麼視口鎖定的排程的主要問題是我們不能明確地知道此時正在滾動,如果我們能夠明確感知到正在滾動話就只需要在滾動結束之後再進行視口鎖定的排程與塊結構的渲染即可,在滾動的過程中不會排程相關的模組。

那麼關於這個問題我有個實現思路,只是還沒有具體實施,既然我們的滾動主要是為了解決上邊兩個問題,那麼我們完全可以模擬這個滾動動畫,也就是說對於固定的滾動delta值,我們根據計算模擬動畫效果,類似於transition ease動畫效果,透過Promise.all來管理所有的滾動進度,緊接著透過佇列實現後續的排程效果,當需要取得當前狀態時透過滾動模組決定取排程值還是scrollTop,當滾動完成之後再排程下一個任務。當然實際上我覺得這個方案可以作為後續的最佳化方向,即使是我們不排程動畫效果,透過定位到相關位置實現目標閃爍的效果也是不錯的。

    Set Top 100
         |
[ 50, 25, 13, 7, 5 ]
         |
    Promise.all
         |
     Next Task
         |
        ...

效能考量

在我們相容完成各類功能之後,必須要對我們的虛擬滾動方案進行效能考量,實際上我們在前期調研的時候就需要對效能進行初步測試,以確定實現此功能的ROI以及資源的投入。

效能指標

那麼既然要進行效能考量,必然就需要明確我們的效能指標,我們的常用的效能測試指標通常有:

  • FP - First Paint: 即首次渲染的時間點,在效能統計指標中,從使用者開始訪問Web頁面的時間點到FP的時間點這段時間可以被視為白屏時間。也就是說在使用者訪問Web網頁的過程中,FP時間點之前使用者看到的都是沒有任何內容的白色螢幕,使用者在這個階段感知不到任何有效的工作在進行。
  • FCP - First Contentful Paint: 即首次有內容渲染的時間點,在效能統計指標中,從使用者開始訪問Web頁面的時間點到FCP的時間點這段時間可以被視為無內容時間。也就是說在使用者訪問Web網頁的過程中,FCP時間點之前,使用者看到的都是沒有任何實際內容的螢幕,注意是有畫素渲染但無實際內容,使用者在這個階段獲取不到任何有用的資訊。
  • LCP - Largest Contentful Paint: 即最大內容繪製時間,是Core Web Vitals度量標準,用於度量視口中最大的內容元素何時可見,其可以用來確定頁面的主要內容何時在螢幕上完成渲染。
  • FMP - First Meaningful Paint: 即首次繪製有意義內容的時間,當整體頁面的佈局和文字內容全部渲染完成後,即可認為是完成了首次有意義內容的繪製。
  • TTI - Time to Interactive: 即完全可互動時間,是一種非標準化的Web效能進度指標,定義為上一個LongTask完成時的時間點,緊隨其後的是5秒鐘的網路和主執行緒處於不活動狀態。

那麼由於此時我們想測試的目標是編輯器引擎,或者通俗點來說其實並不是主應用的效能指標,而是更傾向於對SDK的效能進行測試,那麼我們的指標可能並不是那麼通用的指標標準。此外,由於我們希望還是線上上場景下進行測試,而不是單純基於SDK的開發版本測試,所以在這裡我們選取了LCPTTI兩個指標作為我們的測試標準。並且我們實際上不涉及網路狀態,所以靜態資源和快取都可以啟用,為了防止突發性的尖刺造成的影響,我們也可以多次測試取平均值。

  • LCP標準,在我們的編輯器引擎中通常會對初次渲染完成的進行emit,也就是在初次所有塊渲染完成的那個時間點,可以認為是元件的componentDidMount時機。那麼在這裡我們的LCP就取這個時間點,同樣也是在前邊我們提到的Layout模組中的isEditorLoaded,此外實際上我們的起點也可以從編輯器例項化的時間節點開始計算,可以更加精準地排除主應用的時間消耗。那麼這個方案只需要在編輯器中定義好事件觸發,透過在HTML的時間戳相減即可。
  • TTI標準,由於實際上TTI是一種非標準化的Web效能進度指標,所以我們並不需要按照嚴格按照標準來定義這個行為,實際上我們只需要找到一個代理指標即可。在前邊我們說到我們是線上上的真實場景中進行測試的,所以在系統中的功能都是存在的,所以在這裡我們可以透過使用者的互動行為來定義這個指標,在本次測試中選擇的方案是當使用者點選發布按鈕,並且能夠實際彈窗釋出則認為是完全可互動。那麼這個方案可以藉助油猴指令碼來完成,透過不斷檢查按鈕的狀態來自動模擬使用者釋出互動行為。
// HTML
var __MEASURE_START = Date.now(); // or `performance.now`

// Editor
window.__MEASURE_EDITOR_START = Date.now(); // or `performance.now`

// LCP
editor.once("paint", () => {
  const LCP = Date.now() - __MEASURE_START;
  console.log("LCP", LCP);
  const EDITOR_LCP = Date.now() - window.__MEASURE_EDITOR_START;
  console.log("EDITOR_LCP", EDITOR_LCP);
});

// TTI
// ==UserScript==
// @name         TTI
// @run-at      document-start
// ...
// ==/UserScript==
(function () {
  const task = () => {
    const el = document.querySelector(".xxx")?.parentElement;
    el?.click();
    const result = document.querySelector(".modal-xxx");
    if (result) {
      console.log("TTI", Date.now() - __MEASURE_START);
    } else {
      setTimeout(task, 100);
    }
  };
  setTimeout(task, 100);
})();

效能測試

在前期調研引入的初步效能測試中,引入虛擬滾動對效能的提升是巨大的。特別是對於很多API文件而言,大量的表格塊結構會導致效能迅速劣化,表格中會巢狀大量的塊結構,並且其本身也需要維護大量狀態,所以實現虛擬列表實際上是非常有價值的。那麼還記得前邊我們最開始提到的使用者反饋嘛,我們就需要在這個反饋的大文件上以上述的效能指標進行效能測試,在前邊的效能資料基礎上我們就可以進行對比。

  • 編輯器渲染: 2505ms -> 446ms,最佳化82.20%
  • LCP指標: 6896ms -> 3376ms,最佳化51.04%
  • TTI指標: 13343ms -> 3878ms,最佳化70.94%

那麼如果僅對使用者反饋提供的文件進行測試顯然是不夠的,我們還需要設計其他的測試方案來對文件進行測試,特別是固定測試文件或者是固定的測試方案,能夠為以後的效能方案提供更多的資料參考。所以我們可以設計一種測試方案,那麼既然我們的文件是由塊結構組成的,那麼很顯然我們就可以生成測試塊的方案來生成效能測試資料,那麼此時我們便可以設計基於純文字塊、基本塊、程式碼塊的三種效能測試基準。

首先是基於純文字的塊方案,在這裡我們生成1萬字的純文字文件,實際上我們的我們的文件一般也不會有特別多的字元,比如這篇文件就是3.7萬字元左右,這已經算是超級大的文件了,文件絕大部分都是低於1萬字元的。那麼在生成文字的時候我還發現了個有趣的事情,透過選取岳陽樓記作為基礎文字,隨機挑選字組成基準測試文件,有趣的事情是即使是隨機生成的字,也會別有一番文言文的感覺。實際上在這裡對於純文字的塊我們採取的策略是全量渲染,並不會排程虛擬滾動,因為純文字是很簡單的塊結構,所以由於附加了額外的模組,導致整個渲染時間會有所增加。

  • 編輯器渲染: 219ms -> 254ms,最佳化-13.78%
  • FCP指標: 2276ms -> 2546ms,最佳化-10.60%
  • TTI指標: 3270ms -> 3250ms,最佳化0.61%

接下來是基本塊結構的測試基準,這裡的基本塊結構指的是簡單的塊,例如高亮塊、程式碼塊等單獨的塊結構,由於程式碼塊的通用性且文件中可能會存在比較多的程式碼塊結構,所以在這裡選取程式碼塊作為測試基準。在這裡隨機生成100個基本塊結構,並且每個塊結構中隨機生成文字,文字隨機標註加粗和斜體樣式。

  • 編輯器渲染: 488ms -> 163ms,最佳化66.60%
  • FCP指標: 3388ms -> 2307ms,最佳化30.05%
  • TTI指標: 4562ms -> 3560ms,最佳化21.96%

最後是表格塊結構的測試基準,表格結構由於其維護的狀態比較多,且單個單元表格結構可能會存在大量的單元格,特別是很多文件中還會存在大表格的情況,所以表格結構對於編輯器引擎的效能消耗是最大的。在這裡的表格基準是生成100個表格結構,每個表格中4個單元格,每個單元格中隨機生成文字,文字隨機標註加粗和斜體樣式。

  • 編輯器渲染: 2739ms -> 355ms,最佳化87.04%
  • FCP指標: 5124ms -> 2555ms,最佳化50.14%
  • TTI指標: 20779ms -> 4354ms,最佳化79.05%

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://developer.mozilla.org/zh-CN/docs/Web/CSS/overflow-anchor
https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserverEntry
https://developer.mozilla.org/zh-CN/docs/Web/API/History/scrollRestoration
https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
https://arco.design/react/components/list#%E6%97%A0%E9%99%90%E9%95%BF%E5%88%97%E8%A1%A8

相關文章