有兴趣了解我们的下一本书吗?了解更多关于 使用 React 构建大型 JavaScript Web 应用

设计模式

提供者模式

在某些情况下,我们希望将数据提供给应用程序中的许多(如果不是全部)组件。虽然我们可以使用 props 将数据传递给组件,但如果应用程序中的几乎所有组件都需要访问 props 的值,这将很困难。

我们经常会遇到一种叫做“道具穿透”的东西,即我们通过组件树向下传递 props 的情况。重构依赖于 props 的代码几乎不可能,而且很难知道某些数据来自哪里。

假设我们有一个 App 组件,它包含某些数据。在组件树的底部,我们有一个 ListItemHeaderText 组件,它们都需要这些数据。为了让这些组件获得这些数据,我们必须通过多层组件传递它们。

在我们的代码库中,这将类似于以下内容

function App() {
  const data = { ... }

  return (
    <div>
      <SideBar data={data} />
      <Content data={data} />
    </div>
  )
}

const SideBar = ({ data }) => <List data={data} />
const List = ({ data }) => <ListItem data={data} />
const ListItem = ({ data }) => <span>{data.listItem}</span>

const Content = ({ data }) => (
  <div>
    <Header data={data} />
    <Block data={data} />
  </div>
)
const Header = ({ data }) => <div>{data.title}</div>
const Block = ({ data }) => <Text data={data} />
const Text = ({ data }) => <h1>{data.text}</h1>

这种方式传递 props 会变得非常混乱。如果我们想在将来重命名 data prop,我们必须在所有组件中重命名它。你的应用程序越大,道具穿透就越复杂。

如果我们可以跳过所有不需要使用这些数据的组件层,那就最好了。我们需要有一个东西,它能让需要访问 data 值的组件直接访问它,而不依赖于道具穿透。

这就是 提供者模式 可以帮助我们的地方!使用提供者模式,我们可以将数据提供给多个组件。与其通过 props 将数据向下传递到每一层,不如将所有组件都包裹在一个 Provider 中。Provider 是 Context 对象为我们提供的一个高阶组件。我们可以使用 React 为我们提供的 createContext 方法创建一个 Context 对象。

Provider 接收一个 value prop,它包含我们要向下传递的数据。所有包裹在这个 provider 中的组件都可以访问 value prop 的值。

const DataContext = React.createContext()

function App() {
  const data = { ... }

  return (
    <div>
      <DataContext.Provider value={data}>
        <SideBar />
        <Content />
      </DataContext.Provider>
    </div>
  )
}

我们不再需要手动将 data prop 传递到每个组件!那么,ListItemHeaderText 组件如何访问 data 的值呢?

每个组件都可以使用 useContext 钩子来访问 data。这个钩子接收 data 与之关联的上下文,在本例中为 DataContextuseContext 钩子让我们可以读取和写入上下文对象的数据。

const DataContext = React.createContext();

function App() {
  const data = { ... }

  return (
    <div>
      <SideBar />
      <Content />
    </div>
  )
}

const SideBar = () => <List />
const List = () => <ListItem />
const Content = () => <div><Header /><Block /></div>


function ListItem() {
  const { data } = React.useContext(DataContext);
  return <span>{data.listItem}</span>;
}

function Text() {
  const { data } = React.useContext(DataContext);
  return <h1>{data.text}</h1>;
}

function Header() {
  const { data } = React.useContext(DataContext);
  return <div>{data.title}</div>;
}

不使用 data 值的组件根本不需要处理 data。我们不再需要担心通过不需要 props 值的组件向下传递几层 props,这使得重构变得容易得多。


提供者模式对于共享全局数据非常有用。提供者模式的常见用例是与多个组件共享主题 UI 状态。

假设我们有一个简单的应用程序,它显示一个列表。

index.js
App.js
List.js
Toggle.js
ListItem.js
1import React from "react";
2import ReactDOM from "react-dom";
3
4import App from "./App";
5
6const rootElement = document.getElementById("root");
7ReactDOM.render(
8 <React.StrictMode>
9 <App />
10 </React.StrictMode>,
11 rootElement
12);

我们希望用户能够通过切换开关在浅色模式和深色模式之间切换。当用户从深色模式切换到浅色模式,反之亦然时,背景色和文字颜色应该改变!与其将当前主题值向下传递到每个组件,不如将组件包裹在一个 ThemeProvider 中,并将当前主题颜色传递给 provider。

export const ThemeContext = React.createContext();

const themes = {
  light: {
    background: "#fff",
    color: "#000",
  },
  dark: {
    background: "#171717",
    color: "#fff",
  },
};

