Qwik を静的ビルドして他から読み込む

2023/08/13に公開

qwik のビルド結果を静的コンポーネントとして読み込む

https://blog.cloudflare.com/better-micro-frontends/ というcloudflare によるマイクロフロントエンドの提案があります。 cf-workers にサービス個別に qwik の SSR サーバーをホストし、それを service-binding で合成して一つのフロントエンドとして表現します。

これ、別に SSR は必要なく最終的な静的アセットの状態を吸い出せれば別に cf-workers としてホストするすら必要ないんじゃないかと思い、やってみました。

やりたいこと

  • qwik を静的にビルドする
  • qwik の生成した HTML をそのまま他に埋め込む

Qwik は SSR First なフレームワークなので、JS を読み込むのではなく、一度 HTML として展開してから hydration する必要があります。また、hydration のために明示的にホストを指定してアセットを展開しておく必要もあります。

というのが動くように qwik-city のビルドオプションを色々調整していきます。

事前知識

qwikloader は qwik のコアランタイムで、1kb の軽量ランタイムです。ここに動的要素は含まれず、主に hydration を管理します。
詳しくはこれを読んでください

https://qwik.builder.io/docs/advanced/qwikloader/

qwikloader は埋め込まれたテンプレートの q:base を基準にハイドレーション時のアセットの解決を行います。つまり、こういう HTML のテンプレートを、何かしらの方法で評価します。

<div q:render="static-ssr" q:container="paused" q:version="1.2.6" q:base="http://localhost:9999/build/" q:locale q:manifest-hash="hkeq15" class="qc📦">
  <style q:style="lcydw1-0" hidden>:root{view-transition-name:none}</style>
  <button on:click="q-218fa054.js#s_BN0XWboDYVI[0]" q:id="4">0</button>
  <script>(async(t,e)=>{var n;if(!window._qcs&&history.scrollRestoration==="manual"){window._qcs=!0;const s=(n=history.state)==null?void 0:n._qCityScroll;s&&window.scrollTo(s.x,s.y);const o=document.currentScript;(await import(t))[e](o)}})('/build/q-61e731e2.js','s_DyVc0YBIqQU');</script>
  <!-- 略 -->
</div>

この場合、 button の click 時に http://localhost:9999/build/q-218fa054.js が呼ばれます。

最終的に、何かしらのホストに、こんな感じにホストしておく感じになります。

dist/
  build/
    q-xxx.js
    q-yyy.js
  index.html 

この HTML を外から fetch でテンプレートとして読み込みつつ、build をホストします。
見た目の帳尻を合わせるだけったら iframe でもいいかもしれませんが、せっかく qwik のパフォーマンス面のメリットが失われるので今回はやりません。

qwik-city で SSG プロジェクトを生成

まず qwik-city でプロジェクトを生成します。

$ npm create vite@latest play-qwik-ssg
# Qwik > Qwick City と選択
$ cd play-qwik-ssg
$ npm install
$ npx qwik add static
$ npm run build

これで SSG モードでビルドできます。 dist/ 下が静的サイトになるので、そのまま静的アセットとして netlify や cloudflare-pages にホストできます。

静的コンポーネントとしてビルドする

ここまででも便利なんですが、出力されるルート要素が html なので、そのまま別のページに埋め込むには不適切です。また、デフォルトの service worker 等も切っておく必要があります。

このために、SSR時の挙動を生成する src/entry.ssr.tsx をいじって、次のように変更します。

import {
  renderToStream,
  type RenderToStreamOptions,
} from "@builder.io/qwik/server";
import { manifest } from "@qwik-client-manifest";
import Root from "./root";

export default function (opts: RenderToStreamOptions) {
  return renderToStream(<Root />, {
    manifest,
    ...opts,
    // for ssr
    containerTagName: "div",
    prefetchStrategy: {
      implementation: { linkInsert: "html-append" },
    },
    qwikLoader: {
      include: "auto",
    },
    base: "http://localhost:9999/build/"
  });
}

