React 的 KeepAlive 實戰指南:深度解析組件緩存機制

来源:https://www.cnblogs.com/DTinsight/p/18330653
-Advertisement-
Play Games

Vue 的 Keep-Alive 組件是用於緩存組件的高階組件,可以有效地提高應用性能。它能夠使組件在切換時仍能保留原有的狀態信息,並且有專門的生命周期方便去做額外的處理。該組件在很多場景非常有用,比如: · tabs 緩存頁面 · 分步表單 · 路由緩存 在 Vue 中,通過 KeepAlive ...


Vue 的 Keep-Alive 組件是用於緩存組件的高階組件,可以有效地提高應用性能。它能夠使組件在切換時仍能保留原有的狀態信息,並且有專門的生命周期方便去做額外的處理。該組件在很多場景非常有用,比如:

· tabs 緩存頁面

· 分步表單

· 路由緩存

在 Vue 中,通過 KeepAlive 包裹內的組件會自動緩存下來, 其中只能有一個直接子組件。

<KeepAlive>
  // <component 語法相當於 React的{showA ? <A /> : <B />}
   <component :is="showA ? 'A' : 'B'">
</KeepAlive>

可惜的是 React 官方目前並沒有對外正式提供的 KeepAlive 組件,但是我們可以參考 Vue 的使用方式與 API 設計,實現一套 React 版本的 KeepAlive。

下文將為大家詳細介紹三種不同的實現方式。

Style 隱藏法

Style 隱藏法是最簡單方便的方式,直接使用 display: none 來代替組件的銷毀。

封裝一個 StyleKeepAlive 組件,傳入的 showComponentName 屬性表示當前要展示的組件名,同時 children 組件都需要定義下組件名 name。

const StyleKeepAlive: React.FC<any> = ({children, showComponentName}) => {
    return (
        <>
            {React.Children.map(children, (child) => (
                <div
                    style={{
                        display: child.props.name === showComponentName ? "block" : "none",
                    }}
                >
                    {child}
                </div>
            ))}
        </>
    );
}

// 使用
<StyleKeepAlive showComponentName={counterName}>
      <Counter name="A" />
      <Counter name="B" />
</StyleKeepAlive>

假如就這樣寫,勉強能實現要求,但會帶來以下問題:

· 第一次掛載時每個子組件都會渲染一遍

· 父組件 render ,會導致子組件 render ,即使該組件目前是隱藏狀態

· 對實際 dom 結構具有侵入式,如會為每個子組件包一層 div 用來控制 display 樣式

file

我們研究下antd的Tabs 組件,其 TabPane 也是通過 display 來控制顯隱的, 動態設置.ant-tabs-tabpane-hidden 類來切換。

可是它並沒有一次性就把所有 TabPane 渲染出來,active 過一次後再通過類名來做控制顯隱,且切換 tab後,除了第一次掛載會 render ,後續切換 tab 都不會 rerender 。

file

為了實現與 Tabs 一樣的效果,我們稍加改造 StyleKeepAlive 組件, 對傳入的 children 包裹一層 ShouldRender 組件,該組件實現初次掛載時只渲染當前激活的子組件, 且只有在組件激活時才會進行 rerender 。

const ShouldRender = ({ children, visible }: any) => {
    // 是否已經掛載
    const renderedRef = useRef(false);
    // 緩存子組件,避免不必要的渲染
    const childRef = useRef();
    
    if (visible) {
        renderedRef.current = true;
        childRef.current = children();
    } 

    if (!renderedRef.current) return null;
    
    return (
        <div
            style={{
                display: visible ? "block" : "none",
            }}
        >
            {childRef.current}
        </div>
    );
};

const StyleKeepAlive: React.FC<any> = ({children, showComponentName}) => {
    return (
        <>
            {React.Children.map(children, (child) => {
                const visible = child.props.name === showComponentName;
                return (
                    <ShouldRender visible={visible}>
                       {() => child}
                    </ShouldRender>
                );
            })}
        </>
    );
}

再來看看效果,我們實現了懶載入,但與antd 的 Tabs 不同的是, 父組件 render 時,我們對隱藏的子組件不會再進行 render , 這樣能很大程度的減少性能影響。

file

這種方式雖然通過很簡易的代碼就實現了我們需要的 KeepAlive 功能,但其仍需要保留 dom 元素,在某些大數據場景下可能存在性能問題,並且以下麵這種使用方法,會使開發者感覺到它是一次性渲染所有子組件。

