不一样的 React context

不一样的 React context

context 为 React 中提供了跨层级组件传递数据的能力,避免在每个层级的组件中显式传递 props。在 React16 以前,尽管被 React-Redux、React-Router 等第三方库广泛使用,但由于潜在的诸多问题,context 一直被官方标注为实验性 API,不建议使用,而 React v16.3,推出了 New Context API,期望去解决之前 context 所存在的问题。

在此之前,社区对于 Legacy Context 的问题以及 New Context 的设计有过一定的阐述与讨论,本文便不再老生常谈,大家可以通过阅读 精读《如何安全地使用 React context》从新的 Context API 看 React 应用设计模式 两篇文章对 Legacy Context 与 New Context 做一个简单回顾。而本文将着重聚焦于 context 在 React 内部的具体实现,期待透过 context 的本质能让大家对 React 有更深入的理解。

揭秘 Legacy Context

class Child extends React.Component {
  render() {
    // 输出 context1
    console.log(this.context.value1);
    // 输出 undefined
    console.log(this.context.value2);
    ...
  }
}

Child.contextTypes = {
  value1: PropTypes.string
};

class Parent extends React.Component {
  render() {
    return <Child />
  }
}

class Ancestor extends React.Component {
  getChildContext() {
    return { value1: "context1", value2: "context2" };
  }
  render() {
    return <Parent />;
  }
}

Ancestor.childContextTypes = {
  value1: PropTypes.string,
  value2: PropTypes.string
};

首先我们需要先明确一下 Legacy Context 是如何去定义与获取的,首先 Ancestor Component 需定义 childContextTypes,并利用 getChildContext 给全局的 context 赋值,Child Component 需定义 contextTypes 方可以获取到所定义的 context。而 context 的访问途径有以下几种:

  • this.context
  • constructor(props, context)
  • componentWillReceiveProps(nextProps, nextContext)
  • shouldComponentUpdate(nextProps, nextState, nextContext)
  • componentWillUpdate(nextProps, nextState, nextContext)

在解读 Legacy Context 具体的实现方式之前,我们需要先了解一些全局的定义,在 react-reconciler/src/ReactFiberContext.js 中有两个堆栈指针,contextStackCursor 指向当前的 context 值,didPerformWorkStackCursor 指向当前 context 是否发生变化,后续会用于判断是否需要 rerender,以及 previousContext 存储上一次也就是父级的 context,那么为何需要 previousContext?下文会为你揭晓答案。

Ancestor Component,主要负责全局 context 的整合,将自身提供的 childContext 与 previousContext 合并,塞入全局的 contextStackCursor 中供自己的 Child Component 使用。这里需要注意的是,Component 的 this.context 是不能获取自己定义的 childContext 的,因此只能使用 previousContext。

// From React v16.3.2 release react-reconciler/src/ReactFiberContext.js
function getUnmaskedContext(workInProgress: Fiber): Object {
  const hasOwnContext = isContextProvider(workInProgress);
  if (hasOwnContext) {
    // If the fiber is a context provider itself, when we read its context
    // we have already pushed its own child context on the stack. A context
    // provider should not "see" its own child context. Therefore we read the
    // previous (parent) context instead for a context provider.
    return previousContext;
   }
   return contextStackCursor.current;
}

Child Component 会先获取全局 context,称之为 unmaskedContext,根据 contextTypes 的定义,从 unmaskedContext 割取为 maskedContext,生命周期函数中获取的 context 均由此而来。

在更新过程中,实例上依然会保留原有的 context,新的 context 则会从更新后的 unmaskedContext 中重新获取。如果 Parent Component 间发生 shouldComponentUpdate 为 false,那么 Child Component 将不会触发 updateClassInstance 这个过程,不会去重新获取 context,也不会因为 context 的变化而 rerender,因此 context 的更新就中断了。

揭秘 New Context

从 Legacy Context 与 New Context 尽管是 context 的一次重大重构,但从底层的实现上只有可以忽略的交集,因此他们能完全共存在 React v16.3 中,在使用上也基本不冲突。如果将两种 context 混用,Legacy Context 的更新可能会造成 New Context 所涉及的组件在未更新的情况下使发生 rerender。

const Context = React.createContext();

class Child extends React.Component {
  render() {
    return (
      <Context.Consumer>
        {({ value1, value2 }) => {
          // 输出 context1
          console.log(value1);
          // 输出 context2
          console.log(value2);
          ...
        }}
      </Context.Consumer>
    );
  }
}

class Parent extends React.Component {
  render() {
    return <Child />
  }
}

class Ancestor extends React.Component {
  render() {
    return (
      <Context.Provider value={{
        value1: "context1",
        value2: "context2",
      }}>
        <Parent />
      </Context.Provider>
    );
  }
}

首先通过 createContext,会生成 Provider 与 Consumer,他们是 fiber 中具有特殊类型($$typeof)的节点,同时是内存共享的,这对后续 Consumer 接收 Provider 的值,以及实现跨层级更新都非常重要。

