📜

Webフロントエンドフレームワークに限定されずに利用できるContext API

2022/12/14に公開

はじめに

コンポーネントベースでWebフロントエンドを実装する場合、親コンポーネントから子コンポーネントにデータを渡す場合には 主に Props と表現されるインターフェースを用いる。

これは、HTML における Attribute のように表現されたり、単に関数の引数として表現されたりする。
表現方法はコンポーネントを実装するライブラリによって色々だが、共通しているのは子にのみデータを渡せるという点だ。

この性質がいわゆる props drilling という、データの所有者からデータの使用者まで間に存在するコンポーネントが props をバケツリレーのように渡していく状態を生むことがある。
このことは、間に存在するコンポーネントに対する知識の流出や、コンポーネントの責任の曖昧さを引き起こす。

これを解決するために、中間のコンポーネントをスキップしてデータを渡す API が各ライブラリから提供されている。
React では Context と呼ばれ、Vue では provide/inject と呼ばれる。

単に Context と呼ぶのが一番単純だが、この記事では React Context と区別する意味で Context API と呼ぶ。
そして、この機能を特定のフレームワークに限定されずに実装することについて以降で記載する。

1. Context API に求められること

Context API についてざっくりと記載したが、具体的にはどのような機能が必要かを整理していく。
Context はおそらく React から広がったと思っているが (確信はない)、React での Context は当然 React の言葉で説明されており、ほかのライブラリにおけるほかの概念は、そのライブラリの言葉で記述されていると思う。
なので、ここで議論の対象としている Context API の概念を特定のライブラリに依存せずに示すことが必要であると考えた。以降では私の言葉で Context API を表現し記載する。

一方で、それに相当する機能を利用したことがある人からすれば、まさに、今頭に思い浮かべている『それ』であろうと思う。
何かのライブラリで触れたことがある人は読み飛ばして、後で出てくる内容を踏まえてここでの Context API の定義などに疑問を持った時に戻ってくればよい内容だろう。

1.1. 主要な役割

前章で中間コンポーネントをスキップしてデータを渡すという説明を書いたが、例えばグローバルステート経由でデータをやり取りしたり、Broadcast Channel API にデータを添えてメッセージを送ったりするものとは異なる。

Context API は文字通り文脈を利用する API だ。
文脈を作る Provider と文脈を利用する Consumer というAPIで構成される。
Provider は文脈の識別子 (多くの場合文字列や symbol などで表現される) と、その文脈で共有されるデータを引数として実行される。
その文脈内のコンポーネントは、対象となる文脈を引数として、Consumer を実行することで、Provider から提供されたデータを購読できる。
ここでいう『購読』とは、文脈によって提供された値が変化したら、その変更や変更後の値を受け取ることができる。ということを指している。

以降では、『Provider である AHogeHoge という文脈でデータ Foo を提供し、Consumer である BHogeHoge という文脈を購読する。』のように記述して、文脈を提供する主体やその文脈自体、対象、および文脈の購読者を表現する。

  • データの提供者: A
  • 提供する文脈: HogeHoge
  • 文脈で提供されるデータ: Foo
  • 文脈の購読者: B

先の説明で、「文脈内のコンポーネント」という表現をした。
つまり、Context API で提供・購読される文脈は『範囲』という概念を持つ。
そして、ある文脈の『外』や『内』の識別ができる。

この『範囲』には『縦と横』あるいは『幅と深さ』のように二次元的な広がりがある。
以降の文章では、『幅』と『深さ』という言葉を使って範囲の次元を区別する。

次以降の節ではこの『範囲』について説明をする。
説明を通して、先に書いたグローバルステートでの共有と Context API が異なるという点が理解できると思う。
(逆説的に言えばグローバルステートには『範囲』がないということだ。文字通りグローバルだ。)

1.2. 文脈の幅

例えば、コンポーネントの階層が以下のようになっており、
RootComponentRoot という名前の文脈を、 ParentComponent1Parent という文脈をそれぞれ提供し、RootComponent 以外のすべてのコンポーネントが、自身が提供している文脈を除いてそれぞれの文脈を購読しているとする。

