Read OSS

new Chart() からピクセルまで:チャートのライフサイクルと更新パイプライン

中級

前提知識

  • 第 1 記事:アーキテクチャ概観
  • HTML5 Canvas API の基本的な理解
  • requestAnimationFrame の基礎知識

new Chart() からピクセルまで:チャートのライフサイクルと更新パイプライン

第 1 記事で Chart.js の全体構造を把握したところで、今度はコードベースの中で最も重要な流れを追ってみましょう。new Chart(canvas, config) を呼んだ瞬間から、最初のピクセルが画面に描かれるまでに何が起きているのかです。core.controller.js の Chart クラスは、Config・Platform・PluginService・DatasetControllers・Scales・Layouts といった各コンポーネントを1つの更新・描画サイクルにまとめ上げる中心的なオーケストレーターです。約 1,250 行のファイルにすべての指揮が集約されています。

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 をキーとして使います。つまり destroy() を明示的に呼ばない限り、Chart インスタンスはメモリ上に残り続けます。これは SPA においてクリーンアップ処理を忘れた際によく起きるメモリリークの原因です。

次に、コンストラクタはまず canvas に既存の Chart が存在しないかを確認します(getChart(initialCanvas))。既存のインスタンスが見つかった場合、黙って上書きするのではなくエラーを投げます。2 つのインスタンスが同じ canvas を共有しようとしたときの微妙なバグを、この防御的なチェックが防いでくれます。

また、PluginService インスタンスはグローバルに共有されるのではなく、Chart ごとに生成されます(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 イベントの正規化を担います。タッチ・ポインターイベントを Chart.js のマウスベースのイベントモデルに変換するマッピングもここで行われます。

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

一方、BasicPlatform はイベントバインディングとリサイズ監視を no-op で実装します。これにより、node-canvas のような Canvas polyfill を使えば、web workers や Node.js 環境でも Chart.js を利用できます。

Tips: config に platform クラスを渡すことで、Platform の自動検出をスキップできます:new Chart(canvas, { platform: MyCustomPlatform, ... })。テストやカスタムの描画環境に役立ちます。

update() パイプライン:Config から 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()"]

処理の順序には明確な意図があります。コントローラーは軸マッピングのためにスケールを必要とするため、スケールをコントローラーより先に構築しなければなりません。レイアウトエンジンはスケールや凡例などのボックスが何個あるかを知る必要があるため、コントローラーをレイアウトより先に構築します。新しいコントローラーのリセットはレイアウトのに行われます。これは、リセットによって確立されるアニメーション開始点が、スケールの最終的な配置位置に依存するためです。

mode パラメータ(例:'resize''reset''active')はコントローラーまで伝播し、特定の更新パスを最適化できるようにします。たとえばリサイズ時には、データの再解析をスキップして要素の位置計算だけを行うといった最適化が可能です。

レイアウトエンジンとボックスモデル

core.layouts.js のレイアウトシステムは、反復的な制約解決アルゴリズムで実装されています。スケール・凡例・タイトルといった、画面上にスペースを必要とするすべての視覚的コンポーネントは 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 アートのコメントも一読の価値があります。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 インデックスによるレイヤー描画システムを実装しています。

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

各スケールは _layers() メソッドを持ち、z インデックス付きのレイヤーオブジェクトを返します。グリッド線は通常 z: -1(データセットの背後に描画)、目盛りラベルは z: 0 を使います。これにより、特別なケース処理なしに、スケールコンポーネントとデータセットの描画を自然に交差させられます。

データセット自体は後ろから前へ(metasets.length - 1 から 0 の順)描画されるため、インデックスの大きいデータセットが上に重なります。また、各データセットはスケールの境界でクリッピングされるため、データポイントがチャートエリアの外にはみ出して描画されることはありません。

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() メソッドはすべてのチャートを走査し、アニメーションを tick して、アクティブなアニメーションを持つチャートに対して chart.draw() を呼び出します。

完了したアニメーションアイテムの削除には効率的な手法が使われています。配列の splice は使わず、該当アイテムを末尾の要素で上書きしてから pop() を呼ぶことで、O(n) ではなく O(1) の操作で削除を実現しています。

破棄とクリーンアップ

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 のマップからそのチャートが取り除かれます。最後に、グローバルな instances オブジェクトからチャートが削除され、ガベージコレクションの対象となります。

Tips: SPA では、コンポーネントのクリーンアップライフサイクル(React の useEffect の返り値、Vue の onUnmounted など)で必ず chart.destroy() を呼びましょう。呼び忘れると、canvas コンテキスト・イベントリスナー・チャートインスタンス自体がメモリリークします。

次の記事へ

update() の中では config.createResolver(config.chartOptionScopes(), ...) を呼び出してチャートオプションを解決しています。では、このリゾルバーの内部では実際に何が起きているのでしょうか?次の記事では、Chart.js の中で最も複雑なサブシステムである、JavaScript Proxy をベースとした多層オプション解決エンジンに深く踏み込みます。スコープチェーン・スクリプタブルオプション・再帰的なフォールバックルートを詳しく見ていきましょう。