<StyleKeepAlive showComponentName={componentName}>
      <Counter name="A" />
      <Counter name="B" />
</StyleKeepAlive>

// API可改寫成這種形式更加直觀, 且name也不再需要傳
<StyleKeepAlive active={isActive}>
      <Counter />
</StyleKeepAlive>
<StyleKeepAlive active={isActive}>
      <Counter />
</StyleKeepAlive>

Suspense 法

Suspense 內部使用了 OffScreen 組件,這是一個類似於 KeepAlive 的組件,如下圖所示,Suspense 的 children 會通過 OffScreen 包裹一層,因為 fallback 組件和 children 組件可能會多次進行切換。

file

既然 Offscreen 可以看成 React 內部的 KeepAlive 組件,那我們下麵深入研究下它的特性。

由於Offscreen 目前還是unstable狀態,我們安裝試驗性版本的 react 和 react-dom 可以去嘗試這個組件。

pnpm add react@experimental react-dom@experimental

在組件中導入,註意:Offscreen 在今年某個版本後統一更名為了 Activity 。更名後其實更能體現出 KeepAlive 激活與失活的狀態特性。

import { unstable_Activity as Offscreen } from "react";

Offscreen組件的使用方式也很簡單,只有一個參數 mode: “visible” | ”hidden”。

<Offscreen mode={counterName === "A" ? "visible" : "hidden"}>
    <Counter name="A" />
</Offscreen>
<Offscreen mode={counterName === "B" ? "visible" : "hidden"}>
    <Counter name="B" />
</Offscreen>

我們再看看實際的頁面效果:

file

第一次組件掛載時,竟然把應該隱藏的組件也渲染出來了,而且也是通過樣式來控制顯式隱藏的。

這乍看上去是不合理的,我們期望初次掛載時不要渲染失活的組件,否則類似於 Tabs 搭配數據請求的場景就不太適合了,我們不應該一次性請求所有 Tabs 中的數據。

但先別急,我們看看useEffect的執行情況,子組件中加入以下代碼debug:

console.log(`${name} rendered`)

useEffect(() => {
    console.log(`${name} mounted`)
    return () => {
        console.log(`${name} unmounted`)
    }
}, [])

file

我們可以觀察到,只有激活的組件A執行了 useEffect ,失活的組件B只是進行了一次pre-render 。

切換一次組件後,A組件卸載了,但是它最後又render了一次, 這是因為父組件中的 counterName更新了,導致子組件更新 。

file

我們得出結論:

通過 Offscreen 包裹的組件, useEffect 在每次激活時都會執行一次,且每次父組件更新都會導致其進行render。

雖然激活才會調用 useEffect 的機制解決了副作用會全部執行的問題,但對失活組件的pre-render 是否會造成性能影響?

進行下性能測試,對比使用常規 display 去實現的方法, 其中LongList 渲染20000條數據,且每條數據渲染依賴於參數 value, value 為受控組件控制,那麼當我們在父組件進行輸入時,是否會有卡頓呢?

const StyleKeepAliveNoPerf: React.FC<any> = ({children, showComponentName}) => {
    return (
        <>
            {React.Children.map(children, (child) => (
                <div
                    style={{
                        display: child.props.name === showComponentName ? "block" : "none",
                    }}
                >
                    {child}
                </div>
            ))}
        </>
    );
}

const LongList = ({value}: any) => {
    const [list] = useState(new Array(20000).fill(0))

    return (
        <ul style={{ height: 500, overflow: "auto" }}>
            {list.map((_, index) => (
                <li key={index}>{value}: {index}</li>
            ))}
        </ul>
    );
}

const PerformanceTest = () => {
    const [activeComponent, setActiveComponent] = useState('A');
    const [value, setValue] = useState('');

    return (
        <div className="card">
            <p>
                <button
                    onClick={() =>
                        setActiveComponent((val) => (val === "A" ? "B" : "A"))
                    }
                >
                    Toggle Counter
                </button>
            </p>
            <p>
                受控組件:
                <Input
                    value={value}
                    onChange={(e) => setValue(e.target.value)}
                />
            </p>
            <div>
                {/* 1. 直接使用display進行keep-alive */}
                <StyleKeepAliveNoPerf showComponentName={activeComponent}>
                    <Counter name="A" />
                    <LongList value={value} name="B" />
                </StyleKeepAliveNoPerf>

                {/* 2. 使用Offscreen */}
                <Offscreen mode={activeComponent === 'A' ? 'visible' : 'hidden'}>
                    <Counter name="A" />
                </Offscreen>
                <Offscreen mode={activeComponent === 'B' ? 'visible' : 'hidden'}>
                    <LongList value={value}/>
                </Offscreen>
            </div>
        </div>
    );
}