// From React v16.3.2 release react/src/ReactContext.js
const context: ReactContext<T> = {
  $$typeof: REACT_CONTEXT_TYPE,
  _calculateChangedBits: calculateChangedBits,
  _defaultValue: defaultValue,
  _currentValue: defaultValue,
  _changedBits: 0,
  // These are circular
  Provider: (null: any),
  Consumer: (null: any),
};

context.Provider = {
  $$typeof: REACT_PROVIDER_TYPE,
  _context: context,
};
context.Consumer = context;

别被上图所吓到,New Context 的传递原理其实是比较简单的,可以只用 mount 部分来理解,Provider 所对应的 fiber 节点在创建时,会把所接收到的 value 保存到 context._currentValue,当 Consumer 对应的 fiber 节点在创建时,是可以直接获取到 context._currentValue 来使用的。

那么你可能会问,假如 Provider 与 Consumer 之间,比如示例代码中 Parent Component 使用了 shouldComponentUpdate,并且返回了 false,那么当 Provider 的 value 发生改变后,Consumer 还能接受到更新后的 value 么?答案当然是肯定的,而奥秘就在 propagateContextChange 函数之中。

在 propagateContextChange 中,以当前 fiber 节点为根的子树中寻找相匹配 Consumer 节点,一旦找到后,会不断向父节点回溯,回溯到没有 expirationTime 的节点给予 expirationTime,expirationTime 是 fiber 架构中的时间分片概念,如果 fiber 节点没有 expirationTime 就不会被 update,关于 expirationTime 及相应的时间分片我们将会在以后的文章再去详细讲解。因此,虽然 shouldComponentUpdate 造成了 Child Component 无法获得 expirationTime,但 Provider 的 propagateContextChange 能使 Child 组件重新获得 expirationTime,从而能够被 rerender。

// From React v16.3.2 release react-reconciler/src/ReactFiberBeginWork.js
function propagateContextChange() {
  ...
  // Don't scan deeper than a matching consumer. When we render the
  // consumer, we'll continue scanning from that point. This way the
  // scanning work is time-sliced.
  nextFiber = null;
  ...
}

需要注意的是,对于匹配到的 Consumer 节点,将不再遍历它的子孙节点,而是向上回溯遍历它自身或祖先的兄弟节点,Consumer 的子孙节点将会在 Consumer update 时被遍历,继续向下传播 context 变更,这是为了时间分片而考虑。

const Context = React.createContext();

class Parent extends React.Component {
  shouldComponentUpdate() {
    return false;
  }
  render() {
    console.log('Parent render');
    return <Child />;
  }
}

class Child extends React.Component {
  render() {
    console.log('Child render');
    return (
      <Context.Consumer>
        {({ index, changeIndex }) => {
          console.log('Child Consumer render');
          return <button onClick={changeIndex}>{index} +</button>
        }}
      </Context.Consumer>
    );
  }
}

class Ancestor extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      index: 1
    };
    this.changeIndex = this.changeIndex.bind(this);
  }
  changeIndex() {
    this.setState(preState => {
      return {
        index: preState.index + 1
      };
    });
  }
  render() {
    console.log('Ancestor render');
    return (
      <Context.Provider
        value={{
          index: this.state.index,
          changeIndex: this.changeIndex
        }}
      >
        <Parent />
      </Context.Provider>
    );
  }
}

// mount 结果
// Ancestor render
// Parent render
// Child render
// Child Consumer render
// update 结果
// Ancestor render
// Child Consumer render

读者可自行思考一下以上代码 mount 与 update 时的结果,Provider 更新时 propagateContextChange 寻找到相匹配的 Consumer,Consumer 到 Provider 上的 fiber 节点均会被给予时间分片,但由于 Parent 存在 shouldComponentUpdate 为 false,会导致自身与 Child 均无法被 rerender,但 Consumer 仍保留有时间分片,因此能够被更新。

changedBits/observedBits 粒度控制

记得 New Context 刚横空出世时,社区曾一度掀起广泛讨论,是否可将 Redux 取而代之?同时出现了下面的这种写法,期望把 New Context 的 value 作为 Redux 的 state 与 action,来完成全局共享。

const Context = React.createContext();

<Context.Provider value={{
  value,
  change: () => {
    this.setState((preState) => {
      return {
        value: preState.value + 1,
      };
    });
  }
}}>
  <Context.Consumer>
    {({ value, change }) => {
      return <button onClick={change}>增加 {value}</button>
    }}
  </Context.Consumer>
</Context.Provider>

如果你很仔细看过上一节 Provider 的更新过程,你会发现新旧 value 只是进行了简单的字符串比较和引用比较,那么问题就来了,value 作为对象传入,newValue 与 oldValue 必定为两个不相等的对象,必然会触发 propagateContextChange,由此类推,即使存在 shouldComponentUpdate,仍会触发 Provider 所有匹配 Consumer 的 rerender,很可能根本没有必要。

