React高级详解特性之Context

React
273
0
0
2023-01-08
Context提供了一种不需要手动地通过props来层层传递的方式来传递数据。

正文

在典型的React应用中,数据是通过props,自上而下地传递给子组件的。但是对于被大量组件使用的固定类型的数据(比如说,本地的语言环境,UI主题等)来说,这么做就显得十分的累赘和笨拙。Context提供了一种在组件之间(上下层级关系的组件)共享这种类型数据的方式。这种方式不需要你手动地,显式地通过props将数据层层传递下去。

什么时候用Context?

这一小节,讲的是context适用的业务场景。

Context是为那些可以认定为【整颗组件树范围内可以共用的数据】而设计的。比如说,当前已认证的用户数据,UI主题数据,当前用户的偏好语言设置数据等。举个例子,下面的代码中,为了装饰Button component我们手动地将一个叫“theme”的prop层层传递下去。 传递路径是:App -> Toolbar -> ThemedButton -> Button

class App extends React.Component {
  render() {
    return <Toolbar theme="dark" />;
  }
}

function Toolbar(props) {
  // The Toolbar component must take an extra "theme" prop 
  // and pass it to the ThemedButton. This can become painful 
  // if every single button in the app needs to know the theme 
  // because it would have to be passed through all components. 
  return (
    <div> 
      <ThemedButton theme={props.theme} /> 
    </div>
  );
}

class ThemedButton extends React.Component {
  render() {
    return <Button theme={this.props.theme} />;
  }
}

使用context,我们可以跳过层层传递所经过的中间组件。现在我们的传递路径是这样的:App -> Button

// Context lets us pass a value deep into the component tree
// without explicitly threading it through every component.
// Create a context for the current theme (with "light" as the default).
const ThemeContext = React.createContext('light');

class App extends React.Component {
  render() {
    // Use a Provider to pass the current theme to the tree below. 
    // Any component can read it, no matter how deep it is. 
    // In this example, we're passing "dark" as the current value. 
    return (
      <ThemeContext.Provider value="dark"> 
        <Toolbar /> 
      </ThemeContext.Provider>
    );
  }
}

// A component in the middle doesn't have to
// pass the theme down explicitly anymore.
function Toolbar(props) {
  return (
    <div> 
      <ThemedButton /> 
    </div>
  );
}

class ThemedButton extends React.Component {
  // Assign a contextType to read the current theme context. 
  // React will find the closest theme Provider above and use its value. 
  // In this example, the current theme is "dark". 
  static contextType = ThemeContext;
  render() {
    return <Button theme={this.context} />;
  }
}

在你用Context之前

这一小节,讲的是我们要慎用context。在用context之前,我们得考虑一下当前的业务场景有没有第二种技术方案可用。只有在确实想不出来了,才去使用context。

Context主要用于这种业务场景:大量处在组件树不同层级的组件需要共享某些数据。实际开发中,我们对context要常怀敬畏之心,谨慎使用。因为它犹如潘多拉的盒子,一旦打开了,就造成很多难以控制的现象(在这里特指,context一旦滥用了,就会造成很多组件难以复用)。

如果你只是单纯想免去数据层层传递时对中间层组件的影响,那么组件组合是一个相比context更加简单的技术方案。

举个例子来说,假如我们有一个叫Page的组件,它需要将useravatarSize这两个prop传递到下面好几层的Link组件和Avatar组件:

<Page user={user} avatarSize={avatarSize} />
// ... which renders ...
<PageLayout user={user} avatarSize={avatarSize} />
// ... which renders ...
<NavigationBar user={user} avatarSize={avatarSize} />
// ... which renders ...
<Link href={user.permalink}> 
  <Avatar user={user} size={avatarSize} />
</Link>

我们大费周章地将useravatarSize这两个prop传递下去,最终只有Avatar组件才真正地用到它。这种做法显得有点低效和多余的。假如,到后面Avatar组件需要从顶层组件再获取一些格外的数据的话,你还得手动地,逐层地将这些数据用prop的形式来传递下去。实话说,这真的很烦人。

