Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

@ampproject/worker-dom Code Reading

これはなに

AMPがJS対応するのに、Worker だったらAMPの指標のパフォーマンス的に許容できるから、それに向けて頑張るという発表があった。

好きなJavaScriptをAMPで実行できるようになるかも。Web Workerで実現か? | 海外SEO情報ブログ

つい先日、その試験実装が公開された https://github.com/ampproject/worker-dom

AMPは普通のHTMLサブセットなので、ここで作られているものはどこでも使い回せるはず。Worker にDOMの処理を逃せるなら、汎用的な価値が高い。あらゆるDOMのライブラリが、off-the-main-thread の文脈で WebWorker に移せるようになる。

(DOM を触れる worklet の proposal なかったっけ?と思ったが、ぱっとググってみつからなかった)

忙しい人向け: 結末だけ

今回わかったこと

  • Worker 側に DOM のセマンティクスをまるごと実装している
  • Worker 側に実装された MutationObserver で、 MutationRecord を捕まえる
  • MutationRecord を JSON にシリアライズし、postMeassge で MainThread へ送りつける
  • MainThread で、シリアライズされた MutationRecord を元に、実DOM へ適用操作を行う

読んでないけど、おそらくやってること

  • MainThread で発生した Event を捕まえて、JSON にシリアライズし、postMessage で Worker に送る
  • Worker で Event をデシリアライズし、リスナーを発行する

これで MainThread - Worker 間をぐるぐる廻る。

DOMの実装コードに、React を動かすには〜みたいなコメントがあるので、結構そのあたりを意識してる。demo に preact を使う実例がある。本筋とは関係ないけど、最近PWA系のプロダクトで Reactフレンドリーなものが出てくるのは、developit 先生がGoogleに転職した成果っぽい。

エントリポイントを捕まえる

