Read OSS

設計による拡張性:Registry、プラグインシステム、コンポーネントライフサイクル

中級

前提知識

  • 第1〜3回の記事
  • JavaScript のプロトタイプチェーンの理解
  • オブザーバー/フックパターンの基礎知識

設計による拡張性:Registry、プラグインシステム、コンポーネントライフサイクル

Chart.js は棒グラフ、折れ線グラフ、ドーナツグラフ、レーダーグラフ、極座標グラフ、バブルグラフ、散布図、円グラフを標準搭載していますが、システムはこれらを特別扱いしていません。すべてが「登録済みコンポーネント」であり、あなたが独自に作るサードパーティ製チャートタイプと同等の立場に置かれています。この統一性を支えているのが、Registry(コンポーネントの検索とデフォルト値のマージを担う)と PluginService(ライフサイクルフックを制御する)という2つの抽象化です。両者が組み合わさって、Chart.js の拡張性の根幹をなしています。

Registry と TypedRegistry のアーキテクチャ

Registry シングルトンは、コンポーネントのカテゴリごとに1つずつ、計4つの 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 がコントローラーレジストリに対応するのは、そのプロトタイプチェーンに DatasetController.prototype が含まれているためです。同様に LinearScale がスケールレジストリに対応するのは、チェーン内に Scale.prototype があるからです。

_typedRegistries の順序 — [controllers, scales, elements] — は重要な意味を持ちます。ScaleElement を継承しているため、scales を elements より先にチェックしなければ、すべてのスケールが誤って element として登録されてしまいます。plugins はフォールバックとして機能し、他の3つのレジストリに該当しないものはすべてプラグインとして扱われます。

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() 関数は3方向のマージを行います。

src/core/core.typedRegistry.js#L84-L101

  1. 親スコープのデフォルト値(例:DatasetController.defaults
  2. 対象スコープに既存のデフォルト値がある場合はそれ
  3. コンポーネント自身の defaults プロパティ

これにより、デフォルト値はプロトタイプチェーンを通じてカスケードします。BarControllerDatasetController のデフォルト値を継承しつつ、特定の値を上書きできます。続いて103行目の routeDefaults()defaults.route() を通じてライブフォールバックチェーンを構築します。この仕組みは第3回の記事で詳しく解説しました。

ヒント: カスタムチャートタイプを作成する際は、基本設定として static defaults = { ... } を定義し、グローバルデフォルトへのフォールバックが必要なプロパティには static defaultRoutes = { backgroundColor: 'color' } を使いましょう。こうすることで、コンポーネントがテーマシステムと自然に統合されます。

PluginService とプラグインライフサイクル

PluginService クラスはプラグインに対してリッチなライフサイクルを提供しており、4つの明確なフェーズに分かれています。

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. Chart フック — 各ライフサイクルイベント(init、update、layout、datasets、draw など)における標準的な before/after ペアです。
  4. stop — プラグインが非アクティブになったとき(オプションで無効化された場合やチャートが破棄される場合など)に呼ばれます。
  5. uninstall — チャートの破棄時に一度だけ呼ばれ、最終的なクリーンアップを行います。

35行目の notify() メソッドはすべてのフック呼び出しの入口です。beforeInit フックは特別扱いされており、このタイミングで _createDescriptors() が初めて呼ばれてプラグインリストが構築され、install が実行されます。afterDestroy フックは stopuninstall の順でティアダウンシーケンスを開始します。

73行目の invalidate() メソッドには、二重無効化を防ぐ巧妙なガードが組み込まれています。

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

プラグインが再登録されると、_descriptors() が呼ばれる前にキャッシュが2回無効化される可能性があります。_oldCache パターンは直前のデスクリプタリストを保持することで、_notifyStateChanges() がどのプラグインが追加・削除されたかを正確に判定し、start/stop を適切に呼び出せるようにします。

組み込みプラグインの解剖

Colors プラグイン(最もシンプルな例)

src/plugins/plugin.colors.ts#L94-L127

Colors プラグインはプラグインの最小パターンを示しています。iddefaults、そして単一のフック(beforeLayout)だけで構成されており、独自の色が設定されていないデータセットにパレットカラーを自動割り当てするのが役割です。いずれかのデータセットが既に明示的な色を持っている場合、forceOverridetrue でない限り処理を行いません。

パレットは7色の配列で、モジュロ演算でサイクルします。ドーナツグラフや極座標グラフでは各データポイントに個別の色が割り当てられ、それ以外のチャートタイプではデータセットごとに1つのボーダー/バックグラウンドペアが使われます。

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)を使用し、フィルのターゲット計算やデータセット間の参照解決、Canvas への塗りつぶし描画を担います。

カスタムプラグインの作り方:パターンとフック

Chart.js のプラグインは、id 文字列と1つ以上のフックメソッドを持つオブジェクトです。最小パターンは次のとおりです。

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) という3つの引数を受け取ります。args オブジェクトの内容はフックによって異なります。beforeDraw ではほぼ空ですが、beforeDatasetUpdate では { meta, index, mode, cancelable } が含まれます。cancelable なフックで false を返すと、デフォルトの動作がキャンセルされます。

次回の記事に向けて

ここまでで、コンポーネントの登録方法とプラグインがライフサイクルにフックする仕組みを理解しました。しかし最も重要なコンポーネントである Scales、Elements、DatasetControllers には、それぞれ独自の豊かな内部メカニズムがあります。次回は「データからピクセルへ」のパイプラインを追います。生のデータ値が Scale の変換を経て Element のジオメトリとなり、最終的に Canvas の描画呼び出しになるまでの流れを詳しく見ていきましょう。