我如何从零开始为 Excalidraw 构建 “Magic Move” 动画引擎并发布

发布: (2026年1月12日 GMT+8 21:11)
4 分钟阅读
原文: Dev.to

Source: Dev.to

目标

我喜欢使用 Excalidraw 来绘制系统架构草图。但草图是静态的。当我想展示数据包如何在负载均衡器中流动,或数据库分片如何拆分时,我只能手忙脚乱地挥手,或者制作 10 张不同的幻灯片。

我想要一种 “绘制逻辑,导出动画” 的能力。

我不想要时间轴编辑器(比如 After Effects),那对一个简单的图表来说工作量太大。

我想要 “无键帧动画”

  • 绘制 帧 1(起始状态)。
  • 将其克隆为 帧 2
  • 将元素移动到新位置。
  • 引擎会自动算出过渡效果。

我使用 Next.jsExcalidrawFramer Motion 构建了这个引擎。下面是实现逻辑的技术深度剖析。

1. 核心逻辑:状态差分

最难的部分不是动画循环,而是 差分。当我们从 帧 A 迁移到 帧 B 时,需要通过元素的稳定 ID 来识别它们,并将它们归入以下三类:

  • 稳定(Stable):元素在两个帧中都存在(需要变形/移动)。
  • 进入(Entering):仅在 B 中存在(需要淡入)。
  • 退出(Exiting):仅在 A 中存在(需要淡出)。

我编写了一个 categorizeTransition 工具函数,用于高效映射元素:

// Simplified logic from src/utils/editor/transition-logic.ts
export function categorizeTransition(prevElements, currElements) {
    const stable = [];
    const morphed = [];
    const entering = [];
    const exiting = [];

    const prevMap = new Map(prevElements.map(e => [e.id, e]));
    const currMap = new Map(currElements.map(e => [e.id, e]));

    // 1. Find Morphs (Stable) & Entering
    currElements.forEach(curr => {
        if (prevMap.has(curr.id)) {
            const prev = prevMap.get(curr.id);
            // Separate "Stable" (identical) from "Morphed" (changed)
            // to optimize the render loop
            if (areVisuallyIdentical(prev, curr)) {
                stable.push({ key: curr.id, element: curr });
            } else {
                morphed.push({ key: curr.id, start: prev, end: curr });
            }
        } else {
            entering.push({ key: curr.id, end: curr });
        }
    });

    // 2. Find Exiting
    prevElements.forEach(prev => {
        if (!currMap.has(prev.id)) {
            exiting.push({ key: prev.id, start: prev });
        }
    });

    return { stable, morphed, entering, exiting };
}

2. 插值属性

对于 Morphed(变形)元素,需要在任意 progress(0.0 → 1.0)时计算其中间状态。

  • 数值(x、y、width):线性插值即可。
  • 颜色(strokeColor):先将十六进制转为 RGBA,分别插值每个通道,再转回。
  • 角度:使用 “最短路径” 插值。

如果对象从 10° 旋转到 350°,线性插值会走很长的弧线。下面的辅助函数会找到最短的旋转方向:

// src/utils/smart-animation.ts
const angleProgress = (oldAngle, newAngle, progress) => {
    let diff = newAngle - oldAngle;

    // Normalize to -π to +π to find shortest direction
    while (diff > Math.PI) diff -= 2 * Math.PI;
    while (diff  {
    return 1 - Math.pow(1 - t, 4);
};

这确保了大型架构块能够带着重量“砰砰”落位,而不是像幽灵一样滑来滑去。

接下来

我目前正在开发:

  • 子步骤动画:允许在单个帧内部通过点击逐条展示要点。
  • 导出为 MP4:直接将画布流录制为视频文件。

项目已上线,我构建它的初衷是帮助开发者更好地沟通。

点此尝试:
免费 Stripe 促销码:postara

欢迎分享你对这种方法的看法!

Back to Blog

相关文章

阅读更多 »

你好,我是新人。

嗨!我又回到 STEM 的领域了。我也喜欢学习能源系统、科学、技术、工程和数学。其中一个项目是…