如何優雅的使用 React Context
在開始今天的文章之前,大家不妨先想一下觸發 React
組件 re-render
的原因有哪些,或者說什麼時候 React
組件會發生 re-render
。
先說結論:
-
狀態變化
-
父組件
re-render
-
Context
變化 -
Hooks
變化
這裏有個誤解:
props
變化也會導致re-render
。
其實不會的,props
的變化往上追溯是因爲父組件的state
變化導致父組件re-render
,從而引起了子組件的re-render
,與props
是否變化無關的。只有那些使用了React.memo
和useMemo
的組件,props
的變化纔會觸發組件的re-render
。
針對上述造成 re-render
的原因,又該通過怎樣的策略優化呢?感興趣的朋友可以看這篇文章:React re-renders guide: everything, all at once。
接下來開始我們今天的主題:如何優雅的使用 React Context
。上面我們提到了 Context
的變化也會觸發組件的 re-render
,那 React Context
又是怎麼工作呢?先簡單介紹一下 Context
的工作原理。
Context 的工作原理
Context
是React
提供的一種直接訪問祖先節點上的狀態的方法,從而避免了多級組件層層傳遞props
的頻繁操作。
創建 Context
通過 React.createContext
創建 Context
對象
export function createContext(
defaultValue
) {
const context = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue,
_currentValue2: defaultValue,
_threadCount: 0,
Provider: (null: any),
Consumer: (null: any),
};
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
};
context.Consumer = context;
return context;
}
React.createContext
的核心邏輯:
-
將初始值存儲在
context._currentValue
-
創建
Context.Provider
和Context.Consumer
對應的ReactElement
對象
在 fiber
樹渲染時,通過不同的 workInProgress.tag
處理 Context.Provider
和 Context.Consumer
類型的節點。
主要看下針對 Context.Provider
的處理邏輯:
function updateContextProvider(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
const providerType = workInProgress.type;
const context = providerType._context;
const newProps = workInProgress.pendingProps;
const oldProps = workInProgress.memoizedProps;
const newValue = newProps.value;
pushProvider(workInProgress, context, newValue);
if (oldProps !== null) {
// 更新 context 的核心邏輯
}
const newChildren = newProps.children;
reconcileChildren(current, workInProgress, newChildren, renderLanes);
return workInProgress.child;
}
消費 Context
在 React
中提供了 3 種消費 Context
的方式
-
直接使用
Context.Consumer
組件(也就是上面createContext
時創建的Consumer
) -
類組件中,可以通過靜態屬性
contextType
消費Context
-
函數組件中,可以通過
useContext
消費Context
這三種方式內部都會調用 prepareToReadContext
和 readContext
處理 Context
。prepareToReadContext
中主要是重置全局變量爲readContext
做準備。
接下來主要看下readContext
:
export function readContext<T>(
context: ReactContext<T>,
observedBits: void | number | boolean,
): T {
const contextItem = {
context: ((context: any): ReactContext<mixed>),
observedBits: resolvedObservedBits,
next: null,
};
if (lastContextDependency === null) {
lastContextDependency = contextItem;
currentlyRenderingFiber.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
responders: null,
};
} else {
lastContextDependency = lastContextDependency.next = contextItem;
}
// 2. 返回 currentValue
return isPrimaryRenderer ? context._currentValue : context._currentValue2;
}
readContext
的核心邏輯:
-
構建
contextItem
並添加到workInProgress.dependencies
鏈表(contextItem
中保存了對當前context
的引用,這樣在後續更新時,就可以判斷當前fiber
是否依賴了context
,從而判斷是否需要re-render
) -
返回對應
context
的_currentValue
值
更新 Context
當觸發 Context.Provider
的 re-render
時,重新走 updateContextProvider
中更新的邏輯:
function updateContextProvider(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// ...
// 更新邏輯
if (oldProps !== null) {
const oldValue = oldProps.value;
if (is(oldValue, newValue)) {
// 1. value 未發生變化時,直接走 bailout 邏輯
if (
oldProps.children === newProps.children &&
!hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);
}
} else {
// 2. value 變更時,走更新邏輯
propagateContextChange(workInProgress, context, renderLanes);
}
//...
}
接下來看下 propagateContextChange
(核心邏輯在 propagateContextChange_eager
中) 的邏輯:
function propagateContextChange_eager < T > (
workInProgress: Fiber,
context: ReactContext < T > ,
renderLanes: Lanes,
): void {
let fiber = workInProgress.child;
if (fiber !== null) {
fiber.return = workInProgress;
}
// 從子節點開始匹配是否存在消費了當前 Context 的節點
while (fiber !== null) {
let nextFiber;
const list = fiber.dependencies;
if (list !== null) {
nextFiber = fiber.child;
let dependency = list.firstContext;
while (dependency !== null) {
// 1. 判斷 fiber 節點的 context 和當前 context 是否匹配
if (dependency.context === context) {
// 2. 匹配時,給當前節點調度一個更新任務
if (fiber.tag === ClassComponent) {}
fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, renderLanes);
}
// 3. 向上標記 childLanes
scheduleContextWorkOnParentPath(
fiber.return,
renderLanes,
workInProgress,
);
list.lanes = mergeLanes(list.lanes, renderLanes);
break;
}
dependency = dependency.next;
}
} else if (fiber.tag === ContextProvider) {} else if (fiber.tag === DehydratedFragment) {} else {}
// ...
fiber = nextFiber;
}
}
核心邏輯:
-
從
ContextProvider
的節點出發,向下查找所有fiber.dependencies
依賴當前Context
的節點 -
找到消費節點時,從當前節點出發,向上回溯標記父節點
fiber.childLanes
,標識其子節點需要更新,從而保證了所有消費了該Context
的子節點都會被重新渲染,實現了Context
的更新
總結
-
在消費階段,消費者通過
readContext
獲取最新狀態,並通過fiber.dependencies
關聯當前Context
-
在更新階段,從
ContextProvider
節點出發查找所有消費了該context
的節點
如何避免 Context 引起的 re-render
❝
從上面分析
Context
的整個工作流程,我們可以知道當ContextProvider
接收到value
變化時就會找到所有消費了該Context
的組件進行re-render
,若ContextProvider
的value
是一個對象時,即使沒有使用到發生變化的value
的組件也會造成多次不必要的re-render
。❞
那我們怎麼做優化呢?直接說方案:
-
將
ContextProvider
的值做memoize
處理 -
對數據和
API
做拆分(或者說是將getter
(state
)和setter
(API
)做拆分) -
對數據做拆分(細粒度拆分)
-
Context Selector
具體的 case
可參考上述提到的優化文章:React re-renders guide: everything, all at once。
接下來開始我們今天的重點:Context Selector
。開始之前先來個 case1:
import React, { useState } from "react";
const StateContext = React.createContext(null);
const StateProvider = ({ children }) => {
console.log("StateProvider render");
const [count1, setCount1] = useState(1);
const [count2, setCount2] = useState(1);
return (
<StateContext.Provider
value={{ count1, setCount1, count2, setCount2 }}>
{children}
</StateContext.Provider>
);
};
const Counter1 = () => {
console.log("count1 render");
const { count1, setCount1 } = React.useContext(StateContext);
return (
<>
<div>Count1: {count1}</div>
<button
onClick={() => setCount1((n) => n + 1)}>setCount1</button>
</>
);
};
const Counter2 = () => {
console.log("count2 render");
const { count2, setCount2 } = React.useContext(StateContext);
return (
<>
<div>Count2: {count2}</div>
<button onClick={() => setCount2((n) => n + 1)}>setCount2</button>
</>
);
};
const App = () => {
return (
<StateProvider>
<Counter1 />
<Counter2 />
</StateProvider>
);
};
export default App;
開發環境記得關閉 StrictMode 模式,否則每次
re-render
都會走兩遍。具體使用方式和 StrictMode 的意義可參考官方文檔。
通過上面的 case
,我們會發現在 count1
觸發更新時,即使 Counter2
沒有使用 count1
也會進行 re-render
。這是因爲 count1
的更新會引起 StateProvider
的 re-render
,從而會導致 StateProvider
的 value
生成全新的對象,觸發 ContextProvider
的 re-render
,找到當前 Context
的所有消費者進行 re-render
。
如何做到只有使用到 Context
的 value
改變才觸發組件的 re-render
呢?社區有一個對應的解決方案 dai-shi/use-context-selector: React useContextSelector hook in userland。
接下來我們改造一下上述的 case2:
import React, { useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
const context = createContext(null);
const Counter1 = () => {
const count1 = useContextSelector(context, v => v[0].count1);
const setState = useContextSelector(context, v => v[1]);
const increment = () => setState(s => ({
...s,
count1: s.count1 + 1,
}));
return (
<div>
<span>Count1: {count1}</span>
<button type="button" onClick={increment}>+1</button>
{Math.random()}
</div>
);
};
const Counter2 = () => {
const count2 = useContextSelector(context, v => v[0].count2);
const setState = useContextSelector(context, v => v[1]);
const increment = () => setState(s => ({
...s,
count2: s.count2 + 1,
}));
return (
<div>
<span>Count2: {count2}</span>
<button type="button" onClick={increment}>+1</button>
{Math.random()}
</div>
);
};
const StateProvider = ({ children }) => (
<context.Provider value={useState({ count1: 0, count2: 0 })}>
{children}
</context.Provider>
);
const App = () => (
<StateProvider>
<Counter1 />
<Counter2 />
</StateProvider>
);
export default App
這時候問題來了,不是說好精準渲染的嗎?怎麼還是都會進行
re-render
。
解決方案:將react
改爲v17
版本(v17 對應的 case3),後面我們再說具體原因(只想說好坑..)。
use-context-selector
接下來我們主要分析下 createContext
和 useContextSelector
都做了什麼(官方還有其他的 API ,感興趣的朋友可以自行查看,核心還是這兩個 API
)。
createContext
簡化一下,只看核心邏輯:
import { createElement, useLayoutEffect, useRef, createContext as createContextOrig } from 'react'
const CONTEXT_VALUE = Symbol();
const ORIGINAL_PROVIDER = Symbol();
const createProvider = (
ProviderOrig
) => {
const ContextProvider = ({ value, children }) => {
const valueRef = useRef(value);
const contextValue = useRef();
if (!contextValue.current) {
const listeners = new Set();
contextValue.current = {
[CONTEXT_VALUE]: {
/* "v"alue */ v: valueRef,
/* "l"isteners */ l: listeners,
},
};
}
useLayoutEffect(() => {
valueRef.current = value;
contextValue.current[CONTEXT_VALUE].l.forEach((listener) => {
listener({ v: value });
});
}, [value]);
return createElement(ProviderOrig, { value: contextValue.current }, children);
};
return ContextProvider;
};
export function createContext(defaultValue) {
const context = createContextOrig({
[CONTEXT_VALUE]: {
/* "v"alue */ v: { current: defaultValue },
/* "l"isteners */ l: new Set(),
},
});
context[ORIGINAL_PROVIDER] = context.Provider;
context.Provider = createProvider(context.Provider);
delete context.Consumer; // no support for Consumer
return context;
}
對原始的 createContext
包一層,同時爲了避免 value
的意外更新造成消費者的不必要 re-render
,將傳遞給原始的 createContext
的 value
通過 uesRef
進行存儲,這樣在 React
內部對比新舊 value
值時就不會再操作 re-render
(後續 value
改變後派發更新時就需要通過 listener
進行 re-render
了),最後返回包裹後的 createContext
給用戶使用。
useContextSelector
接下來看下簡化後的 useContextSelector
:
export function useContextSelector(context, selector) {
const contextValue = useContextOrig(context)[CONTEXT_VALUE];
const {
/* "v"alue */ v: { current: value },
/* "l"isteners */ l: listeners
} = contextValue;
const selected = selector(value);
const [state, dispatch] = useReducer(
(prev, action) => {
if ("v" in action) {
if (Object.is(prev[0], action.v)) {
return prev; // do not update
}
const nextSelected = selector(action.v);
if (Object.is(prev[1], nextSelected)) {
return prev; // do not update
}
return [action.v, nextSelected];
}
},
[value, selected]
);
useLayoutEffect(() => {
listeners.add(dispatch);
return () => {
listeners.delete(dispatch);
};
}, [listeners]);
return state[1];
}
核心邏輯:
-
每次渲染時,通過
selector
和value
獲取最新的selected
-
同時將
useReducer
對應的dispatch
添加到listeners
-
當
value
改變時,就會執行listeners
中收集到dispatch
函數,從而在觸發reducer
內部邏輯,通過對比value
和selected
是否有變化,來決定是否觸發當前組件的re-render
在 react v18 下的 bug
回到上面的 case
在 react v18
的表現和在原始 Context
的表現幾乎一樣,每次都會觸發所有消費者的 re-render
。再看 use-context-selector
內部是通過 useReducer
返回的 dispatch
函數派發組件更新的。
接下來再看下 useReducer
在 react v18
和 v17
版本到底有什麼不一樣呢?
看個簡單的 case
:
import React, { useReducer } from "react";
const initialState = 0;
const reducer = (state, action) => {
switch (action) {
case "increment":
return state;
default:
return state;
}
};
export const App = () => {
console.log("UseReducer Render");
const [count, dispatch] = useReducer(reducer, initialState);
return (
<div>
<div>Count = {count}</div>
<button onClick={() => dispatch("increment")}>Inacrement</button>
</div>
);
};
簡單描述下:多次點擊按鈕「Inacrement」
,在 react
的 v17
和 v18
版本分別會有什麼表現?
先說結論:
-
v17
:只有首次渲染會觸發App
組件的render
,後續點擊將不再觸發re-render
-
v18
:每次都會觸發App
組件的re-render
(即使狀態沒有實質性的變化也會觸發re-render
)
這就要說到【
eager state
策略】了,在React
內部針對多次觸發更新,而最後狀態並不會發生實質性變化的情況,組件是沒有必要渲染的,提前就可以中斷更新了。
也就是說 useReducer
內部是有做一定的性能優化的,而這優化會存在一些 bug
,最後 React
團隊也在 v18
後移除了該優化策略(注:useState
還是保留該優化),詳細可看該相關 PR Remove usereducer eager bailout。當然該 PR 在社區也存在一些討論(Bug: useReducer and same state in React 18),畢竟無實質性的狀態變更也會觸發 re-render
,對性能還是有一定影響的。
迴歸到 useContextSelector
,無優化版本的 useReducer
又是如何每次都觸發組件 re-render
呢?
具體原因:在上面 useReducer
中,是通過 Object.is
判斷 value
是否發生了實質性變化,若沒有,就返回舊的狀態,在 v17
有優化策略下,就不會再去調度更新任務了,而在 v18
沒有優化策略的情況下,每次都會調度新的更新任務,從而引發組件的 re-render
。
通過 useSyncExternalStore 優化
通過分析知道造成 re-render
的原因是使用了 useReducer
,那就不再依賴該 hook
,使用 react v18
新的 hook useSyncExternalStore 來實現 useContextSelector
(優化後的 case4)。
export function useContextSelector(context, selector) {
const contextValue = useContextOrig(context)[CONTEXT_VALUE];
const {
/* "v"alue */ v: { current: value },
/* "l"isteners */ l: listeners
} = contextValue;
const lastSnapshot = useRef(selector(value));
const subscribe = useCallback(
(callback) => {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
},
[listeners]
);
const getSnapshot = () => {
const {
/* "v"alue */ v: { current: value }
} = contextValue;
const nextSnapshot = selector(value);
lastSnapshot.current = nextSnapshot;
return nextSnapshot;
};
return useSyncExternalStore(subscribe, getSnapshot);
}
實現思路:
-
收集訂閱函數
subscribe
的callback
(即useSyncExternalStore
內部的handleStoreChange
) -
當
value
發生變化時,觸發listeners
收集到的callback
,也就是執行 handleStoreChange 函數,通過getSnapshot
獲取新舊值,並通過Object.is
進行對比,判斷當前組件是否需要更新,從而實現了useContextSelector
的精確更新
當然除了 useReducer
對應的性能問題,use-context-selector
還存在其他的性能,感興趣的朋友可以查看這篇文章從 0 實現 use-context-selector。同時,use-context-selector
也是存在一些限制,比如說不支持 Class
組件、不支持 Consumer
…
針對上述文章中,作者提到的問題二和問題三,個人認爲這並不是
use-context-selector
的問題,而是React
底層自身帶來的問題。
比如說:問題二,React
組件是否re-render
跟是否使用了狀態是沒有關係的,而是和是否觸發了更新狀態的dispatch
有關,如果一定要和狀態綁定一起,那不就是Vue
了嗎。
對於問題三,同樣是React
底層的優化策略處理並沒有做到極致這樣。
總結
回到 React Context
工作原理來看,只要有消費者訂閱了該 Context
,在該 Context
發生變化時就會觸達所有的消費者。也就是說整個工作流程都是以 Context
爲中心的,那隻要把 Context
拆分的粒度足夠小就不會帶來額外的渲染負擔。但是這樣又會帶來其他問題:ContextProvider
會嵌套多層,同時對於粒度的把握對開發者來說又會帶來一定的心智負擔。
從另一條路出發:Selector
機制,通過選擇需要的狀態從而規避掉無關的狀態改變時帶來的渲染開銷。除了社區提到的 use-context-selector ,React
團隊也有一個相應的 RFC
方案 RFC: Context selectors,不過這個 RFC
從 19 年開始目前還處於持續更新階段。
最後,對於 React Context
的使用,個人推薦:「不頻繁更改的全局狀態(比如說:自定義主題、賬戶信息、權限信息等)可以合理使用 Context
,而對於其他頻繁修改的全局狀態可以通過其他數據流方式維護,可以更好的避免不必要的 re-render
開銷」。
參考
-
https://www.developerway.com/posts/react-re-renders-guide
-
https://react.dev/reference/react/StrictMode#enabling-strict-mode-for-entire-app
-
https://github.com/dai-shi/use-context-selector
-
https://github.com/facebook/react/pull/22445
-
https://github.com/facebook/react/issues/24596
-
https://react.dev/reference/react/useSyncExternalStore
-
https://juejin.cn/post/7197972831795380279
-
https://github.com/reactjs/rfcs/pull/119
-
case1:https://codesandbox.io/s/serverless-frost-9ryw2x?file=/src/App.js
-
case2:https://codesandbox.io/s/use-context-selector-vvs93q?file=/src/App.js
-
case3:https://codesandbox.io/s/elegant-montalcini-nkrvlh?file=/src/App.js
-
case4:https://codesandbox.io/s/use-context-selector-smsft3?file=/src/App.js
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/mQyl3baPRvEI_34kT1Us_g