React 的 KeepAlive 實戰指南:深度解析元件快取機制

袋鼠云数栈發表於2024-07-29

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

相關文章