阅读视图

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

[开源] 从零到一打造在线 PPT 编辑器:React + Zustand + Zundo

image.png

image.png

技术栈

  • React 框架
  • TS 语言
  • Umi 脚手架
  • Zustand 状态管理
  • Zundo 状态回滚记录
  • Svg 图形
  • pptxgenjs导出为PPT,还没做
  • Ant Design UI框架

核心设计与实现

这个在线 PPT 编辑器的核心可以拆解为几个关键模块:数据模型、画布渲染器、状态管理器和属性面板

1. 数据模型:JSON

首先,我们需要定义数据结构。整个演示文稿、每一页幻灯片以及幻灯片上的每一个元素(文本、形状、图片等)都应该被结构化、序列化为 JSON 对象

// 单个元素(文本、形状、图片等)
export interface PPTElement {
  id: string;
  type: 'text' | 'image' | 'shape' | 'line';
  left: number;
  top: number;
  width: number;
  height: number;
  rotate?: number;
  content: string; // 文本内容、图片 URL、或形状类型
  style?: {
    // ... 样式属性
  };
}

// 单张幻灯片
export interface Slide {
  id: string;
  elements: PPTElement[];
  background?: {
    color?: string;
    image?: string;
  };
}

// 整个演示文稿
export interface Presentation {
  title: string;
  slides: Slide[];
}

这种设计使得状态的读取、更新和持久化都变得非常直观

2. 状态管理:Zustand + Zundo

状态管理是编辑器的灵魂。我使用 Zustand 创建了一个 presentationStore 来统一管理所有的状态和操作。

Zundo 的集成异常简单,只需将你的状态创建函数包裹在 zundo 中间件里即可。

import { create } from 'zustand';
import { temporal } from 'zundo'; // 引入 zundo

export const usePresentationStore = create(
  temporal( // 使用 temporal 中间件包裹
    (set, get) => ({
      slides: initialSlides, // 初始幻灯片数据
      selectedSlideId: initialSlides[0].id,
      selectedElementIds: [],

      // 添加元素
      addElement: (element) => {
        // ...
      },

      // 更新元素
      updateElement: (id, patch) => {
        // ...
      },

      // ... 其他所有操作 slides 的方法
    }),
    {
      // Zundo 配置项
      limit: 50, // 最多记录 50 步历史
    }
  )
);

现在,presentationStore 自动拥有了撤销 (undo) 和重做 (redo) 的能力。我们只需要在组件中调用它们:

import { useTemporalStore } from '@/stores/presentationStore';

const CanvasHeader = () => {
  const { undo, redo } = useTemporalStore.temporal.getState();

  return (
    <div>
      <Tooltip title="撤销">
        <Button icon={<UndoOutlined />} onClick={() => undo()} />
      </Tooltip>
      <Tooltip title="重做">
        <Button icon={<RedoOutlined />} onClick={() => redo()} />
      </Tooltip>
      {/* ... */}
    </div>
  );
};

复杂的状态历史追溯功能被 Zundo 优雅地解决了,真香!

3. 画布与渲染器

画布是用户与 PPT 交互的核心区域。它负责根据当前 Slide 的数据,渲染出所有元素。

我创建了一个 ElementRenderer 组件,它会根据元素的 type 字段,动态地渲染出不同的子组件(如 TextElementImageElement 等)。

const ElementRenderer = ({ element }: { element: PPTElement }) => {
  switch (element.type) {
    case 'text':
      return <div style={...}>{element.content}</div>;
    case 'image':
      return <img src={element.content} style={...} />;
    case 'shape':
      return <ShapeElement type={element.content} style={...} />;
    default:
      return null;
  }
};

缩略图列表的实现:我没有为缩略图单独编写一套渲染逻辑,而是直接复用了 渲染器组件,只是通过 props 传入一个缩放比例 scale,并禁用其交互

const SlideThumbnail = ({ slide, size }) => {
  const scale = size.width / 1920; // 假设画布标准宽度为 1920

  return (
    <div style={{ width: size.width, height: size.height }}>
      <Canvas
        slide={slide}
        scale={scale}
        interactive={false} // 禁用交互
        embedded={true}     // 嵌入模式
      />
    </div>
  );
};

4. 属性面板:响应式交互

当用户选中一个元素时,右侧的属性面板会显示其对应的可编辑属性(如颜色、字体大小、位置等)

