Skip to content

函数封装 ​

动态生成随机数 ​

ts
/**
 * @rand 动态生成随机数
 * @param {number} min 最小值 (包含)
 * @param {number} max 最大值(包含)
 * @returns {number} 返回 <数字>
 */
function rand(min: number, max: number): number {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

动画 ​

上下移动动画封装 ​

ts
/**
 * @Up 上下移动动画封装
 * @param {HTMLElement} target 要移动的对象
 * @param {number} end 移动距离
 * @param {Function} callback 该函数完成后的回调函数
 */
function Up(target, end, callback) {
  clearInterval(target.timer);
  target.timer = setInterval(() => {
      let step = (end - target.offsetTop) / 12;
      step = step > 0 ? Math.ceil(step) : Math.floor(step);
      if (end == target.offsetTop) {
          callback && callback();
          clearInterval(target.timer);
          return;
      }
      target.style.top = `${target.offsetTop + step}px`
  }, 10);
};

左右移动动画封装 ​

ts
/**
 * @moveLeftAndRight 左右移动动画封装
 * @param {HTMLElement} target 要移动的对象
 * @param {number} end 移动距离
 * @param {Function} callback 该函数完成后的回调函数
 */
function moveLeftAndRight(target, end, callback) {
  clearInterval(target.timer);
  target.timer = setInterval(() => {
      let step = (end - target.offsetLeft) / 15;
      step = step > 0 ? Math.ceil(step) : Math.floor(step);
      if (end == target.offsetLeft) {
          callback && callback();
          clearInterval(target.timer);
          return;
      }
      target.style.left = `${target.offsetLeft + step}px`
  }, 10);
};

透明度动画封装 ​

ts
/**
 * @transparency 透明度动画封装
 * @param {HTMLElement} target 透明的对象
 * @param {number} end 透明度 (值问 0 ~ 100)
 * @param {Function} callback 该函数完成后的回调函数
 */
function transparency(target, end, callback) {
  let step = 0, sum = 0;
  sum = sum < end ? sum = 0 : sum = 100;

  clearInterval(target.times);
  target.times = setInterval(() => {
      step = end > sum ? 10 : -10;
      if (sum === end) {
          callback && callback();
          clearInterval(target.times);
          return;
      }
      sum += step;
      target.style.opacity = sum / 100;
  }, 30)
};

返回顶部 ​

ts
/**
 * 封装动动画函数
 * @param {HTMLElement | window} target 需要处理的对象
 * @param {number} end 移动的距离 
 * @param {Function} callback 事件完成后的执行的回调函数
 */
function backToTop(target, end, callback) {
  clearInterval(target.timer);
  target.timer = setInterval(function () {
      let step = (end - window.pageYOffset) / 10;
      step = step > 0 ? Math.ceil(step) : Math.floor(step);
      if (window.pageYOffset == end) {
          callback && callback();
          clearInterval(target.timer);
          return;
      };
      target.scroll(0, target.pageYOffset + step);
  }, 10);
}
backToTop(window, 0, () => { })

禁用滚动条 ​

ts
/**
 * 禁用滚动条
 * @param {boolean} flag - 是否禁用滚动条,true为禁用,false为启用
 */
function disableScrollbar(flag: boolean): void {
  // 判断模态是打开状态还是隐藏状态,来判断滚动条是否禁用
  if(flag){
    const scrollY = window.scrollY;
    document.body.style.position = 'fixed';
    document.body.style.top = `-${scrollY}px`;
    document.body.style.width = '100%';
  }else{
    const top = document.body.style.top;
    document.body.style.position = '';
    document.body.style.top = '';
    document.body.style.width = '';
    // window.scrollTo(0, -parseInt(top || '0'))
  }
}

虚拟列表?

如果使用了虚拟列表,position = 'fixed'会导致虚拟列表出问题,根本原因在于虚拟列表的核心依赖被破坏。

使用下面的方法进行禁用处理

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 {string} content 内容
* @param {Function} callback 回调函数,返回复制状态和信息
*/
async function copyClipboard(content: string, callback: Function): Promise<void> {
  try {
     // 复制请求
     await navigator.clipboard.writeText(content);
     // 返回请求状态
     callback && callback({status: true, message: '复制成功'});
  } catch (err) {
     // 返回请求状态
     callback && callback({status: false, message: '复制失败'});
  }
}

读取剪切板封装 ​

ts
/**
 * 读取剪切板内容
 * @returns {string | undefined} - 返回剪切板内容,如果获取失败则返回undefined
 */
 async function readClipboard(): Promise<string | undefined> {
  try {
    // 获取文本内容
    const text = await navigator.clipboard.readText();
    // 返回文本内容
    return text;
  } catch (err) {
    // 获取失败
    return undefined;
  }
}

文本中间用****代替 ​

ts
/**
 * 处理显示的文本值
 * @param {string} str 字符串 
 * @param {number} starSum  中间显示多少个 <*> 【默认5个】
 * @param {number} start  第几个字符开始 【默认第一个】
 * @param {number} end  结束的字符 【默认最后一个】
 * @returns {string} 处理后的字符串
 */
function maskString({ str , starSum = 5, start = 1, end = 1 } : { str: string, starSum?: number, start?: number, end?: number }): string {
  // 长度≤start + end时无需处理
  if (str.length <= start + end) return str;
  // 处理字符串
  return str.substring(0, start) + '*'.repeat(starSum) + str.slice(-end);
}

筛选出文本中所有的链接 ​

ts
/**
 * 筛选出文本中所有的链接
 * @param {string} text - 要筛选的文本
 * @returns {string[]} - 返回一个包含所有链接的数组
 */
function extractLinks(text: string): string[] {
  // 如果文本为空,直接返回空数组
  if (!text) return [];
  // 定义你提供的正则表达式(注意:在 JS 字符串中,反斜杠 \ 需要转义为 \\)
  const regexStr = '(http|ftp|https):\\/\\/[\\w\\-_]+(\\.[\\w\\-_]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?';
  // 创建正则对象,务必加上 'g' 参数进行全局匹配
  const pattern = new RegExp(regexStr, 'g');
  // 使用 match 方法提取所有链接
  const matchArr = text.match(pattern);
  // 如果没找到,match 会返回 null,这里做个兜底返回空数组
  return matchArr || [];
}

双击事件封装 ​

ts
/**
 * 双击事件封装
 * @param {Object} handler - 包含单击和双击回调函数的对象
 * @param {Function} handler.click - 单击回调函数,单击时触发
 * @param {Function} handler.doubleClick - 双击回调函数,双击时触发
 * @param {number} delay 执行时间,默认300毫秒后执行
 * @returns {function} 返回一个函数,用于处理点击事件
 */
function double(handler: { click?: Function, doubleClick?: Function}, delay = 300): () => void {
  // 计时器
  let timer: ReturnType<typeof setTimeout> | null = null;
  // 计数器
  let count = 0;
  // 返回一个函数,用于处理点击事件
  return () => {
    // 增加计数器
    count++;
    // 清除计时器
    if (timer !== null) clearTimeout(timer);
    // 达到 2 次立即触发双击,不再等待延迟
    if (count >= 2) {
      // 触发双击事件
      count = 0;
      // 清除计时器
      timer = null;
      // 执行双击回调函数
      handler.doubleClick && handler.doubleClick();
      // 直接退出
      return;
    }
    // 仅 1 次时启动定时器,超时后触发单击
    timer = setTimeout(() => {
      // 触发单击事件
      if (count === 1) handler.click && handler.click();
      // 重置计数器和计时器
      count = 0;
      // 清除计时器
      timer = null;
    }, delay);
  };
}

事件委派封装 ​

封装函数 ​

ts
/**
 * 事件委派封装
 * @param {Object} params - 参数对象
 * @param {MouseEvent} params.event - 点击事件对象
 * @param {string | Array<string>} params.targetHTML - 目标标签名或目标标签名数组
 * @param {string | Array<string>} params.eventName - 触发事件标识或事件标识数组
 * @param {Function} params.callback - 回调函数,返回自定义事件参数
 */
eventDelegation({event, targetHTML, eventName, callback} : { event: MouseEvent, targetHTML: string | Array<string>, eventName: string | Array<string>, callback: Function}): void {
  // 全部转换为大写字符
  targetHTML = Array.isArray(targetHTML) ? targetHTML.map(t => t && t.toUpperCase()) : targetHTML.toUpperCase();

  // 触发目标元素
  const el = event.target as HTMLElement;
  
  // 判断点击的元素是否符合要求
  if(Array.isArray(targetHTML) && targetHTML.includes(el.tagName)) targetHTML = el.tagName;

  // 判断是否是事件委派的事件
  if(Array.isArray(eventName) && eventName.includes(el.dataset.eventname as string)) eventName = el.dataset.eventname as string;

  // 判断目标元素
  if(el.dataset.eventname !== eventName) return;
  
  // 符合要求触发事件回调
  if(el.tagName === targetHTML && el.dataset.eventname) callback && callback({...el.dataset });
}

使用方法 ​

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: ReturnType<typeof setTimeout> | null = null;
  // 返回包装后的函数
  return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
    // 绑定this上下文(确保原函数的this指向正确)
    const context = this;
    // 标记是否是第一次触发且需要立即执行
    const isCallNow = immediate && !timer;
    // 每次触发时,清除之前的定时器(重新计时)
    if (timer) clearTimeout(timer);
    // 设置新的定时器 --- 非立即执行模式:延迟结束后执行函数  --- 执行完成后清空定时器(重置状态)
    timer = setTimeout(() => (!immediate && fn.apply(context, args), timer = null), delay);
    // 立即执行模式:第一次触发时直接执行
    isCallNow && fn.apply(context, args);
  };
}