まず、ライブラリが提供するものとして、 mainthread 側と /dist/index.mjs worker 側 /dist/worker.mjs がある。 ES module 環境で走る .mjs, ES5 向けの .js と、DOMPurify によるサニタイザ付の /dist/*.safe.(m)js がある。

まず雑に使ってエントリポイントを調べる。

yarn demo でデモを実行できる。

demo/hello-world/index.html を削いだもの

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script src="/dist/index.mjs" type="module"></script>
</head>

<body>
  <div src="hello-world.js" id="root">
    <div class="root"><button>prompt</button></div>
  </div>
  <script type="module">
    import { upgradeElement } from '/dist/index.mjs';
    upgradeElement(document.getElementById('root'), '/dist/worker.mjs');
  </script>
</body>
</html>

hello-world.js が worker 上で実行されるっぽい。document を触る DOM操作スクリプトだった。

const root = document.createElement("div");
const btn = document.createElement("button");
const text = document.createTextNode("Insert Hello World!");

root.className = "root";
btn.appendChild(text);
root.appendChild(btn);

btn.addEventListener("click", () => {
  const h1 = document.createElement("h1");
  h1.textContent = "Hello World!";
  document.body.appendChild(h1);
});

document.body.appendChild(root);

なぜ Workerで document.createElement できるのか、を追う

クラインアント側エントリポイント

upgradeElement をたどると、 src/main-thread/install.ts にたどり着く

div につけた src を読み取って、そのDOMにまつわる worker を生成する、みたいな雰囲気っぽい。(正直APIは全然自分の好みじゃない…)

  // ...
  const authorURL = baseElement.getAttribute("src");

  createWorker(workerDOMUrl, authorURL).then(worker => {
    if (worker === null) {
      return;
    }

    prepareNodes(baseElement);
    prepareMutate(worker);

    worker.onmessage = ({ data }: MessageFromWorker) => {
      switch (data[TransferrableKeys.type]) {
        case MessageType.HYDRATE:
          // console.info(`hydration from worker: ${data.type}`, data);
          hydrate(
            (data as HydrationFromWorker)[TransferrableKeys.nodes],
            (data as HydrationFromWorker)[TransferrableKeys.strings],
            (data as HydrationFromWorker)[TransferrableKeys.addedEvents],
            baseElement,
            worker,
          );
          break;
        case MessageType.MUTATE:
          // console.info(`mutation from worker: ${data.type}`, data);
          mutate(
            (data as MutationFromWorker)[TransferrableKeys.nodes],
            (data as MutationFromWorker)[TransferrableKeys.strings],
            (data as MutationFromWorker)[TransferrableKeys.mutations],
            sanitizer,
          );
          break;
      }
    };
  });

createWorker() が何をしているか

// TODO(KB): Fetch Polyfill for IE11.
export function createWorker(
  workerDomURL: string,
  authorScriptURL: string
): Promise<Worker | null> {
  return Promise.all([
    fetch(workerDomURL).then(response => response.text()),
    fetch(authorScriptURL).then(response => response.text())
  ])
    .then(([workerScript, authorScript]) => {
      // TODO(KB): Minify this output during build process.
      const keys: Array<string> = [];
      for (let key in document.body.style) {
        keys.push(`'${key}'`);
      }
      const code = `
        'use strict';
        ${workerScript}
        (function() {
          var self = this;
          var window = this;
          var document = this.document;
          var localStorage = this.localStorage;
          var location = this.location;
          var defaultView = document.defaultView;
          var Node = defaultView.Node;
          var Text = defaultView.Text;
          var Element = defaultView.Element;
          var SVGElement = defaultView.SVGElement;
          var Document = defaultView.Document;
          var Event = defaultView.Event;
          var MutationObserver = defaultView.MutationObserver;

          function addEventListener(type, handler) {
            return document.addEventListener(type, handler);
          }
          function removeEventListener(type, handler) {
            return document.removeEventListener(type, handler);
          }
          this.appendKeys([${keys}]);
          ${authorScript}
        }).call(WorkerThread.workerDOM);
//# sourceURL=${encodeURI(authorScriptURL)}`;
      return new Worker(URL.createObjectURL(new Blob([code])));
    })
    .catch(error => {
      return null;
    });
}

WebWorker の blob モードで、 Worker のScript を文字列として組み立てる。

(JSDOM で node 環境で DOM をモックする時のコードに似ている…)

というわけで、 this.document を生成しているのは worker スクリプトだと予想できるので、そちらを読む。

src/worker-thread/index.ts

import { HTMLAnchorElement } from './dom/HTMLAnchorElement';
import { HTMLButtonElement } from './dom/HTMLButtonElement';
import { HTMLDataElement } from './dom/HTMLDataElement';
import { HTMLEmbedElement } from './dom/HTMLEmbedElement';
import { HTMLFieldSetElement } from './dom/HTMLFieldSetElement';
import { HTMLFormElement } from './dom/HTMLFormElement';
import { HTMLIFrameElement } from './dom/HTMLIFrameElement';
import { HTMLImageElement } from './dom/HTMLImageElement';
import { HTMLInputElement } from './dom/HTMLInputElement';
import { HTMLLabelElement } from './dom/HTMLLabelElement';
import { HTMLLinkElement } from './dom/HTMLLinkElement';
import { HTMLMapElement } from './dom/HTMLMapElement';
import { HTMLMeterElement } from './dom/HTMLMeterElement';
import { HTMLModElement } from './dom/HTMLModElement';
import { HTMLOListElement } from './dom/HTMLOListElement';
import { HTMLOptionElement } from './dom/HTMLOptionElement';
import { HTMLProgressElement } from './dom/HTMLProgressElement';
import { HTMLQuoteElement } from './dom/HTMLQuoteElement';
import { HTMLScriptElement } from './dom/HTMLScriptElement';
import { HTMLSelectElement } from './dom/HTMLSelectElement';
import { HTMLSourceElement } from './dom/HTMLSourceElement';
import { HTMLStyleElement } from './dom/HTMLStyleElement';
import { HTMLTableCellElement } from './dom/HTMLTableCellElement';
import { HTMLTableColElement } from './dom/HTMLTableColElement';
import { HTMLTableElement } from './dom/HTMLTableElement';
import { HTMLTableRowElement } from './dom/HTMLTableRowElement';
import { HTMLTableSectionElement } from './dom/HTMLTableSectionElement';
import { HTMLTimeElement } from './dom/HTMLTimeElement';
import { createDocument } from './dom/Document';
import { WorkerDOMGlobalScope } from './WorkerDOMGlobalScope';
import { appendKeys } from './css/CSSStyleDeclaration';

declare var __ALLOW_POST_MESSAGE__: boolean;

const doc = createDocument(__ALLOW_POST_MESSAGE__ ? (self as DedicatedWorkerGlobalScope).postMessage : undefined);
export const workerDOM: WorkerDOMGlobalScope = {
  document: doc,
  addEventListener: doc.addEventListener.bind(doc),
  removeEventListener: doc.removeEventListener.bind(doc),
  localStorage: {},
  location: {},
  url: '/',
  appendKeys,
  HTMLAnchorElement: HTMLAnchorElement,
  HTMLButtonElement: HTMLButtonElement,
  HTMLDataElement: HTMLDataElement,
  HTMLEmbedElement: HTMLEmbedElement,
  HTMLFieldSetElement: HTMLFieldSetElement,
  HTMLFormElement: HTMLFormElement,
  HTMLIFrameElement: HTMLIFrameElement,
  HTMLImageElement: HTMLImageElement,
  HTMLInputElement: HTMLInputElement,
  HTMLLabelElement: HTMLLabelElement,
  HTMLLinkElement: HTMLLinkElement,
  HTMLMapElement: HTMLMapElement,
  HTMLMeterElement: HTMLMeterElement,
  HTMLModElement: HTMLModElement,
  HTMLOListElement: HTMLOListElement,
  HTMLOptionElement: HTMLOptionElement,
  HTMLProgressElement: HTMLProgressElement,
  HTMLQuoteElement: HTMLQuoteElement,
  HTMLScriptElement: HTMLScriptElement,
  HTMLSelectElement: HTMLSelectElement,
  HTMLSourceElement: HTMLSourceElement,
  HTMLStyleElement: HTMLStyleElement,
  HTMLTableCellElement: HTMLTableCellElement,
  HTMLTableColElement: HTMLTableColElement,
  HTMLTableElement: HTMLTableElement,
  HTMLTableRowElement: HTMLTableRowElement,
  HTMLTableSectionElement: HTMLTableSectionElement,
  HTMLTimeElement: HTMLTimeElement,
};

// workerDOM ends up being the window object.
// React now requires the classes to exist off the window object for instanceof checks.

(これは…気合じゃな?)

なんと Web 標準の DOM と互換な API の HTMLElement をひたすら実装している!

src/worker-thread/dom/

仮説: 実際には副作用が worker 経由で外に飛んでいってる命令になってるはず。その setter がどうなってるか追えば良い。

眺めた感じ、継承元になっている src/worker-thread/dom/Element.ts を読む

export class Element extends Node {
  // ...
  set textContent(text: string) {
    // TODO(KB): Investigate removing all children in a single .splice to childNodes.
    this.childNodes.forEach(childNode => childNode.remove());
    this.appendChild(new Text(text));
  }
}

src/worker-thread/node.js の appendChild で何が起こるか

  public appendChild(child: Node): void {
    child.remove();
    child.parentNode = this;
    propagate(child, 'isConnected', this.isConnected);
    this.childNodes.push(child);

    mutate({
      addedNodes: [child],
      previousSibling: this.childNodes[this.childNodes.length - 2],
      type: MutationRecordType.CHILD_LIST,
      target: this,
    });
  }

(追記: あとでわかったが、単に MutationObserver をスペック通りに実装してるだけ。しばらくその実装を追ってる。自分が MutationObserver を使ったことがないので、しばらく気づけなかった)

mutate が怪しい

import { mutate } from "../MutationObserver";

なので src/worker-thread/MutationObserver.ts を見る

/**
 * When DOM mutations occur, Nodes will call this method with MutationRecords
 * These records are then pushed into MutationObserver instances that match the MutationRecord.target
 * @param record MutationRecord to push into MutationObservers.
 */
