从零实现2D绘图引擎:6.动画系统的实现

MiniRender仓库地址参考

动画系统 (Animation System)

这是让静态图表变成"活"图表的关键。我们的目标不是写死 requestAnimationFrame,而是构建一个声明式的动画库,让开发者只需要告诉引擎"我想去哪里",引擎自动负责"怎么去"。


1. 核心模块设计

我们需要三个新文件:

  1. Easing.ts : 缓动函数库(提供 linear, cubicOut 等数学公式)。
  2. Animator.ts: 动画执行者(负责单个对象的属性插值计算)。
  3. Animation.ts: 动画管理器(负责全局的时间循环 Loop,调度所有 Animator)。

同时,我们需要修改 ElementMiniRender 类来集成这个系统。


2. 缓动函数 (src/animation/Easing.ts)

缓动函数输入一个 01 的时间进度 t,输出一个变换后的进度 p

typescript 复制代码
// src/animation/Easing.ts

type EasingFunc = (t: number) => number;

export const Easing = {
    linear: (t: number) => t,
    
    // 二次缓动
    quadraticIn: (t: number) => t * t,
    quadraticOut: (t: number) => t * (2 - t),
    
    // 三次缓动 (常用,自然)
    cubicIn: (t: number) => t * t * t,
    cubicOut: (t: number) => --t * t * t + 1,
    
    // 弹性缓动
    elasticOut: (t: number) => {
        const c4 = (2 * Math.PI) / 3;
        return t === 0 ? 0 : t === 1 ? 1 :
            Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
    }
};

export type EasingType = keyof typeof Easing;

3. 动画执行者 (src/animation/Animator.ts)

这是最复杂的部分。它需要能够深度遍历对象,找出数值属性,并在起始值和目标值之间进行插值。

typescript 复制代码
// src/animation/Animator.ts
import { Easing, EasingType } from './Easing';

export class Animator {
    target: any;
    
    private _startState: any = {};
    private _endState: any;
    
    private _duration: number;
    private _easing: EasingType;
    
    private _startTime: number = 0;
    private _delay: number = 0;
    
    private _onUpdate?: () => void;
    private _onDone?: () => void;
    
    // 标记动画是否已结束
    isFinished: boolean = false;

    constructor(target: any, endState: any, duration: number, easing: EasingType = 'linear') {
        this.target = target;
        this._endState = endState;
        this._duration = duration;
        this._easing = easing;
    }

    start(time: number) {
        this._startTime = time + this._delay;
        // 核心:在开始瞬间,克隆当前状态作为起始状态
        this._startState = this._cloneState(this._endState, this.target);
    }

    /**
     * 每一帧调用此方法
     * @param globalTime 全局时间戳
     * @return boolean 是否有变化
     */
    step(globalTime: number): boolean {
        if (this.isFinished) return false;
        if (globalTime < this._startTime) return false; // 还没到 delay 时间

        // 1. 计算进度 (0 ~ 1)
        let p = (globalTime - this._startTime) / this._duration;
        if (p >= 1) {
            p = 1;
            this.isFinished = true;
        }

        // 2. 应用缓动
        const easingFunc = Easing[this._easing] || Easing.linear;
        const v = easingFunc(p);

        // 3. 执行插值
        this._interpolate(this.target, this._startState, this._endState, v);

        // 4. 回调
        if (this._onUpdate) this._onUpdate();
        if (this.isFinished && this._onDone) this._onDone();

        return true;
    }
    
    // API: 设置延迟
    delay(ms: number) {
        this._delay = ms;
        return this;
    }
    
    // API: 完成回调
    done(cb: () => void) {
        this._onDone = cb;
        return this;
    }
    
    // API: 更新回调
    update(cb: () => void) {
        this._onUpdate = cb;
        return this;
    }

    // --- 内部辅助方法 ---

