跳到主要内容

第 1 章 · tap:无头的 React hooks(整个运行时的地基)

本章讲什么: assistant-ui 没有用 Redux/Zustand/MobX 去管运行时状态,而是发明了 @assistant-ui/tap——让 React 的 hooks 脱离 React 树也能跑。读懂这一章,上面几层的「魔法」就都化开了。

1.1 它要解决的小问题

聊天运行时有大量有状态、要响应变化的逻辑:当前消息列表、是否在跑、流式累积、分支……这些天然适合用 hooks(useState/useEffect/useMemo)来表达。

但有个硬约束:React 的 hooks 只能在 React 组件渲染时调用。运行时逻辑却需要在没有组件、甚至没有 React 的地方运行(比如框架无关的 core、或者被命令式地 subscribe)。

常规做法是另起炉灶,用一套独立的状态库。assistant-ui 偏不——它问:能不能让同一套 hooks 心智,既在 React 里跑,也在 React 外跑?

1.2 思路 / 直觉

tap 的 README 一句话点破:

「React 的 hooks,无头化。写一个 resource 就像写组件一样,用同样的 hooks(从 "react" 导入)、同样的规则,只是 resource 返回一个普通值而不是 JSX。」

也就是说,你照常写 const [x, setX] = useState(0),但把它包成一个 resource;这个 resource 可以:

  • 在 React 组件里被 useResource 托管,
  • 在另一个 resource 里被组合,
  • 或者完全脱离 React,用 createTapRoot 独立跑、subscribe 监听。

关键直觉:把「一段带状态的逻辑单元」当成一等公民,而不是绑死在某个组件实例上。

1.3 它怎么做到的(核心机巧)

React 的 hooks 之所以「知道」自己在哪个组件里,是因为渲染时 React 设置了一个内部的 dispatcher——useState 等其实是转发到「当前 dispatcher」上的方法。tap 的全部魔法就一句话:

在 resource 的函数体执行期间,临时把 React 的 dispatcher 换成 tap 自己的实现。

packages/tap/src/core/react-dispatcher.ts:71-81withReactDispatcher:

// 真实源码片段:react-dispatcher.ts:71-81
export function withReactDispatcher<T>(render: () => T): T {
if (!slot) return render();
const previous = slot.current;
slot.current = tapDispatcher; // 换成 tap 的 useState/useEffect/...
try {
return render(); // resource 体执行,react 的 hooks 全路由到 tap
} finally {
slot.current = previous; // 用完恢复,绝不污染 React 自己的渲染
}
}

这里 slot 是从 React 内部「偷」来的 dispatcher 槽位。注释(react-dispatcher.ts:34-39)说明它兼容 React 18 和 19 的不同内部字段:React 19 是 internals 上的 H,React 18 是 ReactCurrentDispatcher.currenttapDispatcher(react-dispatcher.ts:18-32)就是一张「假 dispatcher」,把 useState/useReducer/useMemo/useEffect/useContext 等映射到 tap 自己实现的同名 hooks。

没有 dispatcher 槽位(不支持的 React 版本)怎么办? 优雅降级:withReactDispatcher 直接 render(),里面的 react hooks 会照常抛 React 的「invalid hook call」(react-dispatcher.ts:64-69 的注释)。

妙处:tap 不发明新 API,直接复用 import { useState } from "react",所以零构建步骤、还能被 React 的 lint 规则检查(AGENTS.md:66-67)。代价是依赖 React 未公开的内部字段——这也是为什么要做 18/19 双兼容。

1.4 resource 是怎么「记住」状态的:ResourceFiber

组件靠 React 的 fiber 存 hooks 状态。resource 靠 tap 自己的 ResourceFiber(packages/tap/src/core/types.ts:86-111)。它就是一个普通对象,关键字段:

