Vue项目搜索功能与面包屑导航

Vue 项目实战:搜索功能架构与面包屑导航设计

本文面向已掌握 Vue2 组件、路由、Vuex 基础,正在搭建中后台或电商类项目的工程师。目标不是"照抄一个搜索页",而是讲清楚为什么把搜索状态放进 URL面包屑如何与路由联动事件总线为何要手动清理 这些底层设计动机。以电商搜索页为背景,系统讲解多条件搜索参数的合并策略、Vuex 动态传参、搜索关键词的跨组件传递、面包屑导航的数据模型设计与动态增删、搜索防抖、关键词高亮、搜索历史持久化,以及事件总线在组件联动中的应用。全文结论均对照 Vue Router 官方文档Vue2 官方文档MDN 核对,每个技术点都从"为什么这样设计、解决什么问题"出发。


目录

  1. 零、导读与学习价值
  2. 一、多条件搜索参数的设计与合并
  3. 二、搜索关键词的跨组件传递
  4. 三、面包屑导航的数据模型设计
  5. 四、面包屑标签的动态添加与移除
  6. 五、搜索组件间的联动:事件总线应用
  7. 六、过滤器:数据展示的最后一公里
  8. 七、进阶:防抖、关键词高亮与搜索历史
  9. 总结

零、导读与学习价值

0.1 功能清单

功能 技术手段 核心难点
多条件搜索 Object.assign 合并查询参数 分类与关键词互斥
分类 ID 路由传参 data-* + 事件委托 + $router.push 三级分类动态匹配
Vuex 异步搜索 actions 动态传参 + watch $route.query 立即触发与深度监听
面包屑导航 路由 query 驱动 UI 标签的添加与移除
事件总线联动 $bus.$emit / $bus.$on 搜索框清空时机
全局过滤器 Vue.filter + padStart 时间/货币格式化复用

0.2 核心名词速查