不考虑使用context的前提下,另外一种可以解决这种问题的技术方案是:Avatar组件作为prop传递下去。这样一来,其他中间层的组件就不要知道user这个prop的存在了。

function Page(props) {
  const user = props.user;
  const userLink = (
    <Link href={user.permalink}> 
      <Avatar user={user} size={props.avatarSize} /> 
    </Link>
  );
  return <PageLayout userLink={userLink} />;
}

// Now, we have:
<Page user={user} />
// ... which renders ...
<PageLayout userLink={...} />
// ... which renders ...
<NavigationBar userLink={...} />
// ... which renders ...
{props.userLink}

通过这个改动,只有最顶层的组件Page需要知道Link组件和Avatar组件需要用到“user”和“avatarSize”这两个数据集。

在很多场景下,这种通过减少需要传递prop的个数的“控制反转”模式让你的代码更干净,并赋予了最顶层组件更多的控制权限。然而,它并不适用于每一个业务场景。因为这种方案会增加高层级组件的复杂性,并以此为代价来使得低层家的组件来变得更加灵活。而这种灵活性往往是过度的。

在“组件组合”这种技术方案中,也没有说限定你一个组件只能有一个子组件,你可以让父组件拥有多个的子组件。或者甚至给每个单独的子组件设置一个单独的“插槽(slots)”,正如这里所介绍的那样。

function Page(props) {
  const user = props.user;
  const content = <Feed user={user} />;
  const topBar = (
    <NavigationBar> 
      <Link href={user.permalink}> 
        <Avatar user={user} size={props.avatarSize} /> 
      </Link> 
    </NavigationBar>
  );
  return (
    <PageLayout 
      topBar={topBar} 
      content={content}
    />
  );
}

这种模式对于大部分需要将子组件从它的父组件中分离开来的场景是足够有用的了。如果子组件在渲染之前需要与父组件通讯的话,你可以进一步考虑使用render props技术。

然而,有时候你需要在不同的组件,不同的层级中去访问同一份数据,这种情况下,还是用context比较好。Context负责集中分发你的数据,在数据改变的同时,能将新数据同步给它下面层级的组件。第一小节给出的范例中,使用context比使用本小节所说的“组件组合”方案更加的简单。适用context的场景还包括“本地偏好设置数据”共享,“UI主题数据”共享和“缓存数据”共享等。

相关API

React.createContext

const MyContext = React.createContext(defaultValue);

该API是用于创建一个context object(在这里是指Mycontext)。当React渲染一个订阅了这个context object的组件的时候,将会从离这个组件最近的那个Provider组件读取当前的context值。

创建context object时传入的默认值只有组件在上层级组件树中没有找到对应的的Provider组件的时候时才会使用。这对于脱离Provider组件去单独测试组件功能是很有帮助的。注意:如果你给Provider组件value属性提供一个undefined值,这并不会引用React使用defaultValue作为当前的value值。也就是说,undefined仍然是一个有效的context value。

Context.Provider

<MyContext.Provider value={/* some value */}>

每一个context object都有其对应的Provider组件。这个Provider组件使得Consumer组件能够订阅并追踪context数据。

它接受一个叫value的属性。这个value属性的值将会传递给Provider组件所有的子孙层级的Consumer组件。这些Consumer组件会在Provider组件的value值发生变化的时候得到重新渲染。从Provider组件到其子孙Consumer组件的这种数据传播不会受到shouldComponentUpdate(这个shouldComponentUpdate应该是指Cousumer组件的shouldComponentUpdate)这个生命周期方法的影响。所以,只要父Provider组件发生了更新,那么作为子孙组件的Consumer组件也会随着更新。

判定Provider组件的value值是否已经发生了变化是通过使用类似于Object.is算法来对比新旧值实现的。

注意:当你给在Provider组件的value属性传递一个object的时候,用于判定value是否已经发生改变的法则会导致一些问题,见注意点。

Class.contextType

