阅读视图

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

广州市医疗保障局发布关于征集定点零售药店药品“阴阳价格”等违规问题线索的公告

广州市医疗保障局发布关于征集定点零售药店药品“阴阳价格”等违规问题线索的公告,公告指出,定点零售药店对参保人和非参保人实行“阴阳价格”(即同一药品对参保人销售价格高于对非参保人销售价格)属于违法违规使用医保基金的行为,医保部门鼓励广大群众积极提供有关问题线索,参与定点零售药店“阴阳价格”治理。

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. 闭包与高阶函数 来管理状态和副作用。

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

前字节高管创业教育类出海项目,用Agent做“终身学习搭子”,红杉投了

文|富充

编辑|苏建勋

2024年11月,李可佳、吴俊东、张栖铭决定一起创业,做一款“帮助用户终身学习”的AI Agent。新公司被命名为Ouraca ,是“Our Academy”的缩写。

三位合伙人都是互联网教育领域的“老兵”:

创始人兼CEO李可佳,曾任极课大数据创始人,该项目被字节跳动收购后,他入职字节智慧教育业务担任CEO;

联创吴俊东,哈佛肯尼迪学院硕士,好未来教育投资人,曾为李可佳极课大数据的投资人;

联创张栖铭,曾任字节教育中台负责人,推出过数个日活千万、月活过亿的产品。

Ouraca 公司正式创建于2025年3月,产品还未发布,就已经获得700万美元种子轮融资。股东名单中包括红杉中国、初心资本、Etna Capital等VC机构。

有趣的是,上述投资方在决定出手Ouraca时的决策速度,都非常快。李可佳回忆起,决定创业的当晚,将想法说给初心资本创始人田江川,几分钟后就收到田江川回复“那我投你”;红杉中国的投资速度也近乎如此。

“Etna Capita的投资决策几乎是在24小时内完成的,”投资人Haina Xu回忆当时的情况:“ ChatGPT的使用场景里,教育类别长期排前五,可以说教育是大语言模型带来最大改变的行业之一。人们期待全能AI Tutor很多年了,Ouraca恰好是我们看到的第一个在探索‘AI时代终身学习产品’的团队。”

2025年9月,Ouraca上线第一款应用“Aibrary”,它用名为Idea Twin的独特“AI生成播客”功能引导和陪伴用户学习。目前,Aibrary和Ouraca旗下的新产品仍在持续快速迭代。

用AI做会提问的“学习搭子”

李可佳对AI教育产品,有一套自己的判断:因为大模型可以快速生产大量内容,所以痛点并不是内容不够多,而是如何通过帮助用户向AI提出好的问题,收获高质量的答案。

在Aibrary,用户可以主动向Aibrary提问自己想要学习的话题。如果用户不知道该问什么,Aibrary会每天向用户推送3个与生活相关的话题,比如最近的一个,是关于“积少成多的小习惯“。

每个话题里,Aibrary嵌入了自己的见解,并且推荐了值得一读的阅读清单,也向人们提供书籍讲解。

但Aibrary并非又一款“X分钟给你讲本书”的APP,而是把知识变成用户感兴趣的对话,再用播客的形式说出来。

具体而言,播客分为“主持人”和“嘉宾”两个角色。用户可以选择苏格拉底、爱因斯坦等带有不同发问逻辑的主持人;而回答的“嘉宾”则是用户自己的“数字分身”(Idea Twin),这是Agent在学习用户的身份和资料之后,形成的“嘴替”一般的存在。

吴俊东举了一个最近的例子:“前段时间,关于‘人工智能是否会取代大量岗位’的讨论很热。我有一位做青年领导力的朋友向Aibrary提问该如何看待此事,Aibrary向这位朋友推荐了《未来简史》,并生成了一期播客。”

“播客中的‘嘉宾’是Agent‘扮演’的这位朋友,博客中不仅讨论了技术趋势,还结合他当前的创业方向,分析哪些能力会被替代、哪些能力会放大。最终,‘嘉宾’给出的不是抽象判断,而是基于他的背景形成的应对策略。这本书其实很多人都看过,但是Aibrary会针对每个不同人,以及同一个人的不同心境都有完全不一样的解读。”吴俊东说。