名词 解释
事件委托 将多个子元素的事件监听绑定到父元素,利用事件冒泡统一处理
事件总线($bus) 一个空 Vue 实例,充当全局的发布/订阅中心
面包屑导航 页面顶部展示当前筛选条件的标签组,支持单独移除
Vuex action 动态传参 dispatch('xxx', payload) 中的 payload 即为动态参数
Object.assign 浅拷贝合并多个对象属性到目标对象
全局过滤器 Vue.filter(name, fn) 注册后可在任意模板 `{{ val

0.3 为什么要学本篇

电商搜索页是面试中频繁被考察的场景。它涉及:组件通信 (Header 搜索框 → Search 页面)、状态管理 (Vuex 存储搜索结果)、路由设计 (查询字符串即搜索状态)、UI 联动(面包屑随筛选实时更新)。掌握这套完整链路,能让你在白板题和代码面试中游刃有余。


一、多条件搜索参数的设计与合并

名词解释

搜索参数 :发送给后端接口的条件集合。电商搜索通常包含:关键词(keyword)、一/二/三级分类 ID(category1Idcategory2Idcategory3Id)、分类名称(categoryName)。

Object.assign(target, ...sources):将 sources 中的所有可枚举自有属性浅拷贝到 target,后者属性覆盖前者同名属性,返回 target。

概念与底层原理

为什么需要"合并"而不是"替换"?

搜索页面存在两个独立的入口:

  1. Header 搜索框:输入关键词后点击搜索按钮
  2. TypeNav 分类导航:点击一/二/三级分类

用户的真实使用场景是:先通过分类导航锁定大类,再在搜索框输入关键词精确查找。两个操作产生的参数必须合并到同一个 URL query 中,才能同时生效。

设计基石:URL query 是搜索状态的「单一数据源」

在动手写合并逻辑之前,要先理解一个更底层的架构决策:把搜索状态写进 URL,而不是写进组件 data 或 Vuex 。这是 SPA 里非常成熟的模式------"URL as the single source of truth"(URL 即单一数据源),其要义是让组件状态与 URL 双向同步:状态变化时更新 URL(State → URL),URL 变化(含浏览器前进/后退)时更新状态(URL → State)。这样做带来三个无法替代的好处:可分享 (复制链接发给同事,对方打开看到完全相同的搜索结果)、可收藏/深链接(deep linking) (用户能直接收藏某个筛选状态,关闭后再回来仍在原处)、与浏览器历史天然集成 (前进后退即搜索历史回退)。需要注意的边界:URL 长度通常应控制在 2048 字节内,因此只有"分享/收藏/恢复视图所必需"的状态才放进 query,临时 UI 状态(如下拉是否展开)不应入 URL(Single source of truth - Wikipedia)。

【代码注释】该图把搜索页拆成"写入方"与"读取方"两条边围着中心蓝色 URL 转:橙色「写入方」(Header 搜索框、TypeNav 分类、面包屑的 × 移除)所有交互最终都落到 router.push 改写 query;绿色「读取方」(watch route.query 触发请求、面包屑 v-if 渲染、浏览器前进后退)全部从 query 读状态。原理在于------只要约定"状态只写一处、只读一处",组件之间就无需互相 props/事件传值,URL 成了天然的全局总线。市面应用:电商筛选页、后台列表的分页+排序+筛选条件、地图应用的经纬度缩放级别,都用这套"把状态序列化进 URL"的范式实现刷新保持与链接分享。

Object.assign 浅拷贝机制
js 复制代码
const a = { x: 1, y: 2 };
const b = { y: 3, z: 4 };
const result = Object.assign({}, a, b);
// result: { x: 1, y: 3, z: 4 }
// 解析:
// - 后面的 source 覆盖同名属性(b.y 覆盖 a.y)
// - 不影响原始对象(a、b 不变)
// - 只复制"第一层"引用,嵌套对象仍为引用

【代码注释】这段演示 Object.assign 的三个关键语义:后覆盖前b.y=3 覆盖 a.y=2)、不污染源对象 (target 传空对象 {}a/b 保持不变)、浅拷贝 (只复制第一层,若属性是嵌套对象则复制的是引用)。为什么搜索场景敢用浅拷贝?因为 query 里全是字符串/数字这类基本类型,不存在"嵌套对象被共享引用"的隐患。市面应用 :合并默认配置与用户配置(Object.assign({}, defaults, userOptions))、构造请求参数、Vuex mutation 里生成新 state 片段,都是这套浅拷贝合并的日常用法。详见 MDN Object.assign()

浅拷贝的陷阱:如果搜索参数对象中有数组或嵌套对象,修改拷贝后的嵌套值会影响原始对象。因此在 URL query 场景下,只要参数均为基本类型(字符串/数字),浅拷贝完全安全。

展开运算符与 Object.assign 的等价写法
js 复制代码
// 等价写法一:Object.assign
this.$router.push({
    path: "/search",
    query: Object.assign({}, this.$route.query, { keyword })
});

// 等价写法二:展开运算符(更简洁,推荐)
this.$router.push({
    path: "/search",
    query: { ...this.$route.query, keyword }
});

【代码注释】两种写法功能完全等价,区别只在语法风格:Object.assign({}, a, b) 是 ES6 的方法调用,能接收任意多个 source,适合函数式/动态拼接;{ ...a, b } 是 ES2018 的对象展开,更简洁、是纯表达式可直接内联进对象字面量。Babel 在低版本目标下会把展开语法编译回 Object.assign,所以"性能差异"在实践中可忽略。市面应用 :现代 Vue/React 项目里改写不可变状态(如 this.$route.query 是只读的,不能直接 query.keyword = x,必须先展开再赋值)几乎清一色用展开运算符。

展开运算符是 ES2018 的 Object Spread,Babel 会将其编译为 Object.assign,两者语义完全一致。

搜索参数流转图

【代码注释】该图把"用户操作 → 页面刷新"的完整链路串成一条线:蓝色两个入口(输入关键词 / 点击分类)分别经橙色 goSearch 进入黄色「合并参数」节点------这一步是前文讲的浅拷贝合并;合并结果统一汇入紫色「router.push」,URL 变化后 route.query 生成新对象引用 (这点很关键,下一节讲 watch 时会用到),触发 watch;最后绿色链路把 dispatch → action → API → commit → 重新渲染 走完。重点看两个入口最终汇聚到同一个 push:这正是"多入口、单数据源"的体现。市面应用:任何"多种筛选条件 + 统一结果列表"的页面(电商、招聘、房产、外卖)都是这个拓扑。

完整代码示例

Header 搜索框合并参数:

js 复制代码
// src/components/Header/index.vue
methods: {
    goSearch() {
        // 收集输入框的关键词
        const keyword = this.$refs.keyword.value.trim();

        if (keyword) {
            this.$router.push({
                path: "/search",
                query: {
                    // 保留当前所有 query 参数(如已有的分类 ID)
                    ...this.$route.query,
                    keyword
                }
            });
        }
    }
},
mounted() {
    // 页面刷新后恢复搜索框显示
    if (this.$route.query.keyword) {
        this.$refs.keyword.value = this.$route.query.keyword;
    }
}

【代码注释】这段是 Header 搜索框的两个核心动作。goSearch...this.$route.query 先把当前 URL 已有参数(如分类 ID)原样保留,再追加 keyword------这就是"合并而非替换",保证用户先选分类再搜关键词时两者并存。keyword 用了对象属性简写(等价于 keyword: keyword)。mounted 钩子做的是回填 :刷新页面后 URL 还在,但 input 的 DOM value 是空的,从 $route.query.keyword 读回来写进 $refs.keyword.value,用户就看到搜索框里仍是上次的词。注意回填必须在 mounted 而非 created------$refs 要等 DOM 挂载后才可用。市面应用:所有"刷新后筛选条件不丢"的列表页都靠这种 URL→输入框的回填逻辑。

TypeNav 分类跳转(正确处理互斥):

js 复制代码
// src/components/Header/TypeNav/index.vue
// 关键点:不再展开 this.$route.query,避免历史分类 ID 残留
goSearch(e) {
    const { id, level } = e.target.dataset;
    if (id) {
        this.$router.push({
            path: "/search",
            query: {
                // 只保留关键词,不保留其他分类 ID
                // 防止 category1Id=100 与 category3Id=3 同时存在
                ["category" + level + "Id"]: id,
                categoryName: e.target.innerText.trim(),
                keyword: this.$route.query.keyword
            }
        });
        this.isShow = false;
    }
}

【代码注释】

  • ["category" + level + "Id"]:计算属性名(Computed Property Names),根据分类等级动态生成键名,无需 if-else 分别处理三个等级
  • keyword: this.$route.query.keyword:显式保留关键词,不会因为覆盖而丢失
  • 不展开 this.$route.query 的全部属性,是为了清除上次遗留的分类 ID(互斥设计)

Search 页面监听 query 变化:

js 复制代码
// src/pages/Search/index.vue
export default {
    name: "Search",
    watch: {
        "$route.query": {
            handler(query) {
                // 每次 query 变化,重新发起搜索
                this.$store.dispatch("product/postProductListAsync", query);
            },
            // immediate: true 让页面初始化时立即触发一次
            immediate: true
        }
    }
}

【代码注释】

  • "$route.query" 使用字符串路径监听嵌套属性,等价于 watch: { '$route.query': handler }
  • immediate: true:不加此选项,组件挂载时不会触发 handler,第一次进入搜索页不会加载数据
  • 无需 deep: true,因为 Vue Router 每次 query 变化都会生成新的 query 对象引用

【实战要点】

  • 分类与关键词互斥 :点击分类时,不能把上次的 category1Id 也带进去,否则后端会收到矛盾的参数。正确做法是只保留 keyword,不展开全部 query。
  • 搜索防抖 :若将搜索框改为输入即搜索,需对 goSearch 加防抖(lodash.debounce 或手写 setTimeout),避免每次键入都触发请求。

【本章小结】

搜索参数的合并本质是路由状态管理 :URL query 就是搜索状态的单一数据源。通过展开运算符合并参数,通过 $route.query watch 驱动数据请求,形成"URL → Vuex → 视图"的单向数据流。

维度 合并(展开 query) 替换(不带历史 query)
适用入口 Header 搜索框(保留已选分类) TypeNav 分类(清掉旧分类 ID)
写法 { ...this.$route.query, keyword } 显式列出要保留的字段
风险 旧字段残留导致后端收到矛盾参数 漏写字段导致已选条件丢失
触发请求 二者都靠 watch $route.query 自动重新搜索 同左

记忆口诀"关键词要并,分类要互斥;query 是源头,watch 加 immediate。" 搜索框追加关键词用展开合并,点分类要清掉旧分类 ID,首屏靠 immediate: true 立即触发。

【面试考点】

Q:Object.assign 和展开运算符 {...obj} 有什么区别? A:功能等价,均为浅拷贝。区别在于展开运算符是 ES2018 语法,Object.assign 是 ES6 方法。展开运算符更简洁,且是纯表达式(可内联使用);Object.assign 可接受多个 source,适合函数式写法。
Q:为什么 watch $route.query 要加 immediate: true? A:Vue 的 watch 默认在数据变化后才触发,首次渲染不触发。搜索页需要在组件创建时立即根据当前 URL 参数发起请求,所以必须加 immediate: true


二、搜索关键词的跨组件传递

名词解释

$refs :Vue 提供的模板引用机制,通过在元素上添加 ref="xxx" 属性,在组件实例上通过 this.$refs.xxx 直接访问原生 DOM 元素或子组件实例。

路由传参 :通过 $router.push 将数据写入 URL,目标页面通过 $route.query$route.params 读取。

概念与底层原理

搜索关键词的传递链路:Header 搜索框(输入)→ URL query → Search 页面(消费)

这套设计的本质是以 URL 作为组件间的通信媒介,有以下优点:

  1. 刷新不丢失:关键词写入 URL,刷新后依然存在
  2. 可分享:用户可以复制 URL 发给他人,对方打开会看到相同的搜索结果
  3. 浏览器前进/后退:基于路由历史,搜索记录可以回退

相比 Vuex 或 EventBus 直接传值,路由传参在持久化和可分享性上有天然优势。

$refs vs v-model 收集输入值
js 复制代码
// 方式一:$refs 直接读 DOM 值(命令式)
const keyword = this.$refs.keyword.value.trim();

// 方式二:v-model 双向绑定(声明式,推荐)
// 模板:<input v-model="keyword" />
// script:data() { return { keyword: '' } }
const keyword = this.keyword.trim();

【代码注释】这段对比两种收集输入值的范式。$refs命令式 ------绕过响应式系统,直接读 DOM 节点的 .value,本质是"我主动去问 DOM 现在是什么";v-model声明式 ------Vue 帮你把 input 的值和 data 双向同步,你只管读 data。绝大多数场景推荐 v-model(更符合数据驱动、便于校验与联动)。本项目唯一选 $refs 的理由:回填时要反向写 DOM($refs.keyword.value = ...),读写都走 $refs 比"一会儿 data 一会儿 DOM"更一致。市面应用 :富文本编辑器、文件上传、需要 focus()/select() 等命令式 DOM 操作的场景才用 $refs,普通表单一律 v-model

本项目使用 $refs 是为了在 mounted 钩子中回填关键词时,直接操作 DOM:this.$refs.keyword.value = this.$route.query.keyword。这是一种命令式用法,适合需要与 DOM 直接交互的场景。

完整代码示例

vue 复制代码
<!-- src/components/Header/index.vue -->
<template>
    <div class="searchArea">
        <form autocomplete="off" class="searchForm">
            <!-- ref="keyword" 用于通过 $refs.keyword 直接访问 input DOM -->
            <input
                placeholder="请输入搜索的关键词"
                type="text"
                ref="keyword"
                class="input-error input-xxlarge"
            />
            <button @click="goSearch" type="button">搜索</button>
        </form>
    </div>
</template>

<script>
export default {
    name: "Header",
    methods: {
        goSearch() {
            // 1. 通过 $refs 读取 input 的原生 value
            const keyword = this.$refs.keyword.value.trim();

            // 2. 非空校验:空字符串不触发搜索
            if (keyword) {
                this.$router.push({
                    path: "/search",
                    query: {
                        ...this.$route.query,  // 保留已有参数(分类 ID 等)
                        keyword
                    }
                });
            }
        }
    },
    mounted() {
        // 3. 页面刷新后,将 URL 中的 keyword 回填到搜索框
        if (this.$route.query.keyword) {
            this.$refs.keyword.value = this.$route.query.keyword;
        }
    }
}
</script>

【代码注释】

  • 搜索框没有使用 v-model,而是通过 ref 命令式读取,因为同时需要在 mounted 中回写 DOM,两种操作混用时 $refs 更直观
  • trim() 去除首尾空格,防止纯空格字符串被当作关键词搜索
  • mounted 中的回填逻辑保证了用户刷新页面后搜索框内容不丢失

【实战要点】

  • 若搜索框需要支持"回车触发搜索",在 <input> 上添加 @keyup.enter="goSearch" 即可
  • 关键词为空时不应跳转到搜索页,否则会产生无意义的请求

【本章小结】

关键词传递的核心是"以 URL query 为载体,$refs 负责 DOM 读写"。路由驱动搜索状态的设计让功能天然支持刷新保持、历史回退、链接分享。

维度 $refs(命令式) v-model(声明式)
取值 主动读 DOM .value Vue 自动同步到 data
回写 可直接 $refs.x.value = ... 改 data 即可
可用时机 mounted 之后(DOM 已挂载) 任意时机
适用场景 需命令式操作 DOM(回填、focus) 普通表单首选

记忆口诀"普通表单 v-model,要碰 DOM 才 refs;refs 等挂载,URL 当信使。" 搜索框因要回填 DOM 才用 $refs,且只能在 mounted 后访问;关键词靠 URL 在组件间传递。

【面试考点】

Q:$refs 什么时候才能访问? A:$refs 只在组件挂载(mounted)之后才可用,在 created 中访问会返回 undefined,因为此时 DOM 尚未渲染。


三、面包屑导航的数据模型设计

名词解释

面包屑导航(Breadcrumb):源自童话故事,用于展示用户当前所处位置的路径导航。在电商搜索页中,面包屑展示用户已选择的筛选条件(分类、关键词、品牌),每个条件是一个可以单独移除的标签。

路由驱动 UI :以 $route.query 作为数据源,模板根据 query 的不同字段决定是否渲染对应的面包屑标签。

概念与底层原理

面包屑导航有两种实现方案:

方案一:Vuex 存储标签数组(本地状态)

复制代码
用户点击分类 → 修改 Vuex 中的 breadcrumbs 数组 → 组件渲染数组

【代码注释】这条伪代码描述"把面包屑当成一个独立数组维护"的思路:用户每次操作都要手动 push/splice 这个数组,组件再渲染它。问题在于这个数组脱离了 URL ------一旦刷新页面,Vuex 被重新初始化(内存状态归零),数组清空,面包屑就消失了;而且容易出现"URL 显示 keyword=手机,但面包屑数组里没这条"的不一致。市面应用:只有当面包屑信息无法用 URL 表达(如带复杂富结构、需要本地草稿)时才考虑这种独立状态,普通筛选场景不推荐。

缺点:刷新后 Vuex 状态丢失,面包屑消失;URL 与 UI 不同步。

方案二:路由 query 驱动(推荐)

erlang 复制代码
用户点击分类 → 更新 URL query → 模板读取 $route.query 渲染标签

【代码注释】这条伪代码是本项目采用的方案:面包屑没有独立数据结构 ,它就是 $route.query 的一个"视图投影"。用户操作只改 URL,模板用 v-if 读 query 字段决定渲染哪些标签。因为状态唯一来源是 URL,刷新、分享、前进后退全部自动正确。这正是前文"单一数据源"原则在面包屑上的落地。市面应用:电商/招聘/房产的筛选面包屑几乎都是这种"query 驱动 UI"。

优点:状态持久化(刷新不丢),URL 即状态,天然支持分享。

延伸:中后台系统的另一种面包屑------$route.matched 自动生成

本项目的面包屑表达的是"筛选条件",所以挂在 query 上。但在中后台系统里,面包屑通常表达的是"页面层级路径"(如 首页 / 系统管理 / 用户列表),这类面包屑的标准做法是利用 Vue Router 的 $route.matched。当一个 URL 匹配嵌套路由时,Vue Router 会把从根到当前路由的所有路由记录 按层级有序地放进 $route.matched 数组;配合每条路由的 meta.title,遍历渲染即可自动得到反映当前层级的面包屑(Vue Router 路由元信息)。典型实现是 this.$route.matched.filter(r => r.meta && r.meta.title),并约定 meta.breadcrumb === false 的路由主动排除自己。注意它的局限:动态路由(含 :id 参数)下 matched 拿到的是路径模板而非具体业务名称,此时需手动维护层级列表。一句话区分 :表达"筛选条件"用 query,表达"页面层级"用 $route.matched + meta

本项目采用方案二。面包屑的数据结构不是显式的数组,而是隐含在 URL query 中

ini 复制代码
/search?keyword=手机&categoryName=智能手机&category3Id=249

【代码注释】这行 URL 就是面包屑的"数据库":keyword=手机 渲染关键词标签,categoryName=智能手机 渲染分类标签,category3Id=249 是给后端的三级分类 ID(不直接展示)。三个字段彼此独立、可任意组合,这就是为什么面包屑能"一个一个单独移除"。市面应用 :把多维筛选状态扁平化编码进 query 是 SPA 通用做法,复杂时还会用 qs 库序列化数组/对象(如 tags[]=a&tags[]=b)。

模板通过 v-if 判断每个 query 字段是否存在,决定是否渲染对应标签:

  • v-if="$route.query.categoryName" → 渲染分类标签
  • v-if="$route.query.keyword" → 渲染关键词标签

面包屑状态流程图

【代码注释】该图把面包屑当成一个状态机来看:灰色圆形「空白搜索页」是初态(query 无字段),每条迁移箭头对应一次 router.push------写入字段就进入有标签态,红色箭头表示移除某字段后回退到更少标签的态。关注"有分类+关键词"这个紫色组合态:移除分类标签会退回绿色"只有关键词",移除关键词标签会退回蓝色"只有分类",两个标签彼此正交、独立增删。把面包屑理解成状态机的价值在于:所有迁移都只是 query 字段的增删,没有任何隐藏状态 ,这正是 URL 单一数据源带来的可预测性。市面应用:复杂筛选器(如电商左侧多分组筛选)本质都是这种"标签集合 = query 子集"的状态机,理解这个模型能避免写出一堆互相打架的标志位。

完整代码示例

vue 复制代码
<!-- src/pages/Search/index.vue 面包屑区域 -->
<template>
    <div class="bread-wrap">
        <ul class="fl sui-breadcrumb">
            <!-- 分类面包屑标签:当 query 中有 categoryName 时显示 -->
            <li v-if="$route.query.categoryName" class="with-x">
                {{ $route.query.categoryName }}
                <i @click="moveCategoryName">×</i>
            </li>

            <!-- 关键词面包屑标签:当 query 中有 keyword 时显示 -->
            <li v-if="$route.query.keyword" class="with-x">
                {{ $route.query.keyword }}
                <i @click="moveKeyword">×</i>
            </li>
        </ul>
    </div>
</template>

【代码注释】

  • v-if="$route.query.categoryName" 直接将 query 字段作为条件,字段不存在时为 undefined,Vue 将其视为 false
  • 关闭按钮 <i @click="moveKeyword">×</i> 点击后调用移除方法,重新生成不含该字段的 query

【实战要点】

面包屑标签一般分为三类:

  1. 分类标签(来自 TypeNav 点击)
  2. 关键词标签(来自搜索框)
  3. 品牌标签(来自 SearchSelector 组件的品牌列表)

这三类标签的移除逻辑各有不同,分别在下一章详细讲解。

【本章小结】

将 URL query 作为面包屑的唯一数据源,是最简单也最健壮的设计。它省去了维护额外数据结构的成本,刷新保持、链接分享均自动支持。

维度 方案一:Vuex 数组 方案二:query 驱动(推荐)
数据源 独立维护的数组 $route.query 字段
刷新后 状态丢失,面包屑消失 状态保留(URL 不变)
分享/收藏 不支持 天然支持
同步成本 需手动保持与 URL 一致 零成本(本就是同一份)

记忆口诀"面包屑不是数组,是 query 的投影;字段在就渲染,刷新分享都不丢。" 标签用 v-if="$route.query.xxx" 渲染,状态唯一来源是 URL。中后台的"层级面包屑"则改用 $route.matched + meta

【面试考点】

Q:面包屑导航如何做到刷新不丢失? A:将筛选状态存储在 URL query 中,面包屑模板直接读取 $route.query 渲染,刷新页面 URL 不变,状态天然保留。若存在 Vuex 中,刷新后 Vuex 重置,面包屑消失。


四、面包屑标签的动态添加与移除

名词解释

标签添加:用户执行筛选操作(点击分类、输入关键词)时,将对应参数写入 URL query,面包屑标签随之出现。

标签移除 :用户点击面包屑标签上的 × 时,从 URL query 中删除对应字段,面包屑标签随之消失,搜索结果同步更新。

概念与底层原理

删除 URL query 字段的两种方式

方式一:先解构再删除属性

js 复制代码
const query = { ...this.$route.query };  // 浅拷贝,不修改原始 query
delete query.keyword;                    // 删除目标字段
this.$router.push({ path: "/search", query });

【代码注释】这是删字段的通用范式:先浅拷贝出一个可写副本$route.query 是只读的,直接 delete this.$route.query.keyword 既不规范也可能告警),在副本上 delete 掉目标键,再整体 push 回去。delete 是真正从对象上抹掉这个 key('keyword' in query 会返回 false),所以序列化进 URL 时该参数彻底消失。市面应用 :任意"清除单个筛选条件"的按钮都用这个三步法,配合工具函数 omit(query, ['keyword'])(lodash)可写得更声明式。

方式二:显式指定 undefined 跳过该字段

js 复制代码
this.$router.push({
    path: "/search",
    query: { keyword: undefined }  // Vue Router 会忽略值为 undefined 的字段
});

【代码注释】这是删字段的"取巧法":不真的删 key,而是把值设成 undefined。Vue Router 在把 query 对象序列化成查询字符串时会跳过值为 undefined 的字段,所以 URL 里同样看不到 keyword。它的局限是只适合"已知要删哪个字段"且字段不多的场景;方式一能动态删除任意字段、语义也更明确("我要删除它"而非"我把它设成空"),因此本项目推荐方式一。市面应用 :表单重置某个可选项时常用 undefined 写法,但批量/动态删除仍以方式一为主。

方式一更通用,适合动态删除任意字段;方式二只适合已知字段。本项目两种方式都有出现,推荐使用方式一。

分类标签移除的特殊性

分类标签对应三个 query 字段:category1Id(或 2 或 3)+ categoryName。移除分类标签时需要同时清除所有分类相关字段 ,只保留 keyword

js 复制代码
moveCategoryName() {
    // 只保留 keyword,清除所有分类相关字段
    const { keyword } = this.$route.query;
    this.$router.push({
        path: "/search",
        query: { keyword }
    });
    // 注意:如果 keyword 为 undefined,push 后 URL 不含 keyword 参数
}

【代码注释】分类标签的移除比关键词复杂------它背后牵着三个可能字段(category1Id/category2Id/category3Id 三选一,外加 categoryName)。这里用了一个反向思路 :不去逐个 delete 分类字段(要判断是几级分类、容易漏删),而是直接"白名单重建"------从旧 query 里只解构出 keyword,用它构造一个全新 query,其余字段自然全部丢弃。这样无论之前有几级分类 ID 都被一次清空。若此时 keyword 本身是 undefined,push 后 URL 连 keyword 也没有,回到空白搜索页,逻辑依旧自洽。市面应用:当一个"逻辑标签"对应多个底层字段时(如日期范围对应 start+end),用"保留白名单字段、重建对象"比"删除黑名单字段"更不易出错。

这比逐个删除 category1Idcategory2Idcategory3IdcategoryName 更简洁,且不会遗漏。

完整代码示例

js 复制代码
// src/pages/Search/index.vue
export default {
    name: "Search",
    methods: {
        // 移除分类面包屑标签
        moveCategoryName() {
            // 策略:重构 query,只保留 keyword,丢弃所有分类字段
            // 优于逐个 delete,因为不需要知道是几级分类 ID
            const { keyword } = this.$route.query;
            this.$router.push({
                path: "/search",
                query: { keyword }
            });
        },

        // 移除关键词面包屑标签
        moveKeyword() {
            // 策略:拷贝 query,删除 keyword 字段
            const query = { ...this.$route.query };
            delete query.keyword;
            this.$router.push({
                path: "/search",
                query
            });

            // 同步清空搜索框(通过事件总线通知 Header 组件)
            this.$bus.$emit("clearKeyword");
        }
    }
}

【代码注释】这段把两个移除方法放在一起对照:moveCategoryName 用"白名单重建"清掉全部分类字段、moveKeyword 用"浅拷贝 + delete"精确删一个字段------两种策略各对应前文的两类场景。特别注意 moveKeyword 末尾的 $bus.$emit("clearKeyword"):移除关键词标签时,URL 里的 keyword 没了,但 Header 搜索框 DOM 里的文字还在,必须通过事件总线通知 Header 清空(详见第五章),否则会出现"面包屑没了但搜索框还显示旧词"的不一致 bug。市面应用:跨组件的视觉同步(一处改动要联动另一处 UI)在中大型项目里非常常见,事件总线/状态库是标准解法。

SearchSelector 中的品牌标签添加(子传父):

vue 复制代码
<!-- src/pages/Search/SearchSelector/index.vue -->
<template>
    <ul class="logo-list">
        <li
            v-for="item in trademarkList"
            :key="item.tmId"
            @click="selectBrand(item)"
        >
            {{ item.tmName }}
        </li>
    </ul>
</template>

<script>
export default {
    name: "SearchSelector",
    methods: {
        selectBrand(trademark) {
            // 将品牌信息通过 $emit 传递给父组件 Search
            this.$emit("selectBrand", trademark);
        }
    }
}
</script>

【代码注释】SearchSelector 是子组件,它不直接改路由 ,而是把用户点击的品牌对象通过 $emit("selectBrand", trademark) 抛给父组件 Search。这遵循 Vue 的"props 向下、事件向上"单向数据流原则:子组件只负责"通知发生了什么",由持有路由权限的父组件决定"怎么改 URL"。这样子组件可复用、不与具体页面的路由结构耦合。市面应用 :表格行的操作按钮、列表项的选中、表单子项的变更,标准做法都是子 $emit、父处理,而非子组件直接操作全局状态/路由。

js 复制代码
// src/pages/Search/index.vue 父组件接收并处理品牌选择
methods: {
    addBrandTag(trademark) {
        // 将品牌 ID 和名称写入 URL query
        this.$router.push({
            path: "/search",
            query: {
                ...this.$route.query,
                tmId: trademark.tmId,
                tmName: trademark.tmName
            }
        });
    }
}

【代码注释】

  • 品牌标签的添加通过子组件 $emit 传递给父组件,再由父组件修改路由,符合"数据向上流"原则
  • 关键词移除后立即触发 $bus.$emit("clearKeyword"),确保搜索框视觉同步(详见第五章)

面包屑完整模板(含品牌标签):

vue 复制代码
<template>
    <div class="bread-wrap">
        <ul class="fl sui-breadcrumb">
            <!-- 分类标签 -->
            <li v-if="$route.query.categoryName" class="with-x">
                {{ $route.query.categoryName }}
                <i @click="moveCategoryName">×</i>
            </li>

            <!-- 关键词标签 -->
            <li v-if="$route.query.keyword" class="with-x">
                {{ $route.query.keyword }}
                <i @click="moveKeyword">×</i>
            </li>

            <!-- 品牌标签 -->
            <li v-if="$route.query.tmName" class="with-x">
                {{ $route.query.tmName }}
                <i @click="moveBrand">×</i>
            </li>
        </ul>
    </div>
</template>

【代码注释】这是面包屑的完整形态:分类、关键词、品牌三个 <li> 各自用 v-if 绑定 query 中的一个字段(categoryName/keyword/tmName),字段存在才渲染对应标签,每个标签自带一个 × 绑定独立的移除方法。三个标签结构完全平行、互不干扰,想加第四类筛选(如价格区间)只需再复制一个 <li> + 对应 query 字段。市面应用:这种"每个筛选维度一个可关闭标签"的面包屑是电商搜索页的标配交互(淘宝、京东顶部的已选条件区域),底层都是"query 字段 ↔ 标签"的一一映射。

【实战要点】

  • 点击 × 移除标签时,watch $route.query 会自动触发,重新发起搜索请求,无需手动调用接口
  • 分类、关键词、品牌三个标签互相独立,可以任意组合、单独移除
  • 建议对 moveCategoryNamemoveKeywordmoveBrand 提取公共函数,减少重复代码

【本章小结】

面包屑标签的增删本质是URL query 的精细控制 。添加标签 = 向 query 合并新字段,删除标签 = 从 query 中移除对应字段。由于 watch $route.query 持续监听,任何 query 变化都会自动触发重新搜索。

操作 实现策略 适用场景
添加标签 { ...query, 新字段 } 合并 单字段(关键词、品牌)
删一个字段 浅拷贝 + delete query.xxx 关键词、品牌标签
删一组字段 白名单重建(只保留 keyword 分类标签(牵 3 个字段)
取巧删除 字段值设 undefined 已知固定字段

记忆口诀"加靠展开删靠 delete,一组字段白名单重建;query 一变 watch 自动搜,无需手动调接口。" 删单字段拷贝后 delete,删分类用"只留 keyword"重建,移除后由 watch 自动重新请求。

【面试考点】

Q:如何从 URL query 中删除某个字段? A:两种方式:① 浅拷贝后 delete query.xxx 再 push;② 将该字段值设为 undefined,Vue Router 会在序列化时忽略它。推荐方式①,语义更清晰。
Q:面包屑标签的移除为什么不需要手动重新请求数据? A:Search 组件 watch$route.query,query 变化时 handler 自动触发,重新调用 Vuex action 发起搜索,形成自动联动。


五、搜索组件间的联动:事件总线应用

名词解释

事件总线(Event Bus) :一个额外的 Vue 实例,被挂载到 Vue.prototype.$bus 上,成为全局共享的事件中心。任意组件通过 $bus.$emit 发布事件,通过 $bus.$on 订阅事件。

$emit / $on :Vue 实例上的发布/订阅方法。$emit(eventName, data) 触发事件,$on(eventName, callback) 监听事件。

概念与底层原理

为什么需要事件总线?

当用户点击面包屑上的 × 移除关键词标签时,需要同步清空 Header 组件中搜索框的内容(<input> 的 value)。

但 Header 和 Search 组件的关系是:

css 复制代码
App
├── Header(包含搜索框)
└── RouterView → Search(包含面包屑)

【代码注释】这棵组件树点明了问题的根源:Header 和 Search 都是 App 的直接子组件,互为兄弟 ,它们之间没有父子关系,因此 props(父传子)和 $emit(子传父)这套标准通信都用不上。要让 Search 里的操作影响 Header 的 DOM,要么找它们的共同祖先 App 中转(App 会越来越臃肿),要么走 Vuex(为一个"清空输入框"引入全局状态太重),要么用事件总线(轻量、点对点)。市面应用:顶部导航栏与内容区、侧边栏与主面板这类"远房亲戚"组件的通信,是事件总线/全局状态库最典型的用武之地。

Header 和 Search 是兄弟关系(都是 App 的子组件),无法直接通过 props 通信。可选方案:

方案 优点 缺点
事件总线 简单直接 需要手动清理监听
Vuex 状态统一管理 引入额外状态,成本较高
父组件中转 标准组件通信 App 组件会过于臃肿

对于"清空输入框"这种一次性、轻量级的跨组件通信,事件总线是最合适的选择。

事件总线的内存管理

事件总线容易产生内存泄漏:如果 $bus.$on 注册的监听器在组件销毁时没有被移除,会导致:

  1. 监听器持有组件的引用,组件无法被垃圾回收
  2. 下次组件重新挂载时,监听器会叠加,同一事件被触发多次

正确做法是在 beforeDestroy 中调用 $bus.$off 取消监听:

js 复制代码
mounted() {
    this.$bus.$on("clearKeyword", this.handleClearKeyword);
},
beforeDestroy() {
    this.$bus.$off("clearKeyword", this.handleClearKeyword);
}

【代码注释】这对钩子是事件总线防泄漏的"标准搭档":mounted$on 订阅、beforeDestroy$off 退订,且两处必须传同一个具名函数引用 this.handleClearKeyword。为什么不能传匿名函数?因为 $off 是按"事件名 + 回调引用"精确匹配移除的,匿名函数每次都是新引用,$off 找不到要删的那一个,等于没退订。漏掉 $off 的后果有两个:① 回调闭包持有组件实例,组件销毁后仍被总线引用,无法被 GC 回收(内存泄漏);② 组件反复挂载时监听器层层叠加,一次 $emit 会触发多次回调。市面应用 :所有手动注册的全局监听(addEventListenersetInterval、WebSocket、第三方 SDK 回调)都要在销毁钩子里成对清理,这是排查内存泄漏的高频考点。

事件总线通信流程图

【代码注释】该图按时间顺序还原一次跨组件通信:蓝色起点是 Header 在 mounted 时通过 bus.on 把自己的 callback 注册进黄色「事件中心」;当用户点 × 触发橙色/紫色的 Search 流程(moveKeyword 改路由 → bus.emit),事件被投递到黄色 bus,bus 再派发给所有订阅者,绿色 Header callback 被触发、把搜索框清空。末端红色虚线提醒:Header 销毁时要 bus.off 退订,闭环才完整。看清这条"注册 → 发布 → 派发 → 执行 → 退订"的链路,就理解了发布/订阅(Pub-Sub)模式的全貌。市面应用 :消息推送、全局通知、跨模块解耦通信,本质都是这套发布订阅,Vue3 用 mitt、其他生态用 EventEmitter 实现同样模型。

完整代码示例

第一步:注册全局事件总线

js 复制代码
// src/main.js 或 src/index.js
import Vue from "vue";
import App from "@/App";
import router from "@/router";
import store from "@/store";
import filters from "@/filters";

Vue.config.productionTip = false;

new Vue({
    el: "#app",
    router,
    store,
    beforeCreate() {
        // 在 beforeCreate 钩子中注册事件总线
        // 此时 Vue 实例已创建,可以挂载到原型
        // 所有子组件都可以通过 this.$bus 访问
        Vue.prototype.$bus = this;

        // 同时注册全局过滤器
        for (let key in filters) {
            Vue.filter(key, filters[key]);
        }
    },
    render: h => h(App)
});

【代码注释】

  • 使用根 Vue 实例作为事件总线,而不是 new Vue(),是因为根实例在应用生命周期内始终存在,无需担心被销毁
  • beforeCreate 中挂载,确保所有子组件的 created 之前已可用

第二步:Header 订阅事件

js 复制代码
// src/components/Header/index.vue
export default {
    name: "Header",
    methods: {
        handleClearKeyword() {
            // 将搜索框内容清空
            this.$refs.keyword.value = null;
        }
    },
    mounted() {
        // 组件挂载后开始监听
        this.$bus.$on("clearKeyword", this.handleClearKeyword);
        // 回填逻辑(刷新后恢复搜索框内容)
        if (this.$route.query.keyword) {
            this.$refs.keyword.value = this.$route.query.keyword;
        }
    },
    beforeDestroy() {
        // 组件销毁前移除监听,防止内存泄漏
        this.$bus.$off("clearKeyword", this.handleClearKeyword);
    }
}

【代码注释】这是订阅方 Header 的完整生命周期闭环:mounted 里既订阅 clearKeyword 事件,又顺手做了刷新回填;beforeDestroy 里精确退订(传同一个 this.handleClearKeyword 引用)。把回调单独抽成 methods.handleClearKeyword 而非写匿名箭头函数,正是为了让 $on$off 能拿到同一个引用 完成精确退订------这是事件总线能否正确清理的关键细节。value = null 直接清空 input 的 DOM 值。市面应用:任何"组件 A 的状态变化要让组件 B 做出反应"的解耦场景,订阅方都应遵循这种"挂载订阅、销毁退订、具名回调"的三件套。

第三步:Search 发布事件

js 复制代码
// src/pages/Search/index.vue
export default {
    name: "Search",
    methods: {
        moveKeyword() {
            // 1. 修改路由,移除 keyword 参数
            const query = { ...this.$route.query };
            delete query.keyword;
            this.$router.push({ path: "/search", query });

            // 2. 通知 Header 清空搜索框
            this.$bus.$emit("clearKeyword");
        }
    },
    beforeDestroy() {
        // 组件即将离开(路由切换)时也触发清空
        // 保证用户跳回首页后搜索框不残留上次的关键词
        this.$bus.$emit("clearKeyword");
    }
}

【代码注释】

  • beforeDestroy 中的 $emit 处理"路由跳转后搜索框清空"的场景(如返回首页)
  • moveKeyword 中的 $emit 处理"在搜索页内移除关键词标签"的场景
  • 两个触发时机都需要覆盖,否则会出现搜索框内容与 URL 不一致的 bug

【实战要点】

  • 事件名建议使用驼峰格式(clearKeyword),避免与原生 DOM 事件冲突
  • 如果项目规模较大,建议将所有事件名提取到常量文件,避免拼写错误导致的 bug
  • Vue 3 移除了 $on$off 等实例方法,不再支持事件总线模式;Vue 3 推荐使用 mitt 或 Pinia 替代

【本章小结】

事件总线是 Vue 2 中兄弟组件通信的经典方案。其核心是"一个共享的 Vue 实例充当发布/订阅中心"。使用时需注意在 beforeDestroy 中取消监听,避免内存泄漏。

方案 通信关系 优点 缺点
事件总线 $bus 任意(含兄弟) 轻量、点对点 需手动 $off 清理
Vuex/Pinia 任意 状态统一、可追踪 为小事引入全局态偏重
父组件中转 有共同父级 标准单向数据流 父组件易臃肿
provide/inject 祖先→后代 跨层级、无需逐层传 不适合兄弟

记忆口诀 :**"挂载订阅,销毁退订;具名回调, off精确匹配。"∗∗'mounted'里'off 精确匹配。"** `mounted` 里 ` off精确匹配。"∗∗'mounted'里'onbeforeDestroy off',且两处必须传同一个具名函数引用,否则'off`,且两处必须传同一个具名函数引用,否则 ` off',且两处必须传同一个具名函数引用,否则'off` 删不掉、内存泄漏。

