精讀《用 React 作按需渲染》

1 引言

BI 平臺是阿里數據中臺團隊很是重要的平臺級產品,要保證報表編輯與瀏覽的良好體驗,性能優化是必不可少的。前端

當前 BI 工具廣泛是報表形態,要知道報表形態可不只僅是一張張圖表組件,與這些組件關聯的篩選條件和聯動關係錯綜複雜,任何一個篩選條件變化就會致使其關聯項從新取數並重渲染組件,而報表數據量很是大,一個表格組件加載百萬量級的數據稀鬆日常,爲了維持這麼大量級數據量下的正常展現,按需渲染是必需要作的功課。git

這裏說的按需渲染不是指 ListView 無限滾動,由於報表的佈局模式有流式佈局、磁貼布局和自由佈局三套,每種佈局風格差別很大,沒法用固定的公式計算組件是否可見,所以咱們選擇初始化組件全量渲染,阻止非首屏內組件的重渲染。由於初始條件下尚未獲取數據,全量渲染不會形成性能問題,這是這套方案成立的前提。github

因此我今天就專門介紹如何利用 DOM 判斷組件在畫布中是否可見這個技術方案,從架構設計與代碼抽象的角度一步步分解,不只但願你能輕鬆理解這個技術方案如何實現,也但願你能掌握這其中的訣竅,學會觸類旁通。web

2 精讀

咱們以 React 框架爲例,作按需渲染的思惟路徑是這樣的:算法

獲得組件 active 狀態 -> 阻塞非 active 組件的重渲染。瀏覽器

這裏我選擇從結果入手,先考慮如何阻塞組件渲染,再一步步推導出判斷組件是否可見這個函數怎麼寫。性能優化

阻塞組件重渲染

咱們須要一個 RenderWhenActive 組件,支持一個 active 參數,當 active 爲 true 時這一層是透明的,當 active 爲 false 時阻塞全部渲染。微信

再具體描述一下,其效果是這樣的:架構

  1. inActive 時,任何 props 變化都不會致使組件渲染。
  2. 從 inActive 切換到 active 時,以前做用於組件的 props 要當即生效。
  3. 若是切換到 active 後 props 沒有變化,也不該該觸發重渲染。
  4. 從 active 切換到 inActive 後不該觸發渲染,且當即阻塞後續重渲染。

目前 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  } } 複製代碼

獲取組件 active 狀態

在進一步思考以前,咱們先不要掉到 「如何判斷組件是否顯示」 這個細節中,能夠先假設 「已經有了這樣一個函數」,咱們應該如何調用。

很顯然咱們須要一個自定義 Hook:useActive 判斷組件是不是激活態,並拿到 active 返回值傳遞給 RenderWhenActive 組件:

const ComponentLoader = ({ children }) => {
 const active = useActive();   return <RenderWhenActive active={active}>{children}</RenderWhenActive>; }; 複製代碼

這樣,渲染引擎利用 ComponentLoader 渲染的任何組件就具有了按需渲染的功能。

實現 useActive

到如今,組件與 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 這個函數擁有 observeunobserve 兩個 API,分別是啓動監聽與取消監聽,利用 useEffect 銷燬時執行 return callback 的特性,監聽與銷燬機制也完成了。

下一步就是如何實現最核心的 VisibleObserve 函數,用來監聽組件是否可見。

監聽組件是否可見的準備工做

在實現 VisibleObserve 以前,想一下有幾種方法實現呢?可能你腦海中冒出了不少種奇奇怪怪的方案。是的,判斷組件在某個容器內是否可見有許多種方案,即使從功能上能找到最優解,但從兼容性角度來看也沒法找到完美的方案,所以這是一個擁有多種實現可能性的函數,在不一樣版本的瀏覽器採用不一樣方案纔是最佳策略。

處理這種狀況的方法之一,就是作一個抽象類,讓全部實際方法都繼承並實現抽象類,這樣咱們就擁有了多套 「相同 API 的不一樣實現」,以便在不一樣場景隨時切換使用。

利用 abstract 建立抽象類 AVisibleObserve,實現構造函數並申明兩個 public 的重要函數 observeunobserve

/**  * 監聽元素是否可見的抽象類  */ 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 成員變量存放。

observeunobserve 階段均可以無視具體類的實現,直接調用 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。

  1. 計算 root 與 component 長度之和 sumOfWidth 與寬度之和 sumOfHeight
  2. 計算 root 與 component 長度之和 + 兩倍間距 sumOfWidthWithGap 與 寬度之和 + 兩倍間距 sumOfHeightWithGap
  3. 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)); } 複製代碼
  1. 當元素判斷不在可視區域時,也包含了元素被銷燬。
  2. 所以經過 body.contains 判斷元素是否被銷燬,若是被銷燬則從新監聽新的 DOM 實例。

3 總結

總結一下,按需渲染的邏輯的適用面不只僅在渲染引擎,但對於 ProCode 場景直接編寫的代碼中,要加入這段邏輯就顯得侵入性較強。

或許可視區域內按需渲染能夠作到前端開發框架內部,雖然不屬於標準框架功能,但也不徹底屬於業務功能。

此次留下一個思考題,若是讓手寫的 React 代碼具有按需渲染功能,怎麼設計更好呢?

討論地址是:精讀《用 React 作按需渲染》· Issue #254 · dt-fe/weekly

若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章
相關標籤/搜索