react 的 context 浅析

664 阅读4分钟

「这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

今天来聊聊 react的context。 本文内容按以下结构展开:

  • 参考资料
  • 功能介绍和应用场景
  • 从源码中学习context
  • 源码设计的精髓
  • 学习感悟

参考资料

功能介绍 & 应用场景

context 是针对 react 组件跨多层组件通信时层层传递 prop 导致代码臃肿繁琐的困境的一种解决方案, 其特点是在某层组件外面包括一个 context 的 Provider,所有后代组件都能消费该 context,并且在传到 Provider 的 value 发生变化的时候能够引发所有消费对应 context 的组件的重渲染。

多个后代组件共同消费一个 context 会导致这些后代组件及 context 的 Provider 之间的耦合,从而降低组件的可复用性。对于 prop,你可以明确知道取值是来自父组件,但对于 context 来说,你不能立即反应过来它的 Provider 放在祖先组件的哪一个位置,为了减少维护的复杂性,最好约定一个规则,比如将所有的 Provider 尽可能放在组件顶层的位置。

context的使用语法无非以下几步:

创建一个 context

const RootContext = React.createContext(null)

放置 Provider


function Root() {
    const [provided, setProvided] = useState({})
    // ...
    const calc = useMemo(() => ({ foo: {...provided, bar: true} }), [provided])
    return (
        <RootContext.Provider value={calc}>
          <App />
        </RootContext.Provider>
    )
 }

此处有个细节,我传递给 Provider 的 calc 是一个用了 useMemo 缓存的值,这是为了避免组件重新渲染时引发Provider不必要的更新(如果直接把{ foo: {...provided, bar: true} }传给 Provider,则会因为在 Provider 浅比较 prop 时认为给 value 传了新的引用,引起所有消费了 RootContext 的后代组件的重渲染)

后代组件使用context

function Sub() {
     const rootContext = useContext(RootContext)
     console.log(rootContext)
     // ...
 }

context 的使用场景有:设置主题、读取用户信息、用户偏好设置、设置显示语言、定制组件······ 这些场景的共同特点是多个组件消费同一批数据,且可能会被更新。其他满足这些特点的数据使用场景也可以使用context

从源码中学习context

首先从react github仓库下载源码,为了减少下载的体积,可以直接下载zip包而不是 git clone

读源码的一个习惯就是先看 package.json, 可以看到

{
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  ...
}

react 仓库是一个 monorepo,"workspaces" 指明了工作空间的所有子项目,有关workspaces可以参考Monorepo最佳实践之Yarn Workspaces, 这里只需要知道值得我们关注的源码在packages的某个包中,准确来说就在 packages/react/src中。 这次我们关注 ReactContext.jsReactHooks.js 的两个文件

import {REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';

import type {ReactContext} from 'shared/ReactTypes';

export function createContext<T>(defaultValue: T): ReactContext<T> {
  const context: ReactContext<T> = {
    $$typeof: REACT_CONTEXT_TYPE,

    _currentValue: defaultValue,
    _currentValue2: defaultValue,

    Provider: (null: any),
    Consumer: (null: any),
  };

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

  context.Consumer = context;
 
  return context;
}

省略注释和dev环境特供代码,核心逻辑就这么简单。再看看我们的ReactHooks.js中的useContext做了什么

import ReactCurrentDispatcher from './ReactCurrentDispatcher';
export function useContext<T>(Context: ReactContext<T>): T {
  const dispatcher = resolveDispatcher();

  return dispatcher.useContext(Context);
}

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return ((dispatcher: any): Dispatcher);
}

不讲武德,最终useContext的逻辑是在 packages/react-reconciler/src/ReactFiberHooks.new.js

// 已省略大部分代码
import {readContext} from './ReactFiberNewContext.new';
const HooksDispatcherOnUpdate: Dispatcher = {
    readContext,
    useContext: readContext,
    //...
 }

可以看出

export function readContext<T>(context: ReactContext<T>): T {

  const value = isPrimaryRenderer
    ? context._currentValue
    : context._currentValue2;

  if (lastFullyObservedContext === context) {
    // Nothing to do. We already observe everything in this context.
  } else {
    const contextItem = {
      context: ((context: any): ReactContext<mixed>),
      memoizedValue: value,
      next: null,
    };

    if (lastContextDependency === null) {
      if (currentlyRenderingFiber === null) {
        throw new Error(
          'Context can only be read while React is rendering. ' +
            'In classes, you can read it in the render method or getDerivedStateFromProps. ' +
            'In function components, you can read it directly in the function body, but not ' +
            'inside Hooks like useReducer() or useMemo().',
        );
      }

      // This is the first dependency for this component. Create a new list.
      lastContextDependency = contextItem;
      currentlyRenderingFiber.dependencies = {
        lanes: NoLanes,
        firstContext: contextItem,
      };
      if (enableLazyContextPropagation) {
        currentlyRenderingFiber.flags |= NeedsPropagation;
      }
    } else {
      // Append a new context item.
      lastContextDependency = lastContextDependency.next = contextItem;
    }
  }
  return value;
}

由此看出,useContext的返回值是来自context 对象的_currentValue_currentValue2字段。

下一步我们看看 Provider组件时怎么提供值的,这里可以看看 packages/react-reconciler

function updateContextProvider(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  const providerType: ReactProviderType<any> = workInProgress.type;
  const context: ReactContext<any> = providerType._context;

  const newProps = workInProgress.pendingProps;
  const oldProps = workInProgress.memoizedProps;

  const newValue = newProps.value;

  pushProvider(workInProgress, context, newValue);

  if (enableLazyContextPropagation) {
  } else {
    if (oldProps !== null) {
      const oldValue = oldProps.value;
      if (is(oldValue, newValue)) {
        // No change. Bailout early if children are the same.
        if (
          oldProps.children === newProps.children &&
          !hasLegacyContextChanged()
        ) {
          return bailoutOnAlreadyFinishedWork(
            current,
            workInProgress,
            renderLanes,
          );
        }
      } else {
        propagateContextChange(workInProgress, context, renderLanes);
      }
    }
  }

  const newChildren = newProps.children;
  reconcileChildren(current, workInProgress, newChildren, renderLanes);
  return workInProgress.child;
}

在这一步,会给value做 diff,如果检测到发生变化,就会执行 propagateContextChange(workInProgress, context, renderLanes); 触发所有消费context的组件的重渲染。

而 Provider 给 context 提供 value 的关键代码是 pushProvider(workInProgress, context, newValue);

 export function pushProvider<T>(
  providerFiber: Fiber,
  context: ReactContext<T>,
  nextValue: T,
): void {
  if (isPrimaryRenderer) {
    push(valueCursor, context._currentValue, providerFiber);

    context._currentValue = nextValue;
  
      context._currentRenderer = rendererSigil;
    }
  } else {
    push(valueCursor, context._currentValue2, providerFiber);

    context._currentValue2 = nextValue;
   
      context._currentRenderer2 = rendererSigil;
    }
  }
}

这里可以看到我们的新value被赋给了context对象的 _currentValue2字段,