【面试考点】

Q:Vue 2 的事件总线为什么会有内存泄漏风险?如何避免? A:$bus.$on 注册的回调函数持有组件的引用(通过闭包或 this)。如果组件销毁时不调用 $bus.$off 移除,回调函数持续存在,组件实例无法被 GC 回收。避免方法:在 beforeDestroy 中调用 this.$bus.$off(eventName, specificCallback)(注意传入具体函数引用而不是匿名函数,否则无法精确移除)。
Q:Vue 2 事件总线和 Vue 3 的区别? A:Vue 3 移除了实例上的 $on$off$once 方法,事件总线模式不再开箱即用。Vue 3 推荐使用第三方库 mitt(轻量级事件库)或使用 Pinia/Vuex 进行状态管理。


六、过滤器:数据展示的最后一公里

名词解释

过滤器(Filter) :Vue 2 提供的模板格式化机制,用于对数据进行二次加工后再展示。语法为 {{ value | filterName(arg) }},管道符 | 将左侧值传入右侧过滤器函数。

全局过滤器 :通过 Vue.filter(name, fn) 注册,所有组件均可使用。

局部过滤器 :在组件的 filters 选项中定义,仅当前组件可用。

概念与底层原理

过滤器本质上是纯函数:接收一个值(和可选参数),返回处理后的值,不产生副作用。

