IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    React Context的核心实现,就5行代码

    卡颂发表于 2023-07-03 09:20:53
    love 0

    大家好,我卡颂。

    很多项目的源码非常复杂,让人望而却步。但在打退堂鼓前,我们应该思考一个问题:源码为什么复杂?

    造成源码复杂的原因不外乎有三个:

    1. 功能本身复杂,造成代码复杂
    2. 编写者功力不行,写的代码复杂
    3. 功能本身不复杂,但同一个模块耦合了太多功能,看起来复杂

    如果是原因3,那实际理解起来其实并不难。我们需要的只是有人能帮我们剔除无关功能的干扰。

    React Context的实现就是个典型例子,当剔除无关功能的干扰后,他的核心实现,仅需5行代码。

    本文就让我们看看React Context的核心实现。

    欢迎围观朋友圈、加入人类高质量前端交流群,带飞

    简化模型

    Context的完整工作流程包括3步:

    1. 定义context
    2. 赋值context
    3. 消费context

    以下面的代码举例:

    const ctx = createContext(null);
    
    function App() {
        return (
            <ctx.Provider value={1}>
                <Cpn />
            </ctx.Provider>
        );
    }
    
    function Cpn() {
        const num = useContext(ctx);
        return <div>{num}</div>;
    }

    其中:

    • const ctx = createContext(null) 用于定义
    • <ctx.Provider value={1}> 用于赋值
    • const num = useContext(ctx) 用于消费

    Context数据结构(即createContext方法的返回值)也很简单:

    function createContext(defaultValue) {
      const context = {
        $$typeof: REACT_CONTEXT_TYPE,
        Provider: null,
        _currentValue: defaultValue
      };
    
      context.Provider = {
        $$typeof: REACT_PROVIDER_TYPE,
        _context: context
      };
      return context;
    }

    其中context._currentValue保存context当前值。

    context工作流程的三个步骤其实可以概括为:

    1. 实例化context,并将默认值defaultValue赋值给context._currentValue
    2. 每遇到一个同类型context.Provier,将value赋值给context._currentValue
    3. useContext(context)就是简单的取context._currentValue的值就行

    了解了工作流程后我们会发现,Context的核心实现其实就是步骤2。

    核心实现

    核心实现需要考虑什么呢?还是以上面的示例为例,当前只有一层<ctx.Provider>包裹<Cpn />:

    function App() {
        return (
            <ctx.Provider value={1}>
                <Cpn />
            </ctx.Provider>
        );
    }

    在实际项目中,消费ctx的组件(示例中的<Cpn/>)可能被多级<ctx.Provider>包裹,比如:

    const ctx = createContext(0);
    
    function App() {
        return (
        <ctx.Provider value={1}>
          <ctx.Provider value={2}>
            <ctx.Provider value={3}>
              <Cpn />
            </ctx.Provider>
            <Cpn />
          </ctx.Provider>
          <Cpn />
        </ctx.Provider>
      );
    }

    在上面代码中,ctx的值会从0(默认值)逐级变为3,再从3逐级变为0,所以沿途消费ctx的<Cpn />组件取得的值分别为:3、2、1。

    整个流程就像操作一个栈,1、2、3分别入栈,3、2、1分别出栈,过程中栈顶的值就是context当前的值。

    基于此,context的核心逻辑包括两个函数:

    function pushProvider(context, newValue) {
        // ...
    }
    
    function popProvider(context) {
        // ...
    }

    其中:

    • 进入ctx.Provider时,执行pushProvider方法,类比入栈操作
    • 离开ctx.Provider时,执行popProvider方法,类比出栈操作

    每次执行pushProvider时将context._currentValue更新为当前值:

    function pushProvider(context, newValue) {
        context._currentValue = newValue;
    }

    同理,popProvider执行时将context._currentValue更新为上一个context._currentValue:

    function popProvider(context) {
        context._currentValue = /* 上一个context value */
    }

    该如何表示上一个值呢?我们可以增加一个全局变量prevContextValue,用于保存上一个同类型的context._currentValue:

    let prevContextValue = null;
    
    function pushProvider(context, newValue) {
        // 保存上一个同类型context value
      prevContextValue = context._currentValue;
      context._currentValue = newValue;
    }
    
    function popProvider(context) {
      context._currentValue = prevContextValue;
    }

    在pushProvider中,执行如下语句前:

    context._currentValue = newValue;

    context._currentValue中保存的就是上一个同类型的context._currentValue,将其赋值给prevContextValue。

    以下面代码举例:

    const ctx = createContext(0);
    
    function App() {
        return (
            <ctx.Provider value={1}>
                <Cpn />
            </ctx.Provider>
        );
    }

    进入ctx.Provider时:

    • prevContextValue赋值为0(context实例化时传递的默认值)
    • context._currentValue赋值为1(当前值)

    当<Cpn />消费ctx时,取得的值就是1。

    离开ctx.Provider时:

    • context._currentValue赋值为0(prevContextValue对应值)

    但是,我们当前的实现只能应对一层ctx.Provider,如果是多层ctx.Provider嵌套,我们不知道沿途ctx.Provider对应的prevContextValue。

    所以,我们可以增加一个栈,用于保存沿途所有ctx.Provider对应的prevContextValue:

    const prevContextValueStack = [];
    let prevContextValue = null;
    
    function pushProvider(context, newValue) {
        prevContextValueStack.push(prevContextValue);
      
        prevContextValue = context._currentValue;
        context._currentValue = newValue;
    }
    
    function popProvider(context) {
        context._currentValue = prevContextValue;
        prevContextValue = prevContextValueStack.pop();
    }

    其中:

    • 执行pushProvider时,让prevContextValue入栈
    • 执行popProvider时,让prevContextValue出栈

    至此,完成了React Context的核心逻辑,其中pushProvider三行代码,popProvider两行代码。

    两个有意思的点

    关于Context的实现,有两个有意思的点。

    第一个点:这个实现太过简洁(核心就5行代码),以至于让人严重怀疑是不是有bug?

    比如,全局变量prevContextValue用于保存上一个同类型的context._currentValue,如果我们把不同context嵌套使用时会不会有问题?

    在下面代码中,ctxA与ctxB嵌套出现:

    const ctxA = createContext('default A');
    const ctxB = createContext('default B');
    
    function App() {
      return (
        <ctxA.Provider value={'A0'}>
          <ctxB.Provider value={'B0'}>
            <ctxA.Provider value={'A1'}>
              <Cpn />
            </ctxA.Provider>
          </ctxB.Provider>
          <Cpn />
        </ctxA.Provider>
      );
    }

    当离开最内层ctxA.Provider时,ctxA._currentValue应该从'A1'变为'A0'。考虑到prevContextValue变量的唯一性以及栈的特性,ctxA._currentValue会不会错误的变为'B0'?

    答案是:不会。

    JSX结构的确定意味着以下两点是确定的:

    1. ctx.Provider的进入与离开顺序
    2. 多个ctx.Provider之间嵌套的顺序

    第一点保证了当进入与离开同一个ctx.Provider时,prevContextValue的值始终与该ctx相关。

    第二点保证了不同ctx.Provider的prevContextValue被以正确的顺序入栈、出栈。

    第二个有意思的点:我们知道,Hook的使用有个限制 —— 不能在条件语句中使用hook。

    究其原因,对于同一个函数组件,Hook的数据保存在一条链表上,所以必须保证遍历链表时,链表数据与Hook一一对应。

    但我们发现,useContext获取的其实并不是链表数据,而是ctx._currentValue,这意味着useContext其实是不受这个限制影响的。

    总结

    以上五行代码便是React Context的核心实现。在实际的React源码中,Context相关代码远不止五行,这是因为他与其他特性耦合在一块,比如:

    • 性能优化相关代码
    • SSR相关代码

    所以,当我们面对复杂代码时,不要轻言放弃。仔细分析下,没准儿核心代码只有几行呢?



沪ICP备19023445号-2号
友情链接