Idea Twin的灵感来自NotebookLM,那个能把论文、财报变成双人对话播客的产品。

2024年,李可佳在体验过NotebookLM后就意识到,AI产品的胜负手不仅在于“模型更强”,而在“交互形式”——声音这种模态,会让知识交付出现新的可用性。

但李可佳觉得,NotebookLM的体验还差临门一脚:它像一个万能工具,只是用户得自己找材料、自己提问、自己规划路径。而很多数人并不知道要问什么。

Aibrary的播客,正是填补了这个Gap:先替用户进行高质量提问,再把知识答出来。

△Aibrary提供的功能包括书籍推荐、书籍讲解生成、Idea Twin播客等,图片:采访人提供

找准“持续学习”用户,解决“大量小问题”

关于如何做好用户体验、提升用增和留存,李可佳总结出Aibrary的Know-how:做好大量小问题。

最基础的一步,是要输出值得信赖的内容:书用哪个版本做数据源更可靠、网络上围绕一本书的海量观点怎么清洗……这意味着大量数据收集、标注类的工程化任务要做细致。

此外,要让用户觉得Agent“懂自己”,这来自AI对用户信息和用户行为的学习。

Aibrary使用者在注册时,会填入描述年龄、职业、目标、困惑。这是AI了解用户的基础,此后会随着交互和对话变多,学习越来越多的用户信息。

但一款好产品的基础,离不开对目标用户需求的精准把控。

李可佳和吴俊东都认为,前AI时代的教育产品,还是在追求“更少老师教更多学生”的极致效率。“但AI越强,那些能被规范化培养的技能就过期越快,比如律师助理、基础编程、客服。”李可佳说。

因此,新一代教育产品要挖掘有持续学习目标的用户,并非为了考试和培训而学习的使用者。

为了找准用户人群,Aibrary成立之前就做了大量调研。主创团队先在身边或LinkedIn等求职、社交平台发放问卷,整理用户需求,并且逐步确立三类核心人群:Busy Professionals(繁忙的职场中坚力量)、刚毕业或还在读书的年轻人、以及宝爸宝妈——后者往往脱离职场,但又不想被时代甩下,因此使用碎片时间学习。

Aibrary团队还会一对一大量访谈早期用户。

这些用户中既有得过癌症的全职妈妈,希望在这里找到解决日常生活瓶颈的方法;也有管理着40个员工的仓储公司老板,想要学习怎么和女朋友相处;甚至是在酒店工作、想转行做医药的移民,这位用户还因为自己英语不太好,拼写总是出错而道歉。

这也让Aibrary团队更加确定:“持续学习”在北美并不是一项“精英意愿”;在这里,更普遍的人群希望获得“持续成长的确定感”。这也是团队把首站市场设置为美国的原因。

李可佳和吴俊东很认可Insta360创始人刘靖康的思路:先从与自己相似的一小群人出发,抓住一个足够具体的需求把它做到极致,产品哪怕起步小众,也有可能在扎稳核心人群后逐步外扩。

选择书籍作为切口,核心原因是主创团队本身都很喜欢阅读。两位成长经历几乎全然不同的创始人,都认可书籍是普世的智慧来源。

吴俊东从江西小城长大,学生时代最主要的“扩展视野”方式就是读书;李可佳家里有一个巨大的书柜,也从小酷爱阅读。

二人回忆起,刚刚在硅谷创业起步时,日常娱乐并不多,晚上散步常常不约而同走进街角的小书店,随手翻几页。

对他们来说,在容易过时的技能和不断追逐最新前沿的信息之间,书中的科学、哲学、艺术、美等等知识可以穿越周期,让人的内心变得丰盈。

为了触达目标客户,Aibrary团队的市场开拓同时分为“空军”和“陆军”。

