zh CN Token 遍历 - chiba233/yumeDSL GitHub Wiki
解析器给你一棵 token 树,但光有树还不够——你得能逛这棵树。
这个模块提供三个工具:只读的 walkTokens(看一遍)、不可变的 mapTokens(变一棵新的)、以及快捷的 filterTokens(做树裁剪)。
TextToken[]
│
┌────────────┼────────────┐
▼ ▼ ▼
walkTokens mapTokens filterTokens
(只读遍历) (不可变变换)(基于 mapTokens)
│ │ │
前序 DFS 后序 DFS 后序 DFS
父 → 子 → 兄弟 子先映射 保留 / 删除
│ → 再到父
▼ ▼ ▼
副作用回调 全新 TextToken[] 过滤后的树
一句话总结:
- walkTokens = 带着笔记本逛博物馆,只看不摸
- mapTokens = 复印一份画,想改哪幅改哪幅,原件不动
- filterTokens = 在复印件上把不想要的部分裁掉,剩下的画仍保持原来的挂法
function walkTokens(tokens: TextToken[], visitor: WalkVisitor): void深度优先前序遍历——先看父节点,再递归进子节点。纯副作用,不会改动树。
// 写法一:通用函数——每个 token 都会调用
walkTokens(tokens, (token, ctx) => {
console.log(token.type, ctx.depth);
});
// 写法二:按类型分发——只关心你感兴趣的类型
walkTokens(tokens, {
bold: (token, ctx) => console.log(`bold at depth ${ctx.depth}`),
link: (token) => urls.push(token.url as string),
});interface TokenVisitContext {
parent: TextToken | null; // 父节点,根节点时为 null
depth: number; // 嵌套深度,根节点为 0
index: number; // 在兄弟节点中的位置
}输入树: text("Hello ") → bold([ text("world") ]) → text("!")
访问顺序:
① text("Hello ") depth=0
② bold(...) depth=0
③ text("world") depth=1 ← bold 的子节点
④ text("!") depth=0
先父后子,同层从左到右。
收集所有出现过的标签类型:
const types = new Set<string>();
walkTokens(tokens, (token) => types.add(token.type));
// types → Set { "text", "bold", "link", ... }function mapTokens(tokens: TextToken[], visitor: MapVisitor): TextToken[]深度优先后序变换——先把子节点全部映射完,再轮到父节点。返回一棵全新的树,原树不受影响。
type MapVisitor = (
token: TextToken,
ctx: TokenVisitContext,
) => TextToken | TextToken[] | null;| 返回值 | 效果 |
|---|---|
TextToken |
保留或替换这个节点 |
TextToken[] |
把一个节点展开成多个兄弟节点 |
null |
删掉这个节点 |
原始树: bold([ text("hello"), em([ text("world") ]) ])
映射顺序:
① text("hello") ← 叶子先处理
② text("world") ← 叶子先处理
③ em(...) ← 子节点已经映射完了,em 拿到的是新的子树
④ bold(...) ← 最后才轮到根
好处: 当你的回调看到 bold 时,token.value 里的子节点已经是映射后的版本了。你可以放心地基于子节点的新状态做决策。
ctx.parent 始终是原始父节点(映射之前的)。单次后序遍历在处理子节点时,父节点还没被映射,所以不可能给你映射后的版本。别依赖
ctx.parent.value 反映已映射的子节点。
删除所有 hidden 节点:
const visible = mapTokens(tokens, (token) =>
token.type === "hidden" ? null : token,
);文本全部大写:
const shouted = mapTokens(tokens, (token) =>
token.type === "text" && typeof token.value === "string"
? {...token, value: token.value.toUpperCase()}
: token,
);去掉包装器,把子节点提升为兄弟节点:
const unwrapped = mapTokens(tokens, (token) =>
token.type === "wrapper" ? (token.value as TextToken[]) : token,
);function filterTokens<T extends TextToken = TextToken>(
tokens: TextToken[],
predicate: (token: TextToken, ctx: TokenVisitContext) => boolean,
): T[]mapTokens 的快捷写法——只保留 predicate 返回 true 的节点,删掉其余的。
注意它的语义是树裁剪,不是“先拍平成一列再筛选”。
保留下来的节点仍然保持原有嵌套结构,被删掉节点的子树会原位一起移除。
等价于:
mapTokens(tokens, (token, ctx) => predicate(token, ctx) ? token : null)如果 predicate 是类型谓词函数,返回值会自动收窄:
interface LinkToken extends TextToken { type: "link"; url: string }
const links = filterTokens(tokens, (t): t is LinkToken => t.type === "link");
// links: LinkToken[]删除所有 hidden 节点(和 mapTokens 返回 null 等价,但更直观):
const visible = filterTokens(tokens, (token) => token.type !== "hidden");只保留深度不超过 2 的节点:
const shallow = filterTokens(tokens, (_token, ctx) => ctx.depth <= 2);如果你想要的是“平铺收集所有匹配节点”,那应该用 walkTokens(...) 自己 push 到数组里,而不是用 filterTokens(...)。
在 ~200 KB 文档上测量(9,165 渲染 token,共访问 9,165 个节点)。 环境:鲲鹏 920 aarch64 / Node v24.14.0。数据为 5 次独立运行的平均值。
| 操作 | 耗时 | 备注 |
|---|---|---|
| walkTokens | 0.50 ms | 先序 DFS,访问所有 9,165 个节点 |
| mapTokens 恒等 | 1.21 ms | 返回相同的树,不做变换 |
| mapTokens 变换 | 1.55 ms | 将整棵树中的 bold 改名为 strong
|
两者在 200 KB 文档上均约为 1.5ms 左右——在任何实际管线里相当于免费。