js实现 移动动画 封装

javascript 复制代码
/**
 * 通用动画函数
 * @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;
javascript 复制代码
<!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 的设计模式,这使得参数扩展变得非常灵活,同时保持了调用的清晰度。

javascript 复制代码
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),并在后台标签页暂停执行以节省电量。

javascript 复制代码
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)

最常见的模式。进度等于"已过去的时间"除以"总时长"。

javascript 复制代码
progress = elapsed / duration;

模式 B:速度模式 (Speed Mode)

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

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

3.3 线性插值 (Linear Interpolation / Lerp)

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

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

这个公式非常强大:

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

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

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

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

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

4. 最佳实践与使用示例

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

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

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

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

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

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

javascript 复制代码
let currentAnim = null;

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

5. 总结

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

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

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

相关推荐
小二·38 分钟前
Next.js 15 全栈开发实战
开发语言·javascript·ecmascript
Rain5092 小时前
2.1 Nest.js 项目初始化与模块化架构
开发语言·前端·javascript·后端·架构·数据分析·node.js
拾年2753 小时前
从零手写 Ajax:用原生 XHR 搭建前后端交互全流程
前端·javascript·ajax
拉勾科研工作室3 小时前
区块链工程毕业论文题目【249个】
开发语言·javascript
小林ixn3 小时前
你以为你懂 + 号?看完这篇 Bun + TS 实战,才发现以前全写错了
前端·javascript·typescript
jvxiao4 小时前
你真的懂作用域吗?从编译原理角度深度 JS 的作用域
前端·javascript
Darling噜啦啦4 小时前
二叉树与递归算法实战:从树结构到 LeetCode 爬楼梯,一文吃透前端数据结构与递归思维
前端·javascript·数据结构
Sammyyyyy5 小时前
月之暗面 Kimi Code 0.4.0 发布,终端 AI 编码助手全面采用 TypeScript,实现毫秒级启动
前端·javascript·人工智能·ai·typescript·servbay
宋拾壹6 小时前
fastadmin列表中查看列表,并且添加增加相应的数据
javascript·php·fastadmin
云水一下7 小时前
Vue.js从零到精通系列(三):组件化基础——Props、Emits、插槽与生命周期
前端·javascript·vue.js