从 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 类抽象渲染环境。检测逻辑简洁优雅:
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 - 1 到 0),索引越大的数据集越显示在上层。每个数据集在绘制前都会被裁剪到其 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 构建的多层选项解析引擎,包括其作用域链、可脚本化选项,以及递归回退路由。