Read OSS

可扩展性设计:注册表、插件系统与组件生命周期

中级

前置知识

  • 第 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

  1. 父级作用域的默认值(例如 DatasetController.defaults
  2. 目标作用域中已有的默认值
  3. 组件自身的 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

各生命周期阶段说明如下:

  1. install — 图表初始化时调用一次,用于执行一次性的初始化工作,例如添加 DOM 元素。
  2. start — 插件在图表上变为激活状态时调用(包括插件缓存失效后重新评估的情况)。
  3. 图表钩子 — 针对每个生命周期事件(init、update、layout、datasets、draw 等)提供标准的 before/after 钩子对。
  4. stop — 插件变为非激活状态时调用(例如通过 options 禁用插件,或图表正在销毁时)。
  5. uninstall — 图表销毁时调用一次,用于执行最终的清理工作。

第 35 行的 notify() 方法是所有钩子调用的入口。它对 beforeInit 钩子有特殊处理——在该阶段首次调用 _createDescriptors() 构建插件列表,并触发 installafterDestroy 钩子则负责触发销毁流程:依次调用 stopuninstall

第 73 行的 invalidate() 方法有一个防止重复失效的微妙保护机制:

src/core/core.plugins.js#L73-L83

当插件被重新注册时,缓存可能在 _descriptors() 被调用之前就被连续失效两次。_oldCache 模式保留了上一次的描述符列表,使得 _notifyStateChanges() 能够准确判断哪些插件被添加或移除,并相应地调用 startstop

内置插件解析

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.jsfiller.drawing.jsfiller.helper.jsfiller.options.js),并使用了多个钩子(afterDatasetsUpdatebeforeDrawbeforeDatasetsDrawbeforeDatasetDraw)。该插件负责计算填充目标、解析数据集间的引用关系(例如将数据集 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 对象的内容因钩子而异——beforeDrawargs 基本为空,而 beforeDatasetUpdateargs 则包含 { meta, index, mode, cancelable }。对于标记了 cancelable 的钩子,返回 false 可以阻止默认行为的执行。

下一篇预告

我们已经了解了组件的注册方式,以及插件如何接入生命周期。但最核心的组件——Scales、Elements 和 DatasetControllers——各自都有丰富的内部机制。下一篇文章将深入追踪数据到像素的完整管道:原始数据值如何经过 Scale 变换,转化为 Element 的几何信息,最终生成 Canvas 绘制调用。