● 使用 StyleKeepAliveNoPerf

file

● 使用 Offscreen

file

我們可以看到,使用Offscreen 下幾乎沒有任何性能影響,且查看dom樹,即使失活的LongList組件也照樣被渲染出來了。

file

這樣看來,使用 Offscreen 不但不會有性能影響,還有 pre-render 帶來的某種意義上的性能提升。

這得益於React的 concurrent 模式,高優先順序的組件會打斷低優先順序的組件的更新,用戶輸入事件擁有著最高的優先順序,而 Offscreen 組件在失活時擁有著最低的優先順序。如下為 Lane 模型中的優先順序:

file

我們再與優化過的 StyleKeepAlive 組件比較,該組件對失活的組件不會進行 render,所以在進行輸入時也非常流暢,但當我們切換組件渲染 LongList 時,出現了明細的卡頓掉幀,畢竟需要重新 render 一個長列表。而 Offscreen 在進行組件切換時就顯得非常流暢了,只有 dispaly 改變時產生的重排導致的短暫卡頓感。

因此我們得出結論,使用Offscreen優於第一種Style方案。

由於該組件還是 unstable 的,我們無法直接在項目中使用,所以我們需要利用已經正式發佈的 Suspense 去實現 Offscreen 版的 KeepAlive 。

Suspense 需要讓子組件內部 throw 一個 Promise 錯誤來進行 children 與 fallback 間切換,那麼我們只需要在激活時渲染 children , 失活時 throw Promise ,就能快速的實現 KeepAlive 。

const Wrapper = ({children, active}: any) => {
    const resolveRef = useRef();

    if (active) {
        resolveRef.current && resolveRef.current();
        resolveRef.current = null;
    } else {
        throw new Promise((resolve) => {
           resolveRef.current = resolve;
        })
    }

    return children;
}

const OffscreenKeepAlive = ({children, active}: any) => {
    return <Suspense>
        <Wrapper active={active}>
            {children}
        </Wrapper>
    </Suspense>
}

我們來看看實際效果。

初次渲染情況:

file

切換組件後渲染情況:

file

這與直接使用 Offscreen 的效果並不一致。

· 初次渲染只會渲染當前激活的組件,這是因為 Suspense 會在 render 時就拋出錯誤,那麼當然不能把未激活的組件也 render 了

· 切換組件後,A組件的 useEffect 沒有觸發unmount , 也就是說,進行激活狀態切換不會再去重新執行 useEffect

· 切換組件後,A組件失活,但沒有進行render ,也就是說不會對失活的組件再進行渲染,也就是說沒有了 pre-render 的特性

這樣一來,雖然實現了 KeepAlive 功能,能夠實現與我們的 StyleKeepAlive 完全一致的效果,但丟失了 Offscreen 激活/失活的生命周期,pre-render 預渲染等優點。

接下來,我們為其添加生命周期,由於失活的組件會直接被 throw 出去,子組件中的 useEffect 卸載函數不會被執行,我們需要把兩個生命周期函數 useActiveEffect、useDeactiveEffect 中的回調註冊給上層組件才能實現, 通過 context 傳遞註冊函數。

const KeepAliveContext = React.createContext<{
    registerActiveEffect: (effectCallback) => void;
    registerDeactiveEffect: (effectCallback) => void;
}>({
    registerActiveEffect: () => void 0,
    registerDeactiveEffect: () => void 0,
});

export const useActiveEffect = (callback) => {
  const { registerActiveEffect } = useContext(KeepAliveContext);

  useEffect(() => {
    registerActiveEffect?.(callback);
  }, []);
};

export const useDeactiveEffect = (callback) => {
  const { registerDeactiveEffect } = useContext(KeepAliveContext);

  useEffect(() => {
    registerDeactiveEffect?.(callback);
  }, []);
};