    // 递归插值:将 start 到 end 的值设置给 target
    private _interpolate(target: any, start: any, end: any, p: number) {
        for (const key in end) {
            const sVal = start[key];
            const eVal = end[key];

            if (typeof eVal === 'number' && typeof sVal === 'number') {
                // 数字:直接计算
                target[key] = sVal + (eVal - sVal) * p;
            } else if (Array.isArray(eVal) && Array.isArray(sVal)) {
                // 数组:递归处理 (如 position: [x, y])
                if (!target[key]) target[key] = [];
                this._interpolate(target[key], sVal, eVal, p);
            } else if (typeof eVal === 'object' && eVal !== null) {
                // 对象:递归处理 (如 style: { ... })
                if (!target[key]) target[key] = {};
                this._interpolate(target[key], sVal, eVal, p);
            }
            // 颜色插值比较复杂(涉及字符串解析),Stage 4 暂不实现,直接跳变
        }
    }

    // 递归克隆状态:只克隆 endState 中有的属性
    private _cloneState(end: any, source: any) {
        const res: any = {};
        for (const key in end) {
            const val = source[key];
            if (typeof end[key] === 'object' && end[key] !== null && !Array.isArray(end[key])) {
                // 递归对象
                res[key] = this._cloneState(end[key], val);
            } else if (Array.isArray(end[key])) {
                // 拷贝数组
                res[key] = Array.from(val || []);
            } else {
                // 基本类型
                res[key] = val;
            }
        }
        return res;
    }
}

4. 动画管理器 (src/animation/Animation.ts)

这是一个单例或者依附于 MiniRender 的管理器,负责驱动 requestAnimationFrame

typescript 复制代码
// src/animation/Animation.ts
import { Animator } from './Animator';

export class Animation {
    private _animators: Animator[] = [];
    private _isRunning: boolean = false;

    // 注入 MiniRender 的刷新方法
    onFrame?: () => void;

    add(animator: Animator) {
        this._animators.push(animator);
        // 自动启动 Animator
        animator.start(Date.now());
        
        if (!this._isRunning) {
            this._startLoop();
        }
    }

    private _startLoop() {
        this._isRunning = true;

        const step = () => {
            const time = Date.now();
            let hasChange = false;

            // 倒序遍历,方便删除
            for (let i = this._animators.length - 1; i >= 0; i--) {
                const anim = this._animators[i];
                const changed = anim.step(time);
                if (changed) hasChange = true;

                // 移除已完成的动画
                if (anim.isFinished) {
                    this._animators.splice(i, 1);
                }
            }

            // 如果有属性变化,触发外部重绘
            if (hasChange && this.onFrame) {
                this.onFrame();
            }

            if (this._animators.length > 0) {
                requestAnimationFrame(step);
            } else {
                this._isRunning = false;
            }
        };

        requestAnimationFrame(step);
    }
}

5. 集成到核心系统

A. 修改 src/core/MiniRender.ts

初始化 Animation 模块,并建立"动画 -> 重绘"的桥梁。

typescript 复制代码
import { Animation } from '../animation/Animation';
// ...

export class MiniRender {
    // ...
    animation: Animation;

    constructor(dom: HTMLElement) {
        // ...
        this.animation = new Animation();
        
        // 当动画产生帧更新时,调用 painter 刷新
        this.animation.onFrame = () => {
            this.painter.refresh();
        };
    }

    addAnimator(animator: any) {
        this.animation.add(animator);
    }
    // ...
}

B. 修改 src/graphic/Element.ts

给所有元素添加便捷 API animateTo

注意:我们需要一种方式让 Element 能访问到 MiniRender 实例或者 Animation 全局实例。为了解耦,MiniRender 在添加 Element 时,可以给 Element 注入一个 miniRender 引用,或者我们简单点,让 animateTo 返回一个 Animator 对象,由用户手动 miniRender.animation.add(),或者实现一个更高级的调度。

我们需要修改 Element.ts、Group.ts 和 MiniRender.ts 三个文件。

1). 修改 src/graphic/Element.ts

给 Element 增加一个 miniRender 属性。并在 animateTo 中检查这个属性。

typescript 复制代码
// src/graphic/Element.ts
import { Animator } from '../animation/Animator';
import { EasingType } from '../animation/Easing';

export abstract class Element extends Eventful {
    // ... 原有属性 ...

    // 新增:持有对 MiniRender 实例的引用
    // 使用 any 是为了避免循环引用类型问题 (Element <-> MiniRender)
    miniRender: any = null; 

