自定义指令
搜索匹配的文本高亮
低阶版
ts
// 注册全局
app.directive("highlightTxt", (el: any, binding: { value: { search: string, txt: string } }) => {
// 搜索的文字
const searchTerm = binding.value.search.trim();
// 当前item项的文字
const txt = binding.value.txt;
// 清除高亮
if (!searchTerm) return (el.innerHTML = txt);
// 转义正则特殊字符
const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedTerm})`, "gi");
// 替换匹配文本
el.innerHTML = txt.replace(regex, '<span class="highlight">$1</span>');
});
/** style.css 编写样式 */
/* 代码高亮 */
.highlight {
background: linear-gradient(120deg, #fff9c4 0%, #ffecb3 100%);
padding: 0 4px;
border-radius: 4px;
font-weight: 700;
color: #d35400;
}使用说明
vue
<p v-highlightTxt="{txt: '文本', search: '搜索的内容'}">文本</p>进阶版
ts
/**
* 自定义文字高亮指令(带节流+XSS防护)
* 功能:根据搜索关键词实时高亮元素中的匹配文本,通过节流减少高频DOM操作
* 适用场景:搜索输入实时高亮、列表筛选高亮等
*/
export default {
/**
* 指令绑定到元素时执行(初始化阶段)
* @param el 绑定的DOM元素(需是HTMLElement类型,确保可操作innerHTML)
* @param binding 指令绑定值,格式:{ search: 搜索关键词, txt: 待高亮的原始文本 }
*/
mounted(el: HTMLElement, binding: { value: { search: string; txt: string } }) {
/**
* 节流后的高亮核心逻辑
* @param searchTerm 搜索关键词(可能为空)
* @param txt 待高亮的原始文本
* 节流延迟:200ms(可根据需求调整,输入类场景建议100-200ms)
*/
const highlightThrottle = throttle((searchTerm: string, txt: string) => {
// 无搜索关键词时,直接渲染原始文本(清除高亮)
if (!searchTerm.trim()) {
el.innerHTML = escapeHtml(txt); // 始终转义,防止XSS
return;
}
// 转义正则特殊字符(如 . * + ? 等),避免正则匹配异常
const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 构建正则:g-全局匹配,i-忽略大小写
const regex = new RegExp(`(${escapedTerm})`, 'gi');
// 1. 先转义原始文本(防XSS)→ 2. 用正则匹配关键词 → 3. 包裹高亮样式
el.innerHTML = escapeHtml(txt).replace(regex, '<span class="highlight">$1</span>');
}, 200);
// 将节流函数挂载到el上,供updated钩子复用(避免重复创建节流函数)
(el as any)._highlightThrottle = highlightThrottle;
// 初始化执行一次高亮(确保页面加载时就有正确的显示)
const { search, txt } = binding.value;
highlightThrottle(search, txt);
},
/**
* 绑定值(search/txt)变化时执行(更新阶段)
* @param el 绑定的DOM元素
* @param binding 包含新值(value)和旧值(oldValue)的绑定对象
*/
updated(
el: HTMLElement,
binding: {
value: { search: string; txt: string };
oldValue: { search: string; txt: string };
}
) {
const { search: newSearch, txt: newTxt } = binding.value;
const { search: oldSearch, txt: oldTxt } = binding.oldValue;
// 优化:只有搜索词或原始文本真的变化时,才触发高亮(避免无效执行)
if (newSearch === oldSearch && newTxt === oldTxt) return;
// 执行节流后的高亮逻辑(从el上获取之前挂载的节流函数)
(el as any)._highlightThrottle(newSearch, newTxt);
},
/**
* 指令解绑时执行(清理阶段)
* 作用:避免内存泄漏(清除未执行的节流定时器、删除el上的挂载属性)
* @param el 绑定的DOM元素
*/
unmounted(el: HTMLElement) {
// 获取el上挂载的节流函数
const throttleFn = (el as any)._highlightThrottle;
// 若节流函数存在取消方法,执行取消(清除未触发的定时器)
if (throttleFn && typeof throttleFn.cancel === 'function') {
throttleFn.cancel();
}
// 删除el上的自定义属性,避免内存泄漏
delete (el as any)._highlightThrottle;
},
};
/**
* 节流函数实现(适配浏览器环境)
* 功能:控制函数在指定时间内最多执行一次,避免高频调用
* @param fn 待节流的目标函数
* @param delay 节流延迟时间(毫秒)
* @returns 节流后的函数(附带cancel方法,用于取消未执行的任务)
*/
function throttle<T extends (...args: any[]) => void>(
fn: T,
delay: number
): T & { cancel: () => void } {
let timer: number | null = null; // 浏览器环境定时器类型为number(修复NodeJS.Timeout报错)
let lastExecTime = 0; // 上次函数执行时间戳
/**
* 节流包装函数
* @param this 显式声明为void,修复"this隐式具有类型any"报错(无需绑定this)
* @param args 目标函数的参数列表
*/
const throttled = function (this: void, ...args: Parameters<T>) {
const now = Date.now(); // 当前时间戳
const elapsed = now - lastExecTime; // 距离上次执行的时间差
// 情况1:距离上次执行超过延迟时间 → 直接执行目标函数
if (elapsed >= delay) {
lastExecTime = now; // 更新上次执行时间
fn(...args); // 直接调用目标函数(无需绑定this,因声明this为void)
} else {
// 情况2:未超过延迟时间 → 取消之前的定时器,重新设置
if (timer) clearTimeout(timer); // 清除旧定时器,避免重复执行
timer = setTimeout(() => {
lastExecTime = Date.now(); // 更新上次执行时间
fn(...args); // 延迟后执行目标函数
timer = null; // 定时器执行后重置为null
}, delay - elapsed); // 剩余延迟时间 = 总延迟 - 已过去时间
}
} as T & { cancel: () => void };
/**
* 取消节流任务的方法
* 作用:在指令解绑时清除未执行的定时器,避免内存泄漏
*/
throttled.cancel = () => {
if (timer) clearTimeout(timer); // 清除定时器
timer = null; // 重置定时器状态
lastExecTime = 0; // 重置上次执行时间戳
};
return throttled;
}
/**
* HTML转义函数(防XSS攻击核心)
* 作用:将HTML特殊字符转义为实体,避免恶意脚本注入
* 需转义的特殊字符:& < > " '
*/
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&') // & → &
.replace(/</g, '<') // < → <
.replace(/>/g, '>') // > → >
.replace(/"/g, '"') // " → "
.replace(/'/g, '''); // ' → '
}使用说明
- 在Vue中注册指令:
ts
Vue.directive('highlight', highlightDirective)- 模板中使用:
vue
<div v-highlight="{ search: 搜索关键词, txt: 原始文本 }"></div>- 需添加高亮样式(示例):
css
.highlight {
background-color: #fff2cc; /* 浅黄色背景 */
color: #d97706; /* 橙色文字 */
padding: 0 2px;
border-radius: 2px;
}- 调整建议:
- 节流延迟:输入类场景用100-200ms,筛选类用300ms
- 若需"输入停止后立即高亮",可将throttle改为debounce(防抖)
- 若txt是可信HTML片段(不建议),可移除escapeHtml调用
元素过度动画
元素平滑上升
ts
// y轴偏移的默认值
const DISTANCE = 50;
// 弱映射:存储元素与动画实例的关联(避免内存泄漏,元素销毁时自动释放)
const map = new WeakMap<HTMLElement, Animation>();
// ========================= IntersectionObserver配置 =========================
/**
* 观察器配置项 - 优化元素交叉判定精度
* root: 默认null(视口作为根元素)
* rootMargin: 根元素边界扩展/收缩(下边界扩展DISTANCE,让元素快进入视口时提前触发动画)
* threshold: 交叉阈值(元素可见比例≥30%时才判定为"交叉",避免1px误触发)
*/
const observerOptions: IntersectionObserverInit = {
rootMargin: `0px 0px ${DISTANCE}px 0px`,
threshold: 0.3,
};
// ========================= 创建IntersectionObserver实例 =========================
/**
* 元素可见性监听器 - 监听元素是否进入视口
* 核心逻辑:元素交叉(可见)时,播放预存的动画并清理资源
*/
const ob = new IntersectionObserver((entries) =>{
for (const entry of entries) {
// 仅当元素处于交叉状态(可见)时执行
if(entry.isIntersecting){
// 从映射中获取当前元素的动画实例
const animation = map.get(entry.target as HTMLElement);
// 播放动画(存在动画实例时才执行)
animation?.play();
// 动画播放后,从映射中删除(确保只播放一次)
map.delete(entry.target as HTMLElement);
// 取消对当前元素的监听(减少性能消耗)
ob.unobserve(entry.target);
}
}
}, observerOptions);
export default{
mounted(el: HTMLElement, binding: { value: { firstScreen: Boolean } }){ // firstScreen为true时表示第一屏也有动画,默认为false
// 条件判断:非首屏动画 + 元素在视口外 → 跳过动画(直接显示元素)
// 反之(首屏动画 或 元素在视口内)→ 初始化动画
if(!(el.getBoundingClientRect().top > window.innerHeight) && !binding.value?.firstScreen) return;
// 创建动画
const animation = el.animate([
// 动画起始状态:向下偏移DISTANCE像素 + 完全透明
// { transform: `translateY(${DISTANCE}px)`, opacity: 1 },
{ transform: `translate3d(0, ${DISTANCE}px, 0)`, opacity: 0 },
// 动画结束状态:偏移量为0 + 完全不透明
// { transform: `translateY(0)`, opacity: 1 }
{ transform: `translate3d(0, 0, 0)`, opacity: 1 }
],{
// 动画时长
duration: 300,
// 时间函数
// easing: 'cubic-bezier(1, 0.6, 0.6, 1)',
easing: 'ease-out',
// 填充模式(动画前后元素状态)
// fill: 'forwards',
})
// 初始暂停动画(等待元素进入视口后再播放)
animation.pause();
// 将动画实例存入映射(与元素关联)
map.set(el, animation);
// 开始监听当前元素的可见性
ob.observe(el);
},
// 卸载时移除监听
unmounted(el: HTMLElement){
// 从映射中获取动画实例并取消(避免动画残留)
const animation = map.get(el);
// 全部清除
animation?.cancel();
// 从映射中删除元素关联
map.delete(el);
// 取消对元素的可见性监听
ob.unobserve(el);
}
}使用说明
直接注册使用即可
ts
app.directive('slideIn', slideIn);指令使用 firstScreen表示第一屏是否就开启动画,默认不开启
ts
v-slide-in="{ firstScreen: true }"缩放动画
ts
/********************* 缩放动画 *********************/
// 弱映射:存储元素与动画实例的关联(避免内存泄漏,元素销毁时自动释放)
const map = new WeakMap<HTMLElement, Animation>();
// ========================= IntersectionObserver配置 =========================
/**
* 观察器配置项 - 优化元素交叉判定精度
* root: 默认null(视口作为根元素)
* rootMargin: 根元素边界扩展/收缩(下边界扩展DISTANCE,让元素快进入视口时提前触发动画)
* threshold: 交叉阈值(元素可见比例≥30%时才判定为"交叉",避免1px误触发)
*/
const observerOptions: IntersectionObserverInit = {
threshold: 0.3,
};
// ========================= 创建IntersectionObserver实例 =========================
/**
* 元素可见性监听器 - 监听元素是否进入视口
* 核心逻辑:元素交叉(可见)时,播放预存的动画并清理资源
*/
const ob = new IntersectionObserver((entries) =>{
for (const entry of entries) {
// 仅当元素处于交叉状态(可见)时执行
if(entry.isIntersecting){
// 从映射中获取当前元素的动画实例
const animation = map.get(entry.target as HTMLElement);
// 播放动画(存在动画实例时才执行)
animation?.play();
// 动画播放后,从映射中删除(确保只播放一次)
map.delete(entry.target as HTMLElement);
// 取消对当前元素的监听(减少性能消耗)
ob.unobserve(entry.target);
}
}
}, observerOptions);
export default{ // firstScreen: 第一屏是否立即执行动画.
mounted(el: HTMLElement, binding: { value: { firstScreen: boolean } }){ // firstScreen为true时表示第一屏也有动画,默认为false
// 条件判断:非首屏动画 + 元素在视口外 → 跳过动画(直接显示元素)
// 反之(首屏动画 或 元素在视口内)→ 初始化动画
if(!(el.getBoundingClientRect().top > window.innerHeight) && !binding.value?.firstScreen) return;
// 创建动画
const animation = el.animate([
// 动画起始状态:向下偏移DISTANCE像素 + 完全透明
{ scale: 0.9, opacity: 0 },
// 动画结束状态:偏移量为0 + 完全不透明
{ scale: 1, opacity: 1 }
],{
// 动画时长
duration: 300,
easing: 'ease-out',
})
// 初始暂停动画(等待元素进入视口后再播放)
animation.pause();
// 将动画实例存入映射(与元素关联)
map.set(el, animation);
// 开始监听当前元素的可见性
ob.observe(el);
},
// 卸载时移除监听
unmounted(el: HTMLElement){
// 从映射中获取动画实例并取消(避免动画残留)
const animation = map.get(el);
// 全部清除
animation?.cancel();
// 从映射中删除元素关联
map.delete(el);
// 取消对元素的可见性监听
ob.unobserve(el);
}
}使用说明
直接注册使用即可
ts
app.directive('scalingIn', scalingIn);指令使用 firstScreen表示第一屏是否就开启动画,默认不开启
ts
v-scaling-in="{ firstScreen: true }"