其实 New Context 已经内置让开发者手动控制更新粒度的方式,React.createContext 的第一个参数是 defaultValue,它还拥有第二个参数 calculateChangedBits,它是一个接受 newValue 与 oldValue 的函数,返回值作为 changedBits,在 Provider 中,当 changedBits = 0,将不再触发更新。而在 Consumer 中有一个不稳定的 props,unstable_observedBits,若 Provider 的changedBits & observedBits = 0,也将不触发更新。下面给出一段 New Context 的测试代码,帮助大家来理解:

// From React v16.3.2 release react-reconciler/src/__tests__/ReactNewContext-test.internal.js
it('can skip consumers with bitmask', () => {
  const Context = React.createContext({foo: 0, bar: 0}, (a, b) => {
    let result = 0;
    if (a.foo !== b.foo) {
      result |= 0b01;
    }
    if (a.bar !== b.bar) {
      result |= 0b10;
    }
    return result;
  });

  function Provider(props) {
    return (
      <Context.Provider value={{foo: props.foo, bar: props.bar}}>
        {props.children}
      </Context.Provider>
    );
  }

  function Foo() {
    return (
      <Context.Consumer unstable_observedBits={0b01}>
        {value => {
          ReactNoop.yield('Foo');
          return <span prop={'Foo: ' + value.foo} />;
        }}
      </Context.Consumer>
    );
  }

  function Bar() {
    return (
      <Context.Consumer unstable_observedBits={0b10}>
        {value => {
          ReactNoop.yield('Bar');
          return <span prop={'Bar: ' + value.bar} />;
        }}
      </Context.Consumer>
    );
  }

  class Indirection extends React.Component {
    shouldComponentUpdate() {
      return false;
    }
    render() {
      return this.props.children;
    }
  }

  function App(props) {
    return (
      <Provider foo={props.foo} bar={props.bar}>
        <Indirection>
          <Indirection>
            <Foo />
          </Indirection>
          <Indirection>
            <Bar />
          </Indirection>
        </Indirection>
      </Provider>
    );
  }

  // 表示首次渲染
  ReactNoop.render(<App foo={1} bar={1} />);
  // ReactNoop.yield('Foo'); 与 ReactNoop.yield('Bar'); 均执行了
  expect(ReactNoop.flush()).toEqual(['Foo', 'Bar']);
  // 实际渲染结果校验
  expect(ReactNoop.getChildren()).toEqual([span('Foo: 1'), span('Bar: 1')]);

  // 更新 foo 的值为 2
  // 此时 a.foo !== b.foo,changedBits = 0b01,Provider 发生更新
  ReactNoop.render(<App foo={2} bar={1} />);
  // Foo 的 Consumer changedBits(0b01) & observedBits(0b01) != 0,发生更新
  // Bar 的 Consumer changedBits(0b01) & observedBits(0b10) != 0,不发生更新
  // 只执行了 ReactNoop.yield('Foo');
  expect(ReactNoop.flush()).toEqual(['Foo']);
  expect(ReactNoop.getChildren()).toEqual([span('Foo: 2'), span('Bar: 1')]); 

  // 同理
  ReactNoop.render(<App foo={3} bar={3} />);
  expect(ReactNoop.flush()).toEqual(['Foo', 'Bar']);
  expect(ReactNoop.getChildren()).toEqual([span('Foo: 3'), span('Bar: 3')]);
});

初窥 readContext

实际上 New Context 也并不是那样完美,我们只能通过 Consumer 去获取到 Provider 的 value,这样是否会过于局限呢?在这个 Experimental API for reading context from within any render phase function pull request 中,New Context 添加了一个新实验性 API,Context.unstable_read,可以在 Provider 下属任意 Component 中读取 context value,Consumer 获取 Provider 的 value 也依赖于它。

// From React master react/src/ReactContext.js
export function readContext<T>(
  context: ReactContext<T>,
  observedBits: void | number | boolean,
): T {}
...
context.unstable_read = readContext.bind(null, context);
...

unstable_read 将与所对应的 context 完成绑定,取值的过程非常简单,直接返回 context._currentValue即可。而难点在于 unstable_read 后的 observable,因此在 fiber 节点上又引入了 ContextDependency 的概念,这又是什么呢?

fiber 节点上的 contextDependencyList 会记录当前节点依赖有哪些 context,当 Provider 发生更新时,它所对应的 context 会在 contextDependencyList 查找是否存在,如果存在,说明有依赖,需要类似于 Consumer 给予时间分片并更新。同理,prepareToReadContext 主要是为了能够继续向下传播 context 变更。

由此 React context 的相关内容就全部介绍完成,New Context 尽管在业务研发中并无太多用武之地,并且还在不断更新和完善,但对于一些状态管理框架却意义深远,React-Redux 已经基于 New Context 开展了重写的工作,让我们对 New Context 的未来有了更多的期待,有兴趣的读者可自行了解一下,React 16 experiment #2: rewrite React-Redux to use new context。我们正在深入研究 React16,欢迎社区小伙伴和我们一起前行,想加入我们的话,欢迎私聊或投递简历 dancang.hj@alibaba-inc.com

发布于 2018-08-22

文章被以下专栏收录