Javascript设计模式——提供者模式

141 阅读8分钟

让多个子组件都可以共享同一数据


在一些场景下,我们希望让应用中的多个(或者所有)组件可用同一部分数据。当然我们可以通过props一层一层向下级组件传递这部分数据,但实际使用中却往往因为需要在应用中的大量组件对外暴露该属性而使得实操变得困难重重。

如果真的要这么做得话,结局大概率会碰到prop下钻的难题,也就是指在组件树形结构中props传递层级的过深。即便历经重重困难勉强扛过prop下钻困境,也会在未来由于无法确定数据从何而来,而让组件的重构变得几乎不可能。

假如说我们在App组件中初始了一个数据,而在整个应用的组件树中我们还有ListItem, HeaderText等组件需要这个数据。为了让这些组件都获取到该数据,我们就需要穿越多级组件层层传递该数据。

provider.gif

对应的代码中,它们看起来差不多是这样的:

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>

像这样层层向下传递属性的方式很容易让逻辑变得凌乱。即便我们将来想要对data属性改个名字,我们都不得不在所有组件中对其重新命名。而且应用规模越大,这种prop下钻的问题越严重。

如果可以在传递过程中越过那些不需要这个数据的组件层级,才是最佳方案。对于需要使用这个数据的组件应该给予它直接的访问渠道,而不是依赖于prop下钻的方式。

这正是提供者模式使用的最佳场景!使用提供者模式,可以让数据对多个组件可用。我们可以包装所有组件在一个提供者中,而不是通过props一层一层递进式地传递数据。在React语境中,提供者是通过Context对象构造出来的一个高阶组件。我们可以通过调用createContext方法创建一个Context对象。

提供者接受一个value属性,也就是希望向下级组件传递的属性。于是所有被提供者组件包裹起来的组件都拥有对于value属性的访问能力。

const DataContext = React.createContext()
​
function App() {
  const data = { ... }
​
  return (
    <div>
      <DataContext.Provider value={data}>
        <SideBar />
        <Content />
      </DataContext.Provider>
    </div>
  )
}

我们不再需要手动向每一个下级组件传递data属性了!那么,对于ListItemHeaderText组件来说,他们如何获取到data的值呢?

每个被包裹的组件都可以通过使用useContext钩子函数来获取data。这个钩子函数接受一个通过data引用的context对象,也就是本例中的DataContextuseContext钩子允许我们对context对象进行读写。

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这个东西。因此我们不必再担心向组件树中不需要使用这个数据的那些层级传递属性,这也让重构变得简单的多。

provider2.gif

提供者模式非常适用于需要共享全局数据的场景。最常见的就是向所有组件共享UI主题状态。

比如下面的例子是一个显示列表的应用。

// index.js
import React from "react";
import ReactDOM from "react-dom";
​
import App from "./App";
​
const rootElement = document.getElementById("root");
ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  rootElement
);
​
// App.js
import React from "react";
import "./styles.css";
​
import List from "./List";
import Toggle from "./Toggle";
​
export default function App() {
  return (
    <div className="App">
      <Toggle />
      <List />
    </div>
  );
}
​
// List.jsimport React from "react";
import ListItem from "./ListItem";
​
export default function Boxes() {
  return (
    <ul className="list">
      {new Array(10).fill(0).map((x, i) => (
        <ListItem key={i} />
      ))}
    </ul>
  );
}
​
// Toggle.jsimport React from "react";
​
export default function Toggle() {
  return (
    <label className="switch">
      <input type="checkbox" />
      <span className="slider round" />
    </label>
  );
}
​
// ListItem.jsimport React from "react";
​
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>
  );
}

产品设计希望用户可以点击开关来切换灯光模式和黑暗模式。当用户从灯光模式切换到黑暗模式或者反过来时,背景颜色和文字颜色都应该跟着改变。通过使用提供者模式我们可以将组件包裹在一个名为ThemeProvider的提供者对象中,向下传递当前应该使用的背景颜色和字体颜色,而不是向每一个组件显式地传递属性。

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提供者内部包裹着,因此通过提供者的value值随之传递的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可以直接使用themecontext对象来获取其值。

import React, { useContext } from "react";
import { ThemeContext } from "./App";
​
export default function TextBox() {
  const theme = useContext(ThemeContext);
​
  return <li style={theme.theme}>...</li>;
}

如此一来,便不需要依次将顶层变量递进式地传递给组件树路径上的所有组件,无论它是否需要。

// App.jsimport React, { useState } from "react";
import "./styles.css";
​
import List from "./List";
import Toggle from "./Toggle";
​
export const themes = {
  light: {
    background: "#fff",
    color: "#000"
  },
  dark: {
    background: "#171717",
    color: "#fff"
  }
};
​
export const ThemeContext = React.createContext();
​
export default function App() {
  const [theme, setTheme] = useState("dark");
​
  function toggleTheme() {
    setTheme(theme === "light" ? "dark" : "light");
  }
​
  return (
    <div className={`App theme-${theme}`}>
      <ThemeContext.Provider value={{ theme: themes[theme], toggleTheme }}>
        <>
          <Toggle />
          <List />
        </>
      </ThemeContext.Provider>
    </div>
  );
}
​
// Toggle.jsimport 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>
  );
}

