前端(Vue)headerSearch(页面搜索)通用解决方案 及 原理

简介

击后弹出输入框
image.png
image.png
输入框可以输入页面的索引,比如项目中包含了文章相关的
点击后可以进入对应界面
image.png
同时也支持英文索引
image.png

原理

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

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

综上,根据指定内容检索所有页面,把检索出的页面以 **select** 展示,点击对应 **option** 可进入 => 即可实现。

方案

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

创建 headerSearch 组件

remote-method 自定义远程搜索方法
change 选中值发生变化时触发

<template>
<!-- headerSearch其实包含两种状态(展示搜索框和隐藏,两个状态),需要绑定动态的class,动态添加isShow -->
  <div :class="{ show: isShow }" class="header-search">
<!-- innerHtml里导入icon,然后下面是select -->
    <svg-icon
      class-name="search-icon"
      icon="search"
      @click.stop="onShowClick"
      />
      <el-select
        ref="headerSearchSelectRef"
        class="header-search-select"
        v-model="search"
        filterable
        default-first-option
        remote
        placeholder="Search"
        :remote-method="querySearch"    <!--  搜索触发-->
        @change="onSelectChange"
        >
<!-- 指定select的options -->
        <el-option
          v-for="option in 5"
          :key="option"
          :label="option"
          :value="option"
          ></el-option>
      </el-select>
    </div>
</template>

<script setup>
  import { ref } from 'vue'

  // 控制 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 querySearch = () => {
    console.log('querySearch')
  }
  // 选中回调
  const onSelectChange = () => {
    console.log('onSelectChange')
  }
</script>

