Read OSS

让图表活起来:动画系统、事件处理与交互

高级

前置知识

  • 第 1-5 篇文章
  • 了解 requestAnimationFrame 与浏览器事件模型
  • 熟悉缓动函数与插值的基本概念

让图表活起来:动画系统、事件处理与交互

静态图表只是一张图片,而具备动画与交互的图表则是一个真正的应用。本系列的最后一篇文章将介绍赋予 Chart.js 图表生命力的三个系统:在状态之间平滑过渡的三层动画架构、将 DOM 事件转化为悬停状态的事件处理管道,以及保持一切流畅响应的性能优化机制。这三个系统相互关联——动画完成会触发悬停状态的重新计算,而交互决策也依赖于元素是否已到达最终位置。

三层动画架构

Chart.js 将动画职责拆分为三个独立的类,每个类承担明确定义的职责:

classDiagram
    class Animation {
        -_active: boolean
        -_fn: InterpolatorFunction
        -_easing: EasingFunction
        -_start: number
        -_duration: number
        -_from: any
        -_to: any
        -_target: object
        -_prop: string
        +tick(date)
        +cancel()
        +wait(): Promise
    }

    class Animations {
        -_chart: Chart
        -_properties: Map
        +configure(config)
        +update(target, values): Animation[]
        -_createAnimations(target, values)
        -_animateOptions(target, values)
    }

    class Animator {
        -_charts: Map~Chart, AnimState~
        -_running: boolean
        -_request: number
        +listen(chart, event, cb)
        +add(chart, items)
        +start(chart)
        +stop(chart)
        -_refresh()
        -_update(date)
    }

    Animator "1" -- "*" Animations : coordinates
    Animations "1" -- "*" Animation : creates/manages
    Animation --> Element : writes to target[prop]

Animation 负责处理单个属性的过渡动画;Animations 管理一个图表中所有待执行的动画;Animator 则是驱动 requestAnimationFrame 循环的全局单例。

这种分层设计意味着,新增一个可动画属性时,无需改动帧循环或编排逻辑——只需在 Animations 层进行配置即可。

单属性动画与插值

Animation 类的核心是一个带有缓动效果的属性插值器:

src/core/core.animation.js#L1-L119

第 28 行的构造函数负责初始化动画:解析 fromto 的值,根据值的类型选择对应的插值函数,并配置缓动效果:

flowchart TD
    INIT["new Animation(cfg, target, prop, to)"] --> TYPE{"Determine type"}
    TYPE -->|"typeof from === 'number'"| NUM["interpolators.number<br/>from + (to - from) * factor"]
    TYPE -->|"from is color string"| COLOR["interpolators.color<br/>RGBA space mixing via @kurkle/color"]
    TYPE -->|"typeof from === 'boolean'"| BOOL["interpolators.boolean<br/>factor > 0.5 ? to : from"]
    TYPE -->|"custom cfg.fn"| CUSTOM["User-provided function"]

    NUM --> EASING["Apply easing: effects[cfg.easing]"]
    COLOR --> EASING
    BOOL --> EASING
    CUSTOM --> EASING

第 76 行的 tick() 方法由 Animator 在每一帧调用,它计算已过去的时间,应用缓动函数,并将插值结果直接写入目标对象:this._target[prop] = this._fn(from, to, factor)。元素属性是原地修改的,没有虚拟 DOM,也不存在 diff 计算。

颜色插值使用 @kurkle/color 库(Chart.js 唯一的运行时依赖)在 RGBA 空间中进行混合。插值器将两种颜色转换为 Color 对象,调用 mix(),并返回十六进制字符串,从而实现视觉上平滑的颜色过渡。

第 105 行的 wait() 方法返回一个 Promise,在动画完成时 resolve。Animations._animateOptions() 中的共享选项优化会用到这个方法——在切换到共享选项时,系统会等待所有选项动画完成后,再切换到共享引用。

Animator 单例与帧循环

Animator 统一管理所有图表的 requestAnimationFrame 循环:

src/core/core.animator.js#L38-L107

sequenceDiagram
    participant Chart1 as Chart A
    participant Chart2 as Chart B
    participant Anim as Animator
    participant RAF as Browser rAF

    Chart1->>Anim: start(chartA)
    Anim->>Anim: _refresh()
    Anim->>RAF: requestAnimationFrame
    Chart2->>Anim: start(chartB)
    Note over Anim: No new rAF needed<br/>(already running)

    RAF->>Anim: _update(timestamp)
    Anim->>Anim: Tick Chart A animations
    Anim->>Chart1: chartA.draw()
    Anim->>Anim: Tick Chart B animations
    Anim->>Chart2: chartB.draw()

    alt Animations remaining
        Anim->>RAF: _refresh() → next frame
    else All complete
        Anim->>Anim: _running = false
        Anim->>Chart1: notify 'complete'
        Anim->>Chart2: notify 'complete'
    end