export function mutate(record: MutationRecord): void {
  observers.forEach(observer => {
    if (
      !observer.options.subtreeFlattened ||
      record.type === MutationRecordType.COMMAND
    ) {
      pushMutation(observer, record);
      return;
    }

    let target: Node | null = record.target;
    let matched = match(observer.target, target);
    if (!matched) {
      do {
        if ((matched = match(observer.target, target))) {
          pushMutation(observer, record);
          break;
        }
      } while ((target = target.parentNode));
    }
  });
}

ここで appendChild のときの引数と突き合わせてコードの動き方をイメージしてみる

mutate({
  addedNodes: [child],
  previousSibling: this.childNodes[this.childNodes.length - 2],
  type: MutationRecordType.CHILD_LIST,
  target: this
});

今回は MutationRecordType.CHILD_LIST なので、実行されるのは後半。

let matched = match(observer.target, target); は observer というたぶん実 DOM に紐づく何かと与えられたものを突き合わせて、自身に該当するか判定している。 do ~ while で変更された Node から親方向に対して変更フラグを立てる。

const match = (observerTarget: Node | null, target: Node): boolean =>
  observerTarget !== null && target._index_ === observerTarget._index_;

変更があれば、pushMutation() に、変更したいというレコードをそのまま渡す。

const pushMutation = (
  observer: MutationObserver,
  record: MutationRecord
): void => {
  observer.pushRecord(record);
  if (!pendingMutations) {
    pendingMutations = true;
    Promise.resolve().then(
      (): void => {
        pendingMutations = false;
        observers.forEach(observer =>
          observer.callback(observer.takeRecords())
        );
      }
    );
  }
};

