:从 Google Docs 式版本追踪到高效编码)
数据同步前端【免费下载链接】yjsShared data types for building collaborative software项目地址https://gitcode.com/GitHub_Trending/yj/yjs点击查看免费下载本篇文章以 Yjs 仓库中的 attributing-content.md 为骨架系统讲解 Yjs 用于实现内容归属Attribution的两大核心数据结构——IdSet与IdMap以及它们如何支撑起类似 Google Docs 的变更标注能力。读完本文你将掌握如何用IdSet/IdMap高效表示 ID 区间、如何通过 diff/merge/intersect 等集合运算分离出某个版本之后发生了哪些插入与删除如何将变更归属Attribution映射到具体用户并借助渲染器Renderer输出带标注的 Delta以及这些数据最终如何在网络传输中被压缩编码。为什么需要内容归属Google Docs 式版本追踪在协同编辑场景中仅仅知道文档当前长什么样往往不够。当用户在 Google Docs 中点击某个历史版本时界面会展示类似这样的注解式变更——谁在何时插入了什么、删除了什么、修改了哪些格式# 例如Bob 在上一版本 hello 之后追加了 world [{ insert: hello }, { insert: world, color: blue, creator: Bob, when: yesterday }] # 例如Bob 从上一版本 hello world 中删除了 world [{ insert: hello }, { insert: world, backgroundColor: red, creator: Bob, when: yesterday }]Yjs 采用完全不同的思路来实现这一目标所有变更在 Yjs 中都可以通过 ID 唯一标识ID 由client与clock构成见 src/utils/ID.js。因此我们可以把一段内容抽象成一组 ID 区间把某个用户改了哪些内容抽象成从 ID 区间到属性Attribution的映射。渲染时toString()、toDelta()这类方法对无归属的内容原样输出而对有归属的内容则附带额外信息如creator、when由编辑器决定如何用背景色等视觉效果呈现。这套机制在仓库中的正式名称为Content Attribution内容归属其官方文档正是 attributing-content.md底层实现分布在 src/utils/ids.js、src/utils/Renderer.js 与 src/utils/renderer-helpers.js 中。两大基础数据结构IdSet 与 IdMapIdSetID 区间的紧凑表示IdSet是 Yjs 中用于高效表示一组 ID 区间的数据结构其前身名为DeleteSet历史上用于表示已删除内容。在 Yjs 中一切内容都由 ID 标识因此任何内容集合——例如文档里所有插入的内容或文档里所有被删除的内容——都可以表达为若干条(client, clock, length)形式的 ID 区间。从源码结构看src/utils/ids.js 中的IdSet围绕客户端 IDclient组织区间列表内部为每个 client 维护一组排序合并后的IdRange相邻区间会被合并因此同样的内容集合占用空间极小。createIdSet()ids.js用于创建空集insertIntoIdSetids.js负责插入/合并区间。IdMap从 ID 到属性的映射IdMap是相对较新的数据结构用于将 ID 区间映射到属性列表Attribution。它与IdSet的结构类似但每个区间额外携带一个ArrayContentAttribute。一个ContentAttribute就是一条形如{ name: insert, val: Bob }的记录由createContentAttribute(name, val)ids.js创建用于表达这段区间被谁以什么方式修改。通用集合运算diff、merge、intersectIdSet与IdMap均支持全套标准集合运算这正是归属功能的基础。文档明确指出We can perform all usual set operations onIdMaps andIdSets: diff, merge, intersect.对应到 src/utils/ids.js 中的实现运算IdSet 版本IdMap 版本语义差集diffdiffIdSetL721diffIdMapL1666从集合 A 中剔除集合 B 覆盖的区间并集mergemergeIdSetsL576mergeIdMapsL1421合并多个集合属性按区间拼接去重交集intersectintersectSetsL773intersectMapsL1673取两集合共同覆盖的区间此外还有createIdSetFromIdMapL1485可以把一个IdMap的覆盖范围提取为IdSet供快速intersects/covers查询使用。从文档中提取变更三类关键工厂函数要为一组变更打上归属第一步是从文档结构中提取变更集。文档与源码提供了三个相互配合的工厂函数Y.createInsertSetFromStructStore(store, filterDeleted)ids.js遍历某个文档的 StructStore返回其中所有插入内容的IdSet。第二个参数为false时不做删除过滤。Y.createDeleteSetFromStructStore(store)ids.js返回该文档中所有已删除内容的IdSet。Y.diffIdSet(a, b)计算a - b用于排除掉某个基准版本已有的内容从而得到从这个版本到那个版本之间新增的变更。例如若要找出ydoc相对于ydocVersion0的所有插入与删除文档给出的核心模式是const insertionSet Y.createInsertSetFromStructStore(ydoc.store, false) const deleteSet Y.createDeleteSetFromStructStore(ydoc.store) // exclude the changes from ydocVersion0 const insertionSetDiff Y.diffIdSet(insertionSet, Y.createInsertSetFromStructStore(ydocVersion0.store, false)) const deleteSetDiff Y.diffIdSet(deleteSet, Y.createDeleteSetFromStructStore(ydocVersion0.store))实战为一段文本变更添加归属文档给出了一个完整可运行的端到端示例我们在此完整继承并逐步讲解。第一步构造基准文档与变更文档// We create some initial content Hello World!. Then we create another // document that will have a bunch of changes (make Hell italic, replace World // with attributions). const ydocVersion0 new Y.Doc({ gc: false }) ydocVersion0.get().insert(0, Hello World!) const ydoc new Y.Doc({ gc: false }) Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocVersion0)) const ytext ydoc.get() ytext.applyDelta(delta.create().retain(4, { italic: true }).retain(2).delete(5).insert(attributions).done())要点说明gc: false至关重要。归属功能需要读取已被删除的内容例如渲染被删除的 World如果开启垃圾回收这些内容会被 GC 掉渲染器将无法还原。这一点在 Renderer.js 的AttributionsRenderer文档注释中也有明确要求Restoring deleted content requiresgc: falseon the doc。ytext.applyDelta依次执行retain(4, { italic: true })前 4 个字符 Hell 加斜体、retain(2)跳过 o 、delete(5)删除 World、insert(attributions)插入新词。这里delta.create()是来自lib0/delta的 Delta 构建器在 tests/attribution.tests.js 中同样通过import * as delta from lib0/delta使用Y.applyUpdate/Y.encodeStateAsUpdate是 Yjs 的标准更新同步 API。第二步计算基准版本与当前版本的变更差集// this represents all insertions of ydoc const insertionSet Y.createInsertSetFromStructStore(ydoc.store, false) const deleteSet Y.createDeleteSetFromStructStore(ydoc.store) // exclude the changes from ydocVersion0 const insertionSetDiff Y.diffIdSet(insertionSet, Y.createInsertSetFromStructStore(ydocVersion0.store, false)) const deleteSetDiff Y.diffIdSet(deleteSet, Y.createDeleteSetFromStructStore(ydocVersion0.store))此时insertionSetDiff中只包含 attributions 这个新插入的区间以及格式变更相关的区间deleteSetDiff中只包含被删除的 World 区间——基准版本原有的 Hello World! 都被diffIdSet排除掉了。第三步将差集映射为归属Attribution// assign attributes to the diff const attributedInsertions Y.createIdMapFromIdSet(insertionSetDiff, [Y.createContentAttribute(insert, Bob)]) const attributedDeletions Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute(delete, Bob)])Y.createIdMapFromIdSet(idset, attrs)ids.js把一份IdSet整体包装成IdMap并给其中的每个区间都附上同一组属性。这里我们用createContentAttribute(insert, Bob)表示这些内容是 Bob 插入的用createContentAttribute(delete, Bob)表示这些内容是 Bob 删除的。第四步用渲染器输出带归属的 Delta// now we can define a renderer that maps these changes to output. One of the // implementations is the TwosetRenderer const renderer new Y.TwosetRenderer(attributedInsertions, attributedDeletions) // we render the attributed content with the renderer const attributedContent ytext.toDelta({ renderer }) console.log(JSON.stringify(attributedContent.toJSON(), null, 2)) const expectedContent delta.create().insert(Hell, { italic: true }, { format: { italic: [Bob] } }).insert(o ).insert(World, {}, { delete: [Bob] }).insert(attributions, {}, { insert: [Bob] }).insert(!) t.assert(attributedContent.equals(expectedContent))ytext.toDelta({ renderer })让渲染器介入 Delta 生成过程。从 renderer-helpers.js 的attributionJsonSchemaL7可以确认归属信息的标准 JSON 形态Delta 操作可以附带insert字符串数组表示插入者、delete字符串数组表示删除者、format{ 格式名: [归属者列表] }等字段这正是文档示例输出中attribution对象的来源。需要说明的是文档中提到的TwosetRenderer属于示例性质的渲染器之一在当前仓库中AbstractRenderer的具体实现由 src/utils/Renderer.js 提供包括AttributionsRendererL63将{ inserts, deletes }两份归属图合并渲染、DiffRendererL359比较两个文档并归属两者之间的差异、SnapshotRendererL613面向快照场景。它们输出的归属格式与文档示例保持一致——这一点由 tests/attribution.tests.js 中的testAttributionSession1L143等用例直接断言验证例如期望insert(a, null, { insert: [0] })、insert(b, null, { delete: [0] })这样的输出形态。第五步解读渲染结果上述代码会输出如下 JSON文档原样给出{ type: delta, children: [ { type: insert, insert: Hell, format: { italic: true }, attribution: { // no insert attribution: the insertion Hell is not attributed to anyone format: { italic: [ // the formatting attribute italic was added by Bob Bob ] } } }, { type: insert, insert: o // the insertion o has no attributions }, { type: insert, insert: World, attribution: { // the insertion World was deleted by Bob delete: [ Bob ] } }, { type: insert, insert: attributions, // the insertion attributions was inserted by Bob attribution: { insert: [ Bob ] } }, { type: insert, insert: ! // the insertion ! has no attributions } ] }这段输出与 Google Docs 的版本注解语义高度一致Hell这个插入本身不归属于任何人但它携带的italic格式是 Bob 添加的因此attribution.format.italic [Bob]o 与!是基准版本就存在且未被修改的内容没有任何归属World是被 Bob 删除的内容归属标记为attribution.delete [Bob]attributions是 Bob 插入的新内容归属标记为attribution.insert [Bob]。渲染器只负责把这些归属信息翻译进 Delta至于最终用背景色如红色高亮删除、蓝色高亮插入等视觉效果呈现则是编辑器层的职责——这正是文档所说的 It will be the job of the editor to render those changes with background-color etc.。多用户归属与按用户筛选内容当然可以归属于多个用户。文档给出的示例是为同一段删除同时附加两条属性const attributedDeletions Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute(insert, Bob), Y.createContentAttribute(insert, OpenAI o3)])在真实协同系统中更常见的做法是监听每个用户的 update 事件动态累积全局归属表。仓库测试 testAttributionSession1 给出了这一模式的完整实现const globalAttributions Y.createContentMap() // 见 src/utils/meta.js#L103 users.forEach(user user.on(update, (update, _, ydoc, tr) { if (!tr.local) return const userid ydoc.clientID.toString() const contentIds Y.createContentIdsFromUpdate(update) Y.insertIntoIdMap(globalAttributions.inserts, Y.createIdMapFromIdSet(contentIds.inserts, [Y.createContentAttribute(insert, userid)])) Y.insertIntoIdMap(globalAttributions.deletes, Y.createIdMapFromIdSet(contentIds.deletes, [Y.createContentAttribute(delete, userid)])) }))这里的Y.createContentMap()meta.js返回{ inserts, deletes }两份IdMap的包装结构是归属渲染器的标准输入格式createContentIdsFromUpdate从一次网络同步的 update 中提取插入/删除 ID 区间。测试随后通过Y.filterIdMap(...)筛选出仅属于某个用户的归属构造出只看 Bob 的改动的专属渲染器再用Y.undoContentIds撤销特定用户的改动——这些都是 src/utils/meta.js 提供的高层辅助能力。测试 testYdocDiff 还验证了Y.diffDocsToDelta(ydocStart, ydocUpdated)这一高层 API它直接比较两个文档并输出带{ insert: [] }/{ delete: [] }归属标记的 Delta空数组表示有变更但未命名归属者。AbstractRenderer 与自定义高亮渲染AbstractRenderer是渲染器的抽象基类负责把归属信息映射到内容上。它在 src/utils/renderer-helpers.js 中定义核心契约有三个hasItem(item)判断某个 Item 是否命中归属集合。只有被hasItem认领的内容才会走渲染器路径其余内容走通用快速路径原样渲染见 Renderer.js 中AttributionsRenderer.hasItem的实现。readContent(...)把内容按归属区间切分逐个产出AttributedContentrenderer-helpers.js携带content、clock、deleted、attrs与渲染行为标记。contentLength(item)计算归属内容渲染后的长度供遍历迭代器使用。文档明确指出通过这种抽象It is possible to highlight arbitrary content with this approach——即任何内容片段都可以被任意高亮/标注而不仅限于插入与删除。rendererContentLengthrenderer-helpers.js提供了通用长度规则存活可计数内容全量渲染其余为 0除非渲染器认领供各具体渲染器复用。渲染器还可以通过ObservableV2派发change事件在归属发生变化后通知上层增量更新当前内容的归属标注。从归属输出计算真实 Diff由于归属输出本身就是结构化的 Delta文档指出可以直接用它计算纯 diff只包含删除与插入、去掉归属信息You could use the same output to calculate a real diff as well (consisting of deletions and insertions only, without Attributions).换言之同一份渲染结果既能驱动版本注解视图展示谁改了哪里也能驱动普通的差异视图只展示改了什么。这是toDelta({ renderer })输出模型带来的直接红利也解释了为什么仓库测试如 testAttributedEvents会反复断言insert(world, null, { delete: [] })这类形态归属存在与否、归属为空与否都在同一份 Delta 结构中表达。高效编码RLE 与属性去重归属数据最终需要与文档更新一起在网络上传输因此编码效率至关重要。文档给出了两个硬性结论ID 采用游程编码run-length encoding相邻的 ID 区间合并后以(client, clock, length)三元组紧凑表达属性去重、只编码一次相同的属性如insert、Bob不会重复出现。实测结果上述示例整体仅编码为 27 字节。在仓库实现中这一高效编码由 src/utils/BlockSet.js 承担。writeBlockSetL87按 client 分组写出块引用GC/Skip/ItemreadBlockSetL25对称地读回BlockSet.toIdSet()L118可把块集合还原为IdSetexclude()L144则把被排除的区间替换为Skip占位块以保持编码紧凑。区间排序合并的逻辑位于 ids.js 的AttrRanges.getIds()L1118中先按clock排序再对重叠区间切分、对相邻同属性区间合并这正是 RLE 压缩的算法基础。测试验证与可靠性归属功能并非纸面设计仓库通过 tests/attribution.tests.js 提供了系统性的验证可作为读者理解行为预期的活文档testAttributionSession1L143多用户协同下的全局归属累积、按用户筛选、撤销单用户改动testAttributedEventsL38与testAttributionEventL180在事件回调中通过event.getDelta({ renderer })获取带归属的变更 Delta验证删除段落时子内容也会获得归属testAttributionChangeL203渲染器change事件在归属变化时触发并提供三态null 清除归属更新testInsertionsMindingAttributedContentL62与testInsertionsIntoAttributedContentL80在带归属渲染器下继续插入内容文档实际状态依然正确归属视图与真实状态互不干扰testRdtDeltaAttributionSanityL755维护中的增量 Delta 缓存与全新深渲染结果保持一致。小结Yjs 的内容归属机制是一条完整的技术链路以IdSet紧凑表示 ID 区间以IdMap将区间映射到归属属性通过diff/merge/intersect 集合运算分离版本间变更借助AbstractRenderer家族把归属渲染进 Delta最后以RLE 属性去重的方式压缩编码。这套设计让 Yjs 在保持 CRDT 数据模型不变的前提下原生支持谁在何时改了什么的版本注解能力且与快照、撤销、多用户协同等既有能力自然衔接。无论是实现一个 Google Docs 风格的版本历史面板、一个审阅/建议模式还是一个按用户着色的高亮层上述数据结构与渲染器都是可以直接落地的核心构件。赞分享数据同步前端【免费下载链接】yjsShared data types for building collaborative software项目地址https://gitcode.com/GitHub_Trending/yj/yjs点击查看免费下载相关推荐OneUptime AI 编程助手可观测性基于 OpenTelemetry 追踪代码助手的用量、成本与员工归属OneUptime AI 编程助手可观测性基于 OpenTelemetry 追踪代码助手的用量、成本与员工归属 当团队中的工程师开始使用 Claude Cod可观测性后端运维前端云原生微服务AI AgentYjs v14 Attribution 特性实战指南用 Renderer 为 Delta 渲染作者归属、删除痕迹与变更高亮Yjs v14 Attribution 特性实战指南用 Renderer 为 Delta 渲染作者归属、删除痕迹与变更高亮 导读 Attribution归属数据同步前端Nuxt Content 内容编辑全指南从可视化到代码编辑Nuxt Content 内容编辑全指南从可视化到代码编辑 前言 在现代内容管理系统中内容编辑体验直接影响着开发者和内容创作者的效率。Nuxt Conten前端CMS上一篇阴阳师自动化脚本终极指南如何用AI解放双手轻松获取游戏资源下一篇mistral.rs 中 MCP 客户端实战三种传输方式接入外部工具并自动注册到模型对话创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考