从零实现2D绘图引擎:6.动画系统的实现
动画系统 (Animation System)。
这是让静态图表变成“活”图表的关键。我们的目标不是写死 requestAnimationFrame,而是构建一个声明式的动画库,让开发者只需要告诉引擎“我想去哪里”,引擎自动负责“怎么去”。
1. 核心模块设计
我们需要三个新文件:
-
Easing.ts: 缓动函数库(提供linear,cubicOut等数学公式)。 -
Animator.ts: 动画执行者(负责单个对象的属性插值计算)。 -
Animation.ts: 动画管理器(负责全局的时间循环 Loop,调度所有 Animator)。
同时,我们需要修改 Element 和 MiniRender 类来集成这个系统。
2. 缓动函数 (src/animation/Easing.ts)
缓动函数输入一个 0 到 1 的时间进度 t,输出一个变换后的进度 p。
// 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)
这是最复杂的部分。它需要能够深度遍历对象,找出数值属性,并在起始值和目标值之间进行插值。
// 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。
// 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 模块,并建立“动画 -> 重绘”的桥梁。
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 中检查这个属性。
// 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,就传给子节点。
// 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) 时,注入依赖。
// 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,然后长高。
逻辑分析:
-
初始状态:
-
height: 0 -
y:chartHeight(即 X 轴的位置)
-
-
目标状态:
-
height:barHeight(真实高度) -
y:chartHeight - barHeight(真实 Y 坐标)
-
修改 index.ts:
// ... 前面的代码不变
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 | 声明式动画系统,支持缓动函数与属性插值。 | ✅ 支持递归插值 |