ブログにコードプレビューを実装してみた

< Back to WRITINGS

コードブロックを自動的に埋め込めるようにしたい

フロントエンドの解説をしているサイトなんかでは、実際に動くコードを埋め込んだりしていますが、これを自前で用意できないかな〜と思い、できるだけ省力に実装してみました。

動作確認

Svelte, React, Vanilla HTML のコードを埋め込めるようにしてみました。

Svelte

Svelte Counter Ref:null
<script>
  let a = $state(15);
  let b = $state(25);
  let c = $derived(a + b);
</script>

<div style:padding="1rem">
  <input type="number" bind:value={a}>
  + <input type="number" bind:value={b}> = {c}
</div>

<style>
  p {
    font-size: 1.5rem;
  }
  input {
    width: 5rem;
    padding: 0.5rem;
  }
</style>

React

React Counter Ref:null
const [count, setCount] = useState(0);

return (
  <div
    style={{
      padding: "1rem",
      color: "#fff",
    }}
  >
    <button onClick={() => setCount((c) => c + 1)}>
      Increment
    </button>
    <button
      onClick={() => setCount((c) => c - 1)}
      style={{ marginLeft: "0.5rem" }}
    >
      Decrement
    </button>
    <p>Count: {count}</p>
  </div>
);

HTML

HTML Ref:null
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>
      button {
        background-color: #61dafb;
        color: #fff;
        padding: 0.5em 1em;
        border-radius: 100px;
        border: none;
      }
    </style>
  </head>
  <body>
    <p>HTML + CSS + JSのバニラ構成で書けます。</p>
    <button onclick="increment()">Count: 0</button>
    <script>
      let count = 0;
      function increment() {
        count++;
        const button = document.querySelector("button");
        button.textContent = `Count: ${count}`;
      }
    </script>
  </body>
</html>

中身の技術について

今回は、CodePenのようにページの訪問者がコードを編集出来る必要がないので、Web標準であるWeb Componentを使用して、各コードブロックを独立したカスタム要素としてカプセル化することにしました。

  • 記事解析時の処理: 記事のMarkdownをパースする際、フラグのついたコードブロックを検出し、そのコードをサーバー内のレジストリに登録します。このとき、コード内容のハッシュ値をIDとして発行し、記事のHTMLにはそのIDを持ったプレースホルダー(ただの空div)を出力しておきます。

  • ビルド・配信: 記事更新の後、esbuild等を用いて、WebComponentとして動作するJavaScriptにバンドルします。 バンドルされたJSはメモリに置いて置かれ、それを返すエンドポイントによってサーブされます。

  • WebComponent(CustomElements)実装: 配信されるスクリプトは、ブラウザ上でcustomElements.define を実行し、 独自タグ(例:<wc-abc1234>)を定義します。クライアント側では、 プレースホルダーが画面に表示されたタイミングでスクリプトを動的インポートし、独自タグをDOMに追加するだけで、 ShadowDOM 内でコンポーネントが描画されます。

フレームワークごとの対応

Svelte は公式で Web Component(customElement)へのコンパイルオプション (generate: 'client', customElement: true) を持っているため、それを有効にしてコンパイルするだけで済みました。非常に親和性が高いです。

React は標準では Web Component 化する機能がないため、react-dom/clientcreateRoot を使って Shadow DOM の中に React のルートを作成し、そこにコンポーネントをマウントするようなラッパーコードを自動生成してバンドルしています。

HTML は単純に iframesrcdoc に内容を流し込む形の Web Component としてラップしています。これで外部CSSやスクリプトの影響を受けずにクリーンな環境でプレビューできます。

ポータビリティとバンドルサイズ

これによってビルドされたコンポーネントは完全にスタンドアロンで動作するため、メインのブログシステム(SvelteKit)に依らず、どんなフレームワークのコードでも安全に埋め込みやすくなっています。

また、バンドルサイズも比較出来たため見てみましょう。

当たり前ですが、HTMLが圧倒的に小さいですね。 表現している内容を揃えていないのであまる参考になりませんが、Minifyしてもsvelteはreactの1/3程なので、確かに小さいのかな?

[web-components] compiling 16bdbc9c4dfc70a2 (svelte, tag wc-16bdbc9c4dfc70a2, build v1)
[web-components] compiling 668b42b6038a7ef1 (react, tag wc-668b42b6038a7ef1, build v1)
[web-components] compiling 65e69b4ae6c7edf1 (html, tag wc-65e69b4ae6c7edf1, build v1)
[web-components] bundled 65e69b4ae6c7edf1 -> 1.7 KiB
[web-components] bundled 16bdbc9c4dfc70a2 -> 44.8 KiB
[web-components] bundled 668b42b6038a7ef1 -> 140.4 KiB

ちなみに、Minifyしないと、Reactは900KiB程度、Svelteは100KiB程度でした。

おわりに

この機能はCSSアニメーションを表示したいという需要から実装に至ったのですが、ShadowDOMの仕様?と思われる制約で、CSSアニメーションを作る際に必須な@propertyをCSSに書いても効かないなんてことがありました。そこは結局JSから解決しましたが、多分ShadowDOM越境してしまっています。

CSSアニメーションで遊ぼう!

最後に、そもそもなんでこんなことをしているかということに触れておくと、自分のブログは静的サイトではないことに原因があります。

大人しく静的サイトを構築するなら、mdsvexでmarkdownをSvelteコンポーネントにして、staticでビルドすればいいのですが、記事のリアルタイム更新をしたいという要件があったため、markdownをhtmlに変換し、埋め込んだりしているわけです。

静的サイトではないのでサーバーのランタイム上でビルドする必要があり、セキュリティや軽量さを考慮する箇所が増えてしまったわけです。

それでも、この方法はウェブ標準に乗っかれる為切り分けやすく、ほかでも活用できそうなので、なにか思いついたら実装しようと思います。