节流函数 ​

什么是节流?

核心:固定时间间隔内,最多执行一次。

ts
/**
 * 节流函数
 * @param {Function} fn - 需要防抖的回调函数
 * @param {number} [interval = 150] - 节流间隔时间(默认150毫秒)
 * @param {Object} [options = {}] - 配置项
 * @param {boolean} [options.leading = true] - 是否在时间窗口开始时执行(默认true)
 * @param {boolean} [options.trailing = true] - 是否在时间窗口结束时执行(默认true)
 * @returns 包装后的节流函数
 */
function throttle<T extends (...args: any[]) => any>( fn: T, interval: number = 150, options: { leading?: boolean; trailing?: boolean } = {}): (...args: Parameters<T>) => void {
  const { leading = true, trailing = true } = options;
  let lastTime = 0;
  let timer: ReturnType<typeof setTimeout> | null = null;

  return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
    const context = this;
    const now = Date.now();
    const remaining = interval - (now - lastTime);

    // 清除尾随定时器(避免重复触发)
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }

    // leading 模式:时间窗口开始时立即执行
    if (leading && remaining <= 0) {
      lastTime = now;
      fn.apply(context, args);
    }
    // trailing 模式:时间窗口结束时执行
    else if (trailing) {
      timer = setTimeout(() => {
        lastTime = Date.now();
        timer = null;
        fn.apply(context, args);
      }, remaining);
    }
  };
}

随机token生成器 ​

直接生成 ​

ts
/**
 * 生成随机token
 * @param {number} tokenLength - token长度
 * @returns {string} - 生成的随机token
 */
function getToken(tokenLength: number): string {
  // 生成随机字符串
  const letters = "abcdefghijklmnopqrstuvwxyz0123456789";
  let token = "";
  for (let i = 0; i < tokenLength; i++) {
    if (i == 7 || i == 12 || i == 17 || i == 22) {
      token = token + letters.charAt(Math.floor(Math.random() * letters.length));
    } else {
      token += letters.charAt(Math.floor(Math.random() * letters.length));
    }
  }
  // 返回token
  return token;
}

生成唯一Token ​

ts
/**
 * 随机生成token
 * @param {Array} dataList - 一个数组,用户生成的token不能和数组中的重复
 * @param {string} tokenKey - 数组中对象的key名,用于判断token是否重复, 默认key名为'id'
 * @param {number} tokenLength - 生成的token长度,默认长度为6
 * @returns {string} - 生成的token
 */
function getToken({ dataList, tokenKey = "id", tokenLength = 6 }: { dataList: any[]; tokenKey: string; tokenLength: number }): string {
  // 生成随机字符串
  const letters = "abcdefghijklmnopqrstuvwxyz0123456789";
  let token = "";
  for (let i = 0; i < tokenLength; i++) {
    if (i == 7 || i == 12 || i == 17 || i == 22) {
      token = token + letters.charAt(Math.floor(Math.random() * letters.length));
    } else {
      token += letters.charAt(Math.floor(Math.random() * letters.length));
    }
  }
  // 判断token是否重复
  let flagToken = dataList.some((item: any) => item[tokenKey] === token);
  // flagToken为true表示重复  false表示不重复
  if (!flagToken) return token;
  // 重新生成
  return getToken({ dataList, tokenKey, tokenLength });
}

格式化时间 ​

ts
/**
 * 获取时间
 * @param {string | Date} parameter 时间参数,可以是字符串('YYYY/MM/DD HH:mm:ss')或Date对象
 * @returns {Object} 返回一个包含各种时间格式的对象
 */
function time(parameter?: string | Date): Object {
  // 补零
  const padZero = (val: number): string => String(val).padStart(2, '0');
  // 汉字化日期
  const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
  // 获取时间
  let timer = parameter ? new Date(parameter) : new Date(),
    FullYear = timer.getFullYear(),
    month = padZero(timer.getMonth() + 1),
    date = timer.getDate(),
    day = timer.getDay(),
    h = padZero(timer.getHours()),
    m = padZero(timer.getMinutes()),
    s = padZero(timer.getSeconds());
  return {
    // 完整日期
    fullDateTimeWithDay: `${FullYear}/${month}/${date} ${weekdays[day]} ${h}:${m}:${s}`,
    // 年月日,小时分钟,秒
    fullDateTime: `${FullYear}/${month}/${date} ${h}:${m}:${s}`,
    // 年月
    yearMonth: `${FullYear}/${month}`,
    // 年月日
    dateStr: `${FullYear}/${month}/${date}`,
    // 月日
    monthDayWithWeekday: `${month}/${date} ${weekdays[day]}`,
    // 时分秒
    timeStr: `${h}:${m}:${s}`,
    // 每一项
    every: {
      // 年
      FullYear: FullYear,
      // 月
      month: month,
      // 日
      date: date,
      // 星期
      weekday: weekdays[day],
      // 小时
      hours: h,
      // 分钟
      minutes: m,
      // 秒
      seconds: s,
    },
  };
}

手写base64字符串加密 ​

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 {File[]} files 文件数组,类型为 File[]
 * @return Promise<{ name: string; type: string; size: number; base64: string, url: string }[]> 返回一个 Promise,解析为包含文件信息和 Base64 编码的对象数组
 */
