Read OSS

オプション解決エンジン:デフォルト値・スコープ・Proxy・Scriptable Options の仕組み

上級

前提知識

  • 第1〜2回の記事
  • JavaScript Proxy(get/ownKeys トラップ)の深い理解
  • Object.defineProperties とプロパティディスクリプタの知識

オプション解決エンジン:デフォルト値・スコープ・Proxy・Scriptable Options の仕組み

Chart.js の中で「良い意味でやりすぎ」と評せるサブシステムがあるとすれば、それはオプション解決エンジンです。データセットに backgroundColor: 'red' を設定したとき、その値はデータセットレベルのオプション、チャートタイプのオーバーライド、要素のデフォルト値、グローバルデフォルトの順に照合されます。さらに、実行時コンテキストを受け取る関数、データポイントでインデックス付けされた配列、ランタイムの変更を即座に反映するライブなフォールバックチェーンにも対応しています。本記事では、この仕組みをひとつひとつ紐解いていきます。

3つのグローバルストア:defaults・overrides・descriptors

Chart.js は core.defaults.js で生成される、独立した3つのシングルトンストアを管理しています。

src/core/core.defaults.js#L7-L8

classDiagram
    class defaults {
        +backgroundColor: string
        +borderColor: string
        +color: string
        +font: object
        +elements: object
        +datasets: object
        +plugins: object
        +scales: object
        +set(scope, values)
        +get(scope)
        +route(scope, name, target, targetName)
        +describe(scope, values)
        +override(scope, values)
    }

    class overrides {
        <<prototype: null>>
        +bar: object
        +line: object
        +doughnut: object
        Note: chart-type-specific settings
    }

    class descriptors {
        <<prototype: null>>
        +_scriptable: fn
        +_indexable: fn
        Note: metadata about option properties
    }

    defaults --> overrides : override() writes here
    defaults --> descriptors : describe() writes here

defaultsDefaults クラスのインスタンスで、color: '#666'font.size: 12 などの基本値で初期化されています。あらゆるオプションの最終フォールバックとして機能します。

overrides はチャートタイプ固有の設定を保持します。BarController を登録すると、そのオーバーライド設定(例:{ scales: { x: { type: 'category' } } })がこのストアにマージされます。defaults より優先されますが、ユーザーが指定したオプションには譲ります。

descriptors はオプションプロパティのメタデータを保持します。具体的には、どのプロパティが scriptable(関数として設定可能)で、どれが indexable(配列として設定可能)かを管理します。初期状態では、on で始まるプロパティ(onHover など)は scriptable でなく、events は indexable でないと宣言されています。

Defaults クラスと defaults.route()

Defaults クラスは一見シンプルに見えますが、コードベースの中でも特に巧妙なパターン——route() メソッド——を備えています。

src/core/core.defaults.js#L130-L157

flowchart LR
    subgraph "route('elements.arc', 'backgroundColor', '', 'color')"
        ARC_BG["elements.arc.backgroundColor"] -->|"getter: valueOrDefault(this._backgroundColor, defaults.color)"| DEFAULTS_COLOR["defaults.color"]
    end

    DEV["defaults.color = 'blue'"] -->|runtime change| DEFAULTS_COLOR
    ARC_BG -->|"Now resolves to 'blue'"| RESULT["'blue'"]

route() が呼び出されると、Object.defineProperties を使ってシンプルなプロパティを getter/setter のペアに置き換えます。getter はまず内部の _backgroundColor プロパティを確認し、未定義であればターゲットスコープ(defaults.color)にフォールバックします。setter は内部プロパティに書き込みます。

これにより ライブな フォールバックチェーンが実現します。実行時に defaults.color を変更すると、backgroundColor をそこにルーティングしているすべての要素に即座に反映されます。単純なプロパティのコピーでは、このような動作は実現できません。

route() のメカニズムはコンポーネントの登録時に呼び出されます。ArcElement が登録されると、その defaultRoutes プロパティが backgroundColor をグローバルの color にフォールバックするよう指定します。Chart.js がデフォルト値を重複させることなくカスケードを実現できるのは、この仕組みのおかげです。

Config クラスとスコープ解決

Config クラスは、リゾルバがオプション値を探す際に走査する重要なデータ構造である、順序付きスコープ配列の計算を管理しています。

src/core/core.config.js#L158-L381

chartOptionScopes() メソッドは、チャートレベルのオプションを解決するためのスコープ一覧を返します。

src/core/core.config.js#L331-L342

sequenceDiagram
    participant Consumer as chart.options.X
    participant Resolver as Proxy Resolver
    participant S1 as options (user config)
    participant S2 as overrides[type]
    participant S3 as defaults.datasets[type]
    participant S4 as defaults
    participant S5 as descriptors

    Consumer->>Resolver: get 'backgroundColor'
    Resolver->>S1: has 'backgroundColor'?
    S1-->>Resolver: undefined
    Resolver->>S2: has 'backgroundColor'?
    S2-->>Resolver: undefined
    Resolver->>S3: has 'backgroundColor'?
    S3-->>Resolver: undefined
    Resolver->>S4: has 'backgroundColor'?
    S4-->>Resolver: 'rgba(0,0,0,0.1)'
    Resolver-->>Consumer: 'rgba(0,0,0,0.1)'

データセット要素のオプションでは、スコープチェーンはさらに長くなります。252行目の datasetElementScopeKeys() メソッドは ['datasets.bar.elements.point', 'datasets.bar', 'elements.point', ''] のようなキーを生成します。続いて296行目の getOptionScopes() が、それぞれのキーを mainScopeoptionsoverrides[type]defaultsdescriptors に対して走査します。

