云计算百科
云计算领域专业知识百科平台

100 万节点里搜 3 个字:倒排桶、5000 条截断与祖先补全

前情:《MPTT 不是万能编号:一棵百万节点树的增删改与批量事务》 讲了“写”,《WASM 不是银弹》 算的是边界账。这篇讲“读”的另一种形态:搜索。

在 100 万节点的树里搜一个 3 字关键词,最慢的不是“找”,而是“把找到的节点接回树上”。我原本以为搜索的瓶颈是字符串匹配,实测下来:在各自场景里,匹配只占几毫秒到 25ms,祖先补全能到 500ms。这篇文章按顺序拆——匹配、候选桶、截断、补祖先、缓存、变成视口——每一段都给代码、复杂度和本机实测,最后说清楚候选索引什么时候值得开、什么时候不值得。

先给一组数字,避免你看到一半才发现方向不对:

场景结果耗时(P50)
100 万宽树,搜一个只命中 100 个节点的稀有词 100 行 25.7ms(关闭候选索引)
同上,开启候选索引 100 行 0.27ms(建索引 1.35s)
100 万宽树,搜一个命中全部节点的词(关闭候选索引) 5000 行(截断) 1.1ms
10 万深树,命中 5000 个深层叶子 9590 行(含祖先) 518ms

第一行和第三行放在一起看很反直觉:命中越多的词,反而搜得越快。原因不在匹配算法,而在截断——搜索到 5000 个命中就停了,而稀有词要扫完 100 万行才能确认“没有更多”。 第四行是这篇文章真正想讲的部分:匹配只有几毫秒,剩下 500ms 全在补祖先。

如果只记一句话:

候选桶优化的是“扫描范围”,截断优化的是“结果规模”,祖先补全的成本由“命中数 × 平均深度”决定。三者不能互相替代。

文中所有数据都来自这个项目 vue3-giant-tree-wasm:Vue 3 + AssemblyScript/WASM,BSD-2-Clause,npm 包名 vue3-giant-tree-wasm。搜索主体在 assembly/tree-search.ts,视图切换与缓存位于 assembly/giant-tree.ts,高亮在 lib/TreeItem.vue。


搜索全流程一览

在逐段拆解之前,先把整条链路画出来。一次 fuzzySearch 从输入框到 DOM,会依次经过下面几个阶段,每个阶段都有各自的成本来源:

#mermaid-svg-qSSEtMyKPanvNh1H{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qSSEtMyKPanvNh1H .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qSSEtMyKPanvNh1H .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qSSEtMyKPanvNh1H .error-icon{fill:#552222;}#mermaid-svg-qSSEtMyKPanvNh1H .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qSSEtMyKPanvNh1H .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qSSEtMyKPanvNh1H .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qSSEtMyKPanvNh1H .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qSSEtMyKPanvNh1H .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qSSEtMyKPanvNh1H .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qSSEtMyKPanvNh1H .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qSSEtMyKPanvNh1H .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qSSEtMyKPanvNh1H .marker.cross{stroke:#333333;}#mermaid-svg-qSSEtMyKPanvNh1H svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qSSEtMyKPanvNh1H p{margin:0;}#mermaid-svg-qSSEtMyKPanvNh1H .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-qSSEtMyKPanvNh1H .cluster-label text{fill:#333;}#mermaid-svg-qSSEtMyKPanvNh1H .cluster-label span{color:#333;}#mermaid-svg-qSSEtMyKPanvNh1H .cluster-label span p{background-color:transparent;}#mermaid-svg-qSSEtMyKPanvNh1H .label text,#mermaid-svg-qSSEtMyKPanvNh1H span{fill:#333;color:#333;}#mermaid-svg-qSSEtMyKPanvNh1H .node rect,#mermaid-svg-qSSEtMyKPanvNh1H .node circle,#mermaid-svg-qSSEtMyKPanvNh1H .node ellipse,#mermaid-svg-qSSEtMyKPanvNh1H .node polygon,#mermaid-svg-qSSEtMyKPanvNh1H .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qSSEtMyKPanvNh1H .rough-node .label text,#mermaid-svg-qSSEtMyKPanvNh1H .node .label text,#mermaid-svg-qSSEtMyKPanvNh1H .image-shape .label,#mermaid-svg-qSSEtMyKPanvNh1H .icon-shape .label{text-anchor:middle;}#mermaid-svg-qSSEtMyKPanvNh1H .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qSSEtMyKPanvNh1H .rough-node .label,#mermaid-svg-qSSEtMyKPanvNh1H .node .label,#mermaid-svg-qSSEtMyKPanvNh1H .image-shape .label,#mermaid-svg-qSSEtMyKPanvNh1H .icon-shape .label{text-align:center;}#mermaid-svg-qSSEtMyKPanvNh1H .node.clickable{cursor:pointer;}#mermaid-svg-qSSEtMyKPanvNh1H .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qSSEtMyKPanvNh1H .arrowheadPath{fill:#333333;}#mermaid-svg-qSSEtMyKPanvNh1H .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qSSEtMyKPanvNh1H .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qSSEtMyKPanvNh1H .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qSSEtMyKPanvNh1H .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qSSEtMyKPanvNh1H .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qSSEtMyKPanvNh1H .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qSSEtMyKPanvNh1H .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qSSEtMyKPanvNh1H .cluster text{fill:#333;}#mermaid-svg-qSSEtMyKPanvNh1H .cluster span{color:#333;}#mermaid-svg-qSSEtMyKPanvNh1H div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qSSEtMyKPanvNh1H .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qSSEtMyKPanvNh1H rect.text{fill:none;stroke-width:0;}#mermaid-svg-qSSEtMyKPanvNh1H .icon-shape,#mermaid-svg-qSSEtMyKPanvNh1H .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qSSEtMyKPanvNh1H .icon-shape p,#mermaid-svg-qSSEtMyKPanvNh1H .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qSSEtMyKPanvNh1H .icon-shape .label rect,#mermaid-svg-qSSEtMyKPanvNh1H .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qSSEtMyKPanvNh1H .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qSSEtMyKPanvNh1H .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qSSEtMyKPanvNh1H :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

