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 樣式
我們研究下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