function filesToBase64(files: File[]): Promise<{ name: string; type: string; size: number; base64: string, url: string }[]> {
  // 使用 Promise.all 并发处理所有文件,确保所有文件都转换完成后才返回结果
  return Promise.all(files.map(file => new Promise<{ name: string; type: string; size: number; base64: string, url: string }>((resolve, reject) => {
    // 创建 FileReader 实例用于读取文件内容
    const reader = new FileReader();

    // 当文件读取成功完成时触发
    reader.onload = () => {
      resolve({
        name: file.name, // 文件名
        type: file.type, // 文件的 MIME 类型(如 image/png)
        size: file.size, // 文件大小(字节)
        base64: reader.result as string, // 读取结果,强制转换为字符串类型
        url: URL.createObjectURL(file) // 直接生成本地预览链接
      });
    };

    // 当文件读取发生错误时触发,拒绝(reject)该 Promise 并抛出错误
    reader.onerror = reject;

    // 以 Data URL(Base64)格式读取文件内容
    reader.readAsDataURL(file);
  })));
};

原生自定义封装触底事件 ​

javascript ​

函数封装 ​

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
/**
 * 通用底部触发回调函数(兼容+防抖+滚动方向+多场景扩展)
 * @param {Function} callback - 到达底部时执行的回调函数
 * @param {Object} options - 配置项(可选)
 * @param {number} options.offset - 提前触发偏移量(默认100px,值越大越早触发)
 * @param {number} options.debounceDelay - 防抖延迟(默认150ms)
 * @param {HTMLElement|null} options.scrollContainer - 滚动容器(默认window,支持自定义DOM)
 * @param {number|null} options.maxTriggerTimes - 最大触发次数(默认null,无限触发)
 * @param {boolean} options.repeatTrigger - 是否重复触发(默认false:触发一次后锁定,true:每次到达底部都触发)
 * @param {boolean} options.onlyDownScroll - 是否仅向下滚动触发(默认true:仅向下滚动到底部触发,false:忽略方向)
 * @param {number} options.triggerInterval - 同一底部状态下的最小触发间隔(默认1000ms,避免高频触发)
 * @returns {Function} 取消监听的函数(用于组件卸载时销毁)
 */
/**
 * 回调函数参数类型定义(明确回调接收的参数结构)
 */
export interface ReachBottomCallbackParams {
  triggerCount: number; // 已触发次数(从1开始累加)
  isLastTrigger: boolean; // 是否是最后一次触发(基于maxTriggerTimes配置)
  scrollDirection: 'up' | 'down'; // 触发时的滚动方向
}

/**
 * 函数配置项类型定义(严格限制入参类型,避免错误传参)
 */
export interface OnReachBottomOptions {
  offset?: number; // 提前触发偏移量(默认100px,值越大越早触发,支持负数延后触发)
  debounceDelay?: number; // 防抖延迟(默认150ms,防止滚动时高频触发)
  scrollContainer?: HTMLElement | Window; // 滚动容器(默认window,支持自定义滚动DOM)
  maxTriggerTimes?: number; // 最大触发次数(默认undefined,无限触发;传数字限制次数)
  repeatTrigger?: boolean; // 是否重复触发(默认false:触发一次锁定;true:离开底部后可再次触发)
  onlyDownScroll?: boolean; // 是否仅向下滚动触发(默认true:仅向下滚到底部触发;false:忽略方向)
  triggerInterval?: number; // 同一底部状态最小触发间隔(默认1000ms,避免高频重复触发)
}

/**
 * 通用底部触发回调函数(兼容多容器+防抖+滚动方向+重复触发控制)
 * 核心功能:滚动到底部(支持提前偏移)时执行回调,解决向上滚动重复触发问题
 * @param callback - 到达底部时执行的回调函数(接收触发相关参数)
 * @param options - 配置项(可选,支持多场景自定义)
 * @param {number} options.offset - 提前触发偏移量(默认100px,值越大越早触发)
 * @param {number} options.debounceDelay - 防抖延迟(默认150ms)
 * @param {HTMLElement|null} options.scrollContainer - 滚动容器(默认window,支持自定义DOM)
 * @param {number|null} options.maxTriggerTimes - 最大触发次数(默认null,无限触发)
 * @param {boolean} options.repeatTrigger - 是否重复触发(默认false:触发一次后锁定,true:每次到达底部都触发)
 * @param {boolean} options.onlyDownScroll - 是否仅向下滚动触发(默认true:仅向下滚动到底部触发,false:忽略方向)
 * @param {number} options.triggerInterval - 同一底部状态下的最小触发间隔(默认1000ms,避免高频触发)
 * @returns {Function} 取消监听的函数(用于组件卸载时销毁)
 */