bash 复制代码
原始数据 → 过滤器函数 → 格式化后的展示值
89       → currency(2, "¥") → "¥89.00"
1690000000000 → date → "2023-07-22 10:30:00"

【代码注释】这两行示意图点出过滤器的纯函数本质:输入一个原始值、输出一个展示字符串,不改原数据、不发请求、无副作用 。正因为是纯函数,过滤器才能被缓存推断、可单元测试、跨组件复用。89 → "¥89.00"时间戳 → "2023-07-22 10:30:00" 都是典型的"展示性转换"。市面应用:价格、日期、手机号脱敏、文件大小(字节→KB/MB)这类纯展示格式化,是过滤器(或 Vue3 的工具函数)的标准战场。

过滤器链(多个过滤器串联):

scss 复制代码
{{ value | filter1 | filter2(arg) }}
// 等价于:filter2(filter1(value), arg)
// filter1 的输出是 filter2 的输入

【代码注释】这段揭示过滤器链的求值顺序:管道 | 从左到右串联,前一个过滤器的输出是后一个的输入 ,等价于函数嵌套 filter2(filter1(value), arg)。注意带参数的过滤器,第一个参数永远是管道左侧传来的值,括号里的实参从第二个形参起依次对应。理解这个"从左到右、值优先"的规则,就不会写错复杂的链式格式化。市面应用 :如"金额先取绝对值再格式化货币"{{ amount | abs | currency }},链式组合让小过滤器可自由拼装。

