6

背景

Ctrf+F相信大家一定不陌生,很多人都依赖Ctrf+F来搜索网页上想要看到的内容,也是最频繁使用的功能之一。浏览器之间存在兼容性问题,但是肯定都提供原生搜索框(而且快捷键都是ctrf+f)

原生搜索功能

这里简单介绍下原生搜索框提供了哪些功能
image.png

  1. 关键词高亮
  2. 当前页面命中关键词数量和当前选中的关键词序号
  3. 当前选中关键词高亮
  4. 切换到上/下一个关键词
  5. esc关闭搜索

功能简单且强大,只要是页面内渲染出来的文本都能搜索且定位到目标

不足

ctrf+f的功能满足我们日常所需,那么是不是存在不足之处呢?来看看几个案例

  1. 2个以上div标签组成的连续文本
    image.png

    <div style="display:flex">  
        <div>搜索</div>  
        <div>测试</div>  
    </div>
    
  2. 手风琴组件被折叠的文本也会搜索出来
    image.png

    <div>搜索</div>  
    <div style="height: 0px;">测试</div>  
  3. 页面级别的搜索,某些非正文内容也在搜索范围,比如导航、底部、广告

想法

针对不足点1,其实document.querySelector('.box').textContent打印出来结果是搜索测试中间有个换行符,所以导致浏览器无法搜索出来。在富文本的场景下,这种结构的html很常见,比如某段文字中插入几个高亮文字来做tooltip解释说明。理想的结果当然是能自动生成连续的文字再搜索

针对不足点2、3,也是因为我们不希望站内某些内容被索引到,或者说索引到的时候能自动展开,比如手风琴

方案

需要实现一个类似ctrf+f的搜索功能,并且跟框架无关,那么只有从dom上面入手

  1. 需要一个标识来识别连续文本,通过深度遍历的方式拼接文本同时记录文本节点所在的位置,主要是为了不破坏原有dom结构的同时高亮跨标签的文本,比如

    <div style="display:flex">  
        <div>搜索</div>  
        <div>测试</div>  
    </div>
    <!-- 替换成 -->
    <div style="display:flex">  
        <div><mark>搜索</mark></div>      <!-- mark就是高亮标签 -->
        <div><mark>测试</mark></div>  
    </div>
  2. 深度遍历dom结构的同时需要做几个逻辑

    1. 记录滚动节点,可以根据scrollHeight、clientHeight、scrollWidth、clientWidth判断元素是否存在滚动条
    2. 过滤无关标签,比如设置了display:none或者height:0,当然也可以自己定义不搜索特定的class
  3. 圈定搜索范围,满足不足点3
  4. 修复不合理的文本标签,具体原因后面解释

    <div id="error">
        这是异常的
        <span>节点</span>
    </div>
    <!-- 替换为 -->
    <div id="error">
        <span>这是异常的</span>
        <span>节点</span>
    </div>
  5. 替换高亮标签的同时保留原有的文本,便于后续恢复

    <div>  
        搜索测试
    </div>
    <!-- 关键词 搜索,dom结构替换为 -->
    <div>  
      <mark>搜索</mark>  
      测试
      <template>搜索测试</template>
    </div>
  6. 命中关键词总数量和上/下功能通过数组和当前下标值来确定
  7. 提供上下定位到某个高亮关键词功能~~~~

逻辑处理

圈定搜索范围

传入一个class或者id即可,做为后续深度遍历的顶层节点
const dom = document.querySelector(classname)

深度递归遍历dom结构

  1. 记录滚动节点
  2. 过滤不显示(height:0display:none)和黑名单标签
  3. 记录长文本(拼接搜索区域出现过关键词字串的文本)和节点文本在长文本中起始和结束位置
formatDom(el, value) {
        const childList = el.childNodes
        if (!childList.length || !value.length) return // 无子节点或无查询值,则不进行下列操作
        childList.forEach(el => {
            // 遍历其内子节点
            if (el.nodeType === 1 || el.nodeType === 3) {
                //页面内存在滚动节点的话,需要记录
                if (isRealNode(el)) {
                    if(el.scrollHeight > el.clientHeight) {
                        //纵向滚动条
                        this.overflowYDom.push(el)
                    }
                    if(el.scrollWidth > el.clientWidth) {
                        //横向滚动条
                        this.overflowXDom.push(el)
                    }
                }
                if (
                    isRealNode(el) && // 如果是元素节点
                    checkClassName(el, this.blackClassName) &&
                    !/(script|style|template)/i.test(el.tagName)
                ) {
                    // 并且元素标签不是script或style或template等特殊元素
                    this.formatDom(el, value) // 那么就继续遍历(递归)该元素节点
                } else if (el.nodeType === 3) {
                    // 记录关键词中字串出现过的文本节点
                    for (let j = 0; j < value.length; j++) {
                        if (el.data.indexOf(value[j]) > -1) {
                            const start = this.searchDom.text.length
                            this.searchDom.text = this.searchDom.text + el.parentNode.innerText     //拼接文本,便于后续处理跨文本标签匹配
                            this.searchDom.data[`${start}-${this.searchDom.text.length - 1}`] = el                  //记录每个文本节点内容在全文本中起始下标位置
                            break
                        }
                    }
                }
            }
        })
    }

处理跨标签文本

假设原始dom长这样

<div>
    <h2>跨标签文案</h2>
    <div class="flex">
        <div>
            这是一段跨标签
        </div>
        <div>
            跨多个标签
        </div>
        <div>
            组成的文案
        </div>
        <div>
            测试
        </div>
    </div>
</div>

搜索关键词:文案测试

上一步已经获取到长文本(搜索区域拼接的全部文本内容)和节点出现在长文本的下标
长文本跨标签文案组成的文案测试
节点出现在长文本的下标:

{
    '0-4': textElement,
    '5-9': textElement,
    '10-11': textElement
}

注:这里只收集包含关键词字串的节点和文本内容,比如 这是一段跨标签 这个节点就不收集

  1. 先找到搜索关键词在长文本中出现的起始位置

        searchSubStr(str, subStr) {
            //str 长文本
            //subStr 关键词
            let arr = []
            let index = str.indexOf(subStr)
            while (index > -1) {
                arr.push(index)
                index = str.indexOf(subStr, index + 1)
            }
            return arr
        }
        //返回 [8]
  2. 根据起始位置加上关键词长度得到匹配的区域
    比如关键词所在的位置就是 8 - 12(关键词长度 4),从收集的节点出现在长文本的下标,发现跨2个text节点,那么就需要同时高亮2个节点
  3. 计算高亮节点(可能跨标签),具体逻辑这里就不多描述了,感兴趣可以参考源码

高亮节点

代码细节可以参考源码,有几个需要注意的事项

  1. 需要修复异常节点,比如

     <div id="error">
         这是异常的
         <span>节点</span>
     </div>
     <!-- 替换为 -->
     <div id="error">
         <span>这是异常的</span>
         <span>节点</span>
     </div>

    原因在于这是异常的这个text节点存在一个兄弟节点 span,取消高亮之后无法复原(可能有别的办法,但是考虑成本过高,就不考虑),复原方案参考下面

  2. 替换高亮节点的同时保留template方便后续复原,跨标签节点标记同个id(mark-id),方便上下选中的计算
    image.png

总结

demo传送门
篇幅有限,其它细节就不一一解释,大家可以看下源码,顺便求个start~~~


jayzou
2.8k 声望498 粉丝

生鱼忧患,死鱼安乐