<RootComponent> <!-- `Root` という名前の文脈を提供 -->
  <ParentComponent1> <!-- `Parent` という名前の文脈を提供 -->
    <ChildComponent1A></ChildComponent1A>
    <ChildComponent1B></ChildComponent1B>
  </ParentComponent1>
  <ParentComponent2>
    <ChildComponent2A></ChildComponent2A>
    <ChildComponent2B></ChildComponent2B>
  </ParentComponent2>
</RootComponent>

この時、以下のような状態だ。

コンポーネント 文脈 Root 文脈 Parent
RootComponent 提供している -
ParentComponent1 購読している 提供している
ChildComponent1A 購読している 購読している
ChildComponent1B 購読している 購読している
ParentComponent2 購読している 購読している
ChildComponent2A 購読している 購読している
ChildComponent2B 購読している 購読している

この時、それぞれの文脈からデータを受け取れるか否かを以下に示す。
購読をしていない・自身が当該文脈の Provider の場合は - を記載している。

コンポーネント 文脈 Root のデータを受け取るか 文脈 Parent のデータを受け取るか
RootComponent - -
ParentComponent1 -
ChildComponent1A
ChildComponent1B
ParentComponent2 x
ChildComponent2A x
ChildComponent2B x

つまり、文脈は Provider の子孫 Consumer にのみデータを提供する。
ParentComponent1 と兄弟である ParentComponent2 とその子孫は、文脈 Parent のデータを受け取ることができない。
一方で、すべてのコンポーネントは RootComponentの子孫であるため、文脈 Root のデータを受け取ることができる。

文脈はドキュメント上すべてのコンポーネントに対してデータを提供するのではなく、Provier の位置を基準とした限られた『幅』のみにデータを提供します。

1.3. 文脈の深さ

コンポーネントの階層が以下のようになっているとする。
ここで、RootComponentHoge という名前の文脈で Foo というデータを提供している。加えて、 ParentComponent1Hoge という文脈で Bar というデータを提供している。さらに、RootComponent 以外のすべてのコンポーネントが、Hoge という文脈を購読している。

<RootComponent> <!-- `Hoge` という名前の文脈で `Foo` を提供 -->
  <ParentComponent1> <!-- `Hoge` という名前の文脈で `Bar` を提供 -->
    <ChildComponent1A></ChildComponent1A>
    <ChildComponent1B></ChildComponent1B>
  </ParentComponent1>
  <ParentComponent2>
    <ChildComponent2A></ChildComponent2A>
    <ChildComponent2B></ChildComponent2B>
  </ParentComponent2>
</RootComponent>

この時、文脈 Hoge から受け取れるデータを以下に示す。

コンポーネント 文脈から受け取るデータ
ParentComponent1 Hoge
ChildComponent1A Bar
ChildComponent1B Bar
ParentComponent2 Hoge
ChildComponent2A Hoge
ChildComponent2B Hoge

以上の通り、 Provider から見たとき、文脈によってデータを提供できる『深さ』は同名の文脈を提供しているコンポーネントまでだ。
Consumer から見ると、最も近い親から提供された文脈のデータを購読することができる。

1.4. この章のまとめ

  • Context API は文脈の ProviderConsumer で構成される
  • Provider は識別子とデータで構成される文脈を提供する
  • Consumer は指定した識別子と同じ文脈を購読し、データを参照できる
  • 特定の文脈は特定の範囲でのみ購読できる
  • 特定の文脈を購読できる幅は、コンポーネントツリー上において、Provider の子孫に限定される
  • 特定の文脈を購読できる深さは、ある文脈を提供した Provider から同名の識別子を持つ文脈を提供する子孫コンポーネントまでに限定される

2. Webフロントエンドフレームワークに限定されずに利用できるContext API

前置きがとても長くなったが、ここからが本題だ。
タイトルにもある『Webフロントエンドフレームワークに限定されずに利用できるContext API』の実装について説明していく。
まず、『Webフロントエンドフレームワークに限定されずに利用できる』だが、これはつまり、特定のライブラリで提供される機能に依存せず、Web フロントエンドの標準技術でのみ実装されるということです。
Web フロントエンドの標準技術で実装されるため、例えば、あるプロダクトが、React で実装されていても Vue で実装されていても、あるいは(想像したくないが)React と Vue 両方が混ざって実装されていても、同じ Context API の実装を利用できることになる。

