可扩展性设计:注册表、插件系统与组件生命周期
前置知识
- ›第 1-3 篇文章
- ›理解 JavaScript 原型链
- ›了解观察者/钩子模式的基本概念
可扩展性设计:注册表、插件系统与组件生命周期
Chart.js 开箱即支持柱状图、折线图、环形图、雷达图、极坐标图、气泡图、散点图和饼图——但这些图表类型在系统内部并没有任何"特权"。它们全都是已注册的组件,与你自己创建的第三方图表类型地位完全相同。这种统一性来自两个核心抽象:Registry(负责组件发现和默认值合并)和 PluginService(负责协调生命周期钩子)。两者共同构成了 Chart.js 的可扩展性基础。
Registry 与 TypedRegistry 的架构设计
Registry 单例内部维护四个 TypedRegistry 实例,分别对应四种组件类别:
src/core/core.registry.js#L11-L19
classDiagram
class Registry {
+controllers: TypedRegistry~DatasetController~
+elements: TypedRegistry~Element~
+plugins: TypedRegistry~Object~
+scales: TypedRegistry~Scale~
-_typedRegistries: TypedRegistry[]
+add(...args)
+remove(...args)
-_each(method, args, typedRegistry?)
-_getRegistryForType(type)
}
class TypedRegistry {
+type: Constructor
+scope: string
+override: boolean
+items: Record~string, Component~
+isForType(type): boolean
+register(item): string
+unregister(item)
+get(id): Component
}
Registry "1" *-- "4" TypedRegistry
当调用 Chart.register(BarController, LinearScale, BarElement) 时,Registry 需要判断每个参数应该归属哪个 TypedRegistry。第 161 行的 _getRegistryForType() 方法会遍历 _typedRegistries,依次调用每个实例的 isForType():
src/core/core.registry.js#L161-L170
isForType() 的判断依赖原型链检查:Object.prototype.isPrototypeOf.call(this.type.prototype, type.prototype)。因此,BarController 会匹配 controllers 注册表,因为 DatasetController.prototype 存在于其原型链中;LinearScale 会匹配 scales 注册表,因为 Scale.prototype 在其原型链中。
_typedRegistries 的顺序至关重要:[controllers, scales, elements]。由于 Scale 继承自 Element,scales 必须在 elements 之前检查,否则每个 scale 都会被错误地注册为 element。plugins 是最后的兜底——任何不匹配前三个注册表的组件都会被视为插件。
第 124 行的 _each() 方法还支持处理可遍历参数——当你传入类似 import * as controllers 这样的命名空间对象时,它会遍历对象的所有值并逐一注册:
src/core/core.registry.js#L124-L146
组件注册:默认值合并与路由配置
通过 TypedRegistry.register() 注册一个组件时,会依次发生以下几件事:
src/core/core.typedRegistry.js#L8-L53
flowchart TD
REG["TypedRegistry.register(BarController)"] --> PROTO["Walk prototype chain"]
PROTO --> PARENT{"Parent has id & defaults?"}
PARENT -->|Yes| REC["Recursively register parent first"]
PARENT -->|No| CONT["Continue"]
REC --> CONT
CONT --> CHECK{"Already registered?"}
CHECK -->|Yes| SCOPE["Return existing scope"]
CHECK -->|No| STORE["Store in items[id]"]
STORE --> MERGE["registerDefaults:<br/>merge parent defaults + existing + item.defaults"]
MERGE --> ROUTE{"Has defaultRoutes?"}
ROUTE -->|Yes| ROUTES["routeDefaults:<br/>call defaults.route() for each"]
ROUTE -->|No| DESC{"Has descriptors?"}
ROUTES --> DESC
DESC -->|Yes| DESCRIBE["defaults.describe(scope, descriptors)"]
DESC -->|No| OVERRIDE{"Registry has override flag?"}
DESCRIBE --> OVERRIDE
OVERRIDE -->|Yes| OVERRIDES["defaults.override(id, item.overrides)"]
OVERRIDE -->|No| DONE["Done"]
OVERRIDES --> DONE
第 84 行的 registerDefaults() 函数执行三路合并:
src/core/core.typedRegistry.js#L84-L101
- 父级作用域的默认值(例如
DatasetController.defaults) - 目标作用域中已有的默认值
- 组件自身的
defaults属性
这意味着默认值会沿着原型链向下传递。BarController 继承 DatasetController 的默认值,并可以覆盖其中的特定配置项。随后,第 103 行的 routeDefaults() 会通过 defaults.route() 建立实时的回退链,这一机制在第 3 篇文章中已有详细介绍。
提示: 创建自定义图表类型时,使用
static defaults = { ... }定义基础配置,使用static defaultRoutes = { backgroundColor: 'color' }指定需要回退到全局默认值的属性。这样可以确保你的组件与主题系统无缝集成。
PluginService 与插件生命周期
PluginService 类为插件提供了完整的生命周期管理,共分为四个阶段:
src/core/core.plugins.js#L20-L120
sequenceDiagram
participant Chart as Chart
participant PS as PluginService
participant Plugin as Plugin
Note over PS: On 'beforeInit' hook:
PS->>PS: _createDescriptors(chart, true)
PS->>Plugin: install(chart, args, options)
Note over PS: Normal operation:
PS->>Plugin: beforeInit / afterInit
PS->>Plugin: beforeUpdate / afterUpdate
PS->>Plugin: beforeLayout / afterLayout
PS->>Plugin: beforeDraw / afterDraw
PS->>Plugin: ...all other hooks...
Note over PS: On 'afterDestroy' hook:
PS->>Plugin: afterDestroy
PS->>Plugin: stop(chart)
PS->>Plugin: uninstall(chart)
PS->>PS: _init = undefined
各生命周期阶段说明如下:
- install — 图表初始化时调用一次,用于执行一次性的初始化工作,例如添加 DOM 元素。
- start — 插件在图表上变为激活状态时调用(包括插件缓存失效后重新评估的情况)。
- 图表钩子 — 针对每个生命周期事件(init、update、layout、datasets、draw 等)提供标准的
before/after钩子对。 - stop — 插件变为非激活状态时调用(例如通过 options 禁用插件,或图表正在销毁时)。
- uninstall — 图表销毁时调用一次,用于执行最终的清理工作。
第 35 行的 notify() 方法是所有钩子调用的入口。它对 beforeInit 钩子有特殊处理——在该阶段首次调用 _createDescriptors() 构建插件列表,并触发 install。afterDestroy 钩子则负责触发销毁流程:依次调用 stop 和 uninstall。
第 73 行的 invalidate() 方法有一个防止重复失效的微妙保护机制:
src/core/core.plugins.js#L73-L83
当插件被重新注册时,缓存可能在 _descriptors() 被调用之前就被连续失效两次。_oldCache 模式保留了上一次的描述符列表,使得 _notifyStateChanges() 能够准确判断哪些插件被添加或移除,并相应地调用 start 或 stop。
内置插件解析
Colors 插件(最简示例)
src/plugins/plugin.colors.ts#L94-L127
Colors 插件展示了最简洁的插件实现模式:一个 id、一个 defaults,以及单个钩子(beforeLayout)。它的职责是为未指定颜色的数据集自动分配调色板颜色。其逻辑会先检查是否已有任何数据集定义了明确的颜色——如果有,除非设置了 forceOverride,否则插件会自动退出。
调色板是一个包含 7 种颜色的数组,通过取模循环使用。对于环形图和极坐标图,每个数据点会分配独立的颜色;对于其他图表类型,每个数据集会分配一对边框色和背景色。
Decimation 插件(算法导向)
src/plugins/plugin.decimation.js#L3-L60
Decimation 插件实现了 Largest Triangle Three Buckets(LTTB) 算法,用于将大型数据集缩减为视觉上具有代表性的子集。该算法将数据划分为若干桶,从每个桶中选取与相邻点构成最大三角形面积的点——在大幅减少渲染点数的同时,最大程度地保留数据的视觉形态。
Filler 插件(多文件复杂实现)
src/plugins/plugin.filler/index.js#L12-L60
Filler 插件展示了插件复杂度增长后的组织方式。它被拆分为多个文件(index.js、filler.drawing.js、filler.helper.js、filler.options.js),并使用了多个钩子(afterDatasetsUpdate、beforeDraw、beforeDatasetsDraw、beforeDatasetDraw)。该插件负责计算填充目标、解析数据集间的引用关系(例如将数据集 A 填充至数据集 B),并最终在 Canvas 上执行实际的填充绘制。
编写自定义插件:模式与钩子
Chart.js 插件是任何包含 id 字符串和一个或多个钩子方法的对象。最简实现如下:
const myPlugin = {
id: 'myPlugin',
defaults: {
enabled: true,
color: '#ff0000'
},
beforeDraw(chart, args, options) {
if (!options.enabled) return;
// options.color is resolved through the scope chain
const ctx = chart.ctx;
// Draw custom content...
}
};
// Register globally
Chart.register(myPlugin);
// Or use per-chart
new Chart(ctx, {
plugins: [myPlugin],
options: {
plugins: {
myPlugin: { color: '#00ff00' }
}
}
});
flowchart LR
subgraph "Plugin Resolution"
GLOBAL["registry.plugins.items"] --> ALL["allPlugins()"]
LOCAL["config.plugins array"] --> ALL
ALL --> OPTS["Merge with options.plugins[id]"]
OPTS --> RESOLVE["createResolver with plugin scopes"]
end
传入每个钩子的 options 参数已经是一个完全解析好的 Proxy(即第 3 篇文章中介绍的解析引擎的产物)。插件选项会在 ['plugins.{id}', ...additionalOptionScopes] 这些作用域链上进行解析,因此插件可以声明 additionalOptionScopes: ['interaction'],从而自动继承交互相关的选项。
每个钩子方法接收三个参数:(chart, args, options)。args 对象的内容因钩子而异——beforeDraw 的 args 基本为空,而 beforeDatasetUpdate 的 args 则包含 { meta, index, mode, cancelable }。对于标记了 cancelable 的钩子,返回 false 可以阻止默认行为的执行。
下一篇预告
我们已经了解了组件的注册方式,以及插件如何接入生命周期。但最核心的组件——Scales、Elements 和 DatasetControllers——各自都有丰富的内部机制。下一篇文章将深入追踪数据到像素的完整管道:原始数据值如何经过 Scale 变换,转化为 Element 的几何信息,最终生成 Canvas 绘制调用。