这里的逻辑是:

  1. 监听 presentationStore 中的 selectedElementIds
  2. 当选中元素变化时,从 slides 数据中找到该元素的详细信息
  3. 将元素属性绑定到属性面板的输入框中
  4. 当用户修改输入框时,调用 store 中的 updateElement 方法来更新状态

数据驱动视图的理念

未来路线图 (Roadmap)

这个项目还有很大的想象空间,我计划在未来加入更多的功能:

  • PPT 导出:支持将编辑好的内容导出为 .pptx 文件
  • 完备的快捷键:增加更多快捷键
  • 更多的属性配置:支持配置多种多样的样式
  • 元素对齐与分布:提供辅助线、元素吸附、水平/垂直分布等高级编辑功能
  • 动画效果:为元素添加入场、退场动画
  • 主题与模板:内置更多精美的设计模板
  • 多人实时协作:这是最具挑战性的功能,也是在线文档的终极形态

写在最后

这个项目目前还处于早期阶段,有很多不完善之处。非常欢迎大家提出宝贵的建议、报告 Bug,甚至参与到开发中来。勿喷! Star!!!!

Canvas 如何渲染富文本、图片、SVG 及其 Path 路径?

在前端开发中,我们习惯于使用 HTML 标签(如 <img>, <div>, <svg>)来声明我们想要显示的内容,然后由浏览器负责布局和渲染

<canvas> 元素截然不同

Canvas 提供的是一个“即时模式”的 2D (或 3D) 绘图 API。它是一块空白的位图画布,你通过 JavaScript 发出绘图指令(如“画个圈”、“填充颜色”),它就立即执行。它不会记住你画了什么对象;一旦像素被涂上,它就只是一堆像素

1. 渲染图片

drawImage() 方法可以接受多种图像源,包括 <img> 元素、另一个 <canvas> 元素、<video> 的当前帧或 ImageBitmap

渲染流程:

  1. 等待数据就绪: JavaScript 加载图片是异步的。在 img.onload 事件触发后,确保图片数据(像素)已经完全加载到内存中

  2. 执行 drawImage

    • ctx.drawImage(image, dx, dy): 将源图像的像素数据,原封不动地“复制”到 Canvas 画布的 (dx, dy) 位置。
    • ctx.drawImage(image, dx, dy, dWidth, dHeight): 在复制前,先对源图像的像素进行缩放(拉伸或压缩)到 dWidth x dHeight 大小,然后再绘制。
    • ctx.drawImage(image, sx, sy, sWidth, sHeight, ...): 这是最复杂的形式,它先从源图像上“裁剪”出一块矩形区域,然后(可选)缩放,最后“粘贴”到画布上。
// 示例:加载并绘制一张图片
const img = new Image();
img.src = 'path/to/image.png';

// 必须等待图片解码完成
img.onload = () => {
  // 将图片像素“印”在 (10, 10) 坐标
  ctx.drawImage(img, 10, 10);
};

2. 渲染富文本

Canvas 提供了两个基本方法来绘制文本:ctx.fillText()ctx.strokeText()

例如:<span>Hello <b>World</b></span> 的混合样式和自动换行

Canvas 原生 API 并不支持这些

Canvas 的 fillText 命令只是将一个纯文本字符串,根据当前的 ctx.fontctx.fillStyle 等样式,对这些形状执行一次填充操作

如何渲染真正的“富文本”?

  1. 自动换行:

    • 将长文本分割成单词
    • 逐个单词测量其宽度 (ctx.measureText(word).width)
    • 如果当前行宽度超过了设定的最大宽度,你就必须手动增加 y 坐标(换行),然后从新行开始绘制
  2. 混合样式(例如 Hello <b>World</b>):

    • ctx.font = '16px Arial';
    • ctx.fillText('Hello ', x, y);
    • 计算 "Hello " 的宽度:const w = ctx.measureText('Hello ').width;
    • 更改状态: ctx.font = 'bold 16px Arial';
    • 在后面继续绘制: ctx.fillText('World', x + w, y);

3. 渲染 SVG

SVG(可缩放矢量图形)和 Canvas 在某种理解下可以是对立的

  • SVG 是“声明式”: 用 XML 描述一个“场景”(例如,这里有个圆,那里有条线)。浏览器会记住这些对象
  • Canvas 是“命令式”: 发出“画圆”的指令,它画完就忘了