padStart 补零机制
js 复制代码
// padStart(targetLength, padString) 从左侧填充字符串
"9".padStart(2, "0")   // → "09"
"12".padStart(2, "0")  // → "12"(已足够长,不填充)

// 在日期格式化中的应用
(timer.getMonth() + 1).toString().padStart(2, "0")
// getMonth() 返回 0-11,加 1 后得 1-12
// padStart(2, "0") 将个位数月份前补 0:1 → "01",12 → "12"

【代码注释】padStart(targetLength, padString) 从字符串左侧 填充直到达到目标长度,已够长则原样返回。日期格式化里它解决的痛点是"月份/日期个位数补零"------Date.getMonth() 返回 0~11(一月是 0,所以要 +1),得到的 9 转成 "9"padStart(2,"0")"09",保证统一的两位宽度。注意必须先 .toString(),因为 padStart 是字符串方法,数字没有它。市面应用 :日期/时间格式化、订单号补零、倒计时显示(05:09)都靠 padStart,比手写 n < 10 ? '0'+n : n 更简洁(MDN padStart)。

toFixed 小数处理
js 复制代码
(89).toFixed(2)        // → "89.00"
(98.5).toFixed(2)      // → "98.50"
(1.005).toFixed(2)     // → "1.00"(注意 IEEE754 浮点精度问题)

【代码注释】toFixed(n) 把数字按 n 位小数四舍五入并返回字符串 (注意不是数字!直接用于展示无需再转换)。第三行是著名的坑:1.005.toFixed(2) 预期 "1.01" 却得 "1.00",根因是 1.005 在 IEEE 754 双精度浮点里实际存储为略小于 1.005 的值,四舍五入时被舍去。涉及金额计算必须警惕:展示用 toFixed 没问题,但参与运算/对账时 要用 decimal.js、或先乘成整数(分)再算。市面应用 :价格展示、评分、百分比格式化用 toFixed;电商/金融的金额累加、折扣计算一律走精确小数库以避免一分钱误差。

toFixed 返回的是字符串,直接用于展示无需转换。

完整代码示例

过滤器定义文件:

js 复制代码
// src/filters/index.js
export default {
    /**
     * 日期格式化过滤器
     * @param {number|string} t - 时间戳或日期字符串
     * @returns {string} 格式:YYYY-MM-DD HH:mm:ss
     */
    date(t) {
        const timer = new Date(t);

        const year = timer.getFullYear();
        // getMonth() 从 0 开始,+1 后 padStart 补两位
        const month = (timer.getMonth() + 1).toString().padStart(2, "0");
        const day = timer.getDate().toString().padStart(2, "0");
        const hours = timer.getHours().toString().padStart(2, "0");
        const minutes = timer.getMinutes().toString().padStart(2, "0");
        const seconds = timer.getSeconds().toString().padStart(2, "0");

        return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
    },

    /**
     * 货币格式化过滤器
     * @param {number} v - 价格数值
     * @param {number} n - 小数位数,默认 2
     * @param {string} type - 货币符号,默认 "$"
     * @returns {string} 格式:¥89.00
     */
    currency(v, n = 2, type = "$") {
        // toFixed 保留小数位,前拼货币符号
        return type + v.toFixed(n);
    }
}

