从 Y.js 到 React UI —— 响应式状态投影架构
在上一篇文章中,我们构建了一个基于 "Map-Reduce" 理念的健壮 CRDT Schema。然而,拥有一个完美的数据模型只是第一步。在前端工程中,真正的挑战在于:如何将这个基于 Mutation(突变)和事件驱动的 Y.js 数据源,高效、合规且优雅地投影到基于 Immutability(不可变性)和 Render Cycle(渲染周期)的 React 组件树中。
如果处理不当,不仅会导致代码中充斥着难以维护的 useEffect 胶水代码,还会引发严重的性能问题(如全量重渲染)甚至无限循环。本文将详细阐述我基于 Jotai 原子化状态管理库构建的 "Atoms Projection" 架构。
TL;DR;
通过这套架构,我们将协同数据流构造为确定性的 React 数据流:
- 稳定性:UI 组件不再直接接触可变的 Y.js 对象,永远只消费不可变的快照。
- 高性能:通过
atomFamily实现按需订阅,通过eventFilter阻断无关更新,实现了 O(1) 复杂度的更新性能,与文档总大小无关。 - 可维护性:所有的转换逻辑、空值检查、类型守卫都封装在 Atom 定义层。组件代码极其干净,只关注渲染逻辑。
这套投影架构构成了前端应用的骨架。而在下一篇文章中,我们将探讨如何通过纯函数和 Origin 机制,安全、规范地修改这些数据。
Impedance Mismatch: 可变源与不可变视图
Y.js 和 React 在设计哲学上存在本质的 Impedance Mismatch:
- Y.js (Mutable & Observable): 数据是就地修改的(Mutable)。当我们调用
yMap.set('key', 'value')时,内存中的对象直接发生了变化。组件需要通过监听observe事件来感知变化。 - React (Immutable & Snapshot): UI 是状态的函数
UI = f(State)。React 依赖引用的变化(Reference Equality Check)来决定是否更新 DOM。
错误的实践:直接订阅
初学者常犯的错误是在每个组件内部直接订阅 Y.js 数据:
// ❌ 反模式:在组件内直接管理订阅
function CellComponent({ cell }) {
const [source, setSource] = useState(cell.get('source').toString());
useEffect(() => {
const handler = () => setSource(cell.get('source').toString());
cell.get('source').observe(handler); // 🔴 每个单元格都在独立监听
return () => cell.get('source').unobserve(handler);
}, [cell]);
return <Editor value={source} />;
}这种做法在 Notebook 场景下(可能包含数百个单元格)会导致:
- 内存泄漏风险:忘记清理
unobserve是常态。 - 渲染瀑布:状态分散,父子组件难以协调。
- 调试黑洞:无法通过 React DevTools 或 Redux DevTools 追踪状态变更流。
三层原子投影架构
为了解决上述问题,我们引入了 Jotai 以及 y-jotai 库,构建了一个严格分层的状态投影体系。其核心思想是:将 Y.js 的数据结构“投影”为 React 可消费的原子状态(Atoms)。
y-jotai是我自己的一个库:https://github.com/wibus-wee/jotai-yjs相较于其他的库而言,这个库的上手难度很高,心智负担较为严重,但是它能做到的,真的是独一无二的能力了,至少看一圈都没人做... 我不想老是重新搓轮子了,直接自己写一个轮子就好
我在之前的博文当中也有提到我的这个项目的来路
https://blog.wibus.ren/posts/programming/valtio-yjs-to-jotai-yjs
架构分为三层:Core Refs(核心引用) -> Snapshots(快照数据) -> UI Components(UI 组件)。
Layer 1: Core Refs
最底层是核心引用层。这些 Atom 并不持有具体的数据值,而是持有 Y.js 数据结构的引用。
// /atoms/notebook/projection/refs.ts
// 根节点引用:整个 Notebook 的入口
export const rootRefAtom = atom((get) => {
const state = get(notebookRuntimeStateAtom);
return state.root; // 返回 Y.Doc 中的根 Map
});
// 单元格 Map 引用:从根节点派生
export const cellMapRefAtom = atom((get) => {
const root = get(rootRefAtom);
return root ? getCellMapRef(root) : null;
});关键点:cellMapRefAtom 的值本身(即 Y.Map 对象)几乎不会改变,除非整个 Notebook 被重新加载。这为上层提供了极其稳定的依赖锚点,避免了不必要的重新计算。
Layer 2: Snapshots
我们使用 y-jotai 的 createYAtom 和 atomFamily 将 Y.js 的数据切片投影为不可变的快照。
2.1 单元格列表的投影
对于单元格顺序(Order),我们将其投影为一个纯字符串数组:
// /atoms/notebook/projection/snapshots/order.ts
export const cellIdsAtom = createYAtom({
yAtom: orderRefAtom, // 依赖 Layer 1 的引用
read: (arr) => arr?.toArray() ?? [], // 将 Y.Array 转换为 JS Array
equals: shallowEqual, // 只有当 ID 列表真正变化时,Atom 才会更新
resubscribeOnSourceChange: true,
});2.2 单元格数据的投影(atomFamily)
对于成百上千个单元格,我们不能将其全部加载到一个大对象中。我们使用 atomFamily 为每个单元格动态创建独立的 Atom:
// /atoms/notebook/projection/snapshots/cells.ts
// 1. 定位:获取特定 ID 的 Y.Map 引用
export const cellEntryFamily = atomFamily((cellId: string) =>
createYMapEntryAtom(cellMapRefAtom, cellId, { ... })
);
// 2. 投影:读取关键字段,并应用精准过滤
export const cellCoreFamily = atomFamily((cellId: string) =>
createYAtom({
yAtom: cellEntryFamily(cellId),
read: (cell) => ({
id: cell.get(CELL_ID),
kind: cell.get(CELL_KIND),
}),
// 🟢 性能核心:Event Filter
eventFilter: (evt) =>
evt.keysChanged.has(CELL_KIND) || evt.keysChanged.has(CELL_ID),
})
);我这里使用到了 y-jotai 的 eventFilter:
Y.js 的 observe 事件非常粗粒度,任何该 Map 下的变动(例如 source 文本变动)都会触发回调。但对于 cellCoreFamily 这个 Atom,我们只关心 kind 或 id 的变化。
通过 eventFilter,当用户疯狂打字修改 source 时,尽管底层的 Y.Map 在不断触发事件,cellCoreFamily Atom 会直接忽略这些事件,不会通知 React 组件重渲染。 只有当用户切换单元格类型(Markdown/SQL)时,该 Atom 才会更新。
Layer 3: UI Components
到达 UI 层时,所有复杂的 CRDT 逻辑、订阅管理、性能优化都已被屏蔽。组件只需要像消费普通状态一样消费这些 Atom。
const CellContainer = ({ cellId }) => {
// 只订阅核心元数据,打字时不会重渲染
const { kind } = useAtomValue(cellCoreFamily(cellId));
if (kind === 'sql') return <SqlCell cellId={cellId} />;
return <MarkdownCell cellId={cellId} />;
};幽灵更新:Resubscribe
在我们的场景下,可能会发生极其特殊的 Case:引用的替换。例如,我们的 auto-stale 机制在极端情况下可能会替换掉单元格内的 source(Y.Text 对象)。如果我们仅订阅了旧的 Y.Text 对象,新对象的变更将无法被感知。
为此,我们在 createYAtom 中启用了 resubscribeOnSourceChange: true。这确保了当 Atom 依赖的上游 Y.js 对象引用发生物理变化(被另一个 Y.Text 覆盖)时,订阅会自动销毁并重建,指向新的对象。这保证了状态链路在任何动态结构变更下的鲁棒性。