因此,Canvas 不能直接渲染 SVG 字符串,你必须在两者之间进行“翻译”

方式一:作为图片“光栅化”(最常用)

这是最简单的方法:把 SVG 当作一张普通的 <img> 图片来处理

  1. 将 SVG 转换为图像源:

    • 如果是 .svg 文件:img.src = 'icon.svg';

    • 如果是 SVG 字符串:

      const svgString = '...';

      const svgDataUri = 'data:image/svg+xml;base64,' + btoa(svgString);

      img.src = svgDataUri;

  2. 等待加载: img.onload = () => { ... }

  3. 绘制图像: ctx.drawImage(img, dx, dy, width, height);

drawImage 被调用的那一刻,SVG 的矢量特性就丢失了。它被“光栅化”成了指定 widthheight 的像素。如果你在 Canvas 上放大它,它会像普通图片一样变模糊

方式二:作为矢量“解析转译”(复杂)

这个方法会保留矢量特性

  1. 解析 SVG 的 XML 结构

  2. 遍历 SVG 节点(如 <rect>, <circle>, <path>

  3. 将每个 SVG 节点的属性翻译成等效的 Canvas API 调用

    • <rect x="10" ...> -> ctx.rect(10, ...)
    • 等等

4. 渲染 SVG 中的 Path 路径

SVG 的 <path> 元素使用 d 属性来定义极其复杂的形状,例如:

<path d="M10 10 L100 100 C150 150 200 150 250 100 Z">

M 代表 moveTo(移动到),L 代表 lineTo(画线到),C 代表贝塞尔曲线,Z 代表闭合路径

Canvas 的 ctx.lineTo 等 API 无法直接读取这个字符串,我们如何“翻译”它?

现代方案:Path2D 对象

现代浏览器提供了一个强大的 Path2D 对象,它就是为了解决这个问题而生的。Path2D 构造函数可以直接接收 SVG 的 d 属性字符串

  1. 创建 Path2D 对象:

    JavaScript

    const svgPathData = "M10 10 L100 100 C150 150 200 150 250 100 Z";
    const myPath = new Path2D(svgPathData);
    
  2. 渲染 Path2D 对象:

    一旦你有了 myPath 这个对象,Canvas 就可以直接使用它。这个对象已经“预编译”了所有路径指令

    JavaScript

    ctx.strokeStyle = 'blue';
    ctx.lineWidth = 3;
    ctx.stroke(myPath); // 描边这个路径
    
    ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
    ctx.fill(myPath); // 填充这个路径
    

Path2D 的优势:

  • 简洁: 无需手动解析字符串。
  • 高效: 浏览器底层负责解析和优化路径。
  • 可重用: 你可以创建一次 Path2D 对象,然后在不同的位置、以不同的样式反复绘制它,而无需重新定义路径

传统方案:手动解析(库的实现原理)

Path2D 出现之前,或者当需要支持旧版浏览器时,唯一的办法就是手动解析这个 d 字符串。

你需要写一个解析器,逐个读取命令 (M, L, C...) 和坐标,然后调用等效的 Canvas API:

JavaScript

function parseAndDraw(svgPathData) {
  // 这是一个极度简化的示例
  const commands = svgPathData.split(' '); // 简化的分割
  
  ctx.beginPath(); // **开始 Canvas 路径**
  
  for (let i = 0; i < commands.length; i++) {
    const cmd = commands[i];
    
    if (cmd === 'M') {
      const x = parseFloat(commands[++i]);
      const y = parseFloat(commands[++i]);
      ctx.moveTo(x, y); // 等效 M
    } else if (cmd === 'L') {
      const x = parseFloat(commands[++i]);
      const y = parseFloat(commands[++i]);
      ctx.lineTo(x, y); // 等效 L
    } else if (cmd === 'Z') {
      ctx.closePath(); // 等效 Z
    }
    // ... 还需要处理 C, S, Q, T, A 等等...
  }
  
  ctx.stroke(); // **渲染 Canvas 路径**
}

总结

Canvas 本身只是一个低级的“像素绘图仪”。它之所以能渲染高级内容,完全依赖于将这些内容“翻译”成它能理解的指令:

  • 渲染图片: 翻译成 drawImage(像素块传输)。
  • 渲染富文本: 翻译成 fillText(字形绘制),但布局和换行需要 JavaScript 手动计算
  • 渲染 SVG: 翻译成 drawImage(光栅化)或者解析其所有节点。
  • 渲染 SVG Path: 最佳翻译是使用 new Path2D(d) 将其转换成 Canvas 原生路径对象,然后再 strokefill

理解了这层“翻译”关系,就掌握了 Canvas 渲染万物的核心

如何设计一个 Canvas 事件系统?

HTML5 Canvas 是一个强大的绘图工具,它允许我们在网页上绘制复杂的图形、动画和游戏。然而,Canvas 与 DOM 或 SVG 不同,它本质上是一个“哑”画布,它只是一个像素缓冲区。你画上去的矩形、圆形或文本,在 Canvas 看来都只是像素点,它本身并不“知道”那里有一个“对象”。

这就带来了一个核心挑战:Canvas 元素本身只能监听到整个画布的鼠标事件(如 click, mousemove),但它无法告诉你用户具体点击了哪个图形。

要创建丰富的交互式体验(如拖拽图形、点击按钮、悬停提示),我们必须自己构建一个事件系统。这个系统需要在 Canvas 的像素之上,抽象出一个“对象”层,并管理这些对象上的事件。

1. 从“坐标”到“对象”

我们的目标是实现类似 DOM 的事件绑定:

JavaScript

// 我们想要这个
const myRect = new Rect({ x: 10, y: 10, width: 50, height: 50 });
stage.add(myRect);

myRect.on('click', (event) => {
  console.log('矩形被点击了!');
});

// 而不是这个
canvas.addEventListener('click', (e) => {
  const x = e.offsetX;
  const y = e.offsetY;
  // 手动检查 x, y 是否在 myRect 的范围内...
  // 如果有 1000 个图形怎么办?
});

要实现这一目标,我们的事件系统必须解决三个核心问题:

  1. 场景管理:如何跟踪画布上所有“对象”(图形)的位置和状态?
  2. 命中检测:当鼠标在 (x,y)(x, y) 坐标点发生事件时,如何快速判断哪个图形被“击中”了?
  3. 事件分发:当一个图形被击中时,如何触发它上面绑定的回调函数,并模拟事件冒泡等行为?

2. 系统架构

一个完整的 Canvas 事件系统通常包含以下几个核心组件:

2.1 场景图(Scene Graph)或显示列表(Display List)

这是我们管理所有图形的“数据库”。最简单的是一个数组(显示列表),按照绘制顺序存储所有图形对象。

JavaScript

// 简单的显示列表
const children = [shape1, shape2, shape3];

更高级的实现是一个树状结构(场景图),允许图形分组(Group),这对于实现事件冒泡和复杂的坐标变换至关重要。

每个图形对象(Shape)至少应包含:

  • 位置和尺寸属性(x, y, width, height, radius 等)。
  • 一个 draw(ctx) 方法,用于在 Canvas 上下文中绘制自己。
  • 一个 isPointInside(x, y) 方法,用于命中检测。

2.2 原生事件侦听器(Native Event Listener)

这是系统的入口。我们需要在 <canvas> 元素上绑定原生的浏览器事件。

JavaScript

class Stage {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.children = []; // 我们的显示列表

    this._initListeners();
  }

  _initListeners() {
    // 监听我们关心的所有事件
    this.canvas.addEventListener('click', this._handleEvent.bind(this));
    this.canvas.addEventListener('mousemove', this._handleEvent.bind(this));
    this.canvas.addEventListener('mousedown', this._handleEvent.bind(this));
    this.canvas.addEventListener('mouseup', this._handleEvent.bind(this));
    // 还可以包括 touchstart, touchend, touchmove 等
  }

  _handleEvent(e) {
    // 统一处理所有事件
    // ...
  }
}