译者注:官方文档给出的关于这个API的例子我并没有跑通。不知道是我理解错误还是官方的文档有误,读者谁知道this.context在new context API中是如何使用的,麻烦在评论区指教一下。
class MyClass extends React.Component {
  componentDidMount() {
    let value = this.context;
    /* perform a side-effect at mount using the value of MyContext */
  }
  componentDidUpdate() {
    let value = this.context;
    /* ... */
  }
  componentWillUnmount() {
    let value = this.context;
    /* ... */
  }
  render() {
    let value = this.context;
    /* render something based on the value of MyContext */
  }
}
MyClass.contextType = MyContext;

组件(类)的contextType静态属性可以赋值为一个context object。这使得这个组件类可以通过this.context来消费离它最近的context value。this.context在组件的各种生命周期方法都是可访问的。参考 React面试题详细解答

注意:

  1. 使用这个API,你只可以订阅一个context object。如果你需要读取多个context object,那么你可以查看Consuming Multiple Contexts
  2. 如果你想使用ES7的实验性特征public class fields syntax,你可以使用static关键字来初始化你的contextType属性:
class MyClass extends React.Component {
  static contextType = MyContext;
  render() {
    let value = this.context;
    /* render something based on the value */
  }
}

Context.Consumer

<MyContext.Consumer>
  {value => /* render something based on the context value */}
</MyContext.Consumer>

Consumer组件是负责订阅context,并跟踪它的变化的组件。有了它,你就可以在一个function component里面对context发起订阅。

如上代码所示,Consumer组件的子组件要求是一个function(注意,这里不是function component)。这个function会接收一个context value,返回一个React node。这个context value等同于离这个Consumer组件最近的Provider组件的value属性值。假如Consumer组件在上面层级没有这个context所对应的Provider组件,则function接收到的context value就是创建context object时所用的defaultValue。

注意:这里所说的“function as a child”就是我们所说的render props模式。

示例

1. 动态context

我在这个例子里面涉及到this.context的组件的某个生命周期方法里面打印console.log(this.context),控制台打印出来是空对象。从界面来看,DOM元素button也没有background。

这是一个关于动态设置UI主题类型的context的更加复杂的例子:

theme-context.js

export const themes = {
  light: {
    foreground: '#000000',
    background: '#eeeeee',
  },
  dark: {
    foreground: '#ffffff',
    background: '#222222',
  },
};

export const ThemeContext = React.createContext(
  themes.dark // default value
);

themed-button.js

import {ThemeContext} from './theme-context';

class ThemedButton extends React.Component {
  render() {
    let props = this.props;
    let theme = this.context;
    return (
      <button
        {...props}
        style={{backgroundColor: theme.background}}
      />
    );
  }
}
ThemedButton.contextType = ThemeContext;

export default ThemedButton;

app.js

import {ThemeContext, themes} from './theme-context';
import ThemedButton from './themed-button';

// An intermediate component that uses the ThemedButton
function Toolbar(props) {
  return (
    <ThemedButton onClick={props.changeTheme}>
      Change Theme    </ThemedButton>
  );
}

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      theme: themes.light,
    };

    this.toggleTheme = () => {
      this.setState(state => ({
        theme:
          state.theme === themes.dark
            ? themes.light
            : themes.dark,
      }));
    };
  }

  render() {
    // The ThemedButton button inside the ThemeProvider 
    // uses the theme from state while the one outside uses 
    // the default dark theme 
    // 以上注释所说的结果,我并没有看到。 
    return (
      <Page> 
        <ThemeContext.Provider value={this.state.theme}> 
          <Toolbar changeTheme={this.toggleTheme} /> 
        </ThemeContext.Provider> 
        <Section> 
          <ThemedButton /> 
        </Section> 
      </Page>
    );
  }
}

ReactDOM.render(<App />, document.root);

2. 在内嵌的组件中更新context

组件树的底层组件在很多时候是需要更新Provider组件的context value的。面对这种业务场景,你可以在创建context object的时候传入一个function类型的key-value,然后伴随着context把它传递到Consumer组件当中:

theme-context.js