【代码注释】这个文件把项目所有展示格式化集中成一个对象:date 把时间戳格式化成 YYYY-MM-DD HH:mm:ss(内部全靠 padStart 补零),currency 用默认参数 n=2type="$" 实现"小数位、货币符号"可配置。集中定义的好处是口径统一 ------全站日期/金额格式只在这一处定义,改格式只改一个文件。生产中还应加边界处理(如 t 非法时返回空串),避免 new Date(undefined) 渲染出 Invalid Date市面应用 :团队通常维护一个 filters(Vue2)或 utils/format(Vue3)模块统一所有展示格式,是工程规范的一部分。

全局注册过滤器:

js 复制代码
// src/main.js
import Vue from "vue";
import filters from "@/filters";

new Vue({
    beforeCreate() {
        // 遍历 filters 对象,逐一注册为全局过滤器
        // key 即过滤器名称,filters[key] 即过滤器函数
        for (let key in filters) {
            Vue.filter(key, filters[key]);
        }
        Vue.prototype.$bus = this;
    },
    // ...
});

【代码注释】

  • for...in 遍历对象所有可枚举属性,包含继承属性。若要只处理自有属性,加 if (filters.hasOwnProperty(key)) 判断
  • 集中在一个文件定义再批量注册,方便维护和按需引入

在模板中使用:

vue 复制代码
<!-- 价格展示(保留 2 位小数,人民币符号) -->
<h3>{{ item.price | currency(2, "¥") }}</h3>
<!-- 渲染结果:¥89.00 -->

<!-- 时间展示 -->
<span>{{ item.createTime | date }}</span>
<!-- 渲染结果:2023-07-22 10:30:00 -->

<!-- 多过滤器串联 -->
<span>{{ item.price | currency(2, "¥") }}</span>

<!-- 在 v-bind 中使用(注意:Vue 2 支持,Vue 3 已移除) -->
<el-tag :content="item.createTime | date" />

【代码注释】这组展示过滤器在模板中的几种用法:插值里 {{ price | currency(2,"¥") }} 最常见;{{ time | date }} 不带参数;最后一行 :content="... | date" 演示过滤器也能用在 v-bind 属性上(仅 Vue2 支持)。括号里的实参从过滤器函数的第二个 形参开始对应(第一个永远是管道左侧的值)。市面应用:商品列表的价格、订单时间、消息时间戳几乎都在模板里用过滤器/工具函数即时格式化,避免把格式化结果冗余存进 data。

与 computed 的对比:

过滤器 computed
用途 模板格式化展示 派生状态
缓存 无缓存 有缓存(依赖不变不重算)
参数 支持额外参数 不支持参数
复用 全局过滤器跨组件复用 只在当前组件
Vue 3 已移除 继续支持

Vue 3 替代方案(过渡参考):

js 复制代码
// Vue 3 中没有过滤器,改用工具函数
// utils/format.js
export const currency = (v, n = 2, type = "¥") => type + v.toFixed(n);
export const formatDate = (t) => {
    const d = new Date(t);
    return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
};

// 组件中引入后在模板中调用
import { currency } from "@/utils/format";
// 模板:{{ currency(item.price, 2, "¥") }}

【代码注释】Vue3 移除了过滤器,迁移路径很直接:把过滤器函数原样搬进 utils/format.js 导出成普通函数,模板里从 {{ x | currency }} 改成 {{ currency(x) }} 直接调用即可。Vue3 移除过滤器的官方理由有二:① 函数完全能替代过滤器,没必要保留一套独立语法;② 管道符 | 与 JavaScript 的位或运算符冲突,增加解析复杂度。改成函数后还有额外好处------能享受 IDE 类型提示和 tree-shaking。市面应用 :Vue2→Vue3 升级时,过滤器是必改项,团队通常一次性把所有 filter 抽成 utils 工具函数并全局批量替换。

【实战要点】

  • 过滤器只适合无副作用的纯展示格式化,不应在过滤器内修改数据或触发请求
  • 货币计算涉及浮点精度时(如 1.005.toFixed(2) 结果为 "1.00" 而不是 "1.01"),应引入 decimal.js 等精确计算库
  • 过滤器函数应做边界处理(如 vnull/undefined 时的降级)

【本章小结】

过滤器是 Vue 2 特有的模板格式化机制,通过集中定义、批量注册实现全局复用。核心实现依赖 padStart 补零和 toFixed 保留小数。Vue 3 已移除过滤器,迁移时改为工具函数即可。

维度 过滤器(Vue2) computed 工具函数(Vue3)
定位 模板格式化展示 派生状态 通用纯函数
缓存 有(依赖不变不重算)
参数 支持 不支持 支持
Vue3 已移除 保留 推荐替代

记忆口诀"过滤器是纯函数,无缓存可带参;padStart 补零,toFixed 留两位;Vue3 没了它,工具函数顶上。" 展示格式化用过滤器、派生状态用 computed,升级 Vue3 一律抽成 utils 函数。

【面试考点】

Q:Vue 2 过滤器和 computed 有什么区别?应该如何选择? A:过滤器适合模板中的格式化展示(无缓存、支持参数、全局复用),computed 适合有复杂计算逻辑且需要缓存的派生状态。简单的日期/货币格式化用过滤器,涉及多个数据源计算的用 computed。
Q:Vue 3 为什么移除过滤器? A:Vue 3 认为过滤器的功能完全可以由普通函数替代,且过滤器语法(|)容易与 JavaScript 位运算符混淆,增加了语言复杂度。移除后推荐使用导入的工具函数或 computed 实现相同功能。


七、进阶:防抖、关键词高亮与搜索历史

前六章搭出了搜索页的骨架。要把它做到生产级,还有三块"体验与性能"的拼图:输入即搜索的防抖 (省请求)、结果里的关键词高亮 (提辨识度,但有 XSS 陷阱)、搜索历史持久化(提复访效率)。这一章把三者的底层原理讲透,并各配一个可直接运行的示例。

7.1 搜索防抖:闭包 + 定时器的频率控制器

概念与底层原理

如果把搜索改成"输入即搜索",每敲一个字符就发一次请求,既浪费带宽又可能让响应乱序。防抖(debounce) 是函数调用频率的控制器:触发后等待 wait 毫秒才真正执行,若等待期内再次触发则取消上一次、重新计时 ------只有"安静"了 wait 毫秒才会执行,因此天然适合"用户停止输入后再搜索"。

防抖的实现核心是闭包 + 定时器 :返回的函数通过闭包持久保存一个 timerId,每次触发先 clearTimeout 掉旧定时器再 setTimeout 新的,并用 applythis 和参数正确透传给原函数。lodash 在此基础上加了 leading(前沿触发,等待开始前先执行一次)、trailing(后沿触发,等待结束后执行,默认开启)、maxWait(最长等待,超时强制执行一次)三个选项;有趣的是,节流 throttle 本质就是带 maxWait 的 debounce ,所以 lodash 用同一套底层逻辑实现了两个函数(Debouncing and Throttling Explained)。

js 复制代码
// 手写 debounce:闭包保存 timerId,trailing edge 触发
function debounce(fn, wait = 300) {
    let timerId = null;               // 闭包变量,跨多次调用持久存在
    return function (...args) {
        if (timerId) clearTimeout(timerId);  // 取消上一次未执行的计时
        timerId = setTimeout(() => {
            fn.apply(this, args);      // 用 apply 透传 this 和参数
            timerId = null;
        }, wait);
    };
}

【代码注释】这段是防抖的最小可用实现。timerId 定义在外层函数、被内层返回函数闭包引用,所以它在多次调用之间"活着"------这正是防抖能记住"上一次定时器"的关键。每次触发先 clearTimeout 抹掉上一次的待执行计时,再开一个新计时,于是只要用户还在连续输入,真正的 fn 永远被推迟,直到停手 wait 毫秒。fn.apply(this, args) 保证原函数拿到正确的 this 和事件参数。这是 trailing edge(后沿触发) ,也是搜索框最常用的模式。市面应用 :搜索联想、窗口 resize、滚动加载、表单实时校验,都靠防抖把高频事件压缩成"最后一次"。

在 Vue 中应用防抖(watch route.query)
js 复制代码
import { debounce } from "lodash";

watch: {
    "$route.query": debounce(function (query) {
        this.$store.dispatch("product/postProductListAsync", query);
    }, 300)
}

【代码注释】把 watch 的回调用 debounce 包一层,就把"query 一变就请求"改成"query 稳定 300ms 后才请求"。注意这里必须用普通 function 而非箭头函数 ------debounce 内部用 apply(this, ...) 透传上下文,箭头函数的 this 在定义时就固定了,会拿不到组件实例导致 this.$store 报错。生产中更推荐 lodash 的 debounce(处理了 leading/maxWait/取消等边界),而非手写版。市面应用:输入即搜索、筛选条件联动请求、自动保存草稿,都在 watch/事件回调上套防抖。

