💻 在 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>