空军是LinkedIn、YouTube,Aibrary把创业故事做成播客发布在这些平台,坦诚地讨论踩过的坑,和用户形成共鸣;陆军是美国本土读书会、高校的落地合作。

“我在字节和互联网创业时,积累了不少中国C端产品的推广经验,这些放到美国依然是有效的。”李可佳说,“直播、社群、投流等等打法,中国团队上手更快。”

△团队在硅谷创业初期经常去的Mountain View老牌书店Books Inc.,图片:采访人提供

持续学习新范式:从人到Agent

有趣之处在于,Ouraca终身学习的用户,不一定是人类,也可能是Agent。Ouraca旗下新产品BotLearn的出现,就是这条路的进一步探索。

Clawdbot(现已更名为“OpenClaw”)爆火之后,Ouraca团队快速推出了BotLearn——一个“Agent的持续学习社群”,团队称之为“Bot大学”

用户可以通过将代码复制给自己OpenClaw的Agent,把Agent“送到”BotLearn学习。

△BotLearn页面,图片:网页截图

在这里,Agent间会互相分享。比如某个Agent学会了高效抓取某垂类信息的方法,它可以把这套方法发布到社区,其他Agent在这里学习、复用、再补充心得。

项目上线一天内就吸引了近500个Agents“入学”。登录BotLearn,还可以查看Agent们发布的“学习心得”。

尽管产品形态仍在早期,Etna投资人Penny Deng却认为这是一枚对的种子:

“Agent在2026年会大爆发,产生很多我们想象不到的变化。以前以人为主体设计的产品,可能需要以Agent为主体重新设计一遍。

OpenClaw发布后,Ouraca是反应最快的团队之一——他们立刻以Agent为主体设计了BotLearn。Agents之间互相交换技能、互相学习,再反哺给人类。这种网络效应在传统教育产品里从未出现过。”

△Agent在BotLearn发布的学习心得,图片:网页截图

深圳证监局责令深圳市启富证券投资顾问有限公司改正

深圳市启富证券投资顾问有限公司因未依法备案即开展业务、营销过程中存在误导性宣传、合规管理不到位、投资者适当性管理不到位等问题,被深圳证监局采取责令改正的监管措施。公司需在30日内提交书面整改报告。

中国创新药对外授权年交易额突破千亿美元,4年增长近10倍

中国生物制药公司参与的新药对外许可授权交易在2025年再次创下历史新高,并突破了千亿美元大关。根据数据提供商Pharmcube的最新数据,2025年,中国企业签署了1377亿美元总规模的对外许可交易,较2021年增长了近10倍,交易数量达到186项。包括诺华、阿斯利康和GSK在内的全球制药巨头去年与中国企业签署了多项重大协议。美国银行证券亚太区并购主管汤姆·巴沙(Tom Barsha)预测,这些授权交易的总价值有望在未来18至24个月内再次翻番。“全球制药公司正高度关注在中国寻找下一代创新药物研发管线,并正在考虑各种交易结构。”巴沙表示。

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% 的坑。

2月14日:“农产品批发价格200指数”比昨天下降0.03个点

据农业农村部,2月14日“农产品批发价格200指数”为129.56,比昨天下降0.03个点,“菜篮子”产品批发价格指数为132.40,比昨天下降0.04个点。全国农产品批发市场猪肉平均价格为18.18元/公斤,比昨天下降0.2%;牛肉66.15元/公斤,比昨天下降0.7%;羊肉65.58元/公斤,比昨天上升0.9%;鸡蛋8.12元/公斤,比昨天下降2.3%;白条鸡17.29元/公斤,比昨天下降0.7%。重点监测的28种蔬菜平均价格为5.58元/公斤,与昨天持平;重点监测的6种水果平均价格为7.98元/公斤,比昨天下降1.0%。鲫鱼20.00元/公斤,比昨天上升2.2%;鲤鱼14.75元/公斤,比昨天上升1.9%;白鲢鱼10.56元/公斤,与昨天持平;大带鱼44.58元/公斤,比昨天下降0.6%。今日,国内鲜活农产品批发市场重点监测的46个品种中,与昨天相比价格升幅前五名的是青椒、鲫鱼、大白菜、鲤鱼和南瓜,幅度分别为2.2%、2.2%、2.1%、1.9%和1.8%;价格降幅前五名的是平菇、花鲢鱼、鸡蛋、巨峰葡萄和胡萝卜,幅度分别为3.3%、3.0%、2.3%、2.2%和1.9%。

