Read OSS

从 new Chart() 到像素:图表生命周期与更新流水线

中级

前置知识

  • 第 1 篇:架构总览
  • 对 HTML5 Canvas API 有基本了解
  • 熟悉 requestAnimationFrame

从 new Chart() 到像素:图表生命周期与更新流水线

在第 1 篇中,我们了解了 Chart.js 的整体组织结构。本篇将沿着代码库中最核心的执行路径,追踪从调用 new Chart(canvas, config) 到第一批像素出现在屏幕上的完整过程。core.controller.js 中的 Chart 类是整个系统的中枢调度者——这个约 1250 行的文件将 Config、Platform、PluginService、DatasetController、Scale 和 Layout 协调成一个连贯的更新-渲染循环。

Chart 构造函数:初始化一个 Chart 实例

构造函数位于 core.controller.js 第 123 行,在单次同步调用中完成了大量工作:

src/core/core.controller.js#L103-L195

sequenceDiagram
    participant Dev as Developer
    participant Chart as Chart Constructor
    participant Config as Config
    participant Platform as Platform
    participant Animator as Animator
    participant Init as _initialize()

    Dev->>Chart: new Chart(canvas, config)
    Chart->>Config: new Config(userConfig)
    Chart->>Chart: getCanvas(item)
    Chart->>Chart: Check for existing chart on canvas
    Chart->>Config: createResolver(chartOptionScopes())
    Chart->>Platform: new (detectPlatform())
    Chart->>Platform: acquireContext(canvas)
    Chart->>Chart: Assign id, ctx, canvas, dimensions
    Chart->>Chart: new PluginService()
    Chart->>Chart: Store in instances[this.id]
    Chart->>Animator: listen(this, 'complete', ...)
    Chart->>Animator: listen(this, 'progress', ...)
    Chart->>Init: _initialize()
    Chart->>Chart: update() if attached

这里有几处细节值得关注。

首先,第 66 行的 instances 对象是一个普通对象(而非 WeakMap),以 uid() 生成的数字 ID 作为键。这意味着每个 Chart 实例都会一直保持可达状态,直到显式调用 destroy() 为止——在 SPA 中忘记清理是内存泄漏的常见来源。

其次,构造函数会立即检查 canvas 上是否已存在图表(getChart(initialCanvas))。若存在,则直接抛出错误,而不是静默覆盖。这一防御性检查能避免两个 Chart 实例共享同一个 canvas 时引发的隐蔽 bug。

第三,PluginService 实例是按图表创建的(this._plugins = new PluginService()),并非全局共享。这样,同一页面上的不同图表可以拥有各自独立的插件配置。

Platform 检测与抽象

Chart.js 通过 Platform 类抽象渲染环境。检测逻辑简洁优雅:

src/platform/index.js#L6-L11

flowchart TD
    START["_detectPlatform(canvas)"] --> DOM_CHECK{"_isDomSupported()?"}
    DOM_CHECK -->|No| BASIC["BasicPlatform<br/>(Node.js / Workers)"]
    DOM_CHECK -->|Yes| OFFSCREEN{"canvas instanceof OffscreenCanvas?"}
    OFFSCREEN -->|Yes| BASIC
    OFFSCREEN -->|No| DOM["DomPlatform<br/>(Browser)"]

DomPlatform 负责处理 CSS 与物理像素的比例换算、通过 ResizeObserver 监听尺寸变化,以及 DOM 事件规范化——包括将 touch/pointer 事件映射到 Chart.js 基于鼠标的事件模型:

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

BasicPlatform 则为事件绑定和尺寸监听提供空实现,让 Chart.js 得以在 Web Worker 和 Node.js 环境(配合 node-canvas 等 Canvas polyfill)中正常运行。

提示: 你可以通过在 config 中传入 platform 类来完全跳过自动检测:new Chart(canvas, { platform: MyCustomPlatform, ... })。这在测试或自定义渲染环境中非常有用。

update() 流水线:从配置到 Canvas

update() 方法是 Chart.js 的核心。每次数据变更、选项修改或尺寸调整都会流经这个约 62 行的方法:

src/core/core.controller.js#L475-L537

flowchart TD
    A["config.update()"] --> B["Resolve chart options"]
    B --> C["_updateScales()<br/>Remove old → ensure IDs → build/update"]
    C --> D["_checkEventBindings()"]
    D --> E["plugins.invalidate()"]
    E --> F{"beforeUpdate hook<br/>cancelable?"}
    F -->|cancelled| STOP["Return"]
    F -->|proceed| G["buildOrUpdateControllers()"]
    G --> H["beforeElementsUpdate hook"]
    H --> I["buildOrUpdateElements<br/>for each dataset"]
    I --> J["_updateLayout(minPadding)"]
    J --> K["Reset new controllers<br/>(animation start points)"]
    K --> L["_updateDatasets(mode)"]
    L --> M["afterUpdate hook"]
    M --> N["Sort layers by z-index"]
    N --> O["Replay last event"]
    O --> P["render()"]

操作顺序是经过刻意设计的,不能随意调换。Scale 必须在 controller 之前构建,因为 controller 需要 scale 来确定坐标轴映射;controller 必须在 layout 之前构建,因为 layout 引擎需要知道当前存在哪些盒子(scale、legend 等);新 controller 的重置则在 layout 之后进行,因为重置时需要确定动画起始点,而起始点依赖最终的 scale 位置。

mode 参数(如 'resize''reset''active')会向下传递给 controller,让它们能够针对特定更新路径进行优化。例如,在 resize 时,controller 可以跳过数据重新解析,只重新计算元素位置。

Layout 引擎与盒模型

core.layouts.js 中的 layout 系统实现了一套迭代式约束求解算法。每个需要占用空间的可视组件——scale、legend、title——都实现了 LayoutItem 接口,并被视为一个待定位的"盒子":

src/core/core.layouts.js#L345-L454

flowchart TD
    START["layouts.update(chart, width, height)"] --> BUILD["buildLayoutBoxes(chart.boxes)"]
    BUILD --> NOTIFY["Notify boxes: beforeLayout()"]
    NOTIFY --> PARAMS["Compute params:<br/>padding, availableWidth, availableHeight"]
    PARAMS --> FULL["fitBoxes(fullSize boxes)"]
    FULL --> VERT["fitBoxes(vertical boxes)"]
    VERT --> HORIZ["fitBoxes(horizontal boxes)"]
    HORIZ --> CHANGED{"Chart area changed?"}
    CHANGED -->|Yes| REFIT["Re-fit vertical boxes"]
    CHANGED -->|No| PAD["handleMaxPadding"]
    REFIT --> PAD
    PAD --> PLACE_LT["placeBoxes(leftAndTop)"]
    PLACE_LT --> PLACE_RB["placeBoxes(rightAndBottom)"]
    PLACE_RB --> AREA["Set chart.chartArea"]
    AREA --> CHARTAREA_BOXES["Update chartArea boxes<br/>(e.g., radial scale)"]

关键在于第 179 行的递归 fitBoxes 函数。每个盒子接收可用的宽高并调用 update(),返回的实际尺寸随即从图表区域中扣除。若水平方向的盒子适配改变了可用宽度,垂直方向的盒子会重新进行适配。由于每轮迭代只会缩小可用空间,因此算法必然收敛。

源码第 369–389 行的 ASCII art 注释值得一读——它用图示清晰说明了 T1、L1、L2、R1、B1、B2 与 ChartArea 之间的视觉布局关系。

渲染:draw() 方法与图层系统

到了实际绘制阶段,Chart.js 将调度render() 方法)与绘制draw() 方法)明确分离:

src/core/core.controller.js#L683-L732

render() 首先检查是否有动画正在进行。如果有,则委托给 Animator 单例,由它在每一帧调用 draw();如果没有待执行的动画,则直接调用 draw()

draw() 方法实现了一套基于 z-index 的分层渲染系统:

sequenceDiagram
    participant Draw as draw()
    participant Layers as Layer Stack
    participant Datasets as _drawDatasets()
    participant Canvas as Canvas Context

    Draw->>Draw: Handle pending resize
    Draw->>Canvas: clear()
    Draw->>Draw: beforeDraw plugin hook
    loop Layers where z ≤ 0
        Draw->>Canvas: layer.draw(chartArea)
    end
    Draw->>Datasets: _drawDatasets()
    Note over Datasets: Back-to-front by order/index
    loop Layers where z > 0
        Draw->>Canvas: layer.draw(chartArea)
    end
    Draw->>Draw: afterDraw plugin hook

每个 scale 都提供自己的 _layers() 方法,返回带有 z-index 的图层对象。网格线通常使用 z: -1(绘制在数据集后面),刻度标签使用 z: 0。这样,scale 组件就能与数据集渲染自然交织,无需任何特殊处理。

数据集本身按从后向前的顺序绘制(从 metasets.length - 10),索引越大的数据集越显示在上层。每个数据集在绘制前都会被裁剪到其 scale 边界范围内,防止数据点渲染到图表区域之外。

Animator 单例

Animator 负责为页面上所有图表驱动 requestAnimationFrame 循环:

src/core/core.animator.js#L12-L214

sequenceDiagram
    participant Chart as Chart.render()
    participant Animator as Animator Singleton
    participant RAF as requestAnimationFrame
    participant Anim as Animation Items

    Chart->>Animator: start(chart)
    Animator->>Animator: anims.running = true
    Animator->>RAF: _refresh() → requestAnimFrame
    RAF->>Animator: _update(date)
    loop Each chart in _charts Map
        loop Each active animation item
            Animator->>Anim: item.tick(date)
        end
        Animator->>Chart: chart.draw()
        Animator->>Animator: _notify(chart, 'progress')
    end
    Note over Animator: If items remain, schedule next frame
    Animator->>RAF: _refresh() (loop)
    Note over Animator: When items empty → _notify 'complete'

Animator 使用 Map<Chart, AnimState> 来跟踪每个图表的动画状态。调用 start() 时,它将 running 置为 true,并通过 _refresh() 启动 rAF 循环。_update() 方法遍历所有图表,推进各自的动画进度,并对有活跃动画的图表调用 chart.draw()

已完成的动画项采用了一种高效的移除策略:不使用 splice,而是用最后一个元素替换当前项,再调用 pop()——这是 O(1) 操作,优于 splice 的 O(n)。

销毁与清理

destroy() 方法将构造函数的所有操作逆序还原:

src/core/core.controller.js#L937-L955

sequenceDiagram
    participant Dev as Developer
    participant Chart as Chart
    participant Plugins as PluginService
    participant Animator as Animator
    participant Platform as Platform

    Dev->>Chart: destroy()
    Chart->>Plugins: notifyPlugins('beforeDestroy')
    Chart->>Chart: _stop() → animator.stop + remove
    Chart->>Chart: Destroy all dataset metas
    Chart->>Chart: config.clearCache()
    Chart->>Chart: unbindEvents()
    Chart->>Chart: clearCanvas()
    Chart->>Platform: releaseContext()
    Chart->>Chart: canvas = null, ctx = null
    Chart->>Chart: delete instances[this.id]
    Chart->>Plugins: notifyPlugins('afterDestroy')
    Note over Plugins: Also triggers 'stop' and 'uninstall'

执行顺序经过精心安排:插件在清理之前收到通知(以便访问图表状态),也在清理之后收到通知(以便释放自身资源)。_stop() 调用会停止所有动画并将图表从 Animator 的 map 中移除。最后,图表从全局 instances 对象中删除,从而满足垃圾回收的条件。

提示: 在单页应用中,务必在组件的清理生命周期中调用 chart.destroy()——例如 React 的 useEffect 返回函数,或 Vue 的 onUnmounted。若忘记调用,canvas context、事件监听器以及图表实例本身都将发生泄漏。

衔接下一篇

我们已经看到 update() 会调用 config.createResolver(config.chartOptionScopes(), ...) 来解析图表选项。但这个 resolver 内部究竟发生了什么?下一篇将深入 Chart.js 最复杂的子系统:基于 JavaScript Proxy 构建的多层选项解析引擎,包括其作用域链、可脚本化选项,以及递归回退路由。