背景
Ctrf+F相信大家一定不陌生,很多人都依赖Ctrf+F来搜索网页上想要看到的内容,也是最频繁使用的功能之一。浏览器之间存在兼容性问题,但是肯定都提供原生搜索框(而且快捷键都是ctrf+f)
原生搜索功能
这里简单介绍下原生搜索框提供了哪些功能
- 关键词高亮
- 当前页面命中关键词数量和当前选中的关键词序号
- 当前选中关键词高亮
- 切换到上/下一个关键词
- esc关闭搜索
功能简单且强大,只要是页面内渲染出来的文本都能搜索且定位到目标
不足
ctrf+f的功能满足我们日常所需,那么是不是存在不足之处呢?来看看几个案例
-
2个以上div标签组成的连续文本
<div style="display:flex"> <div>搜索</div> <div>测试</div> </div>
-
手风琴组件被折叠的文本也会搜索出来
<div>搜索</div> <div style="height: 0px;">测试</div>
- 页面级别的搜索,某些非正文内容也在搜索范围,比如导航、底部、广告
想法
针对不足点1,其实document.querySelector('.box').textContent
打印出来结果是搜索和测试中间有个换行符,所以导致浏览器无法搜索出来。在富文本的场景下,这种结构的html很常见,比如某段文字中插入几个高亮文字来做tooltip解释说明。理想的结果当然是能自动生成连续的文字再搜索
针对不足点2、3,也是因为我们不希望站内某些内容被索引到,或者说索引到的时候能自动展开,比如手风琴
方案
需要实现一个类似ctrf+f的搜索功能,并且跟框架无关,那么只有从dom上面入手
-
需要一个标识来识别连续文本,通过深度遍历的方式拼接文本同时记录文本节点所在的位置,主要是为了不破坏原有dom结构的同时高亮跨标签的文本,比如
<div style="display:flex"> <div>搜索</div> <div>测试</div> </div> <!-- 替换成 --> <div style="display:flex"> <div><mark>搜索</mark></div> <!-- mark就是高亮标签 --> <div><mark>测试</mark></div> </div>
-
深度遍历dom结构的同时需要做几个逻辑
- 记录滚动节点,可以根据
scrollHeight、clientHeight、scrollWidth、clientWidth
判断元素是否存在滚动条 - 过滤无关标签,比如设置了display:none或者height:0,当然也可以自己定义不搜索特定的class
- 记录滚动节点,可以根据
- 圈定搜索范围,满足不足点3
-
修复不合理的文本标签,具体原因后面解释
<div id="error"> 这是异常的 <span>节点</span> </div> <!-- 替换为 --> <div id="error"> <span>这是异常的</span> <span>节点</span> </div>
-
替换高亮标签的同时保留原有的文本,便于后续恢复
<div> 搜索测试 </div> <!-- 关键词 搜索,dom结构替换为 --> <div> <mark>搜索</mark> 测试 <template>搜索测试</template> </div>
- 命中关键词总数量和上/下功能通过数组和当前下标值来确定
- 提供上下定位到某个高亮关键词功能~~~~
逻辑处理
圈定搜索范围
传入一个class或者id即可,做为后续深度遍历的顶层节点const dom = document.querySelector(classname)
深度递归遍历dom结构
- 记录滚动节点
- 过滤不显示(
height:0
和display:none
)和黑名单标签 - 记录长文本(拼接搜索区域出现过关键词字串的文本)和节点文本在长文本中起始和结束位置
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
}
注:这里只收集包含关键词字串的节点和文本内容,比如 这是一段跨标签
这个节点就不收集
-
先找到搜索关键词在长文本中出现的起始位置
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]
- 根据起始位置加上关键词长度得到匹配的区域
比如关键词所在的位置就是 8 - 12(关键词长度 4),从收集的节点出现在长文本的下标,发现跨2个text节点,那么就需要同时高亮2个节点 - 计算高亮节点(可能跨标签),具体逻辑这里就不多描述了,感兴趣可以参考源码
高亮节点
代码细节可以参考源码,有几个需要注意的事项
-
需要修复异常节点,比如
<div id="error"> 这是异常的 <span>节点</span> </div> <!-- 替换为 --> <div id="error"> <span>这是异常的</span> <span>节点</span> </div>
原因在于
这是异常的
这个text节点存在一个兄弟节点span
,取消高亮之后无法复原(可能有别的办法,但是考虑成本过高,就不考虑),复原方案参考下面 - 替换高亮节点的同时保留template方便后续复原,跨标签节点标记同个id(mark-id),方便上下选中的计算
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。