export default function App() {
  const [theme, setTheme] = useState("dark");

  function toggleTheme() {
    setTheme(theme === "light" ? "dark" : "light");
  }

  const providerValue = {
    theme: themes[theme],
    toggleTheme,
  };

  return (
    <div className={`App theme-${theme}`}>
      <ThemeContext.Provider value={providerValue}>
        <Toggle />
        <List />
      </ThemeContext.Provider>
    </div>
  );
}

由于 ToggleList 组件都包裹在 ThemeContext provider 中,因此我们可以访问作为 value 传递给 provider 的 themetoggleTheme 值。

Toggle 组件中,我们可以使用 toggleTheme 函数来相应地更新主题。

import React, { useContext } from "react";
import { ThemeContext } from "./App";

export default function Toggle() {
  const theme = useContext(ThemeContext);

  return (
    <label className="switch">
      <input type="checkbox" onClick={theme.toggleTheme} />
      <span className="slider round" />
    </label>
  );
}

List 组件本身并不关心主题的当前值。但是,ListItem 组件却关心!我们可以在 ListItem 中直接使用 theme 上下文。

import React, { useContext } from "react";
import { ThemeContext } from "./App";

export default function TextBox() {
  const theme = useContext(ThemeContext);

  return <li style={theme.theme}>...</li>;
}

完美!我们不必将任何数据传递给不关心主题当前值的组件。

App.js
Toggle.js
1import React, { useState } from "react";
2import "./styles.css";
3
4import List from "./List";
5import Toggle from "./Toggle";
6
7export const themes = {
8 light: {
9 background: "#fff",
10 color: "#000"
11 },
12 dark: {
13 background: "#171717",
14 color: "#fff"
15 }
16};
17
18export const ThemeContext = React.createContext();
19
20export default function App() {
21 const [theme, setTheme] = useState("dark");
22
23 function toggleTheme() {
24 setTheme(theme === "light" ? "dark" : "light");
25 }
26
27 return (
28 <div className={`App theme-${theme}`}>
29 <ThemeContext.Provider value={{ theme: themes[theme], toggleTheme }}>
30 <>
31 <Toggle />
32 <List />
33 </>
34 </ThemeContext.Provider>
35 </div>
36 );
37}

钩子

我们可以创建一个钩子,为组件提供上下文。与其在每个组件中都必须导入 useContext 和 Context,不如使用一个返回我们所需上下文的钩子。

function useThemeContext() {
  const theme = useContext(ThemeContext);
  return theme;
}

为了确保它是一个有效的主题,如果 useContext(ThemeContext) 返回一个虚假值,我们将抛出一个错误。

function useThemeContext() {
  const theme = useContext(ThemeContext);
  if (!theme) {
    throw new Error("useThemeContext must be used within ThemeProvider");
  }
  return theme;
}

与其直接用 ThemeContext.Provider 组件包裹组件,不如创建一个包裹组件以提供其值的 HOC。这样,我们可以将上下文逻辑与渲染组件分开,从而提高 provider 的可重用性。

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("dark");

  function toggleTheme() {
    setTheme(theme === "light" ? "dark" : "light");
  }

  const providerValue = {
    theme: themes[theme],
    toggleTheme,
  };

  return (
    <ThemeContext.Provider value={providerValue}>
      {children}
    </ThemeContext.Provider>
  );
}

export default function App() {
  return (
    <div className={`App theme-${theme}`}>
      <ThemeProvider>
        <Toggle />
        <List />
      </ThemeProvider>
    </div>
  );
}

每个需要访问 ThemeContext 的组件现在都可以简单地使用 useThemeContext 钩子。

export default function TextBox() {
  const theme = useThemeContext();

  return <li style={theme.theme}>...</li>;
}

通过为不同的上下文创建钩子,可以轻松地将 provider 的逻辑与渲染数据的组件分开。


案例研究

一些库提供内置的 provider,我们在消费组件中可以使用它们的 value。一个很好的例子是 styled-components.

无需了解 styled-components 就可以理解此示例。

styled-components 库为我们提供了一个 ThemeProvider。每个样式化组件都可以访问这个 provider 的值!我们无需自己创建上下文 API,可以直接使用它提供的 API!

让我们使用相同的 List 示例,并将组件包裹在从 styled-component 库导入的 ThemeProvider 中。

import { ThemeProvider } from "styled-components";

