使用过 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:
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 需要依靠 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 的值。借助 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 }
注意观察我们的 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 } }
在 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 的支持。
本文作者:Kevin@灼华
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!