Web 標準の技術のみでコンポーネント間での文脈の提供・購読を実現することに強い興味を持つのは、同じ Web 標準のみで UI コンポーネント実装を実現する Web Components の利用者たちだ。

以降で説明する Context API は以下のリポジトリの
https://github.com/webcomponents-cg/community-protocols

Context Protocol という proposal に相当するものだ。
https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md

この proposal に記載された内容で十分に満足する人もいると思うので、そのような人はそちらを読むのがよいと思う。

以降での実装方針などの説明を簡単にするために、proposal に含まれる一部の機能 (例えば、購読の登録要求時以降にデータ変更があっても通知を要求しないことを選択できる) などを排除し、前述の説明内容を満たすのに必須と思われる機能に絞った説明をする。
ただ、実用上は特定のケースで期待通りに動作しないということもあると思う。
そのようなケースの一例は後述する。

参考になる実装例は、Lit が開発している以下の @lit-labs/context というパッケージだ。
https://github.com/lit/lit/tree/main/packages/labs/context

ただ、この実装は ReactiveElement というインターフェースに依存している。
これはコンポーネントのライフサイクルや再描画を要求するようなメソッドが含まれるインターフェースだ。
したがって、いずれのライブラリを使っていても ReactiveElement を実装可能だ。
ただ、説明を簡単にするため、これらに依存しない独自の実装を示す。

2.1. 要件のポイントと実装方針

Context API を実装するうえでポイントとなる要件は以下だと考える。

  1. Consumer は識別子に対応する文脈に対して購読の登録ができる
  2. Provider は提供した文脈に含まれるデータの変更時に、登録された Consumer に対してデータの変更を通知する
  3. Provider の子孫コンポーネントの Consumer のみに通知が行われる (文脈の範囲には『幅』がある)
  4. Provider の子孫コンポーネントに同名の文脈を提供する Provder がいるとき、祖先の Provider が提供する文脈を購読できるのは、子孫の Provider までの間に位置する Consumer のみ (文脈の範囲には『深さ』がある)

2.1.1. 文脈の購読要求に関する実装方針

まず、1, 3 について、Consumer から見て祖先方向に位置する Provider に対して購読の登録依頼をする必要がある。
Consumer から祖先方向へメッセージを飛ばし、Provider がそのメッセージに応答して何かしらの処理を行うということには、Event と EventListner が適している。

つまり、Consumer はコンポーネントに含まれている、(DOM の) Element から CustomEvent を発行し購読の登録依頼をする。 Provider はコンポーネントに含まれている、Element に対して当該イベントに対するイベントリスナを設定し、登録処理を実行すればよい。

例えば以下のような実装だ。

/**
 * Context
 * 
 * キーの型と提供する値の型の組み合わせ
 */
type Context<K, V> = K & { __val: V };

/**
 * Context を作る
 */
export const createContext = <V>(key: string | symbol) => key as Context<typeof key, V>;

/**
 * Context の登録要求イベント
 */
export class ContextRequestEvent<K, V> extends Event {
  public constructor(
    public readonly context: Context<K, V>,
  ) {
    super('context-request', {bubbles: true, composed: true});
  }
}

declare global {
  interface HTMLElementEventMap {
    // イベントの定義
    'context-request': ContextRequestEvent<unknown, unknown>;
  }
}


/**
 * 祖先要素に存在する `Provider` に対して文脈の購読の登録を要求する
 */
export const requestSubscription = <K, V>(ref: HTMLElement, context: Context<K, V>) => 
  ref.dispatchEvent(new ContextRequestEvent(context));

/**
 * `Consumer` からの購読要求を処理する
 */
export const createProvider =  <K, V>(ref: HTMLElement, context: Context<K, V>) => {
  ref.addEventListener('context-request', (event: ContextRequestEvent<unknown, unknown>) => {
    // 別の context への購読要求はスルー
    if(event.context !== context) return
    // 登録の処理など
    //   :
    //   :
  });
}

2.1.2. Provider からの通知の実装方針

次に 2 に関して、Consumer からの要求で文脈の購読を登録した Provider は登録した対象らに対して変更の通知を行う。

まず、対象データの変更の検知には、セッター関数を使えばよい。
セッター関数の内部で、通知処理を実行すれば、『Provider が提供する文脈に関連付くデータの変更時に通知を行う。』ことが実現できる。

次に通知だが、JavaScript では『あるタイミングで実行したい処理を登録する』というときにコールバック関数がよく利用される。
例えば、非同期処理が完了した時に、その処理で得たデータを利用して特定の処理を実行する。などの場合だ。
これは、まさに今回の要求に合致する。
つまり、Consumer は購読の要求の際に、変更時に実行したい処理を合わせて送信させる。

例えば以下のような実装に改良する。

/**
 * Context の登録要求イベント
 */
export class ContextRequestEvent<K, V> extends Event {
  public constructor(
    public readonly context: Context<K, V>,
    // 変更時に実行したい関数 (変更の通知に相当)
    public readonly callback: (v: V) => void
  ) {
    super('context-request', {bubbles: true, composed: true});
  }
}

/**
 * 祖先要素に存在する `Provider` に対して文脈の購読の登録を要求する
 */
export const requestSubscription = <K, V>(ref: HTMLElement, context: Context<K, V>, callback: (v: V) => void) => 
  ref.dispatchEvent(new ContextRequestEvent(context, callback));


/**
 * `Consumer` からの購読要求を処理する
 */
export const createProvider =  <K, V>(ref: HTMLElement, context: Context<K, V>) => {
  // 購読要求を登録する
  const callbacks: Set<(v: V) => void> = new Set()

  ref.addEventListener('context-request', (event: ContextRequestEvent<unknown, unknown>) => {
    // 別の context への購読要求はスルー
    if(event.context !== context) return
    // 登録
    callbacks.add(event.callback)
  });
  
  return {
    updateValue: (v: V) => {
      callbacks.forEach(c => c(v))
    }
  }
}

2.1.3. 直近の祖先 Provider のみが文脈の購読要求を登録する

最後に 4 だが、Consumer の祖先に二つの Provider があるとき、Consumer から近い Provider のみが購読要求を登録するように動作する必要がある。

購読の登録要求は Event で実現するようにしたため、登録処理を行ったらイベントの伝播を停止させることでこれが実現できる。

したがって createProvider を以下のように変更することでこの要求を満たすことができる。

/**
 * `Consumer` からの購読要求を処理する
 */
export const createProvider =  <K, V>(ref: HTMLElement, context: Context<K, V>) => {
  const callbacks: Set<(v: V) => void> = new Set()

  ref.addEventListener('context-request', (event: ContextRequestEvent<unknown, unknown>) => {
    if(event.context !== context) return
    callbacks.add(event.callback)
    // 先祖の Provider に対して購読要求が行われないようにする
    event.stopPropagation()
  });
  
  return {
    updateValue: (v: V) => {
      callbacks.forEach(c => c(v))
    }
  }
}

2.2. 実装例

ここまでの内容を踏まえて実装例の全体を示す。

/**
 * Context
 * 
 * キーの型と提供する値の型の組み合わせ
 */
type Context<K, V> = K & { __val: V };

/**
 * Context を作る
 */
export const createContext = <V>(key: string | symbol) => key as Context<typeof key, V>;

/**
 * Context の登録要求イベント
 */
export class ContextRequestEvent<K, V> extends Event {
  public constructor(
    public readonly context: Context<K, V>,
    public readonly callback: (v: V) => void
  ) {
    super('context-request', {bubbles: true, composed: true});
  }
}

declare global {
  interface HTMLElementEventMap {
    'context-request': ContextRequestEvent<unknown, unknown>;
  }
}

/**
 * 祖先要素に存在する `Provider` に対して文脈の購読の登録を要求する
 */
const requestSubscription = <K, V>(ref: HTMLElement, context: Context<K, V>, callback: (v: V) => void) => 
  ref.dispatchEvent(new ContextRequestEvent(context, callback));


/**
 * `Consumer` からの購読要求を処理する
 */