这里有一个关键设计:第 38 行的 _refresh() 方法在调度新帧之前会检查 if (this._request) return。这意味着无论有多少图表调用 start(),始终只有一个 requestAnimationFrame 处于 pending 状态。所有图表都被批量合并到同一个帧回调中,比各自独立维护 rAF 循环更加高效。

第 57 行的 _update() 循环遍历 _charts Map,依次推进每个图表的动画。当某个动画项的 _active 标志变为 false 时,采用 swap-and-pop 技术(第 83-84 行)将其移除,而非使用 splice(),确保删除操作的时间复杂度为 O(1)。

当一个图表的动画项数组清空后,complete 回调随即触发。正如第 2 篇文章所介绍的,该回调即 onAnimationsComplete,它会调用 chart.notifyPlugins('afterRender') 和用户配置的 animation.onComplete 回调。

事件处理管道:从 DOM 到图表

事件经过多个阶段,从浏览器 DOM 流转至 Chart.js 的交互解析层:

src/core/core.controller.js#L976-L992

sequenceDiagram
    participant DOM as Browser DOM
    participant Platform as DomPlatform
    participant Chart as Chart
    participant Plugins as PluginService
    participant Interaction as Interaction Module
    participant Hover as Hover Styles

    DOM->>Platform: mousemove event
    Platform->>Platform: Map touch/pointer → mouse type
    Platform->>Chart: listener(e, offsetX, offsetY)
    Chart->>Chart: _eventHandler(e)
    Chart->>Chart: isPointInArea(e)?
    Chart->>Plugins: beforeEvent hook (cancelable)
    Chart->>Chart: _handleEvent(e, replay, inChartArea)
    Chart->>Interaction: getElementsAtEventForMode()
    Interaction-->>Chart: active elements[]
    Chart->>Chart: onHover callback
    Chart->>Chart: onClick callback (if click)
    Chart->>Hover: _updateHoverStyles(active, lastActive)
    Chart->>Plugins: afterEvent hook
    Chart->>Chart: render() if changed

第 976 行的 bindUserEvents() 方法遍历 this.options.events(默认包括:mousemovemouseoutclicktouchstarttouchmove),并通过 platform 为每个事件注册监听器。platform 使用 EVENT_TYPES 映射表将触摸和指针事件统一规范化为鼠标事件:

src/platform/platform.dom.js#L21-L31

提示: 通过设置 options.events,可以自定义 Chart.js 监听的事件类型。如果不需要触摸支持,去掉 touchstarttouchmove 可以减少事件处理器的开销。如果只需要点击交互,直接设置 events: ['click'] 即可。

交互模式与命中测试

Interaction 模块提供了六种内置模式,用于根据鼠标位置判断哪些元素处于"激活"状态:

src/core/core.interaction.js#L22-L64

模式 行为
point 包含鼠标位置的元素
nearest 距鼠标位置最近的单个元素
index 同一索引处的所有元素(垂直切片)
dataset 最近数据集中的所有元素
x 相同 x 位置上的所有元素
y 相同 y 位置上的所有元素
flowchart TD
    EVENT["Mouse position (x, y)"] --> MODE{"Interaction mode?"}
    MODE -->|nearest| BINARY["binarySearch()<br/>Narrow candidate range"]
    MODE -->|index| INDEX["Find closest index<br/>then all elements at that index"]
    MODE -->|point| POINT["Test inRange() on<br/>all elements"]

    BINARY --> EVAL["evaluateInteractionItems()"]
    EVAL --> DIST["Calculate distances"]
    DIST --> ACTIVE["Return active elements"]

第 22 行的 binarySearch() 函数是关键的性能优化点。对于数据按索引轴排序的数据集(这是最常见的情况),它使用 _lookupByKey 执行二分搜索,将命中测试的时间复杂度从 O(n) 降低至 O(log n)。对于包含 10,000 个数据点的图表,这意味着只需约 14 次比较,而非 10,000 次。

二分搜索还针对共享选项做了额外优化:当 controller._sharedOptions 已设置时,系统知道所有元素的比例相同,可以用第一个元素的 getRange() 确定命中测试边界,并通过范围式二分搜索在单次遍历中找到所有可能相交的元素。

Replay 机制与动画感知悬停

_handleEvent() 方法中隐藏着 Chart.js 最微妙却至关重要的一个 UX 细节:

