在 JavaScript 中实现和使用 Context

在 JavaScript 中实现和使用 Context

技术向约 3.3 千字

使用过 React 构建应用的开发者对 React Context 一定不会陌生。在 React 的世界中,相比于把 prop 不断透传给下一层子组件(prop-drilling),React Context 可以更优雅地自上而下将数据从父组件传递到深层级的子组件、并确保数据在不同子组件之间保持一致。不过,Context 绝不是仅属于 React,在 JavaScript 中 Context 一样可以大展拳脚。

调用栈与参数传递的噩梦

不论是用 UI 库构建应用,亦或者是组织大量的单元测试,亦或者是编写拥有复杂逻辑的后端程序,开发者经常会遇到在一个函数中调用另一个函数的情况。然后在被调用的另一个函数中,又不得不再调用第三个函数,直到整个调用栈已经有数层之深。在这些函数中传递变量很快就会变成一件非常混乱的事情:

const A = (a = 1) => {
  const b = fibonacci(a);
  const c = B(b, a);

  return c / d + 1;
}

function B(b, a) {
  return C(a) * b;
}

function C(a) {
  try {
    sideEffect(a);
    return 0;
  } catch {
    return 1;
  }
}

console.log(A(10));

在这个例子中,函数 B 其实并不直接用到变量 a,但是由于函数 B 需要调用函数 C、而函数 C 才需要用到变量 a,所以函数 B 不得不接受 a 作为参数、以便将 a 传给函数 C

这个例子已经被简化了很多;想象一下如果你的调用栈如果不止三层,而需要传递的变量不止一个,代码的复杂程度的增长速度很快就会让你措手不及。

在 React 的世界中,这个问题叫 prop-drilling,而 React 解决这个问题的办法就是 React Context:

在 React 的世界中

Context lets the parent component make some information available to any component in the tree below it—no matter how deep—without passing it explicitly through props.
-- Passing Data Deeply with Context, React Docs

React 的 Context 就好像一个虫洞一样。在父组件之中创建一个 Context、并填充一些数据,然后这个 Context 下的所有子组件都可以通过 useContext 获取到这些数据、不论这些子组件有多深:

// sidebar-active-context.tsx
const SidebarActiveContext = createContext<boolean>(false);
const SidebarActiveDispatchContext = createContext<React.Dispatch<React.SetStateAction<boolean>>>(noop);

export const useSidebarActive = () => useContext(SidebarActiveContext);
export const useSetSidebar = () => useContext(SidebarActiveDispatchContext);
export const SidebarActiveProvider = ({ children }: React.PropsWithChildren) => {
  const [active, setActive] = useState(false);
  return (
    <SidebarActiveContext.Provider value={active}>
      <SidebarActiveDispatchContext.Provider value={setActive}>
        {children}
      </SidebarActiveDispatchContext.Provider>
    </SidebarActiveContext.Provider>
  );
};

// 上述代码只是一个简单的例子。在实际的 React 应用中,上述代码可以被封装为一个 utility 函数、减少 boilerplate 代码:
// https://foxact.skk.moe/context-state
import { createContextState } from 'foxact/context-state';
const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive] = createContextState(false);
export { SidebarActiveProvider, useSidebarActive, useSetSidebarActive };

将应用包裹在 <SidebarActiveProvider /> 中,任何子组件就可以通过 useSidebarActive 获取到当前的 Sidebar 状态、并通过 useSetSidebarActive 修改 Sidebar 状态;更棒的是,只有实际使用 useSidebarActive 的子组件才会在状态改变时触发 re-render、仅使用 useSetSidebarActive 的子组件完全不会 re-render。

跳出 React 去思考,有没有什么办法在 JavaScript 的世界中也实现一个 Context 呢?

实现一个自己的 Context

在继续之前,我需要强调一下,实现 Context 需要依靠 JavaScript 的两个非常重要的性质「闭包」和「单线程」。这两个性质对于我们接下来实现 Context 来说非常重要。如果你对此很陌生,在理解接下来的内容时可能会有一些困难。

让我们看看 React Context 都是由哪些 API 组成的:

  • createContext:创建一个 Context,基本上就是为我们创建了一个存储和读写变量的容器。
  • Provider:Context 返回值的一个属性。在 React 中这是一个组件,也是我们「魔法虫洞」的入口
  • useContextConsumer:这是我们「魔法虫洞」的出口,调用时能从最近的 Context 中把变量拉出来。

为了便于理解,我们在实现 Context 时直接模仿一下 React Context 的 API:

const ValueContext = createContext();

// <ValueContext value={42}>
ValueContext.Provider(42, () => {
  callback1();
  callback2();
  // ....
});
// </ValueContext>

function callback1() {
  const value = useContext(ValueContext);
  console.log(value); // 42
}

知道了 Context 的 API 的形状,我们就可以开始搭建 Context 的框架了:

function createContext() {
  const Provider = (value, callback) => {
    // ...
  }

  const Consumer = () => {
    // ...
  }

  return {
    Provider,
    Consumer
  }
}

function useContext(contextRef) {
  // return ...
}

