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

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

思考

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

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

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

此外,咱们还能够在这两个基础功能上进行扩展,好比:npm

  • 既然支持了关键字查询,那么可让是否模糊查询可选
  • 一样,让搜索起点可选
  • 增长对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[]>函数开始检索流程,整个流程又分为关键字匹配选择器查询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的信息:

npm点这里😀

github看过来😂

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

相关文章
相关标签/搜索