是

否

是

否

是

否

是

否

输入关键词

关键词为空?

清空搜索状态回到 fullTree

命中缓存?同一关键词

复用 searchTree

匹配阶段indexOf 子串扫描

开启候选索引?

取最短字符桶缩小扫描范围

全表扫描

收集命中最多 5000 条

达到 MAX_RESULTS?

标记 truncated

继续扫描

祖先补全沿 parent 链上溯

按 leftNode 排序

重建搜索视图应用搜索折叠

只序列化视口serializeShownSlice

主线程渲染 DOM

这张图里最值得记住的是三个分叉点:候选索引开关决定扫描范围,5000 截断决定结果规模,祖先补全决定最坏耗时。后面每一节都在展开其中一个。

0. 为什么树搜索不是“过滤一个数组”

如果数据是平表,搜索就是一行 filter:

rows.filter(row => row.name.includes(keyword))

树不能这么写。假设你搜到了 研发一部-项目 42,但它的父节点 研发一部 本身不包含关键词。如果只把命中行放进结果,这行在树里就没有父节点,层级、缩进、展开箭头全部失效。

所以一个树搜索至少要产出两样东西:

  • 命中集合:名称里包含关键词的节点;
  • 可达路径:这些节点的全部祖先,保证结果仍然是一棵能展开的树。
  • 这个项目的做法是:把两者都塞进一个独立的 searchTree 数组,按 MPTT 的 leftNode 排好序,然后交给虚拟滚动。searchTree 不改主树的 shown 标志,也不改主树的折叠状态。

    // assembly/giant-tree.ts
    fuzzySearch(keyword: string): string {
    if (keyword === '') {
    this._searchCollapsedIds.clear()
    this._searchTruncated = false
    this.tree = this.fullTree
    resetShownFlags(this.fullTree)
    this._rebuildShownNodes()
    return serializeShownSlice(
    this._shownNodes,
    this.scrollTop,
    this.scrollHeight,
    this.lineHeight
    )
    }

    if (!this._hasSearchCache || this._lastSearchKeyword !== keyword) {
    this._searchCollapsedIds.clear()
    if (this.useSearchCandidateIndex) this._ensureSearchCandidates()
    this.shownCount = fuzzySearchTree(
    this.fullTree,
    this.searchTree,
    keyword,
    this._searchIdSet,
    this._searchParents,
    this.useSearchCandidateIndex ? this._searchCandidates : null,
    this.compactStore.parent
    )
    this._searchTruncated = lastSearchTruncated
    this._lastSearchKeyword = keyword
    this._hasSearchCache = true
    } else {
    this.shownCount = this.searchTree.length
    }

    this.tree = this.searchTree
    this._rebuildSearchShownNodes()
    this._invalidateCache()
    return serializeShownSlice(
    this._shownNodes,
    this.scrollTop,
    this.scrollHeight,
    this.lineHeight
    )
    }

    这一段里藏着后面要展开的所有线索:

    • useSearchCandidateIndex 是一个默认关闭的开关;
    • compactStore.parent 是祖先补全用的索引;
    • _hasSearchCache 让同一个关键词只算一次;
    • 返回值是 serializeShownSlice,只序列化视口,不是整个结果。

    1. 第一步:匹配

    核心循环只有十几行:

    // assembly/tree-search.ts
    const MAX_RESULTS: i32 = 5000
    lastSearchTruncated = false
    let matchCount: i32 = 0
    let candidates: i32[] | null = null

    if (candidateIndex !== null && keyword.length > 0) {
    for (let i: i32 = 0; i < keyword.length; i++) {
    const key = keyword.charAt(i)
    if (!candidateIndex.has(key)) {
    candidates = []
    break
    }
    const bucket = candidateIndex.get(key)
    if (candidates === null || bucket.length < (candidates as i32[]).length) {
    candidates = bucket
    }
    }
    }

    const scanCount = candidates !== null ? candidates.length : fullTree.length
    for (let c: i32 = 0; c < scanCount; c++) {
    const i = candidates !== null ? candidates[c] : c
    const node: MpttTree = fullTree[i]
    if (node.name.indexOf(keyword) !== –1) {
    searchTree.push(node)
    idSet.add(node.id)
    matchedIndices.push(i)
    matchCount++
    if (matchCount >= MAX_RESULTS) {
    lastSearchTruncated = true
    break
    }
    }
    }

    没有正则、没有分词、没有编辑距离。indexOf 就是全部语义:区分大小写的子串匹配。这个选择很朴素,但它是后面所有优化的基准——先把最笨的版本跑通,再决定要不要加索引。 1M 宽树的实测:

    关键词命中关闭候选索引
    HIT(每 1 万个节点出现一次) 100 25.7ms
    999(数字子串) 3700 26.1ms
    Node(全命中,截断) 5000 1.1ms
    zzz(无命中) 0 25.5ms

    25ms 看起来不算慢,但搜索是输入框的实时操作。用户每敲一个字符就搜一次,10 个字符就是 250ms 的主线程计算,再加上序列化和渲染,输入框会明显卡顿。项目因此在组件层加了 300ms 防抖(后面会讲),但 WASM 侧仍然值得把单次搜索做快。

    2. 第二步:候选桶

    2.1 数据结构

    候选索引的本质是一个字符级倒排表:

    // assembly/giant-tree.ts
    _searchCandidates: Map<string, i32[]> = new Map<string, i32[]>()

    private _buildSearchCandidates(): void {
    this._searchCandidates.clear()
    for (let i: i32 = 0; i < this.fullTree.length; i++) {
    const name = this.fullTree[i].name
    const seen: Set<string> = new Set<string>()
    for (let j: i32 = 0; j < name.length; j++) {
    const key = name.charAt(j)
    if (seen.has(key)) continue
    seen.add(key)
    if (!this._searchCandidates.has(key))
    this._searchCandidates.set(key, [])
    const bucket = this._searchCandidates.get(key)
    bucket.push(i)
    }
    }
    this._searchCandidateIndexReady = true
    }

    每个节点对名字里出现过的不同字符各登记一次。seen 是为了避免 Node 111 里的三个 1 把同一个节点登记三次。

    搜索时,遍历关键词的每个字符,取最短的那个桶作为候选集:

    if (candidates === null || bucket.length < candidates.length) {
    candidates = bucket
    }

    2.2 为什么取最小桶不会漏

    这是一个很容易想错的点:取一个字符的桶,难道不会漏掉“包含完整关键词、但不包含这个字符”的节点吗?

    不会。因为包含完整关键词的节点,必然包含关键词的每一个字符。所以它一定在每一个字符的桶里。取任意一个桶,都不会漏掉真正的命中;取最短的桶,只是把“一定不会漏”的集合缩到最小。

    反过来说,桶里的节点不一定命中完整关键词。999 的桶里可能有 Node 1999,它确实包含 9,但不一定包含连续三个 9。所以候选集只是“候选”,最终仍要跑一次 indexOf:

    if (node.name.indexOf(keyword) !== –1) { … }

    这两步的分工是:

    • 倒排桶:把 N 次 indexOf 变成 |桶| 次;
    • indexOf:保证子串语义不变。

    用一张图概括“取最小桶”的正确性推理:

    #mermaid-svg-OUS7TDJRqOtgHYSt{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OUS7TDJRqOtgHYSt .error-icon{fill:#552222;}#mermaid-svg-OUS7TDJRqOtgHYSt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OUS7TDJRqOtgHYSt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OUS7TDJRqOtgHYSt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OUS7TDJRqOtgHYSt .marker.cross{stroke:#333333;}#mermaid-svg-OUS7TDJRqOtgHYSt svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OUS7TDJRqOtgHYSt p{margin:0;}#mermaid-svg-OUS7TDJRqOtgHYSt .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-OUS7TDJRqOtgHYSt .cluster-label text{fill:#333;}#mermaid-svg-OUS7TDJRqOtgHYSt .cluster-label span{color:#333;}#mermaid-svg-OUS7TDJRqOtgHYSt .cluster-label span p{background-color:transparent;}#mermaid-svg-OUS7TDJRqOtgHYSt .label text,#mermaid-svg-OUS7TDJRqOtgHYSt span{fill:#333;color:#333;}#mermaid-svg-OUS7TDJRqOtgHYSt .node rect,#mermaid-svg-OUS7TDJRqOtgHYSt .node circle,#mermaid-svg-OUS7TDJRqOtgHYSt .node ellipse,#mermaid-svg-OUS7TDJRqOtgHYSt .node polygon,#mermaid-svg-OUS7TDJRqOtgHYSt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OUS7TDJRqOtgHYSt .rough-node .label text,#mermaid-svg-OUS7TDJRqOtgHYSt .node .label text,#mermaid-svg-OUS7TDJRqOtgHYSt .image-shape .label,#mermaid-svg-OUS7TDJRqOtgHYSt .icon-shape .label{text-anchor:middle;}#mermaid-svg-OUS7TDJRqOtgHYSt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OUS7TDJRqOtgHYSt .rough-node .label,#mermaid-svg-OUS7TDJRqOtgHYSt .node .label,#mermaid-svg-OUS7TDJRqOtgHYSt .image-shape .label,#mermaid-svg-OUS7TDJRqOtgHYSt .icon-shape .label{text-align:center;}#mermaid-svg-OUS7TDJRqOtgHYSt .node.clickable{cursor:pointer;}#mermaid-svg-OUS7TDJRqOtgHYSt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OUS7TDJRqOtgHYSt .arrowheadPath{fill:#333333;}#mermaid-svg-OUS7TDJRqOtgHYSt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OUS7TDJRqOtgHYSt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OUS7TDJRqOtgHYSt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OUS7TDJRqOtgHYSt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OUS7TDJRqOtgHYSt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OUS7TDJRqOtgHYSt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OUS7TDJRqOtgHYSt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OUS7TDJRqOtgHYSt .cluster text{fill:#333;}#mermaid-svg-OUS7TDJRqOtgHYSt .cluster span{color:#333;}#mermaid-svg-OUS7TDJRqOtgHYSt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OUS7TDJRqOtgHYSt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OUS7TDJRqOtgHYSt rect.text{fill:none;stroke-width:0;}#mermaid-svg-OUS7TDJRqOtgHYSt .icon-shape,#mermaid-svg-OUS7TDJRqOtgHYSt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OUS7TDJRqOtgHYSt .icon-shape p,#mermaid-svg-OUS7TDJRqOtgHYSt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OUS7TDJRqOtgHYSt .icon-shape .label rect,#mermaid-svg-OUS7TDJRqOtgHYSt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OUS7TDJRqOtgHYSt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OUS7TDJRqOtgHYSt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OUS7TDJRqOtgHYSt :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    关键词: 999

    字符 9 的桶

    字符 9 的桶

    字符 9 的桶

    候选集 = 最短桶

    逐个 indexOf 校验

    真正的命中

    被排除的伪命中如 Node 1999

    进入 searchTree

    丢弃

    关键点在于:包含完整关键词的节点必然出现在每一个字符的桶里,所以取任意一个桶都不会漏;取最短的那个,只是把候选集缩到最小,后续 indexOf 负责把伪命中剔除。

    2.3 建桶不是免费的

    _buildSearchCandidates 的复杂度是 O(N × 名字平均长度),而且在 AssemblyScript 里,每个节点都要新建一个 Set<string>,每个字符都要 charAt 出一个单字符字符串。这个分配成本正是它默认关闭的原因:

    // Candidate buckets can halve median search time, but their per-character
    // allocation is too expensive to enable for every tree by default.
    useSearchCandidateIndex: bool = false

    实测建桶成本:

    树规模建桶耗时(P50)建树耗时
    10 万英文宽树 118–123ms 约 70ms
    10 万中文宽树 133–140ms 63ms
    100 万英文宽树 1.18–1.35s 804ms
    100 万中文宽树 1.41–1.51s 723ms

    100 万节点建一次桶要 1.3 秒,比建树本身还久。这意味着:如果用户只搜一两次,建桶永远不划算。

    2.4 收益取决于桶有多小

    同一棵 100 万英文宽树,开/关候选索引的对比:

    关键词结果行数关闭开启加速
    HIT(桶约 100) 100 25.66ms 0.27ms 95×
    999(桶约 47 万) 3700 26.14ms 15.00ms 1.7×
    Node(桶 = 全表) 5000 1.10ms 0.82ms ≈1×
    zzz(桶为空) 0 25.52ms 0.022ms ≈1160×

    中文树的数字更极端一点:

    关键词结果行数关闭开启加速
    重点(桶约 100) 100 36.20ms 0.22ms 163×
    研发一部(桶约 12.5 万) 5000 1.86ms 1.23ms 1.5×
    项目(桶 = 全表) 5000 1.13ms 0.99ms ≈1×
    不存在的词(桶为空) 0 29.72ms 0.020ms ≈1500×

    这张表比“候选索引能提速”本身更有用。它说明收益完全由最短桶的规模决定:

    • 稀有字符(H、重)的桶很小,收益接近两个数量级;
    • 常见字符(数字 9、项)的桶很大,收益迅速衰减;
    • 全命中时最短桶就是全表,收益为零;
    • 无命中时桶为空,直接返回,收益最大。

    所以候选索引不是“开了就快”,而是“关键词里至少有一个稀有字符时才快”。

    2.5 一个容易忽略的边界

    charAt(j) 取的是 UTF-16 code unit。对 BMP 字符(ASCII、常用汉字)没问题;对 emoji 这类代理对,一个字符会被拆成两个 code unit,桶会更碎。但 indexOf 本身也是按 code unit 匹配的,所以“候选集不会漏”这个正确性不受影响,只是索引的粒度变了。

    真正需要注意的是大小写。搜索和索引都区分大小写:Node 能命中 Node 42,node 一个都命中不了。中文没有这个问题,英文场景要在文档里写清楚,否则用户会以为是搜索出了故障。

    3. 第三步:5000 条截断

    3.1 截断发生在匹配阶段

    MAX_RESULTS 的判断在匹配循环里:

    if (matchCount >= MAX_RESULTS) {
    lastSearchTruncated = true
    break
    }

    它不是一个“结果列表渲染到 5000 条就停”的 UI 限制,而是收集阶段就停。这意味着:

    • 命中 5000 个之后,后面的节点根本不会被检查;
    • 最终 searchTree 的长度由“5000 个命中 + 它们的祖先”决定;
    • 截断标志 lastSearchTruncated 会一直保留到下一次搜索。

    3.2 为什么全命中反而快

    再看一遍 100 万宽树的数据:

    关键词命中扫描到哪里停耗时
    Node(全命中) 5000 第 5000 个节点 1.1ms
    HIT(100 命中) 100 第 100 万行 25.7ms
    zzz(0 命中) 0 第 100 万行 25.5ms

    扫描成本由“最后一个命中出现的位置”决定,而不是命中数量。全命中时第 5000 个节点就攒够了,后面的 99.5 万行根本不看;稀有词和无命中必须扫到表尾才能确认“没有更多”。

    这也是为什么候选索引对全命中没有收益:最短桶就是全表,扫描上限仍然是 5000 个命中,和关闭时完全一样。

    3.3 截断后行数可能超过 5000

    祖先补全发生在截断之后,所以最终结果行数是“5000 个命中 + 去重后的祖先”。深树实测:

    场景命中上限最终行数祖先行数
    浅层叶子 5000 5500 500
    深层叶子 5000 9590 4590
    链节点 5000 5000 0(祖先已在命中集里)

    所以“最多 5000 条”这个说法只对命中数成立,对 UI 看到的总行数不成立。文档里如果写“最多返回 5000 行”,会误导使用者。

    3.4 把“不全”暴露出去

    早期版本是静默截断:用户搜一个常见词,看到 5000 条,以为这就是全部。后来加了显式 API:

    // assembly/index.ts
    export function isSearchTruncated(target: GiantTree): bool {
    return target.wasSearchTruncated()
    }

    组件层的用法很直接:

    const truncated = treeRef.value?.isSearchTruncated()
    if (truncated) {
    // 提示:只显示了前 5000 个匹配,请补充关键词
    }

    一个布尔值就能避免“搜索结果看起来完整、实际上缺了一大半”的误解。这个改动的成本几乎为零,但不做的话,用户迟早会在几百万行的数据上踩到。


    4. 第四步:祖先补全

    4.1 代码

    命中节点收集完之后,逐个向上走父链:

    // assembly/tree-search.ts
    if (parentIndices !== null) {
    for (let i: i32 = 0; i < matchedIndices.length; i++) {
    let parentIndex = parentIndices[matchedIndices[i]]
    while (parentIndex >= 0) {
    const parent = fullTree[parentIndex]
    if (!idSet.has(parent.id)) {
    idSet.add(parent.id)
    parentNodes.push(parent)
    }
    parentIndex = parentIndices[parentIndex]
    }
    }
    }

    parentIndices 是 compactStore.parent,一个 Int32Array,根节点为 -1。它是在建树时顺带算出来的,搜索阶段不需要再遍历树。

    idSet 里一开始装的是命中节点的 id,所以祖先补全会跳过已经命中过的节点;parentNodes 收集完后,直接追加到 searchTree 末尾,最后统一排序:

    sortByLeftNode(searchTree)

    4.2 复杂度

    这段代码的复杂度不是 O(N),而是:

    O(命中数 × 平均祖先链长)

    while 循环每次向上走一层,即使父节点已经在 idSet 里,也不会停——它只是不重复 push,但仍然继续走到根。这个细节是深树慢的根本原因。

    4.3 三种深度的实测

    构造一棵 10 万节点的深树:一条 9090 层的链,每层挂 10 个叶子。

    关键词命中平均链长(估算)最终行数关闭候选开启候选
    leaf(浅层叶子) 5000 ~250 5500 20.24ms 19.82ms
    chain(链节点) 5000 ~2500 5000 350.75ms 348.56ms
    deep(深层叶子) 5000 ~4300 9590 518.63ms 514.87ms

    三个场景的命中数都是 5000,耗时却差了 25 倍。差别只在平均链长:

    • leaf:命中集中在前 500 层,5000 × 250 ≈ 125 万次上溯;
    • chain:命中是链节点本身,深度从 0 到 5000,5000 × 2500 ≈ 1250 万次;
    • deep:命中集中在 4090–4590 层,5000 × 4300 ≈ 2150 万次。

    按循环次数折算,每次上溯大约 16–28ns,量级吻合。这不是 WASM 慢,而是工作量本来就大。

    4.4 去重不等于剪枝

    chain 这个场景特别值得看:命中的是链节点本身,所以每个命中的祖先全部已经在 idSet 里。直觉上应该很快,实测却是 350ms。

    原因就是 while 没有提前退出:

    while (parentIndex >= 0) {
    const parent = fullTree[parentIndex]
    if (!idSet.has(parent.id)) { // 已经在集合里
    idSet.add(parent.id) // 不执行
    parentNodes.push(parent) // 不执行
    }
    parentIndex = parentIndices[parentIndex] // 但仍然继续走
    }

    去重只避免了重复 push,没有避免重复遍历。要真正剪枝,需要区分“命中集合”和“已完成祖先补全的集合”,或者把命中项按深度从深到浅处理:一旦遇到一个已经完成补全的祖先,就可以停,因为更上层必然也完成过。

    这是一个“知道但还没做”的优化。它不影响正确性,只影响最坏情况的耗时;在当前 5000 上限下,最坏 500ms 还能接受,所以没有优先做。

    4.5 搜索视图的折叠

    祖先补全只解决了“结果是一棵完整的树”,还没解决“这棵树怎么显示”。搜索结果有自己的一套折叠状态,不复用主树的:

    // assembly/giant-tree.ts
    private _rebuildSearchShownNodes(): void {
    this._shownNodes.splice(0)
    this.shownCount = 0
    const boundaries: i32[] = []
    for (let i: i32 = 0; i < this.searchTree.length; i++) {
    const node = this.searchTree[i]
    while (
    boundaries.length > 0 &&
    node.leftNode >= boundaries[boundaries.length – 1]
    )
    boundaries.pop()
    if (boundaries.length === 0) {
    this._shownNodes.push(node)
    this.shownCount++
    if (
    this._searchCollapsedIds.has(node.id) &&
    node.rightNode – node.leftNode > 1
    )
    boundaries.push(node.rightNode)
    }
    }
    }

    searchTree 已经按 leftNode 排好序,所以这里又是一次单遍遍历:用 boundaries 栈记录“当前折叠节点的右边界”,后续节点只要 leftNode 落在边界内就跳过。

    这段代码的复杂度是 O(searchTree.length),在 5000–10000 行的规模下可以忽略;它保证的是语义:在搜索结果里展开/收起,不会改到主树的折叠状态。


    把截断和祖先补全的成本关系画在一起,就能看清为什么“命中越多反而越快”和“深树最慢”可以同时成立:

    #mermaid-svg-jkhUwJ0CMADqzqM9{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jkhUwJ0CMADqzqM9 .error-icon{fill:#552222;}#mermaid-svg-jkhUwJ0CMADqzqM9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jkhUwJ0CMADqzqM9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .marker.cross{stroke:#333333;}#mermaid-svg-jkhUwJ0CMADqzqM9 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jkhUwJ0CMADqzqM9 p{margin:0;}#mermaid-svg-jkhUwJ0CMADqzqM9 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .cluster-label text{fill:#333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .cluster-label span{color:#333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .cluster-label span p{background-color:transparent;}#mermaid-svg-jkhUwJ0CMADqzqM9 .label text,#mermaid-svg-jkhUwJ0CMADqzqM9 span{fill:#333;color:#333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .node rect,#mermaid-svg-jkhUwJ0CMADqzqM9 .node circle,#mermaid-svg-jkhUwJ0CMADqzqM9 .node ellipse,#mermaid-svg-jkhUwJ0CMADqzqM9 .node polygon,#mermaid-svg-jkhUwJ0CMADqzqM9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jkhUwJ0CMADqzqM9 .rough-node .label text,#mermaid-svg-jkhUwJ0CMADqzqM9 .node .label text,#mermaid-svg-jkhUwJ0CMADqzqM9 .image-shape .label,#mermaid-svg-jkhUwJ0CMADqzqM9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-jkhUwJ0CMADqzqM9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jkhUwJ0CMADqzqM9 .rough-node .label,#mermaid-svg-jkhUwJ0CMADqzqM9 .node .label,#mermaid-svg-jkhUwJ0CMADqzqM9 .image-shape .label,#mermaid-svg-jkhUwJ0CMADqzqM9 .icon-shape .label{text-align:center;}#mermaid-svg-jkhUwJ0CMADqzqM9 .node.clickable{cursor:pointer;}#mermaid-svg-jkhUwJ0CMADqzqM9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .arrowheadPath{fill:#333333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jkhUwJ0CMADqzqM9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jkhUwJ0CMADqzqM9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jkhUwJ0CMADqzqM9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jkhUwJ0CMADqzqM9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jkhUwJ0CMADqzqM9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jkhUwJ0CMADqzqM9 .cluster text{fill:#333;}#mermaid-svg-jkhUwJ0CMADqzqM9 .cluster span{color:#333;}#mermaid-svg-jkhUwJ0CMADqzqM9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jkhUwJ0CMADqzqM9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jkhUwJ0CMADqzqM9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-jkhUwJ0CMADqzqM9 .icon-shape,#mermaid-svg-jkhUwJ0CMADqzqM9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jkhUwJ0CMADqzqM9 .icon-shape p,#mermaid-svg-jkhUwJ0CMADqzqM9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jkhUwJ0CMADqzqM9 .icon-shape .label rect,#mermaid-svg-jkhUwJ0CMADqzqM9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jkhUwJ0CMADqzqM9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jkhUwJ0CMADqzqM9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jkhUwJ0CMADqzqM9 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    是

    否

    扫描 fullTree

    命中数达到 5000?

    停止扫描扫描成本 = 第 5000 个命中的位置

    扫到表尾扫描成本 = 全表

    5000 个命中

    实际命中数

    祖先补全成本 = 命中数 × 平均链长

    最终 searchTree 行数= 命中 + 去重祖先

    视口序列化只传当前可见行

    • 宽而浅的树:扫描很快被截断,祖先链短,整体毫秒级;
    • 宽而深的树:扫描同样被截断,但 命中数 × 平均链长 迅速放大,deep 场景到 518ms;
    • 稀有词:扫描必须走完全表,但命中少、祖先少,成本集中在扫描阶段。

    5. 缓存与防抖

    5.1 同一关键词只算一次

    if (!this._hasSearchCache || this._lastSearchKeyword !== keyword) {
    // 重新搜索
    } else {
    this.shownCount = this.searchTree.length
    }

    实测同一关键词的第二次搜索:

    场景首次第二次(缓存)
    1M 英文 HIT 25.66ms 0.146ms
    1M 英文 999 26.14ms 0.253ms
    100k 深树 deep 518.63ms 0.306ms

    缓存命中后仍然会走 _rebuildSearchShownNodes 和视口序列化,所以不是 0,但已经从“几百毫秒”降到“几百微秒”。

    5.2 为什么还要防抖

    缓存只对“同一个关键词”有效。用户在输入框里每敲一个字符,关键词就变了,缓存必然失效。组件层因此提供两个入口:

    // 带 300ms 防抖,适合 input 实时输入
    fuzzySearch(keyword)
    // 立即执行,适合回车/按钮
    fuzzySearchRaw(keyword)

    300ms 的选择是一个体验权衡:太短,连续输入仍然会触发多次全表搜索;太长,用户会觉得“按了没反应”。对一个 1M 树、单次搜索 25ms 的场景,300ms 足够把 10 次按键合并成 1 次。

    5.3 高亮不能跟着输入框走

    高亮用的关键词必须是“实际执行过搜索的那个关键词”,而不是输入框的实时值。否则防抖还没触发,DOM 已经开始按新关键词高亮旧结果了:

    // 搜索真正执行时才更新
    const rawFuzzySearch = (keyword: string) => {
    activeSearchKeyword.value = keyword
    …
    }

    这个细节在文档里只有一句话,但它是“搜索看起来不闪”的关键。


    6. 从结果到 DOM

    6.1 只序列化视口

    fuzzySearch 的返回值不是整个 searchTree,而是 serializeShownSlice:

    return serializeShownSlice(
    this._shownNodes,
    this.scrollTop,
    this.scrollHeight,
    this.lineHeight
    )

    即使搜索结果有 5000 行,跨边界传给主线程的也只有当前视口那几十行。这是为什么“1M 全命中 5000 行”只需要 1.1ms:匹配在 5000 个节点就停了,序列化只做视口,两个成本都被截断和视口切片压住了。

    这也解释了一个常见误解:搜索结果的大小不等于跨边界的数据量。真正决定每次搜索开销的,是“扫描范围 + 祖先补全循环 + 视口序列化”,不是结果总行数。

    6.2 高亮

    高亮默认关闭,打开后只标记输入的关键词本身:

    // lib/TreeItem.vue
    const highlightParts = computed<SearchHighlightPart[] | null>(() => {
    const keyword = props.highlightKeyword
    if (!keyword) return null
    const name = props.item.name
    let from = 0
    let at = name.indexOf(keyword)
    if (at < 0) return null
    const parts: SearchHighlightPart[] = []
    while (at >= 0) {
    if (at > from) parts.push({ text: name.slice(from, at), hit: false })
    parts.push({ text: keyword, hit: true })
    from = at + keyword.length
    at = name.indexOf(keyword, from)
    }
    if (from < name.length) parts.push({ text: name.slice(from), hit: false })
    return parts
    })

    三个语义和 WASM 搜索严格对齐:

  • 区分大小写:indexOf 对 indexOf;
  • 只标关键词:祖先节点只是因为补全路径出现,名称里没有关键词时不会被标记;
  • 全部命中都标:名字里出现多次就标记多处。
  • 渲染时用 <mark class="giant-tree__search-hit">,颜色可以用 CSS 变量覆盖:

    –giant-tree-search-hit-bg
    –giant-tree-search-hit-color

    如果用了 #node 插槽,组件默认的 <mark> 不会生效,但会把切好的 highlights 传给你:

    <template #node="{ node, highlights }">
    <span v-if="!highlights">{{ node.name }}</span>
    <span v-else>
    <template v-for="(part, i) in highlights" :key="i">
    <mark v-if="part.hit" class="giant-tree__search-hit">{{
    part.text
    }}</mark>
    <template v-else>{{ part.text }}</template>
    </template>
    </span>
    </template>


    7. 语义边界:搜索是视图,不是数据子集

    这是最容易踩坑的一块。搜索结果只是 _shownNodes 的一个过滤视图,底层的 fullTree 没有被复制,也没有被裁剪。

    所以下面这些行为都是“正确但反直觉”的:

    7.1 搜索中勾选父节点,会影响未显示的子节点

    // 搜索结果里勾选“研发一部”
    checkNode(tree, 'dept-1', CheckType.CHECKED)

    checkNode 操作的是 fullTree 里的节点,会级联到它的整棵子树,包括那些因为不匹配关键词而没有显示的行。清空搜索后,你会看到子节点也都被勾选了。

    这不是 bug,而是“搜索是视图过滤”的直接结果。要注意 setCheckedNodes 也不是“只设置这些节点”:它会先重置全部选中,再对每个传入的 ID 级联设置其整棵子树。所以如果业务要“只选搜索结果”,需要把结果里的叶子节点 ID 交给它,或者由业务层自己维护选中集合;把结果里的父节点直接传进去,仍然会勾中未显示的子节点。

    项目里有专门的回归测试守住这个语义:

    it('搜索中选中父节点后清空搜索,子节点状态仍保留', () => {
    fuzzyTree(tree, 'Searchable')
    checkNode(tree, 'parent', CheckType.CHECKED)
    fuzzyTree(tree, '')
    // parent / child / grandchild 都应该是 CHECKED
    })

    7.2 清空搜索会恢复主树折叠,而不是保留搜索视图

    fuzzySearch('') 走的是另一条分支:

    this._searchCollapsedIds.clear()
    this._searchTruncated = false
    this.tree = this.fullTree
    resetShownFlags(this.fullTree)
    this._rebuildShownNodes()

    清空关键词意味着“回到主树”,所以搜索视图里的折叠状态被丢弃,主树按自己的 collapsed 标志重建。搜索视图的展开/收起是临时的,不应该污染主树。

    7.3 搜索与 Worker

    workerMode 下,搜索命令走的是同一个 WASM 实例:

    else if (m.type === 'search') wasm.fuzzyTree(tree, m.keyword)

    主线程只负责把 activeSearchKeyword 同步给高亮,结果行由 Worker 的 snapshot 回传。快照带 revision,旧搜索结果不会覆盖新状态;结构变更命令会在搜索之前 flushStructure(),保证“先增删、再搜索”的顺序。

    这部分和 Worker 协议的其他命令共用一套机制,展开会太长,这里只记一句:搜索不是一条独立通道,它和勾选、折叠、结构变更共享同一条有序命令队列。


    8. 决策表

    8.1 候选索引开不开

    场景建议理由
    1M 树,搜索频繁,关键词稀有 开 建桶 1.3s 一次性,单次 0.27ms
    1M 树,首次搜索/偶发搜索 关 建桶比一次搜索贵约 40–50 倍
    关键词常见、经常全命中 关 最短桶≈全表,收益接近 0
    关键词经常无命中 开 空桶直接返回,收益 1000×
    10 万以下的小树 关 全表扫描本身只有几毫秒

    一个粗略的盈亏平衡:

    建桶成本 ≈ 40–50 × 单次全表搜索成本(1M 树)

    也就是说,同一棵树至少要被搜几十次,而且关键词要足够稀有,候选索引才回本。底层 GiantTree 的默认值 false 不是保守,而是对大多数“打开页面搜一次”的场景更优。组件目前没有把这个开关做成 prop;如果要在业务里打开,需要直接调用 WASM 的 setUseSearchCandidateIndex(),或者把建桶放到 Worker/空闲时段,避免首屏付这 1.3 秒。

    8.2 搜索结果怎么用

    需求做法
    展示命中路径 直接用 searchTree,祖先已补全
    搜索结果内再折叠 用搜索视图自己的折叠 API,不影响主树
    只选搜索结果 只传结果中的叶子 ID 给 setCheckedNodes;传父节点会级联整棵子树
    结果可能超过 5000 检查 isSearchTruncated(),提示补充关键词或做后端搜索
    深树搜索超过一帧 考虑后端搜索/预建索引/限制搜索范围

    8.3 什么时候不该在客户端搜

    深树 5000 命中、平均链长 4300 时,单次搜索 518ms,已经超过一帧的预算。这种场景下,候选索引救不了你,截断也只能把 5000 降到更小的数。如果业务要求“输入即出结果”,而树的形状又很深,应该把搜索放到后端,或者预先算好一份“节点 → 祖先链”的扁平索引。

    客户端的优势是“数据已经在内存里”,劣势是“每次搜索都要重新走一遍祖先链”。这两件事不矛盾,关键看你的树形状和查询频率。


    9. 基准口径

    文章里的数字都来自同一套脚本,口径如下,方便你复现或质疑:

    • 环境:Node v26.7.0,release WASM,macOS arm64,单进程,–expose-gc;
    • 调用:端到端 fuzzyTree(),包含视口序列化(boundary = 800px,约 30 行);
    • 重复:每项 3 次,取 P50/P95;
    • 建树:pushNeighborNodes 分批 5 万,popNeighbor 后 setBoundary(0, 800);
    • 宽树:root 下 N 个叶子,英文名 Node ${i},中文名 ${部门}-项目${i};
    • 深树:一条 9090 层的链,每层 10 个叶子;
    • 结果行数:getShownHeight() / 26,包含祖先补全行,不是命中数;
    • 候选索引:建桶成本单列,不计入“开启”的 warm 搜索耗时。

    有两个数字容易被误读,单独说明:

  • 1M 中文树线性内存 1GB,英文树 512MB。这不是候选索引造成的,而是中文名字更长、字符串 payload 更大。测量点在建树之后、建桶之前。
  • 深树的 518ms 是最坏形状,不是所有深树的平均值。同样的 10 万节点,浅层命中的 leaf 只要 20ms。文章用 deep 场景是为了把“祖先补全”的成本单独放大出来。

  • 10. 写在最后

    把这次搜索拆完,最有用的不是“候选索引快了 95 倍”这个结论,而是三件事的分工:

    • 倒排桶:决定“要扫多少行”,收益取决于最短桶的大小;
    • 5000 截断:决定“最多收集多少命中”,它让全命中反而变快,也让结果行数可能超过 5000;
    • 祖先补全:决定“最坏情况有多慢”,成本是 命中数 × 平均深度,任何匹配阶段的优化都救不了它。

    如果只记一句话:

    先搞清楚你的搜索慢在哪一段,再决定加不加索引。 对一棵宽而浅的树,候选桶可能值 95 倍;对一棵深而窄的树,它可能连 1% 都省不下来。

    项目地址:github.com/jktantan/vue3-giant-tree-wasm。搜索的文档在 guide/fuzzy-search,里面有在线 playground,可以直接把关键词换成你自己的数据试一试。


    附:文中涉及的文件

    文件内容
    assembly/tree-search.ts 匹配、候选桶选择、5000 截断、祖先补全、排序
    assembly/giant-tree.ts fuzzySearch 入口、候选桶构建、搜索视图重建、缓存
    assembly/index.ts fuzzyTree / isSearchTruncated 桥接
    lib/VueGiantTree.vue 300ms 防抖、activeSearchKeyword、Worker 搜索命令
    lib/TreeItem.vue 关键词高亮拆分与 <mark> 渲染
    tests/wasm/tree-search.test.ts 祖先补全、截断、视图切换、搜索中勾选
    docs-site/guide/fuzzy-search.md 对外 API、高亮、搜索视图折叠
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 100 万节点里搜 3 个字:倒排桶、5000 条截断与祖先补全
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!