初探富文本之基于虛擬滾動的大型文檔性能優化方案
虛擬滾動是一種優化長列表性能的技術,其通過按需渲染列表項來提高瀏覽器運行效率。具體來說,虛擬滾動只渲染用戶瀏覽器視口部分的文檔數據,而不是整個文檔結構,其核心實現根據可見區域高度和容器的滾動位置計算出需要渲染的列表項,同時不渲染額外的視圖內容。虛擬滾動的優勢在于可以大大減少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
的情況下,不特別關注就可能會存在性能劣化問題。
那么在研究了虛擬滾動的優勢之后,我們就可以開始研究虛擬滾動的實現了,在進入到富文本編輯器的塊級虛擬滾動之前,我們可以先來研究一下虛擬滾動都是怎么做的。那么在這里我們以ArcoDesign
的List
組件為例來研究一下通用的虛擬滾動實現。在Arco
給予的示例中我們可以看到其傳遞了height
屬性,此時如果我們將這個屬性刪除的話虛擬列表是無法正常啟動的,那么實際上Arco
就是通過列表元素的數量與每個元素的高度,從而計算出了整個容器的高度,這里要注意滾動容器實際上應該是虛擬列表的容器外的元素,而對于視口內的區域則可以通過transform: translateY(Npx)
來做實際偏移,當我們滾動的時候,我們需要通過滾動條的實際滾動距離以及滾動容器的高度,配合我們配置的元素實際高度,就可以計算出來當前視口實際需要渲染的節點,而其他的節點并不實際渲染,從而實現虛擬滾動。當然實際上關于Arco
虛擬列表的配置還有很多,在這里就不完整展開了。
<List{/* ... */}virtualListProps={{height: 560,}}{/* ... */}
/>
通過簡單分析Arco
的通用列表虛擬滾動,我們可以發現實現虛擬滾動似乎并沒有那么難,然而在我們的在線文檔場景中,實現虛擬滾動可能并不是簡單的事情。此處我們先來設一下在文檔中圖片渲染的實現,通常在上傳圖片的時候,我們會記錄圖片的大小也就是寬高信息,在實際渲染的時候會通過容器最大寬高以及object-fit: contain;
來保證圖片比例,當渲染時即使圖片未實際加載完成,但是其高度占位是已經固定的。然而回到我們的文檔結構中,我們的塊高度是不固定的,特別是文本塊的高度,在不同的字體、瀏覽器寬度等情況下表現是不同的,我們無法在其渲染之前得到其高度,這就導致了我們無法像圖片一樣提前計算出其占位高度,從而對于文檔塊結構的虛擬滾動就必須要解決塊高度不固定的問題,由此我們需要實現動態高度的虛擬滾動調度策略來處理這個場景。而實際上如果僅僅是動態高度的虛擬滾動也并不是特別困難,社區已經有大量的實現方案,但是我們的文檔編輯器是有很多復雜的模塊在內的,例如選區模塊、評論功能、錨點跳轉等等,要兼容這些模塊便是在文檔本體虛擬滾動之外需要關注的功能實現。
模塊設計
實際上富文本編輯器的具體實現有很多種方式,基于DOM
與Canvas
繪制富文本的區別我們就不聊了,在這里我們還是關注于基于DOM
的富文本編輯器上,例如Quill
是完全自行實現的視圖DOM
繪制,而Slate
是借助于React
實現的視圖層,這兩者對于視圖層的實現方式有很大的不同,在本文中是偏向于Slate
的實現方式,也就是借助于React
來構建塊級別的虛擬滾動,當然實際上如果能夠完全控制視圖層的話,對于性能可優化的空間會更大,例如可以更方便地調度閑時渲染配合緩存等策略,從而更好地優化快速滾動時的體驗。實際上無論是哪種方式,對于本文要講的核心內容差距并沒有那么大,只要我們能夠保證富文本引擎本身控制的選區模塊、高度計算模塊、生命周期模塊等正確調度,以及能夠控制實際渲染行為,無論是哪種編輯器引擎都是可以應用虛擬滾動方案的。
渲染模型
首先我們來構思一下整個文檔的渲染模型,無論是基于塊模型的編輯器還是基于段落描述的編輯器都脫離不了行的概念,因為我們描述內容的時候通常都是由行來組成的一篇文檔的,所以我們的文檔渲染也都是以行為基準來描述的。當然這里的行只是一個比較抽象的概念,這個行結構內嵌套的可能是個塊結構的表達例如代碼塊、表格等等,而無論是如何嵌套塊,其最外層總會是需要包裹行結構的表達,即使是純Blocks
的文檔模型,我們也總能夠找到外層的塊容器DOM
結構,所以我們在這里需要明確定義行的概念。
實際上在此處我們所關注的行更傾向于主文檔直屬的行描述,而如果在主文檔的某個行中嵌套了代碼塊結構,這個代碼塊的整個塊結構是我們要關注的,而對于這個代碼塊結構的內部我們先不做太多關注,當然這是可以進一步優化的方向,特別是對于超大代碼塊的場景是有必要的,但是我們在這里先不關注這部分結構優化。此外,對于Canvas
繪制的文檔或者是類似于分頁表達的文檔同樣不在我們的關注范圍內,只要是能夠通過分頁表達的文章,我們直接通過頁的按需渲染即可,當然如果有需要的話同樣也可以進行段落級別的按需渲染,這同樣也可以算作是進一步的優化空間。
那么我們可以很輕松地推斷出我們文檔最終要渲染的結構,首先是占位區域placeholder
,這部分內容是不在視口的區域,所以會以占位的方式存在;緊接著是buffer
,這部分是提前渲染的內容,即雖然此區域不在視口區域,但是為了用戶在滾動時盡量避免出現短暫白屏的現象,由此提前加載部分視圖內容,通常這部分值可以取得視口高度的一半大小;接下來是viewport
部分,這部分是真實在視口區域要渲染的內容;而在視口區域下我們同樣需要buffer
和placeholder
來作為預加載與占位區域。
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 Event
與Intersection 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 (<divstyle={{ 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
具體的應用場景。根據名字我們可以看到Intersection
與Observer
兩個單詞,由此我們可以大概推斷這個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
的狀態,實際上也就是對于樹結構的增刪改查,例如當觸發的op
為insert { 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
,這個API
在React18
被標記為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
節點正確渲染,如果粒度粗則保證其所在的塊是正常渲染即可。 - 拖拽選擇長選區: 當用戶進行
MouseDown
時anchorNode
在視口內,此時用戶通過拖拽操作導致頁面滾動,從而將anchorNode
拖拽到視口外部。同樣的,此時我們需要保證anchorNode
所在的塊/節點即使不在視口區域也需要正常渲染,否則會導致選區丟失。 - 觸發選區更新: 當因為某些操作導致選區中的內容更新時,例如通過編輯器的
API
操作了文檔內容,此時將出現兩種情況,如果更新的內容不是anchorNode
節點或者focusNode
節點,那么對于整體選區不會造成影響,否則我們需要在渲染完成后通過Model
重新校正選區節點。 - 全選操作: 對于全選操作我們可以認為是特殊的選區行為,我們需要保證文檔的首尾的行/塊節點完整渲染,所以在這里的流程是需要通過
Model
獲得首尾節點的狀態,然后強制將這兩部分渲染出來,由此保證anchorNode
節點與focusNode
節點正確渲染出來,接下來再走正常的選區映射邏輯即可。
實際上,還記得我們的Intersection Observer API
通常是需要占位節點來實現虛擬滾動的,那么既然占位節點本身都在這里,如果我們并不特別注意DOM
節點的數量的話,是可以在占位的時候將Block
的選區標識節點一并渲染出來的,這樣可以解決一些問題,例如全選的操作就可以不需要特殊處理。如果我們將范圍放的再寬泛一些的話,將文本塊以及Void/Embed
結構\u200B
節點在占位的時候也一并渲染出來,只對于復雜塊進行渲染調度,這種情況下我們甚至可以不需要關心選區的問題,此時需要標記的選區映射節點都已經渲染出來了,我們只需要關注復雜塊虛擬滾動的調度即可。
視口鎖定
視口鎖定是比較重要的模塊,對于虛擬滾動來說,如果我們每次打開的時候都是從最列表內容的開始瀏覽,那么通常是不需要進行視口鎖定的。但是對于我們的文檔系統來說這個問題就不一樣了,讓我們來設想一個場景,當用戶A
分享了一個帶錨點的鏈接給用戶B
,用戶B
此時打開了超鏈接直接定位到了文檔中的某個標題甚至是某個塊內容區域,此時如果用戶B
進行向上滾動的操作就會出現問題。記得之前我們說的在我們實際渲染內容之前是無法得到塊的實際高度的,那么當用戶向上滾動的時候,由于此時我們的占位節點的高度和塊的實際高度存在差值,此時用戶向上滾動的時候就會存在視覺上跳躍的情況,而我們的視口鎖定便是為了解決這個問題,顧名思義是將用戶的視口鎖定在當前滾動的位置。
在研究具體的虛擬滾動之前,我們先來了解一下overflow-anchor
這個屬性,實際上實現編輯器引擎的的困難之處有很大一部分就是在于各種瀏覽器的兼容,通過這個屬性也可以看出來,即使是同為基于Webkit
內核的Chrome
與Safari
瀏覽器,Chrome
就支持overflow-anchor
而Safari
就不支持。回到overflow-anchor
屬性,這個屬性就是為了解決上邊提到的調整滾動位置以最大程度地減少內容移動,也就是我們上邊說的視覺上跳躍的情況,這個屬性在支持的瀏覽器中會默認啟用。由于Safari
瀏覽器不支持,并且在后邊也會提到我們實際上是需要這個跳躍的差值的,所以在這里我們需要關閉默認的overflow-anchor
行為,主動控制視口鎖定的能力。當然由于實際上在鎖定視口的時候不可避免地會出現獲取DOM
的Rect
數據,則人工干預視口鎖定會觸發更多的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
數據,然后根據Model
與View
的映射找到與之對應的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 = nullwhile (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
的影響范圍由此來確定更新范圍,甚至由于鎖視口造成的高度變更值我們是明確的,因此每個位置高度我們都可以按需更新。對于當前塊的評論我們需要全量更新,而對于當前塊之后的塊我們只需要更新其高度即可,我們這里的策略是通過HOC
的index
來確定影響范圍的,所以我們需要在變更的維護HOC
的index
范圍。
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
的開發版本測試,所以在這里我們選取了LCP
和TTI
兩個指標作為我們的測試標準。并且我們實際上不涉及網絡狀態,所以靜態資源和緩存都可以啟用,為了防止突發性的尖刺造成的影響,我們也可以多次測試取平均值。
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