💻 在 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>
✅ 效果预览
Ctrl+F
打开搜索框- 输入关键词即时高亮
- 显示总匹配数 + 当前索引
- 点击上下箭头跳转目标
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>