observer.pushRecord(record) はそれっぽいとして、もし pending 中じゃなければ、Promise の Microtask で次フレームまで pendingMutations のフラグを立てる。おそらくだが、見た目上 worker 側が同期でも、実際に変更が適用されたという保障を受け取れる仕組みがあるんだろう。pendingMutations は全体で一つのロックになっている。

次フレームで pending の解除、 observers.forEach(observer => observer.callback(observer.takeRecords())); が発行され、何かが起こる。後回し。

先に observer.pushRecord() を見る。

export class MutationObserver {
  //...
  public takeRecords(): MutationRecord[] {
    return this._records_.splice(0, this._records_.length);
  }

  /**
   * NOTE: This method doesn't exist on native MutationObserver.
   * @param record MutationRecord to store for this instance.
   */
  public pushRecord(record: MutationRecord): void {
    this._records_.push(record);
  }

ついでに takeRecords も見つけた。配列の中身を全部切り出して返す。(splice) pushrecord は 裏で this._records_ に突っ込んでいる。これを渡す observer.callback は?

export class MutationObserver {
  public callback: (mutations: MutationRecord[]) => any;
  private _records_: MutationRecord[] = [];
  public target: Node | null;
  public options: MutationObserverInit;

  constructor(callback: (mutations: MutationRecord[]) => any) {
    this.callback = callback;
  }
  //...

つまり、次フレームに、変更された record を全部切り出してどこか外に送りつける。

callback に代入してるのは…

  constructor(callback: (mutations: MutationRecord[]) => any) {
    this.callback = callback;
  }

というわけで、つまり MutationObserver が何によって生成されてるのかを追うことになりそう。

src/worker-thread/dom/Document.js

export class Document extends Element {
  public defaultView: {
    document: Document;
    MutationObserver: typeof MutationObserver;
    Document: typeof Document;
    Node: typeof Node;
    Text: typeof Text;
    Element: typeof Element;
    SVGElement: typeof SVGElement;
    Event: typeof Event;
  };
  //...

これ単なる MutationObserver の実装だ!使ったことないので気づけなかった! MutationObserver - Web API インターフェイス | MDN を実装してるだけだ!名前で気づくべきだった。

いやしかし、個々まで来ると MutationObserver を使ってるのは間違いない、という確信がある。 ここでおもむろに postMessage で grep する(勘)。すると、たぶんこれだっていうの見つけた。

export function observe(doc: Document, postMessage: Function): void {
  if (!observing) {
    document = doc;
    new doc.defaultView.MutationObserver(mutations => handleMutations(mutations, postMessage)).observe(doc.body);
    observing = true;
  } else {
    console.error('observe() was called more than once.');
  }
}

handleMutations!!たぶんここ!

function handleMutations(incoming: MutationRecord[], postMessage?: Function): void {
  if (postMessage) {
    postMessage(hydrated === false ? serializeHydration(incoming) : serializeMutations(incoming));
  }
  hydrated = true;
}

postMessage の実体を呼んでいる!!!!なんか多分色々やってJSONに落としてる!!(あとで読む)

src/main-thread/install.ts に onmessage があった気がするので、そこで受け取り口を見る。

