React Context,一体化

2025-06-07

React Context,一体化

你需要了解的关于 React Context API 的一切:基础、优化、最佳实践、测试以及未来发展。所有知识点汇集一处,尽在一处。

React Context 有什么用处?

✔️ 简单的依赖注入机制,避免了臭名昭著的prop 钻孔。✔️
无需第三方库,React Context 与 React 集成,并且此 API 肯定会在未来更新并带来许多改进。✔️
当你可以拆分状态以便它们可以被 React 组件树访问时(例如主题、身份验证、i18n 等),这是理想的选择
。❌ 它不是一个全局状态管理工具。你通过useState或管理你的状态useReducer。❌
如果你的应用程序状态经常更新,Context 不是最佳解决方案。❌
如果你需要复杂的功能(例如副作用、持久性和数据序列化),则不合适。❌
由于你没有“Redux DevTools”(包括操作历史记录),因此调试效果更差。❌
你必须正确实现它以避免优化泄漏。React 在这方面无法帮助你。这篇文章可以。

React Context 使用示例

让我们直接从一些代码开始来了解:

  1. 如何创建上下文。
  2. 如何创建提供上下文值的提供者。
  3. 如何创建将使用上下文值的消费者组件。
// index.jsx
ReactDOM.render(
  <MyProvider>
    <MyEntireApp/>
  </MyProvider>,
  document.getElementById('root'),
)
Enter fullscreen mode Exit fullscreen mode
// myContext.js
import { createContext } from 'react'

// Creating the Context
const MyContext = createContext()

export default MyContext
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode
// FunctionalComponent.jsx
const Consumer = () => {
  // Consuming the Context
  const myContext = useContext(MyContext)

  return (
    // Here we can access to the context state
  )
}
Enter fullscreen mode Exit fullscreen mode
// ClassComponent.jsx
class Consumer {
  constructor () { ... }

  render () {
    // Consuming the Context
    <MyContext.Consumer>
      {(myContext) => (
        // Here we can access to the context state
      )}
    </MyContext.Consumer>
  }
}
Enter fullscreen mode Exit fullscreen mode

⚠️<MyContext.Provider>组件上方最近的组件更新时,React.useContext(...)将触发重新渲染,并将最新的上下文值传递给该 MyContext 提供程序。即使祖先组件使用了React.memoshouldComponentUpdate,重新渲染仍将从使用 的组件本身开始进行useContext

调用 useContext 的组件在上下文值发生变化时总会重新渲染。如果重新渲染组件的开销很大,可以使用 memoization 进行优化。

https://reactjs.org/docs/hooks-reference.html#usecontext

传递给的初始值会发生什么React.createContext(...)

在上面的例子中,我们将其undefined作为初始上下文值传递,但同时我们在提供程序中覆盖它:

const MyContext = createContext()
Enter fullscreen mode Exit fullscreen mode
<MyContext.Provider value={{state, setState}}>
  {children}
</MyContext.Provider>
Enter fullscreen mode Exit fullscreen mode

如果组件树中没有任何高于其自身的提供者,则消费者将接收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'))
Enter fullscreen mode Exit fullscreen mode

在我们的例子中,由于我们的 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 }
Enter fullscreen mode Exit fullscreen mode

带钩

// FunctionalComponent.jsx
const Consumer = () => {
  const context = useMyCtx()
}
Enter fullscreen mode Exit fullscreen mode

带课程

// ClassComponent.jsx
class Consumer extends Component {
  constructor() { ... }

  render() {
    return <ContextConsumer>
      {context => // Here we can access to the context state }
      </ContextConsumer>
  }
}
Enter fullscreen mode Exit fullscreen mode

如果提供程序状态发生变化,我的整个应用程序是否会重新渲染?

取决于您如何实现您的提供程序:

// ❌ 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>
}
Enter fullscreen mode Exit fullscreen mode
// ✔️ 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>
}
Enter fullscreen mode Exit fullscreen mode

我可以将全局状态存储在一个上下文中吗?

不。嗯,是的,但你不应该这么做。原因很简单,考虑以下全局状态:

{
  auth: {...}
  translations: {...}
  theme: {...}
}
Enter fullscreen mode Exit fullscreen mode

⚠️如果一个组件只使用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} />
}
Enter fullscreen mode Exit fullscreen mode

你应该把这个状态拆分成几个 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'),
)
Enter fullscreen mode Exit fullscreen mode

正如您所见,这可能会以无尽的箭头组件结束,因此一个好的做法是将其拆分为两个文件:

// ProvidersWrapper.jsx
// This `ProvidersWrapper.jsx` can help you implementing testing 
// at the same time.
const ProvidersWrapper = ({ children }) => (
  <AuthProvider>
    <TranslationsProvider>
      <ThemeProvider>
        {children}
      </ThemeProvider>
    </TranslationsProvider>
  </AuthProvider>
)
Enter fullscreen mode Exit fullscreen mode
// index.jsx
ReactDOM.render(
  <ProvidersWrapper>
    <MyEntireApp/>
  </ProvidersWrapper>,
  document.getElementById('root'),
)
Enter fullscreen mode Exit fullscreen mode

通过这样做,每个消费者应该只使用它所需要的东西。

拆分上下文的替代方案

除了拆分上下文之外,我们还可以应用以下技术,以便<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} />
})
Enter fullscreen mode Exit fullscreen mode

一种高级的实现是创建一个具有自定义函数的HOCconnect(...),如下所示:

const connect = (MyComponent, select) => {
  return function (props) {
    const selectors = select();
    return <WrappedComponent {...selectors} {...props}/>
  }
}
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

来源:https ://github.com/reactjs/rfcs/pull/119#issuecomment-547608494

然而,这违背了 React Context 的本质,并且不能解决主要问题:包装组件的 HOC 仍然会尝试重新渲染,一次更新可能会有多个 HOC,从而导致昂贵的操作。

useMemo2. 一个带内部的组件

const Consumer = () => {
  const { theme } = useContext(MyContext)

  return useMemo(() => {
    // The rest of your rendering logic
    return <ExpensiveTree theme={theme} />
  }, [theme])
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

如果是这样,是的。您必须按如下方式同时记住提供程序组件:

// 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>
}
Enter fullscreen mode Exit fullscreen mode
// 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)
})
Enter fullscreen mode Exit fullscreen mode

但这不太可能,正如我们之前看到的那样,您总是希望用提供程序包装整个应用程序。

ReactDOM.render(
  <MyProvider>
    <MyEntireApp/>
  </MyProvider>,
  document.getElementById('root'),
)
Enter fullscreen mode Exit fullscreen mode

将 Context 拆分为两个stateContextsetStateContext

出于我们之前讨论过的同样的原因:

⚠️仅更改 Context 状态(通过使用setStatedispatch)的消费者将在执行更新且值发生变化后重新渲染。

这就是为什么将上下文分成两部分是一个好主意,如下所示:

const CountStateContext = createContext()
const CountUpdaterContext = createContext()
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode
// CountConsumer.jsx
// This component will re-render if count changes.
const CountDisplay = () => {
  const count = useCountState()

  return (
    <>
      {`The current count is ${count}. `}
    </>
  )
})
Enter fullscreen mode Exit fullscreen mode
// CountDispatcher.jsx
// This component will not re-render if count changes.
const CounterDispatcher = () => {
  const countUpdater = useCountUpdater()

  return (
    <button onClick={countUpdater}>Increment count</button>
  )
}
Enter fullscreen mode Exit fullscreen mode

同时使用状态和更新程序的组件必须像这样导入它们:

const state = useCountState()
const dispatch = useCountDispatch()
Enter fullscreen mode Exit fullscreen mode

您可以通过一个函数导出它们,useCount如下所示:

const useCount = () => {
  return [useCountState(), useCountDispatch()]
}
Enter fullscreen mode Exit fullscreen mode

那使用呢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"
  }
]
Enter fullscreen mode Exit fullscreen mode
// 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 }
Enter fullscreen mode Exit fullscreen mode
// 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()
    })
  })
})

Enter fullscreen mode Exit fullscreen mode

是时候测试我们的消费者了:

// Articles.jsx
const Articles = () => {
  const { articles } = useArticles()

  return (
    <>
      <h2>List of Articles</h2>
      {articles.map((article) => (
        <p>{article.title}</p>
      ))}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode
// 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()
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

不稳定特征:观察到的位

// react/index.d.ts
function useContext<T>(context: Context<T>/*, (not public API) observedBits?: number|boolean */): T;
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode
// 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>;
};
Enter fullscreen mode Exit fullscreen mode

请记住,这是一个不稳定的功能,您最多只能观察 30 个值(MaxInt.js),并且会在控制台中收到警告 :P。我更喜欢拆分上下文,以便将必要的 props 传递给应用程序树,遵循 React Context 的初始特性,同时等待更新。

您可以在此处找到带有功能性游乐场的完整演示:https://stackblitz.com/edit/react-jtb3lv

未来

已经有一些提案来实现这个selector概念,以便让 React 管理这些优化(如果我们只是在全局状态中观察一个值):

const context = useContextSelector(Context, c => c.selectedField)
Enter fullscreen mode Exit fullscreen mode

https://github.com/facebook/react/pull/20646

参考书目

到目前为止,我读过的一些有趣的文章/评论帮助我把所有的东西整合在一起,包括一些用于重新渲染的 stackblitz:

关键点

  • React.memo当组件上方最近的 Provider 更新时,即使祖先使用或,该组件也会触发重新渲染shouldComponentUpdate
  • React.createContext(...)如果组件树中没有任何高于其自身的提供者,则消费者将接收默认值
  • 为了避免重新渲染整个应用程序(或使用memo),提供者必须接收children作为道具以保持引用相等。
  • 如果您实现了全局提供者,那么无论更新什么属性,消费者总是会触发重新渲染。
  • 如果父级组件可以更新(通过 setState() 甚至通过祖父级组件),我们必须小心,因为所有内容都会重新渲染。我们必须同时记录提供者和消费者的更新。
  • 仅更改 Context 状态(通过使用setStatedispatch)的消费者将在执行更新并且值发生更改后重新渲染,因此建议将该 Context 分为两部分:StateContext 和 DispatchContext。
  • 请记住,React Context 不管理状态,您可以通过useState或 来管理useReducer
  • 实现自定义模拟以便正确测试您的提供程序,<Context.Provider {...props} />这不是您的组件将直接使用的。
  • observedBits是一个隐藏的实验性功能,它可以帮助我们实现全局状态,避免不必要的重新渲染。

就是这样,希望你喜欢它!

文章来源:https://dev.to/javmurillo/react-context-all-in-one-54ck
PREV
高级开发人员的 JavaScript 基础知识
NEXT
为什么软件开发人员应该创建在线课程作为副业?