Skip to content

函数封装

动态生成随机数

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());

查看更多audio属性以及方法

视频功能封装

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;
}

Copyright © 2026 Luke