src/core/core.controller.js#L1196-L1238

sequenceDiagram
    participant Update as chart.update()
    participant Handler as _eventHandler
    participant Elements as Elements
    participant Hover as Hover Styles

    Note over Update: Data changes, elements will animate
    Update->>Update: _lastEvent exists?
    Update->>Handler: _eventHandler(lastEvent, replay=true)
    Handler->>Elements: getActiveElements(useFinalPosition=true)
    Note over Elements: getProps() returns animation<br/>target values, not current
    Elements-->>Handler: Active elements at final positions
    Handler->>Hover: Apply hover styles to final-position elements
    Note over Handler: User sees hover on correct<br/>elements even during animation

update() 完成整个管道后,会在第 531 行以 replay = true 的方式重放上一次的鼠标事件。这次重放调用 _handleEvent(e, true),在查询激活元素时将 useFinalPosition 设为 true。正如第 5 篇文章所探讨的,带有 final = true 参数的 Element.getProps() 会返回动画的目标值。

源码第 1199-1211 行的注释解释了这样设计的原因:在每一个动画帧都重新计算激活元素代价昂贵,而且动画过程中元素尚未到达最终位置。通过在 update() 之后基于最终位置一次性完成计算,Chart.js 既能获得正确的悬停状态,又避免了逐帧重复计算。

第 93 行的 determineLastEvent() 辅助函数处理边界情况:mouseout 事件会清除上次记录的事件(鼠标离开时不显示悬停),而点击事件则保留上一次的事件记录(点击不应改变存储的悬停位置)。

性能模式:共享选项与数据抽稀

有两种性能模式值得特别关注,因为它们横跨多个系统:

共享选项

DatasetController 解析元素选项时,如果发现 $shared: true(来自配置引擎中的 needContext() 优化——详见第 3 篇),会将该选项对象存储为 this._sharedOptions,数据集中的所有元素共享同一个对象引用。

这带来了连锁的收益:

  1. 内存:从 N 个对象减少为 1 个对象(对大型数据集效果显著)
  2. 动画Animations._animateOptions() 检测到共享选项后,会通过 Promise.all 等待所有待执行动画完成,再进行引用切换
  3. 交互core.interaction.js 中的二分搜索优化依赖 _sharedOptions 来判断所有元素是否具有相同的比例

LTTB 数据抽稀

Decimation 插件中的 LTTB 算法(src/plugins/plugin.decimation.js#L3-L79)在保留视觉形态的同时大幅减少数据点数量:

flowchart TD
    DATA["10,000 data points"] --> CHECK{"points > available pixels?"}
    CHECK -->|No| PASS["Use all points"]
    CHECK -->|Yes| BUCKET["Divide into N buckets<br/>(N ≈ canvas width in pixels)"]
    BUCKET --> TRIANGLE["For each bucket:<br/>Find point forming largest<br/>triangle with neighbors"]
    TRIANGLE --> RESULT["~800 representative points"]
    RESULT --> RENDER["Render reduced dataset"]

该算法将数据划分为若干桶,计算下一个桶的平均点,然后从当前桶中选出与前一个已选点和下一个桶均值构成面积最大三角形的点。这样在大幅减少数据点数量的同时,能够完整保留峰值、谷值和趋势走向。

第 57 行将 maxArea = area = -1 而非原始算法的 1 进行初始化,这是 Chart.js 针对特定场景的 bug 修复:对于所有三角形面积均为零的平坦曲线,原始代码将永远无法设置 nextA,导致下一次迭代时发生崩溃。

系列总结

在这六篇文章中,我们从模块组织结构出发,追踪了 Chart.js 完整的数据到像素处理流程。我们了解了三个精心设计的入口点如何服务于不同的使用模式(第 1 篇),Chart 构造函数如何编排多阶段更新管道(第 2 篇),基于 Proxy 的选项解析器如何支持级联默认值、可脚本化选项和实时回退路由(第 3 篇),Registry 和 Plugin 系统如何在不为内置组件开特例的前提下实现可扩展性(第 4 篇),Scale、Element 和 Controller 如何协作将数据转化为 Canvas 几何图形(第 5 篇),以及动画与交互系统如何让一切鲜活起来(第 6 篇)。

贯穿始终的设计理念是统一性与组合性:内置图表类型是注册组件,而非享有特权的代码;选项解析是通用的作用域遍历算法,而非针对特定图表类型的逻辑;动画系统对属性类型无感知,能够处理任何可插值的值。正是这种统一性,让 Chart.js 具备了真正的可扩展性——不依赖特殊的逃生舱口或覆盖机制,而是完全通过库内部所使用的相同机制实现。