export default function onReachBottom( callback: (params: ReachBottomCallbackParams) => void, options: OnReachBottomOptions = {}): () => void {
  // 合并默认配置(未传配置时使用默认值,确保参数不undefined)
  const {
    offset = 100,
    debounceDelay = 150,
    scrollContainer = window, // 默认滚动容器为window(全局滚动)
    maxTriggerTimes,
    repeatTrigger = false,
    onlyDownScroll = true,
    triggerInterval = 1000
  } = options;

  // 状态管理变量(局部变量,避免全局污染)
  let isTriggered = false; // 非重复模式下的触发锁定标记(触发一次后锁定)
  let triggerCount = 0; // 已触发次数计数器
  let debounceTimer: number | null = null; // 防抖定时器(浏览器环境返回number,修复飘红)
  let lastTriggerTime = 0; // 上次触发时间(用于控制同一底部状态的触发间隔)
  const isWindow = scrollContainer === window; // 判断是否是window全局滚动容器
  let lastScrollTop = getScrollTop(); // 上一次滚动位置(用于判断滚动方向)
  let isInBottom = false; // 当前是否处于底部状态(避免同一底部连续触发)

  /**
   * 获取当前滚动位置(兼容window和自定义容器,修复Safari浏览器兼容问题)
   * @returns 滚动距离顶部的像素值(number类型,确保非undefined)
   */
  function getScrollTop(): number {
    // 全局滚动容器(window):优先取pageYOffset,兼容所有浏览器
    if (isWindow) {
      return window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop || 0; // 兜底0,避免返回undefined
    }
    // 自定义容器:断言为HTMLElement(已限制入参类型,断言安全),获取scrollTop
    return (scrollContainer as HTMLElement).scrollTop;
  };

  

  /**
   * 计算容器有效高度(包含padding,解决自定义容器padding导致的尺寸偏差)
   * @returns 容器实际可滚动可视高度(包含内边距)
   */
  const getClientHeight = (): number => {
    // 全局容器:取窗口可视高度,兼容不同浏览器
    if (isWindow) {
      return window.innerHeight 
        || document.documentElement.clientHeight 
        || 0; // 兜底0
    }
    // 自定义容器:获取元素样式,计算paddingTop+paddingBottom(clientHeight不含padding)
    const element = scrollContainer as HTMLElement;
    const style = window.getComputedStyle(element); // 获取计算后样式(含单位转换)
    const paddingTop = parseFloat(style.paddingTop) || 0; // 解析paddingTop(默认0)
    const paddingBottom = parseFloat(style.paddingBottom) || 0; // 解析paddingBottom(默认0)
    // 有效高度 = 容器可视高度 + 上下内边距(确保滚动高度计算准确)
    return element.clientHeight + paddingTop + paddingBottom;
  };

  /**
   * 判断是否到达容器底部(核心判断逻辑,兼容所有容器和浏览器)
   * @returns 是否到达底部(true:已到达/超过;false:未到达)
   */
  const isReachBottom = (): boolean => {
    let scrollTop: number; // 滚动距离顶部的距离
    let scrollHeight: number; // 容器内容总高度(包含不可见部分)
    let clientHeight: number; // 容器可视高度(含padding)

    // 全局滚动容器:计算页面总高度(取最大高度值,避免浏览器差异)
    if (isWindow) {
      scrollTop = getScrollTop();
      scrollHeight = Math.max(
        document.body.scrollHeight, // body滚动高度
        document.documentElement.scrollHeight // html滚动高度
      );
      clientHeight = getClientHeight(); // 窗口可视高度
    } else {
      // 自定义容器:直接获取元素的滚动相关属性(已断言为HTMLElement)
      const element = scrollContainer as HTMLElement;
      scrollTop = element.scrollTop;
      scrollHeight = element.scrollHeight; // 元素内容总高度
      clientHeight = getClientHeight(); // 元素可视高度(含padding)
    }

    // 核心判断公式:滚动位置 + 可视高度 + 提前偏移 ≥ 内容总高度 - 1px容差
    // 减1px是为了避免浮点精度问题(比如滚动高度为小数时判断失效)
    return scrollTop + clientHeight + offset >= scrollHeight - 1;
  };

  /**
   * 判断滚动方向(先更新上一次滚动位置,再判断当前方向,避免方向误判)
   * @returns 滚动方向('up':向上滚;'down':向下滚)
   */
  const getScrollDirection = (): 'up' | 'down' => {
    const currentScrollTop = getScrollTop(); // 获取当前滚动位置
    const direction = currentScrollTop > lastScrollTop ? 'down' : 'up'; // 对比上一次位置判断方向
    lastScrollTop = currentScrollTop; // 更新上一次位置(为下一次判断做准备)
    return direction;
  };

  /**
   * 滚动事件核心处理函数(整合方向判断、底部判断、防抖、触发控制)
   */
  const handleScroll = (): void => {
    // 1. 触发次数达上限:直接返回,不再执行后续逻辑
    if (maxTriggerTimes !== undefined && triggerCount >= maxTriggerTimes) return;

    // 2. 获取关键状态:滚动方向 + 是否到达底部 + 上一次底部状态
    const direction = getScrollDirection(); // 当前滚动方向
    const reachedBottom = isReachBottom(); // 当前是否到达底部
    const wasInBottom = isInBottom; // 上一次是否处于底部状态(用于判断状态变化)
    isInBottom = reachedBottom; // 更新当前底部状态

    // 3. 未到达底部:解锁非重复模式的触发标记(让用户下次滚到底部可再次触发)
    if (!reachedBottom) {
      if (repeatTrigger) isTriggered = false; // 仅重复模式需要解锁
      return;
    }

    // 4. 仅向下滚动触发:如果方向不是向下,直接返回(忽略向上滚动触发)
    if (onlyDownScroll && direction !== 'down') return;

    // 5. 非重复模式已触发:如果已触发过且未解锁,直接返回(避免重复触发)
    if (isTriggered && !repeatTrigger) return;

    // 6. 同一底部状态间隔控制:如果还在底部且未到间隔时间,直接返回(避免高频触发)
    const now = Date.now(); // 当前时间戳
    if (wasInBottom && now - lastTriggerTime < triggerInterval) return;

    // 7. 同一底部状态过滤:如果之前已经在底部,直接返回(仅从非底部→底部时触发)
    if (wasInBottom) return;

    // 8. 防抖执行回调:清理之前的定时器,延迟执行回调(防止滚动高频触发)
    if (debounceTimer) clearTimeout(debounceTimer); // 清除旧定时器,避免多次触发
    // 浏览器环境用window.setTimeout(返回number类型,修复TS飘红)
    debounceTimer = window.setTimeout(() => {
      lastTriggerTime = now; // 更新上次触发时间(用于间隔控制)
      triggerCount++; // 触发次数累加
      // 执行用户传入的回调函数,传递触发相关参数
      callback({
        triggerCount, // 当前触发次数
        isLastTrigger: maxTriggerTimes !== undefined && triggerCount === maxTriggerTimes, // 是否最后一次触发
        scrollDirection: direction // 触发时的滚动方向
      });

      // 非重复模式:触发后锁定(避免同一底部状态重复触发)
      if (!repeatTrigger) isTriggered = true;
    }, debounceDelay); // 防抖延迟时间(用户可配置)
  };

  /**
   * 绑定滚动相关事件(兼容移动端+全局/自定义容器)
   */
  // 初始化需要绑定的事件类型(默认绑定scroll事件)
  const eventTypes: ('scroll' | 'touchmove' | 'resize')[] = ['scroll'];
  // 仅全局容器(window)需要额外绑定:touchmove(移动端触摸滚动)、resize(窗口大小变化)
  if (isWindow) {
    if ('ontouchstart' in window) eventTypes.push('touchmove'); // 检测到移动端环境,绑定touchmove
    eventTypes.push('resize'); // 窗口大小变化时,重新计算容器高度,避免判断失效
  }

  // 为滚动容器绑定所有需要的事件(passive: true提升移动端滚动性能)
  eventTypes.forEach(type => {
    scrollContainer.addEventListener(type, handleScroll, {
      passive: true, // 告诉浏览器该事件不会阻止默认行为,提升滚动流畅度
      capture: false // 不使用事件捕获,使用冒泡阶段触发(符合常规滚动事件逻辑)
    });
  });

  /**
   * 取消监听函数(外部调用,用于销毁事件绑定和定时器)
   * 必须在组件卸载/不需要时调用,防止内存泄漏(事件绑定未移除导致的性能问题)
   */
  return function cancelOnReachBottom(): void {
    // 移除所有绑定的事件(与绑定的事件类型、处理函数、capture保持一致)
    eventTypes.forEach(type => {
      // removeEventListener不支持passive选项,仅需匹配capture(与add时一致为false)
      scrollContainer.removeEventListener(type, handleScroll, false);
    });
    // 清除防抖定时器(避免定时器延迟执行导致的回调误触发)
    if (debounceTimer) clearTimeout(debounceTimer);
  };
}

使用方法 ​

ts
// 导入触底监听函数
import reachBottomCallback from '@/utils/reachBottomCallback';
import { reactive, onMounted, onUnmounted } from 'vue';

// 配置文件
const setting = reactive({
  // 接收onReachBottom返回的函数,用于销毁监听事件,防止内存泄漏
  removeReachBottomCallback: ()=>{},
})

// 编写触底业务逻辑
const handle = (msg) =>{
  console.log(`第${msg.triggerCount}次触发,加载更多数据`,msg);
}

// 组件挂在完成后执行
onMounted(()=>{
  // 接收onReachBottom返回的函数,用于销毁监听事件,防止内存泄漏
  setting.removeReachBottomCallback = reachBottomCallback(handle, { repeatTrigger: true });
});

// 组件卸载完成后执行
onUnmounted(()=>{
  // 销毁监听事件,防止内存泄漏
  setting.removeReachBottomCallback();
})

TypeScript Class类 ​

函数封装 ​

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

    /* 恢复默认值 */
    // 非重复模式下的触发锁定标记
    this.isTriggered = false;
    // 已触发次数计数器
    this.triggerCount = 0;
    // 防抖定时器
    this.debounceTimer = null;
    // 上次触发时间(用于间隔控制)
    this.lastTriggerTime = 0;
    // 是否是window全局滚动容器
    this.isWindow = true;
    // 上一次滚动位置(用于判断方向)
    this.lastScrollTop = 0;
    // 当前是否处于底部状态
    this.isInBottom = false;
  }
}