2.3 命中检测(Hit Detection)

这是事件系统的核心算法。当 _handleEvent 被触发时,我们需要获取鼠标坐标,然后遍历我们的显示列表,找出哪个图形被“击中”了。

关键点:Z-Index(堆叠顺序)

Canvas 是“后来者居上”的。后绘制的图形会覆盖先绘制的。因此,当事件发生时,我们应该优先检测最上层(最后绘制)的图形。

这意味着我们应该反向遍历显示列表:

JavaScript

_handleEvent(e) {
  const x = e.offsetX;
  const y = e.offsetY;
  const eventType = e.type; // 'click', 'mousemove' 等

  let targetShape = null;

  // 从后向前遍历(最上层的图形最先被检测)
  for (let i = this.children.length - 1; i >= 0; i--) {
    const shape = this.children[i];

    if (shape.isPointInside(x, y)) {
      targetShape = shape;
      // 找到了!停止遍历
      break;
    }
  }

  if (targetShape) {
    // 找到了目标,现在分发事件
    this._dispatchEvent(targetShape, eventType, e);
  }
}

命中检测的策略

isPointInside(x, y) 方法的实现方式各不相同:

  1. 几何算法(AABB)

    • 矩形(Axis-Aligned Bounding Box, AABB) :最简单的。

      JavaScript

      isPointInside(x, y) {
        return x >= this.x && x <= this.x + this.width &&
               y >= this.y && y <= this.y + this.height;
      }
      
    • 圆形:计算点到圆心的距离。

      JavaScript

      isPointInside(x, y) {
        const dx = x - this.x; // this.x, this.y 是圆心
        const dy = y - this.y;
        return (dx * dx + dy * dy) <= (this.radius * this.radius);
      }
      
    • 多边形:通常使用 射线投射算法(Ray-casting Algorithm)

  2. Canvas API 路径检测(isPointInPath)

    Canvas 2D 上下文提供了一个强大的方法:isPointInPath(x, y) 和 isPointInStroke(x, y)。

    它允许你“重播”一个图形的绘制路径,然后询问浏览器某个点是否在该路径的内部或描边上。

    JavaScript

    isPointInside(x, y) {
      // 必须在传入的 context 上下文中重绘路径
      // 注意:这里我们不能真的 "draw",只是 "build path"
      this.ctx.beginPath();
      this.ctx.rect(this.x, this.y, this.width, this.height);
      // ... 或者 arc, moveTo, lineTo 等
      this.ctx.closePath();
    
      return this.ctx.isPointInPath(x, y);
    }
    
    • 优点:极其精确,能处理任何复杂的路径(贝塞尔曲线、不规则多边形等),甚至可以检测描边。
    • 缺点:可能存在性能开销,因为它需要重新构建路径(尽管不实际渲染像素)。

