浏览器提供的查找功能(
Ctrl+F
唤起)能够方便咱们检索页面中的关键字以及标记它们出如今页面中的位置。在某个需求中,我须要实现一个相似的静态页面文本检索功能,JS
并不能直接调用浏览器提供的检索功能,在不借助任何后端和云搜索(例如Algolia
)的前提下,实现页面文本搜索。node
检索工具的运行流程是这样的:1)用户输入关键字,点击
搜索
按钮,检索完成,并标记第一个出现的位置;2)点击下一个
按钮,页面滚动到下一个匹配关键字的元素的位置,并标记文本;3)使用中途,能够切换关键字,从新开始步骤 1)2)。git
咱们要实现的文本搜索工具主要有两个功能:github
此外,咱们还能够在这两个基础功能上进行扩展,好比:npm
CSS
选择器的支持肯定了要实现的功能后,再想一下大体的实现方式,这里有几个问题,解决完全部的问题,文本检索工具就完成了。后端
文本
到HTMLElement
的映射,如何实现这个过程?带着这些问题,开始!数组
基于上面描述的检索工具的运行流程和将要实现的功能,在工具初始化时,能够配置用户初始输入input
/是否模糊查询useRegexp
/检索入口scope
;初始化完成后,经过调用search
接口,开始检索并返回检索结果;检索完成后,经过调用next
接口,开始在页面中标记文本;最后,还须要一个setSearch
接口来设置用户检索文本。浏览器
最终设计的接口以下:缓存
export declare const TypeSelector = "selector";
export declare const TypeText = "text";
export interface IDomFormated {
dom: HTMLElement;
type: typeof TypeSelector | typeof TypeText;
}
export declare type KeywordsOrSelector =
| string
| keyof HTMLElementTagNameMap
| keyof SVGElementTagNameMap;
export interface IOptions {
useRegexp?: boolean;
scope?: HTMLElement | string;
}
interface IProps extends IOptions {
input?: KeywordsOrSelector;
}
declare class LocalSearch {
private input;
private config;
private current;
private result;
private prevDomText;
private prevDom;
private updateList;
constructor(props: IProps);
setSearch(input: KeywordsOrSelector): void;
begin(): Promise<IDomFormated[]> | undefined;
next(): boolean;
}
export default LocalSearch;
复制代码
调用localSearchInstance.begin
其内部会调用search(input: KeywordsOrSelector, params?: IOptions): Promise<IDomFormated[]>
函数开始检索流程,整个流程又分为关键字匹配和选择器查询。markdown
选择器查询很简单,就是调用dom.querSelectorAll(input)
,须要注意的是,若是input
不是一个合法的selectors
,会抛出错误,在实现的时候,捕获该错误,抛出[]
便可。dom
function querySelector(input: KeywordsOrSelector, scope: HTMLElement) {
let doms: HTMLElement[] = [];
try {
doms = Array.from(scope.querySelectorAll(input));
} catch (error) {
console.warn("invalid selector");
} finally {
return Promise.resolve(doms);
}
}
复制代码
关键字检索就是遍历已经创建好的文本与元素间的映射。首次检索时,映射未创建,须要从指定的根元素开始,进行 DOM 遍历。
在DOM
遍历过程当中,为了更好的创建映射,须要创建如下几条约定:
nodeType = 3
和以及注释nodeType = 8
和[ 'SCRIPT', 'NOSCRIPT', 'BR', 'HR', 'IMG', 'INPUT', 'COL', 'FRAME', 'LINK', 'AREA', 'PARAM', 'EMBED', 'KEYGEN', 'SOURCE', ]
这些自闭合元素,再也不向下遍历display: inline*
,不在向下遍历TextNode
,再也不向下遍历 代码以下:function generateTextMapString(parent: HTMLElement) {
if (!first) {
return;
}
// 非element类型
if (!isValidNode(parent)) {
return;
}
const isInline = /^inline/.test(getStyle(parent, "display"));
// 若是是行内元素
if (isInline) {
setCaches(parent.innerText, parent);
return;
}
// 若是某个元素中只包含TextNode,则取父元素的innerText
const childNodes = Array.from(parent.childNodes);
if (childNodes.every((node) => node.nodeType === 3)) {
setCaches(parent.innerText, parent);
return;
}
// 遍历全部childNode
for (const node of childNodes) {
if (node.nodeType === 3 && node.textContent !== "" && !isWrapMark(node)) {
setCaches(node.textContent!, parent);
} else {
generateTextMapString(node as HTMLElement);
}
}
}
复制代码
注意:考虑到可能存在多个元素有相同文本,在创建映射时,value
须要被设置成一个数组。
待到两种搜索都完成后,返回全部检索到的HTMLElement
便可。
针对关键字检索和CSS
选择器查询两种不一样的类型,各设置了标记策略,若是是关键字检索,使用特定的背景和文字颜色标记文本,若是是CSS
选择器查询,则改变元素的背景色。
挡调用next
方法时,会对下一个匹配到的文本进行标记,这包含两个过程:1)清空上一个标记(若是有的话), 2)标记当前文本
与其说叫清空上一个标记
,不如叫作还原到未标记时的状态,在此以前,咱们须要保存上一次标记的元素以及该元素未标记时的文本,有了这两个数据,清空操做就很好实现了:
function restoreMarked(domObj: IDomFormated | null, text?: string) {
if (!domObj || !domObj!.dom) {
return;
}
const { dom, type } = domObj;
if (type === TypeText) {
dom.innerHTML = text!;
} else if (type === TypeSelector) {
const prevBgColor = dom.dataset["bgc"] || "";
dom.style.backgroundColor = prevBgColor;
}
}
复制代码
因为第一步search
返回的是匹配到的元素,那么当某个元素中的文本含有多个匹配时,应当屡次标记。因此,在标记过程当中,使用updateList
保存该元素中全部匹配结果所对应次更新的文本。在每次调用next
方法时,若是updateList
不为空,则取updateList
中第一项做为当次更新的文本,不然取下一个匹配到的元素。基本实现以下:
export function markKeywords( keywords: KeywordsOrSelector, domObj: IDomFormated, useRegexp: boolean ) {
const { dom, type } = domObj;
let updateList: string[] = [];
if (type === TypeText) {
let newText;
if (!useRegexp) {
newText = dom.innerHTML.replace(
keywords,
`<span style="background-color: #169fe6; color: #ffffff;">${keywords}</span>`
);
} else {
// 存入全部结果到更新队列
const reg = new RegExp(`(${keywords})`, "g");
const domString = dom.innerHTML;
let result = reg.exec(domString);
while (result) {
const updateString =
dom.innerHTML.substring(0, result.index) +
`<span style="background-color: #169fe6; color: #ffffff;">${result[0]}</span>` +
dom.innerHTML.substring(result.index + result[0].length);
updateList.push(updateString);
result = reg.exec(domString);
}
}
if (newText) {
dom.innerHTML = newText;
}
} else if (type === TypeSelector) {
const prevBgColor = dom.style.backgroundColor;
dom.dataset["bgc"] = prevBgColor!;
// 保存背景色,便于后续恢复
dom.style.backgroundColor = "#169fe6";
}
return updateList;
}
复制代码
标记完后,使用dom.scrollIntoView()
便可滚动当前元素到标记位置
页面文本搜索工具主要包含两个流程:文本检索和文本标记,为了加快检索速度,构建了文本到HTMLElement
的映射缓存,该映射的生成遵照几个约定(可能会出现问题
),文本标记则分为两个子步骤:1.还原到未标记状态;2.标记当前文本。若是遇到一个元素中有多个匹配结果,创建了一个更新列表(updateList
),将分屡次标记。
查看更多LocalSearch
的信息:
这里,还写了一个简易的demo欢迎把玩。在掘金上第一次发文,欢迎各位大佬指教。