字段作用
cells按调用顺序存每个 hook 的状态(reducer/memo/effect cell)。这就是 hooks「按顺序、不能放 if 里」规则的来源。
currentIndex当前渲染读到第几个 hook。
root所属的 tap 根,承载版本号与更新派发。
isMounted / isFirstRender生命周期标志,决定 effect 何时跑/清理。

渲染一个 resource 就是「按顺序重放它的 hooks」。看 packages/tap/src/core/ResourceFiber.ts:48-79renderResourceFiber:它在 withResourceFiber(设置「当前 fiber」)+ withReactDispatcher(换 dispatcher)两层包裹里调用 fiber.hook(...args)。注意它有个 do...while 重渲染循环passes > 25 的上限(ResourceFiber.ts:60-74)——和 React 一样,渲染期间的 setState 会触发同步重渲染,但限次防死循环。

1.5 怎么用(三种姿势)

① 把一段逻辑包成 resource:

// 示意,改写自 tap/README
import { resource } from "@assistant-ui/tap";
import { useState, useEffect } from "react";

const useCounter = ({ incrementBy = 1 }) => {
const [count, setCount] = useState(0);
useEffect(() => { console.log("count:", count); }, [count]);
return { count, increment: () => setCount((c) => c + incrementBy) };
};

const Counter = resource(useCounter); // 现在它是一个 resource

② 脱离 React 跑(命令式):

// 示意
import { createTapRoot, useResource } from "@assistant-ui/tap";

const counter = createTapRoot(function CounterRoot() {
return useResource(Counter({ incrementBy: 2 }));
});
counter.subscribe(() => console.log(counter.getValue().count)); // 像 store 一样订阅
counter.getValue().increment(); // 没有任何 React 组件,照样工作

③ 在 React 组件里托管:

// 示意
function CounterButton() {
const { count, increment } = useResource(Counter({ incrementBy: 1 }));
return <button onClick={increment}>{count}</button>;
}

useResource(packages/tap/src/hooks/useResource.ts:11-32)是这三者的桥:它在宿主组件里 useMemo 出一个 fiber、用 renderResourceFiber 渲染、用两个 useEffect 管 commit 与 unmount——把 resource 的生命周期挂到宿主组件上。

1.6 更新怎么批处理:scheduler

脱离 React 后,「setState 之后什么时候重渲染」要 tap 自己管。packages/tap/src/core/scheduler.ts 用一个 UpdateScheduler:markDirty() 把自己加进待刷新集合并安排一次刷新(scheduler.ts:23-28)。刷新MessageChannel 排成宏任务(scheduler.ts:83-93)——注释说这是「像 React 调度器那样,让更多 setState 批进同一次重渲染」。另有 flushTapSync(scheduler.ts:95-110)在需要同步结果时立刻刷。

1.7 关键细节 / 坑

  • hook 顺序仍然神圣。 resource 用 cells 数组按顺序存状态,所以和 React 一样:不能把 hook 放进条件分支。
  • strict mode 双渲染。 开发模式下 createTapRoot 会先渲染一次再正式渲染(createTapRoot.ts:33-37),复刻 React StrictMode 的「双调用揪副作用」。
  • 命名很重要(为了 lint)。 因为 oxlint 用 React 的规则检查 resource,hook 必须 use 前缀、root 要用具名函数表达式,React 才认得出来(AGENTS.md:66-67)。

1.8 代码地图

主题文件符号
公共 APIpackages/tap/src/index.tsresourceuseResourcecreateTapRootflushTapSync
dispatcher 偷换(核心机巧)packages/tap/src/core/react-dispatcher.tswithReactDispatchertapDispatcherslot
resource 状态容器packages/tap/src/core/types.tsResourceFiberCell
渲染/提交/卸载packages/tap/src/core/ResourceFiber.tsrenderResourceFibercommitResourceFiberunmountResourceFiber
脱离 React 跑packages/tap/src/core/createTapRoot.tscreateTapRoot
批处理调度packages/tap/src/core/scheduler.tsUpdateSchedulerflushTapSync
在 React 里托管packages/tap/src/hooks/useResource.tsuseResource