src/core/core.config.js#L296-L325

結果はスコープオブジェクトの Set として生成され、配列に変換されます。スコープのキャッシュ(_scopeCache_resolverCache)により、ユニークなキーリストごとにこの計算が最大1回しか実行されないよう保証されています。

Proxy ベースのリゾルバ:_createResolver()

実際の解決ロジックは helpers.config.ts に実装されています。_createResolver() 関数は、スコープ配列を走査してオプションを遅延解決する JavaScript Proxy を生成します。

src/helpers/helpers.config.ts#L27-L108

64行目の get トラップが解決処理の本体です。ここから _resolveWithPrefixes() が呼ばれ、プレフィックスを順番に試みます。たとえばプレフィックスが ['hover', '']borderColor にアクセスした場合、まずすべてのスコープで hoverBorderColor を探し、見つからなければ borderColor にフォールバックします。

src/helpers/helpers.config.ts#L390-L405

flowchart TD
    ACCESS["proxy.borderColor"] --> CACHED{"Already cached?"}
    CACHED -->|Yes| RETURN["Return cached value"]
    CACHED -->|No| PREFIXES["Try prefixes: ['hover', '']"]
    PREFIXES --> P1["Look for 'hoverBorderColor' in scopes"]
    P1 -->|Found| CHECK_SUB{"Is value an object<br/>needing sub-resolver?"}
    P1 -->|Not found| P2["Look for 'borderColor' in scopes"]
    P2 -->|Found| CHECK_SUB
    P2 -->|Not found| UNDEF["Return undefined"]
    CHECK_SUB -->|Yes| SUB["createSubResolver()"]
    CHECK_SUB -->|No| CACHE["Cache & return value"]
    SUB --> CACHE

解決された値は Proxy のターゲットオブジェクトに直接キャッシュされます。_cached() ヘルパーはリゾルバを呼び出す前に Object.prototype.hasOwnProperty を確認し、各プロパティが最大1回しか解決されないことを保証しています。

Tip: オプション解決のデバッグ時は、デバッガで resolver._scopes にアクセスするとリゾルバの内部スコープを確認できます。検索対象となるスコープオブジェクトの順序付き配列が確認でき、問題の特定に役立ちます。

解決された値がプレーンオブジェクト(プロトタイプが null または Object)の場合、createSubResolver() が独自のスコープチェーンを持つネストされたリゾルバを生成します。font.sizeticks.color のようなオプションが同じメカニズムで解決されるのはこのためです。font のリゾルバ自体が Proxy であり、スコープを走査して font のサブプロパティを探します。

コンテキストのアタッチと Scriptable Options

2層目の Proxy である _attachContext() は、scriptable と indexable オプションに対応するため、リゾルバに実行時コンテキストをラップします。

src/helpers/helpers.config.ts#L118-L195

コンテキスト対応の get トラップが scriptable プロパティで関数値に遭遇すると、_resolveScriptable() を呼び出します。

src/helpers/helpers.config.ts#L255-L273

scriptable リゾルバは (context, subProxy) を引数として関数を呼び出します。context には { chart, dataIndex, datasetIndex, ... } が含まれます。262行目の _stack Set は無限再帰を検出します。scriptable オプションが循環参照を引き起こした場合、ブラウザをハングさせる代わりにスタックトレースをキャプチャしてエラーとしてスローします。

indexable オプション(配列)の場合、275行目の _resolveArray() がコンテキストに index プロパティがあるかを確認し、value[context.index % value.length] を返します。backgroundColor: ['red', 'blue', 'green'] のように異なるデータポイントへ異なる色を割り当てられるのは、この仕組みによるものです。

パフォーマンス最適化:needContext()

すべてのオプション解決でコンテキスト Proxy のオーバーヘッドが必要なわけではありません。needContext() 関数は、指定されたプロパティのいずれかが関数や配列に解決されるかどうかを素早くスキャンします。

src/core/core.config.js#L405-L418

flowchart TD
    START["resolveNamedOptions(scopes, names, context)"] --> RESOLVE["Create base resolver"]
    RESOLVE --> CHECK["needContext(resolver, names)"]
    CHECK -->|"All static values"| SHARED["result.$shared = true<br/>Read values directly from resolver"]
    CHECK -->|"Has functions or arrays"| CONTEXT["result.$shared = false<br/>Create context-wrapped resolver"]
    CONTEXT --> READ["Read values from context resolver"]
    SHARED --> READ
    READ --> RETURN["Return result object"]

needContext() が false を返した場合、解決されたオプションは $shared: true としてマークされます。このフラグは DatasetController において強力な最適化に使われています。データセット内のすべての要素が同一の静的オプションを共有している場合、要素ごとにコピーを作成する代わりに単一の共有オプションオブジェクトを参照できます。この最適化については第5回の記事で詳しく解説します。

402行目の hasFunction() ヘルパーは、関数値を持つプロパティを含むオブジェクトもチェックします。{ size: 12 } のような静的オブジェクトはコンテキストラッピングをトリガーしませんが、{ size: (ctx) => ctx.active ? 14 : 12 } はトリガーします。

次回の記事へ

オプション解決エンジンは、ほぼすべてのサブシステムから利用されています。コントローラはこれを通じて要素オプションを解決し、スケールはティックオプションを解決し、プラグインは自身の設定にアクセスします。では、コントローラ・スケール・プラグインはそもそもどのように登録されるのでしょうか。次回の記事では Registry とプラグインシステムを掘り下げます。コンポーネントの登録がデフォルト値のマージやルート設定をどのようにトリガーするか、そしてプラグインのライフサイクルがチャートの存在全体を通じて豊富なフックをどう調整するかを解説します。