💻 在 Electron 中实现类浏览器的 Ctrl+F 全局搜索功能(Vue2 + mark.js)
本文介绍如何在 Electron 应用中构建一个像 Chrome 一样的 Ctrl+F 查找框,支持全局高亮、滚动定位、关键词计数与上下跳转。
✨ 背景
在网页浏览器中,Ctrl+F
是用户非常熟悉的操作,用于快速搜索页面内容。而 Electron 作为构建桌面应用的强大工具,默认没有提供类似功能,除了使用electron的findInPage api,直接在渲染进程即页面代码中实现也是一种方案,mark.js开源库可以简单快速实现功能:
- 关键词高亮
- 跳转到目标项
- 支持多次匹配
- 上下导航、实时计数
📦 安装 mark.js
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 搜索
const instance = new Mark(document.body);
instance.mark(this.keyword, {separateWordSearch: false,exclude: ['.count_num'], // 可排除domdone: () => {const elements = document.querySelectorAll('mark');this.markedElements = Array.from(elements);this.total = this.markedElements.length;this.current = 0;}
});
🔁 上下跳转高亮逻辑
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',});
}
🎬 动画过渡
.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
引入:
<template><div id="app"><SearchBox /><router-view /></div>
</template><script>
import SearchBox from './components/SearchBox.vue';export default {components: { SearchBox },
};
</script>
✅ 效果预览
Ctrl+F
打开搜索框- 输入关键词即时高亮
- 显示总匹配数 + 当前索引
- 点击上下箭头跳转目标
X
关闭框并清除所有高亮
整体交互体验几乎还原浏览器的搜索能力
🔚 结语与完整代码
通过 mark.js 和 Vue 的组合,我们就能在 Electron 中还原 Ctrl+F 搜索体验。
可直接复制下方代码在项目中使用。
<template><transition name="slide-fade"><divv-if="visible"class="search-box"><inputv-model="keyword"@input="onInput"placeholder="搜索..." /><span class="count"><span class="count_num">{{ current }}/{{ total }}</span></span><iclass="vxe-icon-arrow-up":class="{ disabled: total === 0 }"@click="prev"></i><iclass="vxe-icon-arrow-down":class="{ disabled: total === 0 }"@click="next"></i><iclass="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>