<style lang="scss" scoped>
  .header-search {
    font-size: 0 !important;
    .search-icon {
      cursor: pointer;
      font-size: 18px;
      vertical-align: middle;
    }
    .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>

检索数据源,路由表数据处理

检索数据源 表示:有哪些页面希望检索(被搜索)
检索数据源即为:菜单对应的数据源(可以进入的页面)

<script setup>
import { ref, computed } from 'vue'
import { filterRouters, generateMenus } from '@/utils/route'
import { useRouter } from 'vue-router'
...
// 检索数据源
const router = useRouter()
const searchPool = computed(() => {
  // 从项目的路由工具文件(@/utils/route)中导入的两个实用函数。根据名字推测,filterRouters 用于筛选路由,generateMenus 用于生成菜单。
  const filterRoutes = filterRouters(router.getRoutes())  // 筛选可以跳转的路由
  console.log(generateMenus(filterRoutes))
  return generateMenus(filterRoutes)
})
console.log(searchPool)
</script>

补充

/**
 * 返回所有子路由
 */
const getChildrenRoutes = (routes) => {
  const result = []
  routes.forEach((route) => {
    // 检查当前路由是否有 children 属性并且非空
    if (route.children && route.children.length > 0) {
      result.push(...route.children)
    }
  })
  return result
}

/**
 * 处理脱离层级的路由:filterRouters 函数接收一个 routes 参数,该参数通常是通过 router.getRoutes() 方法获取的路由数组。它的作用是过滤掉那些既是一级路由又是其他路由子级的路由,从而保留清晰的路由层级结构。
 * @param {*} routes router.getRoutes()
 */
export const filterRouters = (routes) => {
  // 返回所有子路由
  const childrenRoutes = getChildrenRoutes(routes)
  return routes.filter((route) => {
    return !childrenRoutes.find((childrenRoute) => {
      return childrenRoute.path === route.path
    })
  })
}

/**
 * 该方法的作用:根据 routes 数据,返回对应 menu 规则数组。
 * 方法本质为构建了一个:递归
 * @param {*} routes 需要解析的路由数组,是整个应用的路由配置。
 * @param {*} basePath 可选参数,用于处理路径拼接,默认值为空字符串。这个参数在递归调用时用于构建完整的路由路径。
 * @returns 返回一个数组,该数组会在 SidebarMenu 中被 v-for 循环用于 sidebar-item 的渲染
*/
export function generateMenus(routes, basePath = '') {
  // 最终需要返回的值
  const result = []
  // 遍历路由表
  routes.forEach((item) => {
    // 不存在 children && 不存在 meta 则被认为是 《忽略不需要处理的路由》, 直接 return
    if (isNull(item.meta) && isNull(item.children)) return
    // 如果一个路由没有 meta 信息但有子路由,则认为它是一个父节点路由,需要递归处理其子路由。这里使用递归调用 generateMenus 来处理子路由,并将结果追加到 result 数组中
    if (isNull(item.meta) && !isNull(item.children)) {
      result.push(...generateMenus(item.children))
      return
    }
    // 合并 path 作为跳转路径
    const routePath = path.resolve(basePath, item.path)
    // 路由分离之后,存在同名父路由(指的是:name 相同的路由对象)的情况,需要单独处理,避免重复
    let route = result.find((item) => item.path === routePath)

    // 查找匹配的 route 对象,如果 route 对象不存在,则表示当前的 route 还没有放入到 result 数组中,所以我们需要构建一个新的 route 对象,并且把它放入到 result 里面
    if (!route) {
      route = {
        ...item,
        path: routePath,
        children: []
      }

      // icon 与 title 必须全部存在,只有这样,我们才认为它是一个需要在 《menu item》 中展示的数据
      if (route.meta.icon && route.meta.title) {
        // meta 存在生成 route 对象,放入 arr
        result.push(route)
      }
    }

    // 如果当前路由有子路由,则再次调用 generateMenus 递归处理子路由,并将生成的子菜单项添加到当前路由的 children 属性中。
    if (item.children) {
      route.children.push(...generateMenus(item.children, route.path))
    }
  })
  return result
}

对检索数据源进行模糊搜索

如果我们想要进行  模糊搜索  的话,那么需要依赖一个第三方的库  fuse.js => 专门做模糊搜索的
image.png

  1. 安装 fuse.js
npm install --save fuse.js@6.4.6
  1. 初始化 Fuse,更多初始化配置项 可点击这里
import Fuse from 'fuse.js'

/**
 * 搜索库相关
 * Fuse接收两个参数,首先是搜索的数据源,然后就说fuse的配置对象了。
 * 配置对象的配置项如下
 */
const fuse = new Fuse(list, {
  // 是否按优先级进行排序,,最匹配的结果会排在前面。
  shouldSort: true,
  // 匹配长度超过这个值的才会被认为是匹配的
  // 如果用户搜索 "s",并且这个值为 1,那么长度为 1 的查询也是有效的。如果设置为 2,那么 "s" 不会触发匹配,只有 "se" 及以上长度的查询才会被考虑。
  minMatchCharLength: 1,
  // 将被搜索的键列表。 这支持嵌套路径、加权搜索、在字符串和对象数组中搜索。
  // name:搜索的键
  // weight:对应的权重
  // 这里通过数据源的title和path进行搜索
  keys: [
    {
      name: 'title',
      weight: 0.7
    },
    {
      name: 'path',
      weight: 0.3
    }
  ]
})
  1. 参考 Fuse Demo 与 最终效果,可以得出,最终期望得到如下的检索数据源结构 :

官网数据结构:

[
  {
    "title": "Old Man's War",
    "author": "John Scalzi",
    "tags": ["fiction"]
  },
  {
    "title": "The Lock Artist",
    "author": "Steve",
    "tags": ["thriller"]
  }
]

设计的数据结构:

[
    {
        "path":"/my",
        "title":[
            "个人中心"
        ]
    },
    {
        "path":"/user",
        "title":[
            "用户"
        ]
    },
    {
        "path":"/user/manage",
        "title":[
            "用户",
            "用户管理"
        ]
    },
    {
        "path":"/user/info",
        "title":[
            "用户",
            "用户信息"
        ]
    },
    {
        "path":"/article",
        "title":[
            "文章"
        ]
    },
    {
        "path":"/article/ranking",
        "title":[
            "文章",
            "文章排名"
        ]
    },
    {
        "path":"/article/create",
        "title":[
            "文章",
            "创建文章"
        ]
    }
]
  1. 之前处理了的数据源并不符合我们的需要,所以我们需要对数据源进行重新处理

image.png

数据源重处理,生成  searchPool

generateRoutes => path和title都分别进行处理。
title 设置为数组,因为路由如果是多层级,需要展示为用户>员工管理,因此title的话以父子层级的形式去展示。
generateRoutes 函数接收三个参数:

  1. **routes**: 路由表,是通过 filterRoutes 生成的,包含了路由的所有信息。
  2. **basePath**: 基础路径,默认值为 /,用于构建完整路径。
  3. **prefixTitle**: 前缀标题,默认是一个空数组,用于构建嵌套路由的完整标题。
import path from 'path'
import i18n from '@/i18n'
/**
 * 筛选出可供搜索的路由对象
 * @param routes 路由表 (通过filterRoutes生成的)
 * @param basePath 基础路径,默认为 /
 * @param prefixTitle
 */
export const generateRoutes = (routes, basePath = '/', prefixTitle = []) => {
  // 创建 result 数据
  let res = []
  // 循环 routes 路由
  // route路由包含很多内容,我们只需要path和title
  for (const route of routes) {
    // 创建包含 path 和 title 的 item
    const data = {
      path: path.resolve(basePath, route.path), // 合并 => 使用 path.resolve 将基础路径 basePath 和当前路由的 path 合并,生成完整的路径
      title: [...prefixTitle] // 初始化 title 数组,将 prefixTitle(前缀标题)复制到 title 中。这个数组将用于存储完整的路由标题。
    }

    // ( meta国家化)当前存在 meta 时,需要使用 i18n 解析国际化数据,最后组合成新的 title 内容
    // 动态路由不允许被搜索
    // 匹配动态路由的正则  =>  !re.exec(route.path) 不是动态路由
    const re = /.*\/:.*/ // 是否包含冒号,无论前后是啥,就认为是动态路由
    if (route.meta && route.meta.title && !re.exec(route.path)) {
      const i18ntitle = i18n.global.t(`msg.route.${route.meta.title}`)
      data.title = [...data.title, i18ntitle]
      res.push(data)
    }

    // 存在 children 时,迭代调用
    if (route.children) {
      // 传递children,父路由path,父title。得到所有子路由筛选的出的route
      const tempRoutes = generateRoutes(route.children, data.path, data.title)
      if (tempRoutes.length >= 1) {
        res = [...res, ...tempRoutes]
      }
    }
  }
  return res
}

使用

<script setup>
import { computed, ref } from 'vue'
import { generateRoutes } from './FuseData'
import Fuse from 'fuse.js'
import { filterRouters } from '@/utils/route'
import { useRouter } from 'vue-router'

...

// 检索数据源
const router = useRouter()
const searchPool = computed(() => {
  const filterRoutes = filterRouters(router.getRoutes())
  return generateRoutes(filterRoutes)
})
/**
 * 搜索库相关
 */
const fuse = new Fuse(searchPool.value, {
  ...
})
</script>

通过 querySearch 测试搜索结果

// 搜索方法
const querySearch = query => {
  console.log(fuse.search(query))
}

输入文章
image.png

渲染检索数据

据源处理完成之后,最后就只需要完成:

  1. 渲染检索出的数据
  2. 完成对应跳转

渲染检索出的数据

<template>
  <el-option
      v-for="option in searchOptions"
      :key="option.item.path"
      :label="option.item.title.join(' > ')"  // 通过箭头的方式拼接
      :value="option.item"
  ></el-option>
</template>

<script setup>
...
// 搜索结果
const searchOptions = ref([])
// 搜索方法
const querySearch = query => {
  if (query !== '') {
    searchOptions.value = fuse.search(query)
  } else {
    searchOptions.value = []
  }
}
...
</script>

完成对应跳转

// 选中回调
const onSelectChange = val => {
  router.push(val.path)
}

其余细节问题处理

search 打开时,点击 body 关闭 search;在 search 关闭时,清理 searchOptions

问题:search关闭后,再打开,之前搜索的内容还存在,点击的选项也存在。
监听 search 打开,处理 close 事件,关闭时删除该事件
关闭事件 => 去除焦点,隐藏输入框,删除搜索内容。

/**
 * 关闭 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)
  }
})

headerSearch 应该具备国际化能力

接下来是国际化的问题,想要处理这个问题非常简单,我们只需要:监听语言变化,重新计算数据源初始化 **fuse** 即可

  1. utils/i18n 下,新建方法 watchSwitchLang ,监听语言的变化,语言变化后执行传递过来的回调函数。
  • **watch**: 这是 Vue.js 3 中的一个函数,用于监听某个响应式数据的变化,并在数据变化时执行指定的回调。它有两个主要参数:
    • 第一个参数: 一个函数,用于返回需要监听的响应式数据。在这里,监听的是 store.getters.language,即当前应用的语言。
    • 第二个参数: 另一个函数,它会在第一个参数返回的值变化时被调用。在这里,当语言发生变化时,第二个参数的函数体将会执行。
  • **store.getters.language**: 这是从 Vuex 中获取当前语言的 getter。它是一个响应式的数据,所以当语言发生变化时,watch 会监听到这一变化。
import { watch } from 'vue'
import store from '@/store'

/**
 *
 * @param  {...any} cbs 所有的回调
 * ...cbs 可以传递任意多的回调函数
 */
export function watchSwitchLang(...cbs) {
  watch(
    () => store.getters.language,
    () => {
      cbs.forEach(cb => cb(store.getters.language))
    }
  )
}

headerSearch 监听变化,重新赋值

<script setup>
...
import { watchSwitchLang } from '@/utils/i18n'

...

// 检索数据源(初始化搜索数据源)
const router = useRouter()
let searchPool = computed(() => {
  const filterRoutes = filterRouters(router.getRoutes())
  return generateRoutes(filterRoutes)
})
/**
 * 搜索库相关
 */
let fuse
const initFuse = searchPool => {
  fuse = new Fuse(searchPool, {
    ...
}
initFuse(searchPool.value)

...

// 处理国际化
watchSwitchLang(() => {
  searchPool = computed(() => {
    const filterRoutes = filterRouters(router.getRoutes())
    return generateRoutes(filterRoutes)
  })
  initFuse(searchPool.value)
})
</script>

当语言发生变化时

  • **重新计算 ****searchPool**: 重新计算 searchPool,这个过程包括:
    1. 获取最新的路由配置(可能会因为语言变化而改变)。
    2. 重新生成带有新语言的路由标题的搜索数据源。
  • **重新初始化 ****Fuse.js**: 调用 initFuse(searchPool.value) 重新初始化 Fuse 实例,使得搜索功能使用最新的 searchPool 数据源。

整体流程

  • 初始加载时:
    • 创建路由实例并生成初始的 searchPool
    • 使用 searchPool 初始化 Fuse 搜索库。
  • 当用户切换语言时:
    • watchSwitchLang 检测到语言变化,触发回调函数。
    • 回调函数重新生成 searchPool,使其包含新的语言标题。
    • 再次初始化 Fuse 实例,使得搜索数据源与当前语言同步。

headerSearch 方案总结

整个 headerSearch 把握住三个核心的关键点

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

关于细节的处理,可能比较复杂的地方有两个:

  1. 模糊搜索
  2. 【创建】检索数据源

对于这两块,依赖于 fuse.js 进行了实现,大大简化了我们的业务处理流程。

补充:  fuse.js

简介

Fuse.js 是一个强大的轻量级 JavaScript 库,用于在小到中等大小的数据集合中进行模糊搜索。它的设计目标是提供一种灵活、易用的方式,帮助开发者在没有数据库的情况下实现高效的搜索功能。
模糊搜索的特点是能够在用户输入的查询与实际数据不完全匹配时仍能找到相关结果。例如,用户输入 “wrd” 可能会匹配到 “word”。

快速上手

安装 Fuse.js

通过 npm 或 yarn 来安装 Fuse.js:

npm install fuse.js

或者

yarn add fuse.js

基本用法

如 数据集:

const data = [
  { title: "Old Man's War", author: "John Scalzi" },
  { title: "The Lock Artist", author: "Steve Hamilton" },
  { title: "The Hero of Ages", author: "Brandon Sanderson" },
  { title: "The Colour of Magic", author: "Terry Pratchett" },
  { title: "The Light Fantastic", author: "Terry Pratchett" }
];

可以使用 Fuse.js 进行搜索:

import Fuse from 'fuse.js';

// 配置搜索选项
const options = {
  includeScore: true,    // 是否包含搜索得分
  keys: ['title', 'author'] // 需要搜索的字段
};

// 创建 Fuse 实例
const fuse = new Fuse(data, options);

// 搜索关键字
const result = fuse.search('The Hero');

// 打印搜索结果
console.log(result);

配置选项

Fuse.js 提供了许多配置选项来定制搜索行为:

  • keys: 需要搜索的字段,如 ['title', 'author']
  • includeScore: 如果设置为 true,则搜索结果将包含每个匹配项的得分。
  • threshold: 设置匹配阈值,值在 01 之间。0 表示完全匹配,1 表示匹配所有项目。
  • minMatchCharLength: 设置最小匹配字符长度,只有当搜索词的长度达到指定值时,才会进行搜索。
const options = {
  includeScore: true,
  threshold: 0.3,          // 匹配度阈值,数值越低匹配要求越高
  minMatchCharLength: 2,   // 最小匹配字符长度
  keys: ['title', 'author']
};

高级用法:权重和搜索模式

可以为不同的搜索字段分配不同的权重,以提高搜索的精度。例如 :

const options = {
  includeScore: true,
  keys: [
    { name: 'title', weight: 0.7 },
    { name: 'author', weight: 0.3 }
  ]
};

const fuse = new Fuse(data, options);
const result = fuse.search('Terry');

在这个示例中,title 字段的权重是 0.7author 的权重是 0.3,表示我们希望在搜索中更重视标题匹配。

使用 Fuse.js 搜索

当你调用 fuse.search(query) 时,Fuse.js 会返回一个结果数组。每个结果对象包含以下信息:

  • item: 匹配的对象。
  • score: (可选)匹配得分,数值越低表示匹配度越高。
  • refIndex: (可选)在原始数据中的索引。
const result = fuse.search('magic');
result.forEach(({ item, score }) => {
  console.log(`Found: ${item.title}, Score: ${score}`);
});

总结

Fuse.js 是一个功能强大的模糊搜索工具,适用于需要在前端进行快速模糊搜索的小型数据集。通过配置选项,你可以灵活地定制搜索行为,使其适应不同的应用场景。结合简单的 API 和丰富的功能,Fuse.js 是一个极为实用的搜索库。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值