瀏覽器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歡迎把玩。在掘金上第一次發文,歡迎各位大佬指教。

相關文章
相關標籤/搜索