zh CN 稳定 Token ID - chiba233/yumeDSL GitHub Wiki
每个 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}" ─────┘
-
指纹 — 把 token 的
type+value序列化成字符串(嵌套子节点递归展开) - 哈希 — FNV-1a 32 位,快、分布好、非加密
-
格式化 —
{prefix}-{hash_base36},默认前缀"s",所以 id 长这样:s-1a2b3c4 - 跟踪 — 用 Map 记录每个 key 出现了几次
-
消歧 — 第一次出现直接用,第二次加
-1,第三次加-2...
function createEasyStableId(options?: EasyStableIdOptions): CreateId
interface EasyStableIdOptions {
prefix?: string; // 默认 "s"
fingerprint?: (token: TokenDraft) => string; // 自定义指纹
disambiguationScope?: "parse" | "lifetime"; // 默认 "parse"
}id 的前缀。默认 "s"。多个文档共存于同一个 DOM 时,用不同前缀区分:
const createIdA = createEasyStableId({prefix: "doc-a"}); // doc-a-xxx
const createIdB = createEasyStableId({prefix: "doc-b"}); // doc-b-xxx控制消歧计数器的重置时机。默认 "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(不同!)控制"什么内容送进哈希"。默认是 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 完全没问题。别当数据库主键用。
每次新建 共享复用(默认 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。注意:重新解析早期文档会导致后续文档的消歧后缀偏移。
// 工厂级别(共享,默认 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、以上都不够用的场景 |
下面是 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 对默认指纹哈希做了两项结构性优化:
-
子数组缓存:生成器闭包内维护
WeakMap<TextToken[], number>。createToken的{ ...draft, id }展开共享了draft.value的数组引用,所以自底向上流程中子数组 一定已被缓存。单次hashDraft从 O(子树大小) 降为 O(type.length),整棵树的总哈希 成本从 O(N × depth) 降为 O(N)。 -
迭代收集 + 自底向上哈希:对手动构造的深层
TokenDraft(不经过createToken), 用迭代 DFS 收集未缓存数组,逆序哈希,完全无递归,栈安全。
对扁平文档(本基准的主要形状)这两项优化影响不大——叶子 token 的 value 是字符串, 不存在子树遍历。真正受益的是深嵌套场景:千层级别的树不再触发 O(N²) 重复遍历。