    worker.onmessage = ({ data }: MessageFromWorker) => {
      switch (data[TransferrableKeys.type]) {
        case MessageType.HYDRATE:
          // console.info(`hydration from worker: ${data.type}`, data);
          hydrate(
            (data as HydrationFromWorker)[TransferrableKeys.nodes],
            (data as HydrationFromWorker)[TransferrableKeys.strings],
            (data as HydrationFromWorker)[TransferrableKeys.addedEvents],
            baseElement,
            worker,
          );
          break;
        case MessageType.MUTATE:
          // console.info(`mutation from worker: ${data.type}`, data);
          mutate(
            (data as MutationFromWorker)[TransferrableKeys.nodes],
            (data as MutationFromWorker)[TransferrableKeys.strings],
            (data as MutationFromWorker)[TransferrableKeys.mutations],
            sanitizer,
          );
          break;
      }
    };

今回は mutate 起点で読んでたので、この MessageType.MUTATE にたどり着いてそう。

ここで sereiarizeMutations の実装を探す。 src/transfer/DocumentMutations.ts

function serializeMutations(mutations: MutationRecord[]): MutationFromWorker {
  const nodes: Array<TransferrableNode> = consumeNodes().map(node => node._creationFormat_);
  const transferrableMutations: TransferrableMutationRecord[] = [];
  mutations.forEach(mutation => {
    let transferable: TransferrableMutationRecord = {
      [TransferrableKeys.type]: mutation.type,
      [TransferrableKeys.target]: mutation.target._index_,
    };

    mutation.addedNodes && (transferable[TransferrableKeys.addedNodes] = serializeNodes(mutation.addedNodes));
    mutation.removedNodes && (transferable[TransferrableKeys.removedNodes] = serializeNodes(mutation.removedNodes));
    mutation.nextSibling && (transferable[TransferrableKeys.nextSibling] = mutation.nextSibling._transferredFormat_);
    mutation.attributeName != null && (transferable[TransferrableKeys.attributeName] = storeString(mutation.attributeName));
    mutation.attributeNamespace != null && (transferable[TransferrableKeys.attributeNamespace] = storeString(mutation.attributeNamespace));
    mutation.oldValue != null && (transferable[TransferrableKeys.oldValue] = storeString(mutation.oldValue));
    mutation.propertyName && (transferable[TransferrableKeys.propertyName] = storeString(mutation.propertyName));
    mutation.value != null && (transferable[TransferrableKeys.value] = storeString(mutation.value));
    mutation.addedEvents && (transferable[TransferrableKeys.addedEvents] = mutation.addedEvents);
    mutation.removedEvents && (transferable[TransferrableKeys.removedEvents] = mutation.removedEvents);

    transferrableMutations.push(transferable);
  });

  return {
    [TransferrableKeys.type]: MessageType.MUTATE,
    [TransferrableKeys.strings]: consumeStrings(),
    [TransferrableKeys.nodes]: nodes,
    [TransferrableKeys.mutations]: transferrableMutations,
  };
}

なんか色々やって Transferable な MutationFromWorker を作っている。(postMessageできる形に変換するという意味。JSONだと思ってよい) ここまでやって気づいたが、mutation.addedEvents && (transferable[TransferrableKeys.addedEvents] = mutation.addedEvents); 見る限り、 MainThread <=> Worker 間の Event の交換もしてそう。 click event とか。つまり worker-dom は MutationObserver 起点であると同時に、異なるスレッドへの Event の Gateway でもある。気がする。MessageType.HYDRATE がそれっぽい名前してる。

MainTheard 側の mutate も追ってみよう。

src/main-thread/mutator.ts

export function mutate(
  nodes: Array<TransferrableNode>,
  stringValues: Array<string>,
  mutations: Array<TransferrableMutationRecord>,
  sanitizer?: Sanitizer,
): void {
  //mutations: TransferrableMutationRecord[]): void {
  // TODO(KB): Restore signature requiring lastMutationTime. (lastGestureTime: number, mutations: TransferrableMutationRecord[])
  // if (performance.now() || Date.now() - lastGestureTime > GESTURE_TO_MUTATION_THRESHOLD) {
  //   return;
  // }
  // this.lastGestureTime = lastGestureTime;
  stringValues.forEach(value => storeString(value));
  nodes.forEach(node => createNode(node));
  MUTATION_QUEUE = MUTATION_QUEUE.concat(mutations);
  if (!PENDING_MUTATIONS) {
    PENDING_MUTATIONS = true;
    requestAnimationFrame(() => syncFlush(sanitizer));
  }
}

(このコメントアウトは元から。自分が書いたものじゃない)

たぶん MUTATION_QUEUE = MUTATION_QUEUE.concat(mutations); を食うやつがいるはずだ。

requestAnimationFrame で呼ばれる syncFlush がそれだった。

function syncFlush(sanitizer?: Sanitizer): void {
  MUTATION_QUEUE.forEach(mutation => {
    mutators[mutation[TransferrableKeys.type]](mutation, getNode(mutation[TransferrableKeys.target]), sanitizer);
  });
  MUTATION_QUEUE = [];
  PENDING_MUTATIONS = false;
}

つまり mutators[mutation[TransferrableKeys.type]](mutation, getNode(mutation[TransferrableKeys.target]), sanitizer); が実DOM適用。

const mutators: {
  [key: number]: (mutation: TransferrableMutationRecord, target: RenderableElement, sanitizer?: Sanitizer) => void;
} = {
  [MutationRecordType.CHILD_LIST](mutation: TransferrableMutationRecord, target: RenderableElement, sanitizer: Sanitizer) {
    (mutation[TransferrableKeys.removedNodes] || []).forEach(node => getNode(node[TransferrableKeys._index_]).remove());

    const addedNodes = mutation[TransferrableKeys.addedNodes];
    const nextSibling = mutation[TransferrableKeys.nextSibling];
    if (addedNodes) {
      addedNodes.forEach(node => {
        let newChild = getNode(node[TransferrableKeys._index_]);
        if (!newChild) {
          newChild = createNode(node as TransferrableNode);
          if (sanitizer) {
            sanitizer.sanitize(newChild); // TODO(choumx): Inform worker?
          }
        }
        target.insertBefore(newChild, (nextSibling && getNode(nextSibling[TransferrableKeys._index_])) || null);
      });
    }
  },
  [MutationRecordType.ATTRIBUTES](mutation: TransferrableMutationRecord, target: RenderableElement, sanitizer?: Sanitizer) {
    const attributeName =
      mutation[TransferrableKeys.attributeName] !== undefined ? getString(mutation[TransferrableKeys.attributeName] as number) : null;
    const value = mutation[TransferrableKeys.value] !== undefined ? getString(mutation[TransferrableKeys.value] as number) : null;
    if (attributeName != null && value != null) {
      if (!sanitizer || sanitizer.validAttribute(target.nodeName, attributeName, value)) {
        target.setAttribute(attributeName, value);
      } else {
        // TODO(choumx): Inform worker?
      }
    }
  },
  [MutationRecordType.CHARACTER_DATA](mutation: TransferrableMutationRecord, target: RenderableElement) {
    const value = mutation[TransferrableKeys.value];
    if (value) {
      // Sanitization not necessary for textContent.
      target.textContent = getString(value);
    }
  },
  [MutationRecordType.PROPERTIES](mutation: TransferrableMutationRecord, target: RenderableElement, sanitizer?: Sanitizer) {
    const propertyName =
      mutation[TransferrableKeys.propertyName] !== undefined ? getString(mutation[TransferrableKeys.propertyName] as number) : null;
    const value = mutation[TransferrableKeys.value] !== undefined ? getString(mutation[TransferrableKeys.value] as number) : null;
    if (propertyName && value) {
      if (!sanitizer || sanitizer.validProperty(target.nodeName, propertyName, value)) {
        target[propertyName] = value;
      } else {
        // TODO(choumx): Inform worker?
      }
    }
  },
  [MutationRecordType.COMMAND](mutation: TransferrableMutationRecord) {
    process(worker, mutation);
  },
};

長いので、一番簡単な、文字列適用っぽいやつだけ読む。