可运行示例:防抖搜索联想
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>防抖搜索联想</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
  <style>
    body { font-family: 'PingFang SC', sans-serif; max-width: 640px; margin: 40px auto; padding: 0 20px; background: #f8fafc; }
    .card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
    input { width: 100%; padding: 12px; font-size: 15px; border: 1px solid #d1d5db; border-radius: 8px; box-sizing: border-box; }
    .meta { font-size: 13px; color: #64748b; margin: 12px 0; }
    .hl { color: #ef4444; font-weight: 700; }
    .item { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; }
    .log { font-family: monospace; font-size: 12px; background: #0d1117; color: #c9d1d9; padding: 12px; border-radius: 8px; margin-top: 16px; max-height: 140px; overflow-y: auto; }
  </style>
</head>
<body>
  <div id="app">
    <div class="card">
      <h2>防抖搜索(停止输入 400ms 后才请求)</h2>
      <input v-model="keyword" @input="onInput" placeholder="试试快速输入:手机 / 电脑 / 耳机">
      <p class="meta">真实请求次数:{{ requestCount }}|按键次数:{{ keyCount }}</p>
      <div v-for="r in results" :key="r" class="item" v-html="highlight(r, keyword)"></div>
      <div class="log">
        <div v-for="(l, i) in logs" :key="i">{{ l }}</div>
      </div>
    </div>
  </div>
  <script>
    // 防抖工厂:闭包保存 timerId
    function debounce(fn, wait) {
      let t = null;
      return function (...args) {
        if (t) clearTimeout(t);
        t = setTimeout(() => fn.apply(this, args), wait);
      };
    }
    // 模拟接口:根据关键词返回候选
    function mockApi(kw) {
      const all = ['小米手机', '苹果手机', '华为手机', '游戏电脑', '办公电脑', '无线耳机', '降噪耳机'];
      return all.filter(x => x.includes(kw));
    }
    new Vue({
      el: '#app',
      data: { keyword: '', results: [], requestCount: 0, keyCount: 0, logs: [] },
      created() {
        // 在 created 中创建防抖实例,保证每个组件实例独立计时
        this.debouncedSearch = debounce(this.search, 400);
      },
      methods: {
        onInput() {
          this.keyCount++;           // 每次按键都自增(高频)
          this.debouncedSearch();    // 但请求被防抖压缩
        },
        search() {
          this.requestCount++;       // 真实请求(低频)
          this.results = this.keyword ? mockApi(this.keyword) : [];
          this.logs.unshift(`[${new Date().toLocaleTimeString()}] 请求 keyword="${this.keyword}"`);
        },
        // 关键词高亮(已做 HTML 转义防 XSS,见 7.2)
        highlight(text, kw) {
          const esc = s => s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
          const safeText = esc(text);
          if (!kw) return safeText;
          const safeKw = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          return safeText.replace(new RegExp(safeKw, 'gi'), m => `<span class="hl">${m}</span>`);
        }
      }
    });
  </script>
</body>
</html>

【代码注释】这个示例直观对比"按键次数"与"真实请求次数":快速输入"手机"时按键计数飙升,但请求计数只在停手 400ms 后加 1,防抖把请求压缩了。两个工程细节值得记:① debounce 实例在 created 里创建并挂到 this.debouncedSearch不能 直接 @input="debounce(search,400)"------那样每次输入都新建一个防抖函数、timerId 各自独立,防抖完全失效;② highlight 在拼 <span> 前先对原始文本做 HTML 转义,从源头堵住 XSS(详见 7.2)。市面应用:百度/淘宝搜索框的下拉联想就是这套"防抖 + 接口 + 高亮"的组合。

7.2 关键词高亮:正则匹配与 XSS 的边界

概念与底层原理

搜索结果里把命中关键词标红,体验上很有用。最直接的实现是正则匹配 + <span> 包裹,但这里藏着两个常被混淆的安全问题:

  1. 正则元字符转义 :用户输入可能含 .*+( 等正则元字符,直接塞进 new RegExp(kw) 会让正则行为异常甚至抛错。需先 kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 把它们转义成普通字符。
  2. HTML 实体转义(真正的 XSS 防线) :很多教程把"正则转义"误标成"防 XSS",这是错误的 。正则转义只解决匹配问题;真正的 XSS 风险在于------当你用 v-html/innerHTML 写回拼好的 HTML 时,如果原始文本 本身含 <script> 等内容就会被执行。正确做法是:在拼接高亮标签之前,先把原始文本的 < > & " ' 转成 HTML 实体 ,再匹配高亮(XSS 从零开始)。
js 复制代码
// 安全的高亮函数:先转义 HTML(防 XSS),再转义正则,最后包裹 <mark>
function highlight(text, keyword) {
    // ① HTML 实体转义------必须先于拼标签执行
    const escapeHtml = s => s.replace(/[&<>"']/g, c => ({
        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[c]));
    const safeText = escapeHtml(text);
    if (!keyword) return safeText;
    // ② 正则元字符转义------防止关键词含 . * + 等导致正则异常
    const safeKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    // ③ 用语义化 <mark> 包裹命中部分(gi:全局 + 忽略大小写)
    return safeText.replace(new RegExp(safeKeyword, 'gi'), m => `<mark>${m}</mark>`);
}

【代码注释】这是一个兼顾两类转义的安全高亮函数,执行顺序至关重要------先 HTML 转义,再做正则替换 。若顺序反了(先包标签再转义),连你自己加的 <mark> 也会被转义成纯文本,高亮失效。$&replace 中代表"匹配到的整体",\\$& 即给每个正则元字符前加反斜杠。用 <mark> 而非 <span> 更语义化(浏览器默认有高亮样式)。更彻底的方案是用浏览器原生 CSS Custom Highlight APICSS.highlights),不改 DOM、不拼 HTML,从根上免疫 XSS。市面应用 :搜索结果高亮、站内全文检索、代码搜索工具,安全实现都遵循"先转义原文、再匹配高亮"这条铁律;成熟库 mark.js 已处理好全部边界。

可运行示例:安全的关键词高亮
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>安全关键词高亮(含 XSS 对照)</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
  <style>
    body { font-family: 'PingFang SC', sans-serif; max-width: 680px; margin: 40px auto; padding: 0 20px; background: #f8fafc; }
    .card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
    input { width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; box-sizing: border-box; margin-bottom: 12px; }
    mark { background: #fef08a; color: #b91c1c; padding: 0 2px; border-radius: 2px; }
    .row { padding: 10px; border-bottom: 1px solid #f1f5f9; }
    .tag { display: inline-block; font-size: 12px; padding: 2px 8px; border-radius: 6px; margin-right: 8px; }
    .safe { background: #dcfce7; color: #166534; }
    .danger { background: #fee2e2; color: #991b1b; }
  </style>
</head>
<body>
  <div id="app">
    <div class="card">
      <h2>关键词高亮(已防 XSS)</h2>
      <input v-model="kw" placeholder="输入关键词,如:脚本 或 .(点号)">
      <div v-for="(item, i) in list" :key="i" class="row">
        <span class="tag safe">安全</span>
        <span v-html="highlight(item, kw)"></span>
      </div>
    </div>
  </div>
  <script>
    new Vue({
      el: '#app',
      data: {
        kw: '脚本',
        // 故意混入含 HTML 标签的"恶意"文本,验证转义是否生效
        list: [
          '这是一段含关键词脚本的正常文本',
          '危险内容:<img onerror=alert(1)> 脚本注入测试',
          '正则元字符测试:a.b.c 与 (group) 脚本'
        ]
      },
      methods: {
        highlight(text, keyword) {
          const escapeHtml = s => s.replace(/[&<>"']/g, c => ({
            '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
          }[c]));
          const safeText = escapeHtml(text);
          if (!keyword) return safeText;
          const safeKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          return safeText.replace(new RegExp(safeKeyword, 'gi'), m => `<mark>${m}</mark>`);
        }
      }
    });
  </script>
</body>
</html>

【代码注释】这个示例用三条数据验证安全性:第二条 <img onerror=alert(1)> 是经典 XSS payload,因为高亮前已 HTML 转义,它会被原样显示成文本而非执行脚本;第三条含 .(group) 等正则元字符,因为关键词侧也做了正则转义,输入"."不会变成"匹配任意字符"。两层转义缺一不可。市面应用:凡是把"用户可控文本 + 用户可控关键词"拼成 HTML 渲染的场景(评论搜索、聊天记录检索),都必须同时做这两层转义,否则就是 XSS 漏洞入口。

7.3 搜索历史:localStorage 持久化

概念与底层原理

URL query 在关闭标签页后就丢了,要让"搜索历史"跨会话保留,需要写进 localStorage(同源、持久、约 5MB)。核心是三步:读历史 → 去重并把最新词置顶 → 截断保留前 N 条再写回。

js 复制代码
const KEY = 'searchHistory';
function pushHistory(keyword) {
    if (!keyword) return;
    // 读:JSON 反序列化,容错默认空数组
    let history = JSON.parse(localStorage.getItem(KEY) || '[]');
    // 去重:移除已存在的同名词
    history = history.filter(k => k !== keyword);
    // 置顶:最新搜索放最前
    history.unshift(keyword);
    // 截断:只留前 10 条,写回(序列化)
    localStorage.setItem(KEY, JSON.stringify(history.slice(0, 10)));
}

【代码注释】这段是搜索历史的标准写入逻辑。localStorage 只能存字符串,所以读时 JSON.parse、写时 JSON.stringify|| '[]' 兜底首次为空的情况。filter 去重 + unshift 置顶的组合保证"重复搜索同一词时它跳到最前而不是堆积重复项";slice(0,10) 限制条数避免无限增长。市面应用:搜索引擎、电商、IDE 的"最近搜索/最近打开"列表都是这套"去重---置顶---截断"模型,更复杂的会加时间戳、按使用频次排序、或区分用户存到后端。

可运行示例:带历史记录的搜索框
html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>搜索历史持久化</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
  <style>
    body { font-family: 'PingFang SC', sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; background: #f8fafc; }
    .card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
    .bar { display: flex; gap: 8px; }
    input { flex: 1; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; }
    button { padding: 10px 18px; border: none; border-radius: 6px; background: #3b82f6; color: #fff; cursor: pointer; }
    .history { margin-top: 16px; }
    .chip { display: inline-block; padding: 5px 12px; margin: 4px; background: #f1f5f9; border-radius: 999px; font-size: 13px; cursor: pointer; }
    .chip:hover { background: #dbeafe; }
    .clear { font-size: 12px; color: #ef4444; cursor: pointer; margin-left: 8px; }
  </style>
</head>
<body>
  <div id="app">
    <div class="card">
      <h2>搜索(历史存 localStorage,刷新不丢)</h2>
      <div class="bar">
        <input v-model="keyword" @keyup.enter="doSearch" placeholder="输入后回车搜索">
        <button @click="doSearch">搜索</button>
      </div>
      <div class="history" v-if="history.length">
        <span>最近搜索:</span>
        <span class="clear" @click="clearHistory">清空</span>
        <div>
          <span v-for="(h, i) in history" :key="i" class="chip" @click="useHistory(h)">{{ h }}</span>
        </div>
      </div>
    </div>
  </div>
  <script>
    const KEY = 'demoSearchHistory';
    new Vue({
      el: '#app',
      data: { keyword: '', history: [] },
      created() {
        // 初始化时从 localStorage 恢复历史
        this.history = JSON.parse(localStorage.getItem(KEY) || '[]');
      },
      methods: {
        doSearch() {
          const kw = this.keyword.trim();
          if (!kw) return;
          // 去重 → 置顶 → 截断 → 持久化
          this.history = [kw, ...this.history.filter(h => h !== kw)].slice(0, 10);
          localStorage.setItem(KEY, JSON.stringify(this.history));
          this.keyword = '';
        },
        useHistory(h) { this.keyword = h; this.doSearch(); },
        clearHistory() { this.history = []; localStorage.removeItem(KEY); }
      }
    });
  </script>
</body>
</html>

【代码注释】这个示例把历史逻辑做成完整闭环:createdlocalStorage 恢复历史,doSearch[kw, ...去重后的旧历史].slice(0,10) 一行完成"置顶+去重+截断",点历史标签 useHistory 回填并再搜,clearHistoryremoveItem 清空。关键体验点:刷新页面后历史仍在(因为存的是 localStorage 而非内存)。市面应用:几乎所有搜索框都有"最近搜索/热门搜索",本地历史用 localStorage、跨设备同步的则上报后端按账号存储。

【实战要点】

  • 防抖实例务必在 created/data 中创建并复用,不要在模板事件里现场 debounce(fn, n),否则每次都是新函数、防抖失效;离开组件时调 .cancel() 取消挂起调用。
  • 关键词高亮先做 HTML 实体转义、再做正则元字符转义,顺序不可颠倒;条件允许优先用浏览器原生 CSS Custom Highlight API,从根上不碰 innerHTML
  • localStorage 写入要 try/catch(隐私模式或超配额会抛错),并对历史条数设上限;敏感关键词不应明文落本地。

【本章小结】

能力 核心机制 关键 API / 陷阱
搜索防抖 闭包持久化 timerId + 定时器重置 防抖实例须在 created 创建,箭头函数丢 this
关键词高亮 先 HTML 转义、再正则转义、后包标签 正则转义 ≠ 防 XSS,两层缺一不可
搜索历史 localStorage 去重---置顶---截断 只存字符串,需 JSON 序列化

记忆口诀"防抖 created 建,箭头丢 this;高亮先转义 HTML 再正则;历史去重置顶截断,JSON 进出 storage。" 三块进阶各有一个最易踩的坑:防抖实例不能现场建、高亮两层转义顺序不能反、历史只能存字符串。

【面试考点】

Q:防抖(debounce)和节流(throttle)的区别?lodash 怎么用一套代码实现两者? A:防抖是"停止触发后等待 wait 才执行"(连续触发只执行最后一次);节流是"固定间隔最多执行一次"。lodash 的 throttle 本质是带 maxWait 选项的 debounce------maxWait 强制在最长等待时间到达时执行一次,于是防抖就变成了节流,因此两者共用底层逻辑。
Q:实现搜索关键词高亮时,"正则转义"是不是就防住了 XSS? A:不是。正则转义(replace(/[.*+?^${}()|[\]\\]/g,'\\$&'))只解决"关键词含正则元字符导致匹配异常"。真正防 XSS 的是 HTML 实体转义 ------在用 v-html/innerHTML 写回前,把原始文本的 < > & " ' 转成实体,且必须在拼接高亮标签之前执行。最稳的方案是用浏览器原生 CSS Custom Highlight API 完全不碰 innerHTML。


总结

功能架构回顾(思维导图)

【代码注释】该图把整篇博客收束成五个模块:蓝色「搜索参数」(关键词/分类/合并)是输入侧,紫色「数据流」(watch→action→API)是处理侧,绿色「面包屑」(分类/关键词/品牌三类标签)是状态可视化,橙色「组件联动」(事件总线 on/emit/清理)是解耦通信,黄色「过滤器」(currency/date)是展示格式化。五个模块全部围绕一条主线------URL query 是单一数据源 :参数往里写、数据流从里读、面包屑是它的投影。把这张图记住,就掌握了一个中型搜索页的完整骨架。市面应用:这套分层(输入→数据流→状态展示→联动→格式化)几乎是所有列表/搜索类页面的通用架构模板。

高频面试题速查

问题 核心答案
Object.assign 和展开运算符的区别 语义等价,前者是方法,后者是语法糖;均为浅拷贝
watch 为什么加 immediate: true 组件首次加载时立即触发 handler,不加则需等 query 变化才触发
如何删除 URL query 中的字段 浅拷贝后 delete,再 router.push;或将值设为 undefined
事件总线内存泄漏如何避免 beforeDestroy 中调用 $bus.$off(name, callback) 取消订阅
Vue 2 过滤器在 Vue 3 的替代方案 工具函数 + 模板直接调用,或 computed 属性
搜索框内容如何与 URL 保持同步 mounted 中读 $route.query.keyword 回填,移除标签时通过事件总线清空
分类标签移除为什么只保留 keyword 三级分类 ID 只有一个生效,重构 query 比逐个删除更简洁可靠

延伸设计思考

1. 搜索参数持久化到 localStorage

URL query 在关闭标签页后会丢失历史搜索记录。可以在每次搜索时将参数写入 localStorage,实现搜索历史功能:

js 复制代码
// 搜索时保存历史
const history = JSON.parse(localStorage.getItem("searchHistory") || "[]");
if (keyword && !history.includes(keyword)) {
    history.unshift(keyword);
    localStorage.setItem("searchHistory", JSON.stringify(history.slice(0, 10)));
}

【代码注释】这是第 7.3 节搜索历史的精简版:|| "[]" 兜底首次读取为空、includes 去重、unshift 置顶、slice(0,10) 限长。注意它和 7.3 完整版的细微差别------这里用 includes 判断后才 unshift,完整版用 filter 移除旧的再 unshift,后者能把"重复搜索的词"重新提到最前(更符合"最近"语义),生产中更常用 filter 版。市面应用:搜索历史、最近访问、草稿自动保存都用 localStorage + JSON 序列化这套组合。

2. 搜索防抖优化

若将搜索改为"输入即搜索",需对触发接口的操作添加防抖:

js 复制代码
import { debounce } from "lodash";

watch: {
    "$route.query": debounce(function(query) {
        this.$store.dispatch("product/postProductListAsync", query);
    }, 300)
}

【代码注释】这是把第 7.1 节防抖落到 watch 上的写法:query 频繁变化时只在稳定 300ms 后请求一次。再次强调 watch 回调必须用 function(依赖 this 指向组件实例),不能用箭头函数。lodash 的 debounce 还返回 .cancel(),可在 beforeDestroy 里调用以取消挂起的请求,避免组件销毁后回调仍执行。市面应用:输入即搜索、筛选联动、地图拖动后重新加载数据,都在监听/事件上套防抖。

3. Vue 3 迁移路径

Vue 2 Vue 3 替代方案
事件总线 $bus mitt 库 / Pinia action
过滤器 ` ` 语法
beforeDestroy onBeforeUnmount
watch 字符串路径 watchEffectwatch(() => route.query, ...)

4. 搜索结果缓存

对于高频相同参数的搜索,可以在 Vuex 中维护一个以参数 JSON 字符串为键的缓存 Map,避免重复请求:

js 复制代码
// Vuex action 中加缓存判断
async postProductListAsync({ commit, state }, body) {
    const cacheKey = JSON.stringify(body);
    if (state.cache[cacheKey]) {
        commit("SAVE_SEARCH_PRODUCT_RESULT", state.cache[cacheKey]);
        return;
    }
    const { data } = await postProductList(body);
    commit("SAVE_CACHE", { key: cacheKey, data });
    commit("SAVE_SEARCH_PRODUCT_RESULT", data);
}

【代码注释】这段在 Vuex action 里加了一层结果缓存:用 JSON.stringify(body) 把搜索参数序列化成稳定的字符串 key,命中缓存就直接复用、跳过网络请求。这对"用户反复点同一筛选条件来回切"非常省请求。要注意两个坑:① JSON.stringify键顺序敏感{a:1,b:2}{b:2,a:1} 会得到不同 key,严谨场景需先对 key 排序;② 缓存要设过期/容量上限(如 LRU),否则长会话下 Map 会无限膨胀且数据可能过期。市面应用:列表筛选缓存、详情页缓存、SWR/React Query 等数据请求库的核心都是这种"参数即缓存键"的思路。


通过本文的系统梳理,搜索页的完整数据流清晰呈现:URL query 是搜索状态的单一数据源 ,所有组件围绕它进行读写;Vuex 负责异步数据的获取与存储事件总线处理轻量级的跨兄弟组件通信过滤器承担视图层的格式化职责。这套架构的每一层职责边界清晰,可扩展性强,是 Vue 2 中端项目的典型组织范式。

相关推荐
星栈1 小时前
LiveView 的实时通信,爽是爽,但 PubSub 和广播也最容易把自己绕晕
前端·前端框架·elixir
用户2930750976691 小时前
告别关键词匹配,拥抱向量语义 —— RAG 搜索从零到一
前端
独孤留白1 小时前
从C到Rust:告别 C 的"指针 + 长度"手动模式
前端·rust
阿黎梨梨1 小时前
揭秘大语言模型的底层逻辑:从文本分词到高维向量的计算之旅
javascript·人工智能
掘金安东尼2 小时前
中小厂前端候选人简历面试拆解:从 HR 面、技术面到主管面的双赢提问法
前端·面试
天平11 小时前
油猴脚本创建webworker踩坑记录
前端·javascript·typescript
原则猫12 小时前
前端基础大厦
前端
陈随易13 小时前
编程语言级别的Skill市场,AI Agent 的未来形态
前端·后端·程序员