上一篇看了 markdown 怎么做文档和外部文本的双向转换。这篇是高级扩展阶段的最后一篇,看 prosemirror-search:编辑器里的查找替换。整个包只有两个源文件:query.ts 负责匹配和替换内容的计算,search.ts 负责插件状态、命令和装饰。对外导出 SearchQuery 类、search 插件构造函数,以及 findNext、findPrev、replaceNext、replaceCurrent、replaceAll 一组命令。它不依赖核心包的任何内部接口,全部功能都长在 StateField、meta、Decoration、Command 这些公开通路上,可以当作「插件系统能力上限」的一个样本读。参考代码是 prosemirror-search 的 647a36f;顺带引用的 prosemirror-state、prosemirror-view 分别是 ffad5d9、ca4c78e。
系列目录
SearchQuery:一次查询的全部配置
SearchQuery(query.ts)是个不可变的配置对象,字段就是查找面板上能看到的那排选项:search(搜索串)、caseSensitive、regexp、wholeWord、replace(替换文本)、literal(关掉 \n、\r、\t 转义还原)、filter(一个 (state, result) => boolean,让调用方按位置过滤结果,比如跳过代码块里的命中)。valid 字段在构造时算好:搜索串非空,且 regexp 模式下能编译通过(validRegExp 用 try new RegExp 探测),两个条件同时满足才算有效。
构造函数最后按配置挑一个内部实现存进 impl:无效查询用 nullQuery(findNext、findPrev 直接返回 null),regexp 模式用 RegExpQuery,其余用 StringQuery。三个实现都满足同一个 QueryImpl 接口,外层的 findNext、findPrev 只跟接口打交道。eq 方法给调用方比较两个查询用,注意它只比 search、replace、caseSensitive、regexp、wholeWord 五个字段,literal 和 filter 不参与,拿 eq 做缓存键时这两个字段的变化会被漏掉。
匹配:以文本块为单位扫
匹配以文本块为单位做,不整篇拼成一个大字符串。这个选择有实际理由:命中位置要能换算回文档坐标,按块扫描时块起始位置是现成的;块与块之间的边界也天然不会造出跨段落的假命中。入口是 scanTextblocks(query.ts),一个递归遍历:遇到 inlineContent 的节点就把它交给回调,遇到块级容器就按子节点的 nodeSize 累加位置、只进入和 [from, to] 窗口有交集的子树,窗口外的整棵子树直接跳过。findPrev 的调用约定是 from 大于 to,scanTextblocks 检测到这一点后改成倒序遍历子节点,从后往前找。
每个文本块先过 textContent 摊平成字符串:文本子节点直接拼接,叶节点(图片这类)换成  占位,嵌套的非内联内容前后加空格递归进去。结果存在 TextContentCache 这个 WeakMap 里,以节点对象为键。文档不可变,没改到的子树节点身份不变,字符串不用重拼,这是全量重扫能跑得动的前提之一。匹配都在这个扁平字符串上做,命中后再用文本块的起始位置把字符串下标换算回文档坐标。
StringQuery 的实现最直接:构造时把搜索串过一遍 unquote(literal 为假时把 \n、\r、\t、\\ 转义还原成真实字符),大小写不敏感时搜索串和文本块内容都转小写,然后 indexOf 找下一个、lastIndexOf 找上一个。两个方向的窗口裁切都落在文本块坐标上:findNext 从 Math.max(from, 块起点) 起切到 Math.min(块内容末尾, to),findPrev 对称,保证跨块扫描时每个块只负责自己的那段窗口。
RegExpQuery 多几件事。构造时拼 flags:固定带 g,运行环境支持就再加 u(unicode)和 d(hasIndices,分组下标),大小写不敏感再加 i。findNext 把 regexp.lastIndex 设成窗口起点在块内的偏移,exec 一次拿走结果。findPrev 麻烦一些,JavaScript 正则没有反向查找,只能从 0 开始反复 exec,每次把 lastIndex 推到上一个命中位置加一,保留最后一个命中。命中结果装进 SearchResult:from、to 是文档坐标,match 是正则的原始命中数组(字符串查询为 null),matchStart 记文本块起点,给后面分组定位用。
wholeWord 和 filter 不进 impl,统一在外层的 checkResult 里过。SearchQuery.findNext 是一个 for(;;) 循环:impl 每返回一个结果就先过 checkResult,通过了才返回,没通过就把搜索起点推到 result.from + 1 继续(findPrev 对称,推到 result.to - 1)。checkWordBoundary 把命中两端 resolve 出来,先看 $pos.nodeBefore 和 nodeAfter:任一侧不存在或者不是文本节点,直接算边界成立;两侧都是文本节点时才用 \p{L} 正则检查边界字符是不是字母,两侧都是字母说明命中嵌在一个词中间,丢弃。这个判定按 \p{L} 走,汉字也算字母,所以中文文本里开启 wholeWord 会很激进:命中两端只要还连着汉字就整批被丢掉,它服务的是西文词边界。这个外层循环也意味着 filter 写得慢会被反复调用,filter 里应该只做轻量判断。
文档变了,结果怎么跟随
search.ts 里的插件状态 SearchState 只有三个字段:query、range、deco。range 是限定查找范围的区间,由 setSearchState 或构造插件时的 initialRange 传入,null 表示整篇文档。deco 是当前所有命中的 DecorationSet。
StateField 的 apply 分两条路。transaction 带了 searchKey 的 meta(setSearchState 塞进去的 {query, range}),整体换新,按新查询重建装饰。否则只要 docChanged 或 selectionSet 成立,就得跟进:range 用 tr.mapping 映射,左端 map(range.from, 1)、右端 map(range.to, -1),两端 assoc 相反,边界上的插入会被排除在区间之外,区间只随内部编辑收缩;映射完 from 不小于 to 说明区间被编辑吞掉了,range 置 null。这套位置跟随又是第 16 篇 Mapping 的消费,搜索插件自己一行映射逻辑都不写。init 也走同一条路:插件初始化时拿 options.initialQuery(默认一个空搜索串的 SearchQuery)和 options.initialRange 建第一个 SearchState,空查询 valid 为假,buildMatchDeco 第一行就返回 DecorationSet.empty,编辑器初始状态没有任何高亮。
装饰的重建在 buildMatchDeco:从 range 起点开始循环 findNext,每轮把搜索起点推到上一个命中的 next.to,直到 findNext 返回 null。每个命中生成一条 Decoration.inline。和当前选区完全重合的那条用 ProseMirror-active-search-match 类名,其余用 ProseMirror-search-match,CSS 里通常给 active 那个配更深的底色表示「当前第 N 个」。selectionSet 也要触发重建,原因就是移动选区时 active 高亮要跟着换。deco 通过插件的 props.decorations 暴露给 view,第 33 篇 Decoration 体系 讲过这条通路。
值得照实记录的一点:这份实现没有增量维护命中列表,每次文档变更都把整个 range 重扫一遍重建 DecorationSet。成本靠两层缓解:textContent 的 WeakMap 缓存让没改到的文本块不用重拼字符串;DecorationSet 本身是有序区间结构,重建一次是线性扫。对大文档高频输入的场景,这是用实现简单换的代价,调用方如果觉得吃不消,可以只在 range 内扫描来缩小工作量。
getReplacements:替换内容怎么算
替换的难点在 $1、$& 这类分组占位符。最简单的情形,replace 是纯文本,getReplacements(query.ts)返回一条区间:命中区间换成新文本,Slice 的 openStart、openEnd 都是 0。带占位符时情况变成:占位符引用的原文内容可能要保留在文档里不动,替换只发生在它两侧。
parseReplacement 先把替换文本解析成段序列:普通文本段、组引用段。$$ 转义成字面 $,$& 等价于 $0。组引用段带一个 copy 标志,划分规则是:组号比之前见过的都大(首次按序出现),标 copy: false,含义是这段原文留在文档里,作为保留内容;重复引用或乱序引用标 copy: true,含义是把原文复制一份插进新内容。$1$1 里第一个是保留锚点,第二个是复制。有个细节:$0(含 $&)首次出现时会把已见最大组号直接顶到 1000,后面的组引用就全部算复制了。
getReplacements 遍历段序列,维护一个累积片段 frag 和位置游标 pos。文本段用 schema.text(part, marks) 追加进 frag,marks 来自 $from.marksAcross(state.doc.resolve(result.to)),让替换文本继承命中位置的格式。遇到 copy 组,用 doc.slice 把组内容原样(连同内部结构)append 进 frag。遇到 skip 组,闭合当前区间:把 {from: pos, to: 组起点, insert: frag} 推进结果数组,frag 清空,pos 跳到组终点;frag 为空且组起点正好在 pos 时跳过这一步,不产出空区间。遍历完再按同样条件收尾最后一段。产物是一组按位置排序的区间,每个区间自带要插入的 Slice。
组在命中串里的位置由 getGroupIndices 给出:运行环境支持 d flag 时直接读 match.indices,否则退化成一个近似,用 indexOf 从上一组结束位置往后找组文本。近似的盲区是组文本在命中串里重复出现时可能定位到前一个相同片段,需要精确分组位置的场景要留意运行环境。marksAcross 这一步也别省:它以命中起点处文本的 marks 为基础,把 spec 里 inclusive 为 false 且没有延续到命中终点的 mark 剔除(第 5 篇 Mark 讲过 inclusive 语义),替换文本带着剩下的格式插入,链式 mark 不会在被替换的位置意外延续。
图上是一个具体例子:正则 /(\d+)/ 在文本块字符串 abc123def 里命中 123,替换文本是 ”[$1]“。解析结果是文本段 ”[“、skip 组 1、文本段 ”]“。组 1 正好覆盖整个命中,getReplacements 产出两条零宽区间:命中起点处插入 ”[“,命中终点处插入 ”]“,123 作为 skip 内容保留在文档里不动;abc 和 def 在命中区间之外,本来就不参与替换。方法注释里提醒了调用约定:所有区间都相对当前 state.doc,应用时要么从后往前替换,要么逐条过 transaction 的 mapping。包内两个消费方都选了从后往前。
命令层与 UI 的组合
search.ts 导出的命令分两组,都遵守第 39 篇 commands 的签名约定:返回 false 表示命令不适用,dispatch 没传时只做 dry-run 判断可行性。
查找组由 findCommand(wrap, dir) 生成。内部的 nextMatch、prevMatch 以当前选区为锚:向后找时从选区末端和 range 起点的较大者开始,找到 range 末端为止;wrap 为真还没找到,就绕回 range 起点到选区前端这段再找一次。prevMatch 对称。找到就 TextSelection.create 选中命中区间并 scrollIntoView。findNext、findPrev、findNextNoWrap、findPrevNoWrap 四个导出就是 wrap 和 dir 两个参数的四种组合,直接挂 keymap。查询无效(比如搜索框为空、正则还没输完)时 findCommand 返回 false,按键会落到其他 keymap 处理,不会卡住回车之类的默认行为。
替换组由 replaceCommand(wrap, moveForward) 生成,逻辑是两段式。选区当前不在任何命中上时,先选中下一个命中,这次不替换(moveForward 为假的 replaceCurrent 在这种情况下直接返回 false)。选区正好落在某个命中上时才执行替换:getReplacements 算出的区间倒序 tr.replace 应用掉,然后处理选区去向。moveForward 为真时先在旧文档上算出下一个命中 after,再用 tr.mapping.map 把 after.from、after.to 映射到替换后的新文档,选区落过去;否则选区留在被替换的位置。这个「旧坐标计算、新坐标落位」的次序值得注意,after 是在 state(旧文档)上找的,不映射就指错地方。
replaceAll 更直接:先在旧文档坐标系里正向把 range 内所有命中收集成数组,然后倒序逐个 getReplacements、tr.replace,全部塞进一个 transaction,一次 undo 就能整体回滚。倒序应用保证前面命中的坐标不被后面已经发生的替换打乱,这正是 getReplacements 注释里给的第一种方案。它也吃 range:限定了查找区间时,区间外的命中不动。有一个使用上的边界要留意:替换文本本身含有搜索串时(比如把 foo 换成 foobar),replaceAll 不受影响,因为命中列表在替换前就全部定好了;但 replaceNext 循环逐个替换同类场景时,选区每次都跳到下一个命中,新插入的 foobar 里的 foo 不会被再次选中,因为下一次查找从当前命中之后开始。
对外通信的三个函数都很薄:setSearchState(tr, query, range) 是 tr.setMeta(searchKey, …) 的封装;getSearchState 读当前 query 和 range;getMatchHighlights 单独取装饰集。查找面板本身这个包不提供,输入框、按钮、计数器都由使用方写,包只提供状态机和命令。这个分工和 menu 篇一致:涉及 DOM 的 UI 不进功能包,功能包只保证状态可读写、命令可绑键。自己写面板时,输入框变化就构造新 SearchQuery 调 setSearchState,回车和 Shift+回车绑 findNext、findPrev,替换按钮绑 replaceNext 和 replaceAll,高亮和选区跳转全部由插件接管。
小结与下一篇
search 包的机制拆开看就四块:匹配限定在文本块的扁平字符串上做,WeakMap 缓存摊掉重扫成本;range 和命中装饰随文档变化走 Mapping 加全量重建;getReplacements 用 skip/copy 两种组引用把 $1 占位换算成保留内容两侧的替换区间;命令层用两段式 replace 和倒序应用把多区间替换收进单个 transaction。加上 decoration 通路,一个查找替换功能的全部内核都在这了。
高级扩展阶段到这里结束。下一篇进表格专题,先看 prosemirror-tables 的 schema 设计和 TableMap:合并单元格的表格怎么用扁平格网表示。

