zh CN 稳定 Token ID - chiba233/yumeDSL GitHub Wiki

稳定 Token ID

Token 结构 | 编写标签处理器

先说人话

每个 token 都有一个 id。这个 id 的用途很简单:让框架知道"这个 token 还是上次那个"

React 的 key、Vue 的 :key、CSS 动画的锚点——都指望这个 id 跨重渲染保持不变。

问题是: 默认 id 是按顺序编号的(rt-0, rt-1, rt-2...)。你在文章开头加一句话,后面所有 id 全都变了。框架一看全变了 → 整棵树重新渲染 → 动画全炸。

解法: createEasyStableId() —— 根据 token 的内容(而非位置)生成 id。你改了别的 token,不影响这个 token 的 id。

默认顺序 ID:                    稳定 ID:

"Hello "     → rt-0             "Hello "     → s-a1b2c3
$$bold(world)$$ → rt-1          $$bold(world)$$ → s-x7y8z9

在前面插入一段文字后:            在前面插入一段文字后:

"New "       → rt-0  ← 抢了     "New "       → s-p4q5r6  ← 新的
"Hello "     → rt-1  ← 变了!    "Hello "     → s-a1b2c3  ← 没变!
$$bold(world)$$ → rt-2  ← 变了! $$bold(world)$$ → s-x7y8z9  ← 没变!

怎么用

import {createEasyStableId, parseRichText} from "yume-dsl-rich-text";

const tokens = parseRichText("Hello $$bold(world)$$", {
    handlers,
    createId: createEasyStableId(),
});
// 编辑 "Hello" → "Hi" 不会改变 bold token 的 id

就这么简单。下面是细节。


内部原理(五步流程)

TokenDraft                     最终 ID
    │                              ▲
    ▼                              │
① 指纹计算               ⑤ 重复消歧
   type + value              第1次: s-abc
   递归序列化                第2次: s-abc-1
    │                        第3次: s-abc-2
    ▼                              ▲
② FNV-1a 哈希            ④ 跟踪已见哈希
   32位,快,分布好            Map<key, 计数>
    │                              ▲
    ▼                              │
③ 格式化 → "s-{hash_base36}" ─────┘
  1. 指纹 — 把 token 的 type + value 序列化成字符串(嵌套子节点递归展开)
  2. 哈希 — FNV-1a 32 位,快、分布好、非加密
  3. 格式化{prefix}-{hash_base36},默认前缀 "s",所以 id 长这样:s-1a2b3c4
  4. 跟踪 — 用 Map 记录每个 key 出现了几次
  5. 消歧 — 第一次出现直接用,第二次加 -1,第三次加 -2...

选项

function createEasyStableId(options?: EasyStableIdOptions): CreateId

interface EasyStableIdOptions {
    prefix?: string;                                // 默认 "s"
    fingerprint?: (token: TokenDraft) => string;    // 自定义指纹
    disambiguationScope?: "parse" | "lifetime";     // 默认 "parse"
}

prefix

id 的前缀。默认 "s"。多个文档共存于同一个 DOM 时,用不同前缀区分:

const createIdA = createEasyStableId({prefix: "doc-a"});  // doc-a-xxx
const createIdB = createEasyStableId({prefix: "doc-b"});  // doc-b-xxx

disambiguationScope

控制消歧计数器的重置时机。默认 "parse"

  • "parse"(默认) — 每次 parseRichText 调用时自动重置计数器和哈希缓存。同一个 generator 跨多次 parse 调用时,相同输入产生相同 ID。
  • "lifetime" — 计数器在 generator 的整个生命周期内累积,不会重置。用于需要跨 parse 全局唯一 ID 的场景。
// parse 作用域(默认):同一个 generator 跨 parse 产生相同 ID
const stableId = createEasyStableId();
const dsl = createParser({ handlers, createId: stableId });
dsl.parse("$$bold(hi)$$");  // bold id = s-xxx
dsl.parse("$$bold(hi)$$");  // bold id = s-xxx(相同!)

// lifetime 作用域:跨 parse 计数器不重置
const stableId = createEasyStableId({ disambiguationScope: "lifetime" });
const dsl = createParser({ handlers, createId: stableId });
dsl.parse("$$bold(hi)$$");  // bold id = s-xxx
dsl.parse("$$bold(hi)$$");  // bold id = s-xxx-1(不同!)

fingerprint

控制"什么内容送进哈希"。默认是 type + value(递归),所以两个一模一样的 bold token 会碰撞(靠消歧区分)。

场景:code 标签有 lang 字段,同内容不同语言的代码块默认会碰撞。 加上 lang:

const createId = createEasyStableId({
    fingerprint: (t) => {
        const lang = typeof t.lang === "string" ? t.lang : "";
        const value = typeof t.value === "string" ? t.value : "";
        return `${t.type}:${lang}:${value}`;
    },
});

场景:内容会变但结构不变,只按类型+出现顺序区分:

const createId = createEasyStableId({
    fingerprint: (t) => t.type,
});
// 所有 bold token 共享基础哈希,靠消歧区分:s-xyz, s-xyz-1, s-xyz-2

稳定性保证

什么时候 id 不变,什么时候会变——这是用这个 API 之前必须搞清楚的:

场景 稳定? 为什么
编辑别的 token 的内容 稳定 每个 token 的哈希独立
末尾追加新 token 稳定 新 token 新哈希,不影响旧的
在前面插入指纹唯一的 token 稳定 新哈希不碰撞
在前面插入指纹重复的 token 部分 消歧后缀会偏移
删除有重复项的 token 部分 剩余重复项的后缀可能变
改了 token 自己的内容 该 token 变,其他不变 指纹变了 → 哈希变了

关于碰撞: 32 位哈希,2^32 种可能。文档里几百几千个 token 时碰撞概率极低。消歧保证 id 始终唯一,但碰撞的 token 会共享基础 key。用作 UI key 完全没问题。别当数据库主键用。


作用域:每次新建 vs 共享复用

每次新建                          共享复用(默认 parse 作用域)
┌──────────────────────┐       ┌──────────────────────────┐
│ 每次 parseRichText    │       │ 一个生成器跨多次解析       │
│ 传一个新的 createId    │       │ 每次 parse 自动重置计数器  │
│ 文档间 id 互不影响     │       │ 相同输入 → 相同 id        │
└──────────────────────┘       └──────────────────────────┘

每次新建

// 每次解析给一个新的——效果和共享复用(parse 作用域)相同
const tokensA = parseRichText(textA, {handlers, createId: createEasyStableId()});
const tokensB = parseRichText(textB, {handlers, createId: createEasyStableId()});
// A 和 B 的 id 可能重叠,但它们属于不同文档,没关系

共享复用(推荐)

// 同一个生成器复用——默认 parse 作用域,每次 parse 自动重置
const stableId = createEasyStableId({prefix: "doc"});
const dsl = createParser({handlers, createId: stableId});
dsl.parse(text1);  // bold(hi) → doc-xxx
dsl.parse(text1);  // bold(hi) → doc-xxx(相同输入 = 相同 id)

需要跨 parse 全局唯一时,使用 disambiguationScope: "lifetime"

const stableId = createEasyStableId({prefix: "doc", disambiguationScope: "lifetime"});
const dsl = createParser({handlers, createId: stableId});
dsl.parse(text1);  // bold(hi) → doc-xxx
dsl.parse(text1);  // bold(hi) → doc-xxx-1(计数器不重置,id 不同)

多文档编辑器、token 混在一棵树里渲染时用 lifetime。注意:重新解析早期文档会导致后续文档的消歧后缀偏移。


与 createParser 集成

// 工厂级别(共享,默认 parse 作用域 → 每次 parse 自动重置)
const dsl = createParser({
    handlers,
    createId: createEasyStableId({prefix: "app"}),
});

// 单次解析覆盖(独立)
const tokens = dsl.parse(text, {
    createId: createEasyStableId({prefix: "page"}),
});
// 单次解析的 createId 优先于工厂的

消歧示例

const tokens = parseRichText("$$bold(hi)$$ and $$bold(hi)$$", {
    handlers,
    createId: createEasyStableId(),
});

// 两个 bold token 内容完全一样 → 指纹一样 → 哈希一样
// tokens[1].id === "s-a1b2c3"     ← 第一个 bold,无后缀
// tokens[2].id === "s-a1b2c3-1"   ← 第二个 bold,加 -1
// 第三个会是 s-a1b2c3-2,以此类推

什么时候用哪个

策略 适合什么场景
默认顺序 rt-N 静态内容、一次性渲染、测试
createEasyStableId() parse 作用域(默认) React/Vue key、动画、编辑后重渲染
createEasyStableId({ disambiguationScope: "lifetime" }) 多文档编辑器、合并多次解析结果
自定义 CreateId 函数 数据库 ID、UUID、以上都不够用的场景

性能(v1.1.9)

下面是 1.1.9 的实测基准(~200 KB 输入,204,804 bytes),对比开启 createEasyStableId 和默认顺序 ID 的开销。 测试环境:HiSilicon TaiShan-v110(鲲鹏 920)24 核 aarch64 / 32 GB / Node v24.14.0。 5 次独立进程运行,每次 20 次采样(含 warmup),取中位数的中位数。

API 默认路径 stableId 说明
parseRichText ~23.3 ms ~29.9 ms ~28% 额外开销
parseStructural ~20.9 ms 不适用 结构树不生成 token id,因此不消费 createId

结论需要分开看:parseRichText 会为每个 token 额外生成稳定 ID,所以在这组输入上出现了稳定的两位数百分比开销;parseStructural 不生成 id,因此这里没有”默认 ID vs stableId”的对比意义。 parseRichText 的绝对额外成本大约是 ~6.6 ms 量级。

复杂度说明

1.1.9 对默认指纹哈希做了两项结构性优化:

  1. 子数组缓存:生成器闭包内维护 WeakMap<TextToken[], number>createToken{ ...draft, id } 展开共享了 draft.value 的数组引用,所以自底向上流程中子数组 一定已被缓存。单次 hashDraft 从 O(子树大小) 降为 O(type.length),整棵树的总哈希 成本从 O(N × depth) 降为 O(N)

  2. 迭代收集 + 自底向上哈希:对手动构造的深层 TokenDraft(不经过 createToken), 用迭代 DFS 收集未缓存数组,逆序哈希,完全无递归,栈安全。

对扁平文档(本基准的主要形状)这两项优化影响不大——叶子 token 的 value 是字符串, 不存在子树遍历。真正受益的是深嵌套场景:千层级别的树不再触发 O(N²) 重复遍历。

⚠️ **GitHub.com Fallback** ⚠️