Read OSS

Chart.js の内部構造:アーキテクチャ概要とコードベースツアー

中級

前提知識

  • JavaScript/TypeScript の基本的な知識
  • 軸・データセット・シリーズなど、チャートの基本概念への理解
  • npm/pnpm パッケージ管理ツールの使用経験

Chart.js の内部構造:アーキテクチャ概要とコードベースツアー

Chart.js はウェブで最も広く使われているチャートライブラリのひとつです。しかし多くの開発者は、設定 API にオブジェクトやコールバックを渡すだけで、その裏側で何が起きているかをほとんど意識したことがないのではないでしょうか。このシリーズでは、そこに踏み込んでいきます。全 6 回にわたり、モジュールの構成から Canvas の fillRect 呼び出しに至るまで、コードベースのあらゆる経路を追いかけます。第 1 回の本稿では全体像を把握することを目標に、ソースコードの構成・ビルドパイプラインの仕組み・そして用途に応じた 3 つのエントリーポイントの設計思想を丁寧に見ていきます。

プロジェクト構造とモジュール構成

src/ ディレクトリには 7 つのトップレベルディレクトリがあり、それぞれが独立したアーキテクチャ層を担っています。

ディレクトリ 役割 主なエクスポート
core/ オーケストレーション、ライフサイクル、設定、レイアウト Chart, Scale, DatasetController, defaults, registry
controllers/ チャートタイプのロジック(bar、line、doughnut など) BarController, LineController, DoughnutController, …
elements/ Canvas に描画される視覚的プリミティブ ArcElement, BarElement, LineElement, PointElement
scales/ 軸の種類と座標マッピング LinearScale, LogarithmicScale, TimeScale, CategoryScale
plugins/ 横断的な機能(凡例、ツールチップ、カラーなど) Legend, Tooltip, Filler, Decimation, Colors
platform/ 実行環境の抽象化(DOM、OffscreenCanvas) DomPlatform, BasicPlatform, BasePlatform
helpers/ 共通ユーティリティ関数群 数学計算、色処理、DOM 操作、イージング、設定解決

これらのディレクトリは単なる整理用のフォルダではありません。依存の流れが一方向になるよう意図的に設計されたレイヤー構造を反映しています。controllers は core と elements に依存し、plugins は core に依存し、helpers は他に依存しないリーフノードとして機能します。

graph TD
    subgraph "Public API"
        CHART["Chart (core.controller)"]
    end
    subgraph "Subsystems"
        CTRL["Controllers"]
        SCALE["Scales"]
        PLUG["Plugins"]
        ELEM["Elements"]
        PLAT["Platform"]
    end
    subgraph "Foundation"
        CORE["Core (defaults, registry, layouts, config)"]
        HELP["Helpers"]
    end

    CHART --> CTRL
    CHART --> SCALE
    CHART --> PLUG
    CHART --> PLAT
    CHART --> CORE
    CTRL --> ELEM
    CTRL --> CORE
    SCALE --> CORE
    PLUG --> CORE
    CORE --> HELP
    ELEM --> HELP
    PLAT --> HELP

core のバレルファイルは、アニメーター・レジストリ・デフォルト設定・レイアウトエンジンのシングルトンインスタンスを含め、ライブラリ全体が必要とするものをすべて再エクスポートしています。

src/core/index.ts#L1-L16

ここではクラスとシングルトンインスタンスの両方がエクスポートされている点に注目してください。Chart クラスは core.controller.js のデフォルトエクスポートですが、defaultsregistrylayoutsanimator はあらかじめインスタンス化されたシングルトンです。この違いは tree-shaking を考えるうえで非常に重要な意味を持ちます。詳しくは後ほど説明します。

エントリーポイントと exports マップ

Chart.js は package.json の exports マップを通じて 3 つの公開エントリーポイントを提供しています。

package.json#L18-L34

flowchart LR
    subgraph "Consumer Code"
        A["import { Chart } from 'chart.js'"]
        B["import 'chart.js/auto'"]
        C["import { color } from 'chart.js/helpers'"]
    end

    subgraph "Entry Points"
        ESM["src/index.ts<br/>(tree-shakeable)"]
        AUTO["auto/auto.js<br/>(side-effectful)"]
        HELP["src/helpers/index.ts<br/>(utilities)"]
    end

    A --> ESM
    B --> AUTO
    C --> HELP
    AUTO -->|"imports & registers"| ESM

ESM エントリーsrc/index.ts)は各サブシステムのすべてを再エクスポートし、registerables 配列も提供しています。重要なのは、ここでは Chart.register() を呼び出さないという点です。これにより、バンドラーが実際に使われているインポートだけを静的解析で特定し、不要なコードを除去できます。

auto エントリーauto/auto.js)は dist の出力から Chartregisterables をインポートし、即座に Chart.register(...registerables) を呼び出します。これは package.jsonsideEffects としてマークされており、戻り値が使われていなくてもバンドラーがこのインポートを除去できないことを示しています。

UMD エントリーsrc/index.umd.ts)はさらに踏み込んでいます。すべてのコンポーネントを自動登録し、Object.assignChart 名前空間にまとめ、window.Chart としてグローバルに公開します。CDN で配信される「フル装備」のバンドルはこれにあたります。

ヒント: モダンなアプリケーションで bar と line チャートだけが必要な場合は、chart.js/auto ではなく chart.js からインポートし、必要なコンポーネントだけを登録しましょう。バンドルサイズを 30〜40% 削減できます。

ビルドパイプライン:ソースから配布物へ

Chart.js は Rollup と SWC を組み合わせて TypeScript をトランスパイルし、1 つの設定ファイルから 4 種類の出力バンドルを生成します。

rollup.config.js#L46-L116

