阅读 870

浏览器Ctrl+F功能的JS实现

浏览器提供的查找功能(Ctrl+F唤起)可以方便我们检索页面中的关键字以及标记它们出现在页面中的位置。在某个需求中,我需要实现一个类似的静态页面文本检索功能,JS并不能直接调用浏览器提供的检索功能,在不借助任何后端和云搜索(例如Algolia)的前提下,实现页面文本搜索。

思考

检索工具的运行流程是这样的:1)用户输入关键字,点击搜索按钮,检索完成,并标记第一个出现的位置;2)点击下一个按钮,页面滚动到下一个匹配关键字的元素的位置,并标记文本;3)使用中途,可以切换关键字,重新开始步骤 1)2)。

我们要实现的文本搜索工具主要有两个功能:

  1. 文本检索
  2. 文本标记

此外,我们还可以在这两个基础功能上进行扩展,比如:

  • 既然支持了关键字查询,那么可以让是否模糊查询可选
  • 同样,让搜索起点可选
  • 增加对CSS选择器的支持

确定了要实现的功能后,再想一下大致的实现方式,这里有几个问题,解决完所有的问题,文本检索工具就完成了。

  1. 接口该如何设计?
  2. 为了保证搜索性能,需要建立文本HTMLElement的映射,如何实现这个过程?
  3. 基于建立的映射,如何处理同一个元素下多个文本的标记?
  4. 其它可能遇到的问题。

带着这些问题,开始!

如何设计接口?

基于上面描述的检索工具的运行流程和将要实现的功能,在工具初始化时,可以配置用户初始输入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[]>函数开始检索流程,整个流程又分为关键字匹配选择器查询

选择器查询

选择器查询很简单,就是调用dom.querSelectorAll(input),需要注意的是,如果input不是一个合法的selectors,会抛出错误,在实现的时候,捕获该错误,抛出[]即可。

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的信息:

npm点这里😀

github看过来😂

这里,还写了一个简易的demo欢迎把玩。在掘金上第一次发文,欢迎各位大佬指教。