  [MutationRecordType.CHARACTER_DATA](mutation: TransferrableMutationRecord, target: RenderableElement) {
    const value = mutation[TransferrableKeys.value];
    if (value) {
      // Sanitization not necessary for textContent.
      target.textContent = getString(value);
    }
  },

target.textContent = getString(value); がよく知るDOM操作だ。

呼び出し元の mutators[mutation[TransferrableKeys.type]](mutation, getNode(mutation[TransferrableKeys.target]), sanitizer); と突き合わせると、getNode が MainThread 側の target を探し出し、シリアライズされた mutation オブジェクトから該当する操作を適用する。

src/main-thread/nodes.ts

export function getNode(id: number): RenderableElement {
  const node = NODES.get(id);

  if (node && node.nodeName === 'BODY') {
    // If the node requested is the "BODY"
    // Then we return the base node this specific <amp-script> comes from.
    // This encapsulates each <amp-script> node.
    return BASE_ELEMENT as RenderableElement;
  }
  return node as RenderableElement;
}

NODES に HTMLElement の実体を登録しているのがこいつ。

export function storeNode(node: HTMLElement | SVGElement | Text, id: number): void {
  (node as RenderableElement)._index_ = id;
  NODES.set(id, node as RenderableElement);
}

node._index_ に id 登録して、worker側に振ったID と突き合わせて見つけ出している。ちょっと雑っぽい。ちゃんとやるなら WeakMap 使ったほうが良さそう。

その実態をちょっと追うと、 src/NodeMapping.ts にあった

import { Node } from './dom/Node';

let count: number = 0;
let transfer: Array<Node> = [];
const mapping: Map<number, Node> = new Map();

/**
 * Stores a node in mapping, and makes the index available on the Node directly.
 * @param node Node to store and modify with index
 * @return index Node was stored with in mapping
 */
export function store(node: Node): number {
  if (node._index_ !== undefined) {
    return node._index_;
  }

  mapping.set((node._index_ = ++count), node);
  transfer.push(node);
  return count;
}

/**
 * Retrieves a node based on an index.
 * @param index location in map to retrieve a Node for
 * @return either the Node represented in index position, or null if not available.
 */
export function get(index: number | null): Node | null {
  // mapping has a 1 based index, since on first store we ++count before storing.
  return (!!index && mapping.get(index)) || null;
}

/**
 * Returns nodes registered but not yet transferred.
 * Side effect: Resets the transfer array to default value, to prevent passing the same values multiple times.
 */
export function consume(): Array<Node> {
  const copy = transfer;
  transfer = [];
  return copy;
}

++count が id 生成ロジック。idを持ってなければ、nodeを汚して返す。 transfer は consume() でさらに転送される?ちょっとわからないので、ここは追うのはあとで。

まとめ(再掲)

今回わかったこと