性能提示:在大型场景中(数千个对象),mousemove 事件上的命中检测开销巨大。一个常见的优化是:

  1. 先进行快速的**包围盒(Bounding Box)**检测。

  2. 如果包围盒命中,再进行精确的(如 isPointInPath)检测。

  3. 对于非常大的场景,使用**空间索引(如 Quadtree 四叉树)**来快速剔除不在鼠标附近的

    对象,避免遍历整个列表。

2.4 事件分发器(Event Dispatcher)

当我们通过命中检测找到了 targetShape 后,需要一种机制来“触发”该图形上的自定义事件。这通常通过在 Shape 基类上实现一个简单的“发布-订阅”模式来完成。

JavaScript

// 在 Shape 基类中添加
class Shape {
  constructor() {
    this._listeners = {}; // 存储事件回调
  }

  // 订阅事件
  on(eventType, callback) {
    if (!this._listeners[eventType]) {
      this._listeners[eventType] = [];
    }
    this._listeners[eventType].push(callback);
  }

  // 发布事件
  fire(eventType, eventObject) {
    const callbacks = this._listeners[eventType];
    if (callbacks) {
      callbacks.forEach(callback => {
        callback(eventObject);
      });
    }
  }
}

现在,我们完善 Stage 类中的 _dispatchEvent 方法:

JavaScript

// 在 Stage 类中
_dispatchEvent(targetShape, eventType, nativeEvent) {
  // 我们可以创建一个自定义的事件对象,封装更多信息
  const customEvent = {
    target: targetShape,      // 触发事件的原始图形
    nativeEvent: nativeEvent, // 原始浏览器事件
    x: nativeEvent.offsetX,
    y: nativeEvent.offsetY,
    // ... 其他需要的信息
  };

  // 直接在目标图形上触发事件
  targetShape.fire(eventType, customEvent);
}

3. 进阶功能

一个基础的事件系统已经成型,但要实现如 DOM 般强大的交互,我们还需要更多功能。

3.1 事件冒泡(Event Bubbling)