export const createProvider =  <K, V>(ref: HTMLElement, context: Context<K, V>) => {
  const callbacks: Set<(v: V) => void> = new Set()
  ref.addEventListener('context-request', (event: ContextRequestEvent<unknown, unknown>) => {
    if(event.context !== context) return
    callbacks.add(event.callback)
    event.stopPropagation()
  });
  
  return {
    updateValue: (v: V) => {
      callbacks.forEach(c => c(v))
    }
  }
}

2.3. 利用例

ここまでに記載した実装の使用例を以下に示す。
プレーンなHTMLのみ使っているが、任意のライブラリを使ってもDOMを参照することができれば利用できることがわかると思う。

/**
 * context を作る
 */
const myContext = createContext<{foo: string}>(Symbol("my-context"))
const anotherContext = createContext<{foo: string}>(Symbol("my-context"))

/**
 * ルート要素
 */
const root = document.createElement("div")

/**
 * Provider1
 */
const providerRoot = document.createElement("div")
providerRoot.innerHTML = `
 <div>
   <div id="consumer-wrapper"></div>
 </div>
`
providerRoot.setAttribute("id", "provider")
/**
 * Consumer 1
 */
const consumer1Root = document.createElement("div")
consumer1Root.setAttribute("id", "consumer1")
/**
 * Consumer 2
 */
const consumer2Root = document.createElement("div")
consumer2Root.setAttribute("id", "consumer2")
/**
 * Consumer 3
 */
const consumer3Root = document.createElement("div")
consumer3Root.setAttribute("id", "consumer3")

root.appendChild(providerRoot)
providerRoot.querySelector("#consumer-wrapper")?.appendChild(consumer1Root)
providerRoot.querySelector("#consumer-wrapper")?.appendChild(consumer2Root)
root.appendChild(consumer3Root)

const rootUpdater = createProvider(root, myContext)
const updater = createProvider(providerRoot, myContext)
// providerRoot が provider
requestSubscription(consumer1Root, myContext, v => consumer1Root.textContent = v.foo)
// 別の context
requestSubscription(consumer2Root, anotherContext, v => consumer2Root.textContent = v.foo)
// root が provider
requestSubscription(consumer3Root, myContext, v => consumer3Root.textContent = v.foo)
console.log(root.outerHTML)

rootUpdater.updateValue({ foo: "piyo" })
updater.updateValue({ foo: "hoge" })
console.log(root.outerHTML)

実行結果は以下のようになる。

まずは文脈の値を更新する前。

<div>
 <div id="provider">
   <div>
     <div id="consumer-wrapper">
       <div id="consumer1"></div>
       <div id="consumer2"></div>
     </div>
   </div>
 </div>
 <div id="consumer3"></div>
</div>

そして値更新後。

<div>
 <div id="provider">
   <div>
     <div id="consumer-wrapper">
       <div id="consumer1">hoge</div>
       <div id="consumer2"></div>
     </div>
   </div>
 </div>
 <div id="consumer3">piyo</div>
</div>

Consumer のうち一番近く、要求したものと同じ文脈を提供していた場合に値を受け取って textContent を更新していることがわかる。

2.4. このままだとうまく動かないケース

イベントリスナを利用しているので、文脈の購読要求がイベントリスナの登録前に実行されてしまうと、空振りしてうまく動かない。

特に、前述の例のように素朴な HTML ではなく、ライブラリを使ったコンポーネントで、コンポーネントの評価順序が親よりも子供のほうが早いとこのケースに該当する。

これの回避策として @lit-labs/context では、アプリケーションの初期化時にドキュメントのルート要素にイベントハンドラを仕込んでおき、そこで Provider への登録から漏れた依頼を収集する。
そして、 Provider の登録時に、Provider を登録したという別のイベントを発行し、漏らした登録依頼があれば、ルート要素から受け取るという方法を用いている。
https://github.com/lit/lit/blob/main/packages/labs/context/README.md#late-upgraded-context-providers

おわりに

各ライブラリで提供されている Context などと呼ばれる API について確認し、 Web 標準の機能で実現する実装例を示した。
最後に書いたとおりここで示した単純な実装だけだと実用性に乏しいが、目的を実現するための手段を複数知っていることは、目的に応じて適切な技術選択をすることに役立つと思う。
特に、特定の UI ライブラリに依存しない手段は強力な特徴になるのではないかと感じた。

Discussion