flowchart TD
    SRC_UMD["src/index.umd.ts"] --> ROLLUP_UMD["Rollup + SWC + Terser"]
    SRC_ESM["src/index.ts"] --> ROLLUP_ESM["Rollup + SWC + Cleanup"]
    SRC_HELP["src/helpers/index.ts"] --> ROLLUP_ESM

    ROLLUP_UMD --> UMD_MIN["dist/chart.umd.min.js<br/>(UMD, minified)"]
    ROLLUP_UMD --> UMD["dist/chart.umd.js<br/>(UMD, minified)"]
    ROLLUP_ESM --> ESM_OUT["dist/chart.js<br/>(ESM)"]
    ROLLUP_ESM --> CJS_OUT["dist/chart.cjs<br/>(CommonJS)"]
    ROLLUP_ESM --> HELP_OUT["dist/helpers.js<br/>(ESM)"]

ビルドパイプラインのプラグイン設定には、見落としがちながら重要な点があります。

rollup.config.js#L17-L44

UMD ビルドでは Terser がミニファイを担当しますが、ESM/CJS ビルドでは代わりに rollup-plugin-cleanup が使われ、comments: ['some', /__PURE__/] という設定が適用されています。これにより出力に #__PURE__ アノテーションが保持され、下流のバンドラーが副作用のない式を識別できるようになります。

SWC の設定ターゲットは es2022 です。つまり出力はクラスフィールドやオプショナルチェーニングといったモダンな構文を使います。これは意図的な選択であり、古いブラウザをサポートしたい場合は利用者側でさらにトランスパイルすることを前提とした設計です。

シングルトンパターンと Tree-Shaking

Chart.js は Defaults ストア、RegistryAnimator という 3 つの重要なシングルトンに依存しています。それぞれモジュールスコープで /* #__PURE__ */ アノテーション付きでインスタンス化されます。

src/core/core.defaults.js#L165-L175

src/core/core.registry.js#L185-L186

src/core/core.animator.js#L213-L214

classDiagram
    class Defaults {
        +backgroundColor: string
        +borderColor: string
        +color: string
        +font: object
        +set(scope, values)
        +get(scope)
        +route(scope, name, targetScope, targetName)
        +describe(scope, values)
        +override(scope, values)
    }

    class Registry {
        +controllers: TypedRegistry
        +elements: TypedRegistry
        +plugins: TypedRegistry
        +scales: TypedRegistry
        +add(...args)
        +remove(...args)
    }

    class Animator {
        -_charts: Map
        -_running: boolean
        +listen(chart, event, cb)
        +add(chart, items)
        +start(chart)
        +stop(chart)
    }

    Defaults <.. Registry : uses for merging
    Animator <.. Chart : drives render loop
    Registry <.. Chart : resolves components

#__PURE__ アノテーションは webpack や Rollup のようなバンドラーへの指示です。「この式に副作用はない。結果が誰にもインポートされなければ、安全に削除してよい」という意味を持ちます。このアノテーションがなければ、バンドラーは new Defaults(...) がグローバルな状態を変更する可能性があると判断し、削除を諦めなければなりません。

ESM エントリーが tree-shaking に対応できているのはこの仕組みのおかげです。ChartBarController だけをインポートすれば、バンドラーは PolarAreaControllerRadarController、それらに紐づく要素が一切参照されていないと判断し、除去することができます。

モジュール依存グラフ

ESM エントリーポイント(src/index.ts)は、スター形式のエクスポートパターンを使ってサブシステムのバレルファイルからライブラリ全体を構成しています。また各サブシステムを名前空間化し、registerables 配列としてエクスポートしています。

graph TD
    INDEX["src/index.ts"]
    INDEX -->|"export *"| CTRL_IDX["controllers/index.js"]
    INDEX -->|"export *"| CORE_IDX["core/index.ts"]
    INDEX -->|"export *"| ELEM_IDX["elements/index.js"]
    INDEX -->|"export *"| PLAT_IDX["platform/index.js"]
    INDEX -->|"export *"| PLUG_IDX["plugins/index.js"]
    INDEX -->|"export *"| SCALE_IDX["scales/index.js"]

    INDEX -->|"import * as controllers"| CTRL_IDX
    INDEX -->|"import * as elements"| ELEM_IDX
    INDEX -->|"import * as plugins"| PLUG_IDX
    INDEX -->|"import * as scales"| SCALE_IDX

    subgraph "registerables array"
        REG["[controllers, elements, plugins, scales]"]
    end
    INDEX --> REG

UMD エントリーはまったく異なるアプローチを取っています。再エクスポートではなく、すべてをインポートしたうえで Chart オブジェクトに手動で紐づけます。

src/index.umd.ts#L27-L51

Object.assign(Chart, controllers, scales, elements, plugins, platforms) の呼び出しによって、すべてのエクスポートが単一の Chart 名前空間にまとめられます。これは ESM 以前の利用者が期待する Chart.LineControllerChart.LinearScale というアクセスパターンに対応するためです。そして 50 行目での window.Chart の設定が、<script> タグによるグローバル登録を完成させます。

ここに込められた設計の核心は、同じソースコードが 3 つの利用パターン——tree-shaken ESM、自動登録 ESM、グローバル UMD——をロジックの重複なく支えているという点です。エントリーポイントがサブシステムをどのように組み合わせて公開するか、その違いだけで実現されています。

次回へのつながり

コードベースの全体像を把握したところで、次はその中を走る最も重要な経路を追いかけましょう。開発者が new Chart(ctx, config) と書いたとき、内部では何が起きているのでしょうか。次回の記事では、Chart コンストラクターを出発点に、プラットフォーム検出・設定の解決・多段階の update() パイプライン・そしてデータを Canvas 上のピクセルへと変換するレイヤー構造の draw() システムまでを丁寧に追っていきます。