また、ルート要素を変更してほぼ最小の状態にします。

src/root.tsx
import { component$ } from "@builder.io/qwik";
import {
  QwikCityProvider,
  RouterOutlet,
} from "@builder.io/qwik-city";

export default component$(() => {
  return (
    <QwikCityProvider>
      <RouterOutlet />
    </QwikCityProvider>
  );
});

ちなみに、この状態でどこかから読み込まれる前提で、スタンドアロンで動かなくなります。

何かのデバッグビルドと本番用をフラグで制御できそうな気がしますが、後で調べたら追記します。

静的コンポーネントにビルドして、外から読み込む

単純なカウンターコンポーネントを作ってテストします。

src/routes/index.tsx
import { component$, useSignal } from "@builder.io/qwik";
export default component$(() => {
  const counter = useSignal(0);
  return (
    <>
      <button onClick$={() => counter.value++}>Foo: {counter.value}</button>
    </>
  );
});

この状態でSSGビルドします。

$ npm run build
# 一旦 SSR server がビルドされ、そこから SSG 生成が行われる
# next export みたいなもの

# 中身を覗いてみる
$ tree dist/
dist/
├── 404.html
├── build
│   ├── q-039a6d94.js
│   ├── q-218fa054.js
...
│   └── q-f8135597.js
├── index.html
├── q-data.json
├── q-manifest.json
└── sitemap.xml

ここで必要なのは index.htmlbuild/* の中身だけです。 今回はやりませんが、 routes/foo/index.tsx 等で複数ページのビルドもできます。ただし、ルーティングは動かないか不整合が起きると思われます。

ここで dist/index.html の中身を見てみると、 ルート要素が <div q:render="static-ssr" q:container="paused" q:version="1.2.6" q:base="http://localhost:9999/build/" q:locale q:manifest-hash="hkeq15" class="qc📦"> みたいになってるはずです。これはポータブルにどの環境にも埋め込みます。

というわけで、雑にローカルにアセットサーバーを建てて、この HTML を読み込んでます。

$ npm run build # dist を生成
$ npx http-server -c-1 dist -p 9999 --cors

別ドメインから読むので、CORS フラグが必須です。

これとは別に、これを読み込む別のHTMLを用意します。どこでもいいのでペライチ html をおいて、別ドメインで serve します。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>Host</h1>
  <script type=module>
    const root = Object.assign(document.createElement("div"), {
      innerHTML: await fetch("http://localhost:9999/").then(res => res.text())
    });
    document.body.appendChild(root);
  </script>
</body>
</html>
$ mkdir host
$ edit host/index.html # この html
$ npx http-server -c-1 host/ -p 10000

動いているように見えますが、これだけでは hydration が動きません。 innerHTML で注入された script は評価されないからです。

というわけで、一手間掛けて script を再評価します。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>Host</h1>
  <script type=module>
    const root = Object.assign(document.createElement("div"), {
      innerHTML: await fetch("http://localhost:9999/").then(res => res.text())
    });
    for (const script of root.querySelectorAll("script")) {
      if (script.__exec ) continue;
      const newScript = document.createElement("script");
      for (const attr of Object.values(script.attributes)) {
        newScript.setAttribute(attr.name, attr.value);
      }
      newScript.textContent = script.innerHTML;
      newScript.__exec = true;
      script.replaceWith(newScript);
    }
    document.body.appendChild(root);
  </script>
</body>
</html>

これでようやく hydration が可能です。

というわけで動きました。

おわり

元ネタの cloudflare 版は、ルートの qwik のSSR時に、他マイクロサービスのSSRを一つのReadableStream として一つの合成にしているんですが、今回はSSRしないかわりにホスティングする複雑さを排除できています。

クライアントでロードする分、外部 html の script を再評価するので余計な手間が掛かってるんですが、手作業で HTML を埋め込むならばそれも不要です。

しばらく qwik にハマってそうです。

Discussion