06全局组件搜索

how to

🌐 全栈前端实战笔记:全局命令面板组件设计

💡 架构常识:由于“全局搜索命令面板”要求在全站任何页面都能通过快捷键唤醒,因此该组件需要放在 src/components/ 下,并在根组件 App.vue 中注册,使其成为常驻的全局组件。

一、 组件骨架设计与 Tailwind CSS 样式落地

1. 语义化结构

一个标准的命令面板通常由 遮罩层(Overlay)面板主体(Main Panel) 组成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  <div class="fixed inset-0 w-full h-full bg-black opacity-30 blur-sm z-40"></div>
  
  <div class="fixed top-20 left-1/2 -translate-x-1/2 w-1/2 z-50">
    <div class="bg-white p-4">
      <input type="text" class="w-full focus:outline-none" />
    </div>
    <div class="bg-gray-50 max-h-96 overflow-y-auto">
      </div>
  </div>
</template>

📌 代码规范:对于像 input 这样没有子内容的标签,推荐使用单标签闭合形式(例如 <input />),保持代码简洁。

2. 核心 Tailwind CSS 类名速查

  • 布局与定位
    • fixed: 固定定位,使其脱离标准文档流,相对于浏览器窗口定位。
    • inset-0: 相当于 top: 0; right: 0; bottom: 0; left: 0;,配合 fixed 实现全屏占满。
    • w-full h-full: 宽高 $100%$。
    • w-1/2: 面板宽度占父级(或屏幕)的 $50%$。
  • 视觉与修饰
    • bg-black: 背景颜色设为黑色。
    • opacity-30: 透明度设为 $30%$,实现标准的半透明遮罩视觉。
    • blur-sm: 背景轻微高斯模糊,增强视觉景深(隔离背景干扰,迫使用户聚焦当前面板)。
    • focus:outline-none: 剔除表单输入框在被点击聚焦时出现的浏览器默认黑色边框。
  • 文本截断
    • line-clamp-2: 多行文本截断。内容如果超出 2 行,会自动截断并在末尾补充省略号(...)。

二、 安全防御(XSS)与剪贴板增强

1. v-html 的应用场景与安全红线

  • 为何使用:字典或翻译数据中通常包含富文本标签(如换行符 <br /> 或加粗 <b>)。如果使用双大括号 {{ }} 会被当成普通文本原样输出,因此必须使用 v-html 指令将其作为真正的纯 HTML 插入标签内。
  • 核心隐患(XSS 攻击):动态渲染任意 HTML 是极其危险的,恶意攻击者可能会注入 <img src="x" onerror="stealLocalStorage()">
1
2
3
4
5
6
// ❌ 危险:绝对不要将用户提供的内容或未清洗的内容直接传给 v-html
<div v-html="item.translation"></div>

//   安全:在交给 v-html 之前,必须使用 DOMPurify 等库进行过滤洗白
import DOMPurify from 'dompurify'
const cleanHTML = DOMPurify.sanitize(item.translation)

2. 剪贴板复制事件

在单词卡片上绑定点击事件,允许用户快速复制目标文本:

1
2
3
4
5
6
7
8
9
const copyWord = (word: string) => {
    try {
        // 安全限制:navigator.clipboard 仅在 localhost 或 HTTPS 环境下有效
        navigator.clipboard.writeText(word);
        ElMessage.success('复制成功');
    } catch (error) {
        ElMessage.error('复制失败');
    }
};

三、 输入防抖(Debounce)的三种技术实现

🔍 概念对齐防抖指的是在事件被触发 $n$ 秒后再执行回调,如果在这 $n$ 秒内又被触发,则重新计时。这能有效防止用户在疯狂敲击键盘输入时,前端不停向后端发起无效的高频网络请求。

方案一:侦听器模型(watch 动态追踪)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let timer: ReturnType<typeof setTimeout> | null = null;

// 推荐:第一个参数写成箭头函数,可以精确、动态地追踪 ref 或 Pinia 状态的变化
watch(
    () => search.value,
    (newVal) => {
        // 如果 500ms 内旧的定时器还在运转,无情清理它,重新开始计时
        if (timer) clearTimeout(timer);
        
        timer = setTimeout(() => {
            console.log('发起网络请求:', newVal);
        }, 500);
    }
);

💡 类型进阶let timer: ReturnType<typeof setTimeout> | null = null 中,ReturnType<typeof setTimeout> 是 TS 的内置泛型工具,能够动态捕获当前宿主环境(浏览器环境为 number,Node 场景下为 Timeout 对象)的定时器 ID 类型。

方案二:事件监听模型(绑定 @input 原生事件)

  • 事件区别
    • @input实时触发。用户每敲一个键、每删一个字或用鼠标右键粘贴文本,输入框内容一变就立马触发。
    • @change失去焦点时触发。用户输了一百个字,只要鼠标不点到别处或不按回车,就绝不触发。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<template>
  <input type="text" @input="onSearchInput" placeholder="输入关键词..." />
</template>

<script setup lang="ts">
let timer: ReturnType<typeof setTimeout> | null = null;

const onSearchInput = (e: Event) => {
    if (timer) clearTimeout(timer);
    
    // 必须要将 e.target 强类型断言为 HTMLInputElement 才能安全读取到 .value
    const value = (e.target as HTMLInputElement).value;
    
    timer = setTimeout(() => {
        console.log('向后端发起搜索请求:', value);
    }, 500);
};
</script>

方案三:底层拦截模型(使用 customRef 封装)

ref 是黑盒,数据一变网页立刻无条件刷新;而 customRef 是一个白盒,能够允许我们劫持 get(依赖收集 track())和 set(派发更新 trigger())的内部时机,从而实现自带防抖功能的响应式变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { customRef } from 'vue';