我們在上層組件 KeepAlive 中對 effects 進行保存,並監聽 active 狀態的變化,以執行對應的生命周期函數。

const KeepAlive: React.FC<KeepAliveProps> = ({ active, children }) => {
  const activeEffects = useRef([]);
  const deactiveEffects = useRef([]);

  const registerActiveEffect = (callback) => {
    activeEffects.current.push(() => {
      callback();
    });
  };

  const registerDeactiveEffect = (callback) => {
    deactiveEffects.current.push(() => {
      callback();
    });
  };

  useEffect(() => {
    if (active) {
      activeEffects.current.forEach((effect) => {
        effect();
      });
    } else {
      deactiveEffects.current.forEach((effect) => {
        effect();
      });
    }
  }, [active]);

  return (
    <KeepAliveContext.Provider value={{ registerActiveEffect, registerDeactiveEffect }}>
      <Suspense fallback={null}>
        <Wrapper active={active}>{children}</Wrapper>
      </Suspense>
    </KeepAliveContext.Provider>
  );
};

至此,我們實現了一個相對比較完美的基於 Suspense 的 KeepAlive 組件。

DOM 移動法

由於組件的狀態保存的一個前提是該組件必須存在於 React組件樹 中,也就是說必須把這個組件 render 出來,但 render 並不是意味著這個組件會存在於DOM樹中,如 createPortal 能把某個組件渲染到任意一個DOM節點上,甚至是記憶體中的DOM節點。

那麼要實現 KeepAlive ,我們可以讓這個組件一直存在於 React組件樹 中,但不讓其存在於 DOM樹中。

社區中兩個 KeepAlive 實現使用最多的庫都使用了該方法,react-keep-alive, react-activation ,下麵以 react-activation 最簡單實現為例。完整實現見 react-activation:https://github.com/CJY0208/react-activation/

file

具體實現如下:

· 在某個不會被銷毀的父組件(比如根組件)上創建一個 state 用來保存所有需要 KeepAlive 的 children ,並通過 id 標識

· KeepAlive 組件會在首次掛載時將 children 傳遞給父組件

· 父組件接收到 children,保存至 state 觸發重新渲染,在父組件渲染所有KeepAlive children,得到真實DOM節點,將DOM節點移動至實際需要渲染的位置

· KeepAlive 組件失活時,組件銷毀,DOM節點也銷毀,但 children 是保存在父組件渲染的,所以狀態得以保存

· KeepAlive 再次激活時,父組件拿到緩存的 children,重新渲染一編,完成狀態切換

import { Component, createContext } from 'react'

const KeepAliveContext = createContext({});

const withScope = WrappedComponent => props => (
  <KeepAliveContext.Consumer>{keep => <WrappedComponent {...props} keep={keep} />}</KeepAliveContext.Consumer>
)

export class AliveScope extends Component<any> {
  nodes = {};
  state = {};

  keep = (id, children) => {
    return new Promise((resolve) =>
      this.setState(
        {
          [id]: { id, children },
        },
        () => resolve(this.nodes[id])
      )
    );
  };

  render() {
    return (
      <KeepAliveContext.Provider value={this.keep}>
        {this.props.children}
        <div className='keepers-store'>
          {Object.values(this.state).map(({ id, children }: any) => (
        <div
          key={id}
          ref={(node) => {
            this.nodes[id] = node;
          }}
          >
          {children}
        </div>
      ))}
        </div>

      </KeepAliveContext.Provider>
    );
  }
}

class ActivationKeepAlive extends Component {
  constructor(props) {
    super(props)
  }

  placeholder: HTMLElement | null = null;

  componentDidMount(): void {
    this.init(this.props)
  }

  init = async ({ id, children, keep }) => {
    // keep用於向父組件傳遞最新的children,並返回該children對應的DOM節點
    const realContent = await keep(id, children)
    // appendChild為剪切操作
    this.placeholder?.appendChild(realContent)
  }
  
  // 只渲染占位元素,不渲染children
  render() {
    return (
      <div
        className='keep-placeholder'
        ref={node => {
          this.placeholder = node
        }}
        />
    )
  }
}

export default withScope(ActivationKeepAlive)

  // 使用
