编辑
2024-05-21
技术相关
00
请注意,本文编写于 338 天前,最后修改于 338 天前,其中某些信息可能已经过时。

目录

在 JavaScript 中实现和使用 Context
调用栈与参数传递的噩梦
在 React 的世界中
实现一个自己的 Context
在 Context 中存储和读取变量
执行回调函数
嵌套 Context
最后的画龙点睛
在实际场景中运用 Context
啊异步,烦人的异步

在 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 中这是一个组件,也是我们「魔法虫洞」的入口
  • 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 的支持。

本文作者:Kevin@灼华

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!