千问大免单活动延长3天

2月14日,千问宣布免单活动延长3天,并接入大麦、飞猪,用户可体验AI买电影票、门票等新功能。从今天下午3点到大年初一,每个人可再领10张25元“超级免单卡”。据了解,“超级免单卡”使用范围更广,除了点餐饮、囤年货,也可以用来在千问上买电影票、门票、订酒店、机票。千问还将陆续接入AI打车、充手机话费、高德扫街榜团购、淘宝购物等新功能,均可使用超级免单卡,有效期持续到4月30日。

梅赛德斯-奔驰中国宣布重要人事任命:销售CEO段建军离职,李德思接任

2月14日,梅赛德斯-奔驰(中国)投资有限公司宣布一系列合资公司管理岗位的人事变动。北京梅赛德斯-奔驰销售服务有限公司总裁兼首席执行官段建军先生因个人原因辞去现任职务并将离开公司。自2026年3月1日起,奔驰销售公司销售执行副总裁李德思先生将被任命总裁兼首席执行官;段建军先生将担任公司战略顾问,确保平稳有序的工作交接,直至4月30日任期届满。自4月1日起,张明霞女士将结束在smart品牌全球公司(smart合资公司)担任全球首席营销官,负责全球销售、市场及客户运营业务的任期,出任奔驰销售公司销售执行副总裁。自4月1日起,梅赛德斯-奔驰汽车金融有限公司现销售与市场营销负责人康毅先生将出任smart合资公司全球首席营销官,负责销售、市场及客户运营业务。

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

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

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

迪拜2025年国际游客近2000万,中国游客达86万

迪拜经济和旅游部公布的最新数据显示,2025年迪拜共接待国际过夜游客1959万,较2024年的1872万同比增长5%,连续第三年创历史新高。其中,中国2025年赴迪拜的过夜游客规模达86万人次,海湾阿拉伯国家合作委员会及中东和北非地区周边市场合计贡献了迪拜26%的游客量。

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! 🚀

Seedance 2.0之后,字节跳动又发布豆包大模型2.0

2月14日,字节跳动发布豆包大模型2.0系列(Doubao-Seed-2.0),该系列包括旗舰版Pro和高性价比Lite版。豆包2.0 Pro在数学和推理能力上达到世界顶尖水平,斩获IMO、CMO数学竞赛及ICPC编程竞赛金牌,并在Putnam基准测试中超越Gemini 3 Pro。它加强了长尾领域知识覆盖,在SuperGPQA等测试中表现突出,科学领域知识成绩与Gemini 3 Pro和GPT 5.2相当。多模态理解全面升级,视觉推理、空间感知、长上下文理解等权威测试均取得业界最佳。同时增强对时间序列与运动感知,支持实时视频流分析、环境感知与主动交互,可应用于健身指导、穿搭建议、看护陪伴等生活场景。Agent能力方面,指令遵循、工具调用和Search Agent评测达顶级水平,在HLE-Text测试中以54.2分大幅领先。目前,豆包2.0 Pro已在豆包App、电脑客户端和网页版上线,用户选择「专家」模式即可体验;火山引擎也已提供API服务。定价上,豆包2.0 Pro按输入长度计费,32k以内输入3.2元/百万tokens,输出16元/百万tokens,相比竞品有较大成本优势。

【前端趋势调查系列】带你看看前端生态圈的技术趋势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 年的变化吧。我期待着更快的工具、更好的开发体验,以及技术真正成为能力放大器,强化我们自身的判断与选择。

❌