函数封装
动态生成随机数
ts
/**
* @rand 动态生成随机数
* @param {*} min 最小值 (包含)
* @param {*} max 最大值(包含)
* @returns retuen <数字>
*/
function rand(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}动画
上下移动动画封装
ts
/**
* @Up 上下移动动画封装
* @param {*} target 要移动的对象
* @param {*} end 移动距离
* @param {*} 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);
};左右移动动画封装
ts
/**
* @moveLeftAndRight 左右移动动画封装
* @param {*} target 要移动的对象
* @param {*} end 移动距离
* @param {*} 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);
};透明度动画封装
ts
/**
* @transparency 透明度动画封装
* @param {*} target 透明的对象
* @param {*} end 透明度 (值问 0 ~ 100)
* @param {*} 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)
};返回顶部
ts
/**
* 封装动动画函数
* @param {*} target 对象
* @param {*} end 移动的距离
* @param {*} 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, () => { })禁用滚动条
ts
/**
* 禁用滚动条
* @param flag <Boolean> 布尔值 true表示禁用
*/
disableScrollbar(flag: boolean): void {
// 判断模态是打开状态还是隐藏状态,来判断滚动条是否禁用
document.documentElement.style.overflow = flag ? 'hidden' : ''; // 禁用html
document.body.style.overflow = flag ? 'hidden' : ''; // 禁用body
}复制文本封装
ts
/**
* 复制文字方法
* @param str <string> 文本
* @param callback 回调函数
*/
async copyUrl(str: string, callback: Function) {
try {
// 复制请求
await navigator.clipboard.writeText(str);
// 返回请求状态
callback && callback(true);
} catch (err) {
// 返回请求状态
callback && callback(false);
}
}文本中间用****代替
ts
/**
* 处理显示的文本值
* @param str 字符串
* @param starSum 中间显示多少个 <*> 【默认5个】
* @param start 第几个字符开始 【默认第一个】
* @param starSum 结束的字符 【默认最后一个】
* @returns <string>
*/
maskString({ str , starSum = 5, start = 1, end = 1 } : { str: string, starSum?: number, start?: number, end?: number }): string {
if (str.length <= start + end) return str; // 长度≤start + end时无需处理
return str.substring(0, start) + '*'.repeat(starSum) + str.slice(-end);
}双击事件封装
ts
/**
* 双击事件封装
* @param hander 回调函数 {
* click:单机时执行
* doubleClick:双击时执行
* }
* @param time 执行时间,默认300毫秒后执行
* @returns null
*/
double(hander: { click: Function, doubleClick: Function}, time = 300) {
// 清除定时器
clearTimeout(doubleTapTime);
// 判断该属性是否存在 ,如果_this.sum不存在,者指定默认值
let doubleSumTap = localStorage.getItem("doubleSumTap") as any;
doubleSumTap = doubleSumTap == "undefined" ? 0 : doubleSumTap;
// 当前索引增加
doubleSumTap++;
// 修改缓存的数据
localStorage.setItem("doubleSumTap", doubleSumTap);
// 判断是否到达预设的时间
doubleTapTime = setTimeout(() => {
// 单击
if(doubleSumTap == 1) hander.click();
// 双击执行完成
if (doubleSumTap == 2) hander.doubleClick();
// 清除缓存
localStorage.removeItem("doubleSumTap");
// 默认值
doubleSumTap = 0;
}, time);
}事件委派封装
封装函数
ts
/**
* 事件委派封装
* @param parameter { 参数:<event: 事件对象,targetHTML:目标标签名,eventName:触发事件标识,callback(返回参数:返回自定义事件):回调函数> }
*/
eventDelegation(parameter: { event: MouseEvent, targetHTML: string | Array<string>, eventName: string | Array<string>, callback: Function}): void {
// 触发目标元素
const el = parameter.event.target as HTMLElement;
// 判断点击的元素是否符合要求
if(Array.isArray(parameter.targetHTML) && parameter.targetHTML.includes(el.tagName)) parameter.targetHTML = el.tagName;
// 判断是否是事件委派的事件
if(Array.isArray(parameter.eventName) && parameter.eventName.includes(el.dataset.eventname as string)) parameter.eventName = el.dataset.eventname as string;
// 判断目标元素
if(el.dataset.eventname !== parameter.eventName) return;
// 符合要求触发事件回调
if(el.tagName === parameter.targetHTML && el.dataset.eventname) parameter.callback && parameter.callback({...el.dataset });
}使用方法
vue
<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>关键提示
建议点击对象加一个伪元素,覆盖点击的对象,背景为透明,防止点击不理想的效果
防抖函数
ts
/**
* 防抖函数
* @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: number | 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);
};
}随机token生成器
直接生成
ts
/**
* 随机生成token
* @param {*} tokenLength 生成的长度 (Number)
* @returns [String]
*/
getToken(tokenLength: number): String {
// 生成随机字符串
const letters = "abcdefghijklmnopqrstuvwxyz0123456789";
let token = "";
for (let i = 0; i < parameter.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
ts
/**
* 随机生成token
* @param {*} dataList 一个数组,用户生成的token不能和数组中的重复 (Array)
* @param {*} tokenKey 数组dataList中标识token的属性名 (string)
* @param {*} tokenLength 生成的长度 (Number)
* @returns [String]
*/
async getToken(parameter: any): String {
// 生成随机字符串
const letters = "abcdefghijklmnopqrstuvwxyz0123456789";
let token = "";
for (let i = 0; i < parameter.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 = parameter.dataList.some((item: any) => item[parameter.tokenKey] === token);
// flagToken为true表示重复 false表示不重复
if (!flagToken) return token;
// 重新生成
this.getToken(parameter);
}格式化时间
ts
/**
* 获取时间
* @param {*} [parameter] <string | Date> 用户指定的时间
* @returns
*/
time(parameter: string | Date): Object {
// 补零
const makeUpZero = (val: number) => (val < 10 ? `0${val}` : val);
// 汉字化日期
const dayArr = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
// 获取时间
let timer = parameter ? new Date(parameter) : new Date(),
FullYear = timer.getFullYear(),
month = makeUpZero(timer.getMonth() + 1),
date = timer.getDate(),
day = timer.getDay(),
h = makeUpZero(timer.getHours()),
m = makeUpZero(timer.getMinutes()),
s = makeUpZero(timer.getSeconds());
return {
// 完整日期
allDate: `${FullYear}/${month}/${date} ${dayArr[day]} ${h}:${m}:${s}`,
// 年月日,小时分钟,秒
allTime: `${FullYear}/${month}/${date} ${h}:${m}:${s}`,
// 年月
years: `${FullYear}/${month}`,
// 年月日
specificDate: `${FullYear}/${month}/${date}`,
// 月日
monthDay: `${month}/${date} ${dayArr[day]}`,
// 时分秒
hourMinuteSecond: `${h}:${m}:${s}`,
// 每一项
every: {
// 年
FullYear: FullYear,
// 月
month: month,
// 日
date: date,
// 星期
day: dayArr[day],
// 小时
h: h,
// 分钟
m: m,
// 秒
s: s,
},
};
}手写base64字符串加密
ts
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;
}
}
}使用方法
ts
// 创建实例
const handle = new base64();
// 方法
const 加密 = handle.encode(str);
const 解密 = handle.decode(str);图片转Base64
ts
/**
* 将文件数组转换为 Base64 编码的 Promise
* @param files 文件数组
* @return Promise<{ name: string; type: string; size: number; base64: string, url: string }[]> 返回一个 Promise,解析为包含文件信息和 Base64 编码的对象数组
*/
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
函数封装
ts
/**
* 通用底部触发回调函数(兼容+防抖+滚动方向+多场景扩展)
* @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;
}使用方法
ts
// 封装的话导入一下
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
函数封装
ts
/**
* 回调函数参数类型定义(明确回调接收的参数结构)
*/
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);
}
}
export default new reachBottom();使用方法
ts
// 封装的话导入一下
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+移动端)
函数封装
ts
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 {
// 音频元素的宽度以及高度
private domOffsetWidth: number = 0;
private domOffsetHeight: number = 0;
// 点击音频元素内的坐标
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退出。
vue
<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>音频功能封装
Class类封装
ts
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();使用方法
ts
第一步先导入
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是浏览器的专属属性,不是定义的】,当重新进入前台时恢复样式。
当然,这取决于一个音频完整的播放结束,中途不需要判断调整。
ts
document.addEventListener('visibilitychange',()=> !document.hidden && 当前音频是否播放结束【ios在后台可以把当前音频播放完,但是不能继续播放下一首,必须要到在浏览器才可以】 && audioHandle.pause());视频功能封装
Class类封装
ts
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();使用方法
ts
// 数据
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]);控制台打印样式自定义
函数封装
ts
/**后台打印 */
consoleLog(options: any) {
// 标题
let title = options.title.reduce((val, item) => val += '%c' + item, "");
let style = options.style.reduce((val, item, i) => {
let str = ''
for (let item in options.style[i]) {
str += `${item}: ${options.style[i][item]};`
}
return [...val, str]
}, []);
console.log(title, style[0] && style[0], style[1] && style[1]);
}使用方法
ts
// 控制台打印
appInst.consoleLog({
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.consoleLog({
// 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",
},
],
});下载文件
ts
/**
* 触发浏览器的下载
* @param {*} url 下载链接
* @param {*} name 文件名称
*/
downloadFiles(url: string, name = '') {
const a = document.createElement("a");
document.body.appendChild(a);
a.style.display = "none";
a.href = url;
// 创建新窗口进行下载
a.target = "_blank"; // _self:不跳转新页面
// 防止被恶意链接攻击
a.rel = 'noopener noreferrer';
a.download = name;
a.click();
document.body.removeChild(a);
}判断是否为移动设备
ts
/**
* 精准检测移动设备(含平板/智能屏/折叠屏,适配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;
}对象去重 (性能最优)
ts
/**
* 对象去重
* @param {*} list 需要去重的对象列表
* @param {*} keyName 用于去重的属性名
* @returns
*/
objectDeDuplication(list: Array<{[key: string]: any}>, keyName: string) {
let newMap = new Map();
return list.filter(item => !newMap.has(item[keyName]) && newMap.set(item[keyName], 1));
}对象的模糊搜索 (性能最优)
ts
/**
* 对象的模糊搜索 (最佳方法,性能最优)
* @param options {
* list: array<object> 查询的列表
* search: string 查询的关键字
* key: string 查询的字段名
* }
* @returns Array<object> 查询成功的数据列表
*/
function optimizedSearch(options: {list: Array<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;
}数组转换为二维数组 (性能最优)
ts
/**
* 转换为二维数组,方便直接转为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-用于查找并获取数组内某一个对象 (性能最优)
函数封装
ts
/**
* 数组转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;
}使用方法
ts
// 示例使用
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数组中的对象 (性能最优)
函数封装
ts
/**
* 筛选出当前用户的文件列表 (最优方案,性能最好)
* @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]));
}使用方法
ts
// 用户拥有的文件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' }]圆形扩散主题
函数封装
TS和Css样式如下
TypeScript
ts
// 是否正在执行主题切换动画(防止连续点击)
let isTransitioning = false;
/**
* 切换主题(带圆形扩散动画)
* @param e 按钮点击事件
*/
export async function toggleTheme(e: MouseEvent) {
// 动画未结束时,不允许再次点击
if (isTransitioning) return;
// html 元素
const root = document.documentElement;
// 当前主题
const currentTheme = root.dataset.theme === "dark" ? "dark" : "light";
// 切换后的主题
const nextTheme = currentTheme === "dark" ? "light" : "dark";
// 浏览器不支持 View Transition
// 或用户开启了「减少动画」
// 则直接切换主题
if (!document.startViewTransition || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
// 切换主题
root.dataset.theme = nextTheme;
// 根据主题切换favicon图标
document.querySelector<HTMLLinkElement>("link[rel='icon']")!.href = `/favicon-${nextTheme}.ico`;
// 保存当前主题
localStorage.setItem("themeType", nextTheme);
return;
}
isTransitioning = true;
// 获取按钮位置(动画从按钮中心开始)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
// 计算圆需要扩散到多大才能覆盖整个屏幕
// Math.hypot = √(x²+y²)
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
// 开启 View Transition
const transition = document.startViewTransition(() => {
// 切换主题
root.dataset.theme = nextTheme;
// 根据主题切换favicon图标
document.querySelector<HTMLLinkElement>("link[rel='icon']")!.href = `/favicon-${nextTheme}.ico`;
// 保存当前主题
localStorage.setItem("themeType", nextTheme);
// 等待 Vue 更新 DOM
// await nextTick();
});
// DOM 更新完成后开始动画
transition.ready.then(() => {
// 根据切换方向决定动画
const clipPath =
nextTheme === "dark"
? [
// 亮 -> 暗:圆从小变大
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`
]
: [
// 暗 -> 亮:圆从大变小
`circle(${endRadius}px at ${x}px ${y}px)`,
`circle(0px at ${x}px ${y}px)`
];
const animation = document.documentElement.animate({ clipPath }, {
// 动画时长(毫秒)
duration: 450,
// 缓动函数(Material Design 推荐)
// easing: "cubic-bezier(0.4, 0, 0.2, 1)",
easing: 'ease-in',
// 立即应用第一帧的样式 不应用最后一帧的样式
fill: "backwards", // 【这里切记不要修改】动画结束前确保不会闪
// 指定动画作用到 View Transition 的哪个图层
// 切到暗色:动画作用在新页面
// 切到亮色:动画作用在旧页面
pseudoElement: nextTheme === "dark" ? "::view-transition-new(root)" : "::view-transition-old(root)"
});
// ✅ 修复:动画结束前确保不会闪
// 监听动画结束,在伪元素被移除前手动将 clip-path 设为最终状态
// 作用:画播放完毕的那一刻,立即强制结束整个 View Transition 过渡,onfinish 负责"掐准时机",skipTransition() 负责"立刻清场",两者配合确保动画结束和伪元素销毁之间零间隔,消除闪烁
animation.onfinish = () => transition.skipTransition(); //【解决闪烁以及帧数重叠问题】
});
// 动画结束,允许再次点击
transition.finished.finally(() => {
isTransitioning = false;
});
};Css样式
css
/* 去掉浏览器默认的淡入淡出动画 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* 亮 -> 暗 */
::view-transition-old(root) {
z-index: 999;
}
::view-transition-new(root) {
z-index: 1;
}
/* 暗 -> 亮 */
:root[data-theme="dark"]::view-transition-old(root) {
z-index: 1;
}
:root[data-theme="dark"]::view-transition-new(root) {
z-index: 999;
}使用方法
ts
// 导入主题切换函数
import { toggleTheme } from '@/utils/circleThemeAnimation';
// 修改标题事件
const themeTap = (event: MouseEvent) => toggleTheme(event);// event为:事件对象阻止打开控制台并跳转空白页面
ts
// 禁用右键菜单
document.addEventListener('contextmenu', function(e) {
e.preventDefault(); // 阻止默认右键行为
});
// 禁用快捷键
document.addEventListener('keydown', function(e) {
// F12键
if (e.code === 'F12') {
e.preventDefault();
}
// Ctrl+Shift+I
if (e.ctrlKey && e.shiftKey && e.code === 'KeyI') {
e.preventDefault();
}
// Ctrl+Shift+J (Chrome)
if (e.ctrlKey && e.shiftKey && e.code === 'KeyJ') {
e.preventDefault();
}
// Ctrl+U (查看网页源代码)
if (e.ctrlKey && e.code === 'KeyU') {
e.preventDefault();
}
// Mac:Command + Option + I
if (e.metaKey && e.altKey && e.code === 'KeyI') {
e.preventDefault();
}
// Mac:Command + Option + J
if (e.metaKey && e.altKey && e.code === 'KeyJ') {
e.preventDefault();
}
// Mac:Command + U
if (e.metaKey && e.code === 'KeyU') {
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;
}