钩子函数

向组件传递context对象可以通过创建一个钩子函数的方式。相较于在每一个组件内部都import useContext以及对应的context对象,也可以使用钩子函数来返回需要的context对象,这样显然会更加方便。

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

首先做一点防御性编程的代码,如果通过useContext(ThemeContext)返回的是一个false值,那么应该抛出一个异常。

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

接下来我们会创建一个高阶组件来包裹其他业务组件以便传递提供者的值,而不是直接通过ThemeContext.Provider组件来包裹。这样可以降低context逻辑和渲染组件之间的耦合度,也会提升提供者的复用性。

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>;
}

通过对不同context创建钩子函数,能更好地降低提供者的逻辑代码与渲染组件之间的耦合度。


案例学习

有一些库提供内置的提供者,我们可以直接使用。其中一个不错的例子来自于styled-components

接下来的讲解并不需要对styled-components具有使用经验

styled-components库提供了一个ThemeProvivder。每一个基于其构建的组件都可以获取这个提供者的值。我们可以直接使用而不需要自行创建context。

回到之前的列表的例子,但这一次直接使用styled-components库提供的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>
  );
}

使用Styled Components之后我们不再向ListItem组件传递行内的style值,而是构建一个基于styled.li的组件。由于该组件基于styled component,因此可以直接获取到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将样式应用于所有通过styled component构建的组件。

// App.jsimport React, { useState } from "react";
import { ThemeProvider } from "styled-components";
import "./styles.css";
​
import List from "./List";
import Toggle from "./Toggle";
​
export 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");
  }
​
  return (
    <div className={`App theme-${theme}`}>
      <ThemeProvider theme={themes[theme]}>
        <>
          <Toggle toggleTheme={toggleTheme} />
          <List />
        </>
      </ThemeProvider>
    </div>
  );
}
​
// ListItem.jsimport React from "react";
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};
  `}
`;

优点

利用提供者模式 / (React) Context API允许我们将数据传递给多个组件,而无需手动层层传递组件的属性。

这也会降低在重构代码的过程中不小心引入bug的风险。也就是前述所提及的假设稍后我们想要对某个属性重命名,可能牵涉到整个组件树路径上的所有组件代码的修改。

使用提供者模式会避免处理所谓的prop下钻的问题,这一问题应该被认为是一种反模式。如前所述,prop下钻会造成一种难以理解应用中数据流向的困局,因为在组件内部并不能轻易地定位到数据最初究竟来自于哪个父级组件。而通过提供者模式,则不再需要向不关心该数据的组件传递这个数据。

由于提供者模式允许组件获取其中的数据,因此也可以认为通过它可以维护某种形式上的全局状态。


缺点

在某些场景下,过度使用提供者模式则会造成性能问题。所有消费提供者上下文的组件都会在状态发生变更时重新渲染。

看下面的例子,一个简单的计数器,当Button组件中的Increment按钮被点击一次,计数器都会+1。另外还有一个Reset组件,其中的Reset按钮被点击时,会重置计数器为0

当你点击Increment按钮时,不仅仅是计数器被重新渲染,在Reset组件内部的日期也会被重新渲染。

// index.jsimport React, { useState, createContext, useContext, useEffect } from "react";
import ReactDOM from "react-dom";
import moment from "moment";
​
import "./styles.css";
​
const CountContext = createContext(null);
​
function Reset() {
  const { setCount } = useCountContext();
​
  return (
    <div className="app-col">
      <button onClick={() => setCount(0)}>Reset count</button>
      <div>Last reset: {moment().format("h:mm:ss a")}</div>
    </div>
  );
}
​
function Button() {
  const { count, setCount } = useCountContext();
​
  return (
    <div className="app-col">
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <div>Current count: {count}</div>
    </div>
  );
}
​
function useCountContext() {
  const context = useContext(CountContext);
  if (!context)
    throw new Error(
      "useCountContext has to be used within CountContextProvider"
    );
  return context;
}
​
function CountContextProvider({ children }) {
  const [count, setCount] = useState(0);
  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  );
}
​
function App() {
  return (
    <div className="App">
      <CountContextProvider>
        <Button />
        <Reset />
      </CountContextProvider>
    </div>
  );
}
​
ReactDOM.render(<App />, document.getElementById("root"));

由于Reset组件也消费了useCountContext因此count变化时也随之重新进行渲染。在小型应用中这不太能感受到有什么影响。但在大型应用中频繁传递或者更新提供者上下文会造成大量组件重新渲染,这将对性能造成消极影响。

为了避免组件在消费提供者上下文时的重复渲染,可以对ContextProvivder进行粒度更细的拆分,避免不相关的数据变更对组件触发重新渲染,或者使用useMemo对组件依赖项状态进行判断,如无依赖状态变更则不会重新渲染。

原文地址:www.patterns.dev/posts/provi…