页面搜索 原理及其方案
原理
headerSearch
是复杂后台系统中非常常见的一个功能,它可以:在指定搜索框中对当前应用中所有页面进行检索,以 select
的形式展示出被检索的页面,以达到快速进入的目的
那么明确好了 headerSearch
的作用之后,接下来我们来看一下对应的实现原理
根据前面的目的我们可以发现,整个 headerSearch
其实可以分为三个核心的功能点:
- 根据指定内容对所有页面进行检索
- 以
select
形式展示检索出的页面 - 通过检索页面可快速进入对应页面
那么围绕着这三个核心的功能点,我们想要分析它的原理就非常简单了:
根据指定内容检索所有页面,把检索出的页面以 select
展示,点击对应 option
可进入
对照着三个核心功能点和原理,想要指定对应的实现方案是非常简单的一件事情了
- 创建
headerSearch
组件,用作样式展示和用户输入内容获取 - 获取所有的页面数据,用作被检索的数据源
- 根据用户输入内容在数据源中进行 模糊搜索
- 把搜索到的内容以
select
进行展示 - 监听
select
的change
事件,完成对应跳转
检索数据源
在有了 headerSearch
之后,接下来就可以来处理对应的 检索数据源
了
检索数据源
表示:有哪些页面希望检索
那么对于我们当前的业务而言,我们希望被检索的页面其实就是左侧菜单中的页面,那么我们检索数据源即为:左侧菜单对应的数据源
安装模糊搜索
如果我们想要进行 模糊搜索 的话,那么需要依赖一个第三方的库 fuse.js
bash
npm install --save fuse.js@6.4.6
设置中英文切换
- src/i18n/langs/cn.js
js
const cn = {
message: {
hello: "你好",
themeChange: "主题更换",
textChange: "文字更换",
activetextChange: "选中更换",
route: {
profile: "个人中心",
login: "登录",
register: "注册",
home: "首页",
user: "用户",
userManage: "用户管理",
roleList: "角色列表",
permissionList: "权限列表",
excelImport: "Excel导入",
article: "文章管理",
articleRanking: "文章等级",
articleCreate: "文章创建",
},
},
};
export default cn;
- src/i18n/langs/en.js
js
const en = {
message: {
hello: "hello",
themeChange: "themeChange",
textChange: "textChange",
activetextChange: "activetextChange",
route: {
profile: "profile",
login: "login",
register: "register",
home: "home",
user: "user",
userManage: "userManage",
roleList: "roleList",
permissionList: "permissionList",
excelImport: "excelImport",
article: "article",
articleRanking: "articleRanking",
articleCreate: "articleCreate",
},
},
};
export default en;
创建搜索数据
- utils/fusedata.js
js
import path from "path-browserify";
import i18n from "@/i18n";
/**
* 筛选出可供搜索的路由对象
* @param routes 路由表
* @param basePath 基础路径,默认为 /
* @param prefixTitle
*/
export const generateRoutes = (routes, basePath = "/", prefixTitle = []) => {
// 创建 result 数据
let res = [];
// 循环 routes 路由
for (const route of routes) {
// 创建包含 path 和 title 的 item
const data = {
path: path.resolve(basePath, route.path),
title: [...prefixTitle],
};
// 当前存在 meta 时,使用 i18n 解析国际化数据,组合成新的 title 内容
// 动态路由不允许被搜索
// 匹配动态路由的正则
const re = /.*\/:.*/;
if (
route.meta &&
route.meta.title &&
!re.exec(route.path) &&
!res.find((item) => item.path === data.path)
) {
const i18ntitle = i18n.global.t(`message.route.${route.meta.title}`);
data.title = [...data.title, i18ntitle];
res.push(data);
}
// 存在 children 时,迭代调用
if (route.children) {
const tempRoutes = generateRoutes(route.children, data.path, data.title);
if (tempRoutes.length >= 1) {
res = [...res, ...tempRoutes];
}
}
}
return res;
};
- utils/i18n.js
js
import { watch } from "vue";
import i18nAll from "@/i18n";
import { useI18n } from "vue-i18n";
export function generateTitle(title) {
return i18nAll.global.t("msg.route." + title);
}
/**
*
* @param {...any} cbs 所有的回调
*/
export function watchSwitchLang(...cbs) {
// 切换语言的方法
const i18n = useI18n();
watch(
() => i18n.locale.value,
() => {
cbs.forEach((cb) => cb(i18n.locale.value));
}
);
}
修改 HeaderSearch.vue
vue
<template>
<div :class="{ show: isShow }" class="header-search">
<div class="search-icon">
<SvgIcon
id="guide-search"
icon="search"
size="36"
color="black"
@click.stop="onShowClick"
/>
</div>
<el-select
ref="headerSearchSelectRef"
class="header-search-select"
v-model="search"
filterable
default-first-option
remote
placeholder="Search"
:remote-method="querySearch"
@change="onSelectChange"
>
<el-option
v-for="option in searchOptions"
:key="option.item.path"
:label="option.item.title.join(' > ')"
:value="option.item"
style="padding-left: 20px"
></el-option>
</el-select>
</div>
</template>
<script setup>
import { generateRoutes } from "@/utils/fusedata";
import Fuse from "fuse.js";
import { filterRouters } from "@/utils/route";
import { useRouter } from "vue-router";
import { watchSwitchLang } from "@/utils/i18n";
// 控制 search 显示
const isShow = ref(false);
// el-select 实例
const headerSearchSelectRef = ref(null);
const onShowClick = () => {
isShow.value = !isShow.value;
headerSearchSelectRef.value.focus();
};
// search 相关
const search = ref("");
// 搜索结果
const searchOptions = ref([]);
// 搜索方法
const querySearch = (query) => {
if (query !== "") {
searchOptions.value = fuse.search(query);
} else {
searchOptions.value = [];
}
};
// 选中回调
const onSelectChange = (val) => {
router.push(val.path);
onClose();
};
// 检索数据源
const router = useRouter();
let searchPool = computed(() => {
const filterRoutes = filterRouters(router.getRoutes());
console.log(generateRoutes(filterRoutes));
return generateRoutes(filterRoutes);
});
/**
* 搜索库相关
*/
let fuse;
const initFuse = (searchPool) => {
fuse = new Fuse(searchPool, {
// 是否按优先级进行排序
shouldSort: true,
// 匹配算法放弃的时机, 阈值 0.0 需要完美匹配(字母和位置),阈值 1.0 将匹配任何内容。
threshold: 0.4,
// 匹配长度超过这个值的才会被认为是匹配的
minMatchCharLength: 1,
// 将被搜索的键列表。 这支持嵌套路径、加权搜索、在字符串和对象数组中搜索。
// name:搜索的键
// weight:对应的权重
keys: [
{
name: "title",
weight: 0.7,
},
{
name: "path",
weight: 0.3,
},
],
});
};
initFuse(searchPool.value);
/**
* 关闭 search 的处理事件
*/
const onClose = () => {
headerSearchSelectRef.value.blur();
isShow.value = false;
searchOptions.value = [];
};
/**
* 监听 search 打开,处理 close 事件
*/
watch(isShow, (val) => {
if (val) {
document.body.addEventListener("click", onClose);
} else {
document.body.removeEventListener("click", onClose);
}
});
// 处理国际化
watchSwitchLang(() => {
searchPool = computed(() => {
const filterRoutes = filterRouters(router.getRoutes());
return generateRoutes(filterRoutes);
});
initFuse(searchPool.value);
});
</script>
<style lang="scss" scoped>
.header-search {
font-size: 0 !important;
.search-icon {
cursor: pointer;
font-size: 18px;
vertical-align: middle;
float: left;
}
.header-search-select {
font-size: 18px;
transition: width 0.2s;
width: 0;
overflow: hidden;
background: transparent;
border-radius: 0;
display: inline-block;
vertical-align: middle;
::v-deep .el-input__inner {
border-radius: 0;
border: 0;
padding-left: 0;
padding-right: 0;
box-shadow: none !important;
border-bottom: 1px solid #d9d9d9;
vertical-align: middle;
}
}
&.show {
.header-search-select {
width: 210px;
margin-left: 10px;
}
}
}
</style>