函数封装
动态生成随机数
/**
* @rand 动态生成随机数
* @param {number} min 最小值 (包含)
* @param {number} max 最大值(包含)
* @returns {number} 返回 <数字>
*/
function rand(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}动画
上下移动动画封装
/**
* @Up 上下移动动画封装
* @param {HTMLElement} target 要移动的对象
* @param {number} end 移动距离
* @param {Function} callback 该函数完成后的回调函数
*/
function Up(target, end, callback) {
clearInterval(target.timer);
target.timer = setInterval(() => {
let step = (end - target.offsetTop) / 12;
step = step > 0 ? Math.ceil(step) : Math.floor(step);
if (end == target.offsetTop) {
callback && callback();
clearInterval(target.timer);
return;
}
target.style.top = `${target.offsetTop + step}px`
}, 10);
};左右移动动画封装
/**
* @moveLeftAndRight 左右移动动画封装
* @param {HTMLElement} target 要移动的对象
* @param {number} end 移动距离
* @param {Function} callback 该函数完成后的回调函数
*/
function moveLeftAndRight(target, end, callback) {
clearInterval(target.timer);
target.timer = setInterval(() => {
let step = (end - target.offsetLeft) / 15;
step = step > 0 ? Math.ceil(step) : Math.floor(step);
if (end == target.offsetLeft) {
callback && callback();
clearInterval(target.timer);
return;
}
target.style.left = `${target.offsetLeft + step}px`
}, 10);
};透明度动画封装
/**
* @transparency 透明度动画封装
* @param {HTMLElement} target 透明的对象
* @param {number} end 透明度 (值问 0 ~ 100)
* @param {Function} callback 该函数完成后的回调函数
*/
function transparency(target, end, callback) {
let step = 0, sum = 0;
sum = sum < end ? sum = 0 : sum = 100;
clearInterval(target.times);
target.times = setInterval(() => {
step = end > sum ? 10 : -10;
if (sum === end) {
callback && callback();
clearInterval(target.times);
return;
}
sum += step;
target.style.opacity = sum / 100;
}, 30)
};返回顶部
/**
* 封装动动画函数
* @param {HTMLElement | window} target 需要处理的对象
* @param {number} end 移动的距离
* @param {Function} callback 事件完成后的执行的回调函数
*/
function backToTop(target, end, callback) {
clearInterval(target.timer);
target.timer = setInterval(function () {
let step = (end - window.pageYOffset) / 10;
step = step > 0 ? Math.ceil(step) : Math.floor(step);
if (window.pageYOffset == end) {
callback && callback();
clearInterval(target.timer);
return;
};
target.scroll(0, target.pageYOffset + step);
}, 10);
}
backToTop(window, 0, () => { })禁用滚动条
/**
* 禁用滚动条
* @param {boolean} flag - 是否禁用滚动条,true为禁用,false为启用
*/
function disableScrollbar(flag: boolean): void {
// 判断模态是打开状态还是隐藏状态,来判断滚动条是否禁用
if(flag){
const scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
}else{
const top = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
// window.scrollTo(0, -parseInt(top || '0'))
}
}虚拟列表?
如果使用了虚拟列表,position = 'fixed'会导致虚拟列表出问题,根本原因在于虚拟列表的核心依赖被破坏。
使用下面的方法进行禁用处理
/**
* 禁用滚动条
* @param flag <Boolean> 布尔值 true表示禁用
*/
disableScrollbar(flag: boolean): void {
// 判断模态是打开状态还是隐藏状态,来判断滚动条是否禁用
document.documentElement.style.overflow = flag ? 'hidden' : ''; // 禁用html
document.body.style.overflow = flag ? 'hidden' : ''; // 禁用body
}复制内容到剪贴板封装
/**
* 复制内容到剪贴板
* @param {string} content 内容
* @param {Function} callback 回调函数,返回复制状态和信息
*/
async function copyClipboard(content: string, callback: Function): Promise<void> {
try {
// 复制请求
await navigator.clipboard.writeText(content);
// 返回请求状态
callback && callback({status: true, message: '复制成功'});
} catch (err) {
// 返回请求状态
callback && callback({status: false, message: '复制失败'});
}
}读取剪切板封装
/**
* 读取剪切板内容
* @returns {string | undefined} - 返回剪切板内容,如果获取失败则返回undefined
*/
async function readClipboard(): Promise<string | undefined> {
try {
// 获取文本内容
const text = await navigator.clipboard.readText();
// 返回文本内容
return text;
} catch (err) {
// 获取失败
return undefined;
}
}文本中间用****代替
/**
* 处理显示的文本值
* @param {string} str 字符串
* @param {number} starSum 中间显示多少个 <*> 【默认5个】
* @param {number} start 第几个字符开始 【默认第一个】
* @param {number} end 结束的字符 【默认最后一个】
* @returns {string} 处理后的字符串
*/
function maskString({ str , starSum = 5, start = 1, end = 1 } : { str: string, starSum?: number, start?: number, end?: number }): string {
// 长度≤start + end时无需处理
if (str.length <= start + end) return str;
// 处理字符串
return str.substring(0, start) + '*'.repeat(starSum) + str.slice(-end);
}筛选出文本中所有的链接
/**
* 筛选出文本中所有的链接
* @param {string} text - 要筛选的文本
* @returns {string[]} - 返回一个包含所有链接的数组
*/
function extractLinks(text: string): string[] {
// 如果文本为空,直接返回空数组
if (!text) return [];
// 定义你提供的正则表达式(注意:在 JS 字符串中,反斜杠 \ 需要转义为 \\)
const regexStr = '(http|ftp|https):\\/\\/[\\w\\-_]+(\\.[\\w\\-_]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?';
// 创建正则对象,务必加上 'g' 参数进行全局匹配
const pattern = new RegExp(regexStr, 'g');
// 使用 match 方法提取所有链接
const matchArr = text.match(pattern);
// 如果没找到,match 会返回 null,这里做个兜底返回空数组
return matchArr || [];
}双击事件封装
/**
* 双击事件封装
* @param {Object} handler - 包含单击和双击回调函数的对象
* @param {Function} handler.click - 单击回调函数,单击时触发
* @param {Function} handler.doubleClick - 双击回调函数,双击时触发
* @param {number} delay 执行时间,默认300毫秒后执行
* @returns {function} 返回一个函数,用于处理点击事件
*/
function double(handler: { click?: Function, doubleClick?: Function}, delay = 300): () => void {
// 计时器
let timer: ReturnType<typeof setTimeout> | null = null;
// 计数器
let count = 0;
// 返回一个函数,用于处理点击事件
return () => {
// 增加计数器
count++;
// 清除计时器
if (timer !== null) clearTimeout(timer);
// 达到 2 次立即触发双击,不再等待延迟
if (count >= 2) {
// 触发双击事件
count = 0;
// 清除计时器
timer = null;
// 执行双击回调函数
handler.doubleClick && handler.doubleClick();
// 直接退出
return;
}
// 仅 1 次时启动定时器,超时后触发单击
timer = setTimeout(() => {
// 触发单击事件
if (count === 1) handler.click && handler.click();
// 重置计数器和计时器
count = 0;
// 清除计时器
timer = null;
}, delay);
};
}事件委派封装
封装函数
/**
* 事件委派封装
* @param {Object} params - 参数对象
* @param {MouseEvent} params.event - 点击事件对象
* @param {string | Array<string>} params.targetHTML - 目标标签名或目标标签名数组
* @param {string | Array<string>} params.eventName - 触发事件标识或事件标识数组
* @param {Function} params.callback - 回调函数,返回自定义事件参数
*/
eventDelegation({event, targetHTML, eventName, callback} : { event: MouseEvent, targetHTML: string | Array<string>, eventName: string | Array<string>, callback: Function}): void {
// 全部转换为大写字符
targetHTML = Array.isArray(targetHTML) ? targetHTML.map(t => t && t.toUpperCase()) : targetHTML.toUpperCase();
// 触发目标元素
const el = event.target as HTMLElement;
// 判断点击的元素是否符合要求
if(Array.isArray(targetHTML) && targetHTML.includes(el.tagName)) targetHTML = el.tagName;
// 判断是否是事件委派的事件
if(Array.isArray(eventName) && eventName.includes(el.dataset.eventname as string)) eventName = el.dataset.eventname as string;
// 判断目标元素
if(el.dataset.eventname !== eventName) return;
// 符合要求触发事件回调
if(el.tagName === targetHTML && el.dataset.eventname) callback && callback({...el.dataset });
}使用方法
<template>
<div @click="goTools($event)">
<div data-eventName="go" :data-target="item.target"></div>
</div>
</template>
<script lang='ts'>
// 单个点击事件使用【示例】
const goTools = (e: MouseEvent)=> appInst.eventDelegation({ event: e, targetHTML: "DIV", eventName: "go", callback: ({ target }:{ target: string })=>{
// 满足条件时的回调函数
}});
// 多个点击事件使用【示例】
const goTools = (e: MouseEvent)=> appInst.eventDelegation({ event: e, targetHTML: ["DIV", "P"], eventName: ["to", "copy"], callback: (result: any)=>{
// 跳转
result.eventname === 'to' && result.target && window.open(result.target);
// 复制网址
result.eventname === 'copy' && result.copy && copyUrl(result.copy);
}})
</script>关键提示
建议点击对象加一个伪元素,覆盖点击的对象,背景为透明,防止点击不理想的效果
防抖函数
什么是防抖?
核心:连续触发时,只保留最后一次。
/**
* 防抖函数
* @param {Function} fn - 需要防抖的回调函数
* @param {number} [delay = 150] - 防抖延迟时间(默认150毫秒)
* @param {boolean} [immediate = false] - 是否立即执行第一次触发(默认false)
* @returns {Function} 包装后的防抖函数
*/
debounce<T extends (...args: any[]) => any>(fn: T, delay: number = 150, immediate: boolean = false): (...args: Parameters<T>) => void {
// 保存定时器ID,用于清除定时器
let timer: ReturnType<typeof setTimeout> | null = null;
// 返回包装后的函数
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
// 绑定this上下文(确保原函数的this指向正确)
const context = this;
// 标记是否是第一次触发且需要立即执行
const isCallNow = immediate && !timer;
// 每次触发时,清除之前的定时器(重新计时)
if (timer) clearTimeout(timer);
// 设置新的定时器 --- 非立即执行模式:延迟结束后执行函数 --- 执行完成后清空定时器(重置状态)
timer = setTimeout(() => (!immediate && fn.apply(context, args), timer = null), delay);
// 立即执行模式:第一次触发时直接执行
isCallNow && fn.apply(context, args);
};
}节流函数
什么是节流?
核心:固定时间间隔内,最多执行一次。
/**
* 节流函数
* @param {Function} fn - 需要防抖的回调函数
* @param {number} [interval = 150] - 节流间隔时间(默认150毫秒)
* @param {Object} [options = {}] - 配置项
* @param {boolean} [options.leading = true] - 是否在时间窗口开始时执行(默认true)
* @param {boolean} [options.trailing = true] - 是否在时间窗口结束时执行(默认true)
* @returns 包装后的节流函数
*/
function throttle<T extends (...args: any[]) => any>( fn: T, interval: number = 150, options: { leading?: boolean; trailing?: boolean } = {}): (...args: Parameters<T>) => void {
const { leading = true, trailing = true } = options;
let lastTime = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
const context = this;
const now = Date.now();
const remaining = interval - (now - lastTime);
// 清除尾随定时器(避免重复触发)
if (timer) {
clearTimeout(timer);
timer = null;
}
// leading 模式:时间窗口开始时立即执行
if (leading && remaining <= 0) {
lastTime = now;
fn.apply(context, args);
}
// trailing 模式:时间窗口结束时执行
else if (trailing) {
timer = setTimeout(() => {
lastTime = Date.now();
timer = null;
fn.apply(context, args);
}, remaining);
}
};
}随机token生成器
直接生成
/**
* 生成随机token
* @param {number} tokenLength - token长度
* @returns {string} - 生成的随机token
*/
function getToken(tokenLength: number): string {
// 生成随机字符串
const letters = "abcdefghijklmnopqrstuvwxyz0123456789";
let token = "";
for (let i = 0; i < tokenLength; i++) {
if (i == 7 || i == 12 || i == 17 || i == 22) {
token = token + letters.charAt(Math.floor(Math.random() * letters.length));
} else {
token += letters.charAt(Math.floor(Math.random() * letters.length));
}
}
// 返回token
return token;
}生成唯一Token
/**
* 随机生成token
* @param {Array} dataList - 一个数组,用户生成的token不能和数组中的重复
* @param {string} tokenKey - 数组中对象的key名,用于判断token是否重复, 默认key名为'id'
* @param {number} tokenLength - 生成的token长度,默认长度为6
* @returns {string} - 生成的token
*/
function getToken({ dataList, tokenKey = "id", tokenLength = 6 }: { dataList: any[]; tokenKey: string; tokenLength: number }): string {
// 生成随机字符串
const letters = "abcdefghijklmnopqrstuvwxyz0123456789";
let token = "";
for (let i = 0; i < tokenLength; i++) {
if (i == 7 || i == 12 || i == 17 || i == 22) {
token = token + letters.charAt(Math.floor(Math.random() * letters.length));
} else {
token += letters.charAt(Math.floor(Math.random() * letters.length));
}
}
// 判断token是否重复
let flagToken = dataList.some((item: any) => item[tokenKey] === token);
// flagToken为true表示重复 false表示不重复
if (!flagToken) return token;
// 重新生成
return getToken({ dataList, tokenKey, tokenLength });
}格式化时间
/**
* 获取时间
* @param {string | Date} parameter 时间参数,可以是字符串('YYYY/MM/DD HH:mm:ss')或Date对象
* @returns {Object} 返回一个包含各种时间格式的对象
*/
function time(parameter?: string | Date): Object {
// 补零
const padZero = (val: number): string => String(val).padStart(2, '0');
// 汉字化日期
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
// 获取时间
let timer = parameter ? new Date(parameter) : new Date(),
FullYear = timer.getFullYear(),
month = padZero(timer.getMonth() + 1),
date = timer.getDate(),
day = timer.getDay(),
h = padZero(timer.getHours()),
m = padZero(timer.getMinutes()),
s = padZero(timer.getSeconds());
return {
// 完整日期
fullDateTimeWithDay: `${FullYear}/${month}/${date} ${weekdays[day]} ${h}:${m}:${s}`,
// 年月日,小时分钟,秒
fullDateTime: `${FullYear}/${month}/${date} ${h}:${m}:${s}`,
// 年月
yearMonth: `${FullYear}/${month}`,
// 年月日
dateStr: `${FullYear}/${month}/${date}`,
// 月日
monthDayWithWeekday: `${month}/${date} ${weekdays[day]}`,
// 时分秒
timeStr: `${h}:${m}:${s}`,
// 每一项
every: {
// 年
FullYear: FullYear,
// 月
month: month,
// 日
date: date,
// 星期
weekday: weekdays[day],
// 小时
hours: h,
// 分钟
minutes: m,
// 秒
seconds: s,
},
};
}手写base64字符串加密
class base64 {
constructor() {
// 加密代替字符
this._keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
}
// 加密方法
encode(strEncode) {
var output = "";
var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
var i = 0;
strEncode = _utf8_encode(strEncode);
while (i < strEncode.length) {
chr1 = strEncode.charCodeAt(i++);
chr2 = strEncode.charCodeAt(i++);
chr3 = strEncode.charCodeAt(i++);
enc1 = chr1 >> 2;
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
enc4 = chr3 & 63;
if (isNaN(chr2)) {
enc3 = enc4 = 64;
} else if (isNaN(chr3)) {
enc4 = 64;
}
output = output +
this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) +
this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4);
}
return output;
// 转码处理
function _utf8_encode(string) {
string = string.replace(/\r\n/g, "\n");
var utftext = "";
for (var n = 0; n < string.length; n++) {
var c = string.charCodeAt(n);
if (c < 128) {
utftext += String.fromCharCode(c);
} else if ((c > 127) && (c < 2048)) {
utftext += String.fromCharCode((c >> 6) | 192);
utfText += String.fromCharCode((c & 63) | 128);
} else {
utftext += String.fromCharCode((c >> 12) | 224);
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
utftext += String.fromCharCode((c & 63) | 128);
}
}
return utftext;
}
}
// 解密方法
decode(strDecode) {
var output = "";
var chr1, chr2, chr3;
var enc1, enc2, enc3, enc4;
var i = 0;
strDecode = strDecode.replace(/[^A-Za-z0-9\+\/\=]/g, "");
while (i < strDecode.length) {
enc1 = this._keyStr.indexOf(strDecode.charAt(i++));
enc2 = this._keyStr.indexOf(strDecode.charAt(i++));
enc3 = this._keyStr.indexOf(strDecode.charAt(i++));
enc4 = this._keyStr.indexOf(strDecode.charAt(i++));
chr1 = (enc1 << 2) | (enc2 >> 4);
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
chr3 = ((enc3 & 3) << 6) | enc4;
output = output + String.fromCharCode(chr1);
if (enc3 != 64) {
output = output + String.fromCharCode(chr2);
}
if (enc4 != 64) {
output = output + String.fromCharCode(chr3);
}
}
output = _utf8_decode(output);
return output;
// 解码处理
function _utf8_decode(utftext) {
var string = "";
var i = 0;
var c = 0;
var c1 = 0;
var c2 = 0;
while (i < utftext.length) {
c = utftext.charCodeAt(i);
if (c < 128) {
string += String.fromCharCode(c);
i++;
} else if ((c > 191) && (c < 224)) {
c1 = utftext.charCodeAt(i + 1);
string += String.fromCharCode(((c & 31) << 6) | (c1 & 63));
i += 2;
} else {
c1 = utftext.charCodeAt(i + 1);
c2 = utftext.charCodeAt(i + 2);
string += String.fromCharCode(((c & 15) << 12) | ((c1 & 63) << 6) | (c2 & 63));
i += 3;
}
}
return string;
}
}
}使用方法
// 创建实例
const handle = new base64();
// 方法
const 加密 = handle.encode(str);
const 解密 = handle.decode(str);图片转Base64
/**
* 将文件数组转换为 Base64 编码的 Promise
* @param {File[]} files 文件数组,类型为 File[]
* @return Promise<{ name: string; type: string; size: number; base64: string, url: string }[]> 返回一个 Promise,解析为包含文件信息和 Base64 编码的对象数组
*/
function filesToBase64(files: File[]): Promise<{ name: string; type: string; size: number; base64: string, url: string }[]> {
// 使用 Promise.all 并发处理所有文件,确保所有文件都转换完成后才返回结果
return Promise.all(files.map(file => new Promise<{ name: string; type: string; size: number; base64: string, url: string }>((resolve, reject) => {
// 创建 FileReader 实例用于读取文件内容
const reader = new FileReader();
// 当文件读取成功完成时触发
reader.onload = () => {
resolve({
name: file.name, // 文件名
type: file.type, // 文件的 MIME 类型(如 image/png)
size: file.size, // 文件大小(字节)
base64: reader.result as string, // 读取结果,强制转换为字符串类型
url: URL.createObjectURL(file) // 直接生成本地预览链接
});
};
// 当文件读取发生错误时触发,拒绝(reject)该 Promise 并抛出错误
reader.onerror = reject;
// 以 Data URL(Base64)格式读取文件内容
reader.readAsDataURL(file);
})));
};原生自定义封装触底事件
javascript
函数封装
/**
* 通用底部触发回调函数(兼容+防抖+滚动方向+多场景扩展)
* @param {Function} callback - 到达底部时执行的回调函数
* @param {Object} options - 配置项(可选)
* @param {number} options.offset - 提前触发偏移量(默认100px,值越大越早触发)
* @param {number} options.debounceDelay - 防抖延迟(默认150ms)
* @param {HTMLElement|null} options.scrollContainer - 滚动容器(默认window,支持自定义DOM)
* @param {number|null} options.maxTriggerTimes - 最大触发次数(默认null,无限触发)
* @param {boolean} options.repeatTrigger - 是否重复触发(默认false:触发一次后锁定,true:每次到达底部都触发)
* @param {boolean} options.onlyDownScroll - 是否仅向下滚动触发(默认true:仅向下滚动到底部触发,false:忽略方向)
* @param {number} options.triggerInterval - 同一底部状态下的最小触发间隔(默认1000ms,避免高频触发)
* @returns {Function} 取消监听的函数(用于组件卸载时销毁)
*/
function onReachBottom(callback, options = {}) {
// 默认配置
const {
offset = 100,
debounceDelay = 150,
scrollContainer = window,
maxTriggerTimes = null,
repeatTrigger = false,
onlyDownScroll = true,
triggerInterval = 1000 // 新增:同一底部状态最小触发间隔
} = options;
let isTriggered = false;
let triggerCount = 0;
let debounceTimer = null;
let lastTriggerTime = 0; // 记录上次触发时间(用于间隔控制)
const isWindow = scrollContainer === window;
let lastScrollTop = getScrollTop();
let isInBottom = false; // 标记当前是否处于底部状态
// 优先获取正确的滚动位置(修复Safari兼容)
function getScrollTop() {
if (isWindow) {
return window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop;
}
return scrollContainer.scrollTop;
}
// 处理自定义容器padding问题(包含内边距高度)
function getClientHeight(element) {
if (isWindow) {
return window.innerHeight || document.documentElement.clientHeight;
}
const style = window.getComputedStyle(element);
const paddingTop = parseFloat(style.paddingTop) || 0;
const paddingBottom = parseFloat(style.paddingBottom) || 0;
return element.clientHeight + paddingTop + paddingBottom;
}
// 底部判断逻辑(精准计算,兼容所有容器)
function isReachBottom() {
let scrollTop, scrollHeight, clientHeight;
if (isWindow) {
scrollTop = getScrollTop();
scrollHeight = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
);
clientHeight = getClientHeight();
} else {
scrollTop = scrollContainer.scrollTop;
scrollHeight = scrollContainer.scrollHeight;
clientHeight = getClientHeight(scrollContainer);
}
// 增加1px容差,避免浮点精度问题
return scrollTop + clientHeight + offset >= scrollHeight - 1;
}
// 滚动方向判断(先更新位置,再判断方向)
function getScrollDirection() {
const currentScrollTop = getScrollTop();
const direction = currentScrollTop > lastScrollTop ? 'down' : 'up';
lastScrollTop = currentScrollTop;
return direction;
}
// 核心事件处理函数(修复重复触发问题)
function handleScroll() {
// 触发次数达上限 → 直接返回
if (maxTriggerTimes !== null && triggerCount >= maxTriggerTimes) return;
const direction = getScrollDirection();
const reachedBottom = isReachBottom();
const wasInBottom = isInBottom; // 记录上一次底部状态
isInBottom = reachedBottom;
// 未到底部 → 解锁触发标记(重复模式)
if (!reachedBottom) {
if (repeatTrigger) isTriggered = false;
return;
}
// 仅向下滚动触发 → 方向不匹配返回
if (onlyDownScroll && direction !== 'down') return;
// 非重复模式已触发 → 返回
if (isTriggered && !repeatTrigger) return;
// 同一底部状态下,间隔不足不触发
const now = Date.now();
if (wasInBottom && now - lastTriggerTime < triggerInterval) return;
// 只有从非底部进入底部时才触发(避免同一底部连续触发)
if (wasInBottom && onlyDownScroll) return;
// 防抖执行回调
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
lastTriggerTime = now;
callback({
// 触发次数
triggerCount: ++triggerCount,
// 是否满足用户设置的最大触发次数
isLastTrigger: maxTriggerTimes !== null && triggerCount === maxTriggerTimes,
// 滚动方向
scrollDirection: direction
});
// 非重复模式 → 触发后锁定
if (!repeatTrigger) isTriggered = true;
}, debounceDelay);
}
// 事件绑定(兼容移动端+被动监听)
const eventTypes = ['scroll'];
if (isWindow && 'ontouchstart' in window) {
eventTypes.push('touchmove');
}
eventTypes.forEach(type => scrollContainer.addEventListener(type, handleScroll, { passive: true }));
isWindow && window.addEventListener('resize', handleScroll);
// 取消监听函数(防止内存泄漏)
return function cancelOnReachBottom() {
eventTypes.forEach(type => scrollContainer.removeEventListener(type, handleScroll, { passive: true }));
isWindow && window.removeEventListener('resize', handleScroll)
clearTimeout(debounceTimer);
};
}
// 导出(浏览器环境可忽略,模块化环境启用)
if (typeof module !== 'undefined' && module.exports) {
module.exports = onReachBottom;
}使用方法
// 导入触底监听函数
import onReachBottom from '@/app/reachBottom';
import { ref, onMounted, onUnmounted } from 'vue';
// 接收onReachBottom返回的函数,用于销毁监听事件,防止内存泄漏
let cancelScroll = ref(null);
// 编写触底业务逻辑
const handle = (msg) =>{
console.log("触底了", msg)
}
onMounted(()=>{
cancelScroll.value = onReachBottom(handle,
{ scrollContainer: container.value, repeatTrigger: true, offset: 150, onlyDownScroll: false, maxTriggerTimes: 10 }
// { scrollContainer: window, repeatTrigger: true, offset: 100, onlyDownScroll: false, maxTriggerTimes: 100}
);
})
// 组件销毁完成后触发
onUnmounted(()=>{
// 销毁触底事件监听
cancelScroll.value()
})TypeScript
函数封装
/**
* 通用底部触发回调函数(兼容+防抖+滚动方向+多场景扩展)
* @param {Function} callback - 到达底部时执行的回调函数
* @param {Object} options - 配置项(可选)
* @param {number} options.offset - 提前触发偏移量(默认100px,值越大越早触发)
* @param {number} options.debounceDelay - 防抖延迟(默认150ms)
* @param {HTMLElement|null} options.scrollContainer - 滚动容器(默认window,支持自定义DOM)
* @param {number|null} options.maxTriggerTimes - 最大触发次数(默认null,无限触发)
* @param {boolean} options.repeatTrigger - 是否重复触发(默认false:触发一次后锁定,true:每次到达底部都触发)
* @param {boolean} options.onlyDownScroll - 是否仅向下滚动触发(默认true:仅向下滚动到底部触发,false:忽略方向)
* @param {number} options.triggerInterval - 同一底部状态下的最小触发间隔(默认1000ms,避免高频触发)
* @returns {Function} 取消监听的函数(用于组件卸载时销毁)
*/
/**
* 回调函数参数类型定义(明确回调接收的参数结构)
*/
export interface ReachBottomCallbackParams {
triggerCount: number; // 已触发次数(从1开始累加)
isLastTrigger: boolean; // 是否是最后一次触发(基于maxTriggerTimes配置)
scrollDirection: 'up' | 'down'; // 触发时的滚动方向
}
/**
* 函数配置项类型定义(严格限制入参类型,避免错误传参)
*/
export interface OnReachBottomOptions {
offset?: number; // 提前触发偏移量(默认100px,值越大越早触发,支持负数延后触发)
debounceDelay?: number; // 防抖延迟(默认150ms,防止滚动时高频触发)
scrollContainer?: HTMLElement | Window; // 滚动容器(默认window,支持自定义滚动DOM)
maxTriggerTimes?: number; // 最大触发次数(默认undefined,无限触发;传数字限制次数)
repeatTrigger?: boolean; // 是否重复触发(默认false:触发一次锁定;true:离开底部后可再次触发)
onlyDownScroll?: boolean; // 是否仅向下滚动触发(默认true:仅向下滚到底部触发;false:忽略方向)
triggerInterval?: number; // 同一底部状态最小触发间隔(默认1000ms,避免高频重复触发)
}
/**
* 通用底部触发回调函数(兼容多容器+防抖+滚动方向+重复触发控制)
* 核心功能:滚动到底部(支持提前偏移)时执行回调,解决向上滚动重复触发问题
* @param callback - 到达底部时执行的回调函数(接收触发相关参数)
* @param options - 配置项(可选,支持多场景自定义)
* @param {number} options.offset - 提前触发偏移量(默认100px,值越大越早触发)
* @param {number} options.debounceDelay - 防抖延迟(默认150ms)
* @param {HTMLElement|null} options.scrollContainer - 滚动容器(默认window,支持自定义DOM)
* @param {number|null} options.maxTriggerTimes - 最大触发次数(默认null,无限触发)
* @param {boolean} options.repeatTrigger - 是否重复触发(默认false:触发一次后锁定,true:每次到达底部都触发)
* @param {boolean} options.onlyDownScroll - 是否仅向下滚动触发(默认true:仅向下滚动到底部触发,false:忽略方向)
* @param {number} options.triggerInterval - 同一底部状态下的最小触发间隔(默认1000ms,避免高频触发)
* @returns {Function} 取消监听的函数(用于组件卸载时销毁)
*/
export default function onReachBottom( callback: (params: ReachBottomCallbackParams) => void, options: OnReachBottomOptions = {}): () => void {
// 合并默认配置(未传配置时使用默认值,确保参数不undefined)
const {
offset = 100,
debounceDelay = 150,
scrollContainer = window, // 默认滚动容器为window(全局滚动)
maxTriggerTimes,
repeatTrigger = false,
onlyDownScroll = true,
triggerInterval = 1000
} = options;
// 状态管理变量(局部变量,避免全局污染)
let isTriggered = false; // 非重复模式下的触发锁定标记(触发一次后锁定)
let triggerCount = 0; // 已触发次数计数器
let debounceTimer: number | null = null; // 防抖定时器(浏览器环境返回number,修复飘红)
let lastTriggerTime = 0; // 上次触发时间(用于控制同一底部状态的触发间隔)
const isWindow = scrollContainer === window; // 判断是否是window全局滚动容器
let lastScrollTop = getScrollTop(); // 上一次滚动位置(用于判断滚动方向)
let isInBottom = false; // 当前是否处于底部状态(避免同一底部连续触发)
/**
* 获取当前滚动位置(兼容window和自定义容器,修复Safari浏览器兼容问题)
* @returns 滚动距离顶部的像素值(number类型,确保非undefined)
*/
function getScrollTop(): number {
// 全局滚动容器(window):优先取pageYOffset,兼容所有浏览器
if (isWindow) {
return window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop || 0; // 兜底0,避免返回undefined
}
// 自定义容器:断言为HTMLElement(已限制入参类型,断言安全),获取scrollTop
return (scrollContainer as HTMLElement).scrollTop;
};
/**
* 计算容器有效高度(包含padding,解决自定义容器padding导致的尺寸偏差)
* @returns 容器实际可滚动可视高度(包含内边距)
*/
const getClientHeight = (): number => {
// 全局容器:取窗口可视高度,兼容不同浏览器
if (isWindow) {
return window.innerHeight
|| document.documentElement.clientHeight
|| 0; // 兜底0
}
// 自定义容器:获取元素样式,计算paddingTop+paddingBottom(clientHeight不含padding)
const element = scrollContainer as HTMLElement;
const style = window.getComputedStyle(element); // 获取计算后样式(含单位转换)
const paddingTop = parseFloat(style.paddingTop) || 0; // 解析paddingTop(默认0)
const paddingBottom = parseFloat(style.paddingBottom) || 0; // 解析paddingBottom(默认0)
// 有效高度 = 容器可视高度 + 上下内边距(确保滚动高度计算准确)
return element.clientHeight + paddingTop + paddingBottom;
};
/**
* 判断是否到达容器底部(核心判断逻辑,兼容所有容器和浏览器)
* @returns 是否到达底部(true:已到达/超过;false:未到达)
*/
const isReachBottom = (): boolean => {
let scrollTop: number; // 滚动距离顶部的距离
let scrollHeight: number; // 容器内容总高度(包含不可见部分)
let clientHeight: number; // 容器可视高度(含padding)
// 全局滚动容器:计算页面总高度(取最大高度值,避免浏览器差异)
if (isWindow) {
scrollTop = getScrollTop();
scrollHeight = Math.max(
document.body.scrollHeight, // body滚动高度
document.documentElement.scrollHeight // html滚动高度
);
clientHeight = getClientHeight(); // 窗口可视高度
} else {
// 自定义容器:直接获取元素的滚动相关属性(已断言为HTMLElement)
const element = scrollContainer as HTMLElement;
scrollTop = element.scrollTop;
scrollHeight = element.scrollHeight; // 元素内容总高度
clientHeight = getClientHeight(); // 元素可视高度(含padding)
}
// 核心判断公式:滚动位置 + 可视高度 + 提前偏移 ≥ 内容总高度 - 1px容差
// 减1px是为了避免浮点精度问题(比如滚动高度为小数时判断失效)
return scrollTop + clientHeight + offset >= scrollHeight - 1;
};
/**
* 判断滚动方向(先更新上一次滚动位置,再判断当前方向,避免方向误判)
* @returns 滚动方向('up':向上滚;'down':向下滚)
*/
const getScrollDirection = (): 'up' | 'down' => {
const currentScrollTop = getScrollTop(); // 获取当前滚动位置
const direction = currentScrollTop > lastScrollTop ? 'down' : 'up'; // 对比上一次位置判断方向
lastScrollTop = currentScrollTop; // 更新上一次位置(为下一次判断做准备)
return direction;
};
/**
* 滚动事件核心处理函数(整合方向判断、底部判断、防抖、触发控制)
*/
const handleScroll = (): void => {
// 1. 触发次数达上限:直接返回,不再执行后续逻辑
if (maxTriggerTimes !== undefined && triggerCount >= maxTriggerTimes) return;
// 2. 获取关键状态:滚动方向 + 是否到达底部 + 上一次底部状态
const direction = getScrollDirection(); // 当前滚动方向
const reachedBottom = isReachBottom(); // 当前是否到达底部
const wasInBottom = isInBottom; // 上一次是否处于底部状态(用于判断状态变化)
isInBottom = reachedBottom; // 更新当前底部状态
// 3. 未到达底部:解锁非重复模式的触发标记(让用户下次滚到底部可再次触发)
if (!reachedBottom) {
if (repeatTrigger) isTriggered = false; // 仅重复模式需要解锁
return;
}
// 4. 仅向下滚动触发:如果方向不是向下,直接返回(忽略向上滚动触发)
if (onlyDownScroll && direction !== 'down') return;
// 5. 非重复模式已触发:如果已触发过且未解锁,直接返回(避免重复触发)
if (isTriggered && !repeatTrigger) return;
// 6. 同一底部状态间隔控制:如果还在底部且未到间隔时间,直接返回(避免高频触发)
const now = Date.now(); // 当前时间戳
if (wasInBottom && now - lastTriggerTime < triggerInterval) return;
// 7. 同一底部状态过滤:如果之前已经在底部,直接返回(仅从非底部→底部时触发)
if (wasInBottom) return;
// 8. 防抖执行回调:清理之前的定时器,延迟执行回调(防止滚动高频触发)
if (debounceTimer) clearTimeout(debounceTimer); // 清除旧定时器,避免多次触发
// 浏览器环境用window.setTimeout(返回number类型,修复TS飘红)
debounceTimer = window.setTimeout(() => {
lastTriggerTime = now; // 更新上次触发时间(用于间隔控制)
triggerCount++; // 触发次数累加
// 执行用户传入的回调函数,传递触发相关参数
callback({
triggerCount, // 当前触发次数
isLastTrigger: maxTriggerTimes !== undefined && triggerCount === maxTriggerTimes, // 是否最后一次触发
scrollDirection: direction // 触发时的滚动方向
});
// 非重复模式:触发后锁定(避免同一底部状态重复触发)
if (!repeatTrigger) isTriggered = true;
}, debounceDelay); // 防抖延迟时间(用户可配置)
};
/**
* 绑定滚动相关事件(兼容移动端+全局/自定义容器)
*/
// 初始化需要绑定的事件类型(默认绑定scroll事件)
const eventTypes: ('scroll' | 'touchmove' | 'resize')[] = ['scroll'];
// 仅全局容器(window)需要额外绑定:touchmove(移动端触摸滚动)、resize(窗口大小变化)
if (isWindow) {
if ('ontouchstart' in window) eventTypes.push('touchmove'); // 检测到移动端环境,绑定touchmove
eventTypes.push('resize'); // 窗口大小变化时,重新计算容器高度,避免判断失效
}
// 为滚动容器绑定所有需要的事件(passive: true提升移动端滚动性能)
eventTypes.forEach(type => {
scrollContainer.addEventListener(type, handleScroll, {
passive: true, // 告诉浏览器该事件不会阻止默认行为,提升滚动流畅度
capture: false // 不使用事件捕获,使用冒泡阶段触发(符合常规滚动事件逻辑)
});
});
/**
* 取消监听函数(外部调用,用于销毁事件绑定和定时器)
* 必须在组件卸载/不需要时调用,防止内存泄漏(事件绑定未移除导致的性能问题)
*/
return function cancelOnReachBottom(): void {
// 移除所有绑定的事件(与绑定的事件类型、处理函数、capture保持一致)
eventTypes.forEach(type => {
// removeEventListener不支持passive选项,仅需匹配capture(与add时一致为false)
scrollContainer.removeEventListener(type, handleScroll, false);
});
// 清除防抖定时器(避免定时器延迟执行导致的回调误触发)
if (debounceTimer) clearTimeout(debounceTimer);
};
}使用方法
// 导入触底监听函数
import reachBottomCallback from '@/utils/reachBottomCallback';
import { reactive, onMounted, onUnmounted } from 'vue';
// 配置文件
const setting = reactive({
// 接收onReachBottom返回的函数,用于销毁监听事件,防止内存泄漏
removeReachBottomCallback: ()=>{},
})
// 编写触底业务逻辑
const handle = (msg) =>{
console.log(`第${msg.triggerCount}次触发,加载更多数据`,msg);
}
// 组件挂在完成后执行
onMounted(()=>{
// 接收onReachBottom返回的函数,用于销毁监听事件,防止内存泄漏
setting.removeReachBottomCallback = reachBottomCallback(handle, { repeatTrigger: true });
});
// 组件卸载完成后执行
onUnmounted(()=>{
// 销毁监听事件,防止内存泄漏
setting.removeReachBottomCallback();
})TypeScript Class类
函数封装
/**
* 回调函数参数类型定义(明确回调接收的参数结构)
*/
export interface ReachBottomCallbackParams {
triggerCount: number; // 已触发次数(从1开始累加)
isLastTrigger: boolean; // 是否是最后一次触发(基于maxTriggerTimes配置)
scrollDirection: 'up' | 'down'; // 触发时的滚动方向
}
/**
* 函数配置项类型定义(严格限制入参类型,避免错误传参)
*/
export interface OnReachBottomOptions {
offset?: number; // 提前触发偏移量(默认100px,值越大越早触发,支持负数延后触发)
debounceDelay?: number; // 防抖延迟(默认150ms,防止滚动时高频触发)
scrollContainer?: HTMLElement | Window; // 滚动容器(默认window,支持自定义滚动DOM)
maxTriggerTimes?: number; // 最大触发次数(默认undefined,无限触发;传数字限制次数)
repeatTrigger?: boolean; // 是否重复触发(默认false:触发一次锁定;true:离开底部后可再次触发)
onlyDownScroll?: boolean; // 是否仅向下滚动触发(默认true:仅向下滚到底部触发;false:忽略方向)
triggerInterval?: number; // 同一底部状态最小触发间隔(默认1000ms,避免高频重复触发)
}
class reachBottom {
/* 配置项(实例属性,合并默认配置后存储) */
// 提前触发偏移量(默认100px,值越大越早触发,支持负数延后触发)
private offset: number = 100;
// 防抖延迟(默认150ms,防止滚动时高频触发)
private debounceDelay: number = 150;
// 滚动容器(默认window,支持自定义滚动DOM)
private scrollContainer: HTMLElement | Window = window;
// 最大触发次数(默认undefined,无限触发;传数字限制次数)
private maxTriggerTimes: number | undefined = undefined;
// 是否重复触发(默认false:触发一次锁定;true:离开底部后可再次触发)
private repeatTrigger: boolean = false;
// 是否仅向下滚动触发(默认true:仅向下滚到底部触发;false:忽略方向)
private onlyDownScroll: boolean = true;
// 同一底部状态最小触发间隔(默认1000ms,避免高频重复触发)
private triggerInterval: number = 1000;
// 非重复模式下的触发锁定标记
private isTriggered: boolean = false;
// 已触发次数计数器
private triggerCount: number = 0;
// 防抖定时器
private debounceTimer: number | null = null;
// 上次触发时间(用于间隔控制)
private lastTriggerTime: number = 0;
// 是否是window全局滚动容器
private isWindow: boolean = true;
// 上一次滚动位置(用于判断方向)
private lastScrollTop: number = 0;
// 当前是否处于底部状态
private isInBottom: boolean = false;
// 初始化需要绑定的事件类型(默认绑定scroll事件)
private eventTypes: ('scroll' | 'touchmove' | 'resize')[] = ['scroll'];
// 保存用户回调函数 【用户传入的回调函数】
private callback: ( res: ReachBottomCallbackParams )=> void = ()=> {};
constructor(){ }
/**
* 监听器【通用底部触发回调函数(兼容+防抖+滚动方向+多场景扩展)】
* @param {Function} callback - 到达底部时执行的回调函数
* @param {Object} options - 配置项(可选)
* @param {number} options.offset - 提前触发偏移量(默认100px,值越大越早触发)
* @param {number} options.debounceDelay - 防抖延迟(默认150ms)
* @param {HTMLElement|null} options.scrollContainer - 滚动容器(默认window,支持自定义DOM)
* @param {number|null} options.maxTriggerTimes - 最大触发次数(默认null,无限触发)
* @param {boolean} options.repeatTrigger - 是否重复触发(默认false:触发一次后锁定,true:每次到达底部都触发)
* @param {boolean} options.onlyDownScroll - 是否仅向下滚动触发(默认true:仅向下滚动到底部触发,false:忽略方向)
* @param {number} options.triggerInterval - 同一底部状态下的最小触发间隔(默认1000ms,避免高频触发)
*/
Listener(callback: (params: ReachBottomCallbackParams) => void, options: OnReachBottomOptions = {}){
/*************** 初始化基础状态 ***************/
// 合并参数
const optionsResult = {
// 偏移量
offset: this.offset,
// 防抖延迟
debounceDelay: this.debounceDelay,
// 监听的对象
scrollContainer: this.scrollContainer,
// 最大触发次数
maxTriggerTimes: this.maxTriggerTimes,
// 否重复触发
repeatTrigger: this.repeatTrigger,
// 是否仅向下滚动触发
onlyDownScroll: this.onlyDownScroll,
// 同一底部状态最小触发间隔
triggerInterval: this.triggerInterval,
// 用户配置项
...options,
};
// 保存用户传递的回调函数
this.callback = callback;
// 是否是window全局滚动容器
options.scrollContainer && (this.isWindow = options.scrollContainer === window);
// 提前触发偏移量(默认100px,值越大越早触发,支持负数延后触发)
this.offset = optionsResult.offset
// 防抖延迟(默认150ms,防止滚动时高频触发)
this.debounceDelay = optionsResult.debounceDelay;
// 滚动容器(默认window,支持自定义滚动DOM)
this.scrollContainer = optionsResult.scrollContainer;
// 最大触发次数(默认undefined,无限触发;传数字限制次数)
this.maxTriggerTimes = optionsResult.maxTriggerTimes;
// 是否重复触发(默认false:触发一次锁定;true:离开底部后可再次触发)
this.repeatTrigger = optionsResult.repeatTrigger;
// 是否仅向下滚动触发(默认true:仅向下滚到底部触发;false:忽略方向)
this.onlyDownScroll = optionsResult.onlyDownScroll;
// 同一底部状态最小触发间隔(默认1000ms,避免高频重复触发)
this.triggerInterval = optionsResult.triggerInterval;
// 上一次滚动位置(用于判断滚动方向)
this.lastScrollTop = this.getScrollTop();
// 仅全局容器(window)需要额外绑定:touchmove(移动端触摸滚动)、resize(窗口大小变化)
if (this.isWindow) {
if ('ontouchstart' in window) this.eventTypes.push('touchmove'); // 检测到移动端环境,绑定touchmove
this.eventTypes.push('resize'); // 窗口大小变化时,重新计算容器高度,避免判断失效
}
// 监听事件
this.addListener();
}
// 添加监听事件 【为滚动容器绑定所有需要的事件(passive: true提升移动端滚动性能)】
private addListener(){
// 为滚动容器绑定所有需要的事件(passive: true提升移动端滚动性能)
this.eventTypes.forEach(type => {
this.scrollContainer.addEventListener(type, this.handleScroll, {
passive: true, // 告诉浏览器该事件不会阻止默认行为,提升滚动流畅度
capture: false // 不使用事件捕获,使用冒泡阶段触发(符合常规滚动事件逻辑)
});
});
}
/**
* 滚动事件核心处理函数(整合方向判断、底部判断、防抖、触发控制)
*/
private handleScroll = (): void => {
// 1. 触发次数达上限:直接返回,不再执行后续逻辑
if (this.maxTriggerTimes !== undefined && this.triggerCount >= this.maxTriggerTimes) return;
// 2. 获取关键状态:滚动方向 + 是否到达底部 + 上一次底部状态
const direction = this.getScrollDirection(); // 当前滚动方向
const reachedBottom = this.isReachBottom(); // 当前是否到达底部
const wasInBottom = this.isInBottom; // 上一次是否处于底部状态(用于判断状态变化)
this.isInBottom = reachedBottom; // 更新当前底部状态
// 3. 未到达底部:解锁非重复模式的触发标记(让用户下次滚到底部可再次触发)
if (!reachedBottom) {
if (this.repeatTrigger) this.isTriggered = false; // 仅重复模式需要解锁
return;
}
// 4. 仅向下滚动触发:如果方向不是向下,直接返回(忽略向上滚动触发)
if (this.onlyDownScroll && direction !== 'down') return;
// 5. 非重复模式已触发:如果已触发过且未解锁,直接返回(避免重复触发)
if (this.isTriggered && !this.repeatTrigger) return;
// 6. 同一底部状态间隔控制:如果还在底部且未到间隔时间,直接返回(避免高频触发)
const now = Date.now(); // 当前时间戳
if (wasInBottom && now - this.lastTriggerTime < this.triggerInterval) return;
// 7. 同一底部状态过滤:如果之前已经在底部,直接返回(仅从非底部→底部时触发)关联底部状态和方向过滤【this.onlyDownScroll】,仅开启onlyDownScroll时拦截底部状态内滚动
if (wasInBottom && this.onlyDownScroll) return;
// 8. 防抖执行回调:清理之前的定时器,延迟执行回调(防止滚动高频触发)
if (this.debounceTimer) clearTimeout(this.debounceTimer); // 清除旧定时器,避免多次触发
// 浏览器环境用window.setTimeout(返回number类型,修复TS飘红)
this.debounceTimer = window.setTimeout(() => {
this.lastTriggerTime = now; // 更新上次触发时间(用于间隔控制)
this.triggerCount++; // 触发次数累加
// 执行用户传入的回调函数,传递触发相关参数
this.callback({
triggerCount: this.triggerCount, // 当前触发次数
isLastTrigger: this.maxTriggerTimes !== undefined && this.triggerCount === this.maxTriggerTimes, // 是否最后一次触发
scrollDirection: direction // 触发时的滚动方向
});
// 非重复模式:触发后锁定(避免同一底部状态重复触发)
if (!this.repeatTrigger) this.isTriggered = true;
}, this.debounceDelay); // 防抖延迟时间(用户可配置)
};
/**
* 获取当前滚动位置(兼容window和自定义容器,修复Safari浏览器兼容问题)
* @returns 滚动距离顶部的像素值(number类型,确保非undefined)
*/
private getScrollTop(): number {
// 全局滚动容器(window):优先取pageYOffset,兼容所有浏览器
if (this.isWindow) return window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop || 0; // 兜底0,避免返回undefined
// 自定义容器:断言为HTMLElement(已限制入参类型,断言安全),获取scrollTop
return (this.scrollContainer as HTMLElement).scrollTop;
};
/**
* 判断滚动方向(先更新上一次滚动位置,再判断当前方向,避免方向误判)
* @returns 滚动方向('up':向上滚;'down':向下滚)
*/
private getScrollDirection (): 'up' | 'down' {
const currentScrollTop = this.getScrollTop(); // 获取当前滚动位置
const direction = currentScrollTop > this.lastScrollTop ? 'down' : 'up'; // 对比上一次位置判断方向
this.lastScrollTop = currentScrollTop; // 更新上一次位置(为下一次判断做准备)
return direction;
};
/**
* 判断是否到达容器底部(核心判断逻辑,兼容所有容器和浏览器)
* @returns 是否到达底部(true:已到达/超过;false:未到达)
*/
private isReachBottom (): boolean {
let scrollTop: number; // 滚动距离顶部的距离
let scrollHeight: number; // 容器内容总高度(包含不可见部分)
let clientHeight: number; // 容器可视高度(含padding)
// 全局滚动容器:计算页面总高度(取最大高度值,避免浏览器差异)
if (this.isWindow) {
scrollTop = this.getScrollTop();
scrollHeight = Math.max(
document.body.scrollHeight, // body滚动高度
document.documentElement.scrollHeight // html滚动高度
);
clientHeight = this.getClientHeight(); // 窗口可视高度
} else {
// 自定义容器:直接获取元素的滚动相关属性(已断言为HTMLElement)
const element = this.scrollContainer as HTMLElement;
scrollTop = element.scrollTop;
scrollHeight = element.scrollHeight; // 元素内容总高度
clientHeight = this.getClientHeight(); // 元素可视高度(含padding)
}
// 核心判断公式:滚动位置 + 可视高度 + 提前偏移 ≥ 内容总高度 - 1px容差
// 减1px是为了避免浮点精度问题(比如滚动高度为小数时判断失效)
return scrollTop + clientHeight + this.offset >= scrollHeight - 1;
};
/**
* 计算容器有效高度(包含padding,解决自定义容器padding导致的尺寸偏差)
* @returns 容器实际可滚动可视高度(包含内边距)
*/
private getClientHeight (): number {
// 全局容器:取窗口可视高度,兼容不同浏览器
if (this.isWindow) return window.innerHeight || document.documentElement.clientHeight || 0; // 兜底0
// 自定义容器:获取元素样式,计算paddingTop+paddingBottom(clientHeight不含padding)
const element = this.scrollContainer as HTMLElement;
const style = window.getComputedStyle(element); // 获取计算后样式(含单位转换)
const paddingTop = parseFloat(style.paddingTop) || 0; // 解析paddingTop(默认0)
const paddingBottom = parseFloat(style.paddingBottom) || 0; // 解析paddingBottom(默认0)
// 有效高度 = 容器可视高度 + 上下内边距(确保滚动高度计算准确)
return element.clientHeight + paddingTop + paddingBottom;
};
/**
* 取消监听函数(外部调用,用于销毁事件绑定和定时器)
* 必须在组件卸载/不需要时调用,防止内存泄漏(事件绑定未移除导致的性能问题)
*/
destroy(): void {
// 移除所有绑定的事件(与绑定的事件类型、处理函数、capture保持一致)
this.eventTypes.forEach(type => {
// removeEventListener不支持passive选项,仅需匹配capture(与add时一致为false)
this.scrollContainer.removeEventListener(type, this.handleScroll, false);
});
// 清除防抖定时器(避免定时器延迟执行导致的回调误触发)
if (this.debounceTimer) clearTimeout(this.debounceTimer);
/* 恢复默认值 */
// 非重复模式下的触发锁定标记
this.isTriggered = false;
// 已触发次数计数器
this.triggerCount = 0;
// 防抖定时器
this.debounceTimer = null;
// 上次触发时间(用于间隔控制)
this.lastTriggerTime = 0;
// 是否是window全局滚动容器
this.isWindow = true;
// 上一次滚动位置(用于判断方向)
this.lastScrollTop = 0;
// 当前是否处于底部状态
this.isInBottom = false;
}
}
// 实例化类并暴露方法
export default new reachBottom();使用方法
// 导入触底监听函数
import onReachBottom from '@/app/reachBottom';
import { onMounted, onUnmounted } from 'vue';
// 编写触底业务逻辑
const handle = (msg) =>{
console.log(`第${msg.triggerCount}次触发,加载更多数据`,msg);
}
onMounted(()=>{
onReachBottom.Listener(handle,
{ scrollContainer: container.value, repeatTrigger: true, offset: 150, onlyDownScroll: false, maxTriggerTimes: 10 }
// { scrollContainer: window, repeatTrigger: true, offset: 100, onlyDownScroll: false, maxTriggerTimes: 100}
);
})
// 组件销毁完成后触发
onUnmounted(()=>{
// 销毁触底事件监听
onReachBottom.destroy();
})dom元素拖动Fixed定位 (支持PC+移动端)
函数封装
type PARAMETER = (parameter: {
// 触发事件的类型
eventType: string;
// 是否是移动端
isMobile: string;
// 当前移动的状态
state: string;
// 当前移动中X轴的坐标
left?: number;
// 当前移动中Y轴的坐标
top?: number;
// 边界判断
boundary?: {
// 是否到达最顶部
isTop: boolean,
// 是否到达最下部
isBottom: boolean,
// 是否到达最左边
isLeft: boolean,
// 是否到达最右边
isRight: boolean,
};
}) => void;
class coordinates {
// dom元素的宽度以及高度
private domOffsetWidth: number = 0;
private domOffsetHeight: number = 0;
// 点击dom元素内的坐标
private offsetX: number = 0;
private offsetY: number = 0;
// 用于映射事件
private map: Map<string, any> = new Map();
// 滑动的元素
private htmlDom: any;
constructor(){ }
/**
* 监听事件
* @param callback 回调函数,返回当前移动的状态以及坐标信息; 如:(res)=>{ console.log(res) };
* @returns void
*/
change(callback?: PARAMETER): void {
// 鼠标松开时触发
const mouseup = (e: any) =>{
// 阻止浏览器默认行为
e.preventDefault();
// 阻止事件冒泡
e.stopPropagation();
// e.type.includes("touch") true表示【移动端】, false表示【电脑端】
const events = e.type.includes("touch") ? ['touchmove', 'touchend'] : ['mousemove', 'mouseup'];
// 移除document事件
events.forEach((eventKey: string)=> document.removeEventListener(eventKey, this.map.get(eventKey)));
/** ------ mouseup中的四个边界判断 ------ */
const playerRect = this.htmlDom!.getBoundingClientRect(); // 获取最终位置
// 1. 判断是否到达最左边(左边缘 ≤ 0)
const isAtLeftEdge = Math.round(playerRect.left) <= 0;
// 2. 判断是否到达最右边(右边缘 ≥ 可视区宽度)
const isAtRightEdge = Math.round(playerRect.right) >= window.innerWidth;
// 3. 判断是否到达最上边(上边缘 ≤ 0)
const isAtTopEdge = Math.round(playerRect.top) <= 0;
// 4. 判断是否到达最下边(下边缘 ≥ 可视区高度)
const isAtBottomEdge = Math.round(playerRect.bottom) >= window.innerHeight;
// 触发的事件类型
const eventType = e.type.includes("touch") ? 'touchend' : 'mouseup';
// 为true表示到达最边界的位置
callback && callback({
// 触发事件的类型
eventType,
// 是否是移动端
isMobile: e.type.includes("touch"),
// 当前移动的状态
state: 'end',
// 边界判断
boundary:{
// 是否到达最顶部
isTop: isAtTopEdge,
// 是否到达最下部
isBottom: isAtBottomEdge,
// 是否到达最左边
isLeft: isAtLeftEdge,
// 是否到达最右边
isRight: isAtRightEdge,
}
});
}
// 鼠标在元素内移动时实时触发
const mousemove = (e: any) =>{
// 阻止浏览器默认行为
e.preventDefault();
// 阻止事件冒泡
e.stopPropagation();
// 兼容鼠标和触摸事件的坐标获取
const clientX = e.type.includes("touch") ? e.touches[0].clientX : e.clientX;
const clientY = e.type.includes("touch") ? e.touches[0].clientY : e.clientY;
// 移动元素的实时坐标
const x = clientX - this.offsetX;
const y = clientY - this.offsetY;
// 限制播放器在可视区域内(最大边界限制)
const maxX = window.innerWidth - this.domOffsetWidth;
const maxY = window.innerHeight - this.domOffsetHeight;
// 相当于判断边界,不允许超过最大边界
const domX = Math.max(0, Math.min(x, maxX));
const domY = Math.max(0, Math.min(y, maxY));
// 触发的事件类型
const eventType = e.type.includes("touch") ? 'touchmove' : 'mousemove';
// 实时返回坐标数据
callback && callback({
// 触发事件的类型
eventType,
// 是否是移动端
isMobile: e.type.includes("touch"),
// 当前移动的状态
state: 'move',
// 当前移动中X轴的坐标
left: domX,
// 当前移动中Y轴的坐标
top: domY
})
}
// 映射事件到map中
// 电脑端
this.map.set("mousemove", mousemove);
this.map.set("mouseup", mouseup);
// 手机端
this.map.set("touchmove", mousemove);
this.map.set("touchend", mouseup);
}
/**
* 鼠标按下事件
* @param e 事件对象【MouseEvent | TouchEvent】
* @returns void
*/
mousedown(e: any): void{
// 阻止事件冒泡
e.stopPropagation();
// 判断,只有自定义属性slide且值为audio的元素才执行以下代码
if(e.target.dataset.slide !== 'audio') return;
// e.type.includes("touch") true表示【移动端】, false表示【电脑端】
const events = e.type.includes("touch") ? ['touchmove', 'touchend'] : ['mousemove', 'mouseup'];
// 添加事件
events.forEach((eventKey: string)=> document.addEventListener(eventKey, this.map.get(eventKey), { passive: false }));
// 保存当前点击的元素
this.htmlDom = e.target;
// 获取当前鼠标点击的坐标
const clientX = e.type.includes("touch") ? e.touches[0].clientX : e.clientX;
const clientY = e.type.includes("touch") ? e.touches[0].clientY : e.clientY;
// 获取鼠标相对于播放器的偏移量
this.offsetX = clientX - e.target.getBoundingClientRect().left;
this.offsetY = clientY - e.target.getBoundingClientRect().top;
// 当前移动元素的高度以及宽度
this.domOffsetWidth = e.target.offsetWidth;
this.domOffsetHeight = e.target.offsetHeight;
}
}
// 暴露数据
export default new coordinates();使用方法
关于事件冒泡的问题
由于使用mousedown和touchstart,阻止事件冒泡必须是相同事件类型才行,不同事件类型不能阻止冒泡行为。
例如:需要移动的dom元素绑定的mousedown事件,但dom元素下的子元素需要click...等等事件,必需要阻止冒泡行为,不能写click.stop,因为事件类型不同,阻止不了。
解决方法【1】:dom元素下需要绑定其它事件是,加上@mousedown.stop @touchstart.stop两个事件即可解决。
解决方法【2】:在class对象的mousedown方法加入判断,比如判断自定义属性,假如属性名为slide,属性值为:audioBox的才能执行后续代码,否则直接return退出。
<template>
<!-- 分别添加mousedown和touchstart事件【要是PC和移动都需要支持,两者必需都绑定】,mousedown必需传入事件对象, 如果【movingCoordinates.mousedown】这样写,可省略-->
<div @mousedown.stop="movingCoordinates.mousedown" @touchstart.stop="movingCoordinates.mousedown"></div>
</template>
<script setup lang='ts'>
// 导入class对象
import movingCoordinates from './movingCoordinates';
movingCoordinates.change((result)=>{
// 元素拖动后的坐标,拖动时实时返回最新坐标信息
console.log(result);
})
</script>获取音频总时长
/**
* 获取音频总时长
* @param {string} url - 音频文件的 URL 地址
* @param {number} timeout - 超时时间,单位毫秒,默认 15000ms
* @returns {Promise<number>} - 返回一个 Promise,成功时 resolve 音频总时长(秒),失败时 reject 错误信息
*/
function getAudioDuration(url: string, timeout = 15000): Promise<number> {
return new Promise((resolve, reject) => {
// 创建临时 audio 元素,不插入 DOM,仅用于读取元数据
const audio = document.createElement('audio');
// 用于存储定时器的引用,以便在需要时清除
let timer: ReturnType<typeof setTimeout>;
/**
* 统一清理函数:移除所有事件监听、清除定时器、释放音频资源
* 无论成功、失败还是超时都必须调用,防止内存泄漏
*/
const cleanup = () => {
// 清除定时器
clearTimeout(timer);
// 移除监听事件
['loadedmetadata', 'durationchange'].forEach((key: string)=> audio.removeEventListener(key, onLoaded));
// 移除错误事件监听
audio.removeEventListener('error', onError);
// 暂停音频播放,确保不会继续下载或播放
audio.pause();
// 释放音频资源,避免内存泄漏
audio.removeAttribute('src');
// 强制中止可能仍在进行的网络请求
audio.load();
};
/**
* 元数据加载完成 / 时长变更时的回调
* 部分浏览器 loadedmetadata 触发时 duration 可能为 Infinity 或 NaN,
* 此时不 resolve,继续等待 durationchange 事件或超时
*/
const onLoaded = () => {
// 获取音频时长
const duration = audio.duration;
// 仅当时长为有效有限数且非负时才认为获取成功
Number.isFinite(duration) && duration > 0 && (cleanup(), resolve(duration));
};
/**
* 音频加载出错时的回调(404、CORS、格式不支持等)
*/
const onError = () => (cleanup(), reject(new Error('音频加载失败')));
// 仅预加载元数据,避免下载完整音频文件
audio.preload = 'metadata';
// 绑定事件监听
['loadedmetadata', 'durationchange'].forEach((key: string)=> audio.addEventListener(key, onLoaded));
// 绑定错误事件监听
audio.addEventListener('error', onError);
// 设置超时保护,防止音频源无响应导致 Promise 永远 pending
timer = setTimeout(() => (cleanup(), reject(new Error('获取音频总时长超时'))), timeout);
// 设置源并触发加载
audio.src = url;
// 在某些浏览器中,load() 方法可能不会立即触发 loadedmetadata 事件,因此手动调用 load() 以确保加载开始
audio.load();
});
}音频功能封装
Class类封装
interface initOptions {
// 音频元素或音频对象
audio: HTMLAudioElement;
// 音频地址链接的字段名
srcKey?: string;
// 当前播放的音频链接
currentPlayAudio?: { [key: string]: any };
// 音频列表集合
audioList?: Array<{[key: string]: any}>;
// 回调函数中返回的监听事件
listeners?: {
onLoad?: (res: { totalTime: number; currentTime: number; [key: string]: any }) => void;
onTimeupdate?: (res: { totalTime: number; currentTime: number; cacheProgress: number; cacheSecond: number, currentTimeProgress: number }) => void;
onPlayStatus?: (res: { playStatus: boolean, msg: string }) => void;
onBuffer?: (res: { loading: boolean; msg: string }) => void;
onRatechange?: (res: { playbackRate: number }) => void;
onVolumechange?: (res: { volume: number, isMute: boolean }) => void;
onEnded?: (controls: {
loop: () => void;
last: () => void;
next: () => void;
random: () => void;
}) => void;
onError?: (error: { status: number, errorMsg: string })=> void;
};
}
class Audio {
// 音频的html元素
private $audio: HTMLAudioElement = null as unknown as HTMLAudioElement;
// 音频地址链接的字段名
#srcKey: string = "";
// 音频列表集合
private $audioList: Array<any> = [];
// 当前播放的音频链接
private $currentPlayAudio: any = null;
// 判断是否重复执行
#currentTimeFlag: number = 0;
// 用于保存当前的监听事件集
#listenerMap: Map<string, any> = new Map();
// 保存用户传递的回调函数
#callback: initOptions['listeners'];
// 自定义事件名对应的audio的事件类型【监听事件】
#eventsKeyName: {[key: string]: string | string[]} = {
// 初始化加载完成后触发该函数,返回当前音频的总时间,名称等参数 (每当音频路径发生改变时都会触发,并返回最新的音频数据等信息)
onLoad: 'loadedmetadata',
// 视频缓冲中/缓冲完成执行该函数(监听视频正在缓冲资源)
onTimeupdate: "timeupdate",
// 音频播放开始时触发(包括暂停后继续播放) 暂停时也会触发
onPlayStatus: ['play', 'pause'],
// 播放进度改变时触发
onBuffer: ['waiting', 'canplay'],
// 播放倍数改变时触发
onRatechange: 'ratechange',
// 音频音量发生改变时触发【静音和音量发生改变时都会触发,静音和音量是两个完全独立的状态】
onVolumechange: 'volumechange',
// 当前音频播放结束时触发该函数
onEnded: 'ended',
// 播放错误触发,返回常见错误:文件不存在(404)、格式不支持、跨域限制
onError: 'error'
}
constructor() {
// 初始化映射事件到map中
this.#mappingEvents();
}
/** 初始化函数 */
init(options: initOptions): void {
// 音频字段名称未指向
if (!options.hasOwnProperty("srcKey")) return console.log(new Error("未指向字段名称,请先指向音频地址值的字段名称"));
// 音频字段名称类型错误
if (typeof options.srcKey !== "string") return console.log(new Error("字段名称类型错误,请确保字段名称为字符串类型"));
// 保存音频字段名
if(options.srcKey) this.#srcKey = options.srcKey;
// 保持当前的aduio html元素或audio对象 【如果this.$audio存在音乐对象,则没必要重新配置】
if(options.audio && !this.$audio) this.$audio = options.audio;
// 仅预加载元数据(时长、格式等);
this.$audio.preload = 'metadata';
this.$audio.setAttribute('preload', 'metadata'); // 设置预加载模式【双重保障】
/** iOS 专用属性 */
// 使视频(或音频)在页面内播放(而不是全屏播放)。对于音频来说,这个属性主要是为了确保音频在页面内播放而不被系统接管。
this.$audio.setAttribute('playsinline', 'true');
// webkit-playsinline是旧版WebKit内核的前缀版本,为了兼容旧版iOS
this.$audio.setAttribute('webkit-playsinline', 'true');
// 允许通过AirPlay将音频(或视频)投射到其他Apple设备上播放。设置为allow表示允许AirPlay
this.$audio.setAttribute('x-webkit-airplay', 'allow');
// 判断当前播放的音频对象是否存在
if(options.currentPlayAudio) {
// 当前播放的音频对象错误
if(options.currentPlayAudio == null || typeof options.currentPlayAudio !== "object") return console.log(new Error("当前播放的音频对象错误,请确保传入值的类型为object"));
// 音频字段名称指向错误
if (!options.currentPlayAudio.hasOwnProperty(options.srcKey)) return console.log(new Error("该字段不存在,请重新指向音频地址值的字段名称"));
// 初始化路径配置项未指向
if (!options.currentPlayAudio[this.#srcKey]) return console.log(new Error("未指向当前播放的路径,应初始化时指向初始化路径"));
// 当前播放的音频链接
this.$currentPlayAudio = options.currentPlayAudio;
// 设置没默认播放链接
this.$audio.src = options.currentPlayAudio[this.#srcKey];
}
// 保存当前音频列表
options.audioList && Array.isArray(options.audioList) && (this.$audioList = options.audioList);
// 保存配置的监听对象,方便后续调用返回数据
options.listeners && (this.#callback = options.listeners);
// 开启监听事件
this.listeners(this.#callback);
}
// 开启监听器
listeners(callback: initOptions['listeners']){
// 保存配置项
this.#callback = callback;
// 如果this.$audio值为空,表示audio还没有创建或没有获取到
if(!this.$audio || this.#callback === undefined) return;
// 移除旧监听器
this.destroy();
// 筛选出已配置的函数名
const eventKey = Object.keys(callback!);
// 转换audio的真实事件名
const events = eventKey.reduce((event: any, item)=> Array.isArray(this.#eventsKeyName[item]) ? [...event, ...this.#eventsKeyName[item]] : [...event, this.#eventsKeyName[item]], [])
// 循环添加已配置的监听事件 (在Map中获取)
for (const item of events) {
// 添加audio相关事件
this.$audio.addEventListener(item, this.#listenerMap.get(item));
}
}
/** 销毁监听器 */
destroy(options?: Array<'onLoad' | 'onTimeupdate' | 'onPlayStatus' | 'onBuffer' | 'onRatechange' | 'onVolumechange' | 'onEnded' | 'onError'>): void {
// 判断是否指定注销某一个或多个监听器
if(options && Array.isArray(options) && options.length >= 1){
// 转换audio的真实事件名
const events = options.reduce((event: any, item)=> Array.isArray(this.#eventsKeyName[item]) ? [...event, ...this.#eventsKeyName[item]] : [...event, this.#eventsKeyName[item]], [])
// 遍历销毁chang监听器
for (const key of events) {
// 注销audio相关事件
this.$audio.removeEventListener(key!, this.#listenerMap.get(key!));
// 删除map中的映射事件
// this.#listenerMap.delete(key!);
}
return;
}
// 遍历销毁chang监听器
for (const [key, value] of this.#listenerMap) {
// 注销audio相关事件
this.$audio.removeEventListener(key, value);
// 删除map中的映射事件
// this.#listenerMap.delete(key!);
}
}
/**
* 清空【释放所有变量】
*/
clear(): void {
// 音频的html元素
this.$audio = null as unknown as HTMLAudioElement;
// 音频地址链接的字段名
this.#srcKey = "";
// 音频列表集合
this.$audioList = [];
// 当前播放的音频链接
this.$currentPlayAudio = null;
// 判断是否重复执行
this.#currentTimeFlag = 0;
// 用于保存当前的监听事件集
this.#listenerMap.clear();
// 保存用户传递的回调函数
this.#callback = {};
}
/** 播放 */
play(): void {
// 当前播放的音频对象错误
if(this.$currentPlayAudio == null || typeof this.$currentPlayAudio !== "object") return console.log(new Error("当前播放的音频对象错误,请确保传入值的类型为object"));
// 播放
this.$audio.play();
// ios获取封面和作者的唯一途径【菜单栏和灵动岛】
navigator.mediaSession.metadata = new MediaMetadata({
// 音频标题
title: this.$currentPlayAudio.title,
// 作者
artist: this.$currentPlayAudio.author,
// 封面
artwork:[{ src: this.$currentPlayAudio.cover }]
});
}
/** 暂停播放 */
pause(): void {
// 判断是否存在播放对象
if(this.$currentPlayAudio == null || typeof this.$currentPlayAudio !== "object") return;
// 暂停播放
this.$audio.pause();
}
/**
* 设置播放速率
* @param num <Number> 0.5~4.0 【支持 0.5~4.0 范围】
*/
setPlaybackRate(num: number): void {
// 判断audio音频元素或音频对象是否存在
if(!this.$audio) return;
// 设置播放速率
this.$audio.playbackRate = num;
}
/**
* 获取播放速率
* @return num <Number> 0.5~4.0 【0.5~4.0 范围】
*/
getPlaybackRate(): number {
// 判断audio音频元素或音频对象是否存在
if(!this.$audio) return 0;
// 设置播放速率
return this.$audio.playbackRate;
}
/**
* 设置音频音量
* @param {*} num <Number> 0~100
*/
volume(num: number): void {
// 判断audio音频元素或音频对象是否存在
if(!this.$audio) return;
// 设置音频音量
this.$audio.volume = Number(num / 100);
}
/**
* 获取音频音量
* @return num <Number> 0~100
*/
getVolume(): number {
// 判断audio音频元素或音频对象是否存在
if(!this.$audio) return 0;
// 设置音频音量
return Number(this.$audio.volume * 100);
}
/** 静音 */
muted(): void {
// 判断audio音频元素或音频对象是否存在
if(!this.$audio) return;
// 静音
this.$audio.muted = true;
}
/** 解除静音 */
relieveMuted(): void {
// 判断audio音频元素或音频对象是否存在
if(!this.$audio) return;
// 解除静音
this.$audio.muted = false;
}
/**
* 设置当前音频播放的进度
* @param {*} num <Number> 单位秒
*/
step(num: number): void {
// 判断是否存在播放对象
if(this.$currentPlayAudio == null || typeof this.$currentPlayAudio !== "object") return;
// 设置当前音频播放的进度
this.$audio.currentTime = num;
}
/** 上一首 */
last(): void {
// 判断该音频集合是不是为一个链接
if (this.$audioList.length <= 1) return;
// 查找当前播放音频的索引
let index = this.$audioList.findIndex((item) => item[this.#srcKey] == this.$currentPlayAudio[this.#srcKey]);
// 索引递减(为上一音频)
index--;
// 判断当前索引是否超过最小索引值
if (index < 0) index = this.$audioList.length - 1;
// 保存当前音频对象信息
this.$currentPlayAudio = this.$audioList[index];
// 赋值给audio当前音频的路径
this.$audio.src = this.$audioList[index][this.#srcKey];
// 进行播放
this.play();
}
/** 下一首 */
next(): void {
// 判断该音频集合是不是为一个链接
if (this.$audioList.length <= 1) return
// 查找当前播放音频的索引
let index = this.$audioList.findIndex((item) => item[this.#srcKey] == this.$currentPlayAudio[this.#srcKey]);
// 索引递增 (为下一音频)
index++;
// 判断当前索引是否超过最大索引值
if (this.$audioList.length - 1 < index) index = 0;
// 保存当前音频对象信息
this.$currentPlayAudio = this.$audioList[index];
// 赋值给audio当前音频的路径
this.$audio.src = this.$audioList[index][this.#srcKey];
// 进行播放
this.play();
}
/** 随机播放 */
random(): void {
// 判断该音频集合是不是为一个链接
if (this.$audioList.length <= 1) return;
// 筛选出除当前播放音频的链接路径集合 (在其中随机选取一个进行播放)
let audioItems = this.$audioList.filter(item => item[this.#srcKey] != this.$currentPlayAudio[this.#srcKey]);
// 生成随机数
const rand = this.#randomNumber(0, audioItems.length - 1);
// 保存当前音频对象信息
this.$currentPlayAudio = audioItems[rand];
// 赋值给audio当前音频的路径
this.$audio.src = audioItems[rand][this.#srcKey];
// 进行播放
this.play();
}
/**
* 设置音频地址链接的字段名
* @param key <string> 音频地址的字段名
*/
setSrcKey(key: string): void {
if(key) this.#srcKey = key;
}
/** 循环播放 */
loop(): void {
// 恢复默认节流值
this.#currentTimeFlag = 0;
// 播放
this.play();
}
/**
* 切换音乐播放
* @param currentPlayAudio 切换的音频对象 <Object>
* @param audioList 音频集合 <Array | funtion>
* 数组对象的形式 如: [{name: "",url :""},{name: "",url :""},等等...]
* 函数形式 (res) => {
* return [...res, 新的音频配置项信息-如:{name: "",url :""}, 等等]
* 注:函数形式必须有返回值,类型为Array<{[key: string]: any}>,可以操作当前音频列表集合
* }
*/
switch(currentPlayAudio: initOptions["currentPlayAudio"], audioList?: ((data: Array<{[key: string] : any}>)=> initOptions["audioList"]) | initOptions["audioList"]): void {
// 判断切换的音频对象是否错误
if(currentPlayAudio == null || typeof currentPlayAudio !== "object") return console.log(new Error("当前播放的音频对象错误,请确保传入值的类型为object"));
// 判断音频字段名称指向是否错误
if (!currentPlayAudio.hasOwnProperty(this.#srcKey)) return console.log(new Error("该字段不存在,请重新指向音频地址值的字段名称"));
// 优化处理,切换相同路径时,不进行任何操作
if(this.$currentPlayAudio !== currentPlayAudio){
// 保存当前音频对象信息
this.$currentPlayAudio = currentPlayAudio;
// 赋值给audio当前音频的路径
this.$audio.src = currentPlayAudio[this.#srcKey];
// 进行播放
this.play();
}
/** 切换音频可替换需要播放的音频集合 (可有可无) */
// 直接传递的数组
audioList && Array.isArray(audioList) && (this.$audioList = audioList);
// 传递的是一个函数
audioList && typeof audioList === 'function' && (this.$audioList = audioList(this.$audioList)!);
}
/**
* 修改音频列表
* @param audioList 音频集合 <Array | funtion>
* 数组对象的形式 如: [{name: "",url :""},{name: "",url :""},等等...]
* 函数形式 (res) => {
* return [...res, 新的音频配置项信息-如:{name: "",url :""}, 等等]
* 注:函数形式必须有返回值,类型为Array<{[key: string]: any}>,可以操作当前音频列表集合
* }
*/
setAudioList(audioList: ((data: Array<{[key: string] : any}>)=> initOptions["audioList"]) | initOptions["audioList"]): void {
// 直接传递的数组
audioList && Array.isArray(audioList) && (this.$audioList = audioList);
// 传递的是一个函数
audioList && typeof audioList === 'function' && (this.$audioList = audioList(this.$audioList)!);
}
/**
* 将秒数格式化为 mm:ss 格式的时间字符串
* @param {number} seconds - 输入的秒数(可以是整数或小数)
* @returns {string} 格式化后的时间,如 02:30
*/
formatTime(seconds: number): string {
// 处理负数或非数字的情况,默认返回 00:00
if (isNaN(seconds) || seconds < 0) return '00:00';
// 计算分钟和秒数,取整
const mins = Math.floor(seconds / 60), secs = Math.floor(seconds % 60);
// 补零处理,确保是两位数并返回处理结果
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
/**
* 判断是否是ios设备
* @returns Boolean true表示为ios设备, false表示不是
*/
isSafari(): boolean {
const userAgent = navigator.userAgent.toLowerCase();
// 核心判断:包含safari且排除其他浏览器的伪装
return /safari/.test(userAgent) && !/chrome|chromium|edge|firefox|brave|opera|vivaldi/.test(userAgent);
}
/**
* 获取音频缓存进度
* @return { progress: number, cacheSecond: number } 【progress】:当前缓存音频的百分比,【cacheSecond】;当前缓存音频的秒数
*/
#getBufferProgress(): { progress: number, cacheSecond: number }{
// 确保音频总时长已加载
if (this.$audio.duration === Infinity || isNaN(this.$audio.duration)) return { progress: 0, cacheSecond: 0 };
// 默认缓存进度
let bufferedEnd = 0;
// 获取已缓存的时间范围(可能有多个分段)
if (this.$audio.buffered.length > 0) {
// 取最后一个缓存分段的结束时间(代表已缓存的最大位置)
bufferedEnd = this.$audio.buffered.end(this.$audio.buffered.length - 1);
}
// 计算缓存进度 使用Math.min(val, 100)确保不超过100%
const progress = Math.min((bufferedEnd / this.$audio.duration) * 100, 100);
// 当前已缓存的音频秒数
const cacheSecond = Math.trunc(bufferedEnd);
// 返回缓存音频百分比进度progress,以及秒数cacheSecond
return { progress, cacheSecond };
}
/**
* @randomNumber 动态生成随机数
* @param {*} min 最小值 (包含) <Number>
* @param {*} max 最大值(包含) <Number>
* @returns retuen <数字 Number>
*/
#randomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// 映射事件到map中
#mappingEvents(): void{
// 判断是否已经初始化
if (this.#listenerMap.size > 0) return; // 已初始化则跳过
/** 添加事件到Map中 */
// 当指定的音频/视频的元数据已加载时 (触发) 或音频路径切换时,必定会重新加载新的音频数据(也会触发)
this.#listenerMap.set('loadedmetadata', this.#loadedmetadata.bind(this));
// 播放进度监听
this.#listenerMap.set('timeupdate', this.#timeupdate.bind(this));
// 音频播放开始时触发(包括暂停后继续播放)
this.#listenerMap.set('play', this.#play.bind(this));
// 音频暂停时执行该函数
this.#listenerMap.set('pause', this.#pause.bind(this));
// 资源加载中(用于loading加载)【资源正在缓冲中】
this.#listenerMap.set('waiting', this.#waiting.bind(this));
// 资源加载完成(用于loading加载)【资源缓冲完成】
this.#listenerMap.set('canplay', this.#canplay.bind(this));
// 播放速率发生改变时执行该函数
this.#listenerMap.set('ratechange', this.#ratechange.bind(this));
// 音量发生改变时执行该函数【静音和音量发生改变时都会触发,静音和音量是两个完全独立的状态】
this.#listenerMap.set('volumechange', this.#volumechange.bind(this));
// 该音频播放结束执行
this.#listenerMap.set('ended', this.#ended.bind(this));
// 播放错误触发,可通过 audio.error 获取具体错误信息,常见错误:文件不存在(404)、格式不支持、跨域限制
this.#listenerMap.set('error', this.#error.bind(this));
}
// 当指定的音频/视频的元数据已加载时 (触发) 或音频路径切换时,必定会重新加载新的音频数据(也会触发)
#loadedmetadata = (): void => {
// 恢复默认节流值
this.#currentTimeFlag = 0;
// 当前音频的总时间【优化烦人的ios在播放失败的时候,获取不到音频的总时间,搞个无穷大来糊弄我】
const totalTime = this.$audio.duration === Infinity ? 0 : Math.trunc(this.$audio.duration);
// 当前音加载时触发该函数
this.#callback && this.#callback.onLoad && this.#callback.onLoad({
// 当前音频的总时间
totalTime,
// 当前播放时间
currentTime: 0,
// 音频源数据信息
...this.$currentPlayAudio,
})
}
// 播放进度监听
#timeupdate = (): void => {
// 总时间
let duration = Math.trunc(this.$audio.duration);
// 当前播放进度的时间
let currentTime = Math.trunc(this.$audio.currentTime);
// 判断是否重复触发
if (this.#currentTimeFlag == currentTime) return;
// 记录当前播放的时间 (防止重复触发)
this.#currentTimeFlag = currentTime;
// 判断duration值是否为NaN
if(Number.isNaN(duration)) return;
// 返回当前时间以及总时间
this.#callback && this.#callback.onTimeupdate && this.#callback.onTimeupdate({
// 总时间
totalTime: duration,
// 当前播放的时间
currentTime: currentTime,
// 当前已播放的百分比,可用于进度的制作
// currentTimeProgress: Number(Math.min((currentTime / duration) * 100, 100).toFixed(2)), // 只保留后两位小数
currentTimeProgress: Math.min((currentTime / duration) * 100, 100),
// 缓存进度(百分比)
// cacheProgress: Number(this.#getBufferProgress().progress.toFixed(2)), // 只保留后两位小数
cacheProgress: this.#getBufferProgress().progress,
// 缓存进度(秒数)
cacheSecond: this.#getBufferProgress().cacheSecond,
});
}
// 音频播放开始时触发(包括暂停后继续播放)
#play = (): void => this.#callback && this.#callback.onPlayStatus && this.#callback.onPlayStatus({ playStatus: true, msg: "开始播放" })
// 音频暂停时执行该函数
#pause = (): void => this.#callback && this.#callback.onPlayStatus && this.#callback.onPlayStatus({ playStatus: false, msg: "暂停播放" })
// 资源加载中(用于loading加载)【资源正在缓冲中】
#waiting = (): void => this.#callback && this.#callback.onBuffer && this.#callback.onBuffer({ loading: true, msg: "音频缓冲中" })
// 资源加载完成(用于loading加载)【资源缓冲完成】
#canplay = (): void => this.#callback && this.#callback.onBuffer && this.#callback.onBuffer({ loading: false, msg: "音频缓冲完成" })
// 播放速率发生改变时执行该函数
#ratechange = (): void => this.#callback && this.#callback.onRatechange && this.#callback.onRatechange({ playbackRate: this.$audio.playbackRate })
// 音量发生改变时执行该函数【静音和音量发生改变时都会触发,静音和音量是两个完全独立的状态】
#volumechange = (): void => this.#callback && this.#callback.onVolumechange && this.#callback.onVolumechange({ volume: Math.ceil(this.$audio.volume * 100), isMute: this.$audio.muted })
// 该音频播放结束执行
#ended = (): void => this.#callback && this.#callback.onEnded && this.#callback.onEnded({
// 循环播放
loop: () => this.loop(),
// 上一首
last: () => this.last(),
// 下一首
next: () => this.next(),
// 随机播放
random: () => this.random(),
})
// 播放错误触发,可通过 audio.error 获取具体错误信息,常见错误:文件不存在(404)、格式不支持、跨域限制
#error = (): void => {
// 枚举错误状态码
enum errorMsg{
'未知错误' = 0,
'音频加载被中止' = 1,
'音频格式不支持' = 2,
'音频加载异常(文件损坏或网络错误)' = 3,
'音频资源不存在404' = 4,
}
// 约束状态码范围
const errorCode = (this.$audio.error!.code in errorMsg) ? (this.$audio.error!.code as errorMsg) : 0;
// 返回错误状态码信息
this.#callback && this.#callback.onError && this.#callback.onError({ status: errorCode, errorMsg: errorMsg[errorCode] as string })
}
}
// 暴露
export const audioHandle = new Audio();使用方法
第一步先导入
import audioHandle from '路径'
// 因为在vue3中,真实dom存在延迟问题,获取不到audio元素,因此使用onMounted等待页面加载完成后再次调用
onMounted(() => {
// 初始化
audioHandle.init({
// html元素(在vue3中,使用ref获取dom即可,原生直接使用[document.querySelector('')]获取即可.)
audio: audio.value, // 也可以使用 new Audio()
// 音频地址链接的字段名 (可有可无,可有通过setSrcKey进行设置)
srcKey: "url",
// 保存当前音频列表 (可有可无)
audioList: list, // list必须为数组并包含对象的形式 如: [{name: "",url :""},{name: "",url :""},等等...]
// 当前播放的音频链接 (可有可无)
currentPlayAudio: list[1],
// 方法 (可有可无)
listeners: {
// 初始化加载完成后触发该函数,返回当前音频的总时间,名称等参数 (每当音频路径发生改变时都会触发,并返回最新的音频数据等信息)
onLoad(res) {
console.log("音频加载时触发,总时间为:" + res.totalTime, res);
},
// 视频缓冲中/缓冲完成执行该函数(监听视频正在缓冲资源)
onBuffer(res) {
console.log(res);
},
// 播放进度改变时触发
onTimeupdate(res) {
console.log(res);
},
// 音频播放开始时触发(包括暂停后继续播放) 暂停时也会触发
onPlayStatus(res){
console.log(res);
},
// 播放倍数改变时触发
onRatechange(res) {
console.log(res);
},
// 音频音量发生改变时触发【静音和音量发生改变时都会触发,静音和音量是两个完全独立的状态】
onVolumechange(res) {
console.log(res);
},
// 当前音频播放结束时触发该函数
onEnded(handle) {
console.log("播放结束", handle);
},
// 播放错误触发,返回常见错误:文件不存在(404)、格式不支持、跨域限制
onError(error) {
console.log(error);
}
}
});
});
// 播放方法
audioHandle.play();
// 暂停方法
audioHandle.pause();
// 修改音频进度方法 (单位秒/s)<Number>
audioHandle.step(40);
// 上一首音乐方法
audioHandle.last();
// 下一首音乐方法
audioHandle.next();
// 随机播放音乐
audioHandle.random();
// 循环播放音乐
audioHandle.loop();
// 静音方法
audioHandle.muted();
// 解除静音方法
audioHandle.relieveMuted();
// 设置音乐音量方法(0~100)<Number>
audioHandle.volume(20);
// 获取音频音量 直接返回number值,<Number>【1~100】
const volume = audioHandle.getVolume();
// 设置播放速率<Number> 0.5~4.0
audioHandle.setPlaybackRate(2);
// 获取播放倍率 直接返回number值,<Number>【0.5~4.0 范围】
const PlaybackRate = audioHandle.getPlaybackRate();
// 音频地址的字段名,用于读取对应的属性值
audioHandle.setSrcKey("src")
// 注销/销毁 指定的事件监听 【用于组件卸载或页面不存在时销毁监听,防止内存泄漏】
audioHandle.destroy(['onLoad', 'onTimeupdate', 'onPlayStatus', 'onBuffer', 'onRatechange', 'onVolumechange', 'onEnded']); // (每一个item皆为可选项)
//或全部销毁,可简写为
audioHandle.destroy();
// 清空【释放所有变量】
audioHandle.clear();
// 切换音乐方法
/**
* 切换音乐播放
* @param {*} currentPlayAudio 切换的音频地址 <Object> 如{name: "",url :""}
* @param {*} audioList 【可选】 音频集合 <Array | function>
* 数组对象的形式 如: [{name: "",url :""},{name: "",url :""},等等...]
* 函数形式 (res) => {
* return [...res, 新的音频配置项信息-如:{name: "",url :""}, 等等]
* 注:函数形式必须有返回值,类型为Array<{[key: string]: any}>,可以操作当前音频列表集合
*/
// 数组形式
audioHandle.switch(currentPlayAudio【切换音频对象-必传】, [{name: "",url :""},{name: "",url :""},等等...])
// 函数形式
audioHandle.switch(currentPlayAudio【切换音频对象-必传】, (result) => console.log(result));
// 修改音频列表方法 (跟switch的第二个参数一模一样,且是必选项)
// 数组形式
audioHandle.setAudioList([{name: "",url :""},{name: "",url :""},等等...])
// 函数形式
audioHandle.setAudioList((result) => console.log(result));
// 监听audio事件
audioHandle.listeners({
// 初始化加载完成后触发该函数,返回当前音频的总时间,名称等参数 (每当音频路径发生改变时都会触发,并返回最新的音频数据等信息)
onLoad(res) {
console.log("音频加载时触发,总时间为:" + res.totalTime, res);
},
// 视频缓冲中/缓冲完成执行该函数(监听视频正在缓冲资源)
onBuffer(res) {
console.log(res);
},
// 播放进度改变时触发
onTimeupdate(res) {
console.log(res);
},
// 音频播放开始时触发(包括暂停后继续播放) 暂停时也会触发
onPlayStatus(res){
console.log(res);
},
// 播放倍数改变时触发
onRatechange(res) {
console.log(res);
},
// 音频音量发生改变时触发【静音和音量发生改变时都会触发,静音和音量是两个完全独立的状态】
onVolumechange(res) {
console.log(res);
},
// 当前音频播放结束时触发该函数
onEnded(handle) {
console.log("播放结束", handle);
},
// 播放错误触发,返回常见错误:文件不存在(404)、格式不支持、跨域限制
onEnded(error) {
console.log(error);
}
});
// 组件卸载之后执行
onUnmounted(() => {
console.log("组件卸载");
// 注销chang所有监听事件
audioHandle.destroy();
// 清除所有配置项以及属性
audioHandle.clear();
});注意事项
哎,为啥要写这个呢,就是应为该死的【ios】,后台结束后不能继续播放,导致样式还是播放状态,audio对象的一些方法不能执行就寄了
解决方法:监听visibilitychange,然后判断document.hidden的布尔值,可知晓用户是在前台还是后台【document.hidden是浏览器的专属属性,不是定义的】,当重新进入前台时恢复样式。
当然,这取决于一个音频完整的播放结束,中途不需要判断调整。
document.addEventListener('visibilitychange',()=> !document.hidden && 当前音频是否播放结束【ios在后台可以把当前音频播放完,但是不能继续播放下一首,必须要到在浏览器才可以】 && audioHandle.pause());视频功能封装
Class类封装
class video {
// 初始化
constructor() {
// 视频的html元素标签
this.$video = null;
// 视频地址链接的字段名
this.$linkName = null;
// 当前播放视频参数对象
this.$currentVideo = null;
// 播放视频集合(用于上集播放或下集播放)
this.$videoList = [];
// 视频的片头 (用于跳过当前视频的片头)<单位秒/s><number>
this.$sliceHead = 0;
// 恢复默认节流值
this.currentTimeFlag = 0;
}
/** 初始化 */
init(options) {
// 音频字段名称未指向
if (!options.hasOwnProperty("linkName")) return console.log(new Error("未指向字段名称,请先指向视频地址值的字段名称"));
if (!options.current.hasOwnProperty(options.linkName)) return console.log(new Error("该字段不存在,请重新指向视频地址值的字段名称"));
// 保存音频字段名
this.$linkName = options.linkName;
// 初始化路径配置项未指向
if (!options.current[this.$linkName]) return console.log(new Error("未指向当前播放的路径,应初始化时指向初始化路径"));
// 视频的html元素标签
this.$video = options.videoHtml;
// 视频地址链接的字段名
this.$linkName = options.linkName;
// 当前博凡视频参数对象
this.$currentVideo = options.current;
// 播放视频集合(用于上集播放或下集播放)
this.$videoList = options.videoList;
// 视频的片头 (用于跳过当前视频的片头)<单位秒/s><number>
if (options.sliceHead) this.$sliceHead = options.sliceHead;
// 回调函数
options.change && this.change(options.change);
// 把当前视频链接赋值给video标签
this.$video.src = options.current[this.$linkName];
this.$video.load()
}
// 回调函数
change(callback) {
// 当指定的音频/视频的元数据已加载时 (触发) 或音频路径切换时,必定会重新加载新的音频数据(也会触发)
this.$video.addEventListener('loadedmetadata', () => {
// 恢复默认节流值
this.currentTimeFlag = 0;
// 当前音加载时触发该函数
callback && callback.onLoad && callback.onLoad({
// 当前音频的总时间
totalTime: this.$video.duration,
// 当前播放时间
currentTime: 0,
// 视频源数据信息
...this.$currentVideo,
})
});
// 监听视频播放进度
this.$video.addEventListener("timeupdate", ({ target }) => {
// // 总时间
let duration = parseInt(target.duration);
// // 当前播放进度的时间
let currentTime = parseInt(target.currentTime);
// 判断是否重复触发
if (this.currentTimeFlag == currentTime) return;
// 记录当前播放的时间 (防止重复触发)
this.currentTimeFlag = currentTime;
// 返回当前时间以及总时间
callback && callback.onTimeupdate && callback.onTimeupdate({
// 总时间
totalTime: duration,
// 当前播放的时间
currentTime: currentTime,
});
});
// 监听视频是否播放(play()和autoplay开始播放时触发)
this.$video.addEventListener("play", () => {
// console.log('视频已开始播放');
callback && callback.onPlay && callback.onPlay()
})
// 监听视频是否暂停播放(视频暂停播放触发该函数)
this.$video.addEventListener("pause", () => {
callback && callback.onPause && callback.onPause()
})
// 资源加载中(用于loading加载)【资源正在缓冲中】
this.$video.addEventListener("seeking", () => {
// console.log('寻找中');
callback && callback.onBuffer && callback.onBuffer({
loading: true,
msg: "视频缓冲中"
})
});
// 资源加载完成(用于loading加载)【资源缓冲完成】
this.$video.addEventListener("seeked", () => {
// console.log('寻找完毕');
callback && callback.onBuffer && callback.onBuffer({
loading: false,
msg: "视频缓冲完成"
})
});
// 该音频播放结束执行
this.$video.addEventListener('ended', () => {
callback && callback.onEnded && callback.onEnded({
// 循环播放
loop: () => this.loop(),
// 上一集
last: () => this.last(),
// 下一集
next: () => this.next()
});
});
}
// 播放视频
play() {
this.$video.play()
}
// 暂停播放
pause() {
this.$video.pause()
}
/**
* 设置音频音量
* @param {*} num <Number> 0~100
*/
volume(num) {
// 设置音频音量
this.$video.volume = Number(num / 100);
}
// 静音
muted() {
this.$video.muted = true;
}
// 解除静音
relieveMuted() {
this.$video.muted = false;
}
/**
* 设置当前音频播放的进度
* @param {*} num <Number> 单位秒
*/
step(num) {
this.$video.currentTime = Number(num);
}
// 小窗口播放
smallWindow() {
this.$video.requestPictureInPicture();
}
/** 全屏播放 */
fullScreen() {
let document = Document;
//此方法不可以在異步任務中執行,否則火狐無法全屏
if (this.$video.requestFullscreen) {
this.$video.requestFullscreen();
} else if (this.$video.mozRequestFullScreen) {
this.$video.mozRequestFullScreen();
} else if (this.$video.msRequestFullscreen) {
this.$video.msRequestFullscreen();
} else if (this.$video.oRequestFullscreen) {
this.$video.oRequestFullscreen();
} else if (this.$video.webkitRequestFullscreen) {
this.$video.webkitRequestFullScreen();
} else {
var docHtml = document.documentElement;
var docBody = document.body;
var videobox = this.$video;
var cssText = "width:100%;height:100%;overflow:hidden;";
docHtml.style.cssText = cssText;
docBody.style.cssText = cssText;
videobox.style.cssText = cssText + ";" + "margin:0px;padding:0px;";
document.IsFullScreen = true;
}
}
/** 上一集 */
last() {
// 判断该音频集合是不是为一个链接
if (this.$videoList.length <= 1) return;
// 查找当前播放音频的索引
let index = this.$videoList.findIndex((item) => item[this.$linkName] == this.$currentVideo[this.$linkName]);
// 索引递减(为上一音频)
index--;
// 判断当前索引是否超过最小索引值
if (index < 0) return (index == 0, console.log("本链接已是第一集"));
// 保存当前音频对象信息
this.$currentVideo = this.$videoList[index];
// 赋值给video当前音频的路径
this.$video.src = this.$videoList[index][this.$linkName];
// 进行播放
this.play();
}
/** 下一集 */
next() {
// 判断该音频集合是不是为一个链接
if (this.$videoList.length <= 1) return
// 查找当前播放音频的索引
let index = this.$videoList.findIndex((item) => item[this.$linkName] == this.$currentVideo[this.$linkName]);
// 索引递增 (为下一音频)
index++;
// 判断当前索引是否超过最大索引值
if (this.$videoList.length - 1 < index) return (index = this.$videoList.length - 1, console.log("已完结"));
// 保存当前音频对象信息
this.$currentVideo = this.$videoList[index];
// 赋值给video当前音频的路径
this.$video.src = this.$videoList[index][this.$linkName];
// 进行播放
this.play();
}
// 循环播放
loop() {
// 恢复默认节流值
this.currentTimeFlag = 0;
// 播放
this.play();
}
/**
* 切换视频播放
* @param {*} audioObj 切换的音频地址配置项 <Object>
* @param {*} [urls] 音频集合 <Array>
*/
switch(currentVideo, urls) {
// 如果要切换的视频链接是当前播放的链接时,则直接退出,无须切换
if (this.$currentVideo[this.$linkName] == currentVideo[this.$linkName]) return;
// 保存当前音频对象信息
this.$currentVideo = currentVideo;
// 赋值给audio当前音频的路径
this.$video.src = currentVideo[this.$linkName];
// 进行播放
this.play();
// 切换音频可替换需要播放的音频集合 (有可进行替换)
if (urls) this.$videoList = urls;
}
}
// 暴漏vide Class类
export const videoHandle = new video();使用方法
// 数据
let arr = [
{
name: "猫和老鼠第一集",
url: ".......mp4",
},
{
name: "猫和老鼠第二集",
url: ".......mp4",
},
{
name: "猫和老鼠第三集",
url: ".......mp4",
},
{
name: "猫和老鼠第四集",
url: ".......mp4",
},
];
// 获取video dom元素
const videoBox = ref(null);
onMounted(() => {
// 初始化
videoHandle.init({
// 视频html元素
videoHtml: videoBox.value,
// 视频地址链接的字段名
linkName: "url",
// 视频列表
videoList: arr,
// 当前播放元素
current: arr[0],
// 视频的片头 (用于跳过当前视频的片头)<单位秒/s><number>
sliceHead: 0,
// 监听事件
change: {
// 当指定的音频/视频的元数据已加载时 (触发) 或音频路径切换时,必定会重新加载新的音频数据(也会触发)
onLoad(res) {
console.log(res);
},
// 视频播放中执行该函数(进度进度)
onTimeupdate(res) {
console.log(res);
},
// 视频缓冲中/缓冲完成执行该函数(监听视频正在缓冲资源)
onBuffer(res) {
console.log(res);
},
// 视频播放触发该函数
onPlay() {
console.log("视频已开始播放");
},
// 视频暂停播放触发该函数
onPause() {
console.log("视频暂停中");
},
// 播放结束
onEnded(handle) {
// 下一集
handle.next();
}
}
})
})
// 播放
const play = () => videoHandle.play();
// 暂停
const pause = () => videoHandle.pause();
// 修改进度套进度
const step = () => videoHandle.step(40);
// 上一集
const last = () => videoHandle.last();
// 下一集
const next = () => videoHandle.next();
// 小窗口播放
const smallWindow = () => videoHandle.smallWindow();
// 设置音量
const volume = () => videoHandle.volume(30);
// 静音
const muted = () => videoHandle.muted();
// 解除静音
const relieveMuted = () => videoHandle.relieveMuted();
// 全屏
const fullScreen = () => videoHandle.fullScreen();
// 视频切换
const switchTap = () => videoHandle.switch(arr[0]);控制台打印样式自定义
函数封装
/**
* 后台打印(无换行高性能版)
* @param {object} param 参数对象,包含标题和样式
* @param {string[]} param.title 标题数组,每个元素为一个标题
* @param {Array<{ [key: string]: string }>} param.style 样式数组,每个元素为一个样式对象
*/
function consoleInfo({ title, style }: { title: string[], style: Array<{ [key: string]: string }> }): void {
// 拼接标题:纯横向拼接,不加换行符
let newTitle = title.reduce((val, item) => val + '%c' + item, "");
// 拼接样式:使用 push 避免每次创建新数组,缓存引用减少寻址
let newStyle = style.reduce((val: string[], _item, i) => {
// 拼接样式字符串
let str = "";
// 遍历当前样式对象的键值对
const currentStyle = style[i];
// 遍历对象的键值对,拼接成样式字符串
for (let key in currentStyle) {
// 判断当前键是否是对象自身的属性,避免遍历到原型链上的属性
if (Object.prototype.hasOwnProperty.call(currentStyle, key)) {
// 拼接样式字符串,格式为 "key:value;",并累加到 str 中
str += `${key}:${currentStyle[key]};`;
}
}
// 将拼接好的样式字符串添加到 newStyle 数组中
val.push(str);
// 返回更新后的数组
return val;
}, []);
// 动态传参
console.log(newTitle, ...newStyle);
}使用方法
// 控制台打印
appInst.consoleInfo({
title: ["version", appInst.$version[0].version.slice(1)],
style: [
{
padding: "3px",
background: "#000",
color: "white",
"margin-bottom": "5px",
},
{
padding: "3px",
background: "#219ebc",
color: "white",
},
],
});
// 控制台打印
appInst.consoleInfo({
// title: ["LUKES版权所有", "窃取源码必承担法律责任,扒站/盗用者请自便"],
title: ["LUKES版权所有", "小破站经不起大佬乱折腾,没经费乱搞"],
style: [
{
"margin-top": "5px",
padding: "3px",
background: "#000",
color: "rgb(248, 223, 154)",
},
{
padding: "3px",
background: "rgb(248, 223, 154)",
color: "#666",
},
],
});下载文件
/**
* 触发浏览器的下载
* @param {object} param 参数对象,包含下载文件的URL、名称和目标窗口
* @param {string} param.url 下载文件的URL
* @param {string} param.name 下载文件的名称
* @param {string} param.target 下载文件的目标窗口,默认为"_blank"(新窗口),可选值为"_self"(当前窗口)
*/
function downloadFiles({ url, name = "", target = "_blank" }: { url: string; name: string; target: string }): void {
// 创建一个隐藏的a标签
const a = document.createElement("a");
// 设置a标签的属性
document.body.appendChild(a);
// 设置a标签的样式为隐藏
a.style.display = "none";
// 设置a标签的href属性为下载文件的URL
a.href = url;
// 设置a标签的target属性为下载文件的目标窗口
a.target = target;
// 设置a标签的rel属性为noopener noreferrer,防止新窗口获取到原窗口的引用,被恶意链接攻击
a.rel = 'noopener noreferrer';
// 设置a标签的download属性为下载文件的名称
a.download = name;
// 触发a标签的点击事件,开始下载
a.click();
// 移除a标签,释放内存
document.body.removeChild(a);
}判断是否为移动设备
/**
* 精准检测移动设备(含平板/智能屏/折叠屏,适配iPad Pro/Nest Hub Max等特殊设备)
* 核心逻辑:先识别特殊设备 → 排除明确桌面设备 → 匹配通用移动设备 → 触摸设备兜底
* @returns {boolean} true=移动/平板/智能屏设备,false=桌面设备
*/
isMobileDevice(): boolean {
const ua = navigator.userAgent.toLowerCase();
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
// 1. 识别Nest Hub Max等智能屏设备
const isSmartDisplay = /nest hub/.test(ua) || ('ontouchstart' in window && screenWidth <= 1280 && screenHeight <= 800 && /linux/.test(ua)); // 智能屏的尺寸+系统特征
// 2. 识别iPad(覆盖所有型号)
const isIPad = /ipad/.test(ua) || (/(macintosh|mac os)/.test(ua) && Math.min(screenWidth, screenHeight) >= 768 && Math.max(screenWidth, screenHeight) <= 1366);
// 3. 排除桌面设备
const isDesktop = (/windows|linux/.test(ua) && !isSmartDisplay && screenWidth > 1366) || (/mac os/.test(ua) && !isIPad && screenWidth > 1366);
if (isDesktop) return false;
// 4. 其他移动设备识别
const isMobile = /iphone|ipod|android|mobile|touch|phone|blackberry|webos|iemobile|opera mini|symbian|kindle|firefox os/.test(ua);
const isTouchDevice = 'ontouchstart' in window && Math.min(screenWidth, screenHeight) <= 1280;
return isSmartDisplay || isIPad || isMobile || isTouchDevice;
}对象去重 (性能最优)
/**
* 对象去重
* @param {Array<{[key: string]: any}>} list 需要去重的对象数组
* @param {string} keyName 去重的键名
* @return {Array<{[key: string]: any}>} 去重后的对象数组
*/
function objectDeDuplication(list: Array<{[key: string]: any}>, keyName: string): Array<{[key: string]: any}> {
let newMap = new Map();
return list.filter(item => !newMap.has(item[keyName]) && newMap.set(item[keyName], 1));
}对象的模糊搜索 (性能最优)
/**
* 对象的模糊搜索 (最佳方法,性能最优)
* @param {Object} options - 配置对象
* @param {Array} options.list - 需要搜索的数组
* @param {string} options.search - 搜索的关键字
* @param {string} options.key - 搜索的键名
* @returns {Array} - 返回搜索结果数组
*/
function optimizedSearch(options: {list: Array<{[key: string]: any}>, search: string, key: string}): object {
// 判断搜索的文件是否为空
if (!options.search.trim()) return options.list;
// toLowerCase() 方法用于把字符串转换为小写
const searchLower = options.search.toLowerCase();
// 定义一个新的数组,用户存放配置的数据
const results = [];
// 查找相匹配的数据
for (let i = 0; i < options.list.length; i++) {
options.list[i][options.key].toLowerCase().indexOf(searchLower) !== -1 ? results.push(options.list[i]) : null;
}
// 返回结果
return results;
}数组转换为二维数组 (性能最优)
/**
* 转换为二维数组,方便直接转为map使用【最优,性能最好】
* @param {Array} arr - 对象数组
* @param {string} keyField - 作为键的字段名 把item列表的哪一属性值作为id,用于快速查找某一个对象
*/
twoDimensionalArray(arr: Array<object>, keyField: string){
// 缓存 length
const len = arr.length;
// 提前分配数组
const result = new Array(len);
// 转换
for (let i = 0; i < len; i++) {
const item = arr[i] as any;
result[i] = [item[keyField], item];
}
// 返回二维数组
return result;
}数组转Map-用于查找并获取数组内某一个对象 (性能最优)
函数封装
/**
* 数组转map 【作用:可以用get方法快速查找并获取数组内某一个对象】 (性能最优)
* @param {Array} arr - 对象数组
* @param {string | Function} keyField - 作为键的字段名 把item列表的哪一属性值作为id,用于快速查找某一个对象
* @return Map对象
*/
arrayToNativeMap(arr: Array<object>, keyField: string | Function) {
const map = new Map();
const len = arr.length;
// 支持自定义键(比如组合键)
const getKey = typeof keyField === "function" ? keyField : (item: any) => item[keyField];
// 循环添加到map实例中
for (let i = 0; i < len; i++) {
const item = arr[i];
const key = getKey(item);
map.set(key, item);
}
return map;
}使用方法
// 示例使用
const data = [
{ id: 1, name: "张三" },
{ id: 2, name: "李四" },
{ id: 3, name: "王五" },
];
// 示例1:方法
const map1 = arrayToNativeMap(data, "id");
console.log(map1.get(2)); // { id: 2, name: "李四" }
// 示例2:自定义组合键
const map2 = arrayToNativeMap(data, (item) => `${item.id}_${item.name}`);
console.log(map2.get("2_李四")); // { id: 2, name: "李四" }筛选出b数组中存在的,且id值在a数组中的对象 (性能最优)
函数封装
/**
* 筛选出当前用户的文件列表 (最优方案,性能最好)
* @param {Object} parameter 参数对象
* @param {Array} parameter.typeId 用户拥有的文件ID数组
* @param {Array} parameter.dataSource 文件数据源数组
* @param {string} parameter.dataSourceKey 文件数据源中对应ID的键名
* @return {Array} 用户文件列表
*/
function filterType(parameter: { typeId: Array<string | number | undefined>, dataSource: Array<{ [key: string]: any }>, dataSourceKey: string }) {
const { typeId, dataSource, dataSourceKey } = parameter;
// 使用Set去重
const newSetUserObj = new Set(typeId);
// 筛选出当前用户的文件列表
return dataSource.filter((item) => newSetUserObj.has(item[dataSourceKey]));
}使用方法
// 用户拥有的文件ID数组
const idArr= [3, 7];
// 文件数据源数组
const dataArr = [
{ id: 1, name: '文件1' },
{ id: 2, name: '文件2' },
{ id: 3, name: '文件3' },
{ id: 4, name: '文件4' },
{ id: 5, name: '文件5' },
{ id: 6, name: '文件6' },
{ id: 7, name: '文件7' },
];
const result = filterType({ typeId: idArr, dataSource: dataArr, dataSourceKey: 'id' });
console.log(result); // [{ id: 3, name: '文件3' }, { id: 7, name: '文件7' }]圆形扩散主题
函数封装
TypeScript和CSS样式如下:
TypeScript
/**
* 切换主题(圆形扩散动画)
* @param {Object} params - 参数对象
* @param {HTMLElement} params.element - element为HTML的dom元素,当传递element时,以该元素的中心点为起始位置进行扩散
* @param {Object} params.startAxis - 动画起始点坐标,包含 x 和 y 两个属性 【当 element 不存在时生效】
* @param {number} params.startAxis.x - 动画起始点的 x 坐标;默认0
* @param {number} params.startAxis.y - 动画起始点的 y 坐标:默认0
* @param {string} params.customAttributes - 自定义属性名,用于获取当前主题 【不传默认获取主题class类名.dark来做为判断条件】【默认主题值 "light" 和 "dark"】
* @param {string} params.diffusionMode - 扩散模式,"smallToLarge" 表示从小圆扩散到大圆,"fromBigToSmall" 表示从大圆收缩到小圆 【默认 smallToLarge】
* @param {number} params.duration - 动画时长,单位毫秒 【默认 450】
* @param {string} params.easing - 缓动函数,支持 CSS easing 函数、贝塞尔曲线等等。【默认 "ease-in"】
* @param {Function} params.themeCallback - 切换主题的回调函数,接收一个对象参数,包含切换后的主题(nextTheme: string)和是否支持动画(isSupported: boolean)
*/
export function toggleTheme({ element, startAxis, customAttributes, diffusionMode = "smallToLarge", duration = 450, easing = "ease-in", themeCallback } : { element?: HTMLElement, startAxis?: { x: number, y: number }, customAttributes?: string, diffusionMode?: "smallToLarge" | "fromBigToSmall", duration?: number, easing?: string, themeCallback: Function }): void {
// 判断扩散模式是否正确
if(!["smallToLarge", "fromBigToSmall"].includes(diffusionMode)) return console.log(new Error("扩散模式错误:diffusionMode 必须是 'smallToLarge' 或 'fromBigToSmall'"));
// 判断参数类型是否正确
if(typeof themeCallback !== 'function') return console.log(new Error("回调函数错误:themeCallback 必须是一个函数"));
// 是否正在执行主题切换动画,动画未结束时,不允许再次点击(防止连续点击)
if (document.documentElement.dataset.isTransitioning === 'inProgress') return;
// 切换后的主题
const nextTheme = (customAttributes ? document.documentElement.dataset[customAttributes] === "dark" : document.documentElement.classList.contains("dark")) ? "light" : "dark";
// 浏览器不支持 View Transition 或 用户开启了「减少动画」; 则直接切换主题,不需要扩散动画
if (!document.startViewTransition || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return themeCallback({ nextTheme, isSupported: false });
// 关闭阀门,正在执行主题切换
document.documentElement.dataset.isTransitioning = 'inProgress';
// 当 diffusionMode 为 "smallToLarge" 时,移除CSS变量,避免影响后续动画
if(diffusionMode === "smallToLarge"){
// 需要删除的CSS变量数组
const delVariable = ['--custom-theme-Hierarchy-Category-I', '--custom-theme-Hierarchy-Category-II', '--custom-theme-clip-path-outside-inside', '--custom-theme-clip-path-inside-outside'];
// 循环遍历删除CSS变量
delVariable.forEach((variable: string)=> document.documentElement.style.removeProperty(variable));
}
// 当 diffusionMode 为 "fromBigToSmall" 时,设置CSS变量,确保动画从大圆到小圆
if(diffusionMode === "fromBigToSmall"){
// cssVariables 对象包含了需要设置的CSS变量及其对应的值
const cssVariables: Record<string, string> = {
'--custom-theme-Hierarchy-Category-I': '1',
'--custom-theme-Hierarchy-Category-II': '9999',
'--custom-theme-clip-path-outside-inside': 'none',
'--custom-theme-clip-path-inside-outside': 'circle(0px at 0 0)'
};
// 循环遍历 cssVariables 对象,设置CSS变量
for(const key in cssVariables){
// 设置css变量
document.documentElement.style.setProperty(key, cssVariables[key])
}
}
// 默认X轴坐标
let X_Axis = Number(startAxis?.x) || 0;
// 默认Y轴坐标
let Y_Axis = Number(startAxis?.y) || 0;
// 如果元素存在,获取按钮中心点坐标
if(element){
// 获取按钮位置(动画从按钮中心开始)
const rect = element.getBoundingClientRect();
// 计算按钮中心点坐标
X_Axis = rect.left + rect.width / 2;
// 计算按钮中心点坐标
Y_Axis = rect.top + rect.height / 2;
}
/**
* 计算圆需要扩散到多大才能覆盖整个屏幕
* Math.hypot = √(x²+y²)
*/
const endRadius = Math.hypot(Math.max(X_Axis, innerWidth - X_Axis), Math.max(Y_Axis, innerHeight - Y_Axis));
// 开启 View Transition
const transition = document.startViewTransition(() => themeCallback({ nextTheme, isSupported: true }));
// DOM 更新完成后开始动画
transition.ready.then(() => {
// 定义 clip-path 动画的起始和结束状态 (从小圆到大圆)
const clipPath = [`circle(0px at ${X_Axis}px ${Y_Axis}px)`, `circle(${endRadius}px at ${X_Axis}px ${Y_Axis}px)`];
// 当 diffusionMode 为 "smallToLarge" 时,开始动画为从小圆到大圆;当 diffusionMode 为 "fromBigToSmall" 时,开始动画为从大圆到小圆
const startAnimation = diffusionMode === "smallToLarge" ? clipPath : clipPath.slice().reverse();
// 当 diffusionMode 为 "smallToLarge" 时,结束动画为从大圆到小圆;当 diffusionMode 为 "fromBigToSmall" 时,结束动画为从小圆到大圆
const endAnimation = diffusionMode === "smallToLarge" ? clipPath.slice().reverse() : clipPath;
// 执行动画 (根据 nextTheme 判断是从小圆到大圆还是从大圆到小圆)
const animation = document.documentElement.animate({ clipPath: nextTheme === "dark" ? startAnimation : endAnimation }, {
// 动画时长(毫秒)
duration,
// 缓动函数(Material Design)
easing,
// 立即应用第一帧的样式 不应用最后一帧的样式
fill: "backwards",
/**
* 指定动画作用到 View Transition 的哪个图层
* 切到暗色主题:当 diffusionMode 为 "smallToLarge" 时,动画作用到新图层;当 diffusionMode 为 "fromBigToSmall" 时,动画作用到旧图层
* 切到亮色主题:当 diffusionMode 为 "smallToLarge" 时,动画作用到旧图层;当 diffusionMode 为 "fromBigToSmall" 时,动画作用到新图层
*/
pseudoElement: nextTheme === "dark" ? `::view-transition-${diffusionMode === "smallToLarge" ? 'new' : 'old'}(root)` : `::view-transition-${diffusionMode === "smallToLarge" ? 'old' : 'new'}(root)`
});
/**
* 动画结束前确保不会闪
* 监听动画结束,在伪元素被移除前手动将 clip-path 设为最终状态
* 作用:画播放完毕的那一刻,立即强制结束整个 View Transition 过渡,onfinish 负责"掐准时机",skipTransition() 负责"立刻清场",两者配合确保动画结束和伪元素销毁之间零间隔,消除闪烁
*/
animation.onfinish = () => transition.skipTransition();
});
// 动画结束,允许再次点击
transition.finished.finally(() => delete document.documentElement.dataset.isTransitioning);
};全局CSS样式(根据主题切换方式进行选择)
- data-* 属性方式
/* 新旧页面快照基本默认样式 */
::view-transition-old(root),
::view-transition-new(root) {
/* 去掉浏览器默认的淡入淡出 */
animation: none !important;
/* 正常图层混合 */
mix-blend-mode: normal;
/* Safari/Chrome/旧WebKit */
-webkit-user-select: none;
/* 旧 Firefox */
-moz-user-select: none;
/* IE10+/旧Edge */
-ms-user-select: none;
/* 标准,现代浏览器 */
user-select: none;
}
/* 深色:新页面在上面,从按钮位置向外扩散 */
:root[data-theme="dark"]::view-transition-new(root) {
z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
/* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散内到外时生效】 */
clip-path: var(--custom-theme-clip-path-outside-inside, circle(0px at 0 0));
}
:root[data-theme="dark"]::view-transition-old(root) {
z-index: var(--custom-theme-Hierarchy-Category-II, 1);
}
/* 浅色:旧页面在上面,从外向按钮位置收缩 */
:root:not([data-theme="dark"])::view-transition-old(root) {
z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
}
:root:not([data-theme="dark"])::view-transition-new(root) {
z-index: var(--custom-theme-Hierarchy-Category-II, 1);
/* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散外到内时生效】 */
clip-path: var(--custom-theme-clip-path-inside-outside, none);
}- class类名方式
/* 新旧页面快照基本默认样式 */
::view-transition-old(root),
::view-transition-new(root) {
/* 去掉浏览器默认的淡入淡出 */
animation: none !important;
/* 正常图层混合 */
mix-blend-mode: normal;
/* Safari/Chrome/旧WebKit */
-webkit-user-select: none;
/* 旧 Firefox */
-moz-user-select: none;
/* IE10+/旧Edge */
-ms-user-select: none;
/* 标准,现代浏览器 */
user-select: none;
}
/* 深色:新页面在上面,从按钮位置向外扩散 */
:root.dark::view-transition-new(root) {
z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
/* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散内到外时生效】 */
clip-path: var(--custom-theme-clip-path-outside-inside, circle(0px at 0 0));
}
:root.dark::view-transition-old(root) {
z-index: var(--custom-theme-Hierarchy-Category-II, 1);
}
/* 浅色:旧页面在上面,从外向按钮位置收缩 */
:root:not(.dark)::view-transition-old(root) {
z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
}
:root:not(.dark)::view-transition-new(root) {
z-index: var(--custom-theme-Hierarchy-Category-II, 1);
/* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散外到内时生效】 */
clip-path: var(--custom-theme-clip-path-inside-outside, none);
}使用方法
// 导入主题切换函数
import { toggleTheme } from '@/utils/circleThemeAnimation';
// 修改标题事件
const themeTap = (event: MouseEvent) => toggleTheme({ element: event.target as HTMLElement, customAttributes: "theme", themeCallback: ({ nextTheme } : { nextTheme: string })=>{
// 切换主题
document.documentElement.dataset.theme = nextTheme;
// 根据主题切换favicon图标
document.querySelector<HTMLLinkElement>("link[rel='icon']")!.href = `/favicon-${nextTheme}.ico`;
// 保存当前主题
localStorage.setItem("themeType", nextTheme);
}});阻止打开控制台并跳转空白页面
// 禁用右键菜单
document.addEventListener('contextmenu', function(e) {
e.preventDefault(); // 阻止默认右键行为
});
// 禁用快捷键
document.addEventListener('keydown', function(e) {
// 定义需要阻止的快捷键组合
const BLOCKED_KEYS = [
// F12键
{ code: 'F12' },
// Ctrl+U (查看网页源代码)
{ ctrlKey: true, code: 'KeyU' },
// Mac:Command + U
{ metaKey: true, code: 'KeyU' },
// Ctrl+Shift+I
{ ctrlKey: true, shiftKey: true, code: 'KeyI' },
// Ctrl+Shift+J (Chrome)
{ ctrlKey: true, shiftKey: true, code: 'KeyJ' },
// Mac:Command + Option + I
{ metaKey: true, altKey: true, code: 'KeyI' },
// Mac:Command + Option + J
{ metaKey: true, altKey: true, code: 'KeyJ' },
];
// 检查当前按下的键是否在阻止列表中
const shouldBlock = BLOCKED_KEYS.some(k => Object.entries(k).every(([key, val]) => e[key as keyof KeyboardEvent] === val));
// 如果匹配到阻止的快捷键,则阻止默认行为
shouldBlock && e.preventDefault();
});
// 检测控制台打开并拦截
const checkConsole = () => {
// 获取当前时间戳
const start = Date.now();
// 检测是否为移动端设备,如果是则不需要执行
if(isMobileDevice()) return;
// 控制台最小宽度/高度阈值
const threshold = 160;
// 判断控制台是否打开,如果打开则直接跳转空白页面
(window.outerWidth - window.innerWidth > threshold || window.outerHeight - window.innerHeight > threshold) && window.location.replace('about:blank'); // 打开控制台立即跳转空白页面
debugger;
// 根据debugger前的时间戳判断是否是打开工作台【跳空白页和清除标签】
Date.now() - start > 100 && (document.body.innerHTML = '', location.replace('about:blank'));
};
// 立即执行一次
checkConsole();
// 每500ms检测一次
setInterval(checkConsole, 500);
/**
* 精准检测移动设备(含平板/智能屏/折叠屏,适配iPad Pro/Nest Hub Max等特殊设备)
* 核心逻辑:先识别特殊设备 → 排除明确桌面设备 → 匹配通用移动设备 → 触摸设备兜底
* @returns {boolean} true=移动/平板/智能屏设备,false=桌面设备
*/
function isMobileDevice() {
const ua = navigator.userAgent.toLowerCase();
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
// 1. 识别Nest Hub Max等智能屏设备
const isSmartDisplay = /nest hub/.test(ua) || ('ontouchstart' in window && screenWidth <= 1280 && screenHeight <= 800 && /linux/.test(ua)); // 智能屏的尺寸+系统特征
// 2. 识别iPad(覆盖所有型号)
const isIPad = /ipad/.test(ua) || (/(macintosh|mac os)/.test(ua) && Math.min(screenWidth, screenHeight) >= 768 && Math.max(screenWidth, screenHeight) <= 1366);
// 3. 排除桌面设备
const isDesktop = (/windows|linux/.test(ua) && !isSmartDisplay && screenWidth > 1366) || (/mac os/.test(ua) && !isIPad && screenWidth > 1366);
if (isDesktop) return false;
// 4. 其他移动设备识别
const isMobile = /iphone|ipod|android|mobile|touch|phone|blackberry|webos|iemobile|opera mini|symbian|kindle|firefox os/.test(ua);
const isTouchDevice = 'ontouchstart' in window && Math.min(screenWidth, screenHeight) <= 1280;
return isSmartDisplay || isIPad || isMobile || isTouchDevice;
}Dom元素失去焦点
让当前网页上获得焦点(Focus)的元素失去焦点
document.activeElement:这是 DOM 的一个属性,它会返回当前页面中正处于激活状态(拥有焦点)的元素。比如你正在输入的输入框、你按空格键选中的按钮,或者带有 tabindex 属性的元素。
.blur():这是表单元素和链接等支持焦点的元素上的一个方法,作用是移除焦点(与 focus() 相反)。
if (document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}