在这里我们声明了两个函数 createContextuseContextcreateContext 会返回一个 Context 对象,这个对象包含两个属性,分别是 ProviderConsumer 两个函数。useContext 接受的是 Context 对象本身的引用,我们希望它能够返回当前 Context 的值。

在 Context 中存储和读取变量

首先我们需要一个地方来存储 Context 的值。借助 JavaScript 的闭包特性,我们可以将这个值存在 createContext 的调用栈里:

function createContext() {
  let contextValue = undefined;

  const Provider = (value, callback) => {
  }

  const Consumer = () => {
  }

  return {
    Provider,
    Consumer
  }
}

现在,当我们调用 createContext 函数时,函数内部的作用域会创建一个变量 contextValue。这个变量属于当前函数的调用栈、不泄漏不共享。当多次调用 createContext 函数时,每个调用栈都会创建各自互不共享的 contextValue 变量。

现在,我们的 contextValue 已经储存在 createContext 的调用栈里了。接下来我们只需要让 Provider 能够从参数中接收一个值并存储在调用栈的变量里、再让 Consumer 能够读取这个变量:

function createContext() {
  let contextValue = undefined;

  const Provider = (value, callback) => {
    contextValue = value;
  }

  const Consumer = () => contextValue;

  return {
    Provider,
    Consumer
  }
}

现在 Context 返回的 Consumer 函数已经可以返回 contextValue 了,但是为了让我们的 API 更像 React 一些,我们可以通过 useContext 间接调用 Consumer

function useContext(contextRef) {
  return contextRef.Consumer();
}

执行回调函数

光光存储和读取变量还不够。我们的 Provider 接受一个回调函数,这个回调函数需要在 Provider 被调用时执行:

function createContext() {
  let contextValue = undefined;

  const Provider = (value, callback) => {
    contextValue = value;
    callback();
  }

  const Consumer = () => contextValue;

  return {
    Provider,
    Consumer
  }
}

因为 JavaScript 单线程的特性,我们在调用 callback 函数的时候,不会有任何其它代码在执行、因此我们无需担心 contextValue 的值在 callback 函数执行期间会发生改变。

现在,我们的 Context 已经可以在回调函数中读取变量了。我们只需要在 Provider 的回调参数中调用 useContext,就可以在回调函数中读取到当前的 contextValue 了:

const ValueContext = createContext();
ValueContext.Provider(42, () => {
  someFn();
});

function someFn() {
  const value = useContext(ValueContext);
  console.log(value); // 42
}

嵌套 Context

注意观察我们的 Provider 函数:

const Provider = (value, callback) => {
  contextValue = value;
  callback();
}

每次当我们的 Provider 函数结束时,我们的 contextValue 依然是我们传入的值。这意味着如果我们在一个 Provider 的回调函数中再次调用 Provider,那么第二个 Provider 结束时就会把整个 Context 的 contextValue 设置为新的值。这样的话,我们就无法在第一个 Provider 的回调函数中读取到原始的 contextValue 了。

幸运的是,我们只需要在 Provider 函数中保存当前 contextValue 的值、然后在 Provider 函数结束时,将 contextValue 重置为之前的值即可:

const Provider = (value, callback) => {
  const currentValue = contextValue;
  contextValue = value;
  callback();
  contextValue = currentValue;
}

现在,我们就可以嵌套 Provider 了:

const ValueContext = createContext();

ValueContext.Provider(42, () => {
  // 现在 contextValue 的值是 42、currentValue 的值是 undefined
  console.log(useContext(ValueContext)); // 42

  ValueContext.Provider(13, () => {
    // 现在 contextValue 的值是 13、currentValue 的值是上一层的 42
    console.log(useContext(ValueContext)); // 13

    ValueContext.Provider(2, () => {
      // 现在 contextValue 的值是 2、currentValue 的值是上一层的 13
      console.log(useContext(ValueContext)); // 2
    });

    // 我们退出了一层 Provider,现在 contextValue 的值被重置为 13,这一层的 currentValue 是上一层的 42
    console.log(useContext(ValueContext)); // 13
  });

  // 我们又退出了一层 Provider,现在 contextValue 的值被重置为 42
  // 已经没有更外面的 Provider 了,这一层的 currentValue 是 undefined
});

// 我们退出了最外层的 Provider,现在 contextValue 的值被重置为 undefined

最后的画龙点睛

我们已经实现了一个完整的 Context,但是我们的 Context 并不是完美的。例如,React Context 支持默认值,不在 <Provider /> 的子组件中调用 useContext 可以获取到默认值;由于我们的 Context 的初始值就是 undefined,意味着我们的 Context 就无法存储 undefined;即使是 React Context 也不完全是严格的,即使没有给 Context 提供默认值,依然可以在 <Provider /> 的作用域之外调用 useContext

我们可以通过一些小的改动来优化我们的 Context、并为我们的 Context 加上 TypeScript 类型声明:

// 我们使用 Symbol 来标记一个 Context 没有提供默认值
const NO_VALUE_DEFAULT = Symbol('NO_VALUE_DEFAULT');
type ContextValue<T> = T | typeof NO_VALUE_DEFAULT;

