React Context,一体化
你需要了解的关于 React Context API 的一切:基础、优化、最佳实践、测试以及未来发展。所有知识点汇集一处,尽在一处。
React Context 有什么用处?
✔️ 简单的依赖注入机制,避免了臭名昭著的prop 钻孔。✔️
无需第三方库,React Context 与 React 集成,并且此 API 肯定会在未来更新并带来许多改进。✔️
当你可以拆分状态以便它们可以被 React 组件树访问时(例如主题、身份验证、i18n 等),这是理想的选择
。❌ 它不是一个全局状态管理工具。你通过useState
或管理你的状态useReducer
。❌
如果你的应用程序状态经常更新,Context 不是最佳解决方案。❌
如果你需要复杂的功能(例如副作用、持久性和数据序列化),则不合适。❌
由于你没有“Redux DevTools”(包括操作历史记录),因此调试效果更差。❌
你必须正确实现它以避免优化泄漏。React 在这方面无法帮助你。这篇文章可以。
React Context 使用示例
让我们直接从一些代码开始来了解:
- 如何创建上下文。
- 如何创建提供上下文值的提供者。
- 如何创建将使用上下文值的消费者组件。
// index.jsx
ReactDOM.render(
<MyProvider>
<MyEntireApp/>
</MyProvider>,
document.getElementById('root'),
)
// myContext.js
import { createContext } from 'react'
// Creating the Context
const MyContext = createContext()
export default MyContext
// MyProvider.jsx
const MyProvider = ({ children }) => {
const [state, setState] = useState({})
const fetch = async () => {
// Fetching some data
setState({ ... })
}
useEffect(() => {
fetch()
}, [])
// Providing a value
return (
<MyContext.Provider value={{state, setState}}>
{children}
</MyContext.Provider>
)
}
// FunctionalComponent.jsx
const Consumer = () => {
// Consuming the Context
const myContext = useContext(MyContext)
return (
// Here we can access to the context state
)
}
// ClassComponent.jsx
class Consumer {
constructor () { ... }
render () {
// Consuming the Context
<MyContext.Consumer>
{(myContext) => (
// Here we can access to the context state
)}
</MyContext.Consumer>
}
}
⚠️当
<MyContext.Provider>
组件上方最近的组件更新时,React.useContext(...)
将触发重新渲染,并将最新的上下文值传递给该 MyContext 提供程序。即使祖先组件使用了React.memo
或shouldComponentUpdate
,重新渲染仍将从使用 的组件本身开始进行useContext
。调用 useContext 的组件在上下文值发生变化时总会重新渲染。如果重新渲染组件的开销很大,可以使用 memoization 进行优化。
https://reactjs.org/docs/hooks-reference.html#usecontext
传递给的初始值会发生什么React.createContext(...)
?
在上面的例子中,我们将其undefined
作为初始上下文值传递,但同时我们在提供程序中覆盖它:
const MyContext = createContext()
<MyContext.Provider value={{state, setState}}>
{children}
</MyContext.Provider>
如果组件树中没有任何高于其自身的提供者,则消费者将接收createContext
默认值( undefined )。
const Root = () => {
// ⚠️ Here we will get an error since we cannot
// destructure `state` from `undefined`.
const { state } = useContext(MyContext)
return <div>{state}</div>
}
ReactDOM.render(<Root />, document.getElementById('root'))
在我们的例子中,由于我们的 Provider 封装了整个应用程序,因此消费者始终会拥有一个上层 Provider(参见index.js
)。实现一个自定义钩子来使用我们的 Context 可能是一个很酷的想法,它可以提高代码的可读性,抽象 的使用useContext
,并在 Context 使用不当时抛出错误(记住,快速失败)。
// MyProvider.jsx
const MyProvider = ({ children }) => {
const [state, setState] = useState([])
// Provider stuff...
<MyContext.Provider value={{state, setState}}>
{children}
</MyContext.Provider>
}
// For Hooks
const useMyCtx = () => {
const context = useContext(MyContext)
if (context === undefined) {
throw new Error('useMyCtx must be used withing a Provider')
}
return context
}
// For Classes
const ContextConsumer = ({ children }) => {
return (
<MyContext.Consumer>
{context => {
if (context === undefined) {
throw new Error('ContextConsumer must be used
within a Provider')
}
return children(context)
}}
</MyContext.Consumer>
)
}
export { MyProvider, useMyCtx, ContextConsumer }
带钩
// FunctionalComponent.jsx
const Consumer = () => {
const context = useMyCtx()
}
带课程
// ClassComponent.jsx
class Consumer extends Component {
constructor() { ... }
render() {
return <ContextConsumer>
{context => // Here we can access to the context state }
</ContextConsumer>
}
}
如果提供程序状态发生变化,我的整个应用程序是否会重新渲染?
取决于您如何实现您的提供程序:
// ❌ Bad
// When the provider's state changes, React translates the rendering
// of <MyEntireApp/> as follows:
// React.creatElement(MyEntireApp, ...),
// rendering it as a new reference.
// ⚠️ No two values of the provider’s children will ever be equal,
// so the children will be re-rendered on each state change.
const Root = () => {
const [state, setState] = useState()
<MyContext.Provider value={{state, setState}>
<MyEntireApp />
</MyContext.Provider>
}
// ✔️ Good
// When the provider's state changes, the children prop
// stays the same so <MyEntireApp/> is not re-rendering.
// `children` prop share reference equality with its previous
// `children` prop.
const MyProvider = ({ children }) => {
const [state, setState] = useState()
<MyContext.Provider value={{state, setState}}>
{children}
</MyContext.Provider>
}
const Root = () => {
<MyProvider>
<MyEntireApp />
</MyProvider>
}
我可以将全局状态存储在一个上下文中吗?
不。嗯,是的,但你不应该这么做。原因很简单,考虑以下全局状态:
{
auth: {...}
translations: {...}
theme: {...}
}
⚠️如果一个组件只使用theme
,即使另一个状态属性发生变化,它仍然会被重新渲染。
// FunctionalComponent.jsx
// This component will be re-rendered when `MyContext`'s
// value changes, even if it is not the `theme`.
const Consumer = () => {
const { theme } = useContext(MyContext)
render <ExpensiveTree theme={theme} />
}
你应该把这个状态拆分成几个 Contexts。像这样:
// index.jsx
// ❌ Bad
ReactDOM.render(
<GlobalProvider>
<MyEntireApp/>
</GlobalProvider>,
document.getElementById('root'),
)
// ✔️ Good
ReactDOM.render(
<AuthProvider>
<TranslationsProvider>
<ThemeProvider>
<MyEntireApp/>
</ThemeProvider>
</TranslationsProvider>
</AuthProvider>,
document.getElementById('root'),
)
正如您所见,这可能会以无尽的箭头组件结束,因此一个好的做法是将其拆分为两个文件:
// ProvidersWrapper.jsx
// This `ProvidersWrapper.jsx` can help you implementing testing
// at the same time.
const ProvidersWrapper = ({ children }) => (
<AuthProvider>
<TranslationsProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</TranslationsProvider>
</AuthProvider>
)
// index.jsx
ReactDOM.render(
<ProvidersWrapper>
<MyEntireApp/>
</ProvidersWrapper>,
document.getElementById('root'),
)
通过这样做,每个消费者应该只使用它所需要的东西。
拆分上下文的替代方案
除了拆分上下文之外,我们还可以应用以下技术,以便<ExpensiveTree />
在未使用的属性发生变化时不重新渲染:
1. 将消费者一分为二memo
。
// FunctionalComponent.jsx
const Consumer = () => {
const { theme } = useContext(MyContext)
return <ThemeConsumer theme={theme} />
}
const ThemeConsumer = memo(({ theme }) => {
// The rest of your rendering logic
return <ExpensiveTree theme={theme} />
})
一种高级的实现是创建一个具有自定义函数的HOCconnect(...)
,如下所示:
const connect = (MyComponent, select) => {
return function (props) {
const selectors = select();
return <WrappedComponent {...selectors} {...props}/>
}
}
import connect from 'path/to/connect'
const MyComponent = React.memo(({
somePropFromContext,
otherPropFromContext,
someRegularPropNotFromContext
}) => {
... // regular component logic
return(
... // regular component return
)
});
const select = () => {
const { someSelector, otherSelector } = useContext(MyContext);
return {
somePropFromContext: someSelector,
otherPropFromContext: otherSelector,
}
}
export default connect(MyComponent, select)
来源:https ://github.com/reactjs/rfcs/pull/119#issuecomment-547608494
然而,这违背了 React Context 的本质,并且不能解决主要问题:包装组件的 HOC 仍然会尝试重新渲染,一次更新可能会有多个 HOC,从而导致昂贵的操作。
useMemo
2. 一个带内部的组件
const Consumer = () => {
const { theme } = useContext(MyContext)
return useMemo(() => {
// The rest of your rendering logic
return <ExpensiveTree theme={theme} />
}, [theme])
}
3. 第三方 React 跟踪
在 v1.6.0 之前,React Tracked 是一个用于替代 React Context 用例来处理全局状态的库。每当状态对象的一小部分发生更改时,React hook useContext 都会触发重新渲染,这很容易导致性能问题。React Tracked 提供的 API 与 useContext 风格的全局状态非常相似。
const useValue = () => useState({
count: 0,
text: 'hello',
})
const { Provider, useTracked } = createContainer(useValue)
const Consumer = () => {
const [state, setState] = useTracked()
const increment = () => {
setState((prev) => ({
...prev,
count: prev.count + 1,
})
}
return (
<div>
<span>Count: {state.count}</span>
<button type="button" onClick={increment}>+1</button>
</div>
)
}
useTracked 钩子返回一个 useValue 返回的元组,不同之处在于第一个部分是代理包装的状态,而第二部分是出于某种原因而包装的函数。
由于代理,渲染中的属性访问被跟踪,并且只有当 state.count 发生变化时,该组件才会重新渲染。
https://github.com/dai-shi/react-tracked
我是否需要记住我的提供商值或我的组件?
这得看情况。除了我们刚才看到的情况之外……你的 Provider 上是否有一个父级组件,它可以通过更新来强制 React 自然地重新渲染子级组件?
// ⚠️ If Parent can be updated (via setState() or even via
// a grandparent) we must be careful since everything
// will be re-rendered.
const Parent = () => {
const [state, setState] = useState()
// Stuff that forces a re-rendering...
return (
<Parent>
<MyProvider>
<MyEntireApp/>
</MyProvider>
</Parent>
)
}
如果是这样,是的。您必须按如下方式同时记住提供程序和组件:
// MyProvider.jsx
const MyProvider = ({ children }) => {
const [state, setState] = useState({})
// With `useMemo` we avoid the creation of a new object reference
const value = useMemo(
() => ({
state,
setState,
}),
[state]
)
<MyContext.Provider value={value}>
{children}
</MyContext.Provider>
}
// FunctionalComponent.jsx
// With `memo` we avoid the re-rendering if props didn't change
// Context value didn't change neither thanks to the previous
// `useMemo`.
const Consumer = memo((props) => {
const myContext = useContext(MyContext)
})
但这不太可能,正如我们之前看到的那样,您总是希望用提供程序包装整个应用程序。
ReactDOM.render(
<MyProvider>
<MyEntireApp/>
</MyProvider>,
document.getElementById('root'),
)
将 Context 拆分为两个stateContext
:setStateContext
出于我们之前讨论过的同样的原因:
⚠️仅更改 Context 状态(通过使用setState
或dispatch
)的消费者将在执行更新且值发生变化后重新渲染。
这就是为什么将上下文分成两部分是一个好主意,如下所示:
const CountStateContext = createContext()
const CountUpdaterContext = createContext()
const Provider = () => {
const [count, setCount] = usetState(0)
// We memoize the setCount in order to do not create a new
// reference once `count` changes. An alternative would be
// passing directly the setCount function (without
// implementation) via the provider's value or implementing its
// behaviour in our custom hook.
const memoSetCount = useCallback(() => setCount((c) => c + 1), [
setCount,
])
return (
<CountStateContext.Provider value={count}>
<CountUpdaterContext.Provider value={memoSetCount}>
{props.children}
</CountUpdaterContext.Provider>
</CountStateContext.Provider>
)
}
const useCountState() {
const countStateCtx = useContext(StateContext)
if (typeof countStateCtx === 'undefined') {
throw new Error('useCountState must be used within a Provider')
}
return countStateCtx
}
function useCountUpdater() {
const countUpdaterCtx = useContext(CountUpdaterContext)
if (typeof countUpdaterCtx === 'undefined') {
throw new Error('useCountUpdater must be used within a Provider')
}
// We could here implement setCount to avoid the previous useCallback
// const setCount = () => countUpdaterCtx((c) => c + 1)
// return setCount
return countUpdaterCtx
}
// CountConsumer.jsx
// This component will re-render if count changes.
const CountDisplay = () => {
const count = useCountState()
return (
<>
{`The current count is ${count}. `}
</>
)
})
// CountDispatcher.jsx
// This component will not re-render if count changes.
const CounterDispatcher = () => {
const countUpdater = useCountUpdater()
return (
<button onClick={countUpdater}>Increment count</button>
)
}
同时使用状态和更新程序的组件必须像这样导入它们:
const state = useCountState()
const dispatch = useCountDispatch()
您可以通过一个函数导出它们,useCount
如下所示:
const useCount = () => {
return [useCountState(), useCountDispatch()]
}
那使用呢useReducer
?我需要把我们讨论的所有内容都算进去吗?
当然。使用useReducer
钩子的唯一区别在于,现在你不再需要用它setState
来处理状态了。
⚠️请记住,React Context 不管理状态,您可以通过useState
或 来管理useReducer
。
可能的优化泄漏与我们在本文中讨论的一样。
React Context 与 Redux
让我给你链接一篇很棒的文章,由 Redux 维护者 Mark "acemarke" Erikson 撰写:
Context 和 Redux 是一回事吗?
不是。它们是不同的工具,做不同的事情,你可以用它们来实现不同的目的。Context 是一个“状态管理”工具吗?
不是。Context 是一种依赖注入的形式。它是一种传输机制——它不“管理”任何东西。任何“状态管理”都由你和你自己的代码完成,通常通过 useState/useReducer 来实现。Context 和 useReducer 能替代 Redux 吗?
不能。它们有一些相似之处和重叠之处,但在功能上存在很大差异。什么时候应该使用 Context?
任何时候,当你想让某些值在 React 组件树的某个部分中访问,而不想将该值作为 props 传递到各个组件层级时。什么时候应该使用 Context 和 useReducer?
当你在应用程序的特定部分中需要管理中等复杂的 React 组件状态时。什么时候应该使用 Redux?
Redux 在以下情况下最有用:
- 您拥有大量应用程序状态,这些状态在应用程序的许多地方都需要。
- 应用程序状态会随着时间的推移而频繁更新。
- 更新该状态的逻辑可能很复杂。
- 该应用程序具有中型或大型代码库,可能需要许多人参与开发。
- 您希望能够了解应用程序状态何时、为何以及如何更新,并将状态随时间的变化可视化。
- 您需要更强大的功能来管理副作用、持久性和数据序列化。
https://blog.isquaredsoftware.com/2021/01/context-redux-differences/#context-and-usereducer
测试
让我们测试以下情况:我们有一个提供者,它异步获取一些文章,以便让我们的同伴消费者可以使用它们。
我们将使用以下模拟:
[
{
"id": 1,
"title": "Article1",
"description": "Description1"
},
{
"id": 2,
"title": "Article2",
"description": "Description2"
}
]
// ArticlesProvider.jsx
const ArticlesProvider = ({ children }) => {
const [articles, setArticles] = useState([])
const fetchArticles = async () => {
const articles = await ArticlesService.get('/api/articles')
setArticles(articles)
}
useEffect(() => {
fetchArticles()
}, [])
return (
<ArticlesContext.Provider value={{ articles, setArticles }}>
{children}
</ArticlesContext.Provider>
)
}
const useArticles = () => {
const articlesCtx = useContext(ArticlesContext)
if (typeof articlesCtx === "undefined") {
throw new Error("articlesCtx must be used within a Provider")
}
return articlesCtx
}
export { ArticlesProvider, useArticles }
// ArticlesProvider.spec.jsx
describe("ArticlesProvider", () => {
const noContextAvailable = "No context available."
const contextAvailable = "Articles context available."
const articlesPromise = new Promise((resolve) => resolve(articlesMock))
ArticlesService.get = jest.fn(() => articlesPromise)
// ❌ This code fragment is extracted directly from Testing Library
// documentation but I don't really like it, since here we are
// testing the `<ArticlesContext.Provider>` functionality, not
// our `ArticlesProvider`.
const renderWithProvider = (ui, { providerProps, ...renderOptions }) => {
return render(
<ArticlesContext.Provider {...providerProps}>
{ui}
</ArticlesContext.Provider>,
renderOptions
)
}
// ✔️ Now we are good to go, we test what our Consumers will actually use.
const renderWithProvider = (ui, { ...renderOptions }) => {
return render(<ArticlesProvider>{ui}</ArticlesProvider>, renderOptions)
}
// ⚠️ We mock a Consumer in order to test our Provider.
const ArticlesComsumerMock = (
<ArticlesContext.Consumer>
{(articlesCtx) => articlesCtx ? (
articlesCtx.articles.length > 0 &&
articlesCtx.setArticles instanceof Function && (
<span>{contextAvailable}</span>
)
) : (
<span>{noContextAvailable}</span>
)
}
</ArticlesContext.Consumer>
)
it("should no render any articles if no provider is found", () => {
render(ArticlesComsumerMock)
expect(screen.getByText(noContextAvailable)).toBeInTheDocument()
})
it("should render the articles are available", async () => {
renderWithProvider(ArticlesComsumerMock)
await waitFor(() => {
expect(screen.getByText(contextAvailable)).toBeInTheDocument()
})
})
})
是时候测试我们的消费者了:
// Articles.jsx
const Articles = () => {
const { articles } = useArticles()
return (
<>
<h2>List of Articles</h2>
{articles.map((article) => (
<p>{article.title}</p>
))}
</>
)
}
// Articles.spec.jsx
describe("Articles", () => {
const articlesPromise = new Promise((resolve) => resolve(articlesMock))
ArticlesService.get = jest.fn(() => articlesPromise)
const renderWithProvider = (ui, { ...renderOptions }) => {
return render(<ArticlesProvider>{ui}</ArticlesProvider>, renderOptions)
}
it("should render the articles list", async () => {
renderWithProvider(<Articles />)
await waitFor(() => {
expect(screen.getByText("List of Articles")).toBeInTheDocument()
})
articlesMock.forEach((article) => {
expect(screen.getByText(article.title)).toBeInTheDocument()
})
})
})
不稳定特征:观察到的位
// react/index.d.ts
function useContext<T>(context: Context<T>/*, (not public API) observedBits?: number|boolean */): T;
observedBits
是一个隐藏的实验特征,代表了上下文值发生了哪些变化。
我们可以通过计算哪些位发生了变化并告诉我们的组件观察我们正在使用的位来防止全局状态下不必要的重新渲染。
// globalContext.js
import { createContext } from 'react';
const store = {
// The bit we want to observe
observedBits: {
theme: 0b001,
authentified: 0b010,
translations: 0b100
},
initialState: {
theme: 'dark',
authentified: false,
translations: {}
}
};
const getChangedBits = (prev, next) => {
let result = 0;
// ⚠️ With `result OR bits[key]` we calculate the total bits
// that changed, if only `theme` changed we will get 0b001,
// if the three values changed we will get: 0b111.
Object.entries(prev.state).forEach(([key, value]) => {
if (value !== next.state[key]) {
result = result | store.observedBits[key];
}
});
return result;
};
const GlobalContext = createContext(undefined, getChangedBits);
export { GlobalContext, store };
// Theme.jsx
const Theme = () => {
console.log('Re-render <Theme />');
// ⚠️ No matter if the state changes, this component will only
// re-render if the theme is updated
const { state } = useContext(GlobalContext, store.observedBits.theme);
return <p>Current theme: {state.theme}</p>;
};
请记住,这是一个不稳定的功能,您最多只能观察 30 个值(MaxInt.js),并且会在控制台中收到警告 :P。我更喜欢拆分上下文,以便将必要的 props 传递给应用程序树,遵循 React Context 的初始特性,同时等待更新。
您可以在此处找到带有功能性游乐场的完整演示:https://stackblitz.com/edit/react-jtb3lv
未来
已经有一些提案来实现这个selector
概念,以便让 React 管理这些优化(如果我们只是在全局状态中观察一个值):
const context = useContextSelector(Context, c => c.selectedField)
https://github.com/facebook/react/pull/20646
参考书目
到目前为止,我读过的一些有趣的文章/评论帮助我把所有的东西整合在一起,包括一些用于重新渲染的 stackblitz:
- 使用 React context 避免不必要的渲染- James K Nelson
- Context API 中的 useMemo - React - Agney Menon
- 使用 React context 防止额外重新渲染的 4 个选项- Daishi Kato
- 如何有效地使用 React Context - Kent C. Dodds
- 如何优化你的上下文价值- Kent C. Dodds
- React Context:隐藏的力量- Alex Khismatulin
- 为什么 React Context 不是一个“状态管理”工具(以及为什么它不能取代 Redux) - Mark Erikson
- 使用 React.memo 和 useContext hook 防止重新渲染- Dan Abramov
- RFC:上下文选择器- Pedro Bern
关键点
React.memo
当组件上方最近的 Provider 更新时,即使祖先使用或,该组件也会触发重新渲染shouldComponentUpdate
。React.createContext(...)
如果组件树中没有任何高于其自身的提供者,则消费者将接收默认值。- 为了避免重新渲染整个应用程序(或使用
memo
),提供者必须接收children
作为道具以保持引用相等。 - 如果您实现了全局提供者,那么无论更新什么属性,消费者总是会触发重新渲染。
- 如果父级组件可以更新(通过 setState() 甚至通过祖父级组件),我们必须小心,因为所有内容都会重新渲染。我们必须同时记录提供者和消费者的更新。
- 仅更改 Context 状态(通过使用
setState
或dispatch
)的消费者将在执行更新并且值发生更改后重新渲染,因此建议将该 Context 分为两部分:StateContext 和 DispatchContext。 - 请记住,React Context 不管理状态,您可以通过
useState
或 来管理useReducer
。 - 实现自定义模拟以便正确测试您的提供程序,
<Context.Provider {...props} />
这不是您的组件将直接使用的。 observedBits
是一个隐藏的实验性功能,它可以帮助我们实现全局状态,避免不必要的重新渲染。
就是这样,希望你喜欢它!
文章来源:https://dev.to/javmurillo/react-context-all-in-one-54ck