阅读视图

发现新文章,点击刷新页面。

html翻页时钟 效果

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Flip Clock</title>
  <style>
    body {
      background: #111;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
      margin: 0;
      font-family: 'Courier New', monospace;
      color: white;
    }

    .clock {
      display: flex;
      gap: 20px;
    }

    .card-container {
      width: 80px;
      height: 120px;
      position: relative;
      perspective: 500px;
      background: #2c292c;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.5);
    }

    /* 中间分割线 */
    .card-container::before {
      content: "";
      position: absolute;
      left: 0;
      top: 50%;
      width: 100%;
      height: 4px;
      background: #120f12;
      z-index: 10;
    }

    .card-item {
      position: absolute;
      width: 100%;
      height: 50%;
      left: 0;
      overflow: hidden;
      background: #2c292c;
      color: white;
      text-align: center;
      font-size: 64px;
      font-weight: bold;
      backface-visibility: hidden;
      transition: transform 0.4s ease-in-out;
    }

    /* 下层数字:初始对折(背面朝上) */
    .card1 { /* 下层上半 */
      top: 0;
      line-height: 120px; /* 整体高度对齐 */
    }
    .card2 { /* 下层下半 */
      top: 50%;
      line-height: 0;
      transform-origin: center top;
      transform: rotateX(180deg);
      z-index: 2;
    }

    /* 上层数字:当前显示 */
    .card3 { /* 上层上半 */
      top: 0;
      line-height: 120px;
      transform-origin: center bottom;
      z-index: 3;
    }
    .card4 { /* 上层下半 */
      top: 50%;
      line-height: 0;
      z-index: 1;
    }

    /* 翻页动画触发 */
    .flip .card2 {
      transform: rotateX(0deg);
    }
    .flip .card3 {
      transform: rotateX(-180deg);
    }

    /* 冒号分隔符 */
    .colon {
      font-size: 64px;
      display: flex;
      align-items: center;
      color: #aaa;
    }
  </style>
</head>
<body>
  <div class="clock">
    <div class="card-container flip" id="hour" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>

    <div class="colon">:</div>

    <div class="card-container flip" id="minute" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>

    <div class="colon">:</div>

    <div class="card-container flip" id="second" data-number="00">
      <div class="card1 card-item">00</div>
      <div class="card2 card-item">00</div>
      <div class="card3 card-item">00</div>
      <div class="card4 card-item">00</div>
    </div>
  </div>

  <script>
    function setHTML(dom, nextValue) {
      const curValue = dom.dataset.number;
      if (nextValue === curValue) return;

      // 更新 DOM 结构:下层为新值,上层为旧值
      dom.innerHTML = `
        <div class="card1 card-item">${nextValue}</div>
        <div class="card2 card-item">${nextValue}</div>
        <div class="card3 card-item">${curValue}</div>
        <div class="card4 card-item">${curValue}</div>
      `;

      // 触发重绘以重启动画
      dom.classList.remove('flip');
      void dom.offsetWidth; // 强制重排
      dom.classList.add('flip');

      dom.dataset.number = nextValue;
    }

    function updateClock() {
      const now = new Date();
      const h = String(now.getHours()).padStart(2, '0');
      const m = String(now.getMinutes()).padStart(2, '0');
      const s = String(now.getSeconds()).padStart(2, '0');

      setHTML(document.getElementById('hour'), h);
      setHTML(document.getElementById('minute'), m);
      setHTML(document.getElementById('second'), s);
    }

    // 初始化
    updateClock();
// setTimeout(updateClock,1000)

    setInterval(updateClock, 1000);
  </script>
</body>
</html>

在这里插入图片描述 这个翻页时钟(Flip Clock)通过 CSS 3D 变换 + 动画类切换 + DOM 内容动态更新 的方式,模拟了类似机械翻页牌的效果。下面从结构、样式和逻辑三方面详细分析其实现原理:


🔧 一、HTML 结构设计

每个时间单位(小时、分钟、秒)由一个 .card-container 容器表示,内部包含 4 个 .card-item 元素

<div class="card-container" id="second">
  <div class="card1">00</div> <!-- 下层上半 -->
  <div class="card2">00</div> <!-- 下层下半(初始翻转180°)-->
  <div class="card3">00</div> <!-- 上层上半(当前显示)-->
  <div class="card4">00</div> <!-- 上层下半 -->
</div>

四个卡片的作用:

  • .card3.card4:组成当前显示的数字(上半+下半),正常显示。
  • .card1.card2:组成即将翻出的新数字,但初始时 .card2rotateX(180deg) 翻转到背面(不可见)。
  • 中间有一条 ::before 伪元素作为“折痕”,增强翻页视觉效果。

🎨 二、CSS 样式与 3D 翻转原理

关键 CSS 技术点:

1. 3D 空间设置

.card-container {
  perspective: 500px; /* 创建 3D 视角 */
}
  • perspective 让子元素的 3D 变换有景深感。

2. 上下两半的定位与旋转轴

.card2 {
  transform-origin: center top;
  transform: rotateX(180deg); /* 初始翻到背面 */
}
.card3 {
  transform-origin: center bottom;
}
  • .card2顶部边缘旋转 180°,藏在下方背面。
  • .card3底部边缘旋转,用于向上翻折。

3. 翻页动画(通过 .flip 类触发)

.flip .card2 {
  transform: rotateX(0deg); /* 展开新数字下半部分 */
}
.flip .card3 {
  transform: rotateX(-180deg); /* 当前数字上半部分向上翻折隐藏 */
}
  • 动画持续 0.4s,使用 ease-in-out 缓动。
  • .card1.card4 始终保持静态,作为背景支撑。

视觉效果

  • 上半部分(.card3)向上翻走(像书页翻开)
  • 下半部分(.card2)从背面转正,露出新数字
  • 中间的“折痕”让翻页更真实

⚙️ 三、JavaScript 动态更新逻辑

核心函数:setHTML(dom, nextValue)

步骤分解:

  1. 对比新旧值:如果相同,不更新(避免无谓动画)。
  2. 重写整个容器的 HTML
    • 下层(新值).card1.card2 显示 nextValue
    • 上层(旧值).card3.card4 显示 curValue
  3. 触发动画
    dom.classList.remove('flip');
    void dom.offsetWidth; // 强制浏览器重排(关键!)
    dom.classList.add('flip');
    
    • 先移除 .flip,再强制重排(flush styles),再加回 .flip,确保动画重新触发。
  4. 更新 data-number 保存当前值。

时间更新:

  • 每秒调用 updateClock(),获取当前时分秒(两位数格式)。
  • 分别调用 setHTML 更新三个容器。

🌟 四、为什么能实现“翻页”错觉?

元素 初始状态 翻页后状态 视觉作用
.card3 显示旧数字上半 向上翻转 180° 隐藏 模拟“翻走”的上半页
.card2 旧数字下半(翻转180°藏起) 转正显示新数字下半 模拟“翻出”的下半页
.card1 / .card4 静态背景 不变 提供视觉连续性

💡 关键技巧

  • 利用 两个完整数字(新+旧)叠加,通过控制上下半部分的旋转,制造“翻页”而非“淡入淡出”。
  • 强制重排(offsetWidth 是确保 CSS 动画每次都能重新触发的经典 hack。

✅ 总结

这个 Flip Clock 的精妙之处在于:

  1. 结构设计:4 个卡片分工明确,上下层分离。
  2. CSS 3D:利用 rotateX + transform-origin 实现真实翻页。
  3. JS 控制:动态替换内容 + 巧妙触发动画。
  4. 性能优化:仅在值变化时更新,避免无效渲染。

这是一种典型的 “用 2D DOM 模拟 3D 物理效果” 的前端动画范例,既高效又视觉惊艳。

js 封装 动画效果

/**
 * 通用动画函数
 * @param {Object} options 配置对象
 * @param {number} [options.duration] 动画持续时间 (毫秒),如果提供则优先使用
 * @param {number} [options.speed] 动画速度 (单位/毫秒),当未提供 duration 时生效
 * @param {number} options.from 起始值,默认为 0
 * @param {number} options.to 结束值
 * @param {Function} [options.callback] 每一帧的回调函数,接收 (currentValue, progress) 作为参数
 * @param {Function} [options.onComplete] 动画结束时的回调函数
 * @param {Function} [legacyCallback] 兼容旧调用的第二个参数作为回调
 * @returns {Function} 取消动画的函数
 */
let animateMoveFn = ({ duration, speed, from, to, callback, onComplete }) => {
 
    // --- 参数类型校验开始 ---
    
    // 校验 from
    if (from === undefined || from === null) {
        console.error(`animateMoveFn: "from" 必须是数字且必填。当前值: ${from}。动画将不执行。`);
        return () => { }; // 返回空的取消函数
    }
    if (typeof from !== 'number' || isNaN(from)) {
        console.warn(`animateMoveFn: "from" 必须是数字。当前值: ${from}。已重置为 0。`);
        return () => { }; // 返回空的取消函数
    }

    // 校验 to
    if (to === undefined || to === null) {
        console.error(`animateMoveFn: "to" 必须是数字且必填。当前值: ${to}。动画将不执行。`);
        return () => { }; // 返回空的取消函数
    }
    if (typeof to !== 'number' || isNaN(to)) {
        console.warn(`animateMoveFn: "to" 必须是数字。当前值: ${to}。已重置为 0。`);
        return () => { }; // 返回空的取消函数
    }

    // 校验 duration
    if (duration !== undefined && duration !== null) {
        if (typeof duration !== 'number' || isNaN(duration) || duration < 0) {
            console.warn(`animateMoveFn: "duration" 必须是非负数字。当前值: ${duration}。将忽略此参数。`);
            duration = undefined;
        }
    }

    // 校验 speed
    if (speed !== undefined && speed !== null) {
        if (typeof speed !== 'number' || isNaN(speed) || speed <= 0) {
            console.warn(`animateMoveFn: "speed" 必须是正数字。当前值: ${speed}。将忽略此参数。`);
            speed = undefined;
        }
    }

    // 校验 callback
    if (callback !== undefined && typeof callback !== 'function') {
        console.warn(`animateMoveFn: "callback" 必须是函数。当前类型: ${typeof callback}。`);
        callback = null;
    }

    // 校验 onComplete
    if (onComplete !== undefined && typeof onComplete !== 'function') {
        console.warn(`animateMoveFn: "onComplete" 必须是函数。当前类型: ${typeof onComplete}。`);
        onComplete = null;
    }

    // --- 参数类型校验结束 ---

    // 记录动画开始的时间戳
    let startTime = Date.now();

    // 存储当前的 requestAnimationFrame ID,用于取消动画
    let reqId = null;

    // 动画是否已取消的标志
    let isCancelled = false;

    // 核心动画循环函数
    let moveFn = () => {
        
        // 如果动画已取消,直接退出
        if (isCancelled) return;

        // 计算从开始到现在经过的时间
        let elapsed = Date.now() - startTime;

        // 当前动画进度 (0 到 1 之间)
        let progress = 0;

        if (duration && duration > 0) {
            // 模式 1: 基于持续时间 (Duration-based)
            progress = elapsed / duration;
        } else if (speed && speed > 0) {
            // 模式 2: 基于速度 (Speed-based)
            // 计算总距离
            let totalDistance = Math.abs(to - from);
            if (totalDistance === 0) {
                progress = 1;
            } else {
                // 已移动距离 = 速度 * 时间
                let coveredDistance = speed * elapsed;
                progress = coveredDistance / totalDistance;
            }
        } else {
            // 既无 duration 也无 speed,或者值无效,默认直接完成
            progress = 1;
        }

        // 确保进度不超过 1
        if (progress > 1) progress = 1;

        // 计算当前值:起始值 + (总变化量 * 进度)
        // 使用线性插值 (Linear Interpolation)
        let currentValue = from + (to - from) * progress;

        // 执行回调,将当前值和进度传递出去
        if (callback) {
            callback(currentValue, progress);
        }

        // 检查动画是否结束
        if (progress < 1) {
            // 动画未结束,请求下一帧
            reqId = requestAnimationFrame(moveFn);
        } else {
            // 动画结束
            onComplete(currentValue, progress);
        }
    };

    // 启动动画
    reqId = requestAnimationFrame(moveFn);

    // 返回一个取消函数,外部调用它可以立即停止动画
    return () => {
        isCancelled = true;
        if (reqId) {
            cancelAnimationFrame(reqId);
        }
    };
};

// 兼容旧的命名(如果项目中有其他地方用到)
window.animateMoeveFn = animateMoveFn;
window.animateMoveFn = animateMoveFn;

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Animation Test</title>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            padding: 20px;
            max-width: 800px;
            margin: 0 auto;
        }
        .box {
            width: 50px;
            height: 50px;
            background-color: #e74c3c;
            position: relative;
            margin-bottom: 30px;
            border-radius: 4px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-weight: bold;
            font-size: 12px;
        }
        .controls {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
            gap: 10px;
            margin-bottom: 20px;
        }
        button {
            padding: 10px 15px;
            cursor: pointer;
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 4px;
            font-size: 14px;
            transition: background 0.2s;
        }
        button:hover {
            background-color: #2980b9;
        }
        button.cancel {
            background-color: #e67e22;
        }
        button.cancel:hover {
            background-color: #d35400;
        }
        #output {
            padding: 15px;
            border: 1px solid #ddd;
            background: #f8f9fa;
            max-height: 300px;
            overflow-y: auto;
            font-family: 'Consolas', monospace;
            font-size: 13px;
            border-radius: 4px;
        }
        .log-entry {
            margin-bottom: 4px;
            border-bottom: 1px solid #eee;
            padding-bottom: 2px;
        }
        .log-time {
            color: #888;
            margin-right: 8px;
        }
        .log-success { color: #27ae60; font-weight: bold; }
        .log-warn { color: #e67e22; }
        .log-error { color: #c0392b; }
    </style>
</head>
<body>

    <h1>Animation Test for ani.js</h1>

    <div class="box" id="testBox">0</div>

    <div class="controls">
        <button id="btnDuration">1. 时长模式 (Duration)</button>
        <button id="btnSpeed">2. 速度模式 (Speed)</button>
        <button id="btnReverse">3. 反向动画 (Reverse)</button>
        <button id="btnOnComplete">4. 完整回调 (onComplete)</button>
        <button id="btnPriority">5. 优先级 (Duration > Speed)</button>
        <button id="btnError">6. 错误参数测试 (Check Console)</button>
        <button id="btnCancel" class="cancel">7. 中途取消 (Cancel)</button>
        <button id="btnClearLog" style="background:#95a5a6">清除日志</button>
    </div>

    <div id="output">日志准备就绪...</div>

    <script src="./js/ani.js"></script>
    <script>
        const box = document.getElementById('testBox');
        const output = document.getElementById('output');
        let currentCancelFn = null;

        function log(msg, type = 'normal') {
            const div = document.createElement('div');
            div.className = 'log-entry';
            
            const timeSpan = document.createElement('span');
            timeSpan.className = 'log-time';
            timeSpan.textContent = `[${new Date().toLocaleTimeString()}]`;
            
            const msgSpan = document.createElement('span');
            msgSpan.textContent = msg;
            
            if (type === 'success') msgSpan.className = 'log-success';
            if (type === 'warn') msgSpan.className = 'log-warn';
            if (type === 'error') msgSpan.className = 'log-error';

            div.appendChild(timeSpan);
            div.appendChild(msgSpan);
            output.prepend(div);
        }

        function reset(startVal = 0) {
            if (currentCancelFn) {
                currentCancelFn();
                currentCancelFn = null;
                log('上一个动画已终止', 'warn');
            }
            box.style.left = startVal + 'px';
            box.textContent = Math.round(startVal);
        }

        // 1. 基础时长模式
        document.getElementById('btnDuration').onclick = () => {
            reset(0);
            log('测试1: 基于 Duration (0 -> 500px, 1000ms)');
            
            currentCancelFn = animateMoveFn({
                duration: 1000,
                from: 0,
                to: 500,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: (val) => {
                    log(`动画结束: 到达 ${val}px`, 'success');
                }
            });
        };

        // 2. 速度模式
        document.getElementById('btnSpeed').onclick = () => {
            reset(0);
            log('测试2: 基于 Speed (0 -> 500px, speed: 0.5px/ms)');
            log('预期耗时: 500 / 0.5 = 1000ms');

            currentCancelFn = animateMoveFn({
                speed: 0.5, // 0.5px per ms = 500px per second
                from: 0,
                to: 500,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: (val) => {
                    log(`动画结束: 到达 ${val}px`, 'success');
                }
            });
        };

        // 3. 反向动画
        document.getElementById('btnReverse').onclick = () => {
            reset(500);
            log('测试3: 反向动画 (500 -> 0px, speed: 1px/ms)');
            
            currentCancelFn = animateMoveFn({
                speed: 1, // 1000px/s, fast!
                from: 500,
                to: 0,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: () => log('反向动画结束', 'success')
            });
        };

        // 4. onComplete 测试
        document.getElementById('btnOnComplete').onclick = () => {
            reset(0);
            log('测试4: 测试 onComplete 回调');
            
            currentCancelFn = animateMoveFn({
                duration: 500,
                from: 0,
                to: 200,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: (val, progress) => {
                    log(`onComplete 触发! Val: ${val}, Progress: ${progress}`, 'success');
                    box.style.backgroundColor = '#2ecc71'; // 变绿
                    setTimeout(() => box.style.backgroundColor = '#e74c3c', 500); // 变回红
                }
            });
        };

        // 5. 优先级测试
        document.getElementById('btnPriority').onclick = () => {
            reset(0);
            log('测试5: 优先级测试 (传入 duration=2000 和 speed=10)');
            log('预期: 应该使用 duration (2秒),忽略极快的 speed');

            currentCancelFn = animateMoveFn({
                duration: 2000,
                speed: 10, // 如果生效只要 50ms,如果不生效要 2000ms
                from: 0,
                to: 500,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: () => log('动画结束 (检查耗时是否接近 2秒)', 'success')
            });
        };

        // 6. 错误参数测试
        document.getElementById('btnError').onclick = () => {
            reset(0);
            log('测试6: 错误参数 (请查看浏览器控制台 Console)', 'warn');
            
            // Case A: 缺少 to
            log('Case A: 缺少 "to" 参数 -> 应该报错不执行');
            animateMoveFn({ duration: 1000, from: 0 });

            // Case B: 错误的 duration
            // setTimeout(() => {
            //     log('Case B: duration 为字符串 -> 应该警告并忽略');
            //     animateMoveFn({ 
            //         duration: "invalid", 
            //         speed: 1, // 备用方案
            //         from: 0, 
            //         to: 100,
            //         callback: (v) => box.style.left = v + 'px'
            //     });
            // }, 500);
        };

        // 7. 取消测试
        document.getElementById('btnCancel').onclick = () => {
            reset(0);
            log('测试7: 启动并在 500ms 后取消');

            currentCancelFn = animateMoveFn({
                duration: 2000,
                from: 0,
                to: 800,
                callback: (val) => {
                    box.style.left = val + 'px';
                    box.textContent = Math.round(val);
                },
                onComplete: () => log('ERROR: 动画不应该完成!', 'error')
            });

            setTimeout(() => {
                if (currentCancelFn) {
                    currentCancelFn();
                    currentCancelFn = null;
                    log('已调用 cancel()', 'warn');
                }
            }, 500);
        };

        document.getElementById('btnClearLog').onclick = () => {
            output.innerHTML = '';
            log('日志已清空');
        };

    </script>
</body>
</html>


在这里插入图片描述

ani.js 动画库实现原理解析教程

本教程将带你深入了解 ani.js 的实现原理。这是一个轻量级的通用动画函数,旨在通过精确的时间控制来实现平滑的数值过渡效果。它不仅支持传统的时长模式 (Duration),还创新地引入了速度模式 (Speed),非常适合用于 UI 交互、游戏开发或任何需要动态数值变化的场景。

1. 核心设计理念

ani.js 的核心思想是基于时间 (Time-based) 而非基于帧数 (Frame-based)

  • 基于帧数:每一帧增加固定的数值。如果设备卡顿,掉帧会导致动画变慢,总时长不可控。
  • 基于时间:根据当前时间与开始时间的差值 (elapsed) 来计算当前应处的位置。无论帧率如何波动,动画总是在预定的时间到达终点,保证了动画的流畅性和同步性。

2. 函数签名与参数设计

函数采用单一对象参数 options 的设计模式,这使得参数扩展变得非常灵活,同时保持了调用的清晰度。

let animateMoveFn = ({ 
    duration,   // 动画持续时间 (毫秒)
    speed,      // 动画速度 (单位/毫秒)
    from = 0,   // 起始值 (默认为 0)
    to,         // 结束值 (必填)
    callback,   // 每帧回调:(currentValue, progress) => {}
    onComplete  // 结束回调:(finalValue, progress) => {}
}) => { ... }

亮点分析:

  • 双模式驱动
    1. 时长优先:如果你提供了 duration,动画将严格在指定时间内完成。
    2. 速度优先:如果你未提供 duration 但提供了 speed,函数会自动根据 Math.abs(to - from) 计算所需时间。
  • 健壮性校验:函数内部对所有参数进行了严格的类型检查(如 typeof, isNaN),确保无效参数不会导致运行时错误,并提供友好的控制台警告。

3. 核心实现深度解析

3.1 动画循环 (The Loop)

动画引擎的心脏是 requestAnimationFrame。它比 setInterval 更高效,因为它会跟随浏览器的刷新率(通常是 60Hz),并在后台标签页暂停执行以节省电量。

let startTime = Date.now();
let moveFn = () => {
    // 1. 计算流逝的时间
    let elapsed = Date.now() - startTime;
    
    // 2. 计算进度 (0.0 ~ 1.0)
    // ... (核心算法见下文)

    // 3. 更新数值并绘制
    // ...

    // 4. 决定下一帧
    if (progress < 1) {
        reqId = requestAnimationFrame(moveFn);
    } else {
        // 动画结束
    }
};

3.2 进度计算策略 (The Math)

这是 ani.js 最精彩的部分。它根据输入模式动态决定进度计算方式:

模式 A:时长模式 (Duration Mode) 最常见的模式。进度等于“已过去的时间”除以“总时长”。

progress = elapsed / duration;

模式 B:速度模式 (Speed Mode) 当距离不确定,但希望保持恒定速度时使用(例如:无论滑块拖动多远,回弹速度一致)。

let totalDistance = Math.abs(to - from);
let coveredDistance = speed * elapsed; // 速度 * 时间 = 路程
progress = coveredDistance / totalDistance;

3.3 线性插值 (Linear Interpolation / Lerp)

一旦算出 progress (0 到 1 之间的浮点数),我们就可以计算当前的数值:

// 公式:当前值 = 起始值 + (总变化量 * 进度)
let currentValue = from + (to - from) * progress;

这个公式非常强大:

  • progress = 0 时,结果为 from
  • progress = 1 时,结果为 to
  • progress = 0.5 时,结果正好在中间。
  • 支持反向:即使 to < from,公式依然成立(因为 to - from 会是负数)。

3.4 生命周期管理与取消机制

为了让动画可控,函数返回了一个闭包函数 (Closure),用于取消动画。

return () => {
    isCancelled = true; // 标志位:阻止后续帧执行
    if (reqId) cancelAnimationFrame(reqId); // 清除浏览器队列中的请求
};

这种设计允许外部代码随时打断动画(例如用户再次触发了新的动画),防止多个动画冲突。

4. 最佳实践与使用示例

场景一:基础位移 (1秒内移动到 500px)

const cancel = animateMoveFn({
    duration: 1000,
    from: 0,
    to: 500,
    callback: (val) => element.style.left = val + 'px'
});

场景二:恒定速度回弹 (无论多远,速度都是 2px/ms)

const cancel = animateMoveFn({
    speed: 2, // 2000px/s,非常快
    from: currentPosition, // 动态获取当前位置
    to: 0,
    callback: (val) => element.style.left = val + 'px'
});

场景三:防止动画冲突 (Anti-conflict)

在启动新动画前,务必取消旧动画。

let currentAnim = null;

function startNewAnim() {
    if (currentAnim) currentAnim(); // 停止旧的
    
    currentAnim = animateMoveFn({
        to: 100,
        // ...
        onComplete: () => currentAnim = null // 结束后清理引用
    });
}

5. 总结

ani.js 是一个教科书式的现代 JavaScript 动画实现。它展示了如何通过:

  1. 参数解构与默认值 来提升 API 易用性。
  2. 防御性编程 来处理无效输入。
  3. 时间轴插值算法 来保证动画平滑度。
  4. 闭包与高阶函数 来管理状态和副作用。

掌握了这个函数的实现,你就掌握了前端动画引擎的基石。

gsap 配置解读 --7

什么是registerEffect

 <div class="card">
      <h1>案例 41:registerEffect 自定义效果</h1>
      <p>封装常用动画为可复用效果。</p>
      <div class="row">
        <div class="box" id="boxA"></div>
        <div class="box" id="boxB"></div>
        <div class="box" id="boxC"></div>
      </div>
      <button id="play">播放效果</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script>
      const playButton = document.querySelector("#play");

      // 注册自定义效果
      gsap.registerEffect({
        name: "popIn",
        effect: (targets, config) => {
          return gsap.fromTo(
            targets,
            { scale: 0.6, opacity: 0 },
            {
              scale: 1,
              opacity: 1,
              duration: config.duration,
              stagger: config.stagger,
              ease: config.ease
            }
          );
        },
        defaults: { duration: 0.6, stagger: 0.08, ease: "back.out(1.6)" },
        extendTimeline: true
      });

      playButton.addEventListener("click", () => {
        gsap.effects.popIn(".box");
      });
    </script>

gsap.registerEffect()GSAP(GreenSock Animation Platform) 提供的一个强大功能,用于将常用的动画逻辑封装成可复用、可配置的“自定义效果”(custom effect),就像创建自己的动画“函数库”一样。


📌 简单定义:

registerEffect 允许你定义一个命名动画模板(如 "popIn"),之后通过 gsap.effects.effectName(targets, config) 一行代码即可在任意地方调用它,实现代码复用、语义化和团队协作标准化。


✅ 核心作用:

1. 封装复杂动画逻辑

把重复的 fromTotimeline 等逻辑打包成一个“黑盒”。

2. 支持参数配置

通过 config 对象传入自定义参数(如 durationstagger)。

3. 提供默认值

通过 defaults 设置常用参数的默认值,调用时可省略。

4. 无缝集成 GSAP 生态
  • 可用于 Timeline
  • 支持 stagger
  • 返回动画实例(可 play/pause/reverse

🔧 在你的代码中:

gsap.registerEffect({
  name: "popIn", // 效果名称
  effect: (targets, config) => {
    return gsap.fromTo(
      targets,
      { scale: 0.6, opacity: 0 },
      {
        scale: 1,
        opacity: 1,
        duration: config.duration,
        stagger: config.stagger,
        ease: config.ease
      }
    );
  },
  defaults: { 
    duration: 0.6, 
    stagger: 0.08, 
    ease: "back.out(1.6)" 
  },
  extendTimeline: true // 允许在 Timeline 中直接使用 .popIn()
});

然后调用:

gsap.effects.popIn(".box"); // 所有 .box 元素执行 popIn 动画

效果:

  • 三个盒子依次从小且透明放大到正常尺寸;
  • 带有弹性回弹(back.out 缓动);
  • 每个盒子延迟 0.08s 启动(stagger)。

🌟 优势 vs 普通函数封装:

普通函数 registerEffect
需手动管理返回值 ✅ 自动注册到 gsap.effects 命名空间
无法在 Timeline 中直接使用 ✅ 开启 extendTimeline: true 后可用 tl.popIn(...)
参数处理需自己写 ✅ 自动合并 configdefaults
团队协作需文档说明 ✅ 效果名称即文档(gsap.effects.popIn 语义清晰)

⚙️ 参数详解:

字段 说明
name 效果名称(字符串),注册后可通过 gsap.effects[name] 调用
effect(targets, config) 动画工厂函数: - targets: DOM 元素或选择器 - config: 用户传入的配置对象
defaults 默认配置(会被 config 覆盖)
extendTimeline 若为 true,可在 Timeline 实例上直接调用该效果: timeline.popIn(".box")

🛠️ 更多使用方式:

1. 传入自定义参数
gsap.effects.popIn(".item", {
  duration: 1,
  stagger: 0.2,
  ease: "elastic.out(1, 0.5)"
});
2. 在 Timeline 中使用(需 extendTimeline: true
const tl = gsap.timeline();
tl.popIn(".box", { duration: 0.5 });
3. 返回 Timeline 实现复杂效果
effect: (targets) => {
  const tl = gsap.timeline();
  tl.from(targets, { x: -100, opacity: 0 })
    .to(targets, { rotation: 360 }, "<");
  return tl;
}

🎨 典型应用场景:

  • UI 组件库:统一按钮点击、卡片入场、提示弹出等动效
  • 设计系统:确保全站动画风格一致
  • 游戏开发:角色受伤、道具拾取等特效复用
  • 快速原型:设计师给效果命名,开发者一键实现

⚠️ 注意事项:

  • 效果名称全局唯一,避免冲突;
  • targets 可以是单个元素、数组或 CSS 选择器字符串;
  • 免费功能registerEffect 是 GSAP 核心 API,无需额外插件或会员;
  • 如果效果内部使用了 ScrollTrigger 等插件,需确保已注册。

📚 官方文档:

👉 greensock.com/docs/v3/GSA…


✅ 总结:

gsap.registerEffect() 是 GSAP 的“动画组件化”方案——它将零散的动画代码提炼为可命名、可配置、可复用的效果模块,大幅提升开发效率与代码可维护性,是构建大型交互动效项目的最佳实践。

什么是ScrollTrigger

<header>
      <h1>案例 42:ScrollTrigger Pin 固定</h1>
      <p>滚动时固定元素并配合进度动画。</p>
    </header>
    <div class="spacer"></div>
    <section>
      <div class="panel">
        <div class="pin" id="pin">我被固定了</div>
      </div>
    </section>
    <section>
      <div class="panel">继续往下滚动</div>
    </section>
    <div class="spacer"></div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
    <script>
      // 注册 ScrollTrigger 插件
      gsap.registerPlugin(ScrollTrigger);

      // pin + scrub 绑定滚动进度
      gsap.to("#pin", {
        scale: 0.8,
        rotation: 10,
        scrollTrigger: {
          trigger: "#pin",
          start: "top center",
          end: "+=400",
          scrub: true,
          pin: true
        }
      });
    </script>

ScrollTriggerGSAP(GreenSock Animation Platform) 中最强大、最受欢迎的插件之一,它能将滚动行为(用户上下滑动页面)与GSAP 动画无缝结合,实现如“视差滚动”、“进度条动画”、“元素固定(Pin)”、“滚动触发动画”等高级交互动效。


📌 简单定义:

ScrollTrigger 让你把任何 GSAP 动画“绑定”到页面滚动位置上——当用户滚动到某个区域时,动画自动播放、暂停、反向或跟随滚动进度实时更新。

它本质上是一个滚动驱动的动画控制器


✅ 核心能力:

1. 触发动画(Toggle)
  • 当元素进入/离开视口时,播放/暂停动画。
scrollTrigger: {
  trigger: ".section",
  start: "top center", // 当 .section 顶部到达视口中心时触发
  toggleActions: "play none none reverse"
}
2. 滚动进度驱动(Scrub)
  • 动画进度完全跟随滚动位置,形成“拖拽式”效果。
scrollTrigger: {
  scrub: true // 滚动多少,动画就播放到多少
}
3. 固定元素(Pin)你的案例重点
  • 在滚动过程中将元素“钉”在视口某处,直到滚动结束。
scrollTrigger: {
  pin: true,       // 固定 trigger 元素
  // 或 pin: "#otherElement" 固定其他元素
  end: "+=400"     // 固定持续 400px 的滚动距离
}
4. 标记与指示器(Markers)
  • 开发时显示调试线(start/end 位置),方便调整。
scrollTrigger: {
  markers: true // 显示绿色(start)和红色(end)标记线
}

gsap.to("#pin", {
  scale: 0.8,
  rotation: 10,
  scrollTrigger: {
    trigger: "#pin",      // 监听 #pin 元素的滚动位置
    start: "top center",  // 当 #pin 顶部到达视口中心时开始
    end: "+=400",         // 滚动再往下 400px 后结束
    scrub: true,          // 动画进度随滚动平滑更新
    pin: true             // 在 start → end 区间内,#pin 被固定住
  }
});
用户体验流程:
  1. 向下滚动,当 #pin 到达屏幕中央时 →
  2. #pin 被固定在当前位置(不再随页面滚动而移动);
  3. 继续滚动的 400px 过程中,#pin 逐渐缩小并旋转scale: 0.8, rotation: 10);
  4. 滚动超过 400px 后,固定解除,#pin 随页面继续滚动。

💡 这就是“固定 + 进度动画”的经典组合,常用于产品展示、故事叙述等场景。


🌟 典型应用场景:

效果 描述
视差滚动 背景图慢速移动,前景快移
进度条/数字计数器 滚动时数字从 0 增长到目标值
元素入场/离场 卡片滑入、标题淡入
固定导航栏 滚动到某区域时导航栏吸顶
横向滚动画廊 垂直滚动驱动水平位移
3D 视差 滚动时多层元素产生景深感

⚙️ 关键配置项说明:

配置 作用
trigger 触发动画的参考元素(默认为动画目标)
start 动画开始的滚动位置(如 "top center"
end 动画结束的滚动位置(如 "bottom bottom""+=500"
scrub true = 平滑跟随滚动;number = 延迟秒数
pin true = 固定 trigger"#id" = 固定指定元素
toggleActions 控制进入/离开时的播放行为(play pause resume reset

📏 位置语法"edge1 edge2"

  • edge1: trigger 元素的边缘(top/bottom/center
  • edge2: 视口的边缘(top/bottom/center
    例如:"top bottom" = trigger 顶部碰到视口底部时触发

⚠️ 注意事项:

  • 必须注册插件:gsap.registerPlugin(ScrollTrigger)
  • pin自动包裹元素并设置 position: stickyfixed,无需手动写 CSS;
  • 如果页面高度不足,可能看不到完整效果(需确保有足够滚动空间);
  • 免费功能ScrollTrigger 是 GSAP 标准插件(无需 Club 会员);
  • 移动端性能优秀,支持触摸滚动。

📚 官方资源:


✅ 总结:

ScrollTrigger 是 GSAP 赋予网页“电影级滚动叙事能力”的核心插件——它将枯燥的滚动转化为精准、流畅、富有表现力的动画触发器,是现代高端网站交互动效的事实标准。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GSAP 案例 43 - ScrollTrigger toggleClass</title>
    <style>
      body {
        margin: 0;
        font-family: "Segoe UI", sans-serif;
        background: #0f172a;
        color: #e2e8f0;
      }
      header {
        padding: 80px 24px 40px;
        text-align: center;
      }
      section {
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .panel {
        width: 70%;
        max-width: 680px;
        padding: 32px;
        border-radius: 24px;
        background: #111827;
        box-shadow: 0 25px 60px rgba(15, 23, 42, 0.5);
        transition: transform 0.3s ease, background 0.3s ease;
      }
      .panel.active {
        transform: scale(1.03);
        background: #1f2937;
      }
      .spacer {
        height: 40vh;
      }
    </style>
  </head>
  <body>
    <header>
      <h1>案例 43:toggleClass 触发样式</h1>
      <p>滚动到卡片时添加高亮样式。</p>
    </header>
    <div class="spacer"></div>
    <section>
      <div class="panel" id="panel">滚动到这里会高亮</div>
    </section>
    <div class="spacer"></div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
    <script>
      // 注册 ScrollTrigger 插件
      gsap.registerPlugin(ScrollTrigger);

      ScrollTrigger.create({
        trigger: "#panel",
        start: "top 70%",
        end: "top 40%",
        toggleClass: "active"
      });
    </script>
  </body>
</html>

这段代码展示了 GSAP 的 ScrollTrigger 插件中一个非常实用的功能:toggleClass。它的作用是——

当用户滚动到指定区域时,自动给目标元素添加或移除一个 CSS 类名,从而触发样式变化(如高亮、缩放、变色等),无需手动编写 JavaScript 逻辑。


🔍 逐行解析核心部分:

ScrollTrigger.create({
  trigger: "#panel",     // 监听 #panel 元素的滚动位置
  start: "top 70%",      // 当 #panel 的顶部进入视口 70% 位置时 → 添加类
  end: "top 40%",        // 当 #panel 的顶部到达视口 40% 位置时 → 移除类
  toggleClass: "active"  // 要切换的 CSS 类名
});
📏 滚动位置语法说明:
  • "top 70%" 表示:trigger 元素的顶部视口的 70% 高度线 对齐。
  • 视口从上到下:0%(顶部)→ 100%(底部)
  • 所以 70% 在视口偏下方,40% 在视口偏上方。

效果逻辑

  • 向下滚动,当 #panel 进入视口下部(70%) 时 → 添加 .active
  • 继续滚动,当 #panel 上升到视口上部(40%) 时 → 移除 .active
  • 向上滚动时行为相反(进入 end 区域加类,离开 start 区域去类)

🎨 CSS 配合实现高亮:

.panel {
  /* 默认样式 */
  background: #111827;
  transform: scale(1);
}

.panel.active {
  /* 滚动到区域时激活 */
  background: #1f2937;     /* 背景变亮 */
  transform: scale(1.03);  /* 轻微放大 */
}

通过 transition 实现了平滑过渡,视觉反馈更自然。


toggleClass 的优势:

传统方式 使用 toggleClass
需监听 scroll 事件 + 计算位置 + 手动 classList.add/remove ✅ 一行配置自动完成
容易性能差(频繁触发 scroll) ✅ ScrollTrigger 内部优化(requestAnimationFrame + 节流)
逻辑分散,难维护 ✅ 声明式写法,意图清晰

⚙️ 其他用法示例:

1. 切换多个类
toggleClass: "highlight zoom-in"
2. 作用于其他元素
ScrollTrigger.create({
  trigger: "#section",
  toggleClass: { targets: ".nav-item", className: "current" }
});
3. 配合 onToggle 回调
ScrollTrigger.create({
  trigger: "#panel",
  toggleClass: "active",
  onToggle: self => console.log("是否激活:", self.isActive)
});

⚠️ 注意事项:

  • toggleClassScrollTrigger内置功能,无需额外插件;
  • 类名切换是双向的:进入区间加类,离开区间去类;
  • 如果 startend 顺序颠倒(如 start: "top 40%", end: "top 70%"),则行为反转(常用于“离开时激活”);
  • 移动端兼容性良好,支持触摸滚动。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrollTrigger.toggleClass 是实现“滚动高亮”、“区域激活”等交互的最简洁方案——它将复杂的滚动监听与 DOM 操作封装成声明式配置,让你专注于 CSS 样式设计,大幅提升开发效率与代码可读性。

getProperty + getVelocity 是什么

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GSAP 案例 44 - velocity / getProperty</title>
    <style>
      body {
        margin: 0;
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: "Segoe UI", sans-serif;
        background: #0f172a;
        color: #e2e8f0;
      }
      .card {
        width: 620px;
        padding: 28px;
        border-radius: 20px;
        background: #111827;
        box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
      }
      .track {
        height: 100px;
        border-radius: 14px;
        background: #0b1220;
        position: relative;
        margin: 18px 0;
      }
      .dot {
        width: 32px;
        height: 32px;
        border-radius: 50%;
        background: #a3e635;
        position: absolute;
        top: 34px;
        left: 20px;
      }
      .info {
        font-size: 14px;
        color: #94a3b8;
      }
      button {
        width: 100%;
        margin-top: 12px;
        padding: 12px 16px;
        border: none;
        border-radius: 12px;
        font-size: 14px;
        cursor: pointer;
        background: #a3e635;
        color: #0f172a;
        font-weight: 600;
      }
    </style>
  </head>
  <body>
    <div class="card">
      <h1>案例 44:getProperty + getVelocity</h1>
      <p>读取属性与速度,了解当前运动状态。</p>
      <div class="track">
        <div class="dot" id="dot"></div>
      </div>
      <div class="info" id="info">x: 0 | velocity: 0</div>
      <button id="play">播放</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script>
      const dot = document.querySelector("#dot");
      const info = document.querySelector("#info");
      const playButton = document.querySelector("#play");

      const tween = gsap.to(dot, {
        x: 480,
        duration: 2,
        ease: "power1.inOut",
        paused: true,
        onUpdate: () => {
          const x = Math.round(gsap.getProperty(dot, "x"));
          const velocity = Math.round(tween.getVelocity());
          info.textContent = `x: ${x} | velocity: ${velocity}`;
        }
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>
  </body>
</html>

GSAP(GreenSock Animation Platform) 中,gsap.getProperty()tween.getVelocity() 是两个用于实时读取动画状态的实用工具方法,常用于调试、交互反馈或基于物理状态的逻辑判断。


✅ 一、gsap.getProperty(target, property, unit?)

🔍 作用:

获取目标元素当前被 GSAP 控制的某个 CSS 属性或 transform 值。

即使该属性是通过 transform(如 x, y, rotation)设置的,也能正确返回数值。

📌 语法:
gsap.getProperty(element, "propertyName", "unit?");
  • element:DOM 元素
  • "propertyName":属性名,如 "x", "opacity", "backgroundColor"
  • "unit?"(可选):指定返回单位,如 "px", "deg";默认返回纯数字
const x = Math.round(gsap.getProperty(dot, "x"));
  • 实时读取小球当前的 水平位移 x 值(以像素为单位的数字)
  • 即使你用 gsap.to(dot, { x: 480 }) 设置的是“相对位移”,getProperty 也能返回绝对计算值

⚠️ 注意:它读取的是 GSAP 内部记录的值,不是 getComputedStyle() 的结果,因此更准确、更高效(尤其对 transform 属性)。


✅ 二、tween.getVelocity()

🔍 作用:

获取当前动画目标属性的瞬时速度(单位/秒)。

对于多属性动画(如同时动 xy),默认返回第一个属性的速度;也可指定属性:

tween.getVelocity("x") // 获取 x 方向速度
📌 特点:
  • 速度单位:每秒变化量(如 px/s, deg/s
  • 方向有正负:+ 表示正向(如向右),- 表示反向(如向左)
  • onUpdate 回调中调用最准确
const velocity = Math.round(tween.getVelocity());
  • 返回小球在 x 方向上的当前速度(px/s)
  • 动画开始和结束时速度接近 0(因为使用了 power1.inOut 缓动)
  • 中间时刻速度最大(约 ±240 px/s)

🔬 动画过程中的典型值(duration: 2s, x: 0 → 480):

时间 x velocity (px/s) 说明
0s 0 0 起始,静止
0.5s ~120 ~240 加速到峰值
1.0s 240 0 中点,瞬时静止(inOut 对称)
1.5s ~360 ~-240 反向加速(减速阶段)
2.0s 480 0 结束,静止

📌 注意:power1.inOut 是先加速后减速,在中点速度为 0(这是缓动函数决定的)。


🌟 典型应用场景:

场景 用途
物理模拟 根据速度决定反弹强度、摩擦力
交互反馈 鼠标松开时根据拖拽速度继续滑动(惯性滚动)
游戏开发 判断角色是否在移动、碰撞检测
动画调试 实时监控属性与速度变化
动态效果 速度越大,粒子越多 / 模糊越强

⚠️ 注意事项:

  • getProperty 仅能读取 GSAP 已经控制过的属性
  • getVelocity() 必须在 动画进行中 调用才有意义(暂停/结束后返回 0);
  • 对于 Timeline,需在具体 tween 上调用 getVelocity()
  • 这两个方法都是 GSAP 核心 API,无需额外插件。

📚 官方文档:


✅ 总结:

gsap.getProperty()tween.getVelocity() 是 GSAP 提供的“动画状态探测器”——它们让你能精确掌握元素当前的位置和运动速度,为构建基于物理、交互或调试需求的高级动画提供了关键数据支持。

什么是utils.random / wrap / interpolate

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>GSAP 案例 45 - utils random/wrap/interpolate</title>
  <style>
    body {
      margin: 0;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      font-family: "Segoe UI", sans-serif;
      background: #0b1020;
      color: #e2e8f0;
    }

    .card {
      width: 640px;
      padding: 28px;
      border-radius: 20px;
      background: #111827;
      box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
    }

    .stage {
      height: 160px;
      border-radius: 16px;
      background: #0f172a;
      position: relative;
      overflow: hidden;
      margin: 18px 0;
    }

    .dot {
      width: 30px;
      height: 30px;
      border-radius: 50%;
      background: #38bdf8;
      position: absolute;
      top: 65px;
      left: 20px;
    }

    button {
      width: 100%;
      padding: 12px 16px;
      border: none;
      border-radius: 12px;
      font-size: 14px;
      cursor: pointer;
      background: #38bdf8;
      color: #0f172a;
      font-weight: 600;
    }

    .info {
      margin-top: 8px;
      font-size: 13px;
      color: #94a3b8;
    }
  </style>
</head>

<body>
  <div class="card">
    <h1>案例 45:utils.random / wrap / interpolate</h1>
    <p>快速生成随机值、循环值与插值。</p>
    <div class="stage">
      <div class="dot" id="dot"></div>
    </div>
    <button id="play">随机移动</button>
    <div class="info" id="info"></div>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
  <script>
    const dot = document.querySelector("#dot");
    const info = document.querySelector("#info");
    const playButton = document.querySelector("#play");

    // random 生成随机数,wrap 限制循环范围,interpolate 计算插值
    const randomX = gsap.utils.random(40, 540, 1);
    const wrapHue = gsap.utils.wrap(0, 360);
    const mix = gsap.utils.interpolate(0, 1);

    let step = 0;

    playButton.addEventListener("click", () => {
      const x = randomX;
      const hue = wrapHue(step * 80);
      const scale = mix(0.7, 1.4);

      gsap.to(dot, {
        x,
        scale,
        backgroundColor: `hsl(${hue}, 90%, 60%)`,
        duration: 0.6,
        ease: "power2.out"
      });

      info.textContent = `x: ${x}px | hue: ${hue} | scale: ${scale.toFixed(2)}`;
      step += 1;
    });
  </script>
</body>

</html>

GSAP(GreenSock Animation Platform) 中,gsap.utils 是一个内置的实用工具函数集合,提供了许多高效、简洁的辅助方法,用于处理动画中常见的数学和逻辑操作。

你提到的三个方法:

  • gsap.utils.random
  • gsap.utils.wrap
  • gsap.utils.interpolate

是其中最常用、最强大的三个工具,分别用于生成随机值循环限制范围计算插值。它们让复杂逻辑变得简单,且性能优异。


✅ 1. gsap.utils.random(min, max, [step])

🔍 作用:

生成一个指定范围内的随机数(可选步长)。

📌 语法:
const rand = gsap.utils.random(min, max, step);
  • min:最小值
  • max:最大值
  • step(可选):步长(如 1 表示整数,0.1 表示保留一位小数)

⚠️ 注意:它返回的是一个函数!调用该函数才会生成新随机数。

但如果你直接传数字(如你的代码),GSAP 会自动缓存一次结果(等价于 random(40, 540, 1)())。

const randomX = gsap.utils.random(40, 540, 1); // 实际返回一个数字(因为未作为函数调用)
  • 每次点击按钮,x 都是一个 40~540 之间的整数
  • 用于让小球随机水平移动

✅ 更推荐写法(每次点击都新随机):

playButton.addEventListener("click", () => {
  const x = gsap.utils.random(40, 540, 1)(); // 加 () 才是函数调用
});

✅ 2. gsap.utils.wrap(min, max)

🔍 作用:

将任意数值“包裹”到 [min, max) 范围内,实现无缝循环(类似取模 %,但支持浮点数和负数)。

📌 语法:
const wrap = gsap.utils.wrap(min, max);
const result = wrap(value); // 返回循环后的值
🌰 例子:
const wrap360 = gsap.utils.wrap(0, 360);
wrap360(400)   // → 40   (400 - 360)
wrap360(-20)   // → 340  (-20 + 360)
wrap360(720)   // → 0    (720 % 360)
const hue = wrapHue(step * 80); // step=0→0, step=1→80, step=2→160, step=3→240, step=4→320, step=5→40→40...
  • 实现 HSL 色相(0~360)的循环切换,避免颜色溢出
  • 视觉上形成:蓝 → 紫 → 红 → 橙 → 黄 → 绿 → 蓝 … 的循环

✅ 3. gsap.utils.interpolate(start, end)

🔍 作用:

创建一个插值函数,根据进度值(0~1)计算 startend 之间的中间值。

📌 语法:
const interpolator = gsap.utils.interpolate(start, end);
const value = interpolator(progress); // progress ∈ [0, 1]
🌰 例子:
const mix = gsap.utils.interpolate(10, 50);
mix(0)   // → 10
mix(0.5) // → 30
mix(1)   // → 50

它不仅支持数字,还支持颜色、数组、甚至对象

const scale = mix(0.7, 1.4); // ❌ 这里有误!

正确用法应该是:

// 先创建插值函数
const scaleInterp = gsap.utils.interpolate(0.7, 1.4);

// 再用 0~1 之间的值去插值(比如用 Math.random())
const scale = scaleInterp(Math.random());

这会导致 scale = 0.7(因为 mix(0.7) ≈ 0.7,第二个参数被忽略)。

修复建议:

// 方案 1:直接随机 scale
const scale = gsap.utils.random(0.7, 1.4, 0.01)();

// 方案 2:用插值 + 随机进度
const getScale = gsap.utils.interpolate(0.7, 1.4);
const scale = getScale(Math.random());

🌟 总结对比:

工具 用途 返回值 典型场景
random(min, max, step) 生成随机数 函数(或直接数值) 随机位置、延迟、颜色
wrap(min, max) 循环限制数值 函数 色相循环、角度归一化、无限滚动
interpolate(a, b) 计算 a→b 的中间值 函数 动态缩放、颜色混合、进度映射

🎯 高级技巧(Bonus):

支持颜色插值:
const colorMix = gsap.utils.interpolate("red", "blue");
colorMix(0.5); // → "rgb(128, 0, 128)"(紫色)
数组插值:
const pointMix = gsap.utils.interpolate([0, 0], [100, 200]);
pointMix(0.5); // → [50, 100]

📚 官方文档:

👉 greensock.com/docs/v3/GSA…


✅ 最终总结:

gsap.utils.randomwrapinterpolate 是 GSAP 提供的“动画数学工具箱”——它们以极简 API 解决了随机性、循环性和连续性三大常见需求,让你无需手写复杂公式,即可构建丰富、动态、可控的交互动效。

什么是timeScale / yoyoEase

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GSAP 案例 46 - timeScale / yoyoEase</title>
    <style>
      body {
        margin: 0;
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: "Segoe UI", sans-serif;
        background: #0f172a;
        color: #e2e8f0;
      }
      .card {
        width: 620px;
        padding: 28px;
        border-radius: 20px;
        background: #111827;
        box-shadow: 0 20px 50px rgba(15, 23, 42, 0.5);
      }
      .track {
        height: 90px;
        border-radius: 14px;
        background: #0b1220;
        position: relative;
        margin: 18px 0;
      }
      .ball {
        width: 46px;
        height: 46px;
        border-radius: 50%;
        background: #f472b6;
        position: absolute;
        top: 22px;
        left: 20px;
      }
      .controls {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 10px;
      }
      button {
        padding: 10px 12px;
        border: none;
        border-radius: 12px;
        font-size: 13px;
        cursor: pointer;
        background: #1f2937;
        color: #e5e7eb;
      }
      button.primary {
        background: #f472b6;
        color: #0f172a;
        font-weight: 600;
      }
    </style>
  </head>
  <body>
    <div class="card">
      <h1>案例 46:timeScale 与 yoyoEase</h1>
      <p>调整播放速度,并在往返时使用不同缓动。</p>
      <div class="track">
        <div class="ball" id="ball"></div>
      </div>
      <div class="controls">
        <button id="slow">慢速</button>
        <button class="primary" id="play">播放</button>
        <button id="fast">快速</button>
      </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script>
      const ball = document.querySelector("#ball");
      const playButton = document.querySelector("#play");
      const slowButton = document.querySelector("#slow");
      const fastButton = document.querySelector("#fast");

      // yoyoEase 可以在回放时使用不同缓动
      const tween = gsap.to(ball, {
        x: 520,
        duration: 1.6,
        ease: "power2.out",
        yoyo: true,
        repeat: -1,
        yoyoEase: "power2.in",
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.paused(!tween.paused());
      });

      slowButton.addEventListener("click", () => {
        tween.timeScale(0.6);
      });

      fastButton.addEventListener("click", () => {
        tween.timeScale(1.6);
      });
    </script>
  </body>
</html>

GSAP(GreenSock Animation Platform) 中,timeScaleyoyoEase 是两个用于精细控制动画播放行为的强大特性:


✅ 一、timeScale控制动画播放速度

🔍 作用:

调整动画的时间流速,实现快放、慢放、甚至倒放,而不改变 duration

📌 基本用法:
tween.timeScale(1);   // 正常速度(默认)
tween.timeScale(0.5); // 半速(慢动作)
tween.timeScale(2);   // 2倍速(快进)
tween.timeScale(-1);  // 反向播放(倒放)
  • timeScale 是一个乘数因子
    • 1 = 100% 速度
    • 0.6 = 60% 速度(变慢)
    • 1.6 = 160% 速度(变快)
slowButton.addEventListener("click", () => {
  tween.timeScale(0.6); // 慢速
});

fastButton.addEventListener("click", () => {
  tween.timeScale(1.6); // 快速
});
  • 点击按钮即可实时改变动画速度,无需重新创建动画;
  • 即使动画正在播放,也能无缝变速

⚠️ 注意:timeScale 不影响 duration 的设定值,只影响实际播放耗时


✅ 二、yoyoEase为往返动画(yoyo)指定不同的缓动函数

🔍 背景知识:什么是 yoyo
  • 当设置 repeat: -1(无限重复) + yoyo: true 时,
  • 动画会正向播放 → 反向播放 → 正向播放 → …,形成“来回”效果。
📌 默认问题:
  • 如果只设 ease: "power2.out"
  • 那么正向和反向都使用同一个缓动,导致:
    • 正向:先快后慢(out)
    • 反向:先慢后快(因为是倒放 out)

但很多时候,我们希望去程和回程有不同的运动感觉

✅ 解决方案:yoyoEase

yoyoEase 允许你为“回程”(反向播放)单独指定一个缓动函数。

💡 在你的代码中:
const tween = gsap.to(ball, {
  x: 520,
  duration: 1.6,
  ease: "power2.out",      // 去程:先快后慢(弹出感)
  yoyo: true,
  repeat: -1,
  yoyoEase: "power2.in",   // 回程:先慢后快(吸入感)
  paused: true
});
🎯 视觉效果对比:
阶段 缓动 运动特点
去程(→) power2.out 快速冲出去,然后缓缓停下
回程(←) power2.in 缓缓启动,然后快速收回

这比单纯用 yoyo: true + 单一缓动更自然、更有“弹性”!


🔬 技术细节补充:

1. yoyoEase 的工作原理:
  • GSAP 不会倒放 ease,而是正向播放 yoyoEase 来模拟回程。
  • 所以 ease: "out" + yoyoEase: "in" = 去程快停 + 回程快启,非常合理。
2. yoyoEase 支持所有缓动类型:
yoyoEase: "elastic.out"
yoyoEase: "bounce.inOut"
yoyoEase: CustomEase.create(...)
3. timeScale 是可叠加的:
tween.timeScale(2).timeScale(0.5); // 最终 = 1(2 * 0.5)

🌟 典型应用场景:

场景 用途
UI 微交互动效 按钮点击“弹跳”:去程快,回程缓
游戏对象移动 敌人巡逻:匀速去,加速回
视频/音频播放器 拖拽预览时慢放,正常播放时快放
科学可视化 模拟不同速度下的物理过程

⚠️ 注意事项:

  • yoyoEase 仅在 yoyo: true 时生效
  • timeScale 影响整个时间线或 tween,包括子动画;
  • 两者都是 GSAP 核心功能,无需额外插件;
  • timeScale(0) 会暂停动画(等价于 paused(true))。

📚 官方文档:


✅ 总结:

timeScale 让你像“调速旋钮”一样控制动画节奏,而 yoyoEase 则赋予往返动画“去程与回程不同性格”的能力——两者结合,可构建出既灵活又富有表现力的交互动效,是 GSAP 高级动画控制的标志性特性。

Wget Cheatsheet

Basic Downloads

Download files to the current directory.

Command Description
wget URL Download a file
wget -q URL Download silently (no output)
wget -nv URL Less verbose (errors and basic info only)
wget -v URL Verbose output (default)
wget -d URL Debug output

Save Options

Control where and how files are saved.

Command Description
wget -O name.txt URL Save with a specific filename
wget -O - URL Output to stdout
wget -P /path/ URL Save to a specific directory
wget --content-disposition URL Use server-suggested filename
wget --no-clobber URL Skip if file already exists
wget -nc URL Short form of --no-clobber

Resume and Speed

Resume interrupted downloads and control bandwidth.

Command Description
wget -c URL Resume a partial download
wget --limit-rate=2m URL Limit speed to 2 MB/s
wget --limit-rate=500k URL Limit speed to 500 KB/s
wget -b URL Download in background
wget -b -o log.txt URL Background with custom log

Multiple Files

Download several files at once.

Command Description
wget URL1 URL2 URL3 Download multiple URLs
wget -i urls.txt Download URLs from a file
wget -i - < urls.txt Read URLs from stdin

Recursive Download

Download directory trees and linked pages.

Command Description
wget -r URL Recursive download
wget -r -l 2 URL Limit depth to 2 levels
wget -r --no-parent URL Do not ascend to parent directory
wget -r --accept=jpg,png URL Accept only these file types
wget -r --reject=mp4,avi URL Reject these file types
wget -r -A "*.pdf" URL Download only PDFs
wget -r --follow-tags=a URL Follow only <a> tags

Website Mirroring

Create offline copies of websites.

Command Description
wget -m URL Mirror a website
wget -m -k -p URL Mirror with local link conversion
wget -m -k -p -E URL Mirror and add .html extensions
wget -m --wait=2 URL Mirror with 2 second delay between requests
wget -m --random-wait URL Mirror with random delay (0.5x–1.5x of --wait)

Authentication

Access protected resources.

Command Description
wget --user=USER --password=PASS URL HTTP basic auth
wget --ask-password URL Prompt for password
wget --header="Authorization: Bearer TOKEN" URL Token auth
wget --ftp-user=USER --ftp-password=PASS URL FTP auth

Headers and User Agent

Send custom headers and change identity.

Command Description
wget --header="Key: Value" URL Add custom header
wget --header="Accept: application/json" URL Request JSON
wget -U "CustomAgent/1.0" URL Change user agent
wget --referer=URL URL Set referer header

SSL/TLS

Handle HTTPS connections and certificates.

Command Description
wget --no-check-certificate URL Skip certificate verification
wget --ca-certificate=ca.crt URL Use custom CA certificate
wget --certificate=client.crt URL Client certificate
wget --private-key=client.key URL Client private key
wget --https-only URL Only follow HTTPS links

FTP

Work with FTP servers.

Command Description
wget ftp://server/file Download file
wget -r ftp://server/dir/ Download directory recursively
wget --ftp-user=USER --ftp-password=PASS ftp://server/ Authenticated FTP
wget --no-passive-ftp ftp://server/file Use active FTP mode
wget --no-remove-listing ftp://server/dir/ Keep .listing files

Retries and Timeouts

Control retry behavior and connection timing.

Command Description
wget --tries=5 URL Retry up to 5 times
wget --retry-connrefused URL Retry on connection refused
wget --waitretry=10 URL Wait 10 seconds between retries
wget --timeout=30 URL Set all timeouts to 30 seconds
wget --connect-timeout=10 URL Connection timeout only
wget --read-timeout=30 URL Read timeout only
wget --dns-timeout=5 URL DNS timeout only

Output and Logging

Control progress display and log output.

Command Description
wget -q URL Suppress all output
wget -nv URL Print errors and basic info only
wget -o log.txt URL Log output to file
wget -a log.txt URL Append to log file
wget --show-progress -q URL Quiet but show progress bar
wget --progress=dot URL Dot-style progress indicator

Timestamping and Caching

Download only new or updated files.

Command Description
wget -N URL Download only if remote file is newer
wget --no-cache URL Disable server-side caching
wget --spider URL Check if URL exists (do not download)
wget --spider -r URL Check all links recursively

Common Patterns

Frequently used command combinations.

Command Description
wget -q -O - URL | tar -xzf - -C /path Download and extract in one step
wget -c --limit-rate=1m -P /tmp URL Resume to directory with speed limit
wget -r -np -nH --cut-dirs=2 URL Recursive without host and path prefix
wget -m -k -p --wait=1 -e robots=off URL Full mirror ignoring robots.txt

Related Guides

Use these articles for detailed wget workflows.

Guide Description
Wget Command in Linux with Examples Full wget tutorial with practical examples
Curl Command in Linux with Examples Alternative HTTP client for API interactions
How to Use SCP Command to Securely Transfer Files Transfer files between hosts over SSH
How to Use Rsync for Local and Remote Data Transfer Incremental file sync and transfer

做了个 EPUB 阅读器,被「阅读进度同步」折磨了一周,总结 4 个血泪教训

你做过"打开一本书,自动回到上次阅读位置"这个功能吗?

听起来很简单对吧——存个页码,下次打开翻过去就行。我一开始也是这么想的,直到在 Web EPUB 阅读器上被反复打脸。

这篇文章不讲理论框架,直接讲:我在实现 Web/Mobile 阅读进度同步时踩过的每一个坑,以及为什么"存页码"这条路从一开始就是死的。

一句话结论

进度 = 内容位置(Anchor),页面 = 当前设备的渲染结果。

只要你不存页码,Web 双页 / Mobile 单页 / 字体可调 / 阅读器大小可调 / 多端同步,全部迎刃而解。

为什么不能存页码?

同一本 EPUB,79 章,30 万字:

环境 页数
PC 双页 (319px/页) 1165 页
iPad 横屏 (500px/页) 约 750 页
iPhone 竖屏 (350px/页) 约 1400 页
调大字号 (20px) 约 1800 页

用户在 PC 上读到第 142 页,存下来。手机打开,翻到第 142 页——内容完全对不上,可能差了好几章。

页码是渲染结果,不是内容属性。 它取决于字体、字号、行高、容器宽高、双页/单页模式。换任何一个参数,页码就变了。

Anchor 锚点设计

数据结构

interface ReadingAnchor {
  chapterIndex: number;   // 第 11 章
  blockIndex: number;     // 章内第 17 个段落
  charOffset: number;     // 段内第 0 个字符
  textSnippet: string;    // "尤里身体前倾,像是在敦促她"
}

每个字段都是内容属性——和设备无关、和字体无关、和屏幕宽度无关。

textSnippet 是保险:万一书的内容更新导致 blockIndex 偏移,还能用文字片段做模糊匹配(Kindle 也是这么做的)。

存储格式

anchor:11:17:0|snippet:尤里身体前倾,像是在敦促她|char:25000

char:25000 是全局字符偏移,供旧客户端降级。一个字符串,三层 fallback,向前兼容。

多端同步流程

手机端退出 → 保存 anchor → 后端存储
                                    ↓
PC 端打开 → 请求 anchor → 当前设置下重新分页 → 定位到锚点所在页

后端只做一件事:存最新的 anchor。"翻到哪一页"这个问题完全由前端根据当前设备环境实时计算。

前端分页:CSS 多列布局测量

EPUB 分页的核心是 CSS column-width。将章节 HTML 注入一个隐藏容器,浏览器自动按列排布,scrollWidth / columnWidth 就是页数。

// 隐藏测量容器
measureEl.innerHTML = `
  <div class="epub-measure-container" style="
    width: ${pageWidth}px;
    height: ${pageHeight}px;
    column-width: ${pageWidth}px;
    column-gap: 0px;
    column-fill: auto;
    font-size: ${fontSize}px;
    line-height: ${lineHeight};
  ">${chapter.html}</div>
`;

const scrollW = contentEl.scrollWidth;
const pageCount = Math.ceil(scrollW / pageWidth);

同时,遍历每个块级元素,记录它在第几列(第几页),构建 blockMap

// 用 getBoundingClientRect 计算元素所在列
const containerRect = containerEl.getBoundingClientRect();
for (const el of leafElements) {
  const elRect = el.getBoundingClientRect();
  const relativeLeft = elRect.left - containerRect.left;
  const pageInChapter = Math.floor(relativeLeft / columnWidth);
  // 记录:blockIndex → pageInChapter
}

有了 blockMap,锚点 → 页码的转换就是一次查表:range.startPage + block.pageInChapter

四个真实的坑

坑 1:测量 CSS ≠ 渲染 CSS → 定位偏移

这是最隐蔽的 Bug。测量容器和实际渲染的 CSS 差了几条规则:

/* 渲染容器有,测量容器漏了 */
h1, h2, h3 { margin-top: 0.5em; margin-bottom: 0.3em; }
blockquote { text-indent: 0 !important; }
a { color: inherit; text-decoration: underline; }

一个标题的 margin 差了 0.5em(≈ 8px),在 319px 宽的手机屏幕上,就足以让后续段落的列分配偏移一整页。79 章累积下来,锚点可以偏差几十页。

结论:测量 CSS 和渲染 CSS 必须完全一致,差一个属性就可能出错。

坑 2:offsetLeft 在多列布局中不可靠

最初用 el.offsetLeft / columnWidth 判断元素在哪一列。但 offsetLeft 的语义是"相对于 offsetParent",在 CSS 多列布局中,不同浏览器的实现有差异。

换成 getBoundingClientRect() 后解决。它返回元素的实际视觉位置,跨浏览器一致:

// ❌ 不可靠
const page = Math.floor(el.offsetLeft / columnWidth);

// ✅ 可靠
const rect = el.getBoundingClientRect();
const page = Math.floor((rect.left - containerRect.left) / columnWidth);

坑 3:字体设置变更 → 用旧数据算出错误页码

用户调整字号 → settingsFingerprint 变化 → 触发重新分页。但 React 中多个 Hook 的状态更新有时差:

Effect 看到:新的 settingsFingerprint ✓
             旧的 blockMaps ✗ (分页 Hook 还没完成重新测量)

用旧的 blockMaps + 新设置去算 anchorToPage,结果必然是错的。

解决方案:两阶段导航。

// 第一阶段:检测到设置变更,标记等待,不导航
if (isSettingsChange) {
  pendingSettingsNavRef.current = true;
  return; // 等分页重新测量
}

// 第二阶段:分页完成后,用新 blockMaps 安全导航
if (pendingSettingsNavRef.current) {
  pendingSettingsNavRef.current = false;
  const newPage = anchorToPage(anchor, newRanges, newBlockMaps);
  navigateTo(newPage);
}

坑 4:渐进加载 + 翻页库事件竞态

79 章的书不会一次加载完。第一次分页只有 17 章精确测量 + 62 章估算。当更多章节加载后,avgCharsPerPage 从 135 变成 129,所有估算章节的 startPage 集体偏移,锚点对应的全局页码从 132 变成 142。

但阅读器还停在 132 页,因为初始化后没有"自动修正"逻辑。

更麻烦的是,尝试用 setSettingsKey 重新挂载 flipbook 来修正时,翻页库在 mount 时会发射一个 onFlip({page: 0}) 的伪事件。这个事件把 currentPageRef 污染成 0,导致后续自动修正全部失效。

解决方案:两个机制配合。

门控机制:flipbook 初始化阶段忽略 onFlip 事件。

const flipReadyRef = useRef(false);

// mount 后 300ms 才标记就绪
setTimeout(() => { flipReadyRef.current = true; }, 300);

// handleFlip 中门控
if (!flipReadyRef.current) return; // 忽略伪事件

直接导航:渐进加载修正时用 turnToPage 而不是重新挂载,从根本上避免竞态。

if (!userHasFlippedRef.current && startPage !== currentPageRef.current) {
  flipBookRef.current?.pageFlip()?.turnToPage(startPage);
}

最终架构

┌───────────────────────────────────┐
│ 后端:只存 anchor 字符串          │  POST /api/library/progress
├───────────────────────────────────┤
│ 前端解析层:anchor ↔ 页码转换     │  anchorToPage / pageToAnchor
├───────────────────────────────────┤
│ 前端测量层:CSS columns 精确测量   │  buildBlockMap → blockMaps
├───────────────────────────────────┤
│ 前端渲染层:flipbook 翻页 UI      │  react-pageflip
└───────────────────────────────────┘

核心原则:

  • 后端不分页,只存内容位置
  • 页码纯前端算,根据当前设备环境实时计算
  • 锚点与设备无关,同一个锚点在任何设备上都能定位
  • 转换方向:永远是 anchor → page(打开时),page → anchor(保存时)

写在最后

实现这个功能的过程让我深刻理解了一件事:看似简单的需求,难点往往不在算法设计,而在工程细节的一致性

CSS 差一条规则、React Effect 的执行时序差一帧、第三方库的一个初始化事件——这些"微小"的不一致累积起来,就是"打开书发现位置完全不对"的用户体验灾难。

如果你也在做类似的阅读器产品,记住这个原则:

永远不要存页码。存内容位置,让前端去算页码。

这一个决策,能帮你避开 80% 的坑。

LeetCode 25. K个一组翻转链表:两种解法详解+避坑指南

LeetCode 难度为 Hard 的经典链表题——25. K个一组翻转链表,这道题是链表翻转的进阶题,考察对链表指针操作的熟练度,也是面试中的高频考点,很多人会在“组内翻转”“组间连接”“边界处理”上踩坑。

今天不仅会讲解题目核心,还会对比两份不同思路的代码,分析它们的优缺点、避坑点,帮大家彻底吃透这道题,下次遇到直接秒解!

一、题目解读(清晰易懂版)

题目核心需求很明确,一句话概括:给一个链表,每k个节点当成一组,组内翻转;如果最后剩下的节点不足k个,就保持原样

关键约束(必看,避坑前提):

  • k是正整数,且k ≤ 链表长度(不用考虑k大于链表长度的情况);

  • 不能只改节点的值,必须实际交换节点(排除“偷巧”解法);

  • 组间顺序不变,只有组内节点翻转(比如链表1->2->3->4,k=2,结果是2->1->4->3,不是4->3->2->1)。

示例辅助理解:

  • 输入:head = [1,2,3,4,5], k = 2 → 输出:[2,1,4,3,5]

  • 输入:head = [1,2,3,4,5], k = 3 → 输出:[3,2,1,4,5]

  • 输入:head = [1,2], k = 2 → 输出:[2,1]

二、链表节点定义(题目给出,直接复用)

先贴出题目给出的ListNode定义,两份解法都基于这个结构,不用额外修改:

class ListNode {
  val: number
  next: ListNode | null
  constructor(val?: number, next?: ListNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
  }
}

三、两种解法详解对比

下面分别讲解两份代码(reverseKGroup_1 和 reverseKGroup_2),从思路、执行流程、优缺点三个维度拆解,帮大家看清两种思路的差异。

解法一:reverseKGroup_1(全局翻转+局部调整+回滚,新手易上手但需避坑)

1. 核心思路

这种思路的核心是「边遍历边全局翻转,每凑够k个节点,就调整一次组间连接;最后如果不足k个节点,再把这部分翻转回去」。

可以类比成:把链表当成一串珠子,从头开始逐个翻转(珠子顺序颠倒),每翻k个,就把这k个珠子“固定”到正确的位置(连接好前后组);如果最后剩的珠子不够k个,就把这几个珠子再翻回来,恢复原样。

2. 关键变量说明

  • dummy:虚拟头节点,避免处理头节点翻转的特殊情况(所有链表题的通用技巧);

  • preGroup:每组翻转的“前置节点”,负责连接上一组的尾和当前组的头;

  • prev:翻转节点时的“前驱节点”,记录当前节点的前一个节点(用于翻转指针);

  • curr:当前正在遍历、翻转的节点;

  • count:组内节点计数器,用于判断是否凑够k个节点。

3. 代码执行流程(以 head=[1,2,3,4], k=2 为例)

  1. 初始状态:dummy(0)->1->2->3->4,preGroup=dummy,prev=dummy,curr=1,count=0;

  2. 遍历curr=1:count≠2,翻转1(1.next=prev=dummy),prev=1,curr=2,count=1;

  3. 遍历curr=2:count≠2,翻转2(2.next=prev=1),prev=2,curr=3,count=2;

  4. 凑够k=2个节点:调整组间连接——preGroup.next=prev=2(dummy->2),原组头lastNode=1,1.next=curr=3(2->1->3);更新preGroup=1,prev=1,count=0;

  5. 继续遍历curr=3:重复步骤2-3,翻转3、4,凑够k=2个节点,调整连接(1->4,3.next=null);

  6. 循环结束,count=0,无不足k个的节点,返回dummy.next=2,最终结果2->1->4->3(正确)。

4. 优点&缺点

优点:思路直观,新手容易理解(只需要掌握“单个节点翻转”的基础操作,再加上计数和回滚);代码结构清晰,逐步骤执行,容易调试。

缺点:存在冗余逻辑(比如单独处理“最后一组刚好k个节点”的else if分支);过度使用空值断言(!),有潜在空指针风险;最后回滚步骤增加了少量时间开销(虽然时间复杂度还是O(n))。

5. 核心避坑点

  • 避免链表环:翻转后必须及时调整组尾的next指针(lastNode.next=curr),否则会出现“dummy<->1”的环,触发运行错误;

  • 回滚逻辑不能漏:如果最后剩余节点不足k个,必须把这部分翻转的节点再翻回来,否则会破坏原有顺序;

  • 空值判断:preGroup.next不可能为null,可移除多余的空值判断,避免错误返回null。

解法二:reverseKGroup_2(先找组边界+组内单独翻转,最优解法)

这是更推荐的解法,也是面试中更常考的思路——「先找每组的边界(头和尾),确认够k个节点后,再单独翻转这组节点;组间连接直接通过边界节点处理,无需回滚」。

类比:还是一串珠子,先找到前k个珠子(确定组头和组尾),把这k个珠子单独翻转,再连接好前后珠子;再找下k个珠子,重复操作;如果找不到k个,就直接结束,不用再调整。

1. 关键变量说明(新增/差异变量)

  • groupTail:当前组的尾节点,通过移动k次找到,同时判断剩余节点是否够k个;

  • groupHead:当前组的头节点(翻转后会变成组尾);

  • nextGroupHead:下一组的头节点,提前记录,避免翻转后找不到下一组。

2. 代码执行流程(以 head=[1,2,3,4], k=2 为例)

  1. 初始状态:dummy(0)->1->2->3->4,preGroup=dummy;

  2. 找第一组边界:groupTail从preGroup开始移动2次,找到groupTail=2(确认够k个节点);记录groupHead=1,nextGroupHead=3;

  3. 单独翻转当前组(1->2):prev初始化为nextGroupHead=3,curr=groupHead=1;循环翻转,直到curr=nextGroupHead,翻转后变成2->1;

  4. 连接组间:preGroup.next=groupTail=2(dummy->2),preGroup更新为groupHead=1(下一组的前置节点);

  5. 找第二组边界:groupTail从preGroup=1移动2次,找到groupTail=4;记录groupHead=3,nextGroupHead=null;

  6. 单独翻转当前组(3->4),连接组间;

  7. 下一次找组边界:移动不足2次,count<k,直接返回dummy.next=2,结果2->1->4->3(正确)。

3. 优点&缺点

优点:逻辑更高效,无需回滚(提前判断节点数量,不足k个直接返回);无冗余分支,代码更简洁;指针操作更严谨,避免链表环和空指针风险;时间复杂度O(n),空间复杂度O(1),是最优解法。

缺点:对指针操作的熟练度要求更高,需要提前规划好“找边界-翻转-连接”的流程,新手可能需要多调试几次才能理解。

4. 核心避坑点

  • 找组边界时,必须同时判断节点数量:移动k次后,如果groupTail.next不存在,说明不足k个节点,直接返回;

  • 翻转组内节点时,prev初始化为nextGroupHead:这样翻转后,组尾(原groupHead)的next会自动指向nextGroupHead,无需额外调整;

  • preGroup更新为原groupHead:翻转后,原groupHead变成组尾,作为下一组的前置节点,保证组间连接正确。

四、两份代码对比总结

对比维度 reverseKGroup_1 reverseKGroup_2
核心思路 全局翻转+组间调整+不足k个回滚 先找组边界+组内单独翻转+无回滚
时间复杂度 O(n)(回滚最多增加O(k),可忽略) O(n)(最优,每个节点只遍历一次)
空间复杂度 O(1) O(1)
可读性 高,新手易理解 中等,需熟练掌握指针操作
适用场景 新手刷题、快速调试 面试、生产环境(最优解)
潜在坑点 链表环、回滚遗漏、空值断言 组边界判断、prev初始化

五、刷题建议&拓展思考

1. 刷题建议

  • 新手:先吃透 reverseKGroup_1,掌握“翻转+计数+回滚”的思路,熟练后再过渡到 reverseKGroup_2;

  • 进阶:重点练习 reverseKGroup_2,尝试自己手写“找边界-翻转-连接”的流程,避免依赖模板;

  • 调试技巧:遇到指针混乱时,画链表结构图(比如用草稿纸写出每个节点的next指向),逐步骤跟踪指针变化,比单纯看代码更高效。

2. 拓展思考(面试高频追问)

  • 如果k可以大于链表长度,该如何修改代码?(提示:在找组边界时,判断count是否等于链表长度,不足则不翻转);

  • 如何用递归实现K个一组翻转链表?(提示:递归终止条件是剩余节点不足k个,递归逻辑是翻转当前组,再递归翻转下一组);

  • 如果要求“每k个节点一组翻转,不足k个节点时全部翻转”,该如何修改?(提示:移除回滚逻辑,或不判断节点数量,直接翻转)。

六、最终优化版代码(推荐面试使用)

基于 reverseKGroup_2 优化,移除空值断言,增加防御性判断,代码更健壮、简洁,适配面试场景:

function reverseKGroup(head: ListNode | null, k: number): ListNode | null {
  if (k === 1 || !head || !head.next) return head;

  const dummy = new ListNode(0, head);
  let preGroup = dummy; // 每组翻转的前置节点
  let count = 0;

  while (true) {
    // 第一步:找组尾,判断剩余节点是否够k个
    let groupTail = preGroup;
    count = 0;
    while (count < k && groupTail.next) {
      groupTail = groupTail.next;
      count++;
    }
    if (count < k) return dummy.next; // 不足k个,直接返回

    // 第二步:记录关键节点
    const groupHead = preGroup.next;
    const nextGroupHead = groupTail.next;

    // 第三步:组内翻转
    let prev: ListNode | null = nextGroupHead;
    let curr = groupHead;
    while (curr !== nextGroupHead) {
      const next = curr?.next;
      if (curr) curr.next = prev;
      prev = curr;
      curr = next;
    }

    // 第四步:组间连接
    preGroup.next = groupTail;
    preGroup = groupHead!;
  }
}

七、总结

LeetCode 25题的核心是「组内翻转+组间连接」,两种解法的本质都是通过指针操作实现,但思路的高效性有差异。

无论哪种解法,都要记住三个核心要点:① 用虚拟头节点简化头节点处理;② 明确每组的边界(头、尾、下一组头);③ 翻转时避免链表环和空指针。

刷题不是背代码,而是理解思路、掌握技巧。建议大家多调试、多画图,熟练掌握指针操作,下次遇到类似的链表翻转题(比如两两翻转、指定区间翻转),就能举一反三、轻松应对!

Claude Code 作者再次分享 Anthropic 内部团队使用技巧

大家好,我是 Immerse,一名独立开发者、内容创作者、AGI 实践者。

关注公众号:沉浸式AI,获取最新文章(更多内容只在公众号更新)

个人网站:yaolifeng.com 也同步更新。

转载请在文章开头注明出处和版权信息。

我会在这里分享关于编程独立开发AI干货开源个人思考等内容。

如果本文对您有所帮助,欢迎动动小手指一键三连(点赞评论转发),给我一些支持和鼓励,谢谢!


Boris 又发了一份 Anthropic 内部的 Claude Code 使用心得。

看完觉得挺实用,记录几条:

1. 多开 worktree 同时跑 3-5 个 git worktree,每个开一个独立会话。团队里公认这个最提效。Boris 自己习惯用 git checkout,但大部分人更爱 worktree。

2. 复杂任务先规划 遇到复杂活儿就开 plan mode。可以让一个 Claude 写计划,另一个当幕僚审查。跑偏了就切回去重新规划。验证环节也会专门进计划模式。

3. 错误后更新 CLAUDE.md 每次纠错完都加一句:"更新你的 CLAUDE.md,别再犯同样的错。"反复迭代到错误率明显降下来。

4. 自建 Skills 库 把常用操作做成 Skills 提交到 git,各项目复用。一天做两次以上的事就该做成 Skills。

5. 让 Claude 自己修 bug 接入 Slack MCP,把 bug 讨论帖扔给 Claude,说一句"修它"就行。或者直接"去修失败的 CI",不用管细节。

6. 提高提示词质量 试试"严格审查这些改动,测试不过不准建 PR",让 Claude 当审查员。或者"证明给我看这能跑通",让它对比 main 和功能分支的差异。

7. 追求更优方案 碰到平庸的修复就说:"基于现在掌握的信息,废掉这个方案,实现更优雅的。"任务前写详细规格,减少歧义。描述越具体,输出越好。

8. 终端配置 团队在用 Ghostty 终端,支持同步渲染、24 位色彩和完整 Unicode。用 /statusline 自定义状态栏显示上下文用量和 git 分支。给标签页做颜色编码和命名,一个任务一个标签页。

9. 语音输入 说话比打字快三倍,提示词也会详细很多。macOS 连按两次 fn 就能开启。

10. 用子代理 想让 Claude 多花点算力就加"use subagents"。把任务分给子代理,主代理的上下文窗口保持干净。

详情:x.com/bcherny/status/2017742741636321619 x

gsap 配置解读 --5

什么是ScrollTo

 <header>
    <h1>案例 29:ScrollTo 平滑滚动</h1>
    <button id="to-second">滚动到第二屏</button>
  </header>
  <section>
    <div class="panel">第一屏内容</div>
  </section>
  <section id="second">
    <div class="panel">第二屏内容</div>
  </section>
  <section>
    <div class="panel">第三屏内容</div>
  </section>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollToPlugin.min.js"></script>
  <script>
    const button = document.querySelector("#to-second");

    // 注册 ScrollToPlugin
    gsap.registerPlugin(ScrollToPlugin);

    button.addEventListener("click", () => {
      gsap.to(window, {
        duration: 1,
        scrollTo: "#second",
        ease: "power2.out"
      });
    });
  </script>

ScrollToPluginGSAP(GreenSock Animation Platform) 提供的一个轻量级但非常实用的插件,用于实现 平滑、可控的页面滚动动画——无论是滚动到页面某个元素、指定坐标,还是精确控制滚动行为。


📌 简单定义:

ScrollToPlugin 让你用 GSAP 的动画语法(如 durationease)驱动浏览器窗口或任意可滚动容器,平滑滚动到目标位置。

它解决了原生 window.scrollTo() 只能“瞬间跳转”或简单 behavior: 'smooth' 缺乏控制的问题。


✅ 核心能力:

1. 滚动到多种目标
// 滚动到元素(通过选择器或 DOM 节点)
scrollTo: "#second"
scrollTo: document.querySelector(".footer")

// 滚动到具体坐标
scrollTo: { y: 500 }          // 垂直滚动到 500px
scrollTo: { x: 200, y: 300 }  // 水平 + 垂直

// 滚动到页面顶部/底部
scrollTo: { y: "top" }
scrollTo: { y: "bottom" }

// 滚动到元素并预留偏移(如避开固定导航栏)
scrollTo: { y: "#section", offsetY: 80 }
2. 完全控制动画体验
  • duration: 滚动持续时间(秒)
  • ease: 缓动函数(如 "power2.out""expo.inOut"
  • 可暂停、反向、加入时间轴(Timeline)
3. 支持任意可滚动容器

不仅限于 window,也可用于 <div style="overflow: auto"> 等局部滚动区域:

gsap.to(scrollableDiv, {
  duration: 1,
  scrollTo: { y: 1000 }
});

🔧 在你的代码中:

gsap.to(window, {
  duration: 1,
  scrollTo: "#second",      // 平滑滚动到 id="second" 的 <section>
  ease: "power2.out"        // 先快后慢的缓动效果
});

点击按钮后:

  • 页面不会“瞬间跳转”到第二屏;
  • 而是用 1 秒时间,以 优雅的缓动曲线 滚动到 #second 元素的顶部;
  • 用户体验更自然、专业。

🌟 典型应用场景:

场景 示例
导航跳转 点击菜单项平滑滚动到对应章节
“回到顶部”按钮 带缓动的返回顶部动画
表单错误定位 提交失败时滚动到第一个错误字段
交互式故事页 按钮触发滚动到下一情节
局部滚动容器 在聊天窗口中自动滚动到底部

⚙️ 高级选项(常用):

scrollTo: {
  y: "#target",
  offsetX: 0,       // 水平偏移
  offsetY: 60,      // 垂直偏移(常用于避开固定头部)
  autoKill: true    // 用户手动滚动时自动中断动画(默认 true)
}

🆚 对比原生方案:

方式 控制力 缓动 中断处理 兼容性
window.scrollTo({ behavior: 'smooth' }) 仅线性 现代浏览器
ScrollToPlugin 任意 GSAP 缓动 智能中断 全浏览器(含 IE11)

⚠️ 注意事项:

  • 必须注册插件:gsap.registerPlugin(ScrollToPlugin)
  • 目标元素必须已存在于 DOM 中
  • 如果结合 ScrollSmoother(平滑滚动容器),需使用其 API 而非直接操作 window

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrollToPlugin 是 GSAP 中实现“专业级页面导航动画”的标准工具——它用极简的代码,赋予滚动行为电影般的流畅感和精准控制,是提升网站交互质感的必备插件。

什么是SplitText

<div class="card">
      <h1 id="headline">SplitText 可以拆分文字做逐字动画</h1>
      <button id="play">逐字出现</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>
    <script>
      const headline = document.querySelector("#headline");
      const playButton = document.querySelector("#play");

      // 注册 SplitText 插件
      gsap.registerPlugin(SplitText);

      let split;

      playButton.addEventListener("click", () => {
        if (split) {
          split.revert();
        }

        // 将文字拆分为字符
        split = new SplitText(headline, { type: "chars" });

        gsap.from(split.chars, {
          opacity: 0,
          y: 20,
          duration: 0.6,
          ease: "power2.out",
          stagger: 0.04
        });
      });
    </script>

SplitTextGSAP(GreenSock Animation Platform) 提供的一个强大工具(虽然叫“插件”,但实际是一个独立的实用类),用于将 HTML 文本智能地拆分为可单独动画的 <span> 元素,从而实现精细的逐字、逐词或逐行动画效果。


📌 简单定义:

SplitText 能把一段普通文字(如 <h1>Hello</h1>)自动转换成包裹在 <span> 中的字符、单词或行,让你可以用 GSAP 对每个部分做独立动画。

例如:

<!-- 原始 -->
<h1 id="headline">你好</h1>

<!-- SplitText({ type: "chars" }) 处理后 -->
<h1 id="headline">
  <span class="char"></span>
  <span class="char"></span>
</h1>

✅ 核心功能:三种拆分模式

模式 说明 生成结构
type: "chars" 拆分为单个字符(包括中文、英文、标点) 每个字一个 <span class="char">
type: "words" 拆分为单词(以空格/标点分隔) 每个词一个 <span class="word">
type: "lines" 拆分为视觉上的行(根据实际换行) 每行外层包 <div class="line">

💡 也可组合使用:type: "words, chars" → 先分词,再把每个词拆成字。


split = new SplitText(headline, { type: "chars" });

gsap.from(split.chars, {
  opacity: 0,
  y: 20,
  duration: 0.6,
  ease: "power2.out",
  stagger: 0.04 // 每个字符延迟 0.04 秒启动
});
  • 点击按钮时,标题文字被拆成单个字符;
  • 每个字符从下方 20px、透明的状态,依次向上淡入
  • 形成“逐字打字机”或“文字飞入”的经典动效。

⚠️ 注意:每次点击前调用 split.revert() 是为了还原原始 HTML 结构,避免重复嵌套 <span> 导致样式错乱。


🌟 为什么需要 SplitText?

如果不使用它,手动写 <span> 包裹每个字:

  • 繁琐:尤其对动态内容或 CMS 内容不现实;
  • 破坏语义:影响 SEO 和可访问性(屏幕阅读器);
  • 难以维护

SplitText

  • 非破坏性:原始文本保持不变,仅运行时包装;
  • 智能处理:正确保留 HTML 标签、空格、换行、内联样式;
  • 支持复杂排版:包括多行、响应式断行(lines 模式会监听 resize)。

🛠️ 高级特性:

  • 保留原始样式:即使文字有 CSS 动画、颜色、字体,拆分后依然生效。
  • 与 ScrollTrigger 结合:实现“滚动到此处时逐字出现”。
  • 支持 SVG 文本(需额外配置)。
  • 可自定义包裹标签:默认 <span>,也可设为 <div> 等。

⚠️ 注意事项:

  • 不是免费插件:在 GSAP 3 中,SplitText 属于 Club 会员专属功能(可试用,但商业项目需授权)。
  • 不要重复拆分:务必在重新拆分前 revert(),否则会嵌套多层 <span>
  • 对 SEO 友好:因为原始 HTML 不变,搜索引擎仍能读取完整文本。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

SplitText 是 GSAP 中实现“高级文字动画”的基石工具——它将枯燥的文本转化为可编程的动画单元,让逐字淡入、弹跳、飞入等效果变得简单、可靠且专业,广泛应用于官网、片头、交互叙事等场景。

什么是TextPlugin

 <div class="card">
      <h1>案例 31:TextPlugin 数字滚动</h1>
      <p>让文本从 0 变化到目标值。</p>
      <div class="counter" id="counter">0</div>
      <button id="play">开始计数</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/TextPlugin.min.js"></script>
    <script>
      const counter = document.querySelector("#counter");
      const playButton = document.querySelector("#play");

      // 注册 TextPlugin
      gsap.registerPlugin(TextPlugin);

      const tween = gsap.to(counter, {
        duration: 1.6,
        text: "1280",
        ease: "power2.out",
        paused: true
      });

      playButton.addEventListener("click", () => {
        counter.textContent = "0";
        tween.restart();
      });
    </script>

TextPluginGSAP(GreenSock Animation Platform) 提供的一个轻巧但非常实用的插件,专门用于对 DOM 元素的文本内容进行动画化更新。它最经典的应用就是实现 “数字滚动计数器” 效果(如从 0 平滑变化到 1280),但也支持普通文本的渐变替换。


📌 简单定义:

TextPlugin 能让元素的 textContent 从一个值“动画过渡”到另一个值——对于数字,它会逐帧递增/递减;对于文字,它可模拟打字、随机字符替换等效果。


✅ 核心功能:

1. 数字滚动(最常用)
gsap.to(element, {
  duration: 2,
  text: "1000" // 自动从当前数字(如 "0")滚动到 1000
});
  • 自动识别数字并进行数值插值
  • 支持整数、小数、带千分位格式(需配合 delimiter);
  • 可设置前缀/后缀(如 $%)。
2. 文本替换动画
gsap.to(element, {
  text: "Hello World",
  duration: 1.5
});
  • 默认行为:直接替换(无中间动画);
  • 但配合 delimiter 或自定义逻辑,可实现打字机、乱码过渡等(不过复杂文本动画更推荐 ScrambleTextPlugin)。

gsap.to(counter, {
  duration: 1.6,
  text: "1280",        // 目标文本
  ease: "power2.out",
  paused: true
});
  • 初始文本是 "0"
  • 点击按钮后,TextPlugin 会:
    • 解析 "0""1280" 都是有效数字
    • 1.6 秒内,将文本内容从 0 → 1 → 2 → ... → 1280 逐帧更新
    • 视觉上形成“数字飞速增长”的计数器效果。

💡 注意:每次播放前重置 counter.textContent = "0" 是为了确保动画从起点开始。


⚙️ 常用配置选项(通过 text 对象):

gsap.to(element, {
  text: {
    value: "¥1,280",     // 目标值
    delimiter: ",",      // 千分位分隔符
    prefix: "¥",         // 前缀(也可直接写在 value 里)
    suffix: " 元",       // 后缀
    padSpace: true       // 保持文本长度一致(防跳动)
  },
  duration: 2
});

🌟 典型应用场景:

场景 示例
数据看板 用户数、销售额、点赞数的动态增长
加载进度 “加载中... 78%”
倒计时/计时器 活动剩余时间、秒表
游戏得分 分数变化动画
简单文本切换 状态提示(“成功” → “完成”)

🆚 对比其他方案:

方法 数字滚动 文本动画 精确控制 性能
手动 setInterval 一般
CSS + JS 拼接 ⚠️ 复杂 ⚠️ 有限 一般
TextPlugin ✅✅✅ (GSAP 时间轴)

⚠️ 注意事项:

  • 只作用于 textContent,不会影响 HTML 标签(即不能插入 <strong> 等);
  • 如果起始或目标文本不是纯数字,则直接替换(无滚动);
  • 要实现更炫的文字扰动(如乱码过渡),应使用 ScrambleTextPlugin
  • 免费可用TextPlugin 是 GSAP 的标准免费插件(无需会员)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

TextPlugin 是 GSAP 中实现“数字计数器动画”的首选工具——它用一行代码就能将静态数字变成动态增长的视觉焦点,简单、高效、且完全集成于 GSAP 动画生态系统。

什么是EasePack

 <div class="card">
    <h1>案例 32:EasePack 特殊缓动</h1>
    <p>RoughEase / SlowMo / ExpoScaleEase 都在 EasePack 中。</p>
    <div class="row">
      <div>
        <div class="lane">
          <div class="ball" id="ballA"></div>
        </div>
        <div class="label">RoughEase</div>
      </div>
      <div>
        <div class="lane">
          <div class="ball" id="ballB"></div>
        </div>
        <div class="label">SlowMo</div>
      </div>
      <div>
        <div class="lane">
          <div class="ball" id="ballC"></div>
        </div>
        <div class="label">ExpoScaleEase</div>
      </div>
    </div>
    <button id="play">播放缓动</button>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>

  <!-- RoughEase, ExpoScaleEase and SlowMo are all included in the EasePack file -->
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/EasePack.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
  <!-- CustomBounce requires CustomEase -->
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomBounce.min.js"></script>
  <!-- CustomWiggle requires CustomEase -->
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomWiggle.min.js"></script>
  <script>
    const ballA = document.querySelector("#ballA");
    const ballB = document.querySelector("#ballB");
    const ballC = document.querySelector("#ballC");
    const playButton = document.querySelector("#play");

    // 预设三个缓动
    const rough = RoughEase.ease.config({
      strength: 1.5,
      points: 20,
      template: Power1.easeInOut,
      randomize: true
    });
    const slowMo = SlowMo.ease.config(0.7, 0.7, false);
    const expoScale = ExpoScaleEase.config(1, 3);

    const timeline = gsap.timeline({ paused: true });
    timeline.to(ballA, { y: 100, duration: 1.2, ease: rough }, 0);
    timeline.to(ballB, { y: 100, duration: 1.2, ease: slowMo }, 0);
    timeline.to(ballC, { y: 100, duration: 1.2, ease: expoScale }, 0);

    playButton.addEventListener("click", () => {
      gsap.set([ballA, ballB, ballC], { y: 0 });
      timeline.restart();
    });
  </script>

RoughEaseSlowMoExpoScaleEaseGSAP(GreenSock Animation Platform) 中三个非常有特色的高级缓动函数(easing functions),它们都包含在 EasePack 插件中。它们超越了传统的“入/出”缓动(如 easeInOut),提供了更具创意和物理感的动画节奏。

下面分别解释它们的作用和适用场景:


1. 🌀 RoughEase —— “抖动式”缓动

✅ 作用:

模拟不规则、随机抖动的运动效果,常用于表现:

  • 手绘感、草图风格
  • 震动、故障、不稳定状态
  • 卡通式的“弹跳后晃动”

🔧 核心参数(通过 .config() 设置):

const rough = RoughEase.ease.config({
  strength: 1.5,     // 抖动强度(0~2,默认 1)
  points: 20,        // 抖动点数量(越多越密集)
  template: Power1.easeInOut, // 基础缓动曲线(决定整体趋势)
  randomize: true    // 是否每次播放随机(true=更自然)
});

🎯 在你的代码中:

  • 小球 A 下落时会上下轻微抖动,不是平滑移动,而是像“被手抖着拉下来”。

💡 适合:游戏中的受击反馈、加载失败提示、趣味 UI。


2. 🐢 SlowMo —— “慢动作中心”缓动

✅ 作用:

让动画在中间阶段变慢,两端加速,形成“慢镜头”效果。
特别适合强调某个关键状态(如悬停、高亮、停顿)。

🔧 核心参数:

const slowMo = SlowMo.ease.config(
  linearRatio,   // 中间匀速部分占比(0~1)
  power,         // 两端加速强度(0=线性,1=强缓出)
  yoyoMode       // 是否用于往返动画(true=对称)
);

例如:SlowMo.ease.config(0.7, 0.7, false)
→ 动画 70% 的时间以近似匀速缓慢进行,开头和结尾快速过渡。

  • 小球 B 下落时,大部分时间缓慢移动,只在开始和结束瞬间加速,仿佛“优雅降落”。

💡 适合:产品展示、LOGO 入场、需要突出中间状态的动画。


3. 📏 ExpoScaleEase —— “指数缩放”缓动

✅ 作用:

实现基于比例(scale)或指数增长/衰减的非线性缓动。
常用于:

  • 缩放动画(从 1x 到 10x)
  • 音量/亮度/透明度等对数感知属性
  • 模拟真实世界的指数变化(如声音衰减、光强)

🔧 核心参数:

const expoScale = ExpoScaleEase.config(startValue, endValue);
  • 它会将动画值从 startValueendValue指数曲线映射。
  • 通常配合 scaleopacity 或自定义属性使用。

🎯 虽然用于 y,但效果仍体现非线性:

  • 小球 C 的下落速度先快后慢(或反之,取决于范围),但变化是非线性的,比 power2 更“陡峭”。

💡 更典型用法:

gsap.to(circle, {
  scale: 5,
  ease: ExpoScaleEase.config(1, 5) // 从 1 倍到 5 倍的指数缩放
});

💡 适合:放大镜效果、爆炸扩散、雷达扫描、声波可视化。


🆚 对比总结:

缓动类型 视觉特点 典型用途
RoughEase 随机抖动、不规则 故障风、手绘感、震动反馈
SlowMo 中间慢、两头快 强调关键帧、优雅停顿
ExpoScaleEase 指数级加速/减速 缩放、对数感知属性、物理模拟

⚠️ 注意事项:

  • 这些缓动都来自 EasePack,需单独引入(如你代码中已做);
  • 它们可以像普通 ease 一样用在任何 GSAP 动画中;
  • 结合 Timeline 可创建复杂节奏组合。

📚 官方文档:


✅ 总结:

RoughEaseSlowMoExpoScaleEase 是 GSAP 赋予动画“性格”的秘密武器——它们让运动不再机械,而是充满随机性、戏剧性或物理真实感,是打造高级交互动效的关键工具。

什么是 CustomEase

<div class="card">
      <h1>案例 33:CustomEase 自定义缓动</h1>
      <p>用贝塞尔曲线定义缓动曲线。</p>
      <div class="track">
        <div class="block" id="block"></div>
      </div>
      <button id="play">播放自定义缓动</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
    <script>
      const block = document.querySelector("#block");
      const playButton = document.querySelector("#play");

      // 注册 CustomEase
      gsap.registerPlugin(CustomEase);

      // 创建一个自定义缓动曲线
      CustomEase.create("myEase", "0.25,0.1,0.25,1");

      const tween = gsap.to(block, {
        x: 470,
        duration: 1.4,
        ease: "myEase",
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

CustomEaseGSAP(GreenSock Animation Platform) 提供的一个强大插件,允许你通过自定义贝塞尔曲线(cubic-bezier)来创建完全个性化的缓动函数(easing function),从而精确控制动画的速度变化节奏。


📌 简单定义:

CustomEase 让你像在 CSS 或设计工具中那样,用 4 个控制点定义一条缓动曲线,并将其注册为可复用的 GSAP 缓动名称,用于任何动画。

它打破了内置缓动(如 power2.inOutelastic)的限制,实现电影级、品牌专属或物理拟真的运动节奏


✅ 核心原理:贝塞尔曲线

缓动曲线本质是一条 三次贝塞尔曲线(Cubic Bezier),由 4 个点定义:

  • 起点固定为 (0, 0)
  • 终点固定为 (1, 1)
  • 中间两个控制点 (x1, y1)(x2, y2) 决定曲线形状

CustomEase 中,你只需提供这 4 个数值(按顺序):

" x1, y1, x2, y2 "

例如你的代码:

CustomEase.create("myEase", "0.25,0.1,0.25,1");

表示:

  • 控制点 1: (0.25, 0.1)
  • 控制点 2: (0.25, 1)

这条曲线的特点是:启动非常快(y1 很低),然后突然减速并平稳结束,形成一种“急冲后刹车”的效果。


🔧 使用步骤:

  1. 引入插件

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script>
    
  2. 注册自定义缓动

    CustomEase.create("myEase", "0.25,0.1,0.25,1");
    
    • 第一个参数:缓动名称(字符串,如 "myEase"
    • 第二个参数:贝塞尔控制点(4 个 0~1 之间的数字,用逗号分隔)
  3. 在动画中使用

    gsap.to(block, {
      x: 470,
      duration: 1.4,
      ease: "myEase" // 直接使用注册的名称
    });
    

🌟 优势 vs 其他方式:

方式 灵活性 可视化 复用性 性能
CSS cubic-bezier() ✅(开发者工具) ❌(需重复写)
手动计算进度 ⚠️
CustomEase ✅✅✅ ✅(配合 GSAP 工具) ✅✅✅(全局注册) ✅✅(预计算优化)

💡 CustomEase预计算并缓存曲线数据,运行时性能极高,适合复杂动画。


🛠️ 如何获取贝塞尔值?

  1. 使用 GSAP 官方工具(推荐!)
    👉 GSAP Ease Visualizer

    • 拖动控制点实时预览动画
    • 自动生成 CustomEase 代码
  2. 从 CSS 复制
    如果你在 CSS 中写了:

    transition: all 1s cubic-bezier(0.25, 0.1, 0.25, 1);
    

    那么值就是 "0.25,0.1,0.25,1"

  3. 从 Figma / After Effects 导出
    许多设计工具支持导出贝塞尔缓动参数。


🎨 典型应用场景:

效果 贝塞尔示例 用途
弹性回弹 "0.68,-0.55,0.27,1.55" 按钮点击反馈
缓入缓出加强版 "0.33,0,0.67,1" 平滑过渡
快速启动+慢速结束 "0.25,0.1,0.25,1"(你的例子) 强调终点状态
延迟启动 "0.5,0,0.75,0" 悬停后才开始动画

⚠️ 注意事项:

  • 所有数值必须在 0 到 1 之间(超出会导致不可预测行为);
  • 注册一次后,可在整个项目中复用(如 "brandBounce""softEase");
  • 免费可用CustomEase 是 GSAP 的标准插件(无需 Club 会员);
  • 若需更复杂曲线(如多段),可结合 CustomWiggleCustomBounce(它们依赖 CustomEase)。

📚 官方资源:


✅ 总结:

CustomEase 是 GSAP 中实现“精准运动设计”的终极工具——它把缓动从“选择预设”升级为“自由创作”,让开发者和设计师能用同一套语言定义品牌专属的动画节奏,是打造高端用户体验的核心技术之一。

React 性能优化双子星:深入、全面解析 useMemo 与 useCallback

引言

在现代 React 应用开发中,随着组件逻辑日益复杂、状态管理愈发庞大,性能问题逐渐成为开发者绕不开的话题。幸运的是,React 提供了两个强大而精巧的 Hooks —— useMemouseCallback,它们如同“缓存魔法”,帮助我们在不牺牲可读性的前提下,显著提升应用性能。

本文将结合完整代码示例,逐行解析、对比说明、深入原理,带你彻底掌握 useMemouseCallback 的使用场景、工作机制、常见误区以及最佳实践。文章内容力求全面、准确、生动有趣,并严格保留原始代码一字不变,确保你既能理解理论,又能直接复用实战。


一、为什么需要 useMemo 和 useCallback?

1.1 React 函数组件的“重运行”特性

在 React 中,每当组件的状态(state)或 props 发生变化时,整个函数组件会重新执行一遍。这意味着:

  • 所有变量都会重新声明;
  • 所有函数都会重新定义;
  • 所有计算逻辑都会重新跑一次。

这本身是 React 响应式更新机制的核心,但也会带来不必要的开销

💡 关键洞察
“组件函数重新运行” ≠ “DOM 重新渲染”。
React 会通过 Virtual DOM diff 算法决定是否真正更新 DOM。
昂贵的计算子组件的无谓重渲染,仍可能拖慢应用。


二、useMemo:为“昂贵计算”穿上缓存外衣

2.1 什么是“昂贵计算”?

看这段代码:

// 昂贵的计算
function slowSum(n) {
  console.log('计算中...')
  let sum = 0
  for(let i = 0; i < n*10000; i++){
    sum += i
  }
  return sum
}

这个 slowSum 函数执行了 n * 10000 次循环!如果 n=100,就是一百万次加法。在每次组件重渲染时都调用它,用户界面可能会卡顿。

2.2 不用 useMemo 的后果

假设我们这样写:

const result = slowSum(num); // ❌ 每次渲染都重新计算!

那么,即使你只是点击了 count + 1 按钮(与 num 无关),slowSum 依然会被执行!因为整个 App 函数重新运行了。

2.3 useMemo 如何拯救性能?

React 提供 useMemo记忆(memoize)计算结果

const result = useMemo(() => {
  return slowSlow(num)
}, [num])

工作原理

  • 第一次渲染:执行函数,缓存结果。

  • 后续渲染:检查依赖项 [num] 是否变化。

    • 如果 num 没变 → 直接返回缓存值,不执行函数体
    • 如果 num 变了 → 重新执行函数,更新缓存。

2.4 完整上下文中的 useMemo 使用

export default function App(){
  const [num, setNum] = useState(0)
  const [count, setCount] = useState(0)
  const [keyword, setKeyword] = useState('')
  const list = ['apple','banana', 'orange', 'pear']

  // ✅ 仅当 keyword 改变时才重新过滤
  const filterList = useMemo(() => {
    return list.filter(item => item.includes(keyword))
  }, [keyword])

  // ✅ 仅当 num 改变时才重新计算 slowSum
  const result = useMemo(() => {
    return slowSum(num)
  }, [num])

  return (
    <div>
      <p>结果: {result}</p>
      <button onClick={() => setNum(num + 1)}>num + 1</button>

      <input type="text" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>count + 1</button>
      {
        filterList.map(item => (
          <li key={item}>{item}</li> 
        ))
      }
    </div>
  )
}

🔍 重点观察

  • 点击 “count + 1” 时:

    • slowSum 不会执行(因为 num 没变);
    • filterList 不会重新计算(因为 keyword 没变);
    • 控制台不会打印 “计算中...” 或隐含的 “filter执行”。
  • 这就是 useMemo 带来的精准缓存

2.5 关于 includes 和 filter 的小贴士

  • "apple".includes("") 确实返回 true(空字符串是任何字符串的子串);
  • list.filter(...) 返回的是一个新数组,即使结果为空(如 []),它也是一个新的引用

⚠️ 正因如此,如果不使用 useMemo,每次渲染都会生成一个新数组引用,可能导致依赖该数组的子组件误判为 props 变化而重渲染!


三、useCallback:为“回调函数”打造稳定身份

3.1 问题起源:函数是“新”的!

在 JavaScript 中,每次函数定义都会创建一个新对象

// 每次 App 重运行,handleClick 都是一个全新函数!
const handleClick = () => { console.log('click') }

即使函数体完全一样,handleClick !== previousHandleClick

3.2 子组件为何“无辜重渲染”?

看这段代码:

const Child = memo(({count, handleClick}) => {
  console.log('child重新渲染')
  return (
    <div onClick={handleClick}>
      <h1>子组件 count: {count}</h1>
    </div>
  )
})
  • memo 的作用:浅比较 props,若没变则跳过渲染。
  • 但每次父组件重渲染,handleClick 都是新函数 → props 引用变了 → memo 失效 → 子组件重渲染!

即使你只改了 numChild 也会重渲染,尽管它只关心 count

3.3 useCallback 的解决方案

useCallback 本质上是 useMemo 的语法糖,专用于缓存函数

const handleClick = useCallback(() => {
  console.log('click')
}, [count])

效果

  • 只要 count 不变,handleClick 的引用就保持不变;
  • Child 的 props 引用未变 → memo 生效 → 跳过重渲染

3.4 完整 useCallback 示例

import {
  useState,
  memo,
  useCallback
} from 'react'

const Child = memo(({count, handleClick}) => {
  console.log('child重新渲染')
  return (
    <div onClick={handleClick}>
      <h1>子组件 count: {count}</h1>
    </div>
  )
})

export default function App(){
  const [count, setCount] = useState(0)
  const [num, setNum] = useState(0)

  // ✅ 缓存函数,依赖 count
  const handleClick = useCallback(() => {
    console.log('click')
  }, [count])
  
  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>count + 1</button>
      <p>num: {num}</p>
      <button onClick={() => setNum(num + 1)}>num + 1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  )
}

🔍 行为验证

  • 点击 “num + 1”:Child 不会打印 “child重新渲染”;
  • 点击 “count + 1”:Child 重渲染(因为 counthandleClick 都变了);
  • 如果 handleClick 不依赖 count(依赖项为 []),则只有 count 变化时 Child 才重渲染。

四、useMemo vs useCallback:一张表说清区别

特性 useMemo useCallback
用途 缓存任意值(数字、数组、对象等) 缓存函数
本质 useMemo(fn, deps) useMemo(() => fn, deps) 的简写
典型场景 昂贵计算、过滤/映射大数组、创建复杂对象 传递给 memo 子组件的事件处理器
返回值 函数执行的结果 函数本身
错误用法 用于无副作用的纯计算 用于依赖外部变量但未声明依赖

💡 记住
useCallback(fn, deps)useMemo(() => fn, deps)


五、常见误区与最佳实践

❌ 误区1:到处使用 useMemo/useCallback

  • 不要为了“可能的优化”而滥用

  • 缓存本身也有开销(存储、比较依赖项)。

  • 只在以下情况使用

    • 计算确实昂贵(如大数据处理);
    • 导致子组件无谓重渲染(配合 memo);
    • 作为 props 传递给已优化的子组件。

❌ 误区2:依赖项遗漏

const handleClick = useCallback(() => {
  console.log(count) // 依赖 count
}, []) // ❌ 错误!应该写 [count]

这会导致函数捕获旧的 count(闭包陷阱)。

✅ 正确做法:所有外部变量都必须出现在依赖数组中

✅ 最佳实践

  1. 先写逻辑,再优化:不要过早优化。
  2. 配合 React DevTools Profiler:定位真实性能瓶颈。
  3. useMemo 用于值,useCallback 用于函数
  4. 依赖项要完整且精确:使用 ESLint 插件 eslint-plugin-react-hooks 自动检查。

六、总结:性能优化的哲学

useMemouseCallback 并非银弹,而是 React 赋予我们的精细控制权。它们让我们能够:

  • 隔离变化:让无关状态的更新不影响其他部分;
  • 减少冗余:避免重复计算和渲染;
  • 提升用户体验:使应用更流畅、响应更快。

正:

“count 和 keyword 不相关”
“某一个数据改变,只想让相关的子组件重新渲染”

这正是 React 性能优化的核心思想:局部更新,全局协调


附:完整代码地址

源码地址:react/memo/memo/src/App.jsx · Zou/lesson_zp - 码云 - 开源中国

🎉 掌握 useMemouseCallback,你已经迈入 React 性能优化的高手之列!
下次遇到“为什么子组件总在乱渲染?”或“计算太慢怎么办?”,你就知道答案了。

Happy coding! 🚀

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2025详细解读

往期文章:

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2021 & state-of-css 2021详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2022 & state-of-js 2022详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2023详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-js 2023详细解读

【前端趋势调查系列】带你看看前端生态圈的技术趋势state-of-css 2024和state-of-js 2024详细解读

一、写在前面

  • 本次分享的数据来源是state-of-js,是由Devgraphics开源社区团队发起的前端生态圈中规模最大的数据调查。
  • 想要贡献state-of-js调查结果中文翻译的同学可以联系我,或者直接向Devographics/locale-zh-Hans这个仓库提PR,然后艾特我来帮你review。
  • 如果这篇文章有其他意见或更好的建议,欢迎各位同学们多多指教。

二、受访者统计

今年的state-of-js调查共回收了13002份问卷结果。和去年相问卷结果又少了一些。

其实自从2022年起,填写问卷的人就越来越少,原因无外乎这么几个:

  • 前端的整体热度都在走低,像是google trends上前端相关的搜索词的热度都在下降;
  • 问卷内容过长导致内容填写起来比较麻烦;
  • 受访者虽然一直关注这项调查,但填了第一年的问卷之后第二年的问卷就不填了等等。

而在今年我结合我在Datawhale做的一些数据调查来看,有一个更重要的原因,就是AI的崛起——大部分开发者们的注意力已经转向了AI领域(包括我自己也是),基本不会在前端领域投入过多关注了

之前我也和调查发起人@SachaG聊过state-of-js调查的未来,作为一项坚持了9年的前端数据调查,也算是见证了前端领域的崛起与衰落。而如今,前端领域的热度早已不再是当年的样子,这项调查也不知道还能做多少年,大家且看且珍惜吧。

三、JS特性

语法特性

从今年的语法特性使用情况来看,社区对提升代码健壮性和简洁性的新特性抱有极大的热情:

  • 空值合并 运算符 ?? 的使用率高达 87% ,已经成为事实上的标准,这说明开发者在处理 nullundefined 时,迫切需要一种比 || 更严谨、更明确的工具来避免将 0false 等有效值意外覆盖,在日常开发中,我们应当优先使用 ?? 来处理默认值赋值,以增强代码的稳定性和可预测性。
  • 动态导入( Dynamic Import 66% 的使用率紧随其后,反映出代码分割和按需加载已是现代 Web 应用性能优化的核心实践,在构建大型应用、特别是需要考虑首屏加载速度的场景时,动态导入几乎是必修课。
  • 类私有字段( Private Fields 逻辑赋值 运算符 Logical Assignment 的使用率分别为 43%35% ,表明封装和代码简写同样是开发者追求的目标,尤其是私有字段,为在团队协作中保护内部状态、减少意外修改提供了语言层面的保障。

Array、Set、Object的特性

今年对 ArraySetObject 数据结构的新特性调查,揭示了不可变性(Immutability)数据处理便利性 已成为前端开发的核心趋势:

  • 返回新数组的 toSorted() 使用率已达 47% ,其孪生兄弟 toReversed() 也达到 37% ,说明社区正主动避免原地修改数组带来的副作用。
  • Set 新方法整体普及度不高,但在使用者中 union()intersection()difference() 等集合运算需求最集中,开始用于表达更复杂的数据关系与权限逻辑。
  • 首次进入调查的 Object.groupBy() 拿到 39% 使用率,说明了“按字段分组”这类高频需求可以摆脱 Lodash 等库,直接靠原生 JS 优雅解决。

Promise的特性

在异步编程领域,对多个 Promise 的精细化控制能力已成为现代前端的标配:

  • Promise.allSettled()52% 的使用率登顶,适合在“批量请求但不希望单点失败拖垮整体流程”的场景下使用,例如并行拉取多个非关键数据源、日志或埋点结果,它能保证我们总能拿到每个 Promise 的最终状态。
  • Promise.any() 使用率也达到 47% ,是“抢最快一个结果”的利器,典型场景是对多个镜像服务发起并行请求、谁先返回就用谁,从而显著优化响应延迟。
  • 这两个 API 的走红说明前端异步模型已经从“能并发”走向“可编排”,开发者不再满足于简单的 Promise.all,而是开始为不同业务场景选择更合适的并发策略。

浏览器API

浏览器 API 的使用情况反映了 Web 应用能力正从传统的页面展示,向功能更丰富、更接近原生应用的形态演进:

  • WebSocket 仍以 64% 的使用率牢牢占据基础设施地位,支撑了社交、协作、监控看板等场景中的实时通信。
  • PWA 使用率达到 48% ,说明离线能力、安装体验和通知能力已经被越来越多团队纳入评估维度。
  • 更值得关注的是 WebAssembly (WASM) ,使用率已达 21% 且排名上升 2 位,高性能语言(如 C++、Rust)编译到浏览器侧解决音视频处理、加解密、游戏等计算密集型问题,正在从先锋实践迈向工程常规武器。

JS语言的痛点

关于 JS 语言自身的痛点,今年的结果再次印证了社区共识:

  • 缺乏静态类型(Static Typing)28% 的提及率高居第一,这直接解释了为何 TypeScript 能在短时间内成为事实标准——大型项目在可维护性、重构安全和错误提前暴露上的诉求远非动态类型所能满足。
  • 日期处理(Dates)10% 排名第二,说明即便有 Temporal 提案在推进,现实中开发者仍大量依赖 date-fnsDay.js 等第三方库来填补标准库短板。
  • 同时,ESM CJS 的兼容问题标准库整体匮乏 等历史包袱也依然是工程实践中的绊脚石,这些痛点共同构成了“JS 好用但不够省心”的真实写照。

浏览器的痛点

当我们把视线从语言本身转向其运行环境——浏览器时,痛点显得更具工程现实感:

  • 跨浏览器支持(Browser support)31% 的提及率稳居首位,说明即便现代浏览器在标准实现上趋于一致,边缘行为差异、新特性落地节奏和兼容性策略仍是困扰前端团队的主要问题。
  • 浏览器测试(Browser testing)13% 位列第二,本质上是跨浏览器差异在测试和回归成本上的放大反馈
  • 而被单独点名的 Safari7% 成为第三大痛点,很多团队已经默认把它视作“新时代的 IE”,其标准跟进节奏和独特限制,为跨端一致性和平滑体验带来了额外负担。

四、JS技术

综述

这两张图分别从“历史趋势”和“当前满意度”两个维度,为我们描绘了 JS 技术生态的全景图:

  • 左侧四象限清晰展示出以 Vite 为代表的新一代工具,正沿着“低使用、高满意度”向“高使用、高满意度”高速跃迁,而曾经的王者 webpack 虽然仍有庞大使用量,但满意度明显滑落且轨迹线转为紫色,显示出疲态
  • 从右侧满意度分级我们可以发现,Vite (98%)Vitest (97%)Playwright (94%)Astro (94%) 等新星占据 S 级,而 webpack (26%)Angular (48%)Next.js (55%) 等传统选手则跌入 B/C 级,这意味着“存量巨大但口碑一般”的技术栈随时可能迎来用户流失;同时,Vite 生态中 Vite + Vitest 的双双登顶也说明高度协同的一体化工具链的优势,对于开发者而言,技术选型时不能只看当前占有率,更要关注满意度和趋势曲线,尤其要多留意那些位于右下象限、线条仍在上扬的新工具。

前端框架

前端框架的长期“三巨头”格局正在被悄然改写:

  • React 依旧以 80%+ 的使用率牢牢占据生态核心,但满意度已滑落到 B 级(72%),复杂的心智模型和渐进式演化成本让不少团队收到困扰。
  • Vue.js 在 2022 年前后正式超越 Angular 成为第二大框架,并以 84% 的满意度稳居 A 级,证明其在开发体验与性能之间找到了不错的平衡点。
  • Svelte 则凭借“无虚拟 DOM”的编译时理念持续走高,使用率已升至 26% ,成为追求极致性能和简洁语法团队的心头好。
  • 更有意思的是 HTMX,在近两年实现爆发式增长、使用率来到 13% ,它用“回归 HTML、用属性驱动交互”的思路,对当下 JS-heavy 的前端栈提出了有力反思。

元框架(前后端一体化框架)

元框架领域呈现出“一家独大 + 新星涌现”的混合格局:

  • Next.js 继续凭借与 React 的深度绑定,以近 60% 的使用率统治榜单,是大多数 React 团队构建生产级应用的默认选项,App Router 等激进改动和整体复杂度的提升正在透支开发者耐心。
  • Nuxt 在 Vue 生态中稳扎稳打,使用率升至 28%
  • AstroSvelteKit 则是近年最值得关注的两颗新星,前者在内容密集型站点中大放异彩,后者与 Svelte 深度绑定,为全栈应用提供了端到端的极致体验。

后端框架

在 Node.js 后端框架领域,我们不难看出,还是有些新面孔:

  • 老牌选手 Express 仍以 80%+ 的使用率稳居第一,作为“薄核心 + 丰富中间件”的事实标准难以被完全替代,但 81% 的满意度也表明开发者正在寻找更现代的方案
  • tRPC 是过去两年最耀眼的新星,通过直接在 TypeScript 中实现端到端类型安全调用,大幅简化了前后端联调与接口演进的成本。

测试框架

JavaScript 测试生态正在经历一场“现代化重构”:

  • 在单元与集成测试层面,Jest75% 的使用率独占鳌头。
  • 端到端测试领域则被 Cypress (55%)Playwright (49%) 两强主导,其中 Playwright 以 94% 的满意度跻身 S 级,体现了其在稳定性、调试体验和多浏览器支持上的优势。
  • 紧随其后的是 Vitest,作为 Vite 生态的一员,在短短两年内使用率冲到 50% ,满意度更是高达 97% ,验证了“测试工具与构建工具深度一体化”带来的体验红利。

构建工具

前端构建工具领域也在发生变革:

  • webpack 依旧以 85% 的使用率占据绝对存量,但满意度已经跌至 26% ,复杂配置和缓慢构建让它越来越像一座难以完全迁移的“基础设施债务”。
  • Vite 则是新时代的领跑者,使用率在短短数年间拉升到 83% 、几乎追平 webpack,满意度更是高达 98% ,依托基于 Go 的 esbuild 实现极快冷启动和热更新,重新定义了“本地开发体验”的下限
  • 在更底层 esbuild 的直接使用率已达 52%SWC 也拿到 83% 的满意度,说明社区正将编译热点下沉到 Rust/Go 等原生实现,再在其之上搭建更友好的工具。

五、其它工具

JS库使用情况

在通用 JS 库层面,数据清晰地表明开发者最在乎两件事:

  • 类型安全数据处理效率。以 TypeScript 为优先设计的校验库 Zod48% 的使用率登顶,成为“运行时数据校验 + 类型推导”领域的绝对主角,反映出大家在 API 返回、表单输入等链路上,对类型与数据一致性的强烈诉求。
  • 传统工具库 Lodash (39%) 依然宝刀不老,仍在大量项目中承担通用数据处理职责。
  • 而在日期处理上,date-fns (39%)Moment (25%)Day.js (24%) 等多家共存,本质上是对 JS 原生日期能力长期缺位的弥补
  • 即便是已经被视作“老古董”的 jQuery (16%) ,也仍凭借海量遗留项目保持着不可忽视的存在感。

AI使用情况

AI 工具已经深度嵌入前端开发者的日常工作流,成为新的基础设施:

  • ChatGPT60% 的使用率位居首位,承担了问答、代码草稿生成、调试思路辅助等“外脑”角色。
  • 深度集成 IDE 的 GitHub Copilot 使用率也达 51% ,更偏向于在写代码时提供上下文感知补全与重构建议,两者形成“离线思考 + 在线自动补全”的互补关系
  • 与此同时,Claude (44%)Google Gemini (28%) 等通用大模型产品也在快速补位,说明开发者愿意多源头对比体验
  • 值得注意的是 AI-native 编辑器 Cursor 已有 26% 的使用率,一部分人开始直接迁移到“以 AI 为核心交互对象”的编辑环境中,这预示着未来开发工具形态本身也会被 AI 重塑。
  • 另外,国产大模型 Deepseek 也榜上有名,占据了 8% 的使用率。

其它编程语言使用情况

这张图展示了 JS 开发者的多语言画像:

  • Python41% 的占比成为最常见的第二语言,依托后端开发、自动化脚本、数据分析与 AI 等丰富场景,为前端同学打开了更多技术边界。
  • PHP (27%) 的存在感说明不少人仍在使用 Web 传统栈构建项目或是在维护古老的历史项目。
  • 在工具链和 DevOps 侧,Bash (22%) 几乎是所有工程师的“必修课”。
  • Java (21%)Go (20%)C# (19%) 等企业级后端语言,以及以安全与性能著称的 Rust (16%) ,则构成了很多前端开发者向全栈或更底层系统方向延展的技能支点。

六、使用情况及痛点问题

TS与JS的使用情况

这张分布图有力地说明,TypeScript 已经从“可选增强”进化为 JavaScript 生态的默认选项

  • 48% 的受访者表示项目代码 100% 使用 TS 编写,体现出“一旦采用就倾向于全量迁移”的强烈偏好;在所有项目(包括纯 JS、纯 TS 与混合工程)中计算得到的平均采用率高达 77% ,意味着当今前端代码大部分都运行在类型系统保护之下;仍坚持纯 JS 的开发者仅占 6% ,多半集中在遗留项目或极轻量脚本场景;对于在做技术选型的新项目来说,这几乎已经构成了一个共识结论:默认使用 TS,而不是再纠结要不要上 TS

AI代码生成情况

这张图刻画了 AI 在代码生成中的“真实渗透率”,结论很清晰:

  • AI 目前更像是开发者的“副驾驶”,而非自动写代码的主力工程师。只有 10% 的受访者认为项目代码完全没有 AI 贡献,说明九成以上的团队或多或少已经在用 AI 提效;最集中的区间是 1%–20% 代码由 AI 生成(占 38% ),典型用法是让 AI 帮忙写模板代码、样板逻辑、特定算法实现或提供重构建议,而不是让它从零实现完整模块;总体算下来,平均约有 29% 的代码可以归功于 AI,这是一个不容忽视但远未到“全自动开发”的比例,也意味着复杂业务建模、架构设计和质量把控这些高阶工作,短期内仍牢牢掌握在人类开发者手中。

JS的痛点问题

在所有 JS 开发痛点中,真正让团队头疼的并不是某个语法细节,而是宏观层面的工程复杂度:

  • 代码架构(Code Architecture)38% 的提及率高居榜首,说明随着前端项目体量和生命周期不断拉长,如何拆分模块、划分边界、治理依赖、避免“屎山”成为最大挑战。
  • 紧随其后的是 状态管理(State Management,34%) ,无论是 React 的 hooks 与各种状态库,还是 Vue 的 Pinia,跨组件、跨页面的复杂状态流转依然极易失控。
  • 依赖管理(Managing Dependencies,32%) 也是老大难问题,node_modules 黑洞、版本冲突、安全漏洞以及 ESM/CJS 兼容性都会侵蚀工程稳定性。
  • 相对而言,曾经广受诟病的 异步 代码(Async Code) 如今只剩 11% 的人视其为痛点,Promiseasync/await 已经在很大程度上平滑了这块心智负担,这也从侧面证明语言与工具的演进确实可以逐步“消灭”一部分历史问题。

七、总结

首先,毫无疑问,TypeScript 已然胜出。它赢下的不只是「能编译成js的工具」的争论,而是语言本身。Deno 和 Bun 早已原生支持它。如今,你甚至能在稳定版 Node.js 中直接编写 TypeScript了。

而 Vite 的时代也已到来。今年,Vite 的下载量正式超越 webpack。与之相伴,Vitest 的使用量也大幅飙升。现在正是切换到新一代 Vite 工具链的好时机,而 2026 年注定会是全面落地之年—— 随着 Rolldown 稳定版发布,将驱动出更快的新一代 Vite,同时还有一体化的「Vite+」值得期待。

我们的开发工具从未如此优秀。但大家如今真正关心的却是另一个问题:AI 又将带来什么?

AI 即将彻底改变我们查阅文档、编写代码、做架构决策等一系列工作方式。各家公司都在全力押注全新的开发模式。对我们绝大多数人而言,AI 编程助手正在改变我们与代码交互的方式。

这是一件好事吗?

截至 2025 年底,已有近 30% 的代码由 AI 生成。Cursor 的人气暴涨,尽管它们暂时还无法撼动 VS Code 第一 IDE 的地位。而基于智能代理的工具,比如 Claude、Gemini 和 Copilot,也在迅速普及。

对开发者来说,无论使用什么工具,懂得分辨「什么是好代码」 将会比以往任何时候都更重要。紧跟新语言特性、知道该基于哪些库去开发,而非凭感觉从零手写一切,也会变得愈发关键。

现在,一天之内快速搭建新项目、轻松迁移老项目都已成为现实。这对框架和库的作者来说是个挑战。我们必须保证工具能持续服务好开发者,不能指望用户会一直因惯性而使用。

而这一点,恰恰值得所有开发者的期待。

就让我们拭目以待 2026 年的变化吧。我期待着更快的工具、更好的开发体验,以及技术真正成为能力放大器,强化我们自身的判断与选择。

父传子全解析:从基础到实战,新手也能零踩坑

在 Vue3 组件化开发中,父传子是最基础、最常用的组件通信方式,也是新手入门组件通信的第一步。无论是传递简单的字符串、数字,还是复杂的对象、数组,甚至是方法,父传子都有清晰、规范的实现方式。

不同于 Vue2 选项式 API 中 props 的写法,Vue3 组合式 API(

一、核心原理:单向数据流 + Props 传值

Vue3 父传子的核心逻辑只有两个关键词:Props单向数据流

  • Props:父组件通过在子组件标签上绑定属性(类似 HTML 标签属性),将数据传递给子组件;子组件通过定义 props,接收父组件传递过来的数据,相当于子组件的「输入参数」。
  • 单向数据流:数据只能从父组件流向子组件,子组件不能直接修改父组件传递过来的 props 数据(否则会报错)。如果子组件需要修改 props 数据,必须通过子传父的方式,通知父组件修改原始数据。

记住一句话:Props 是只读的,修改需找父组件。这是 Vue 组件通信的核心规范,也是避免数据混乱的关键。

父传子的核心流程(3步走):

  1. 父组件:在使用子组件的标签上,通过 :属性名="要传递的数据" 绑定数据;
  2. 子组件:通过 defineProps 定义要接收的 props(声明属性名和类型,可选但推荐);
  3. 子组件:在模板或脚本中,直接使用 props 中的数据(无需额外导入,直接通过 props.属性名 或 直接写属性名使用)。

二、基础用法:最简洁的父传子实现(必学)

我们用一个「父组件传递基本数据,子组件展示」的简单案例,讲解最基础的父传子写法,代码可直接复制到项目中运行,零门槛上手。

1. 父组件(Parent.vue):绑定数据并传递

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>父组件的基本数据:{{ parentName }}、{{ parentAge }}</p>
    <p>父组件的数组:{{ parentList.join('、') }}</p>
    <p>父组件的对象:{{ parentObj.name }} - {{ parentObj.gender }}</p>

    <!-- 1. 核心:在子组件标签上,通过 :属性名 绑定要传递的数据 -->
    <Child 
      :name="parentName"  // 传递字符串
      :age="parentAge"    // 传递数字
      :list="parentList"  // 传递数组
      :user-info="parentObj"  // 传递对象推荐用短横线命名)
    />
  </div>
</template>

<script setup>
// 引入子组件(Vue3 <script setup> 中,引入后可直接在模板中使用)
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 父组件要传递的数据(涵盖基本类型、数组、对象)
const parentName = ref('张三') // 字符串
const parentAge = ref(25)     // 数字
const parentList = ref(['苹果', '香蕉', '橙子']) // 数组
const parentObj = reactive({  // 对象
  name: '李四',
  gender: '男',
  age: 30
})
</script>

2. 子组件(Child.vue):定义Props并使用

<template>
  <div class="child">
    <h4>我是子组件(接收父组件传递的数据)</h4>
    <p>接收的字符串:{{ name }}</p>
    <p>接收的数字:{{ age }} 岁</p>
    <p>接收的数组:{{ list.join('、') }}</p>
    <p>接收的对象:{{ userInfo.name }}({{ userInfo.gender }})</p>
  </div>
</template>

<script setup>
// 2. 核心:通过 defineProps 定义要接收的 props
// 写法1:数组形式(简单场景,只声明属性名,不限制类型)
// const props = defineProps(['name', 'age', 'list', 'userInfo'])

// 写法2:对象形式(推荐,可限制类型、设置默认值、必填校验)
const props = defineProps({
  // 字符串类型
  name: {
    type: String,
    default: '默认用户名' // 默认值(父组件未传递时使用)
  },
  // 数字类型
  age: {
    type: Number,
    default: 18
  },
  // 数组类型(注意:数组/对象的默认值必须用函数返回,避免复用污染)
  list: {
    type: Array,
    default: () => [] // 数组默认值:返回空数组
  },
  // 对象类型(同理,默认值用函数返回)
  userInfo: {
    type: Object,
    default: () => ({}) // 对象默认值:返回空对象
  }
})

// 3. 在脚本中使用 props 数据(通过 props.属性名)
console.log('脚本中使用props:', props.name, props.age)
</script>

3. 基础细节说明(新手必看)

  • defineProps 是 Vue3 内置宏,无需导入,可直接在
  • 父组件传递数据时,属性名推荐用 kebab-case(短横线命名),比如 :user-info,子组件接收时用 camelCase(小驼峰命名),比如 userInfo,Vue 会自动做转换;
  • 数组/对象类型的 props,默认值必须用 函数返回(比如 default: () => []),否则多个子组件会复用同一个默认值,导致数据污染;
  • 子组件模板中可直接使用 props 的属性名(比如{{ name }}),脚本中必须通过 props.属性名 使用(比如 props.name)。

三、进阶用法:优化父传子的体验(实战常用)

基础用法能满足简单场景,但在实际开发中,我们还会遇到「必填校验」「类型多可选」「props 数据转换」等需求,这部分进阶技巧能让你的代码更规范、更健壮,避免后续维护踩坑。

1. Props 校验:必填项 + 多类型 + 自定义校验

通过 defineProps 的对象形式,我们可以对 props 进行全方位校验,避免父组件传递错误类型、遗漏必填数据,提升代码可靠性。

<script setup>
const props = defineProps({
  // 1. 必填项校验(required: true)
  username: {
    type: String,
    required: true, // 父组件必须传递该属性,否则控制台报警告
    default: '' // 注意:required: true 时,default 无效,可省略
  },

  // 2. 多类型校验(type 为数组)
  id: {
    type: [Number, String], // 允许父组件传递数字或字符串类型
    default: 0
  },

  // 3. 自定义校验(validator 函数)
  score: {
    type: Number,
    default: 0,
    // 自定义校验规则:分数必须在 0-100 之间
    validator: (value) => {
      return value >= 0 && value <= 100
    }
  }
})
</script>

说明:校验失败时,Vue 会在控制台打印警告(不影响代码运行),但能帮助我们快速定位问题,尤其适合团队协作场景。

2. Props 数据转换:computed 处理 props 数据

子组件不能直接修改 props 数据,但可以通过 computed 对 props 数据进行转换、格式化,满足子组件的展示需求,不影响原始 props 数据。

<template>
  <div class="child">
    <p>父组件传递的分数:{{ score }}</p>
    <p>转换后的等级:{{ scoreLevel }}</p>
    <p>父组件传递的姓名(大写):{{ upperName }}</p>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  score: {
    type: Number,
    default: 0
  },
  name: {
    type: String,
    default: ''
  }
})

// 对 props 分数进行转换:0-60 不及格,60-80 及格,80-100 优秀
const scoreLevel = computed(() => {
  const { score } = props
  if (score >= 80) return '优秀'
  if (score >= 60) return '及格'
  return '不及格'
})

// 对 props 姓名进行格式化:转为大写
const upperName = computed(() => {
  return props.name.toUpperCase()
})
</script>

3. 传递方法:父组件给子组件传递回调函数

父传子不仅能传递数据,还能传递方法(回调函数)。核心用途:子组件通过调用父组件传递的方法,通知父组件修改数据(解决子组件不能直接修改 props 的问题)。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <p>父组件计数器:{{ count }}</p>
    <!-- 传递方法::方法名="父组件方法" -->
    <Child 
      :count="count"
      :addCount="handleAddCount"  // 传递父组件的方法
    />
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { ref } from 'vue'

const count = ref(0)

// 父组件的方法(将被传递给子组件)
const handleAddCount = () => {
  count.value++
}
</script>
<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <p>子组件接收的计数器:{{ count }}</p>
    <!-- 调用父组件传递的方法 -->
    <button @click="addCount">点击让父组件计数器+1</button>
  </div>
</template>

<script setup>
const props = defineProps({
  count: {
    type: Number,
    default: 0
  },
  // 声明接收父组件传递的方法(type 为 Function)
  addCount: {
    type: Function,
    required: true
  }
})

// 也可以在脚本中调用父组件的方法
const callParentMethod = () => {
  props.addCount()
}
</script>

注意:传递方法时,父组件只需写 :addCount="handleAddCount"(不带括号),子组件调用时再带括号 addCount();如果父组件写 :addCount="handleAddCount()",会导致方法立即执行,而非传递方法本身。

4. 批量传递 props:v-bind 绑定对象

如果父组件需要给子组件传递多个 props,逐个绑定会比较繁琐,这时可以用 v-bind 批量绑定一个对象,子组件只需对应接收即可。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 批量传递:v-bind="对象",等价于逐个绑定对象的属性 -->
    <Child v-bind="userObj" />
  </div>
</template>

<script setup>
import Child from './Child.vue'
import { reactive } from 'vue'

// 要批量传递的对象
const userObj = reactive({
  name: '张三',
  age: 25,
  gender: '男',
  address: '北京'
})
</script>
<!-- 子组件(Child.vue) -->
<script setup>
// 逐个接收父组件批量传递的 props,和普通 props 接收一致
const props = defineProps({
  name: String,
  age: Number,
  gender: String,
  address: String
})
</script>

四、实战场景:父传子的高频应用(贴合实际开发)

结合实际开发中的高频场景,补充 3 个常用案例,覆盖大部分父传子需求,直接套用即可。

场景1:父组件控制子组件弹窗显示/隐藏

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <button @click="visible = true">打开子组件弹窗</button>
    <!-- 传递弹窗显示状态 + 关闭弹窗的方法 -->
    <ChildModal 
      :visible="visible"
      :closeModal="handleCloseModal"
    />
  </div>
</template>

<script setup>
import ChildModal from './ChildModal.vue'
import { ref } from 'vue'

const visible = ref(false)

// 关闭弹窗的方法
const handleCloseModal = () => {
  visible.value = false
}
</script>
<!-- 子组件(ChildModal.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="closeModal">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  closeModal: {
    type: Function,
    required: true
  }
})
</script>

场景2:父组件给子组件传递接口数据

实际开发中,父组件通常会请求接口,将接口返回的数据传递给子组件展示,这是最常见的场景之一。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 加载中状态 -->
    <div v-if="loading">加载中...</div>
    <!-- 接口数据请求成功后,传递给子组件 -->
    <ChildList :list="goodsList" v-else />
  </div>
</template>

<script setup>
import ChildList from './ChildList.vue'
import { ref, onMounted } from 'vue'

const goodsList = ref([])
const loading = ref(false)

// 父组件请求接口
onMounted(async () => {
  loading.value = true
  try {
    const res = await fetch('https://api.example.com/goods')
    const data = await res.json()
    goodsList.value = data.list // 接口返回的列表数据
  } catch (err) {
    console.error('接口请求失败:', err)
  } finally {
    loading.value = false
  }
})
</script>

场景3:子组件复用,父组件传递不同配置

子组件复用是组件化开发的核心优势,通过父传子传递不同的配置,让同一个子组件实现不同的展示效果。

<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <!-- 同一个子组件,传递不同配置,展示不同效果 -->
    <Button 
      :text="按钮1"
      :type="primary"
      :disabled="false"
    />
    <Button 
      :text="按钮2"
      :type="default"
      :disabled="true"
    />
  </div>
</template>

<script setup>
import Button from './Button.vue'
</script>
<!-- 子组件(Button.vue) -->
<template>
  <button 
    class="custom-btn"
    :class="type === 'primary' ? 'btn-primary' : 'btn-default'"
    :disabled="disabled"
  >
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: {
    type: String,
    required: true
  },
  type: {
    type: String,
    default: 'default',
    validator: (val) => {
      return ['primary', 'default', 'danger'].includes(val)
    }
  },
  disabled: {
    type: Boolean,
    default: false
  }
})
</script>

五、常见坑点避坑指南(新手必看)

很多新手在写父传子时,会遇到「props 接收不到数据」「修改 props 报错」「方法传递后无法调用」等问题,以下是最常见的 5 个坑点,帮你快速避坑。

坑点1:父组件传递数据时,忘记加冒号(:)

错误写法:<Child name="parentName"></Child>(没有冒号,传递的是字符串 "parentName",而非父组件的 parentName 变量);

正确写法:<Child :name="parentName"></Child>(加冒号,传递的是父组件的变量)。

坑点2:子组件直接修改 props 数据

错误写法:props.name = '李四'(直接修改 props,会报错);

正确写法:通过父传子的方法,通知父组件修改原始数据(参考「传递方法」章节),或通过 computed 转换数据(不修改原始 props)。

坑点3:数组/对象 props 的默认值未用函数返回

错误写法:list: { type: Array, default: [] }(直接写数组,会导致多个子组件复用同一个数组,数据污染);

正确写法:list: { type: Array, default: () => [] }(用函数返回数组,每个子组件都会得到一个新的空数组)。

坑点4:传递方法时,父组件带了括号

错误写法:<Child :addCount="handleAddCount()"></Child>(方法立即执行,传递的是方法的返回值,而非方法本身);

正确写法:<Child :addCount="handleAddCount"></Child>(不带括号,传递方法本身)。

坑点5:props 命名大小写不一致

错误写法:父组件 :userInfo="parentObj",子组件接收 userinfo(小写 i);

正确写法:父组件用 kebab-case(:user-info),子组件用 camelCase(userInfo),或保持大小写一致(不推荐)。

六、总结:父传子核心要点回顾

Vue3 父传子的核心就是「Props 传值 + 单向数据流」,记住以下 4 个核心要点,就能应对所有父传子场景:

  1. 基础流程:父组件 :属性名="数据" 绑定 → 子组件 defineProps 接收 → 子组件使用数据;
  2. 核心规范:Props 是只读的,子组件不能直接修改,修改需通过父传子的方法通知父组件;
  3. 进阶技巧:props 校验提升可靠性,computed 转换数据,v-bind 批量传值,传递方法实现双向交互;
  4. 避坑关键:加冒号传递变量、不直接修改 props、数组/对象默认值用函数返回、传递方法不带括号。

父传子是 Vue3 组件通信中最基础、最常用的方式,掌握它之后,再学习子传父、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

i18n Ally Next:重新定义 VS Code 国际化开发体验

生成关于 i18n Ally Next 的图片.png

做国际化(i18n)是前端开发中最「看起来简单、做起来要命」的事情之一。翻译文件散落各处,键名拼写错误要到运行时才发现,新增语言要手动逐个补全,源语言改了但翻译没跟上……如果你也深有同感,这篇文章就是写给你的。

为什么要做 i18n Ally Next?

i18n Ally 是 VS Code 生态中最受欢迎的国际化插件之一,由 @antfu 创建。它提供了内联注解、翻译管理、文案提取等一系列强大功能,极大地提升了 i18n 开发体验。

但随着时间推移,原项目的维护逐渐放缓。社区积累了大量 Issue 和 PR 未被处理,一些现代框架(如 Next-intl、Svelte 5)的支持也迟迟没有跟上。更关键的是——原项目没有文档站点,所有使用说明散落在 README 和 Wiki 中,新用户上手成本很高。

i18n Ally Next 正是在这样的背景下诞生的——我们 fork 了原项目,在保留所有经典功能的基础上,持续修复 Bug、新增特性、改善性能,并且从零搭建了完整的文档体系

为什么文档这么重要?

原版 i18n Ally 功能强大,但有一个致命问题:你不知道它能做什么

很多开发者安装后只用到了内联注解这一个功能,对审阅系统、自定义框架、正则匹配、路径匹配等高级功能一无所知。这不是用户的问题,是文档缺失的问题。

i18n Ally Next 的文档站点从以下几个维度重新组织了内容:

🧱 结构化的指南体系

  • 快速开始 — 从安装到看到第一个内联注解,5 分钟搞定
  • 支持的框架 — 完整列出 25+ 支持的框架及其自动检测机制
  • 语言文件格式 — JSON、YAML、JSON5、PO、Properties、FTL……每种格式的用法和注意事项
  • 命名空间 — 大型项目必备的翻译文件组织方式
  • 文案提取 — 从硬编码字符串到 i18n 键的完整工作流
  • 审阅系统 — 团队协作翻译的质量保障
  • 机器翻译 — 8 种翻译引擎的配置与对比

🏗️ 框架最佳实践

不同框架的 i18n 方案差异很大。我们为每个主流框架编写了专属的最佳实践指南:

📋 完整的配置参考

每一个配置项都有:类型、默认值、使用场景说明和代码示例。不再需要猜测某个配置是干什么的。

新增功能详解

🤖 Editor LLM:零配置 AI 翻译

这是 i18n Ally Next 最具创新性的功能之一。它直接调用你编辑器内置的大语言模型进行翻译——无需 API Key,无需额外配置

{ "i18n-ally-next.translate.engines": ["editor-llm"] }

它会自动检测你的编辑器环境:

  • VS Code — 调用 GitHub Copilot
  • Cursor — 调用 Cursor 内置模型
  • Windsurf — 调用 Windsurf 内置模型

更强大的是,Editor LLM 支持批量翻译。当你选择翻译多个键时,它会将同一语言对的翻译请求合并为一次 API 调用,按 JSON 格式批量处理,大幅提升翻译速度并降低 token 消耗。

你也可以指定模型:

{ "i18n-ally-next.translate.editor-llm.model": "gpt-4o" }

🦙 Ollama:完全离线的本地翻译

对于有数据安全要求的团队,Ollama 引擎让你可以使用本地部署的大模型进行翻译,数据完全不出本机

{
  "i18n-ally-next.translate.engines": ["ollama"],
  "i18n-ally-next.translate.ollama.apiRoot": "http://localhost:11434",
  "i18n-ally-next.translate.ollama.model": "qwen2.5:latest"
}

通过 OpenAI 兼容 API 调用,支持任何 Ollama 上可用的模型。翻译 prompt 经过专门优化,能正确保留 {0}{name}{{variable}} 等占位符。

🔌 8 种翻译引擎全覆盖

引擎 特点 适用场景
Google 免费、语言覆盖广 日常开发
Google CN 国内可直接访问 国内开发者
DeepL 翻译质量最佳 面向用户的正式翻译
OpenAI 灵活、可自定义 API 地址 需要高质量 + 自定义
Ollama 完全离线、数据安全 企业内网环境
Editor LLM 零配置、批量翻译 快速迭代
百度翻译 国内 API、中文优化 中文项目
LibreTranslate 开源自托管 完全自主可控

引擎可以配置多个作为 fallback:

{ "i18n-ally-next.translate.engines": ["editor-llm", "deepl", "google"] }

🕵️ 陈旧翻译检测

这是一个容易被忽视但极其重要的功能。当源语言的文案发生变更时,其他语言的翻译可能已经过时了——但你不会收到任何提醒。

i18n Ally Next 的陈旧翻译检测(Stale Translation Check)解决了这个问题:

  1. 插件会记录每个键的源语言快照
  2. 当你运行检测命令时,它会对比快照与当前值
  3. 发现变更后,你可以选择:
    • 全部重新翻译 — 一键将所有过期翻译发送到翻译引擎
    • 逐个确认 — 逐条查看变更内容,决定是否重新翻译
    • 仅更新快照 — 确认当前翻译仍然有效,更新基准

这意味着你的翻译永远不会「悄悄过期」。

🔍 全项目扫描与批量提取

单文件的硬编码检测很有用,但真正的 i18n 迁移需要全项目级别的能力。

「扫描并提取全部」命令可以:

  1. 扫描项目中所有支持的文件(可通过 glob 配置范围)
  2. 检测每个文件中的硬编码字符串
  3. 显示扫描结果摘要(N 个文件,M 个硬编码字符串)
  4. 确认后自动批量提取,为每个字符串生成键名并写入 locale 文件
{
  "i18n-ally-next.extract.scanningInclude": ["src/**/*.{ts,tsx,vue}"],
  "i18n-ally-next.extract.scanningIgnore": ["src/generated/**"],
  "i18n-ally-next.extract.keygenStrategy": "slug",
  "i18n-ally-next.extract.keygenStyle": "camelCase"
}

📝 审阅系统(v2的功能)

翻译不是一个人的事。i18n Ally Next 内置了审阅系统(Review System),支持:

  • 逐条审阅翻译结果,标记为「通过」或「需修改」
  • 留下评论,与团队成员异步协作
  • 审阅数据以 JSON 存储在项目中,可纳入版本控制
  • 翻译结果可先保存为候选翻译translate.saveAsCandidates),审阅通过后再正式写入

这意味着翻译质量不再是黑盒——每一条翻译都有迹可循。

自定义框架:支持任意 i18n 方案

这是 i18n Ally Next 最灵活的功能之一。无论你使用什么 i18n 库,甚至是团队自研的方案,都可以通过一个 YAML 配置文件让插件完美支持。

为什么需要自定义框架?

内置框架覆盖了 25+ 主流方案,但现实中总有例外:

  • 公司内部封装的 i18n 工具函数
  • 使用了非标准的翻译函数名(如 __(), lang(), msg()
  • 新兴框架尚未被内置支持
  • 需要同时匹配多种调用模式

如何配置?

在项目根目录创建 .vscode/i18n-ally-next-custom-framework.yml

# 指定生效的文件类型
languageIds:
  - typescript
  - typescriptreact
  - vue

# 正则匹配翻译函数调用,{key} 是占位符
usageMatchRegex:
  - "\\Wt\\(\\s*['\"`]({key})['\"`]"
  - "\\W\\$t\\(\\s*['\"`]({key})['\"`]"
  - "\\Wi18n\\.t\\(\\s*['\"`]({key})['\"`]"

# 提取重构模板,$1 代表键名
refactorTemplates:
  - "t('$1')"
  - "{t('$1')}"

# 命名空间支持
namespace: true
namespaceDelimiter: ":"

# 作用域范围检测(如 React useTranslation hook)
scopeRangeRegex: "useTranslation\\(['\"](.+?)['\"]\\)"

# 是否禁用所有内置框架
monopoly: false

作用域范围是什么?

scopeRangeRegex 是一个非常实用的功能。以 React 为例:

const { t } = useTranslation("settings")

t("title")        // → 自动解析为 "settings.title"
t("theme.dark")   // → 自动解析为 "settings.theme.dark"

插件会根据正则匹配的结果自动划分作用域范围——从匹配位置到下一个匹配位置(或文件末尾)。在作用域内的所有 t() 调用都会自动加上命名空间前缀。

热重载

修改 YAML 配置文件后无需重启 VS Code,插件会自动检测文件变更并重新加载。这让调试正则表达式变得非常方便——改完立刻看效果。

快速上手

安装

在 VS Code 扩展面板搜索 i18n Ally Next,或从以下渠道安装:

最小配置

对于大多数项目,你只需要两步:

1. 指定语言文件路径

// .vscode/settings.json
{
  "i18n-ally-next.localesPaths": ["src/locales"],
  "i18n-ally-next.sourceLanguage": "zh-CN"
}

2. 打开项目,开始使用

插件会自动检测你的 i18n 框架(Vue I18n、react-i18next 等),无需额外配置。

打开任意包含翻译键的文件,你会看到:

  • 🏷️ 翻译键旁边出现内联注解,直接显示翻译值
  • 🌐 悬停键名可查看所有语言的翻译
  • ✏️ 点击即可编辑翻译
  • 📊 侧边栏显示翻译进度和缺失项

从 i18n Ally 迁移

如果你正在使用原版 i18n Ally,迁移非常简单:

  1. 卸载 i18n Ally
  2. 安装 i18n Ally Next
  3. settings.json 中的 i18n-ally. 前缀替换为 i18n-ally-next.

所有配置项保持兼容,无需其他改动。

写在最后

国际化不应该是痛苦的。i18n Ally Next 的目标是让 i18n 成为开发流程中自然而然的一部分——写代码时看到翻译,提交前检查缺失,源文案变更时自动提醒,协作时有据可查。

我们不只是在做一个插件,更是在构建一套完整的 i18n 开发工具链:从文档到配置,从检测到提取,从翻译到审阅,每一个环节都有对应的解决方案。

如果你觉得这个项目有用,欢迎:

  • ⭐ 在 GitHub 上给我们一个 Star
  • 🐛 提交 Issue 反馈问题
  • 💬 分享给你的团队和朋友
  • 📖 阅读完整文档开始使用

本文同步发布于 i18n Ally Next 官方文档

uni-swipe-action 从编辑页返回后滑动按钮仍显示的问题

一、问题描述

在 uni-app 二手车小程序首页,车辆列表使用了 uni-swipe-action 组件实现左滑展示「删除」和「编辑」按钮。用户操作流程如下:

  1. 左滑某条车辆,露出「删除」「编辑」按钮
  2. 点击「编辑」,跳转到编辑页面
  3. 保存成功,返回首页

Bug 表现:返回首页后,之前滑开的那条车辆仍然处于展开状态,「删除」「编辑」按钮依然可见,而不是像首次进入页面那样只显示列表本身。

期望行为:从编辑页返回首页时,应重置为初始状态,只看到车辆列表,看不到操作按钮。


二、原因分析

2.1 页面生命周期与状态保持

在 uni-app 中,使用 uni.navigateTo 跳转到编辑页时,首页并不会被销毁,而是被压入页面栈并进入「隐藏」状态。当用户从编辑页返回时,首页会触发 onShow,从隐藏恢复为显示。

问题在于:Vue 组件及其内部状态都被保留,包括 uni-swipe-action-item 的展开/收起状态。因此之前滑开的项会保持展开状态。

2.2 原有关闭逻辑的不足

原先的实现思路是:通过 ref 拿到每个 uni-swipe-action-item,调用其 close() 方法关闭:

const closeAllSwipeActions = () => {
  if (swipeItems.value && swipeItems.value.length) {
    swipeItems.value.forEach(item => {
      if (item && typeof item.close === 'function') {
        item.close()
      }
    })
  }
}

但在 @dcloudio/uni-ui 的 swipe-action 实现中:

  • 在微信小程序、H5 等平台,滑动状态由 wxs/renderjs 控制,内部使用 is_show 状态
  • close() 主要供部分非 H5/微信平台使用
  • 官方推荐的关闭方式是调用父组件 uni-swipe-actioncloseAll() 方法,由父组件统一遍历子组件并设置 vm.is_show = 'none'

因此,直接对子项调用 close() 在上述平台上可能无法正确关闭。

2.3 ref 使用方式的影响

原先把 ref="swipeItems" 写在 v-foruni-swipe-action-item 上,Vue 3 中会得到子组件数组。不同平台下子组件的 API 不完全一致,直接遍历子项调用 close() 的可靠性和兼容性较差。


三、解决方案

3.1 使用父组件的 closeAll API

ref 放在父组件 uni-swipe-action 上,并调用其 closeAll() 方法:

<uni-swipe-action ref="swipeActionRef">
  <uni-swipe-action-item
    v-for="car in carList"
    :key="car._id"
    :right-options="getSwipeOptions(car)"
    @click="onSwipeAction($event, car)"
  >
    <!-- 内容 -->
  </uni-swipe-action-item>
</uni-swipe-action>
const swipeActionRef = ref(null)

const closeAllSwipeActions = () => {
  const swipeAction = swipeActionRef.value
  if (swipeAction && typeof swipeAction.closeAll === 'function') {
    swipeAction.closeAll()
  }
}

3.2 在 onShow 中配合 nextTick 调用

从编辑页返回时,onShow 会触发,但此时 DOM 可能尚未完全恢复。使用 nextTick 确保在下次 DOM 更新后再关闭:

onShow(() => {
  nextTick(() => {
    closeAllSwipeActions()
  })
  getCarList()
})

3.3 跳转前主动关闭(可选)

在点击「编辑」跳转前,也调用一次 closeAllSwipeActions(),避免带着展开状态离开页面,有利于状态更一致。


四、知识点小结

要点 说明
页面栈 navigateTo 跳转时原页面不销毁,返回时只是 onShow,组件状态保留
uni-swipe-action 关闭 应通过父组件 uni-swipe-actioncloseAll() 关闭,而非遍历子项调用 close()
ref 使用 父组件统一管理子项状态时,优先对父组件加 ref,调用其对外 API
nextTick 页面恢复显示时,用 nextTick 等 DOM 更新后再操作组件,避免时序问题

五、参考资料

  • uni-swipe-action 插件文档
  • uni-ui 源码:node_modules/@dcloudio/uni-ui/lib/uni-swipe-action/uni-swipe-action.vue
  • uni-ui 源码:node_modules/@dcloudio/uni-ui/lib/uni-swipe-action-item/mpwxs.js

gsap 配置解读 --4

morphSVG 是什么

 <div class="card">
      <h1>案例 22:MorphSVG 形状变形</h1>
      <p>把一个 SVG 形状平滑变形为另一个。</p>
      <svg viewBox="0 0 200 200">
        <path
          id="shape"
          class="shape"
          d="M40 100 C40 40, 160 40, 160 100 C160 160, 40 160, 40 100 Z"
        />
        <path
          id="target"
          d="M100 20 L180 180 L20 180 Z"
          fill="none"
          stroke="none"
        />
      </svg>
      <button id="play">开始变形</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/MorphSVGPlugin.min.js"></script>
    <script>
      const shape = document.querySelector("#shape");
      const target = document.querySelector("#target");
      const playButton = document.querySelector("#play");

      // 注册 MorphSVGPlugin
      gsap.registerPlugin(MorphSVGPlugin);

      const tween = gsap.to(shape, {
        duration: 1.4,
        ease: "power2.inOut",
        morphSVG: target,
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

morphSVGGSAP(GreenSock Animation Platform) 动画库中的一个高级插件(MorphSVGPlugin),专门用于实现 SVG 路径(<path>)之间的平滑形状变形动画


📌 简单定义:

morphSVG 可以让一个 SVG 形状(如圆形、心形、自定义路径)流畅地“变形”为另一个完全不同的 SVG 形状,即使它们的点数、结构完全不同。


✅ 核心能力:

  1. 自动路径匹配
    即使两个 <path>d 属性(路径数据)包含不同数量的锚点或命令(比如一个有 4 个点,另一个有 20 个点),MorphSVGPlugin智能地插入/删除点,使变形过程平滑自然。

  2. 无需手动对齐
    传统变形需要手动确保路径点数一致,而 morphSVG 自动处理这些复杂细节。

  3. 支持多种目标格式

    • 另一个 <path> 元素(如你的代码中的 #target
    • SVG 路径字符串(如 "M10,10 L50,50 ..."
    • 基本 SVG 形状(如 "circle", "rect" —— 插件会自动转换为等效路径)
  4. 高性能 & 精确
    基于数学算法优化,确保变形流畅且视觉准确。


gsap.to(shape, {
  duration: 1.4,
  ease: "power2.inOut",
  morphSVG: target, // 把 #shape 变形成 #target 的形状
  paused: true
});
  • 起始形状#shape 是一个椭圆/胶囊形(用两个三次贝塞尔曲线构成的闭合路径)。
  • 目标形状#target 是一个三角形M100 20 L180 180 L20 180 Z)。
  • 点击按钮后,椭圆会平滑地变成三角形,中间经过自然的过渡形态。

💡 注意:#target 设置了 fill="none" stroke="none",因为它只是作为“目标形状数据”存在,不需要显示出来。


⚠️ 重要前提:

  • morphSVG 只能作用于 <path> 元素
    如果你有 <circle><rect> 等,GSAP 会自动将其转换为等效的 <path>(需插件支持)。
  • 需要加载 MorphSVGPlugin(如你代码中已引入)。
  • GSAP 3 中该插件属于会员功能(免费版不能用于商业项目,但可试用)。

🌟 应用场景:

  • Logo 动画变形(如从图标变文字)
  • 加载动画(形状循环变化)
  • 数据可视化(图表类型切换)
  • 创意交互动画(按钮 hover 变形、图标切换等)

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

morphSVG 是 GSAP 中实现“SVG 形状魔法变形”的利器——只需一行代码,就能让任意两个 SVG 形状之间产生电影级的流畅过渡效果,极大简化了复杂矢量动画的开发。

什么是 Observer

 <div class="card">
      <h1>案例 23:Observer 监听输入</h1>
      <p>滚轮或触摸滑动时移动条形。</p>
      <div class="stage">
        <div class="bar" id="bar"></div>
      </div>
      <div class="hint">在页面上滚动鼠标或触摸滑动</div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Observer.min.js"></script>
    <script>
      const bar = document.querySelector("#bar");
      const limit = 380;
      let position = 0;

      // 注册 Observer 插件
      gsap.registerPlugin(Observer);

      Observer.create({
        target: window,
        type: "wheel,touch,pointer",
        onChange: (event) => {
          position += event.deltaX + event.deltaY;
          position = gsap.utils.clamp(-limit, limit, position);
          gsap.to(bar, { x: position, duration: 0.3, ease: "power2.out" });
        }
      });
    </script>

ObserverGSAP(GreenSock Animation Platform) 提供的一个实用插件(Observer plugin),用于统一监听和处理多种用户输入事件,如:

  • 鼠标滚轮(wheel
  • 触摸滑动(touch
  • 指针设备移动(pointer,包括鼠标、触控笔、触摸屏等)

它的核心目标是:跨设备、跨浏览器地标准化输入行为,让你用一套简洁的 API 响应各种交互,而无需手动处理不同事件的兼容性问题。


📌 简单定义:

Observer 是一个“输入事件聚合器”,它把滚轮、触摸、指针等操作统一转化为带有 deltaX / deltaY 的标准化数据,方便你驱动动画或逻辑。


✅ 核心特性:

  1. 多输入类型支持
    通过 type: "wheel,touch,pointer" 一行代码同时监听多种交互方式,适配桌面(滚轮)和移动端(滑动)。

  2. 标准化增量值(delta)

    • event.deltaX:水平方向的滚动/滑动量(向右为正)
    • event.deltaY:垂直方向的滚动/滑动量(向下为正)
      不同设备/浏览器的原始事件(如 wheel.deltaYtouchmove 位移)会被自动归一化,数值更一致。
  3. 防抖与性能优化
    内置节流(throttle)机制,避免高频事件导致性能问题。

  4. 灵活的目标绑定
    可监听 windowdocument 或任意 DOM 元素。

  5. 额外控制选项

    • preventDefault: 是否阻止默认滚动行为
    • tolerance: 触发阈值(避免误触)
    • dragMinimum: 最小拖拽距离

Observer.create({
  target: window,                     // 监听整个窗口
  type: "wheel,touch,pointer",        // 同时响应滚轮、触摸、指针
  onChange: (event) => {
    position += event.deltaX + event.deltaY; // 累加水平+垂直移动量
    position = gsap.utils.clamp(-limit, limit, position); // 限制范围 [-380, 380]
    gsap.to(bar, { x: position, ... });       // 驱动条形移动
  }
});
  • 当你在页面上滚动鼠标滚轮在手机上左右/上下滑动时,
  • Observer 会捕获这些动作,并计算出 deltaX(水平)和 deltaY(垂直),
  • 代码将两者相加(实现“任意方向滑动都影响条形位置”),
  • 然后用 GSAP 动画平滑更新 .barx 位置。

💡 注意:这里把 deltaX + deltaY 合并使用,意味着上下滚轮也会移动条形(通常只用 deltaX 做横向拖拽)。这是一种简化设计,实际项目中可能只取某一方向。


🌟 典型应用场景:

场景 说明
自定义滚动容器 禁用原生滚动,用 Observer 驱动内容平移
视差/交互动画 滚动时触发元素位移、缩放、透明度变化
移动端拖拽交互 实现可拖拽的时间轴、图片对比滑块等
游戏控制 用滚轮/触摸控制角色移动或视角

⚠️ 注意事项:

  • Observer 不会阻止浏览器默认行为(如页面滚动),除非设置 preventDefault: true
  • 如果只想监听特定方向,建议分开处理 deltaXdeltaY,避免干扰。
  • 在触摸设备上,若需精确拖拽,通常配合 Draggable 插件更合适;Observer 更适合“感应式”输入(如滚动触发)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

Observer 是 GSAP 中用于“感知用户输入”的瑞士军刀——它抹平了滚轮、触摸、指针设备的差异,提供统一、流畅、高性能的交互数据,是构建现代交互动画不可或缺的工具。

什么是physics2D

<div class="card">
      <h1>案例 24:Physics2D 抛物线</h1>
      <p>模拟速度与重力的抛物线。</p>
      <div class="stage">
        <div class="ball" id="ball"></div>
      </div>
      <button id="play">发射小球</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Physics2DPlugin.min.js"></script>
    <script>
      const ball = document.querySelector("#ball");
      const playButton = document.querySelector("#play");

      // 注册 Physics2DPlugin
      gsap.registerPlugin(Physics2DPlugin);

      const tween = gsap.to(ball, {
        duration: 1.6,
        physics2D: {
          velocity: 420,
          angle: 60,
          gravity: 600
        },
        paused: true,
        onComplete: () => {
          gsap.set(ball, { clearProps: "all" });
        }
      });

      playButton.addEventListener("click", () => {
        gsap.set(ball, { x: 0, y: 0 });
        tween.restart();
      });
    </script>

Physics2DGSAP(GreenSock Animation Platform) 提供的一个专用插件(Physics2DPlugin),用于模拟二维空间中的基础物理运动,比如抛物线轨迹、弹道、重力下落等效果。


📌 简单定义:

Physics2D 插件让你只需指定初始速度(velocity)、发射角度(angle)和重力(gravity),就能自动计算并驱动元素沿真实的物理抛物线运动。

它把复杂的物理公式(如匀加速运动、矢量分解)封装成简单的配置项,无需手动编写运动方程。


✅ 核心参数说明:

physics2D: {
  velocity: 420,   // 初始速度(单位:像素/秒)
  angle: 60,       // 发射角度(单位:度,0°=向右,90°=向上)
  gravity: 600     // 重力加速度(单位:像素/秒²,方向向下)
}

GSAP 会自动将速度分解为水平(x)和垂直(y)分量:

  • vx = velocity * cos(angle)
  • vy = velocity * sin(angle)

然后根据物理公式逐帧更新位置:

  • x(t) = vx * t
  • y(t) = vy * t + 0.5 * gravity * t²

从而生成自然的抛物线轨迹


🔧 案例中发生了什么?

  1. 点击“发射小球”按钮;
  2. 小球被重置到起点(x: 0, y: 0);
  3. GSAP 启动 Physics2D 动画:
    • 420 像素/秒 的初速度,
    • 60° 角(斜向上)发射,
    • 受到 600 像素/秒² 的重力影响(向下加速);
  4. 小球沿一条先上升后下降的抛物线飞行;
  5. 动画结束后,clearProps: "all" 清除内联样式,便于重复播放。

🌟 典型应用场景:

场景 说明
游戏开发 子弹、炮弹、跳跃角色的轨迹
交互动效 点击时元素“弹出”并落回(如点赞动画)
数据可视化 模拟粒子运动、物理演示
趣味 UI 抛物线菜单、飞入购物车的商品

⚠️ 注意事项:

  • Physics2D 只控制 xy 属性(即 transform: translate(x, y)),不影响旋转、缩放等。
  • 不处理碰撞检测(如撞墙反弹),如需复杂物理,应使用专业引擎(如 Matter.js、Box2D)。
  • 所有单位基于CSS 像素,数值需根据视觉效果调整(例如 gravity: 600 比真实地球重力大很多,只为视觉流畅)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

Physics2D 是 GSAP 中实现“真实感抛物线运动”的快捷方式——你只需提供初速度、角度和重力,它就能自动生成符合牛顿力学的平滑轨迹,让网页动画更具物理直觉和趣味性。

PhysicsProps是什么

 <div class="card">
      <h1>案例 25:PhysicsProps 物理属性</h1>
      <p>同时给 x / y 设置速度与加速度。</p>
      <div class="stage">
        <div class="square" id="square"></div>
      </div>
      <button id="play">启动物理运动</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/PhysicsPropsPlugin.min.js"></script>
    <script>
      const square = document.querySelector("#square");
      const playButton = document.querySelector("#play");

      // 注册 PhysicsPropsPlugin
      gsap.registerPlugin(PhysicsPropsPlugin);

      const tween = gsap.to(square, {
        duration: 1.6,
        physicsProps: {
          x: { velocity: 380, acceleration: -100 },
          y: { velocity: -420, acceleration: 600 }
        },
        paused: true,
        onComplete: () => {
          gsap.set(square, { clearProps: "all" });
        }
      });

      playButton.addEventListener("click", () => {
        gsap.set(square, { x: 0, y: 0 });
        tween.restart();
      });
    </script>

PhysicsPropsGSAP(GreenSock Animation Platform) 提供的一个高级插件(PhysicsPropsPlugin),用于为任意 CSS 属性(如 xyrotationscale 等)模拟基于“初速度”和“加速度”的物理运动

Physics2D(仅限二维抛物线)不同,PhysicsProps灵活、通用——你可以分别控制每个属性的物理行为。


📌 简单定义:

PhysicsProps 允许你为元素的任意可动画属性设置初始速度(velocity)和恒定加速度(acceleration),GSAP 会根据物理公式自动计算其随时间变化的值。

它本质上实现了:
位移 = 初速度 × 时间 + ½ × 加速度 × 时间²
(即经典运动学公式 s=v0t+12at2s = v_0 t + \frac{1}{2} a t^2


✅ 核心特点:

  • 按属性独立控制:可以只给 x 加速度,y 只有速度,rotation 单独减速等。
  • 支持任意数值型属性:不仅限于位置,还可用于 opacityscalebackgroundColor(需配合其他插件)等。
  • 精确物理模拟:基于真实时间(秒)和像素/单位/秒² 的加速度模型。

🔧 在你的代码中:

physicsProps: {
  x: { velocity: 380, acceleration: -100 },
  y: { velocity: -420, acceleration: 600 }
}

这意味着:

方向 初速度(velocity) 加速度(acceleration) 物理含义
x(水平) +380 px/s(向右) -100 px/s²(向左减速) 小方块先快速右移,但逐渐慢下来,甚至可能反向
y(垂直) -420 px/s(向上) +600 px/s²(向下加速) 小方块先快速上冲,然后被“重力”拉回并加速下落

💡 注意:y 轴在网页中是向下为正,所以 velocity: -420 表示向上运动,而 acceleration: 600 表示向下加速(类似重力)。

这实际上也形成了一个抛物线轨迹,但比 Physics2D 更自由——你可以让 x 减速、y 加速,甚至让 rotation 也参与物理运动。


🆚 PhysicsProps vs Physics2D

特性 Physics2D PhysicsProps
用途 专为 2D 抛物线设计 通用物理属性模拟
输入方式 velocity, angle, gravity 每个属性单独设 velocityacceleration
灵活性 低(固定模式) 高(任意组合)
适用属性 xy 任何数值型属性(x, y, rotation, scaleX...)

✅ 简单抛物线 → 用 Physics2D
✅ 复杂多属性物理效果 → 用 PhysicsProps


🌟 应用场景举例:

  • 弹跳方块y 有负初速度 + 正重力加速度,配合 onComplete 实现多次弹跳
  • 旋转减速rotation: { velocity: 720, acceleration: -300 }(转两圈后慢慢停下)
  • 淡入淡出惯性opacity: { velocity: 1, acceleration: -0.5 }
  • 组合动画:位置抛物线 + 自身旋转减速

⚠️ 注意事项:

  • duration 仍然有效,动画会在指定时间内结束(即使物理上还没“停”)。
  • 如果希望无限物理模拟(如持续受力),应使用 gsap.ticker 手动更新,而非 to() 动画。
  • Physics2D 一样,不处理碰撞或边界反弹,需自行添加逻辑。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

PhysicsProps 是 GSAP 中实现“精细化物理动画”的利器——它让你像写物理题一样,为每个属性设定初速度和加速度,从而创造出更真实、更动态的交互动效。

什么是PixiPlugin

   <div class="card">
      <h1>案例 26:PixiPlugin + PixiJS</h1>
      <p>用 GSAP 控制 PixiJS 图形。</p>
      <div id="canvas-wrapper"></div>
      <button id="play">播放动画</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/pixi.js@7.4.0/dist/pixi.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/PixiPlugin.min.js"></script>
    <script>
      const wrapper = document.querySelector("#canvas-wrapper");
      const playButton = document.querySelector("#play");

      // 创建 Pixi 应用
      const app = new PIXI.Application({
        width: 520,
        height: 220,
        backgroundColor: 0x0f172a
      });
      wrapper.appendChild(app.view);

      // 创建一个圆形图形
      const circle = new PIXI.Graphics();
      circle.beginFill(0xf97316).drawCircle(0, 0, 30).endFill();
      circle.x = 80;
      circle.y = 110;
      app.stage.addChild(circle);

      // 注册 PixiPlugin
      gsap.registerPlugin(PixiPlugin);

      const tween = gsap.to(circle, {
        duration: 1.6,
        pixi: {
          x: 440,
          y: 110,
          rotation: Math.PI * 2,
          scale: 1.3
        },
        ease: "power2.inOut",
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

PixiPluginGSAP(GreenSock Animation Platform) 提供的一个专用插件,用于无缝、高效地对 PixiJS 创建的图形对象(如 SpriteGraphicsContainer 等)进行动画控制


📌 简单定义:

PixiPlugin 让你像操作普通 DOM 元素一样,用 GSAP 的简洁语法直接动画 PixiJS 对象的属性(如位置、旋转、缩放、颜色等),而无需手动更新渲染或处理底层细节。


✅ 为什么需要它?

PixiJS 是一个基于 WebGL 的高性能 2D 渲染引擎,常用于游戏、交互式图形和复杂动画。但它的对象属性(如 x, y, rotation, scale不是普通的 CSS 属性,而是 JavaScript 对象的数值字段。

如果没有 PixiPlugin,你虽然也能用 GSAP 动画这些值,但:

  • 无法自动触发 Pixi 的渲染更新;
  • 不能直接使用某些 Pixi 特有属性(如 tint 颜色、anchor);
  • 缩放(scale)是 { x, y } 对象,不能直接写 scale: 1.5

PixiPlugin 解决了这些问题!


🔧 核心功能:

gsap.to(circle, {
  duration: 1.6,
  pixi: {
    x: 440,
    y: 110,
    rotation: Math.PI * 2, // 旋转 360°
    scale: 1.3             // 自动作用于 circle.scale.x 和 .y
  },
  ease: "power2.inOut",
  paused: true
});
PixiPlugin 做了什么?
属性 普通方式问题 PixiPlugin 如何处理
x, y 可以直接设,但需确保在渲染循环中生效 自动集成,高效更新
rotation 单位是弧度,需手动计算 直接接受弧度(也可配置角度)
scale {x: 1, y: 1} 对象,不能直接赋值数字 scale: 1.3 → 自动设为 scale.x = scale.y = 1.3
tint 颜色是十六进制数字(如 0xff0000 支持字符串 "red""#ff0000" 自动转数字
anchor, pivot 同样是 {x, y} 对象 支持简写(如 anchor: 0.5

此外,PixiPlugin 还会:

  • 自动与 Pixi 的渲染循环协同,确保动画流畅;
  • 优化性能,避免不必要的计算;
  • 支持 GSAP 所有高级功能:时间轴、缓动、重复、回调等。

🌟 典型可动画的 Pixi 属性(通过 pixi: {}):

pixi: {
  x: 100,
  y: 200,
  rotation: Math.PI / 2,
  scaleX: 1.5,
  scaleY: 0.8,
  scale: 1.2,        // 同时设 scaleX 和 scaleY
  anchor: 0.5,       // 或 { x: 0.5, y: 0.5 }
  pivot: { x: 10, y: 10 },
  tint: "#ff5500",   // 自动转为 0xff5500
  alpha: 0.7         // 注意:alpha 不需要 pixi 前缀,但也可用
}

💡 注意:像 alphavisible 等通用属性,即使不写在 pixi 对象里,GSAP 也能直接动画。但强烈建议把 Pixi 专属属性放在 pixi: {},以获得最佳兼容性和功能支持。


🆚 对比:不用插件 vs 用 PixiPlugin

不用插件(繁琐且易错)

gsap.to(circle.scale, { x: 1.3, y: 1.3, duration: 1.6 });
gsap.to(circle, { x: 440, y: 110, rotation: Math.PI * 2, duration: 1.6 });
// tint 颜色还得自己转换...

PixiPlugin(简洁直观)

gsap.to(circle, {
  pixi: { x: 440, y: 110, rotation: Math.PI * 2, scale: 1.3, tint: "orange" }
});

⚠️ 使用前提:

  1. 已引入 PixiJS(如 pixi.min.js
  2. 已引入 GSAPPixiPlugin
  3. 调用 gsap.registerPlugin(PixiPlugin)

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

PixiPlugin 是 GSAP 与 PixiJS 之间的“翻译官”和“加速器”——它让你用最简洁的代码,高效、准确地驱动高性能 WebGL 图形的复杂动画,是开发 PixiJS 交互动画的必备工具。

什么是ScrambleText

<div class="card">
      <h1>案例 27:ScrambleText 文字扰动</h1>
      <p>让文字从乱码过渡到目标内容。</p>
      <div class="text" id="text">GSAP 很好用</div>
      <button id="play">开始扰动</button>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrambleTextPlugin.min.js"></script>
    <script>
      const text = document.querySelector("#text");
      const playButton = document.querySelector("#play");

      // 注册 ScrambleTextPlugin
      gsap.registerPlugin(ScrambleTextPlugin);

      const tween = gsap.to(text, {
        duration: 1.2,
        scrambleText: {
          text: "ScrambleText 很炫酷",
          chars: "上下左右123456",
          revealDelay: 0.2
        },
        paused: true
      });

      playButton.addEventListener("click", () => {
        tween.restart();
      });
    </script>

ScrambleTextGSAP(GreenSock Animation Platform) 提供的一个趣味性插件(ScrambleTextPlugin),用于实现 “文字扰动”动画效果
即让一段文本从随机乱码字符开始,经过短暂的“闪烁/跳动”过程,最终平滑揭示出目标文字内容


📌 简单定义:

ScrambleText 会将元素的文本临时替换为指定的乱码字符(如符号、数字、自定义文字),然后逐字“解码”为目标文本,营造出黑客风、密码破解、数据加载等酷炫效果。


✅ 核心特性:

1. 乱码字符可自定义

通过 chars 参数指定用于扰动的字符集:

chars: "上下左右123456"  // 使用这些汉字和数字作为乱码
// 或内置预设:
// chars: "upperCase"    → A-Z
// chars: "lowerCase"    → a-z
// chars: "upperAndLowerCase" → A-Za-z
// chars: "all"          → 所有 ASCII 可见字符
2. 控制揭示节奏
  • revealDelay: 每个字符开始“解码”前的延迟(秒),制造逐字显现效果。
  • 整体动画时长由 duration 控制。
3. 保留原始样式

动画只改变文本内容,不会影响元素的 CSS 样式(字体、颜色、大小等)。

4. 支持中文、emoji 等 Unicode 字符

只要 chars 和目标文本使用合法 Unicode 字符,即可正常工作(如你的例子中使用中文)。


scrambleText: {
  text: "ScrambleText 很炫酷", // 最终显示的文字
  chars: "上下左右123456",     // 乱码阶段使用的字符
  revealDelay: 0.2            // 每个字延迟 0.2 秒开始揭示
}

动画过程:

  1. 初始文本 "GSAP 很好用" 被隐藏;
  2. 显示与目标文本相同长度的乱码(如 "上1下2左3右4");
  3. 从左到右(或按内部逻辑),每个字符在 0.2s 间隔后,从乱码“跳变”为目标字符;
  4. 最终完整显示 "ScrambleText 很炫酷"

💡 注意:乱码长度 = 目标文本长度。如果目标文本更长,会补乱码;更短则截断。


🌟 典型应用场景:

场景 示例
科技感 UI 登录界面密码验证、数据加载提示
标题动画 页面标题/LOGO 的入场效果
游戏反馈 得分变化、关卡名称切换
营销 H5 “揭晓答案”、“惊喜文案”展示

⚠️ 注意事项:

  • 只作用于纯文本内容,不能用于富文本(如包含 <span> 的 HTML)。
  • 如果目标文本包含 HTML 标签,会被当作普通字符处理(不推荐)。
  • 动画结束后,元素的 textContent 会被永久替换为目标文本。
  • GSAP 3 中该插件属于会员功能(免费版可用于学习/非商业项目)。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrambleText 是 GSAP 中实现“文字解码”特效的魔法工具——只需一行配置,就能让静态文字变身科幻电影中的动态信息流,极大提升界面的趣味性和专业感。

什么是ScrollSmoother

<div class="wrapper" id="smooth-wrapper">
      <div class="content" id="smooth-content">
        <header>
          <h1>案例 28:ScrollSmoother 平滑滚动</h1>
          <p>滚动更柔和,体验更顺滑。</p>
        </header>
        <div class="section">
          <div class="card">第一屏内容</div>
        </div>
        <div class="section">
          <div class="card">第二屏内容</div>
        </div>
        <div class="section">
          <div class="card">第三屏内容</div>
        </div>
      </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollSmoother.min.js"></script>
    <script>
      // 注册插件
      gsap.registerPlugin(ScrollTrigger, ScrollSmoother);

      // 创建平滑滚动
      ScrollSmoother.create({
        wrapper: "#smooth-wrapper",
        content: "#smooth-content",
        smooth: 1.2
      });
    </script>

ScrollSmootherGSAP(GreenSock Animation Platform) 提供的一个高级插件(属于 ScrollSmoother 模块),用于实现 “平滑滚动”(Smooth Scrolling) 效果——即当你用鼠标滚轮或触摸板滚动页面时,内容不是“一格一格”跳动,而是像被“缓冲”一样流畅、柔顺地滑动,极大提升用户体验。


📌 简单定义:

ScrollSmoother 通过拦截原生滚动事件,用 GSAP 驱动页面内容以缓动动画的方式移动,从而实现电影级的丝滑滚动体验。

它常用于高端网站、作品集、品牌官网等追求精致交互的场景。


✅ 核心原理:

  1. 结构要求

    • 一个外层容器(wrapper):设置 overflow: hidden,作为“视窗”。
    • 一个内层内容(content):高度由实际内容决定,被 GSAP 控制垂直位移。
  2. 工作方式

    • 用户滚动时,不直接滚动页面,而是记录滚动意图(delta)。
    • GSAP 用 requestAnimationFrame 和缓动函数(如 power3.out逐步更新 contenty 位置
    • 视觉上形成“惯性滚动”或“阻尼滚动”效果。
  3. 与 ScrollTrigger 无缝集成

    • 所有基于滚动的动画(如元素淡入、视差)仍能正常工作。
    • ScrollTrigger 会自动识别 ScrollSmoother,使用其内部的虚拟滚动位置。

ScrollSmoother.create({
  wrapper: "#smooth-wrapper",   // 外层容器(固定高度,隐藏溢出)
  content: "#smooth-content",   // 内层可滚动内容
  smooth: 1.2                   // 平滑系数(值越大越“慢”,默认 1)
});
  • 页面结构被包裹在 .wrapper 中;
  • 实际内容放在 .content 里;
  • 滚动时,.content 会被 GSAP 以 ease-out 方式平滑移动;
  • smooth: 1.2 表示轻微增强平滑感(数值通常在 0.5 ~ 2 之间)。

💡 注意:你不需要写任何 CSS 动画或监听 wheel 事件——ScrollSmoother 全自动处理!


🌟 主要优势:

特性 说明
极致流畅 消除原生滚动的卡顿感,尤其在 macOS 触控板上效果显著
保留原生行为 地址栏、锚点链接、键盘导航(↑↓ PgUp/PgDn)依然有效
兼容 ScrollTrigger 所有 GSAP 滚动触发动画无需修改即可运行
支持方向控制 可限制仅垂直/水平平滑,或完全禁用某方向
移动端优化 在 iOS/Android 上自动降级为原生滚动(避免性能问题)

⚙️ 常用配置选项:

ScrollSmoother.create({
  wrapper: "#wrapper",
  content: "#content",
  smooth: 1,               // 平滑系数
  effects: true,           // 启用高级效果(如视差需此开启)
  smoothTouch: 0.1,        // 移动端平滑度(0 = 关闭)
  normalizeScroll: true,   // 防止浏览器地址栏收起导致的跳动
  ignoreMobileResize: true // 避免 iOS 键盘弹出时布局错乱
});

⚠️ 注意事项:

  • 必须正确设置 HTML/CSS 结构
    #smooth-wrapper {
      overflow: hidden;
      position: fixed;
      top: 0; left: 0;
      width: 100%;
      height: 100%;
    }
    
  • 不适合所有网站:内容极少或需要快速滚动的页面(如文档、博客列表)可能反而降低效率。
  • 性能敏感:确保内容不包含大量未优化的动画或重绘元素。
  • SEO 友好:因为只是视觉平滑,不影响 HTML 结构,对 SEO 无影响。

📚 官方文档:

👉 greensock.com/docs/v3/Plu…


✅ 总结:

ScrollSmoother 是 GSAP 打造高端网页体验的“秘密武器”——它用一行代码将生硬的页面滚动升级为丝滑流畅的交互动画,同时完美兼容 ScrollTrigger 和原生浏览器行为,是现代创意网站的标配工具。

在行情面板中加入 K 线:一次结构升级的实现过程

在行情面板中加入 K 线:一次结构升级的实现过程

系列文章 · Demo#2

在上一篇《用 Ticker API 写一个行情面板:一次完整的实现过程》中,我用 REST Ticker + 定时刷新,完成了一个可以长期运行的行情展示面板。

那个版本解决的是"看一眼实时行情"的问题:无论是美股、港股,还是外汇、指数,只要通过统一的行情 API 拉取数据,就可以稳定展示当前价格、涨跌幅与波动区间。

但当这个面板真正跑起来之后,我很快意识到——它只能告诉我"现在",却无法回答"它是怎么走到这里的"。

对于一个真正可用的行情系统来说,无论是做股票分析、量化研究,还是单纯查看历史走势,K 线都是不可或缺的一部分。

这篇文章,就是在原有实时行情面板的基础上,加入 K 线数据,完成一次结构升级。


一、让列表给图表让位

Demo#1 上线后,我盯着那个行情列表看了很久。

它可以稳定展示多市场实时行情数据,包括美股、港股、外汇等不同品种。但每次我点开某个品种,都会下意识地想知道:

它这几天是怎么涨上来的?

于是我决定在原有结构上做一次升级,而不是新开一个页面。

我没有做路由跳转,而是选择让列表"让位"------点击某一行后,左侧收缩成产品列表,右侧展开 K 线详情。

行情列表+K线 .png


二、我只是想把图画出来

一开始目标很简单:

把蜡烛图显示出来。

我直接去接 /kline 接口,然后丢进图表里。

真实接入行情 API 后,第一个报错是:

map is not a function

我才回去认真看了一遍官方文档

接口实际上是:

  • /kline ------ 历史 K 线数据\
  • /kline/latest ------ 当前周期实时更新

真正数组在 data.klines 里。

那一刻我意识到:

永远不要假设接口结构。


三、图表开始"反抗"我

排查后我发现:

  • API 返回倒序数据\
  • 数值是字符串\
  • 图表只接受最小结构

K 线转换逻辑大概是这样的:

const rawKlines = result.data.klines
const klineData = rawKlines.map(item => ({
    time: Math.floor(item.time / 1000),  // 毫秒转秒
    open: parseFloat(item.open),
    high: parseFloat(item.high),
    low: parseFloat(item.low),
    close: parseFloat(item.close)
}))

klineData.sort((a, b) => a.time - b.time)  // 升序排序

这一段看起来简单,但它让我第一次真正理解:

图表库并不是黑盒,它要求的是"结构正确的时间序列"。


四、当价格动而图不动时

/kline 只返回已经完成的周期。

当前正在形成的这一根,在历史数据里是不存在的。

于是逻辑变成:

// 加载历史数据
historyData = await fetchKLine(symbol, interval, 75)

// 获取最新K线
latestKline = await fetchLatestKLine(symbol, interval)

if latestKline exists:
    existingIndex = historyData.findIndex(item => item.time === latestKline.time)
    if existingIndex >= 0:
        historyData[existingIndex] = latestKline  // 更新现有K线
    else:
        historyData.push(latestKline)  // 添加新K线

从那一刻起,我开始理解:

行情系统的核心不是"画图",而是处理时间。实时行情是点,K 线是区间。


五、预加载策略的演进

最初的逻辑很简单:

chart.onScroll(() => {
    if scrollToLeft:
        fetchKLine(symbol, interval, 50)
})

能用,但滚动会卡顿。

后来我改成了"buffer 策略":

|------------------ 75 ------------------|
|------ 50 visible ------|-- 25 buffer --|

当可视区域消耗掉一半 buffer 时 → 触发加载

对应逻辑:

const KLINE_CONFIG = {
    initialLoad: 75,
    batchLoad: 25,
    triggerRatio: 0.5
}

// 首次加载
fetchKLine(symbol, interval, KLINE_CONFIG.initialLoad)

// 监听可见范围变化
onVisibleRangeChange():
    leftBufferCount = visibleRange.from
    minBufferCount = batchLoad * triggerRatio
    
    if leftBufferCount < minBufferCount:
        preloadHistoricalData(symbol, interval, KLINE_CONFIG.batchLoad)

这一刻我意识到:功能完成,不等于体验完成。


六、Resize 给我的提醒

有一次我关闭浏览器的开发者工具。

图表变宽了,但加载范围没变。

于是我开始用 ResizeObserver:

const resizeObserver = new ResizeObserver(entries => {
    if chartInstance && chartEl:
        // 保存当前可见范围
        currentRange = chartInstance.timeScale().getVisibleLogicalRange()
        
        // 更新图表尺寸
        chartInstance.applyOptions({
            width: chartEl.clientWidth,
            height: chartEl.clientHeight
        })
        
        // 恢复可见范围
        if currentRange:
            chartInstance.timeScale().setVisibleLogicalRange(currentRange)
            
            // 检查是否需要预加载
            loadMoreHistoricalData()
})

逻辑范围和视觉范围,是两回事。图变宽,单位时间内展示的 K 线数量会变化。

这一步让我第一次认真思考"视图驱动的数据加载"。


七、不是所有错误都值得被看到

我最终做了一个决定:

// 首次加载历史数据 - 失败显示错误
async function fetchKLine(symbol, interval, limit, startTime, endTime, silent = false):
    try:
        // 加载数据
        ...
    catch error:
        if not silent:
            klineErrorByKey[key] = error.message  // 显示错误
            updateKLineUI()
        else:
            console.warn('预加载失败(不影响显示)')  // 静默失败

// 预加载历史数据 - 使用静默模式
preloadHistoricalData(symbol, interval, count):
    await fetchKLine(symbol, interval, count, startTime, endTime, true)  // silent = true

// 获取最新K线 - 静默失败
fetchLatestKLine(symbol, interval):
    try:
        // 获取最新K线
        ...
    catch error:
        console.warn('获取最新K线失败(不影响显示)')
        return null
  • 首次加载是核心能力\
  • 预加载是增强能力\
  • latest 是增强能力

增强层失败,不应该影响主图。


八、它已经不像一个 Demo

当我开始处理这些边界时,我突然意识到:

我已经不再只是实现一个功能。

在 Demo #1 里,我解决的是实时行情快照。

在 Demo #2 里,我开始处理历史行情与时间结构。

但它还不够。

因为 K 线依然是静止的。

ezgif-71293c90c1d84a80.gif

这就是现在的 Demo #2。

下一步,它应该动起来了。


源码与示例

完整Demo代码已开源:

👉 github.com/TickDB/tick…

实战A2UI:从JSON到像素——深入Lit渲染引擎

在之前《A2UI初探:为什么前端不再需要手写UI代码了?》和《A2UI二番战:AI生成UI的“三权分立”与生态融合》这两篇文章我们介绍了A2UI的基本原理和特征,现在,是时候卷起袖子进行实践了。

image.png 本文将带你进行一次深度实战之旅,依托高考 信息查询的业务场景(本合集中之前AI Agent开发案例),演练通过A2UI实现动态渲染的具体做法,并深入探讨其背后的安全架构与前沿实践

我们将以流行的Lit库作为实现载体,因为它轻量、高性能且基于Web Components,与A2UI的组件化理念高度契合。

这个A2UI系列文章,是组内前端大叔研究梳理,完整介绍了A2UI在AI应用中出现的逻辑和对应场景,它的设计理念、有了A2UI以后对前端工程师的影响,以及它与Vibe Coding的区别、与AG-UI的区别、与开发框架的关系等,最后分享一个真实的案例验证过程。

全景应用示例:基于高考信息查询Agent实战



1、业务背景说明

在我们正式开始介绍之前,先回顾下本公众号“AI Agent开发”的文章合集,针对高考信息查询这个业务需求开发Agent的过程做了详细介绍,本文在此基础上,来进行我们的实战练习。

原有“高考信息查询Agent”支持考生或者家长通过自然语言输入,查询历年高考信息,含考生人数、录取人数、复读人数、普通高校数量、本科录取人数、专科录取人数等。

我们现在引入CopilotKit框架升级该Agent,并搭配相应前端组件,根据用户输入的不同意图,由“高考信息查询Agent”返回相应结果,实现动态渲染。

2、实现流程描述

技术栈协同工作流:

image.png 1. 用户:在Web应用中对AI说:“历年高考信息分析”。 2. 前端 (使用CopilotKit React SDK):捕获用户输入,通过 AG-UI协议 发送 user.action 事件到后端。 3. 后端AI Agent (使用CopilotKit Backend SDK):

    • 接收到请求。
    • 通过NL2SQL将自然语言转成SQL,通过SQL查询数据。
    • 通过 MCP协议 调用“高考信息数据库查询工具”,获取历年高考信息。
    • 将数据发送给专业的“数据分析Agent”(A2A协议用于Agent间协作)。
    • 收到分析结果(如“2025年高考考生报名人数,录取人数”)。
    • 决定生成一个包含年份、报名人数、查询详情的卡片列表界面。
    • 按照 A2UI规范 编排这个界面的JSON描述。
    • 通过 AG-UI协议,以 ui.delta 事件流的形式,将A2UI JSON 增量式地发回前端。

4. 前端 (CopilotKit内置A2UI渲染器):实时接收AG-UI事件流,解析其中的A2UI JSON,调用本地注册的企业级安全组件(如List、Card),将界面渲染出来。5. 用户:看到一个交互式卡片列表,可以点击卡片查看详情。

  • 让我们从输入“历年高考信息”开始

image.png

  • Agent返回历年高考信息卡片列表

image.png

  • 接口协议遵循“application/json+a2ui”规范,下面是接口返回的UI结构定义(surfaceUpdate)JSON
[    {        "id":"root-column",        "component":{            "Column":{                "children":{                    "explicitList":[                        "title-heading",                        "item-list"                    ]
                }
            }
        }
    },
    {
        "id":"title-heading",
        "component":{
            "Text":{
                "usageHint":"h1",
                "text":{
                    "path":"title"
                }
            }
        }
    },
    {
        "id":"item-list",
        "component":{
            "List":{
                "direction":"vertical",
                "children":{
                    "template":{
                        "componentId":"item-card-template",
                        "dataBinding":"/items"
                    }
                }
            }
        }
    },
    {
        "id":"item-card-template",
        "component":{
            "Card":{
                "child":"card-layout"
            }
        }
    },
    {
        "id":"card-layout",
        "component":{
            "Row":{
                "alignment":"center",
                "children":{
                    "explicitList":[
                        "template-year",
                        "template-count",
                        "template-query-button"
                    ]
                }
            }
        }
    },
    {
        "id":"template-year",
        "weight":1,
        "component":{
            "Text":{
                "usageHint":"h3",
                "text":{
                    "path":"year"
                }
            }
        }
    },
    {
        "id":"template-count",
        "weight":2,
        "component":{
            "Text":{
                "text":{
                    "path":"countText"
                }
            }
        }
    },
    {
        "id":"template-query-button",
        "weight":1,
        "component":{
            "Button":{
                "child":"btn-text",
                "primary":true,
                "action":{
                    "name":"query_year_detail",
                    "context":[
                        {
                            "key":"year",
                            "value":{
                                "path":"year"
                            }
                        }
                    ]
                }
            }
        }
    },
    {
        "id":"btn-text",
        "component":{
            "Text":{
                "text":{
                    "literalString":"查询详情"
                }
            }
        }
    }
]
  • 如果我们输入的是“2024年高考详细信息”Agent会返回如下渲染界面

image.png

  • 对应的A2UI协议JSON
[    {        "id":"detail-card",        "component":{            "Card":{                "child":"detail-column"            }        }    },    {        "id":"detail-column",        "component":{            "Column":{                "children":{                    "explicitList":[                        "detail-title",                        "div1",                        "candidates-info",                        "retake-info",                        "div2",                        "admission-total",                        "undergrad-info",                        "specialty-info",                        "div3",                        "uni-count"                    ]
                }
            }
        }
    },
    {
        "id":"detail-title",
        "component":{
            "Text":{
                "usageHint":"h2",
                "text":{
                    "path":"yearTitle"
                }
            }
        }
    },
    {
        "id":"candidates-info",
        "component":{
            "Text":{
                "text":{
                    "path":"candidatesCount"
                }
            }
        }
    },
    {
        "id":"retake-info",
        "component":{
            "Text":{
                "text":{
                    "path":"retakeCount"
                }
            }
        }
    },
    {
        "id":"admission-total",
        "component":{
            "Text":{
                "usageHint":"h4",
                "text":{
                    "path":"totalAdmission"
                }
            }
        }
    },
    {
        "id":"undergrad-info",
        "component":{
            "Text":{
                "text":{
                    "path":"undergradCount"
                }
            }
        }
    },
    {
        "id":"specialty-info",
        "component":{
            "Text":{
                "text":{
                    "path":"specialtyCount"
                }
            }
        }
    },
    {
        "id":"uni-count",
        "component":{
            "Text":{
                "usageHint":"caption",
                "text":{
                    "path":"universityCount"
                }
            }
        }
    },
    {
        "id":"div1",
        "component":{
            "Divider":{}
        }
    },
    {
        "id":"div2",
        "component":{
            "Divider":{}
        }
    },
    {
        "id":"div3",
        "component":{
            "Divider":{}
        }
    }
]

如果我们输入“历年高考人数变化情况”,LLM识别到用户更想了解高考人数的趋势变化,这时候可能会以折线图或者其他展示形式的组件去渲染,前提是我们的“可信组件库”已经实现了这些组件。

3、关键模式解析

1. literalString vs path:literalString代表静态文本,而path指向一个动态数据模型(如title),这是数据绑定的核心。2. 组件复合:title-heading、item-list、等布局组件通过explicitList管理子组件ID,形成清晰的层级。3. 根布局组件,组件类型:Column(垂直列布局)

{
    "id": "root-column",
    "component": {
        "Column": {
            "children": {
                "explicitList": [
                    "title-heading",
                    "item-list"
                ]
            }
        }
    }
}

4. 列表组件 - 核心部分

亮点设计:

  • 采用模板化渲染(template + dataBinding)
  • 垂直方向排列,适合移动端展示
{
    "id":"item-list",
    "component":{
        "List":{
            "direction":"vertical",
            "children":{
                "template":{
                    "componentId":"item-card-template",
                    "dataBinding":"/items"
                }
            }
        }
    }
}

5. 卡片模板架构

权重系统:通过weight属性控制各部分宽度比例

Card(卡片容器)
└── Row(水平布局)
    ├── 年份文本 (weight1)
    ├── 人数文本 (weight2)  
    └── 查询按钮 (weight1)

6. 按钮交互功能实现

交互流程:

  • 用户点击"查询详情"按钮
  • 触发query_year_detail动作
  • 携带年份参数(如"2024年")
{
  "action":{
    "name":"query_year_detail",
    "context":[
      {
        "key":"year",
        "value":{"path":"year"}
      }
    ]
}
}

7. 完整渲染流程

  • 初始化阶段:beginRendering创建画布

  • 结构定义:surfaceUpdate定义组件树

  • 数据注入:dataModelUpdate填充内容

  • 交互就绪:状态变为input-required

Lit渲染引擎实现:四步构建安全UI

第1步:定义与注册“可信组件白名单”(Catalog)

这是安全的第一道防线。我们不是渲染任意JSON,而是只渲染Catalog中注册过的组件类型。

// catalog.js - 我们的安全组件库
import { LitElement, html, css } from'lit';
import { repeat } from'lit/directives/repeat.js';

// 1. 基础安全按钮组件
exportclassSafeButtonextendsLitElement {
static properties = {
    label: { typeString },
    variant: { typeString },
    icon: { typeString }
  };

render() {
    return html`
      <buttonclass="btn btn-${this.variant}" @click=${this._handleClick}>
        ${this.icon ? html`<i class="icon-${this.icon}"></i>` : ''}
        ${this.label}
      </button>
    `;
  }

// 点击事件不直接执行,而是派发一个自定义事件
_handleClick() {
    this.dispatchEvent(newCustomEvent('a2ui-action', {
      bubblestrue,
      composedtrue,
      detail: { 
        type'BUTTON_CLICK',
        idthis.id// 组件ID来自A2UI描述
      }
    }));
  }

static styles = css`...`// 封装样式,防止污染
}
customElements.define('safe-button'SafeButton);

// 2. 绑定到数据模型的输入框组件
exportclassSafeTextFieldextendsLitElement {
static properties = {
    label: { typeString },
    value: { typeString },
    fieldPath: { typeString } // 对应A2UI中的 `path`
  };

constructor() {
    super();
    // 从全局状态管理器订阅数据变化
    window.A2UIState.subscribe(this.fieldPath(newVal) => {
      if (this.value !== newVal) {
        this.value = newVal;
      }
    });
  }

_onInput(e) {
    const newValue = e.target.value;
    // 派发事件,通知状态管理器更新数据
    this.dispatchEvent(newCustomEvent('a2ui-model-update', {
      detail: {
        paththis.fieldPath,
        value: newValue
      }
    }));
  }

render() {
    return html`
      <label>${this.label}
        <input
          type="text"
          .value=${this.value || ''}
          @input=${this._onInput}
        >
      </label>
    `;
  }
}
customElements.define('safe-text-field'SafeTextField);

// 3. Catalog 映射表
exportconstA2UI_CATALOG = {
'Button'(descriptor, id) => html`<safe-button.id=${id}.label=${descriptor.label}.variant=${descriptor.variant}></safe-button>`,
'TextField'(descriptor, id) => {
    // 安全属性提取:只允许已知属性
    const safeProps = {};
    const allowedProps = ['label''placeholder''textFieldType''width'];
    allowedProps.forEach(prop => {
      if (descriptor[prop] !== undefined) safeProps[prop] = descriptor[prop];
    });
    // 特别处理数据绑定路径
    if (descriptor.value && descriptor.value.path) {
      safeProps.fieldPath = descriptor.value.path;
    }
    return html`<safe-text-field.id=${id}...=${safeProps}></safe-text-field>`;
  },
'DataTable'(descriptor, id) => { /* 实现复杂表格 */ },
'Header'(descriptor, id) => { /* 实现标题 */ },
// ... 注册更多组件
};

第2步:实现A2UI解析与渲染引擎

这个引擎负责将A2UI JSON转化为Lit模板,并管理组件实例。

// a2ui-renderer.js
import { A2UI_CATALOG } from'./catalog.js';

exportclassA2UIRenderer {
constructor(rootElement) {
    this.root = rootElement;
    this.componentMap = newMap(); // id -> Lit TemplateResult
    this.dataModel = {}; // 维护当前数据状态
    this.subscriptions = newMap(); // 数据路径 -> 回调函数集合
  }

// 处理 surfaceUpdate 消息
applySurfaceUpdate(componentsDescriptorArray) {
    const updates = [];
    
    for (const desc of componentsDescriptorArray) {
      const { id, component } = desc;
      const componentType = Object.keys(component)[0];
      const componentConfig = component[componentType];
      
      if (!A2UI_CATALOG[componentType]) {
        console.warn(`[A2UI Security] 组件类型 "${componentType}" 不在白名单中,已跳过。`);
        continue; // 关键安全策略:忽略未注册组件
      }
      
      // 调用Catalog中的工厂函数,创建该组件的Lit模板
      const template = A2UI_CATALOG[componentType](componentConfig, id);
      this.componentMap.set(id, template);
      updates.push(id);
    }
    
    // 智能重渲染:找出受影响的根节点进行更新
    this._recomputeAndRender(updates);
  }

// 处理 dataModelUpdate 消息
applyDataModelUpdate(path, value) {
    // 使用JSON Patch风格路径,如 '/filters/searchKeyword'
    this._setDataByPath(this.dataModel, path, value);
    
    // 通知所有订阅了此路径的组件更新
    const callbacks = this.subscriptions.get(path) || [];
    callbacks.forEach(cb =>cb(value));
  }

// 辅助函数:根据路径设置对象值
_setDataByPath(obj, path, value) {
    const parts = path.split('/').filter(p => p);
    let current = obj;
    for (let i0; i < parts.length - 1; i++) {
      if (!current[parts[i]]) current[parts[i]] = {};
      current = current[parts[i]];
    }
    current[parts[parts.length - 1]] = value;
  }

// 订阅数据变化(供组件调用)
subscribe(path, callback) {
    if (!this.subscriptions.has(path)) {
      this.subscriptions.set(path, []);
    }
    this.subscriptions.get(path).push(callback);
    // 立即回调当前值
    const currentVal = this._getDataByPath(path);
    callback(currentVal);
  }

// 核心渲染函数(使用Lit的render函数)
_recomputeAndRender(updatedIds) {
    // 1. 找到需要更新的最顶层根ID(避免重复渲染子树)
    const rootsToUpdate = this._findRootComponentsToUpdate(updatedIds);
    
    // 2. 为每个需要更新的根节点重新渲染
    rootsToUpdate.forEach(rootId => {
      const rootTemplate = this.componentMap.get(rootId);
      if (rootTemplate) {
        const container = this.root.querySelector(`[data-a2ui-id="${rootId}"]`) || this._createContainer(rootId);
        // Lit的核心渲染API
        render(rootTemplate, container);
      }
    });
  }

// 根据A2UI的引用关系,计算依赖树,优化渲染范围
_findRootComponentsToUpdate(ids) {
    // 实现依赖分析算法,此处简化返回所有ID
    return ids;
  }
}

第3步:构建状态管理与事件总线

这是连接“静态UI”和“动态交互”的关键。

// state-event-bus.js
exportclassA2UIStateManager {
constructor() {
    this.state = {};
    this.eventHandlers = newMap(); // actionType -> handler function
  }

// 注册安全的动作处理器(由前端开发者控制)
registerActionHandler(actionType, handler) {
    // 关键:只有这里注册的action才能被A2UI事件触发
    this.eventHandlers.set(actionType, handler);
  }

// 处理来自UI组件的事件(如按钮点击、输入框变化)
asynchandleUIEvent(eventDetail) {
    const { action, payload } = eventDetail;
    
    if (!this.eventHandlers.has(action)) {
      console.error(`[A2UI Security] 未注册的动作类型: ${action}`);
      return;
    }

    const handler = this.eventHandlers.get(action);
    
    try {
      // 执行安全注册的处理器
      const result = awaithandler(payload);
      
      // 处理器可以返回新的A2UI指令或数据更新
      if (result && result.type === 'surfaceUpdate') {
        // 触发渲染器更新UI
        window.A2UIRenderer.applySurfaceUpdate(result.components);
      }
    } catch (error) {
      console.error(`[A2UI] 动作执行失败: ${action}`, error);
    }
  }
}

// 全局单例
window.A2UIState = newA2UIStateManager();

// 注册一些示例动作处理器
window.A2UIState.registerActionHandler('navigate'(payload) => {
console.log('导航到:', payload.route);
// 可以集成到你的路由系统,如React Router
});

window.A2UIState.registerActionHandler('updateFilter'(payload) => {
// 1. 更新本地状态
window.A2UIRenderer.applyDataModelUpdate(`/filters/${payload.field}`, payload.value);
// 2. 可选:触发后端API调用,获取新的筛选结果
fetchUsersWithFilters(window.A2UIRenderer.dataModel.filters);
});

第4步:集成与启动

最后,将所有部分连接起来。

<!DOCTYPE html>
<html>
<head>
<scripttype="module"src="./catalog.js"></script>
<scripttype="module"src="./a2ui-renderer.js"></script>
<scripttype="module"src="./state-event-bus.js"></script>
</head>
<body>
<divid="a2ui-root"></div>

<scripttype="module">
    import { A2UI_CATALOG } from'./catalog.js';
    import { A2UIRenderer } from'./a2ui-renderer.js';
    
    // 初始化
    const rootEl = document.getElementById('a2ui-root');
    window.A2UIRenderer = newA2UIRenderer(rootEl);
    
    // 模拟接收来自AI Agent(通过AG-UI)的A2UI消息
    asyncfunctionsimulateAIStream() {
      // 第一批:渲染骨架
      const skeletonUpdate = [...]; // 包含Container, Header, loading状态的JSON
      window.A2UIRenderer.applySurfaceUpdate(skeletonUpdate);
      
      // 模拟网络延迟
      awaitnewPromise(resolve =>setTimeout(resolve, 300));
      
      // 第二批:更新数据(例如用户数)
      window.A2UIRenderer.applyDataModelUpdate('/userCount', 42);
      
      // 第三批:渲染复杂的表格和筛选栏
      const mainContentUpdate = [...]; // 包含DataTable, FilterBar等的JSON
      window.A2UIRenderer.applySurfaceUpdate(mainContentUpdate);
      
      // 第四批:填充表格数据
      const userData = [...];
      window.A2UIRenderer.applyDataModelUpdate('/users/list', userData);
    }
    
    simulateAIStream();
</script>
</body>
</html>

安全措施再加固

除了可信组件白名单机制,还需要多层防护:

  • JSON Schema验证:在解析A2UI JSON之前,先用严格的JSON Schema验证其结构,过滤畸形数据。
  • 属性值净化:对于所有字符串类型的属性值(如label, placeholder),进行HTML实体编码,防止HTML/脚本注入。
  • 资源加载限制:对于image组件的src,限制只允许加载来自可信CDN或数据URI的图片。
  • 事件速率限制:防止恶意脚本通过快速触发onClick事件进行DDoS攻击。
  • CSP(内容安全策略)集成:即使组件被破坏,严格的CSP也能阻止任何脚本执行。

前端开发者的实践路线图

1. 入门(1-2周):

2. 进阶(1-2月):

    • 为你所在团队的设计系统,构建10-20个核心A2UI Catalog组件。
    • 集成到现有React/Vue应用中的一个非核心功能模块进行试点。

3. 精通(3-6月):

    • 实现高性能、支持SSR的A2UI渲染引擎。
    • 设计并实现一套完整的A2UI+AG-UI开发、调试、部署工作流。
    • 主导将某个核心业务流的UI生成迁移到A2UI模式。

4. 专家(长期):

    • 研究如何将A2UI与你的后端LLM编排框架深度集成。
    • 探索在AR/VR、车载系统等新兴平台上的A2UI渲染方案。
    • 为开源A2UI生态贡献核心特性或工具。

结语:从执行者到规则制定者

通过这次深度实战,我们清晰地看到,A2UI并非一个遥远的“黑盒”技术。它是一套清晰、可实现的规范与模式。前端开发者的角色,正从UI细节的“手工执行者”,转变为定义AI如何安全、高效构建UI的“规则制定者”和“系统架构师”。

我们不再仅仅关心“这个按钮的圆角是4px还是6px”,而是思考:

  • “如何设计一个SmartForm组件,能让AI根据数据模型自动生成最合适的表单布局?”
  • “如何建立一套事件处理机制,既能满足复杂交互,又绝无安全漏洞?”
  • “如何让同一份A2UI描述,在手机、桌面、大屏上都呈现最佳体验?”

这既是挑战,更是前所未有的机遇。 我们正在亲手编写人机交互新纪元的底层代码。当你理解了A2UI从JSON到像素的每一个字节的旅程,你便掌握了开启这扇未来之门的钥匙。

现在,代码世界静待你的重新定义。

本系列说明:完整介绍A2UI在AI应用中出现的逻辑和对应场景,它的设计理念、有了A2UI以后对前端工程师的影响,以及它与Vibe Coding的区别、与AG-UI的区别、与开发框架的关系等,最后分享一个真实的案例验证过程。

本文作者:一只大叔

本文原载:公众号“木昆子记录AI”

React 页面加载埋点的正确姿势:useEffect 与 document.readyState 完美配合

React 页面加载埋点的正确姿势:useEffect 与 document.readyState 完美配合

前端埋点必看:告别“假PV”,确保用户真正看到页面再上报

在前端数据埋点体系中,页面 PV 上报是最基础也最关键的一环。我们追求的理想效果是:页面完全加载、用户真正看到完整内容时,只上报一次

但在 React 项目里,一个很容易踩的坑是:组件挂载时机 ≠ 页面完全加载时机。直接在 useEffect 里上报,大概率会出现“页面还没渲染完就埋点”的问题。

今天就从问题根源、原理到最佳实践,一次性讲透 React 页面加载埋点的标准方案。


一、埋点痛点:为什么 useEffect 直接上报不靠谱?

先看一段很多人写过的“错误代码”:

useEffect(() => {
  // ❌ 错误示范:组件挂载就上报
  dataReport('PageView', 'Load', 'HomePage');
  sessionStorage.setItem('REPORTED', 'true');
}, []);

这段代码逻辑上能跑,但埋点时机不准

  • React 执行 useEffect 时,DOM 可能刚构建完
  • 图片、样式、字体等静态资源还在加载
  • 用户看到的是不完整页面,此时上报不符合“真实可见”埋点原则

所以,我们不能只依赖 React 生命周期,必须结合浏览器原生加载状态一起判断。


二、前置知识:document.readyState 三个状态

浏览器提供了 document.readyState 用来精准描述页面加载状态,这是埋点的核心依据:

// 1. loading:HTML 还在解析,页面处于加载中
// 2. interactive:DOM 构建完成,但资源(图片/CSS/字体)可能未加载完
// 3. complete:页面 + 所有资源加载完成 ✅

我们要的就是 complete 状态——这才是用户真正看到完整页面的时刻。


三、正确方案:useEffect + readyState + load 事件

最佳实践思路:

  1. sessionStorage 做幂等,防止重复上报
  2. 先判断当前是否已经加载完成
  3. 已完成:直接上报
  4. 未完成:监听 load 事件,加载完再上报
  5. 组件卸载时清理监听,避免内存泄漏

标准工具化代码(可直接复制使用)

useEffect(() => {
  const hasReported = sessionStorage.getItem('PAGE_LOAD_REPORTED');

  // 已上报过,直接跳过
  if (hasReported) return;

  // 上报逻辑
  const reportPageView = () => {
    dataReport('PageView', 'Load', 'HomePage');
    sessionStorage.setItem('PAGE_LOAD_REPORTED', 'true');
  };

  // 情况1:页面已加载完成,立即上报
  if (document.readyState === 'complete') {
    reportPageView();
  } 
  // 情况2:页面还在加载,监听 load 事件
  else {
    window.addEventListener('load', reportPageView);
    // 清理监听
    return () => window.removeEventListener('load', reportPageView);
  }
}, []);

四、两种加载场景详解

场景1:页面加载极快

0ms:   页面开始加载
50ms:  HTML 解析完成
80ms:  所有资源加载完成(readyState = 'complete')
100ms: React 渲染,useEffect 执行
       ↓
       检测到页面已完成加载
       ↓
       直接上报 ✓

场景2:页面加载较慢(图片多/网络差)

0ms:   页面开始加载
50ms:  HTML 解析完成
80ms:  React 渲染,useEffect 执行(readyState = 'interactive')
       ↓
       未加载完成,注册 load 监听
500ms: 所有资源加载完成(readyState = 'complete')
       ↓
       load 事件触发,执行上报 ✓

这套逻辑能同时兼容快慢页面,保证时机绝对准确。


五、业务实战示例(带业务状态)

在真实项目中,埋点通常需要带上业务参数(如来源、当前页面信息、用户状态),这里给一个生产可用版本:

const HomePage = () => {
  const { isInZone, runningGame } = useGlobalState();

  useEffect(() => {
    const hasReported = sessionStorage.getItem('PAGE_LOAD_REPORTED');
    if (hasReported) return;

    const reportPageView = () => {
      dataReport('LZ_aiAgent', 'LZ_aiagentWindowShow', 'pv', {
        detail: JSON.stringify({
          source: isInZone ? 'app' : 'game',
          gameName: runningGame?.gameName || '',
          timestamp: Date.now()
        })
      });
      sessionStorage.setItem('PAGE_LOAD_REPORTED', 'true');
    };

    if (document.readyState === 'complete') {
      reportPageView();
    } else {
      window.addEventListener('load', reportPageView);
      return () => window.removeEventListener('load', reportPageView);
    }
  }, [isInZone, runningGame]);

  return <div>页面内容</div>;
};

六、高频 QA 避坑指南

Q1:为什么用 sessionStorage,不用 localStorage?

  • sessionStorage:关闭标签页自动清空,刷新页面可重新上报,符合 PV 统计逻辑
  • localStorage:永久存储,会导致二次进入页面不上报,不符合业务需求

Q2:会不会出现重复上报?

不会

  • if-else 互斥逻辑,只会走一条分支
  • sessionStorage 幂等标记,上报后直接拦截

Q3:为什么不直接用 window.onload?

window.onload 会被其他代码覆盖,事件覆盖会导致埋点丢失。 使用 addEventListener 是更安全、更工程化的方案。

Q4:Next.js 客户端组件要注意什么?

Next.js 服务端渲染时不存在 window 对象,必须确保:

  • 组件顶部加 'use client'
  • 所有浏览器相关逻辑(window/document)都写在 useEffect 内部

七、总结

React 页面加载埋点的正确四件套

  1. document.readyState 判断真实加载状态
  2. 结合 window.load 事件兼容慢加载场景
  3. sessionStorage 做幂等,防止重复上报
  4. 组件卸载时清理事件监听,避免内存泄漏

按照这套写法,无论普通 React 项目还是 Next.js 项目,都能做到:时机准、不重复、兼容强、无隐患

你在项目里遇到过哪些埋点坑?欢迎在评论区交流~


标签

React 前端埋点 数据上报 useEffect document.readyState 前端性能 Next.js


AI 时代掌握 Markdown,是最基础也最必要的技能 (小红书长文也可以用哦)

AI 时代一来,最常用、最通用、最省心的技能,是 Markdown

就那套:

# 是标题

- 是列表

三个反引号是代码块……

以前我把它当程序员小玩具,写 README 用的。

现在我写提示词写知识库写会议纪要写项目计划、甚至写小红书草稿都在用它

很多大模型默认吐出来的也是 Markdown

你不学也会天天用;你学了,就能把它用得更顺、更像人机协作的公共语言

这篇文章就把 AI 时代的 Markdown 技能讲清楚:

  • 为啥说 Markdown 是 AI 时代基础必修
  • 新手怎么用 30 分钟上手
  • 我踩过的坑
  • 直接给你几套能复制走就用的模板

AI 时代大家都在写『文档』

以前我们写作给人看的。

现在很多文字要同时两类读者看:

  • 人:扫一眼懂不懂、读起来顺不顺
  • AI:能不能切分、能不能检索、能不能执行、会不会误解

Markdown 的优势特别像夹在中间的翻译官

  • 对人:比 Word 清爽,写起来也不费手
  • 对模型:结构清晰、层级明确、token 还省(真的省)

一个很现实的事:会 Markdown,不代表你是技术人;但不会 Markdown,你在 AI 时代做事会莫名卡住

比如:

  • 你发提示词给模型,一大段纯文本没结构,模型抓不到重点
  • 你做个人知识库,内容堆在一起,RAG 切分一塌糊涂
  • 你写项目计划,别人看不懂,Agent 更执行不了

Markdown 其实就是把我要说的话变成可计算的文字

为什么它是新手最容易掌握的技能

你想学一个新技能,最怕两件事:

你掌握 10% 的语法,就能覆盖 90% 场景;你今天学,今天就能用在:提示词、笔记、文档、发文、写代码说明。

而且它是纯文本。

这意味着,不依赖软件、不依赖平台、不怕格式丢失、还能被 Git 管起来。

你换工具、换平台、换设备,Markdown 都能带走。

这在 AI 时代太重要了,因为你的内容会被喂给各种模型、各种工具链。

30 分钟上手法

我自己教朋友(完全小白那种)就用这四个:

1)标题:# 真的就够了

这是大标题

这是小标题

这是更小的标题

最常见的坑:# 后面要空一格。

写成 #标题 有的平台不认,真的会气到。

2)列表:用 -1.,全篇统一就行

无序列表

  • 这是一条

  • 这也是一条

  • 这也是一条

有序列表

  1. 这是一条

  2. 这也是一条

  3. 这也是一条

列表上下不空行,有的平台会在一起。你就当空行是免费的,随便用。

3)链接

这是链接文字

你以后写提示词、写文档、写小红书参考来源,这个非常好用。

4)代码块


npm i

新手最常见的痛苦:代码复制出来一坨。加上代码块,世界瞬间清净。

踩过的坑

坑 1:换行不是你以为的换行

Markdown 里回车一下不一定等于换行

很多渲染器会把同一段落里的换行当成空格。

解决办法就两个:

  • 真分段:中间空一行(部分渲染引擎支持)
  • 想强制换行:上一行行尾加 3 个空格再接回车一下即可,或者用 <br>

坑 2:你从 Markdown 复制到某些平台,会出现一堆 ###

比如你发到某些富文本编辑器(包括部分社媒的长文功能),它不完整支持 Markdown,就会把符号原样贴上去。

我的小技巧:

  • 在 VS Code 里(或者其他 Markdown 预览器)先打开预览(Mac:Cmd + Shift + V
  • 在预览里复制,再粘贴到平台

这个技巧我自己用来发公众号也挺稳。

坑 3:文件名有空格/中文,后面做知识库会很难受

我以前喜欢起名:“今天学 Markdown 好开心.md”

后来要做链接引用、做同步、做脚本处理,全变成麻烦。

解决办法:

  • kebab-caseai-markdown-guide.md
  • 或者加日期:2026-02-14-notes.md

中文当然也能用,只是后续工具链会更容易出小毛病(尤其跨平台)。

它不只是排版,它是结构化提示词的容器

我以前写提示词是:一大段话丢给模型,靠缘分。

后来我发现:你用 Markdown 把提示词分区,模型执行力会明显变好。

你可以直接复制这个模板:

你的角色

你是一个严谨但很会讲人话的编辑。

背景

我要写一篇小红书长文,主题是:掌握 Markdown 是 AI 时代的基础技能。

读者是完全新手。

输出要求

  • 口语化

  • 有真实使用场景

  • 不要“首先/其次/总结一下”那种模板腔

  • 给出可复制的 Markdown 示例

我已经有的素材

  • 我经常用 VS Code 写 Markdown

  • 我会把笔记喂给 AI 做总结

你需要交付

成稿 + 配图提示词

这玩意儿本质上就是,你用标题告诉模型这块是什么,减少它乱猜。

做个人知识库、做 RAG、做第二大脑

我以前记笔记是什么都往一个长文档里堆,然后想找东西就靠搜索,搜不到就崩溃。

后来我学会一个很简单的思路:

把一条知识写成一个小条目,用标题+短段落+列表。

你甚至可以给每条笔记加一个 YAML 头(有些工具会识别):


title: Markdown 换行规则

tags: [markdown, writing, ai]

created: 2026-02-14


结论

段落之间要空一行。需要强制换行就用行尾两个空格或 <br>

我踩过的坑

  • 直接回车在某些渲染器里不会换行

示例

第一行

第二行

这种结构对你也友好,对 AI 也友好,AI 检索的时候能更容易切到对的块,总结也更不容易跑偏

进阶一点点:表格、任务清单、引用

这几个我个人用得特别多,但我只在平台支持的时候用(比如 GitHub、Notion、一些博客系统)。

任务清单

  • 学会标题

  • 学会列表

  • 学会代码块

引用

这是一段引用。

我用它来放原文/结论/别人的观点。

表格

| 场景 | 用 Markdown 的原因 |

| --- | --- |

| 写提示词 | 结构清晰,模型更听话 |

| 写知识库 | 易切分,易检索 |

| 写文档 | 跨平台,不怕格式丢 |

写作这事,在 AI 时代更像你的生活感 + 结构能力的组合。

Markdown 负责结构,你负责生活感。

TinyEngine 2.10 版本发布:零代码 CRUD、云端协作,开发效率再升级!

本文由体验技术团队Hexqi原创。

前言

TinyEngine 是一款面向未来的低代码引擎底座,致力于为开发者提供高度可定制的技术基础设施——不仅支持可视化页面搭建等核心能力,更可通过 CLI 工程化方式实现深度二次开发,帮助团队快速构建专属的低代码平台。

无论是资源编排、服务端渲染、模型驱动应用,还是移动端、大屏端、复杂页面编排场景,TinyEngine 都能灵活适配,成为你构建低代码体系的坚实基石。

最近我们正式发布 TinyEngine v2.10 版本,带来多项功能升级与体验优化:模型驱动、登录鉴权、应用中心等全新特性,同时还有Schema面板与画布节点同步、出码源码即时预览、支持添加自定义 MCP 服务器等功能进行了增强,以让开发协作、页面搭建变得更简单、更高效。

版本特性总览

核心特性

  • 模型驱动:零代码创建 CRUD
  • 多租户与登录鉴权能力
  • 新增应用中心与模板中心

功能增强

  • 出码支持即时查看代码
  • 自定义 MCP 服务器,扩展 AI 助手能力
  • 画布与 Schema 面板支持同步滚动
  • 页面 Schema CSS 字段格式优化
  • 图表物料更新,组件属性配置平铺优化
  • 多项细节优化与 Bug 修复

体验升级

  • 新官网:UI 全面焕新
  • 新文档:域名迁移与样式升级
  • 新演练场:真实前后端,完整功能体验

新特性详解

1. 【核心特性】模型驱动:零代码极速创建CRUD页面(体验版本)

背景与问题

在传统的后台管理系统开发中,创建一个包含表单、表格和完整 CRUD(增删改查) 功能的页面往往需要开发者:

  • 重复配置相似的表单和表格组件
  • 手动绑定数据源、编写事件处理逻辑
  • 数据模型变更时,同步修改多个组件配置

这种重复性劳动不仅耗时,还容易出错。

核心功能

模型驱动特性通过声明式的数据模型配置,自动生成对应的 UI 组件和数据交互逻辑,实现真正的"零代码"生成数据管理页面。

核心模块

模块 功能
模型管理器插件 可视化创建数据模型、配置字段和 API,管理模型
内置模型组件 表单、表格、组合表单+表格,基于模型自动生成表单、表格,或组合生成完整 CRUD 页面
模型绑定配置器组件 为模型生成 UI、绑定 CRUD 逻辑

支持的模型字段类型:String(字符串)、Number(数字)、Boolean(布尔)、Date(日期)、Enum(枚举)、ModelRef(关联模型)

1.png

价值亮点

  • 开发效率大幅提升:通过配置模型即可生成完整的 CRUD 页面,无需手动配置每个组件
  • 后端自动生成:使用默认接口路径时,自动生成数据库表结构和 CRUD 接口
  • UI 与接口自动绑定:拖拽组件选择模型后,UI 自动生成,接口自动绑定,一站式完成前后端搭建
  • 支持嵌套模型:字段可关联其他模型,实现复杂数据结构(如用户关联部门)(后续实现)
  • 标准化输出:基于统一模型生成的 UI 组件保证了一致性
  • 灵活扩展:可自定义字段类型和组件映射

使用场景

  • 后台管理系统的数据管理页面
  • 需要频繁进行增删改查操作的业务场景
  • 需要快速原型的项目

快速上手

1. 创建数据模型

打开模型管理器插件,创建数据模型(如"用户信息"):

  • 配置模型基本信息:中文名称、英文名称、描述
  • 添加模型字段(如姓名、年龄、邮箱等)
  • 配置字段类型、默认值、是否必填等属性

2. 配置接口路径(可选)

创建模型时,可以选择:

  • 使用默认路径:系统自动使用后端模型接口作为基础路径,并在后端自动生成对应的 SQL 表结构和 CRUD 接口
  • 自定义路径:指定自己的接口基础路径,对接已有后端服务

3. 拖拽模型组件到页面

在物料面板中选择模型组件拖拽到画布:

  • 表格模型:生成数据列表
  • 表单模型:生成数据录入表单
  • 页面模型:生成包含搜索、表格、编辑弹窗的完整 CRUD 页面

4. 绑定模型,自动生成

选中组件后,在右侧属性面板:
1) 点击"绑定模型数据",选择刚才创建的模型
2) 系统自动生成 UI 界面
3) 系统自动绑定 CRUD 接口
4) 一站式完成前后端搭建

5. 预览页面

预览即可看到包含搜索、新增、编辑、删除、分页功能的完整数据管理页面。

2.gif

核心流程图

graph LR
    A[创建数据模型] --> B{选择接口路径}
    B -->|默认路径| C[后端自动生成<br/>SQL表结构+CRUD接口]
    B -->|自定义路径| D[对接已有后端]
    C --> E[拖拽模型组件到页面]
    D --> E
    E --> F[绑定模型]
    F --> G[系统自动生成UI]
    F --> H[系统自动绑定接口]
    G --> I[预览完整CRUD页面]
    H --> I

    style A fill:#e1f5fe
    style C fill:#fff3e0
    style G fill:#f3e5f5
    style H fill:#f3e5f5
    style I fill:#e8f5e9

用户只需关注

定义好数据模型,前后端自动生成:

  • ✅ 无需手动编写表单 HTML
  • ✅ 无需手动编写表格渲染逻辑
  • ✅ 无需手动编写 API 调用代码
  • ✅ 无需手动编写数据验证规则
  • ✅ 无需手动编写分页排序逻辑

让用户专注于业务逻辑和模型设计,而非重复的 UI 代码编写。

2. 【核心特性】多租户与登录鉴权能力

功能概述

TinyEngine v2.10 引入了完整的用户认证系统,支持用户登录、注册、密码找回,并结合多租户体系,让您的设计作品可以实现云端保存、多设备同步和团队协作。

登录注册

  • 用户登录:基于用户名/密码的身份认证,Token 自动管理
  • 用户注册:支持新用户注册,注册成功后提供账户恢复码用于找回密码
  • 密码找回:通过账户恢复码重置密码,无需邮件验证

3.png

组织管理

  • 多组织支持:用户可属于多个组织,灵活切换不同工作空间
  • 组织切换:动态切换组织上下文,组织间数据隔离
  • 创建组织:一键创建新组织,邀请团队成员加入

4.png

登录鉴权流程

系统采用 Token 认证机制,通过 HTTP 拦截器实现统一的请求处理和权限验证:

sequenceDiagram
    participant 用户
    participant 前端应用
    participant HTTP拦截器
    participant 后端API

    用户->>前端应用: 1. 输入用户名/密码登录
    前端应用->>后端API: 2. POST /platform-center/api/user/login
    后端API-->>前端应用: 3. 返回 Token
    前端应用->>前端应用: 4. 保存 Token 到 localStorage

    Note over 前端应用,后端API: 后续请求自动携带 Token

    前端应用->>HTTP拦截器: 5. 发起业务请求
    HTTP拦截器->>HTTP拦截器: 6. 检查 Token 并注入 Authorization 头
    HTTP拦截器->>后端API: 7. 携带 Token 的请求
    后端API-->>HTTP拦截器: 8. 返回数据 或 认证失败(401)

    alt 认证失败
        HTTP拦截器->>前端应用: 9. 清除 Token,显示登录弹窗
        前端应用->>用户: 10. 提示重新登录
    end

访问入口

1)登录界面:访问 TinyEngine 时系统会自动弹出登录窗口,未登录用户需完成登录或注册。

2)组织切换:登录后可通过以下方式切换组织:

  • 点击顶部工具栏的用户头像,选择「切换组织」
  • 在用户菜单中直接选择目标组织

3)退出/重新登录:已登录用户可以点击右上角头像在菜单点击"退出登录",进入登录页面重新登录

使用场景

1)个人使用:登录后即可享受云端保存、多设备同步等功能,设计作品永不丢失。

2)团队协作

  • 创建组织:为团队或项目创建独立组织空间
  • 数据隔离:不同组织的资源完全隔离,清晰区分个人与团队项目

💡 提示:新注册用户默认属于 public 公共组织,所有数据公共可见,您也可以创建自定义组织隔离数据。

开发者指南

1)环境配置

  • 开发环境:通过 pnpm dev:withAuth 命令启用登录认证,pnpm dev 默认不启用(mock server)
  • 生产环境:自动启用完整登录认证系统

也可以修改配置文件来启动或关闭登录鉴权:

export default {
  // enableLogin: true // 打开或关闭登录认证
}

2)多租户机制

  • 用户可属于多个组织,通过 URL 参数标识当前组织上下文
  • 组织间数据完全隔离,切换组织可查看不同资源
  • 当 URL 未携带应用 ID 或组织 ID 时,系统自动跳转到应用中心

3. 【核心特性】应用中心与模板中心

应用中心和模板中心是此次版本新增的两大核心功能模块。通过应用中心可以集中管理您创建的所有低代码应用,为不同的场景创建不同的应用;模板中心则让优秀页面设计得以沉淀为可复用资产,团队成员可以基于模板快速搭建新页面,大幅提升协作效率。

应用中心

登录后进入应用中心,集中管理您创建的所有低代码应用。

功能亮点

  • 统一管理:在一个界面查看、创建、打开所有应用
  • 快速切换:无需手动输入 URL,一键进入任意应用编辑器
  • 组织隔离:不同组织的应用数据隔离,清晰区分个人与团队项目

5.png

模板中心

模板中心让页面设计资产得以沉淀和复用,提升团队协作效率。

核心价值

  • 设计复用:保存优秀页面设计为模板,避免重复造轮子
  • 快速启动:基于模板创建新页面,继承已有布局和样式
  • 团队共享:组织内共享设计资产,统一 UI 风格和设计规范

6.png

7.png

访问入口

在编辑器中点击左上角菜单按钮,悬停即可看到应用中心模板中心入口,点击即可前往。

使用说明

自动跳转规则

  • 如果访问编辑器时未携带应用 ID 或组织 ID 参数,系统会自动跳转到应用中心
  • 您可以在应用中心创建新应用,或打开已有应用进入编辑器

组织权限说明

  • public 组织:默认公共组织,所有用户的应用对所有人可见
  • 自定义组织:用户新建的组织默认仅创建者可见,需手动邀请成员加入
  • 切换组织可以查看不同组织下的应用和资源

特性开关

如果不需要使用应用中心与模板中心,可以在注册表中进行关闭:

// registry.js
export default {
  [META_APP.AppCenter]: false, // 关闭应用中心
  [META_APP.TemplateCenter]: false // 关闭模板中心
  // ...
}

4. 【增强】出码即时预览 - 导出前预览所见即所得

出码功能新增源码预览能力,用户在导出代码前可以实时查看生成的源码内容,提升代码导出体验和准确性。

功能特性

  • 左右分栏布局:左侧树形文件列表,右侧 Monaco 代码编辑器预览
  • 智能初始化:打开对话框时自动显示当前编辑页面对应的文件代码
  • 实时预览:点击树形列表中的任意文件,即可在右侧预览其代码内容
  • 灵活选择:支持勾选需要导出的文件

使用方法

1) 在编辑器中点击「出码」按钮
2) 打开的弹窗中左侧树形列表显示所有可生成的文件,当前页面对应文件自动展示在右侧
3) 点击任意文件预览源码,勾选需要导出的文件
4) 点击「确定」选择保存目录完成导出

8.png

5. 【增强】自定义 MCP 服务器 - 扩展 AI 助手能力

之前版本中,TinyEngine已经提供内置MCP 服务,可以通过MCP工具让AI调用平台提供的各种能力。 本次特性是在TinyEngine 中支持添加自定义 MCP (Model Context Protocol) 服务器,可以通过配置轻松集成第三方 MCP 服务,扩展 AI 开发助手的工具能力。

功能特性

  • 灵活配置:通过注册表简单的配置即可添加自定义服务器
  • 协议支持:支持 SSE 和 StreamableHttp 两种传输协议
  • 服务管理:在 AI 插件的配置界面即可管理 MCP 服务器的开关状态
  • 工具控制:可查看并切换各个工具的启用状态

使用步骤

1) 准备您的 MCP 服务器(需符合 MCP 协议规范

2) 在项目的 registry.js 中添加配置

// 使用示例
// registry.js
export default {
  [META_APP.Robot]: {
    options: {
      mcpConfig: {
        mcpServers: {
          'my-custom-server': {
            type: 'SSE',              // 支持 'SSE' 或 'StreamableHttp'
            url: 'https://your-server.com/sse',
            name: '我的自定义服务器',
            description: '提供xxx功能的工具',
            icon: 'https://your-icon.png'  // 可选
          }
        }
      }
    }
  }
}

3) 刷新编辑器,在 AI 插件 MCP 管理面板中即可看到新添加的服务器

9.png

4) 启用服务器,选择需要的工具,即可在 AI 助手中开始使用!

场景示例

您可以集成企业内部 MCP 服务、社区 MCP 服务、第三方 MCP 工具等,扩展 AI 助手的业务能力。

例如,下面是一个添加图片搜索MCP服务后使用AI生成带图片页面的场景示例:

10.gif

6. 【增强】画布与 Schema 面板支持同步滚动

Schema 面板新增"跟随画布"功能,启用后当在画布中选中组件时,Schema 面板会自动滚动到选中组件的对应位置并高亮显示。

使用场景

  • 快速定位:当页面元素较多时,能快速找到对应组件的 Schema 配置
  • 双向对照:可视化视图与 JSON 代码视图对照,便于理解页面结构

使用方法

打开 Schema 面板,勾选面板标题栏的"跟随画布"复选框启用。在画布中点击切换元素,即可看到 Schema 面板跟随变化。

效果如下:

11.gif

7. 【优化】页面 Schema CSS 字段格式优化

页面 Schema 中的 CSS 样式字段由字符串格式优化为对象格式,提升样式配置的可读性和可维护性。系统会自动处理对象与字符串的相互转换,出码时自动转换为标准 CSS 字符串格式,同时完美兼容之前的字符串格式。

优化场景

  • AI场景更友好:AI生成代码及修改样式场景,能够更快速地进行增量生成及修改
  • 编辑更直观:对象格式支持属性智能提示和语法高亮,编辑体验更佳
  • 阅读更清晰:结构化的对象格式易于查看和修改样式属性
  • 维护更便捷:新增或修改样式规则时,无需手动拼接 CSS 字符串

格式对比

之前(字符串格式)

"css": ".page-base-style { padding: 24px; background: #FFFFFF; } .block-base-style { margin: 16px; } .component-base-style { margin: 8px; }"

现在(对象格式)

"css": {
  ".page-base-style": {
    "padding": "24px",
    "background": "#FFFFFF"
  },
  ".block-base-style": {
    "margin": "16px"
  },
  ".component-base-style": {
    "margin": "8px"
  }
}

兼容性说明

  • 两种格式完全兼容,可在同一项目中混用
  • 系统自动识别格式类型并进行转换
  • 出码时统一转换为标准 CSS 字符串格式
  • 页面样式设置等场景使用都与之前保持一致,不受该特性影响

8. 【增强】图表物料更新,组件属性优化

图表物料进行了如下优化:

  • 添加三种常用图表组件物料:仪表盘、拓扑图、进度图
  • 图表组件的配置面板优化,将原有的图标配置属性由整体 options 配置拆分为独立的属性配置项(颜色、数据、坐标轴等),使配置更加清晰直观。

12.png

9. 【新体验】新演练场 - 完整的前后端体验

演练场进行了全面升级,从原来的前端 Mock 数据改为完整的前后端部署,带来真实的体验环境。

升级亮点

  • 完整的前后端部署:不再是拦截接口 Mock 数据,而是真实的服务端环境
  • 支持用户登录:可以使用真实账户登录演练场
  • 数据隔离:用户数据基于租户进行共享或隔离,更符合实际使用场景
  • 功能完整体验:之前无法体验的功能现在都可以正常使用,如AI助手插件自然语言生成页面

新演练场地址playground.opentiny.design/tiny-engine…

13.png

通过下面两个入口都可以访问:

如您希望继续使用旧版演练场,依旧可以通过下面地址继续访问: 旧版演练场:opentiny.design/tiny-engine…

10. 【新体验】新官网 - UI 全面焕新

TinyEngine 官网首页 UI 全面焕新,带来更现代、更清爽的视觉体验。

  • 全新设计:首页内容刷新,并采用现代化的设计语言,视觉更加清爽美观
  • 响应式布局:完美适配各种屏幕尺寸,移动端访问更友好

访问新版官网:opentiny.design/tiny-engine

14.png

11.【新体验】新文档 - 全新文档体验

TinyEngine 文档与其他OpenTiny产品文档统一迁移至新docs子域名:

新域名docs.opentiny.design/tiny-engine…

文档变化:

  • 整体更统一,方便查找切换其他文档
  • 同时也进行了全面的样式优化,阅读体验更佳

15.png

12. 【其他】功能细节优化&bug修复

结语

回首这一年,TinyEngine 在开源社区的成长离不开每一位开发者和贡献者的支持。v2.10 版本作为春节前的最后一次发布,我们为大家带来了多项重磅特性:

特性 核心价值
模型驱动 零代码 CRUD,开发效率跃升
多租户与登录鉴权 云端协作、团队协作
应用中心与模板中心 应用管理、资产沉淀
出码预览 导出前预览,提升代码导出体验
自定义 MCP 扩展 AI 能力,集成企业服务
Schema 面板同步滚动 画布与代码视图联动
CSS 字段格式优化 对象格式,可读性更强
图表物料更新 配置平铺,更直观
新演练场 真实前后端,完整体验
新官网/文档 UI 焕新,体验升级

致谢

本次版本的开发和问题修复诚挚感谢各位贡献者的积极参与!同时邀请大家加入开源社区的建设,让 TinyEngine 在新的一年里成长得更加优秀和茁壮!

新春祝福

值此新春佳节即将到来之际,TinyEngine 团队衷心祝愿大家:

🧧 新年快乐,万事如意! 🧧

愿新的一年里:

  • 代码如诗行云流水
  • 项目如期顺利上线
  • Bug 远离,需求清晰
  • 团队协作高效顺畅
  • 事业蒸蒸日上,生活幸福美满!

🎊 春节快乐,阖家幸福! 🎊

让我们在春节后带着满满的热情和能量,继续在未来道路上探索前行!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyEngine源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyPro、TinyNG、TinyCLI、TinyEditor
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

❌