function createContext<T>(defaultValue: ContextValue<T> = NO_VALUE_DEFAULT) {
  let contextValue: ContextValue<T> = defaultValue;

  const Provider = (value: T, callback: () => void) => {
    const currentValue: ContextValue<T> = contextValue;
    contextValue = value;
    callback();
    contextValue = currentValue;
  }

  const Consumer = (): T => {
    // 只有当 Consumer 没有在 Provider 的作用域之内调用、且 Context 本身没有提供默认值时,
    // contextValue 才会是 NO_VALUE_DEFAULT 的 Symbol,此时我们可以抛出一个 TypeError
    if (contextValue === NO_VALUE_DEFAULT) {
      throw new TypeError('You should only use useContext inside a Provider, or provide a default value!');
    }
    // 由于 contextValue 的类型是 T | typeof NO_VALUE_DEFAULT,而我们在之前的 type guard 中
    // narrow 掉了 typeof NO_VALUE_DEFAULT,所以这里的 contextValue 的类型一定是 T
    return contextValue;
  };

  return {
    Provider,
    Consumer
  }
}

在实际场景中运用 Context

在 React 中使用 React Context 构建大型应用时十分方便,但是在更为广阔的 JavaScript 中,对 Context 编程范式的运用也比比皆是。以众多的 JavaScript 测试框架为例:

import { describe, it, beforeAll } from 'mocha';
import { expect } from 'chai';
// jest、bun、vitest 也都提供了类似的 API
import { describe, it, beforeAll, expect } from '@jest/globals';
import { describe, it, beforeAll, expect } from 'bun:test';
import { describe, it, beforeAll, expect } from 'vitest';

describe('add', () => {
  it('basic', () => {
    expect(add(1, 1)).toBe(2);
  });
});

describeit 都是基于 Context 实现的。为了更好地理解 Context 的运用,我们不妨试着简单实现一下单元测试框架中的 describeit 函数:

function describe(description, callback) {
  callback()
}

function it(text, callback) {
  try {
    callback()
    console.log("yeah~ " + text)
  } catch {
    console.log("ohno! " + text)
  }
}

在单元测试框架中,打印到控制台的信息不仅仅是来自 ittext,还有来自 describedescription

add > yeah~ basic
add > ohno! basic

我们怎样才能在 it 函数中获取到上一层调用栈 describe 函数的 description 参数呢?这就是 Context 的大显身手的时候了:

const DescribeContext = createContext();

function describe(description, callback) {
  DescribeContext.Provider({ description }, () => {
    callback();
  });
}

function it(text, callback) {
  const { description } = useContext(DescribeContext);
  try {
    callback()
    console.log(description + " > yeah~ " + text)
  } catch {
    console.log(description + " > ohno! " + text)
  }
}

在上述代码中,it 函数只能打印当前最近一层的 describe 函数的 description 参数,遇到 describe 函数嵌套时,就无能为力了。如何修改 Context 以实现记录所有 describe 函数的 description 参数,就留作读者的思考题了。

啊异步,烦人的异步

JavaScripe 语言本身是单线程的、同步的,但是 JavaScript 运行在的环境(浏览器、Node.js)却不是同步的。浏览器支持通过 fetch 发送网络请求,而 JavaScript 代码并不会完全停止运行、卡在那里等待服务器返回结果;Node.js 支持通过 fs 内置模块读写文件系统,而 Node.js 的 fs API 也同时支持同步和异步两种模式;绝大部分 JavaScript Runtime 都支持 Web 的 setTimeout API。

我们的 Context 的实现是建立在同步的基础上的,一旦我们的回调函数引入异步操作,我们就不再能保证 Context 能够正常工作:

const ValueContext = createContext(2);

ValueContext.Provider(42, () => {
  console.log(useContext(ValueContext)); // 42
  setTimeout(() => {
    console.log(useContext(ValueContext)); // 2 (?!?)
  }, 0);
  console.log(useContext(ValueContext)); // 42
});

在上面的例子中,我们在创建 Context 时设置了一个初始值 2。在 Provider 的回调函数中,通过 setTimeout 引入了异步操作。等到 setTimeout 内部的回调函数再执行的时候,ValueContext 内的 contextValue 已经被重置为 2 了。

JavaScript 引入了 Promise,Generator、async/await,让我们可以更方便地编写异步代码。然而,一旦一个函数被送入了 事件循环(Event Loop) 之中,这个函数的原始的调用栈就被替换成了事件循环、来自调用处的隐含信息(例如对调用栈上的变量的引用)也随之丢失了。

本文在这里不再介绍如何实现 AsyncContext。感兴趣的读者可以参考现在已有的实现:

除此之外,也已经有一个 AsyncContext 的草案 proposal-async-context 被提交给 TC39。截至本文写就,这个草案已经到达了 Stage 2,如果草案通过,那么 JavaScript 语言本身就会内置对 AsyncContext 的支持。

在 JavaScript 中实现和使用 Context
本文作者
Sukka
发布于
2024-02-11
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
如果你喜欢我的文章,或者我的文章有帮到你,可以考虑一下打赏作者
评论加载中...