export default function App() {
  const [theme, setTheme] = useState("dark");

  function toggleTheme() {
    setTheme(theme === "light" ? "dark" : "light");
  }

  return (
    <div className={`App theme-${theme}`}>
      <ThemeProvider theme={themes[theme]}>
        <Toggle toggleTheme={toggleTheme} />
        <List />
      </ThemeProvider>
    </div>
  );
}

与其向 ListItem 组件传递一个内联的 style prop,不如将其设为一个 styled.li 组件。由于它是一个样式化组件,因此我们可以访问 theme 的值!

import styled from "styled-components";

export default function ListItem() {
  return (
    <Li>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
      tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
      veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
      commodo consequat.
    </Li>
  );
}

const Li = styled.li`
  ${({ theme }) => `
     background-color: ${theme.backgroundColor};
     color: ${theme.color};
  `}
`;

太棒了,现在我们可以使用 ThemeProvider 轻松地将样式应用到所有样式化组件!

App.js
ListItem.js
1import React, { useState } from "react";
2import { ThemeProvider } from "styled-components";
3import "./styles.css";
4
5import List from "./List";
6import Toggle from "./Toggle";
7
8export const themes = {
9 light: {
10 background: "#fff",
11 color: "#000"
12 },
13 dark: {
14 background: "#171717",
15 color: "#fff"
16 }
17};
18
19export default function App() {
20 const [theme, setTheme] = useState("dark");
21
22 function toggleTheme() {
23 setTheme(theme === "light" ? "dark" : "light");
24 }
25
26 return (
27 <div className={`App theme-${theme}`}>
28 <ThemeProvider theme={themes[theme]}>
29 <>
30 <Toggle toggleTheme={toggleTheme} />
31 <List />
32 </>
33 </ThemeProvider>
34 </div>
35 );
36}

权衡

优点

提供者模式/Context API 使得可以将数据传递给许多组件,而无需手动通过每个组件层传递。

它降低了在重构代码时意外引入错误的风险。以前,如果我们想重命名一个 prop,我们必须在整个应用程序中重命名它,因为该值在那里被使用。

我们不再需要处理道具穿透,这可以被视为一种反模式。以前,很难理解应用程序的数据流,因为并不总是清楚某些 prop 值来自哪里。使用提供者模式,我们不再需要将 props 不必要地传递给不关心这些数据的组件。

使用提供者模式可以轻松地保持某种全局状态,因为我们可以让组件访问这个全局状态。

缺点

在某些情况下,过度使用提供者模式会导致性能问题。所有消费上下文的组件都会在每次状态更改时重新渲染。

让我们看一个例子。我们有一个简单的计数器,每次我们在 Button 组件中点击 Increment 按钮时,它的值就会增加。我们还有一个 Reset 组件中的 Reset 按钮,它将计数重置回 0

但是,当你点击 Increment 时,你会发现,不仅仅是计数重新渲染了。Reset 组件中的日期也重新渲染了!

index.js
1import React, { useState, createContext, useContext, useEffect } from "react";
2import ReactDOM from "react-dom";
3import moment from "moment";
4
5import "./styles.css";
6
7const CountContext = createContext(null);
8
9function Reset() {
10 const { setCount } = useCountContext();
11
12 return (
13 <div className="app-col">
14 <button onClick={() => setCount(0)}>Reset count</button>
15 <div>Last reset: {moment().format("h:mm:ss a")}</div>
16 </div>
17 );
18}
19
20function Button() {
21 const { count, setCount } = useCountContext();
22
23 return (
24 <div className="app-col">
25 <button onClick={() => setCount(count + 1)}>Increment</button>
26 <div>Current count: {count}</div>
27 </div>
28 );
29}
30
31function useCountContext() {
32 const context = useContext(CountContext);
33 if (!context)
34 throw new Error(
35 "useCountContext has to be used within CountContextProvider"
36 );
37 return context;
38}
39
40function CountContextProvider({ children }) {
41 const [count, setCount] = useState(0);
42 return (
43 <CountContext.Provider value={{ count, setCount }}>
44 {children}
45 </CountContext.Provider>
46 );
47}
48
49function App() {
50 return (
51 <div className="App">
52 <CountContextProvider>
53 <Button />
54 <Reset />
55 </CountContextProvider>
56 </div>
57 );
58}
59
60ReactDOM.render(<App />, document.getElementById("root"));

Reset 组件也重新渲染了,因为它消费了 useCountContext。在较小的应用程序中,这影响不大。在较大的应用程序中,将频繁更新的值传递给许多组件会导致性能下降。

为了确保组件不消费包含不必要的可能更新值的 provider,你可以为每个单独的用例创建多个 provider。


参考资料