在 DOM 中,点击一个子元素,事件会向上传播到父元素。如果我们的场景图(Scene Graph)是一个树状结构(Group 包含 Shape),我们也应该模拟这个行为。

targetShape 被击中时,我们不仅要 targetShape.fire(),还应该沿着它的 parent 链向上,依次触发父级的事件,直到根节点(Stage)或者事件被停止。

JavaScript

// 在 _dispatchEvent 中
_dispatchEvent(targetShape, eventType, nativeEvent) {
  const customEvent = {
    target: targetShape,
    nativeEvent: nativeEvent,
    // ...
    _stopped: false, // 冒泡停止标记
    stopPropagation: function() {
      this._stopped = true;
    }
  };

  let currentTarget = targetShape;
  while (currentTarget && !customEvent._stopped) {
    // 触发当前目标上的事件
    currentTarget.fire(eventType, customEvent);
    // 移动到父级
    currentTarget = currentTarget.parent;
  }
}

3.2 拖拽事件(Drag and Drop)

拖拽不是一个单一事件,而是一个事件序列:mousedown -> mousemove -> mouseup

我们需要在 Stage 层面管理拖拽状态:

JavaScript

// 在 Stage 类中添加
_initListeners() {
  // ... 其他事件
  this.canvas.addEventListener('mousedown', this._onMouseDown.bind(this));
  this.canvas.addEventListener('mousemove', this._onMouseMove.bind(this));
  this.canvas.addEventListener('mouseup', this._onMouseUp.bind(this));

  this._draggingTarget = null; // 当前正在拖拽的对象
  this._dragStartPos = { x: 0, y: 0 }; // 拖拽起始位置
}

_onMouseDown(e) {
  const target = this._findHitTarget(e.offsetX, e.offsetY);
  if (target) {
    // 检查图形是否可拖拽 (e.g., shape.draggable = true)
    if (target.draggable) {
      this._draggingTarget = target;
      this._dragStartPos.x = e.offsetX - target.x;
      this._dragStartPos.y = e.offsetY - target.y;

      // 分发 'dragstart' 事件
      this._dispatchEvent(target, 'dragstart', e);
    }
    // 分发 'mousedown' 事件
    this._dispatchEvent(target, 'mousedown', e);
  }
}

_onMouseMove(e) {
  if (this._draggingTarget) {
    // 如果正在拖拽
    const target = this._draggingTarget;
    target.x = e.offsetX - this._dragStartPos.x;
    target.y = e.offsetY - this._dragStartPos.y;

    // 分发 'drag' 事件
    this._dispatchEvent(target, 'drag', e);
    
    // 拖拽时需要重绘画布
    this.render(); 
  } else {
    // 正常的 mousemove 命中检测
    // ...
  }
}

_onMouseUp(e) {
  if (this._draggingTarget) {
    // 分发 'dragend' 事件
    this._dispatchEvent(this._draggingTarget, 'dragend', e);
    this._draggingTarget = null; // 停止拖拽
  }
  
  // 正常的 'mouseup' 命中检测
  const target = this._findHitTarget(e.offsetX, e.offsetY);
  if (target) {
    this._dispatchEvent(target, 'mouseup', e);
  }
  // 触发 click 事件的逻辑也可以在这里处理
}

3.3 mouseentermouseleave 事件

这两个事件比 mousemove 更复杂,因为它们需要状态。你需要跟踪上一帧鼠标悬停在哪个对象上。

JavaScript

// 在 Stage 类中添加
_lastHoveredTarget = null;

_onMouseMove(e) {
  // ... 拖拽逻辑优先 ...
  
  const currentHoveredTarget = this._findHitTarget(e.offsetX, e.offsetY);

  if (this._lastHoveredTarget !== currentHoveredTarget) {
    // 鼠标移出了上一个目标
    if (this._lastHoveredTarget) {
      this._dispatchEvent(this._lastHoveredTarget, 'mouseleave', e);
    }
    // 鼠标移入了新目标
    if (currentHoveredTarget) {
      this._dispatchEvent(currentHoveredTarget, 'mouseenter', e);
    }
    // 更新状态
    this._lastHoveredTarget = currentHoveredTarget;
  }
  
  // 始终分发 mousemove
  if (currentHoveredTarget) {
      this._dispatchEvent(currentHoveredTarget, 'mousemove', e);
  }
}
❌