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 樣式
我們研究下antd的Tabs 元件,其 TabPane 也是透過 display 來控制顯隱的, 動態設定.ant-tabs-tabpane-hidden 類來切換。
可是它並沒有一次性就把所有 TabPane 渲染出來,active 過一次後再透過類名來做控制顯隱,且切換 tab後,除了第一次掛載會 render ,後續切換 tab 都不會 rerender 。
為了實現與 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 , 這樣能很大程度的減少效能影響。
這種方式雖然透過很簡易的程式碼就實現了我們需要的 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 元件可能會多次進行切換。
既然 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>
我們再看看實際的頁面效果:
第一次元件掛載時,竟然把應該隱藏的元件也渲染出來了,而且也是透過樣式來控制顯式隱藏的。
這乍看上去是不合理的,我們期望初次掛載時不要渲染失活的元件,否則類似於 Tabs 搭配資料請求的場景就不太適合了,我們不應該一次性請求所有 Tabs 中的資料。
但先別急,我們看看useEffect的執行情況,子元件中加入以下程式碼debug:
console.log(`${name} rendered`)
useEffect(() => {
console.log(`${name} mounted`)
return () => {
console.log(`${name} unmounted`)
}
}, [])
我們可以觀察到,只有啟用的元件A執行了 useEffect ,失活的元件B只是進行了一次pre-render 。
切換一次元件後,A元件解除安裝了,但是它最後又render了一次, 這是因為父元件中的 counterName更新了,導致子元件更新 。
我們得出結論:
透過 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
● 使用 Offscreen
我們可以看到,使用Offscreen 下幾乎沒有任何效能影響,且檢視dom樹,即使失活的LongList元件也照樣被渲染出來了。
這樣看來,使用 Offscreen 不但不會有效能影響,還有 pre-render 帶來的某種意義上的效能提升。
這得益於React的 concurrent 模式,高優先順序的元件會打斷低優先順序的元件的更新,使用者輸入事件擁有著最高的優先順序,而 Offscreen 元件在失活時擁有著最低的優先順序。如下為 Lane 模型中的優先順序:
我們再與最佳化過的 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>
}
我們來看看實際效果。
初次渲染情況:
切換元件後渲染情況:
這與直接使用 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/
具體實現如下:
· 在某個不會被銷燬的父元件(比如根元件)上建立一個 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 下。
雖然這種方法理論性可行,但實際上會有很多事情要處理,比如事件流會亂掉,父元件更新渲染也會有問題,因為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