BI 平臺是阿里數據中臺團隊很是重要的平臺級產品,要保證報表編輯與瀏覽的良好體驗,性能優化是必不可少的。前端
當前 BI 工具廣泛是報表形態,要知道報表形態可不只僅是一張張圖表組件,與這些組件關聯的篩選條件和聯動關係錯綜複雜,任何一個篩選條件變化就會致使其關聯項從新取數並重渲染組件,而報表數據量很是大,一個表格組件加載百萬量級的數據稀鬆日常,爲了維持這麼大量級數據量下的正常展現,按需渲染是必需要作的功課。git
這裏說的按需渲染不是指 ListView 無限滾動,由於報表的佈局模式有流式佈局、磁貼布局和自由佈局三套,每種佈局風格差別很大,沒法用固定的公式計算組件是否可見,所以咱們選擇初始化組件全量渲染,阻止非首屏內組件的重渲染。由於初始條件下尚未獲取數據,全量渲染不會形成性能問題,這是這套方案成立的前提。github
因此我今天就專門介紹如何利用 DOM 判斷組件在畫布中是否可見這個技術方案,從架構設計與代碼抽象的角度一步步分解,不只但願你能輕鬆理解這個技術方案如何實現,也但願你能掌握這其中的訣竅,學會觸類旁通。web
咱們以 React 框架爲例,作按需渲染的思惟路徑是這樣的:算法
獲得組件 active
狀態 -> 阻塞非 active
組件的重渲染。瀏覽器
這裏我選擇從結果入手,先考慮如何阻塞組件渲染,再一步步推導出判斷組件是否可見這個函數怎麼寫。性能優化
咱們須要一個 RenderWhenActive
組件,支持一個 active
參數,當 active
爲 true 時這一層是透明的,當 active
爲 false 時阻塞全部渲染。微信
再具體描述一下,其效果是這樣的:架構
目前 Function Component 作不到這一點,咱們仍需藉助 Class Component 的 shouldComponentUpdate
作到這一點,由於 Class Component 阻塞渲染時,會將最新 props 存儲下來,而 Function Component 徹底沒有內部狀態,目前還沒法勝任這項工做。框架
咱們能夠寫一個 RenderWhenActive
組件輕鬆實現此功能:
class RenderWhenActive extends React.Component {
public shouldComponentUpdate(nextProps) { return nextProps.active; } public render() { return this.props.children } } 複製代碼
在進一步思考以前,咱們先不要掉到 「如何判斷組件是否顯示」 這個細節中,能夠先假設 「已經有了這樣一個函數」,咱們應該如何調用。
很顯然咱們須要一個自定義 Hook:useActive
判斷組件是不是激活態,並拿到 active
返回值傳遞給 RenderWhenActive
組件:
const ComponentLoader = ({ children }) => {
const active = useActive(); return <RenderWhenActive active={active}>{children}</RenderWhenActive>; }; 複製代碼
這樣,渲染引擎利用 ComponentLoader
渲染的任何組件就具有了按需渲染的功能。
到如今,組件與 Hook 側的流程已經完整串起來了,咱們能夠聚焦於如何實現 useActive
這個 Hook。
利用 Hooks 的 API,能夠在組件渲染完畢後利用 useEffect
判斷組件是否 Active,並利用 useState
存儲這個狀態:
export function useActive(domId: string) {
// 全部元素默認 unActive const [active, setActive] = React.useState(false); React.useEffect(() => { const visibleObserve = new VisibleObserve(domId, "rootId", setActive); visibleObserve.observe(); return () => visibleObserve.unobserve(); }, [domId]); return active; } 複製代碼
初始化時,全部組件 active 狀態都是 false,然而這種狀態在 shouldComponentUpdate
並不會阻塞第一次渲染,所以組件的 dom 節點初始化仍會渲染出來。
在 useEffect
階段註冊了 VisibleObserve
這個自定義 Class,用來監聽組件 dom 節點在其父級節點 rootId
內是否可見,並在狀態變動時經過第三個回調拋出,這裏將 setActive
做爲第三個參數,能夠及時改變當前組件 active 狀態。
VisibleObserve
這個函數擁有 observe
與 unobserve
兩個 API,分別是啓動監聽與取消監聽,利用 useEffect
銷燬時執行 return callback 的特性,監聽與銷燬機制也完成了。
下一步就是如何實現最核心的 VisibleObserve
函數,用來監聽組件是否可見。
在實現 VisibleObserve
以前,想一下有幾種方法實現呢?可能你腦海中冒出了不少種奇奇怪怪的方案。是的,判斷組件在某個容器內是否可見有許多種方案,即使從功能上能找到最優解,但從兼容性角度來看也沒法找到完美的方案,所以這是一個擁有多種實現可能性的函數,在不一樣版本的瀏覽器採用不一樣方案纔是最佳策略。
處理這種狀況的方法之一,就是作一個抽象類,讓全部實際方法都繼承並實現抽象類,這樣咱們就擁有了多套 「相同 API 的不一樣實現」,以便在不一樣場景隨時切換使用。
利用 abstract
建立抽象類 AVisibleObserve
,實現構造函數並申明兩個 public 的重要函數 observe
與 unobserve
:
/** * 監聽元素是否可見的抽象類 */ abstract class AVisibleObserve { /** * 監聽元素的 DOM ID */ protected targetDomId: string; /** * 可見範圍根節點 DOM ID */ protected rootDomId: string; /** * Active 變化回調 */ protected onActiveChange: (active?: boolean) => void; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { this.targetDomId = targetDomId; this.rootDomId = rootDomId; this.onActiveChange = onActiveChange; } /** * 開始監聽 */ abstract observe(): void; /** * 取消監聽 */ abstract unobserve(): void; } 複製代碼
這樣咱們就能夠實現多套方案。稍加思索能夠發現,咱們只要兩套方案,一套是利用 setInterval
實現的輪詢檢測的笨方法,一種是利用瀏覽器高級 API IntersectionObserver
實現的新潮方法,因爲後者有兼容性要求,前者就做爲兜底方案實現。
所以咱們能夠定義兩套對應方法:
class IntersectionVisibleObserve extends AVisibleObserve {
constructor(/**/) { super(targetDomId, rootDomId, onActiveChange); } observe() { // balabala.. } unobserve() { // balabala.. } } class SetIntervalVisibleObserve extends AVisibleObserve { constructor(/**/) { super(targetDomId, rootDomId, onActiveChange); } observe() { // balabala.. } unobserve() { // balabala.. } } 複製代碼
最後再作一個總類做爲調用入口:
/** * 監聽元素是否可見總類 */ export class VisibleObserve extends AVisibleObserve { /** * 實際 VisibleObserve 類 */ private actualVisibleObserve: AVisibleObserve = null; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { super(targetDomId, rootDomId, onActiveChange); // 根據瀏覽器 API 兼容程度選用不一樣 Observe 方案 if ('IntersectionObserver' in window) { // 最新 IntersectionObserve 方案 this.actualVisibleObserve = new IntersectionVisibleObserve(targetDomId, rootDomId, onActiveChange); } else { // 兼容的 SetInterval 方案 this.actualVisibleObserve = new SetIntervalVisibleObserve(targetDomId, rootDomId, onActiveChange); } } observe() { this.actualVisibleObserve.observe(); } unobserve() { this.actualVisibleObserve.unobserve(); } } 複製代碼
在構造函數就判斷了當前瀏覽器是否支持 IntersectionObserver
這個 API,然而不管何種方案建立的實例都繼承於 AVisibleObserve
,因此咱們能夠用統一的 actualVisibleObserve
成員變量存放。
observe
與 unobserve
階段均可以無視具體類的實現,直接調用 this.actualVisibleObserve.observe()
與 this.actualVisibleObserve.unobserve()
這兩個 API。
這裏體現的思想是,父類關心接口層 API,子類關心基於這套接口 API 如何具體實現。
接下來咱們看看低配版(兼容)與高配版(原生)分別如何實現。
兼容版本模式中,須要定義一個額外成員變量 interval
存儲 SetInterval 引用,在 unobserve
的時候 clearInterval
。
其判斷可見函數我抽象到了 judgeActive
函數中,核心思想是判斷兩個矩形(容器與要判斷的組件)是否存在包含關係,若是包含成立則表明可見,若是包含不成立則不可見。
下面是完整實現函數:
class SetIntervalVisibleObserve extends AVisibleObserve {
/** * Interval 引用 */ private interval: number; /** * 檢查是否可見的時間間隔 */ private checkInterval = 1000; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { super(targetDomId, rootDomId, onActiveChange); } /** * 判斷元素是否可見 */ private judgeActive() { // 獲取 root 組件 rect const rootComponentDom = document.getElementById(this.rootDomId); if (!rootComponentDom) { return; } // root 組件 rect const rootComponentRect = rootComponentDom.getBoundingClientRect(); // 獲取當前組件 rect const componentDom = document.getElementById(this.targetDomId); if (!componentDom) { return; } // 當前組件 rect const componentRect = componentDom.getBoundingClientRect(); // 判斷當前組件是否在 root 組件可視範圍內 // 長度之和 const sumOfWidth = Math.abs(rootComponentRect.left - rootComponentRect.right) + Math.abs(componentRect.left - componentRect.right); // 寬度之和 const sumOfHeight = Math.abs(rootComponentRect.bottom - rootComponentRect.top) + Math.abs(componentRect.bottom - componentRect.top); // 長度之和 + 兩倍間距(交叉則間距爲負) const sumOfWidthWithGap = Math.abs( rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right, ); // 寬度之和 + 兩倍間距(交叉則間距爲負) const sumOfHeightWithGap = Math.abs( rootComponentRect.bottom + rootComponentRect.top - componentRect.bottom - componentRect.top, ); if (sumOfWidthWithGap <= sumOfWidth && sumOfHeightWithGap <= sumOfHeight) { // 在內部 this.onActiveChange(true); } else { // 在外部 this.onActiveChange(false); } } observe() { // 監聽時就判斷一次元素是否可見 this.judgeActive(); this.interval = setInterval(this.judgeActive, this.checkInterval); } unobserve() { clearInterval(this.interval); } } 複製代碼
根據容器 rootDomId
與組件 targetDomId
,咱們能夠拿到其對應 DOM 實例,並調用 getBoundingClientRect
拿到其對應矩形的位置與寬高。
算法思路以下:
設容器爲 root,組件爲 component。
sumOfWidth
與寬度之和
sumOfHeight
。
sumOfWidthWithGap
與 寬度之和 + 兩倍間距
sumOfHeightWithGap
。
sumOfWidthWithGap - sumOfWidth
的差值就是橫向 gap 距離,
sumOfHeightWithGap - sumOfHeight
的差值就是橫向 gap 距離,兩個值都爲負數表示在內部。
其中的關鍵是,從橫向角度來看,下面的公式能夠理解爲寬度之和 + 兩倍的寬度間距:
// 長度之和 + 兩倍間距(交叉則間距爲負)
const sumOfWidthWithGap = Math.abs( rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right ); 複製代碼
而 sumOfWidth
是寬度之和,這之間的差值就是兩倍間距值,正數表示橫向沒有交集。當橫縱兩個交集都是負數時,表明存在交叉或者包含在內部。
若是瀏覽器支持 IntersectionObserver
這個 API 就好辦多了,如下是完整代碼:
class IntersectionVisibleObserve extends AVisibleObserve {
/** * IntersectionObserver 實例 */ private intersectionObserver: IntersectionObserver; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { super(targetDomId, rootDomId, onActiveChange); this.intersectionObserver = new IntersectionObserver( changes => { if (changes[0].intersectionRatio > 0) { onActiveChange(true); } else { onActiveChange(false); // 由於虛擬 dom 更新致使實際 dom 更新,也會在此觸發,判斷 dom 丟失則從新監聽 if (!document.body.contains(changes[0].target)) { this.intersectionObserver.unobserve(changes[0].target); this.intersectionObserver.observe(document.getElementById(this.targetDomId)); } } }, { root: document.getElementById(rootDomId), }, ); } observe() { if (document.getElementById(this.targetDomId)) { this.intersectionObserver.observe(document.getElementById(this.targetDomId)); } } unobserve() { this.intersectionObserver.disconnect(); } } 複製代碼
經過 intersectionRatio > 0
就能夠判斷元素是否出如今父級容器中,若是 intersectionRatio === 1
則表示組件完整出如今容器內,此處咱們的要求是任意部分出現就 active。
有一點要注意的是,這個判斷與 SetInterval 不一樣,因爲 React 虛擬 DOM 可能會更新 DOM 實例,致使 IntersectionObserver.observe
監聽的 DOM 元素被銷燬後,致使後續監聽失效,所以須要在元素隱藏時加入下面的代碼:
// 由於虛擬 dom 更新致使實際 dom 更新,也會在此觸發,判斷 dom 丟失則從新監聽
if (!document.body.contains(changes[0].target)) { this.intersectionObserver.unobserve(changes[0].target); this.intersectionObserver.observe(document.getElementById(this.targetDomId)); } 複製代碼
body.contains
判斷元素是否被銷燬,若是被銷燬則從新監聽新的 DOM 實例。
總結一下,按需渲染的邏輯的適用面不只僅在渲染引擎,但對於 ProCode 場景直接編寫的代碼中,要加入這段邏輯就顯得侵入性較強。
或許可視區域內按需渲染能夠作到前端開發框架內部,雖然不屬於標準框架功能,但也不徹底屬於業務功能。
此次留下一個思考題,若是讓手寫的 React 代碼具有按需渲染功能,怎麼設計更好呢?
若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公衆號
版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)