let timer: ReturnType<typeof setTimeout> | null = null;

const search = customRef((track, trigger) => {
    let value = ''; // 闭包存储真实值
    return {
        get() {
            track(); // 告诉 Vue 追踪这个值的变化
            return value;
        },
        set(newValue: string) {
            value = newValue; // 实时更新内存中的值
            
            if (timer) clearTimeout(timer);
            
            // 延迟触发网页更新(把 trigger 关进定时器里)
            timer = setTimeout(() => {
                console.log('防抖值改变:', value);
                getList();   // 请求后端数据
                trigger();   // 【核心】500ms 到了,这才通知 Vue 刷新网页
            }, 500);
        }
    };
});

🔬 四、 核心原理剖析:防抖代码中的“闭包”双重维度

在防抖的方案一(watch 机制)中,看似简单的几行代码,底层在两个维度上深度依赖了 闭包(Closure) 机制:

1
2
3
4
5
6
7
// 外层环境(常驻内存)
let timer = null; 

watch(() => search.value, (newVal) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => { console.log(newVal) }, 500); // 👈 两个内部函数
});

维度一:timer 变量的“跨生命周期长寿记忆”

  1. watch 的回调函数是一个内部函数,它访问了它外部作用域的 timer 变量。
  2. 当单次 watch 执行完毕后,按理说该函数作用域应该被销毁。但因为 Vue 保持着对这个回调函数的引用,这个函数被迫形成了闭包。
  3. 它成功让 timer 变量存活了下来。当用户下一次快速输入,第二次触发 watch 回调时,它依然能精确“记住”上一次生成的定时器 ID 从而成功执行 clearTimeout(timer)

维度二:newVal 的“环境时空冻结”

假设用户在输入框里连续输入了 "V", "Vu", "Vue" 三个字母:

  1. watch 实际上被连续触发了三次。每次触发,都会在内存中开辟一个独立的作用域,里面存放着当时的 newVal(第一次是 "V",第二次是 "Vu",第三次是 "Vue")。
  2. 前两次的调用在运行完 clearTimeout 后就匆匆结束了。
  3. 第三次触发时,setTimeout 内部的箭头函数 () => { console.log(newVal) } 诞生了。它没有立刻执行,而是在等待 500ms。
  4. 此时,这个箭头函数向外凝视,将第三次调用产生的空间以及里面的 newVal = "Vue" 打包带走并冻结
  5. 500ms 后,即使 watch 的第三次调用早就执行完毕、其作用域理应被销毁,这个定时器回调函数依然能从自己“打包带走的冻结环境”中,精准无误地抓取并打印出当时的最新值:"Vue"

五、 全局级高级能力增强

1. 全局快捷键监听与视口锁死

为了实现通过 Ctrl + F 唤醒面板,通过 Esc 关闭面板,需要在全局 window 上绑定键盘事件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
window.addEventListener('keydown', (e: KeyboardEvent) => {
    // 唤醒快捷键:Ctrl + F
    if (e.key === 'f' && e.ctrlKey) {
        e.preventDefault(); // 关键:阻止浏览器自带的默认网页搜索行为
        isShow.value = true;
        // 体验优化:当弹窗出现时,锁死网页滚动条,防止背景乱滚
        document.body.style.overflow = 'hidden';
    }
    
    // 退出快捷键:Escape
    if (e.key === 'Escape') {
        isShow.value = false;
        search.value = ''; // 清空搜索词
        document.body.style.overflow = 'auto'; // 恢复页面滚动条
    }
});

2. 全局组件自动聚焦(自定义插件与指令)

当命令面板弹出时,最人性化的体验是让输入框自动聚焦。我们可以编写一个全局指令插件 v-focus

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// directives/focus.ts
import type { App, Plugin } from 'vue';

export default {
    install(app: App) {
        // 注册全局指令 v-focus
        app.directive('focus', {
            // 当绑定了 v-focus 的 input 标签被真正塞入 DOM 树瞬间触发
            mounted(el: HTMLElement) {
                el.focus(); // 调用原生 DOM 方法使光标聚焦
            }
        });
    }
} as Plugin; // 使用 TypeScript 断言,确保其完美符合 Vue 插件的规范
  • 入口文件注册 (main.ts)
1
2
3
4
5
6
7
8
import { createApp } from 'vue';
import App from './App.vue';
import focusPlugin from './directives/focus'; // 默认导出可以由我们自定义任意名称

const app = createApp(App);

app.use(focusPlugin); // 安装插件,会自动调用插件内部的 install 函数
app.mount('#app');

3. <Transition> 优雅过渡动效

原生 v-ifv-show 的切换会让组件生硬地闪现或消失。利用 Vue 内置的 <Transition> 可以赋予其丝滑的缩放动画(从小变大,从大变小)。

1
2
3
<Transition name="panel">
  <div v-if="isShow" class="search-panel"> ... </div>
</Transition>

六个生命周期样式类名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* 1. 进入(Enter)阶段:由暗到明,由小到大 */
.panel-enter-from {
  opacity: 0;
  transform: scale(0.9); /* 动画起始状态:轻微缩小 */
}
.panel-enter-active {
  transition: all 0.3s ease-out; /* 整个过渡动画持续 0.3s */
}
.panel-enter-to {
  opacity: 1;
  transform: scale(1);
}

/* 2. 离开(Leave)阶段:由明到暗,由大到小 */
.panel-leave-from {
  opacity: 1;
  transform: scale(1);
}
.panel-leave-active {
  transition: all 0.2s ease-in;
}
.panel-leave-to {
  opacity: 0;
  transform: scale(0.9); /* 动画结束状态:缩回 0.9 */
}
comments powered by Disqus
记录学习与研究