この DOM がすごい2018: worker-dom

おもしろライブラリを見つけて興奮しているので紹介します。

UIスレッド(メインスレッド)からユーザー操作をブロックしてしまうような重い処理を逃がす off-the-main-thread を実践しようとなると、実際に問題になるのは、ほとんどの処理は何らかの形で DOM を参照し、それに連なるものが処理時間の殆どを占めている、ということです。

off-the-main-thread の時代 - mizchi's blog

DOM に触れない WebWorker でビジネスロジックを処理するのは、ある種の健全性(Universal/Isomorphic)を手に入れるための「縛りプレイ」として有用ですが、現状は実用上のメリットが殆どありません。

例えば react / redux の reducer で、ビジネスロジックを worker 側に移して処理できるぐらいアイソモーフィックに(DOMに触れずに/nodeでテストできるように)記述することは可能ですが、 redux の処理がボトルネックになることはほぼなく、基本的にその後のReactの内部的な更新処理がユーザーの処理をロックするのがほとんどです。React/Vueのようなライブラリの処理をWorker側に逃したいと思っても、DOMと密接に絡んでいる以上、現状はその手段がありません。

worker-dom

そんな中、AMP Project から面白いものが出てきました。

ampproject/worker-dom: The same DOM API and Frameworks you know, but in a Web Worker.

WebWorkerでDOMを実行できるようにする、という実装です。

動くのはこんなコード

<!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>

ここまでは Main Thread での Worker の起動部分

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);

(APIがなんかダサいですが、アルファ版ということで…)

このJSが worker 側で動いて id="root" の DOM が操作される、ってことですね。

これがなぜ AMP からでてきたかというと、AMPは色々とビジネス的な側面もあってややこしいのですが、基本的にはベストプラクティスの強制によってWebの体験を良くしたい、というのが彼らの発想です。例えばレンダリング最適化の為の CSS インライン化であったり、レンダリングブロッキングするような JS の禁止だったり…

で、AMP Project は別に好き好んでJSをブロックしている訳ではなく、彼らが嫌っているのは JS によるメインスレッドの専有です。なので Worker で DOM 操作すればいいのでは?という発想の元、 worker で DOM を動かす、というライブラリを試験的に試しているんでしょう。AMPから出てきたといっても、特にAMPでしか使えないというものではないです。(そもそもサブセットなので)

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

どうやって Worker 上での DOM を実現しているか

というわけでコードを読んできました。

基本的には、 Worker側に実装した MutationObserver をメインスレッドに postMessage して適用する、という感じです。

この記事の前段階として、コードリーディングのメモがあるので置いておきます。 worker-dom-code-reading.md

Worker 側の変更を Main Thread に伝える

JavaScript で MutationObserver 含む DOM API を全部実装してるのが正気ではない感じですが、Googleならではの腕力という感じ…

UI Event を Worker に送る

  • Main Thread で発生した event をキャッチして、シリアライズして Worker に送る
  • Worker 側で イベントスナーを発火させる

この結果、Worker にまるで DOM があるかのように振る舞わせる事ができます。コード読むとWebWorkerの名前空間に self.document などを露出させていて、JSDOM でテストを書くときなどの雰囲気に近いですね。

つまり、今までこうだったのが

こうなるということです。

この UI Thread のJavaScript では UI Event を Worker に渡すだけで、Scripting 処理自体は初回にworkerを起動して、あとは postMessage するだけなので、基本的に Worker 側で全てのロジックを捌きます。例えばReact/Vue の仮想DOMの生成/比較のようなボトルネックになってた部分も解決する可能性があります。

demo見る限りは preact の実装があります。コードを読んでいても React を動かすには〜みたいなコメントが散見されるので、意識してるみたいですね https://github.com/ampproject/worker-dom/tree/master/demo/preact-todomvc

この手法の問題

  • event.preventDefault() のような同期的にイベントバブリングを中断する処理が実現できない
  • window.getComputedStyle のように、レンダラから値を取り出すような処理が出来ない(Worker側にレンダラがない為)
  • postMessage で JSONシリアライズ/デシリアライズのコストが余分に掛かる
  • MutationObserver がJSなので、ネイティブ実装に比べて遅いのではないか(要検証)

preventDefalut の部分は本質的な問題で、web-components でそもそもバブリングしないボタンを作るとか、Facebook の Yoga エンジンみたいなレンダラをWorker側に実装してしまうとか、そういう荒業が思いつかなくはないのと、縛りを受け入れて書くのは不可能ではないのですが、なんとかなってほしい…

展望

見た感じ筋が良さそうに見えます。裏側に偽のDOMを置いて、その差分だけ実体に通知するという発想は仮想DOMと似ていますね。

もしこれが実用可能になったら、既存のJSをそのまま移すという用途ではなく、ライブラリの下回りで使ってユーザーは気付いたら WebWorker 名前空間にいた、という体験になるんじゃないでしょうか。

追って調査して、実用できそうになったらまた報告します。