Skip to content

页面搜索 原理及其方案

原理

headerSearch 是复杂后台系统中非常常见的一个功能,它可以:在指定搜索框中对当前应用中所有页面进行检索,以 select 的形式展示出被检索的页面,以达到快速进入的目的

那么明确好了 headerSearch 的作用之后,接下来我们来看一下对应的实现原理

根据前面的目的我们可以发现,整个 headerSearch 其实可以分为三个核心的功能点:

  1. 根据指定内容对所有页面进行检索
  2. select 形式展示检索出的页面
  3. 通过检索页面可快速进入对应页面

那么围绕着这三个核心的功能点,我们想要分析它的原理就非常简单了:

根据指定内容检索所有页面,把检索出的页面以 select 展示,点击对应 option 可进入

对照着三个核心功能点和原理,想要指定对应的实现方案是非常简单的一件事情了

  1. 创建 headerSearch 组件,用作样式展示和用户输入内容获取
  2. 获取所有的页面数据,用作被检索的数据源
  3. 根据用户输入内容在数据源中进行 模糊搜索
  4. 把搜索到的内容以 select 进行展示
  5. 监听 selectchange 事件,完成对应跳转

检索数据源

在有了 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>