<AliveScope>
  {counterName === "A" && (
    <ActivationKeepAlive id="A">
      <Counter name="A" />
    </ActivationKeepAlive>
  )}
  {counterName === "B" && (
  <ActivationKeepAlive id="B">
    <Counter name="B" />
  </ActivationKeepAlive>
  )}
</AliveScope>

組件樹如下,渲染在了 AliveScope 下,而非 ActivationKeepAlive 下。

file

雖然這種方法理論性可行,但實際上會有很多事情要處理,比如事件流會亂掉,父組件更新渲染也會有問題,因為children 實際渲染在 AliveScope 上, 要讓 AliveScope 重新渲染才會使 children 重新渲染。

在 react-activation 中,也還有部分問題有待解決,如果使用 createPortal 方案,也只是 AliveScope 中免去了移動 DOM 的操作(隱藏時渲染在空標簽下,顯示時渲染在占位節點下)。

《行業指標體系白皮書》下載地址:https://www.dtstack.com/resources/1057?src=szsm

《數棧產品白皮書》下載地址:https://www.dtstack.com/resources/1004?src=szsm

《數據治理行業實踐白皮書》下載地址:https://www.dtstack.com/resources/1001?src=szsm

想瞭解或咨詢更多有關大數據產品、行業解決方案、客戶案例的朋友,瀏覽袋鼠雲官網:https://www.dtstack.com/?src=szbky


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

-Advertisement-
Play Games
更多相關文章
  • 第十五章 按鍵中斷實驗 1)實驗平臺:正點原子DNK210開發板 2)章節摘自【正點原子】DNK210使用指南 - CanMV版 V1.0 3)購買鏈接:https://detail.tmall.com/item.htm?&id=782801398750 4)全套實驗源碼+手冊+視頻下載地址:htt ...
  • 使用場景: 文件數量統計,在科研場景中,更多是用於檢驗、核對數據集的樣本數量,防止數據遺漏等意外情況。 常用命令: ls -l | grep "^-" | wc -l 作用:統計當前目錄下,文件的個數(不包括目錄/文件夾) ls -lR | grep "^-" | wc -l 作用:統計當前目錄下, ...
  • 本章將和大家分享Linux系統中的Vim編輯器。廢話不多說,下麵我們直接進入主題。 一、Vim 入門幫助 Vim 帶有完整的幫助文檔:進入 Vim 後輸入“:help”即可訪問 二、Vim 模式介紹 與大部分其它編輯器不同,進入 Vim 後,預設狀態下鍵入的字元並不會插入到所編輯的文件之中。Vim ...
  • SPI是嵌入式中使用比較廣泛的協議之一,本文從該協議的原理入手對其進行了詳細介紹,並結合STM32F103ZET主控晶元對其進行了說明,最後給出了兩個實例代碼demo供大家做參考。 ...
  • 本節內容 因為risc-v存在硬體特權級機制,我們又要實現一個可以使得應用程式工作在用戶級,使得操作系統工作在特權級.原因是要保證用戶態的應用程式不能隨意使用內核態的指令,要使用內核態的指令就必須通過操作系統來執行,這樣有了操作系統的控制和檢查,程式不會因為應用程式的問題導致整個操作系統都運行錯誤. ...
  • 本章將和大家分享Linux中的許可權控制。廢話不多說,下麵我們直接進入主題。 一、基礎知識 Linux作為一種多用戶的操作系統(伺服器系統),允許多個用戶同時登陸到系統上,並響應每個用戶的請求。 任何需要使用操作系統的用戶,都需要一個系統賬號,賬號分為:管理員賬號與普通用戶賬號。 在Linux中,操作 ...
  • 在現代軟體開發和部署中,Docker容器已成為一種流行的技術。然而,隨著容器的廣泛使用,數據保護和遷移也變得至關重要。本文將詳細介紹如何備份和遷移Docker容器,確保你的應用和數據在任何時候都是安全的。 一、為什麼需要備份和遷移Docker容器? 在某些情況下,你可能需要備份和遷移Docker容器 ...
  • 書接上文,在一個正常的事務複製環境中,如果發生了資料庫還原,事務複製會不會出問題,出問題之後又如何恢復,如果在不刪除訂閱發佈重建的情況下,如何在現有基礎上修複事務複製的異常,這個問題可以分為兩部分看: 1,如果publisher資料庫發生了還原操作,事務複製會出現什麼異常,該如何恢復? 2,如果是s ...
一周排行
    -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.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...