    animateTo(targetState: any, duration: number, easing: EasingType = 'linear', delay: number = 0) {
        const animator = new Animator(this, targetState, duration, easing);
        if (delay > 0) animator.delay(delay);
        
        this.animators.push(animator);

        if (this.miniRender) {
            this.miniRender.animation.add(animator);
        }

        return animator; // 依然返回,以便链式调用 .done() 等
    }
}
2). 定义遍历辅助函数

我们需要一个递归函数,当一个 Group 被加入时,把它底下所有的子孙节点的 miniRender 属性都设置好。为了避免复杂的循环依赖,我们可以把这个函数放在 src/utils/zrenderHelper.ts 或者直接作为 Element 的一个简单方法,这里我们修改 Group.ts 和 MiniRender.ts 来配合。

我们采用最简单的方式:在 添加子节点 时进行传递。

3).修改 src/graphic/Group.ts

当向 Group 添加子节点时,如果 Group 已经有了 miniRender,就传给子节点。

typescript 复制代码
// src/graphic/Group.ts
import { Element } from './Element';

export class Group extends Element {
    // ...

    add(child: Element) {
        if (child && child !== this && child.parent !== this) {
            this.children.push(child);
            child.parent = this;

            if (this.miniRender) {
                // 递归设置子树
                this._propagateRender(child, this.miniRender);
            }
        }
    }

    /**
     * 辅助方法:递归向下传递 miniRender 引用
     */
    private _propagateRender(el: Element, miniRender: any) {
        el.miniRender = miniRender;
        if ((el as Group).isGroup) {
            const children = (el as Group).children;
            for (let i = 0; i < children.length; i++) {
                this._propagateRender(children[i], miniRender);
            }
        }
    }
}
4).修改 src/core/MiniRender.ts

这是入口。当用户调用 miniRender.add(el) 时,注入依赖。

typescript 复制代码
// src/core/MiniRender.ts

export class MiniRender {
    // ...

    add(el: Element) {
        // 关键修改:根节点注入
        this._propagateRender(el, this);
        
        this.storage.addRoot(el);
        this.refresh();
    }

    // 复制 Group 中的那个辅助逻辑,或者提取成公共函数
    // 这里简单拷贝一份逻辑确保根节点也能递归
    private _propagateRender(el: Element, miniRender: any) {
        el.miniRender = miniRender;
        if ((el as any).isGroup) {
            const children = (el as any).children;
            for (let i = 0; i < children.length; i++) {
                this._propagateRender(children[i], miniRender);
            }
        }
    }
    
    // addAnimator 方法现在是给内部用的,或者作为高级 API 保留
    addAnimator(animator: any) {
        this.animation.add(animator);
    }
}

6. Demo

我们要修改之前的柱状图代码,让柱子在创建时高度为 0,然后长高。

逻辑分析

  1. 初始状态 :
    • height: 0
    • y: chartHeight (即 X 轴的位置)
  2. 目标状态 :
    • height: barHeight (真实高度)
    • y: chartHeight - barHeight (真实 Y 坐标)

修改 index.ts:

typescript 复制代码
// ... 前面的代码不变

data.forEach((value, index) => {
    // ... 计算 barHeight, finalY 等 ...

    const bar = new Rect({
        shape: {
            x: finalX,
            y: chartConfig.height,
            width: chartConfig.barWidth,
            height: 0
        },
        style: { fill: chartConfig.barColor }
    });

    // 先 add 到 Group (此时 Group 还没加到 miniRender,所以 bar.miniRender 还是 null)
    chartGroup.add(bar);
    chartGroup.add(label);
    
    // 方式 1: 延迟动画
    // 因为 chartGroup 还没加到 miniRender,所以这里直接调 animateTo 不会立即启动
    // 但是!我们在 chartGroup 加到 miniRender 后,bar.miniRender 会被赋值。
    // 可是 animator 已经在 create 时判断过 miniRender 是 null 了,没有加进去。
    // 【修正逻辑】:
    // 我们的 animateTo 是"创建即启动"。
    // 如果 element 还没加到 miniRender,调用 animateTo 会创建 Animator 但不会加入 Animation Loop。
    // 这会导致动画"丢失"。
    
    // 为了解决这个问题,通常有两种写法:
    // 1. 先把 chartGroup 加到 miniRender,再创建图形和动画 (推荐)。
    // 2. Element 内部做一个 pendingAnimators 队列,当 miniRender 被赋值时自动 add (实现较复杂)。
    
    // 这里我们采用写法 1 (ZRender 的标准用法也是先 add 再 animate)。
});

