Skip to content

Web Component: Context

context 是為了在 web component 解決 prop drilling 問題(其他框架已有類似解決方案,也都千篇一律的稱作 Context,請參閱參考資料連結)由 w3c web component working group 提出的一個上下文協定。

這個協定的建議實作本身依賴於 DOM element/DOM event,以 bottom-up 的方式由 context consumer 發起一個 ContextRequestEvent(實作 Event 類別,也就是可以被 dispatchEvent 作為參數),這個事件的命名為context-request(對標其他標準DOMEvent名稱如 click/hover)。

當一個 context-request 事件從某個元素發出之後,如同一般 DOM Event 一樣會從 DOM tree 中尋找能夠消化(以addEventListener的形式),父元素消化請求的方法是提供一個 key 值完全相等的上下文內容用以比對是否為子元素所需要的上下文;這麼做的原因很顯而易見,因為同時可能有好幾個不同的子孫節點發出上下文的請求,其事件名稱都是同一個,就像好幾個子孫元素都可能被點擊(click)一樣,我們無法只單純透過事件名稱去區別作為提供者能提供的上下文資訊。

以下是一個針對提案寫出來的 roughly implementation:

js
/**
 * This is recommended by the protocol proposal.
 * It is very simple now to provide equality between contexts.
 */
function createContext(key) {
  return key;
}

/**
 * This is the core of context protocol.
 * It relies on DOMEvent propagration to bypass the context request from the consumer to the provider.
 */
class ContextRequestEvent extends Event {
  constructor(context, callback) {
    super('context-request', { bubbles: true, cancelable: true })
    this.context = context;
    this.callback = callback;
  }
}

/**
 * We need this to catch not-catched context request.
 */
const contextRoot = new ContextProvider(document, null, { test: 2 });

/**
 * A helper class to generate context consumer.
 */
class ContextConsumer {
  constructor(dom, context) {
    dom.dispatchEvent(new ContextRequestEvent(context, (data) => {
      this.data = data;
      dom[context] = data;
    }));
  }
}

/**
 * A helper class to generate context provider.
 */
class ContextProvider {
  constructor(dom, context, data) {
    console.log(dom, 'listening')
    dom.addEventListener('context-request', this, false);
    this.dom = dom;
    this.context = context;
    this.data = data; // Ensure the data is stored
  }

  handleEvent(evt) {
    if (this.context && evt.context !== this.context) {
      return;
    }
    evt.stopPropagation();
    console.log(this.data);
    evt.callback(this.data);
  }
}

document.querySelector('div').innerHTML = `
<div class="ancestor">
  <div class="parent">
    <div class="child"></div>
  </div>
</div>
`;

const ancestor = document.querySelector('.ancestor');
const parent = document.querySelector('.parent');
const child = document.querySelector('.child');

const data = { test: 1 };

// Use an object as the key instead of a Symbol
const contextKey = 'my-context-1';
const provider = new ContextProvider(ancestor, createContext(contextKey), data);
const consumer = new ContextConsumer(child, createContext(contextKey));

console.log(child.data); // Should output: { test: 1 }

實作過程

createContext

首先提案說明需要有一個 createContext 函數,並且內容可以用來被比對

js
function createContext(key) {
  return key;
}

注意此處的 key 本身可以拿來比對 provider/consumer 請求是否相等的情況下,暫時不需要一個中央管理式的 contextMap 來儲存。

ContextRequestEvent

根據提案,上下文請求事件是 DOMEvent 的擴展,因此我們需要寫一個繼承自 Event 的新事件

js

class ContextRequestEvent extends Event {
  constructor(context, callback) {
    // Needs to specify bubbles explictly
    super('context-request', { bubbles: true, cancelable: true })
    this.context = context;
    this.callback = callback;
  }
}

值得注意的是這邊需要主動設定可冒泡,否則祖父元素以上無法收到這個事件進而無法(跨節點)觸發整套機制。

ContextConsumer

接下來先實作發出請求的人,在協定中其實沒有定義這個類別,但為了架構上考量,設計了一個叫做 ContextConsumer 的類別 他會接受 DOMElementcreateContext 的結果用以發出 ContextRequestEvent

js
class ContextConsumer {
  constructor(dom, context) {
    dom.dispatchEvent(new ContextRequestEvent(context, (data) => {
      this.data = data;
      dom[context] = data;
    }));
  }
}

使用方式

js
new ContextConsumer(dom, createContext('my-context-01'));

這邊為了方便起見,我們規定了當收到 callback 的時候,將收到的資料帶往 DOMElement 上,並掛在當初設定的 context作為屬性的位置。

js
dom[context] = data;

ContextProducer

一樣,雖然協議並沒有規定,但我們把發出請求的人做成 ContextProvider 類別,跟消費者不同的地方是它會提供第三個參數也就是要傳遞的資料本身。

js
class ContextProvider {
  constructor(dom, context, data) {
    console.log(dom, 'listening')
    dom.addEventListener('context-request', this, false);
    this.dom = dom;
    this.context = context;
    this.data = data; // Ensure the data is stored
  }

  handleEvent(evt) {
    if (this.context && evt.context !== this.context) {
      return;
    }
    evt.stopPropagation();
    console.log(this.data);
    evt.callback(this.data);
  }
}

使用方式

js
const data = {};
new ContextProvider(domElement, createContext('my-context-01', data);

為了避免沒有人能收到某個請求(race condition 或延遲載入資料),仿照 @lit/context 做了一個 ContextRoot 用來收沒被攔截的請求

js
const contextRoot = new ContextProvider(window, null, {});

這邊後續還可以添加延遲載入的邏輯。

總體架構如下

設計思考

  • ContextMap 存在的必要性:當需要跨子樹或脫離 DOM Tree 傳遞消息的時候,也許會需要一個集中管理 Context 的地方
  • 有沒有可能像是 TC39 Signal 一樣做成一個跨框架(web components, vue, react...)的 Context API?

參考