// 实例化类并暴露方法
export default new reachBottom();

使用方法 ​

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 {
  // dom元素的宽度以及高度
  private domOffsetWidth: number = 0;
  private domOffsetHeight: number = 0;
  // 点击dom元素内的坐标
  private offsetX: number = 0;
  private offsetY: number = 0;
  // 用于映射事件
  private map: Map<string, any> = new Map();
  // 滑动的元素
  private htmlDom: any;

  constructor(){ }

  /**
   * 监听事件
   * @param callback 回调函数,返回当前移动的状态以及坐标信息; 如:(res)=>{ console.log(res) };
   * @returns void
   */
  change(callback?: PARAMETER): void {
    // 鼠标松开时触发
    const mouseup = (e: any) =>{
        // 阻止浏览器默认行为
        e.preventDefault();
        // 阻止事件冒泡
        e.stopPropagation();

        // e.type.includes("touch") true表示【移动端】, false表示【电脑端】
        const events = e.type.includes("touch") ? ['touchmove', 'touchend'] : ['mousemove', 'mouseup'];
        // 移除document事件
        events.forEach((eventKey: string)=> document.removeEventListener(eventKey, this.map.get(eventKey)));

        /** ------ mouseup中的四个边界判断 ------ */
        const playerRect = this.htmlDom!.getBoundingClientRect(); // 获取最终位置

        // 1. 判断是否到达最左边(左边缘 ≤ 0)
        const isAtLeftEdge = Math.round(playerRect.left) <= 0;
        // 2. 判断是否到达最右边(右边缘 ≥ 可视区宽度)
        const isAtRightEdge = Math.round(playerRect.right) >= window.innerWidth;
        // 3. 判断是否到达最上边(上边缘 ≤ 0)
        const isAtTopEdge = Math.round(playerRect.top) <= 0;
        // 4. 判断是否到达最下边(下边缘 ≥ 可视区高度)
        const isAtBottomEdge = Math.round(playerRect.bottom) >= window.innerHeight;

        // 触发的事件类型
        const eventType = e.type.includes("touch") ? 'touchend' : 'mouseup';

        // 为true表示到达最边界的位置
        callback && callback({
            // 触发事件的类型
            eventType,
            // 是否是移动端
            isMobile: e.type.includes("touch"),
            // 当前移动的状态
            state: 'end',
            // 边界判断
            boundary:{
                // 是否到达最顶部
                isTop: isAtTopEdge,
                // 是否到达最下部
                isBottom: isAtBottomEdge,
                // 是否到达最左边
                isLeft: isAtLeftEdge,
                // 是否到达最右边
                isRight: isAtRightEdge,
            }
        });
    }

    // 鼠标在元素内移动时实时触发
    const mousemove = (e: any) =>{
        // 阻止浏览器默认行为
        e.preventDefault();
        // 阻止事件冒泡
        e.stopPropagation();
        
        // 兼容鼠标和触摸事件的坐标获取
        const clientX = e.type.includes("touch") ? e.touches[0].clientX : e.clientX;
        const clientY = e.type.includes("touch") ? e.touches[0].clientY : e.clientY;

        // 移动元素的实时坐标
        const x = clientX - this.offsetX;
        const y = clientY - this.offsetY;
        // 限制播放器在可视区域内(最大边界限制)
        const maxX = window.innerWidth - this.domOffsetWidth;
        const maxY = window.innerHeight - this.domOffsetHeight;

        //  相当于判断边界,不允许超过最大边界
        const domX = Math.max(0, Math.min(x, maxX));
        const domY = Math.max(0, Math.min(y, maxY));
        
        // 触发的事件类型
        const eventType = e.type.includes("touch") ? 'touchmove' : 'mousemove';
        
        // 实时返回坐标数据
        callback && callback({
            // 触发事件的类型
            eventType,
            // 是否是移动端
            isMobile: e.type.includes("touch"),
            // 当前移动的状态
            state: 'move',
            // 当前移动中X轴的坐标
            left: domX,
            // 当前移动中Y轴的坐标
            top: domY
        })
    }

    // 映射事件到map中
    // 电脑端
    this.map.set("mousemove", mousemove);
    this.map.set("mouseup", mouseup);
    // 手机端
    this.map.set("touchmove", mousemove);
    this.map.set("touchend", mouseup);
  }

  /**
   * 鼠标按下事件
   * @param e 事件对象【MouseEvent | TouchEvent】
   * @returns void
   */
  mousedown(e: any): void{
    // 阻止事件冒泡
    e.stopPropagation();
    
    // 判断,只有自定义属性slide且值为audio的元素才执行以下代码
    if(e.target.dataset.slide !== 'audio') return;

    // e.type.includes("touch") true表示【移动端】, false表示【电脑端】
    const events = e.type.includes("touch") ? ['touchmove', 'touchend'] : ['mousemove', 'mouseup'];
    // 添加事件
    events.forEach((eventKey: string)=> document.addEventListener(eventKey, this.map.get(eventKey), { passive: false }));

    // 保存当前点击的元素
    this.htmlDom = e.target;

    // 获取当前鼠标点击的坐标
    const clientX = e.type.includes("touch") ? e.touches[0].clientX : e.clientX;
    const clientY = e.type.includes("touch") ? e.touches[0].clientY : e.clientY;

    // 获取鼠标相对于播放器的偏移量
    this.offsetX = clientX - e.target.getBoundingClientRect().left;
    this.offsetY = clientY - e.target.getBoundingClientRect().top;

    // 当前移动元素的高度以及宽度
    this.domOffsetWidth = e.target.offsetWidth;
    this.domOffsetHeight = e.target.offsetHeight;
  }
}
// 暴露数据
export default new coordinates();

使用方法 ​

关于事件冒泡的问题

由于使用mousedown和touchstart,阻止事件冒泡必须是相同事件类型才行,不同事件类型不能阻止冒泡行为。

例如:需要移动的dom元素绑定的mousedown事件,但dom元素下的子元素需要click...等等事件,必需要阻止冒泡行为,不能写click.stop,因为事件类型不同,阻止不了。

解决方法【1】:dom元素下需要绑定其它事件是,加上@mousedown.stop @touchstart.stop两个事件即可解决。

解决方法【2】:在class对象的mousedown方法加入判断,比如判断自定义属性,假如属性名为slide,属性值为:audioBox的才能执行后续代码,否则直接return退出。

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>

获取音频总时长 ​