  • Worker 側に DOM のセマンティクスをまるごと実装している
  • Worker 側に実装された MutationObserver で、 MutationRecord を捕まえる
  • MutationRecord を JSON にシリアライズし、postMeassge で MainThread へ送りつける
  • MainThread で、シリアライズされた MutationRecord を元に、実DOM へ適用操作を行う

読んでないけど、おそらくやってること

  • MainThread で発生した Event を捕まえて、JSON にシリアライズし、postMessage で Worker に送る
  • Worker で Event をデシリアライズし、リスナーを発行する
  • その結果副作用が起こると、 ↑ の

DOMの実装コードに、React を動かすには〜みたいなコメントがあるので、結構そのあたりを意識してる

感想

おそらくAMPのJS対応の過程で出てきたものだが、特に AMP に閉じたツールではない。 Mutation と Event のスレッド間の gateway を実装している。 Web標準互換なDOM APIセット作るなんて簡単だぜ!っていう気概がないとできない発想で、Googleから出てきたものっぽい。

実際パフォーマンス出るかは不明だけど、一応 requestAnimationFrame でバッチ化されるはずだから、問題ないのかな? 仮想DOMと同じで、仮想の Worker DOMと Real DOM の対応が崩れると、何もかも壊れる。ある種の仮想DOMみたいに見える。

課題

getBoundingRect とか event.preventDefault() みたいな同期縛りがあるAPIは、今の所実現不可能に見える

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment