前端实现类浏览器的 Ctrl+F 全局搜索功能(Vue2 + mark.js,用于Electron 、QT等没有浏览器Ctrl+F全局搜索功能的壳子中)

💻 在 Electron 中实现类浏览器的 Ctrl+F 全局搜索功能(Vue2 + mark.js)

本文介绍如何在 Electron 应用中构建一个像 Chrome 一样的 Ctrl+F 查找框,支持全局高亮、滚动定位、关键词计数与上下跳转。


✨ 背景

在网页浏览器中,Ctrl+F 是用户非常熟悉的操作,用于快速搜索页面内容。而 Electron 作为构建桌面应用的强大工具,默认没有提供类似功能,除了使用electron的findInPage api,直接在渲染进程即页面代码中实现也是一种方案,mark.js开源库可以简单快速实现功能:

  • 关键词高亮
  • 跳转到目标项
  • 支持多次匹配
  • 上下导航、实时计数

📦 安装 mark.js

bash 复制代码
npm install mark.js

mark.js 是一个轻量级的 JavaScript 高亮库,用于在网页上高亮关键词,常用于搜索结果展示。

✅ 主要功能

在指定容器内高亮关键词

支持排除特定元素(通过 exclude 选项)

⚙️基本原理简化说明

遍历 DOM 节点:递归遍历所有子节点(跳过排除节点)。

查找匹配:在文本节点中使用正则或字符串查找关键词。

插入<mark>标签:将关键词部分包裹在<mark>元素中插入 DOM


🧩 功能组件结构

我们封装了一个 SearchBox 组件,挂载到 App.vue 中,监听全局 keydown 事件,只要按下 Ctrl+F,搜索框就会弹出。

💡 核心特性:

功能点 实现说明
Ctrl+F 打开搜索框 keydown 全局监听
ESC 或点击 X 关闭搜索框 清除高亮并隐藏组件
实时搜索关键词 使用 mark.js 动态高亮
上下跳转匹配项 通过数组索引控制焦点
当前项橙色,其它项黄色 样式区分当前匹配项
平滑过渡动画 transition + CSS 动画

🧩 组件代码(SearchBox.vue)

🔎 mark.js 搜索

js 复制代码
const instance = new Mark(document.body);
instance.mark(this.keyword, {
  separateWordSearch: false,
  exclude: ['.count_num'], // 可排除dom
  done: () => {
    const elements = document.querySelectorAll('mark');
    this.markedElements = Array.from(elements);
    this.total = this.markedElements.length;
    this.current = 0;
  }
});

🔁 上下跳转高亮逻辑

js 复制代码
highlightCurrent() {
  this.markedElements.forEach((el, i) => {
    el.style.backgroundColor = i + 1 === this.current ? 'orange' : 'yellow';
    el.style.color = i + 1 === this.current ? '#000' : '';
  });
  this.markedElements[this.current - 1]?.scrollIntoView({
    block: 'center',
  });
}

🎬 动画过渡

css 复制代码
.slide-fade-enter-active,
.slide-fade-leave-active {
  transition: all 0.3s ease;
}
.slide-fade-enter,
.slide-fade-leave-to {
  transform: translateY(-20px);
  opacity: 0;
}

📥 App.vue 中挂载组件

App.vue 引入:

html 复制代码
<template>
  <div id="app">
    <SearchBox />
    <router-view />
  </div>
</template>

<script>
import SearchBox from './components/SearchBox.vue';

export default {
  components: { SearchBox },
};
</script>

✅ 效果预览

  1. Ctrl+F 打开搜索框
  2. 输入关键词即时高亮
  3. 显示总匹配数 + 当前索引
  4. 点击上下箭头跳转目标
  5. X 关闭框并清除所有高亮

整体交互体验几乎还原浏览器的搜索能力


🔚 结语与完整代码

通过 mark.js 和 Vue 的组合,我们就能在 Electron 中还原 Ctrl+F 搜索体验。

可直接复制下方代码在项目中使用。

html 复制代码
<template>
  <transition name="slide-fade">
    <div
      v-if="visible"
      class="search-box">
      <input
        v-model="keyword"
        @input="onInput"
        placeholder="搜索..." />
      <span class="count">
        <span class="count_num">{{ current }}/{{ total }}</span>
      </span>
      <i
        class="vxe-icon-arrow-up"
        :class="{ disabled: total === 0 }"
        @click="prev"></i>
      <i
        class="vxe-icon-arrow-down"
        :class="{ disabled: total === 0 }"
        @click="next"></i>
      <i
        class="vxe-icon-close"
        @click="close"></i>
    </div>
  </transition>
</template>

<script>
import Mark from 'mark.js';

export default {
  data() {
    return {
      keyword: '',
      current: 0,
      total: 0,
      visible: false,
      markedElements: [],
    };
  },
  mounted() {
    document.addEventListener('keydown', this.onKeydown);
  },
  beforeDestroy() {
    document.removeEventListener('keydown', this.onKeydown);
  },
  methods: {
    onKeydown(e) {
      console.log(111, e);

      if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) {
        e.preventDefault();
        this.visible = true;
        this.$nextTick(() => this.$el.querySelector('input').focus());
      }
    },
    close() {
      this.visible = false;
      this.clearMarks();
      this.keyword = '';
      this.current = 0;
      this.total = 0;
    },
    onInput() {
      this.clearMarks();
      if (!this.keyword.trim()) {
        this.current = 0;
        this.total = 0;
        return;
      }
      const instance = new Mark(document.body);
      instance.mark(this.keyword, {
        separateWordSearch: false,
        exclude: ['.czp-link', '.count_num'],
        done: () => {
          const elements = document.querySelectorAll('mark');
          this.markedElements = Array.from(elements);
          this.total = this.markedElements.length;
          this.current = 0;
        },
      });
    },
    clearMarks() {
      const instance = new Mark(document.body);
      instance.unmark();
      this.markedElements = [];
    },
    highlightCurrent() {
      this.markedElements.forEach((el, i) => {
        el.style.backgroundColor = i + 1 === this.current ? 'orange' : 'yellow';
        el.style.color = i + 1 === this.current ? '#000' : '';
      });
      if (this.markedElements[this.current - 1]) {
        this.markedElements[this.current - 1].scrollIntoView({
          //   behavior: 'smooth',
          block: 'center',
        });
      }
    },
    next() {
      if (this.total === 0) return;
      this.current = this.current < this.total ? this.current + 1 : 1;
      this.highlightCurrent();
    },
    prev() {
      if (this.total === 0) return;
      this.current = this.current > 1 ? this.current - 1 : this.total;
      this.highlightCurrent();
    },
  },
};
</script>

<style lang="scss" scoped>
.search-box {
  position: fixed;
  top: 60px;
  right: 15px;
  background: #fff;
  border: 1px solid #ccc;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
  border-radius: 4px;
  padding: 6px 10px;
  display: flex;
  align-items: center;
  z-index: 9999;
  font-size: 14px;
  input {
    flex: 1;
    padding: 4px 8px;
    border: 1px solid #ddd;
    border-radius: 3px;
    outline: none;
  }
  .count {
    min-width: 40px;
    text-align: center;
    color: #555;
    margin-left: 10px;
    margin-right: 10px;
  }
  .vxe-icon-arrow-up {
    font-size: 14px;
    color: #535353;
    cursor: pointer;
  }
  .vxe-icon-arrow-down {
    font-size: 14px;
    color: #535353;
    margin-left: 15px;
    cursor: pointer;
  }
  .vxe-icon-close {
    font-size: 10px;
    font-weight: bold;
    color: #535353;
    margin-left: 15px;
    cursor: pointer;
  }
  .disabled {
    color: #a9a9aa;
    cursor: not-allowed;
  }
}

.slide-fade-enter-active {
  transition: all 0.3s ease;
}
.slide-fade-leave-active {
  transition: all 0.3s ease;
}
.slide-fade-enter {
  transform: translateY(-20px);
  opacity: 0;
}
.slide-fade-leave-to {
  transform: translateY(-20px);
  opacity: 0;
}
</style>