ts
/**
* 获取音频总时长
* @param {string} url - 音频文件的 URL 地址
* @param {number} timeout - 超时时间,单位毫秒,默认 15000ms
* @returns {Promise<number>} - 返回一个 Promise,成功时 resolve 音频总时长(秒),失败时 reject 错误信息
*/
function getAudioDuration(url: string, timeout = 15000): Promise<number> {
  return new Promise((resolve, reject) => {
    // 创建临时 audio 元素,不插入 DOM,仅用于读取元数据
    const audio = document.createElement('audio');
    // 用于存储定时器的引用,以便在需要时清除
    let timer: ReturnType<typeof setTimeout>;
    /**
     * 统一清理函数:移除所有事件监听、清除定时器、释放音频资源
     * 无论成功、失败还是超时都必须调用,防止内存泄漏
     */
    const cleanup = () => {
      // 清除定时器
      clearTimeout(timer);
      // 移除监听事件
      ['loadedmetadata', 'durationchange'].forEach((key: string)=> audio.removeEventListener(key, onLoaded));
      // 移除错误事件监听
      audio.removeEventListener('error', onError);
      // 暂停音频播放,确保不会继续下载或播放
      audio.pause();
      // 释放音频资源,避免内存泄漏
      audio.removeAttribute('src');
      // 强制中止可能仍在进行的网络请求
      audio.load();
    };
    /**
     * 元数据加载完成 / 时长变更时的回调
     * 部分浏览器 loadedmetadata 触发时 duration 可能为 Infinity 或 NaN,
     * 此时不 resolve,继续等待 durationchange 事件或超时
     */
    const onLoaded = () => {
      // 获取音频时长
      const duration = audio.duration;
      // 仅当时长为有效有限数且非负时才认为获取成功
      Number.isFinite(duration) && duration > 0 && (cleanup(), resolve(duration));
    };
    /**
     * 音频加载出错时的回调(404、CORS、格式不支持等)
     */
    const onError = () => (cleanup(), reject(new Error('音频加载失败')));
    // 仅预加载元数据,避免下载完整音频文件
    audio.preload = 'metadata';
    // 绑定事件监听
    ['loadedmetadata', 'durationchange'].forEach((key: string)=> audio.addEventListener(key, onLoaded));
    // 绑定错误事件监听
    audio.addEventListener('error', onError);
    // 设置超时保护,防止音频源无响应导致 Promise 永远 pending
    timer = setTimeout(() => (cleanup(), reject(new Error('获取音频总时长超时'))), timeout);
    // 设置源并触发加载
    audio.src = url;
    // 在某些浏览器中,load() 方法可能不会立即触发 loadedmetadata 事件,因此手动调用 load() 以确保加载开始
    audio.load();
  });
}

音频功能封装 ​

Class类封装 ​

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
/**
 * 后台打印(无换行高性能版)
 * @param {object} param 参数对象,包含标题和样式
 * @param {string[]} param.title 标题数组,每个元素为一个标题
 * @param {Array<{ [key: string]: string }>} param.style 样式数组,每个元素为一个样式对象
 */
function consoleInfo({ title, style }: { title: string[], style: Array<{ [key: string]: string }> }): void {
    // 拼接标题:纯横向拼接,不加换行符
    let newTitle = title.reduce((val, item) => val + '%c' + item, "");

    // 拼接样式:使用 push 避免每次创建新数组,缓存引用减少寻址
    let newStyle = style.reduce((val: string[], _item, i) => {
        // 拼接样式字符串
        let str = "";
        // 遍历当前样式对象的键值对
        const currentStyle = style[i];
        // 遍历对象的键值对,拼接成样式字符串
        for (let key in currentStyle) {
            // 判断当前键是否是对象自身的属性,避免遍历到原型链上的属性
            if (Object.prototype.hasOwnProperty.call(currentStyle, key)) {
                // 拼接样式字符串,格式为 "key:value;",并累加到 str 中
                str += `${key}:${currentStyle[key]};`;
            }
        }
        // 将拼接好的样式字符串添加到 newStyle 数组中
        val.push(str);
        // 返回更新后的数组
        return val;
    }, []);

    // 动态传参
    console.log(newTitle, ...newStyle);
}

使用方法 ​

ts
// 控制台打印
appInst.consoleInfo({
  title: ["version", appInst.$version[0].version.slice(1)],
  style: [
    {
      padding: "3px",
      background: "#000",
      color: "white",
      "margin-bottom": "5px",
    },
    {
      padding: "3px",
      background: "#219ebc",
      color: "white",
    },
  ],
});

// 控制台打印
appInst.consoleInfo({
  // title: ["LUKES版权所有", "窃取源码必承担法律责任,扒站/盗用者请自便"],
  title: ["LUKES版权所有", "小破站经不起大佬乱折腾,没经费乱搞"],
  style: [
    {
      "margin-top": "5px",
      padding: "3px",
      background: "#000",
      color: "rgb(248, 223, 154)",
    },
    {
      padding: "3px",
      background: "rgb(248, 223, 154)",
      color: "#666",
    },
  ],
});

下载文件 ​

ts
/**
* 触发浏览器的下载
* @param {object} param 参数对象,包含下载文件的URL、名称和目标窗口
* @param {string} param.url 下载文件的URL
* @param {string} param.name 下载文件的名称
* @param {string} param.target 下载文件的目标窗口,默认为"_blank"(新窗口),可选值为"_self"(当前窗口)
*/
function downloadFiles({ url, name = "", target = "_blank" }: { url: string; name: string; target: string }): void {
  // 创建一个隐藏的a标签
  const a = document.createElement("a");
  // 设置a标签的属性
  document.body.appendChild(a);
  // 设置a标签的样式为隐藏
  a.style.display = "none";
  // 设置a标签的href属性为下载文件的URL
  a.href = url;
  // 设置a标签的target属性为下载文件的目标窗口
  a.target = target;
  // 设置a标签的rel属性为noopener noreferrer,防止新窗口获取到原窗口的引用,被恶意链接攻击
  a.rel = 'noopener noreferrer';
  // 设置a标签的download属性为下载文件的名称
  a.download = name;
  // 触发a标签的点击事件,开始下载
  a.click();
  // 移除a标签,释放内存
  document.body.removeChild(a);
}

判断是否为移动设备 ​

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 {Array<{[key: string]: any}>} list 需要去重的对象数组
 * @param {string} keyName 去重的键名
 * @return {Array<{[key: string]: any}>} 去重后的对象数组
 */
function objectDeDuplication(list: Array<{[key: string]: any}>, keyName: string): Array<{[key: string]: any}> {
  let newMap = new Map();
  return list.filter(item => !newMap.has(item[keyName]) && newMap.set(item[keyName], 1));
}

对象的模糊搜索 (性能最优) ​

ts
/**
 * 对象的模糊搜索 (最佳方法,性能最优)
 * @param {Object} options - 配置对象
 * @param {Array} options.list - 需要搜索的数组
 * @param {string} options.search - 搜索的关键字
 * @param {string} options.key - 搜索的键名
 * @returns {Array} - 返回搜索结果数组
 */
function optimizedSearch(options: {list: Array<{[key: string]: any}>, search: string, key: string}): object {
  // 判断搜索的文件是否为空
  if (!options.search.trim()) return options.list;
  // toLowerCase() 方法用于把字符串转换为小写
  const searchLower = options.search.toLowerCase();
  // 定义一个新的数组,用户存放配置的数据
  const results = [];
  // 查找相匹配的数据
  for (let i = 0; i < options.list.length; i++) {
    options.list[i][options.key].toLowerCase().indexOf(searchLower) !== -1 ? results.push(options.list[i]) : null;
  }
  // 返回结果
  return results;
}

数组转换为二维数组 (性能最优) ​

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' }]

圆形扩散主题 ​

函数封装 ​

TypeScript和CSS样式如下:

TypeScript

ts
/**
 * 切换主题(圆形扩散动画)
 * @param {Object} params - 参数对象
 * @param {HTMLElement} params.element - element为HTML的dom元素,当传递element时,以该元素的中心点为起始位置进行扩散
 * @param {Object} params.startAxis - 动画起始点坐标,包含 x 和 y 两个属性 【当 element 不存在时生效】
 * @param {number} params.startAxis.x - 动画起始点的 x 坐标;默认0
 * @param {number} params.startAxis.y - 动画起始点的 y 坐标:默认0
 * @param {string} params.customAttributes - 自定义属性名,用于获取当前主题 【不传默认获取主题class类名.dark来做为判断条件】【默认主题值 "light" 和 "dark"】
 * @param {string} params.diffusionMode - 扩散模式,"smallToLarge" 表示从小圆扩散到大圆,"fromBigToSmall" 表示从大圆收缩到小圆 【默认 smallToLarge】
 * @param {number} params.duration - 动画时长,单位毫秒 【默认 450】
 * @param {string} params.easing - 缓动函数,支持 CSS easing 函数、贝塞尔曲线等等。【默认 "ease-in"】
 * @param {Function} params.themeCallback - 切换主题的回调函数,接收一个对象参数,包含切换后的主题(nextTheme: string)和是否支持动画(isSupported: boolean)
 */
