初探富文本之基於虛擬滾動的大型文檔性能優化方案

来源:https://www.cnblogs.com/WindrunnerMax/p/18227998
-Advertisement-
Play Games

初探富文本之基於虛擬滾動的大型文檔性能優化方案 虛擬滾動是一種優化長列表性能的技術,其通過按需渲染列表項來提高瀏覽器運行效率。具體來說,虛擬滾動只渲染用戶瀏覽器視口部分的文檔數據,而不是整個文檔結構,其核心實現根據可見區域高度和容器的滾動位置計算出需要渲染的列表項,同時不渲染額外的視圖內容。虛擬滾動 ...


初探富文本之基於虛擬滾動的大型文檔性能優化方案

虛擬滾動是一種優化長列表性能的技術,其通過按需渲染列表項來提高瀏覽器運行效率。具體來說,虛擬滾動只渲染用戶瀏覽器視口部分的文檔數據,而不是整個文檔結構,其核心實現根據可見區域高度和容器的滾動位置計算出需要渲染的列表項,同時不渲染額外的視圖內容。虛擬滾動的優勢在於可以大大減少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 re

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 如何在 Flutter 中分享視頻到抖音 話不多說,先上效果: 原理 發佈內容至抖音 H5 場景_移動/網站應用_抖音開放平臺 (open-douyin.com) 本教程沒有接入抖音原生 SDK 以及任何第三方插件,使用抖音的 h5 分享介面配合 url_launcher 插件實現跳轉至抖音分享頁面 ...
  • 前言 頁面跳轉是指在瀏覽器中從當前頁面跳轉到另一個頁面的操作。可以通過點擊鏈接、輸入網址、提交表單等方式實現頁面跳轉。 瀏覽記錄是指記錄用戶在瀏覽器中瀏覽過的頁面的歷史記錄。當用戶跳轉到一個新頁面時,該頁面會被加入瀏覽記錄中,用戶可以通過瀏覽器的後退按鈕或者瀏覽歷史列表來查看和訪問之前瀏覽過的頁 ...
  • 在電腦啟動過程和Linux內核Kernel啟動過程介紹了電腦啟動和內核載入,本篇文章主要介紹Android系統是如何啟動的。 一、Android啟動流程 Android系統的啟動流程與Linux接近: sequenceDiagram participant Bootloader as 引導載入程 ...
  • 傳統實現方式 當前文章的gif文件較大,載入的時長可能較久 這裡我拿小紅書的首頁作為分析演示 可以看到他們的實現方式是傳統做法,把每個元素通過獲取尺寸,然後算出left、top的排版位置,最後在每個元素上設置偏移值,思路沒什麼好說的,就是算元素坐標。那麼這種做法有什麼缺點?請看下麵這張圖的操作 容器 ...
  • title: Vue渲染函數與JSX指南 date: 2024/6/3 下午6:43:53 updated: 2024/6/3 下午6:43:53 categories: 前端開發 tags: Vue渲染 JSX基礎 性能優化 組件對比 React JSX 大項目 測試策略 第1章:Vue.js入門 ...
  • 使用 Vite 快速搭建腳手架 命令行選項直接指定項目名稱和想要使用的模板,Vite + Vue 項目,運行(推薦使用yarn) # npm 6.x npm init vite@latest my-vue-app --template vue # npm 7+, 需要額外的雙橫線: npm init ...
  • Vue3簡單項目流程分享——工作室主頁 零、寫在最前 以下是項目相關的一些鏈接: 源代碼GitHub倉庫(需要魔法上網):倉庫 網頁示例(需要魔法上網):網頁示例 UI圖(來源@設計師楊賀):MasterGo主頁 補充:由於時間關係,該網頁沒有適配手機端,最佳展示效果為網頁端1440p寬度。 如果你 ...
  • FormCreate 是一個可以通過 JSON 生成具有動態渲染、數據收集、驗證和提交功能的表單生成組件。支持5個UI框架,並且支持生成任何 Vue 組件。內置20種常用表單組件和自定義組件,再複雜的表單都可以輕鬆搞定 FormCreate官網:https://www.form-create.com ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...