You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Angular中实现类似Chrome Ctrl+F的指定div内容搜索功能

Angular 实现类Chrome Ctrl+F 页面内搜索功能方案

你之前用Pipe仅实现高亮是因为Pipe仅负责视图渲染转换,不维护搜索状态、匹配位置等数据,要实现完整功能需要结合状态管理+DOM操作+数据缓存的方案,具体实现如下:


核心实现思路

  • 不依赖Pipe做全量高亮,改为预先扫描目标div的所有文本节点,缓存所有匹配位置、匹配文本片段、对应DOM节点的映射关系
  • 输入框加长度校验,只有输入内容≥3字符时才触发扫描逻辑
  • 维护全局状态:匹配总数、当前选中的匹配项索引、所有匹配项的DOM节点列表

具体代码实现

1. 组件类基础状态定义

import { Component, ViewChild, ElementRef } from '@angular/core';

@Component({
  selector: 'app-search-in-page',
  templateUrl: './search-in-page.component.html',
  styleUrls: ['./search-in-page.component.css']
})
export class SearchInPageComponent {
  searchQuery = '';
  matchCount = 0;
  currentMatchIndex = -1;
  private matchElements: HTMLElement[] = [];
  // 绑定目标搜索容器
  @ViewChild('searchContainer') searchContainer!: ElementRef<HTMLElement>;

  // 输入框内容变化触发逻辑
  onSearchInput() {
    const query = this.searchQuery.trim();
    if (query.length < 3) {
      this.clearSearchState();
      return;
    }
    this.performSearch(query);
    this.jumpToMatch(0);
  }

  // 跳转到上一个匹配项
  goToPrev() {
    if (this.matchCount === 0) return;
    const prevIndex = this.currentMatchIndex <= 0 ? this.matchCount - 1 : this.currentMatchIndex - 1;
    this.jumpToMatch(prevIndex);
  }

  // 跳转到下一个匹配项
  goToNext() {
    if (this.matchCount === 0) return;
    const nextIndex = this.currentMatchIndex >= this.matchCount - 1 ? 0 : this.currentMatchIndex + 1;
    this.jumpToMatch(nextIndex);
  }
}

2. 搜索与高亮逻辑实现

private performSearch(query: string) {
  // 先清空上一次搜索的高亮状态
  this.clearSearchState();
  const container = this.searchContainer.nativeElement;
  // 转义用户输入的特殊正则字符,避免报错
  const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(escapedQuery, 'gi');

  // 递归遍历所有文本节点
  const traverseTextNodes = (node: Node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent || '';
      if (!regex.test(text)) return;

      const fragment = document.createDocumentFragment();
      let lastIndex = 0;
      let match: RegExpExecArray | null;
      regex.lastIndex = 0;

      while ((match = regex.exec(text)) !== null) {
        // 插入匹配内容前的普通文本
        if (match.index > lastIndex) {
          fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
        }
        // 生成高亮标签
        const mark = document.createElement('mark');
        mark.textContent = match[0];
        fragment.appendChild(mark);
        this.matchElements.push(mark);
        lastIndex = regex.lastIndex;
      }
      // 插入剩余普通文本
      if (lastIndex < text.length) {
        fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
      }
      // 替换原文本节点
      node.parentNode?.replaceChild(fragment, node);
      this.matchCount = this.matchElements.length;
    } else if (node.nodeType === Node.ELEMENT_NODE && node.nodeName !== 'MARK' && !['SCRIPT', 'STYLE'].includes(node.nodeName)) {
      // 跳过已生成的高亮标签、脚本和样式标签,避免重复嵌套
      Array.from(node.childNodes).forEach(traverseTextNodes);
    }
  };

  traverseTextNodes(container);
}

3. 匹配项跳转逻辑

private jumpToMatch(index: number) {
  // 移除上一个选中项的高亮样式
  this.matchElements.forEach(el => el.classList.remove('current-match'));
  this.currentMatchIndex = index;
  const currentEl = this.matchElements[index];
  // 给当前选中项加专属高亮样式
  currentEl.classList.add('current-match');
  // 平滑滚动到匹配项位置,居中显示
  currentEl.scrollIntoView({
    behavior: 'smooth',
    block: 'center'
  });
}

// 清空所有搜索状态,恢复原始DOM结构
private clearSearchState() {
  this.matchElements.forEach(mark => {
    const parent = mark.parentNode;
    parent?.replaceChild(document.createTextNode(mark.textContent || ''), mark);
    // 合并相邻文本节点,避免多次搜索后DOM碎片化
    parent?.normalize();
  });
  this.matchElements = [];
  this.matchCount = 0;
  this.currentMatchIndex = -1;
}

4. 样式补充

/* 普通匹配项高亮样式 */
mark {
  background-color: #ffeb3b;
  padding: 0 2px;
  border-radius: 2px;
}
/* 当前选中匹配项高亮样式 */
mark.current-match {
  background-color: #ff9800;
  box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.3);
}

5. 模板代码

<!-- 搜索工具栏 -->
<div class="search-toolbar">
  <input 
    type="text" 
    [(ngModel)]="searchQuery" 
    (input)="onSearchInput()" 
    placeholder="输入至少3个字符搜索"
  >
  <button (click)="goToPrev()" [disabled]="matchCount === 0">上一个</button>
  <button (click)="goToNext()" [disabled]="matchCount === 0">下一个</button>
  <span *ngIf="matchCount > 0">{{currentMatchIndex + 1}} / {{matchCount}}</span>
</div>

<!-- 搜索目标容器 -->
<div #searchContainer class="content-container">
  <!-- 你的页面内容放在此处 -->
</div>

注意事项

  • 如果内容是异步加载的,需要等内容渲染完成后再触发搜索,避免扫描不到新增文本节点
  • 如果需要支持大小写敏感匹配,可以去掉正则声明里的i标志
  • 如果需要加入防抖避免输入过程频繁触发搜索,可以在onSearchInput里加入rxjs的防抖操作符

内容的提问来源于stack exchange,提问作者Aldo Abazi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.03 18:06:03