export function toggleTheme({ element, startAxis, customAttributes, diffusionMode = "smallToLarge", duration = 450, easing = "ease-in",  themeCallback } : { element?: HTMLElement, startAxis?: { x: number, y: number }, customAttributes?: string, diffusionMode?: "smallToLarge" | "fromBigToSmall", duration?: number, easing?: string, themeCallback: Function }): void { 
  // 判断扩散模式是否正确
  if(!["smallToLarge", "fromBigToSmall"].includes(diffusionMode)) return console.log(new Error("扩散模式错误:diffusionMode 必须是 'smallToLarge' 或 'fromBigToSmall'"));
  // 判断参数类型是否正确
  if(typeof themeCallback !== 'function') return console.log(new Error("回调函数错误:themeCallback 必须是一个函数"));

  // 是否正在执行主题切换动画,动画未结束时,不允许再次点击(防止连续点击)
  if (document.documentElement.dataset.isTransitioning === 'inProgress') return;

  // 切换后的主题
  const nextTheme = (customAttributes ? document.documentElement.dataset[customAttributes] === "dark" : document.documentElement.classList.contains("dark")) ? "light" : "dark";

  // 浏览器不支持 View Transition 或 用户开启了「减少动画」; 则直接切换主题,不需要扩散动画
  if (!document.startViewTransition || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return themeCallback({ nextTheme, isSupported: false });

  // 关闭阀门,正在执行主题切换
  document.documentElement.dataset.isTransitioning = 'inProgress';

  // 当 diffusionMode 为 "smallToLarge" 时,移除CSS变量,避免影响后续动画
  if(diffusionMode === "smallToLarge"){
    // 需要删除的CSS变量数组
    const delVariable = ['--custom-theme-Hierarchy-Category-I', '--custom-theme-Hierarchy-Category-II', '--custom-theme-clip-path-outside-inside', '--custom-theme-clip-path-inside-outside'];
    // 循环遍历删除CSS变量
    delVariable.forEach((variable: string)=> document.documentElement.style.removeProperty(variable));
  }

  // 当 diffusionMode 为 "fromBigToSmall" 时,设置CSS变量,确保动画从大圆到小圆
  if(diffusionMode === "fromBigToSmall"){
    // cssVariables 对象包含了需要设置的CSS变量及其对应的值
    const cssVariables: Record<string, string> = {
      '--custom-theme-Hierarchy-Category-I': '1',
      '--custom-theme-Hierarchy-Category-II': '9999',
      '--custom-theme-clip-path-outside-inside': 'none',
      '--custom-theme-clip-path-inside-outside': 'circle(0px at 0 0)'
    };
    // 循环遍历 cssVariables 对象,设置CSS变量
    for(const key in cssVariables){
      // 设置css变量
      document.documentElement.style.setProperty(key, cssVariables[key])
    }
  }
  
  // 默认X轴坐标
  let X_Axis = Number(startAxis?.x) || 0;
  // 默认Y轴坐标
  let Y_Axis = Number(startAxis?.y) || 0;

  // 如果元素存在,获取按钮中心点坐标
  if(element){
    // 获取按钮位置(动画从按钮中心开始)
    const rect = element.getBoundingClientRect();
    // 计算按钮中心点坐标
    X_Axis = rect.left + rect.width / 2;
    // 计算按钮中心点坐标
    Y_Axis = rect.top + rect.height / 2;
  }

  /**
   * 计算圆需要扩散到多大才能覆盖整个屏幕
   * Math.hypot = √(x²+y²)
   */
  const endRadius = Math.hypot(Math.max(X_Axis, innerWidth - X_Axis), Math.max(Y_Axis, innerHeight - Y_Axis));

  // 开启 View Transition
  const transition = document.startViewTransition(() => themeCallback({ nextTheme, isSupported: true }));

  // DOM 更新完成后开始动画
  transition.ready.then(() => {
    // 定义 clip-path 动画的起始和结束状态 (从小圆到大圆)
    const clipPath =  [`circle(0px at ${X_Axis}px ${Y_Axis}px)`, `circle(${endRadius}px at ${X_Axis}px ${Y_Axis}px)`];

    // 当 diffusionMode 为 "smallToLarge" 时,开始动画为从小圆到大圆;当 diffusionMode 为 "fromBigToSmall" 时,开始动画为从大圆到小圆
    const startAnimation = diffusionMode === "smallToLarge" ? clipPath : clipPath.slice().reverse();

    // 当 diffusionMode 为 "smallToLarge" 时,结束动画为从大圆到小圆;当 diffusionMode 为 "fromBigToSmall" 时,结束动画为从小圆到大圆
    const endAnimation = diffusionMode === "smallToLarge" ? clipPath.slice().reverse() : clipPath;

    // 执行动画 (根据 nextTheme 判断是从小圆到大圆还是从大圆到小圆)
    const animation = document.documentElement.animate({ clipPath: nextTheme === "dark" ? startAnimation : endAnimation }, {
      // 动画时长(毫秒)
      duration,

      // 缓动函数(Material Design)
      easing,

      // 立即应用第一帧的样式 不应用最后一帧的样式
      fill: "backwards",

      /**
       * 指定动画作用到 View Transition 的哪个图层
       * 切到暗色主题:当 diffusionMode 为 "smallToLarge" 时,动画作用到新图层;当 diffusionMode 为 "fromBigToSmall" 时,动画作用到旧图层
       * 切到亮色主题:当 diffusionMode 为 "smallToLarge" 时,动画作用到旧图层;当 diffusionMode 为 "fromBigToSmall" 时,动画作用到新图层
       */
      pseudoElement: nextTheme === "dark" ? `::view-transition-${diffusionMode === "smallToLarge" ? 'new' : 'old'}(root)` : `::view-transition-${diffusionMode === "smallToLarge" ? 'old' : 'new'}(root)`
    });

    /**
     * 动画结束前确保不会闪
     * 监听动画结束,在伪元素被移除前手动将 clip-path 设为最终状态
     * 作用:画播放完毕的那一刻,立即强制结束整个 View Transition 过渡,onfinish 负责"掐准时机",skipTransition() 负责"立刻清场",两者配合确保动画结束和伪元素销毁之间零间隔,消除闪烁
     */
    animation.onfinish = () => transition.skipTransition();
  });

  // 动画结束,允许再次点击
  transition.finished.finally(() => delete document.documentElement.dataset.isTransitioning);
};

全局CSS样式(根据主题切换方式进行选择)

  • data-* 属性方式
css
/* 新旧页面快照基本默认样式 */
::view-transition-old(root),
::view-transition-new(root) {
  /* 去掉浏览器默认的淡入淡出 */
  animation: none !important;
  /* 正常图层混合 */
  mix-blend-mode: normal;
  /* Safari/Chrome/旧WebKit */
  -webkit-user-select: none;
  /* 旧 Firefox */
  -moz-user-select: none;
  /* IE10+/旧Edge */
  -ms-user-select: none;
  /* 标准,现代浏览器 */
  user-select: none;
}

/* 深色:新页面在上面,从按钮位置向外扩散 */
:root[data-theme="dark"]::view-transition-new(root) {
  z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
  /* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散内到外时生效】 */
  clip-path: var(--custom-theme-clip-path-outside-inside, circle(0px at 0 0));
}

:root[data-theme="dark"]::view-transition-old(root) {
  z-index: var(--custom-theme-Hierarchy-Category-II, 1);
}

/* 浅色:旧页面在上面,从外向按钮位置收缩 */
:root:not([data-theme="dark"])::view-transition-old(root) {
  z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
}

:root:not([data-theme="dark"])::view-transition-new(root) {
  z-index: var(--custom-theme-Hierarchy-Category-II, 1);
  /* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散外到内时生效】 */
  clip-path: var(--custom-theme-clip-path-inside-outside, none);
}
  • class类名方式
css
/* 新旧页面快照基本默认样式 */
::view-transition-old(root),
::view-transition-new(root) {
  /* 去掉浏览器默认的淡入淡出 */
  animation: none !important;
  /* 正常图层混合 */
  mix-blend-mode: normal;
  /* Safari/Chrome/旧WebKit */
  -webkit-user-select: none;
  /* 旧 Firefox */
  -moz-user-select: none;
  /* IE10+/旧Edge */
  -ms-user-select: none;
  /* 标准,现代浏览器 */
  user-select: none;
}

/* 深色:新页面在上面,从按钮位置向外扩散 */
:root.dark::view-transition-new(root) {
  z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
  /* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散内到外时生效】 */
  clip-path: var(--custom-theme-clip-path-outside-inside, circle(0px at 0 0));
}

:root.dark::view-transition-old(root) {
  z-index: var(--custom-theme-Hierarchy-Category-II, 1);
}

/* 浅色:旧页面在上面,从外向按钮位置收缩 */
:root:not(.dark)::view-transition-old(root) {
  z-index: var(--custom-theme-Hierarchy-Category-I, 9999);
}

:root:not(.dark)::view-transition-new(root) {
  z-index: var(--custom-theme-Hierarchy-Category-II, 1);
  /* 设置初始状态,防止火狐浏览器第一帧出现闪烁【扩散外到内时生效】 */
  clip-path: var(--custom-theme-clip-path-inside-outside, none);
}

使用方法 ​

ts
// 导入主题切换函数
import { toggleTheme } from '@/utils/circleThemeAnimation';

// 修改标题事件
const themeTap = (event: MouseEvent) => toggleTheme({ element: event.target as HTMLElement, customAttributes: "theme", themeCallback: ({ nextTheme } : { nextTheme: string })=>{
  // 切换主题
  document.documentElement.dataset.theme = nextTheme;
  // 根据主题切换favicon图标
  document.querySelector<HTMLLinkElement>("link[rel='icon']")!.href = `/favicon-${nextTheme}.ico`;
  // 保存当前主题
  localStorage.setItem("themeType", nextTheme);
}});

阻止打开控制台并跳转空白页面 ​

ts
// 禁用右键菜单
document.addEventListener('contextmenu', function(e) {
  e.preventDefault(); // 阻止默认右键行为
});

// 禁用快捷键
document.addEventListener('keydown', function(e) {
  // 定义需要阻止的快捷键组合
  const BLOCKED_KEYS = [
    // F12键
    { code: 'F12' },
    // Ctrl+U (查看网页源代码)
    { ctrlKey: true, code: 'KeyU' },
    // Mac:Command + U
    { metaKey: true, code: 'KeyU' },
    // Ctrl+Shift+I
    { ctrlKey: true, shiftKey: true, code: 'KeyI' },
    // Ctrl+Shift+J (Chrome)
    { ctrlKey: true, shiftKey: true, code: 'KeyJ' },
    // Mac:Command + Option + I
    { metaKey: true, altKey: true, code: 'KeyI' },
    // Mac:Command + Option + J
    { metaKey: true, altKey: true, code: 'KeyJ' },
  ];

  // 检查当前按下的键是否在阻止列表中
  const shouldBlock = BLOCKED_KEYS.some(k => Object.entries(k).every(([key, val]) => e[key as keyof KeyboardEvent] === val));

  // 如果匹配到阻止的快捷键,则阻止默认行为
  shouldBlock && e.preventDefault();
});

// 检测控制台打开并拦截
const checkConsole = () => {
  // 获取当前时间戳
  const start = Date.now();
  // 检测是否为移动端设备,如果是则不需要执行
  if(isMobileDevice()) return;
  // 控制台最小宽度/高度阈值
  const threshold = 160;
  // 判断控制台是否打开,如果打开则直接跳转空白页面
  (window.outerWidth - window.innerWidth > threshold || window.outerHeight - window.innerHeight > threshold) && window.location.replace('about:blank'); // 打开控制台立即跳转空白页面
  debugger;
  // 根据debugger前的时间戳判断是否是打开工作台【跳空白页和清除标签】
  Date.now() - start > 100 && (document.body.innerHTML = '', location.replace('about:blank'));
};
// 立即执行一次
checkConsole();
// 每500ms检测一次
setInterval(checkConsole, 500);

/**
 * 精准检测移动设备(含平板/智能屏/折叠屏,适配iPad Pro/Nest Hub Max等特殊设备)
 * 核心逻辑:先识别特殊设备 → 排除明确桌面设备 → 匹配通用移动设备 → 触摸设备兜底
 * @returns {boolean} true=移动/平板/智能屏设备,false=桌面设备
 */
function isMobileDevice() {
  const ua = navigator.userAgent.toLowerCase();
  const screenWidth = window.innerWidth;
  const screenHeight = window.innerHeight;

  // 1. 识别Nest Hub Max等智能屏设备
  const isSmartDisplay = /nest hub/.test(ua) || ('ontouchstart' in window && screenWidth <= 1280 && screenHeight <= 800 && /linux/.test(ua)); // 智能屏的尺寸+系统特征

  // 2. 识别iPad(覆盖所有型号)
  const isIPad = /ipad/.test(ua) || (/(macintosh|mac os)/.test(ua) && Math.min(screenWidth, screenHeight) >= 768 && Math.max(screenWidth, screenHeight) <= 1366);

  // 3. 排除桌面设备
  const isDesktop = (/windows|linux/.test(ua) && !isSmartDisplay && screenWidth > 1366) || (/mac os/.test(ua) && !isIPad && screenWidth > 1366);
  if (isDesktop) return false;

  // 4. 其他移动设备识别
  const isMobile = /iphone|ipod|android|mobile|touch|phone|blackberry|webos|iemobile|opera mini|symbian|kindle|firefox os/.test(ua);
  const isTouchDevice = 'ontouchstart' in window && Math.min(screenWidth, screenHeight) <= 1280;

  return isSmartDisplay || isIPad || isMobile || isTouchDevice;
}

Dom元素失去焦点 ​

让当前网页上获得焦点(Focus)的元素失去焦点

document.activeElement:这是 DOM 的一个属性,它会返回当前页面中正处于激活状态(拥有焦点)的元素。比如你正在输入的输入框、你按空格键选中的按钮,或者带有 tabindex 属性的元素。

.blur():这是表单元素和链接等支持焦点的元素上的一个方法,作用是移除焦点(与 focus() 相反)。

ts
if (document.activeElement && document.activeElement !== document.body) {
  document.activeElement.blur();
}

Copyright © 2026 Luke