在 JavaScript 中实现和使用 Context
使用过 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 中这是一个组件,也是我们「魔法虫洞」的入口useContext
和Consumer
:这是我们「魔法虫洞」的出口,调用时能从最近的 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 ...
}
在这里我们声明了两个函数 createContext
和 useContext
。createContext
会返回一个 Context 对象,这个对象包含两个属性,分别是 Provider
和 Consumer
两个函数。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);
});
});
describe
、it
都是基于 Context 实现的。为了更好地理解 Context 的运用,我们不妨试着简单实现一下单元测试框架中的 describe
和 it
函数:
function describe(description, callback) {
callback()
}
function it(text, callback) {
try {
callback()
console.log("yeah~ " + text)
} catch {
console.log("ohno! " + text)
}
}
在单元测试框架中,打印到控制台的信息不仅仅是来自 it
的 text
,还有来自 describe
的 description
:
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 的支持。