// 1. 先把组加到引擎中!(此时所有 children 的 .miniRender 都会被赋值)
miniRender.add(chartGroup); 

// 2. 再遍历数据创建动画 (或者在创建 bar 时就 animate,前提是 bar 已经有 miniRender)
// 这里我们需要稍微调整代码顺序,或者在上面的 forEach 里改一下逻辑:

// === 最佳实践代码顺序 ===

// 1. 准备 Group
const chartGroup = new Group({ position: [chartConfig.x, chartConfig.y] });
miniRender.add(chartGroup); // <--- 先把 Group 挂载上去!

data.forEach((value, index) => {
    // ... 坐标计算 ...

    const bar = new Rect({ /*...*/ });
    
    // 2. bar 加入 chartGroup
    // 因为 chartGroup 已经在 miniRender 里了,bar 加入瞬间,Group.add 会把 miniRender 传给 bar
    chartGroup.add(bar); 
    
    // 3. 此时 bar.miniRender 已经有值了,直接开启动画!
    bar.animateTo(
        {
            shape: {
                y: finalY,
                height: finalHeight
            }
        },
        1000,
        'cubicOut',
        index * 100
    );
    // 不需要 miniRender.addAnimator(animator) 了!
});

7.阶段总结

我们已经从零构建了一个具备对象模型、渲染管线、交互系统、动画引擎的 Canvas 2D 引擎微内核(MiniRender)。它已经时一个具备扩展性的图形库雏形了。

我们目前的 MiniRender 已经实现了 ZRender v4/v5 的核心设计思想:

模块 类名 (Class) 核心职责 当前状态
入口 MiniRender 外观模式入口,协调各模块,管理主循环。 ✅ 已实现依赖注入
数据 Storage 场景图 (Scene Graph) 管理,维护显示列表,处理层级排序。 ✅ 支持 Group/Z-index
渲染 Painter 视图 (View),负责 Canvas 上下文管理、重绘循环 (refresh)。 ✅ 基础全量重绘
图形 Element / Displayable 节点 (Node),实现仿射变换矩阵 (transform)、父子级联。 ✅ 矩阵运算/样式封装
形状 Rect / Circle / Text 具体图形几何定义与包含检测 (contain)。 ✅ 基础形状
交互 Handler 控制器 (Controller),实现坐标逆变换,DOM 事件代理与分发。 ✅ Click/Hover/Silent
动画 Animation / Animator 声明式动画系统,支持缓动函数与属性插值。 ✅ 支持递归插值
相关推荐
_Jyann_1 小时前
uniapp两种方式实现自定义tabbar
前端·javascript·uni-app
一 乐1 小时前
数码商城系统|电子|基于SprinBoot+vue的数码商城系统(源码+数据库+文档)
java·前端·javascript·数据库·vue.js·springboot
盛夏绽放1 小时前
新手入门:实现聚焦式主题切换动效(Vue3 + Pinia + View Transitions)
前端·vue3·pinia·聚焦式主题切换
Howie Zphile1 小时前
NEXTJS/REACT有哪些主流的UI可选
前端·react.js·ui
fruge1 小时前
React Server Components 实战:下一代 SSR 开发指南
前端·javascript·react.js
lichong9511 小时前
harmonyos 大屏设备怎么弹出 u 盘
前端·macos·华为·typescript·android studio·harmonyos·大前端
irises1 小时前
从零实现2D绘图引擎:5.5.简单图表demo
前端·数据可视化
irises1 小时前
从零实现2D绘图引擎:5.鼠标悬停事件
前端·数据可视化
青莲8431 小时前
Android Lifecycle 完全指南:从设计原理到生产实践
android·前端