// Make sure the shape of the default value passed to
// createContext matches the shape that the consumers expect!
export const ThemeContext = React.createContext({
  theme: themes.dark,
  toggleTheme: () => {},
});

theme-toggler-button.js

import {ThemeContext} from './theme-context';

function ThemeTogglerButton() {
  // The Theme Toggler Button receives not only the theme 
  // but also a toggleTheme function from the context 
  return (
    <ThemeContext.Consumer>
      {({theme, toggleTheme}) => (        <button 
          onClick={toggleTheme} 
          style={{backgroundColor: theme.background}}>
          Toggle Theme        </button>
      )}    </ThemeContext.Consumer>
  );
}

export default ThemeTogglerButton;

app.js

import {ThemeContext, themes} from './theme-context';
import ThemeTogglerButton from './theme-toggler-button';

class App extends React.Component {
  constructor(props) {
    super(props);

    this.toggleTheme = () => {
      this.setState(state => ({
        theme:
          state.theme === themes.dark
            ? themes.light
            : themes.dark,
      }));
    };

    // State also contains the updater function so it will 
    // be passed down into the context provider 
    this.state = {
      theme: themes.light,
      toggleTheme: this.toggleTheme,
    };
  }

  render() {
    // The entire state is passed to the provider 
    return (
      <ThemeContext.Provider value={this.state}> 
        <Content /> 
      </ThemeContext.Provider>
    );
  }
}

function Content() {
  return (
    <div> 
      <ThemeTogglerButton /> 
    </div>
  );
}

ReactDOM.render(<App />, document.root);

3. 同时消费多个context

为了使得context所导致的重新渲染的速度更快,React要求我们对context的消费要在单独的Consumer组件中去进行。

// Theme context, default to light theme
const ThemeContext = React.createContext('light');

// Signed-in user context
const UserContext = React.createContext({
  name: 'Guest',
});

class App extends React.Component {
  render() {
    const {signedInUser, theme} = this.props;

    // App component that provides initial context values 
    // 两个context的Provider组件嵌套 
    return (
      <ThemeContext.Provider value={theme}> 
        <UserContext.Provider value={signedInUser}> 
          <Layout /> 
        </UserContext.Provider> 
      </ThemeContext.Provider>
    );
  }
}

function Layout() {
  return (
    <div> 
      <Sidebar /> 
      <Content /> 
    </div>
  );
}

// A component may consume multiple contexts
function Content() {
  return (
     // 两个context的Consumer组件嵌套 
    <ThemeContext.Consumer>
      {theme => (        <UserContext.Consumer>
          {user => (            <ProfilePage user={user} theme={theme} />
          )}        </UserContext.Consumer>
      )}    </ThemeContext.Consumer>
  );
}

但是假如两个或以上的context经常被一同消费,这个时候你得考虑合并它们,使之成为一个context,并创建一个接受多个context作为参数的render props component。

注意点

因为context是使用引用相等(reference identity)来判断是否需要re-redner的,所以当你给Provider组件的value属性提供一个字面量javascript对象值时,这就会导致一些性能问题-consumer组件发生不必要的渲染。举个例子,下面的示例代码中,所有的consumer组件将会在Provider组件重新渲染的时候跟着一起re-render。这是因为每一次value的值都是一个新对象。

class App extends React.Component {
  render() {
    return (
     // {something: 'something'} === {something: 'something'}的值是false 
      <Provider value={{something: 'something'}}> 
        <Toolbar /> 
      </Provider>
    );
  }
}

为了避免这个问题,我们可以把这种引用类型的值提升到父组件的state中去:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: {something: 'something'},
    };
  }

  render() {
    return (
      <Provider value={this.state.value}> 
        <Toolbar /> 
      </Provider>
    );
  }
}

遗留的API

React在先前的版本中引入了一个实验性质的context API。相比当前介绍的这个context API,我们称它为老的context API。这个老的API将会被支持到React 16.x版本结束前。但是你的app最好将它升级为上文中所介绍的新context API。这个遗留的API将会在未来的某个大版本中去除掉。