🧩

HTMLのデフォルト機能だけでパーシャルHTMLを埋め込む

2024/02/27に公開

最近発表されたこちらに衝撃を受けました。

https://leanrada.com/htmz/

htmzは、iframeを利用してhtmlの一部を書き換えるアイデアです(本記事ではhtmzの詳細な解説はしません)。
リポジトリはこちら。

https://github.com/Kalabasa/htmz

かなり古いブラウザ環境でも動きそうなミニマルなスニペットで、個人的に非常に好みです。なぜいままで現れなかったのでしょう…。

感動したので自分でいろいろ試していたところ、パーシャルHTMLの埋め込みを思いついたので記事にしました。

やりたいこと

以下のhtmlファイルがあるとしましょう。
管理の単純化や再利用性の向上のために、ページ要素を分割しているイメージです。

header.html
<header>
  <div>This is header</div>
</header>
main.html
<main>
  <div>This is main</div>
</main>
footer.html
<footer>
  <div>This is footer</div>
</footer>

これらの3ファイルをひとつにまとめて表示したいとします。
以下のようになるイメージです。

result
<header>
  <div>This is header</div>
</header>

<main>
  <div>This is main</div>
</main>

<footer>
  <div>This is footer</div>
</footer>

このようなファイル分割ができるライブラリやフレームワークは多いと思いますが、そういったものを使わずに実現するにはどうしたらよいでしょうか?

パーシャルレンダリングタグ

答えはこちらです。

<iframe
 hidden
 src="path/to/partial.html"
 onload="this.replaceWith(...contentDocument.body.childNodes)"
></iframe>

以下のことを行います。

  • iframe要素を定義し
  • 不可視(hidden)の状態にしておき
  • path/to/partial.htmlを読み込み
  • 自身と読み込んだ結果を置き換える

demo

実際に動かしてみます。前出の3ファイルと同じ階層に以下のindex.htmlを用意して開きます。

index.html
<iframe
  hidden
  src="header.html"
  onload="this.replaceWith(...contentDocument.body.childNodes)"
></iframe>
<iframe
  hidden
  src="main.html"
  onload="this.replaceWith(...contentDocument.body.childNodes)"
></iframe>
<iframe
  hidden
  src="footer.html"
  onload="this.replaceWith(...contentDocument.body.childNodes)"
></iframe>

header / main / footerがそれぞれ表示されているはずです。

ローカルで試す場合

クロスオリジンではcontentDocumentがnullになるので、単にファイルパスでhtmlファイルを開いても正常に動作しません。
適当なローカルサーバーを立てて、localhostからアクセスする必要があります。

以下はdenoで立てる簡単サーバーです。

server.ts
const contentTypes = {
  html: "text/html",
  // 必要に応じてtypeは追加…
  // css: "text/css",
  // js: "text/javascript",
};

async function handler(request: Request): [BodyInit, ResponseInit] {
  const { pathname } = new URL(request.url);
  const filename = pathname === "/" ? "index.html" : pathname.replace("/", "");

  const ext = filename.match(/\.([A-Za-z0-9]+)$/)?.[1] || "";
  console.log({ pathname, ext });
  try {
    const src = await Deno.readTextFile(filename);
    return [src, { headers: { "content-type": contentTypes[ext] } }];
  } catch (e) {
    console.warn(e);
    const [status, statusText] = (e.name === "NotFound")
      ? [404, "Not Found"]
      : [500, "Internal Server Error"];
    return [`${status}: ${statusText}`, { status, statusText }];
  }
}

Deno.serve(async (request: Request) => new Response(...await handler(request)));

deno run --allow-net --allow-read server.tsで起動できます。

カスタムタグ化

上記のiframeスニペットを、WebComponentsを使用してカスタム要素にしてみました。
カスタムタグ自体を書き換えるため、前述のスニペットと違いparentNodesを対象にしています。
(ついでにcontentDocument.headも含めているので、パーシャル内でスタイルを付けられます!)

render_partial.js
class RenderPartial extends HTMLElement {
  constructor() {
    super();

    const src = this.getAttribute("src");
    if (!src) return; // src is required

    const iframe = document.createElement("iframe");
    iframe.src = src;
    iframe.setAttribute("hidden", "hidden");

    const onload = `.replaceWith(
      ...contentDocument.head.childNodes,
      ...contentDocument.body.childNodes)`;

    if (this.hasAttribute("capsule")) {
      iframe.setAttribute("onload", `this${onload}`);
      this.attachShadow({ mode: "open" });
      this.shadowRoot.append(iframe);
    } else {
      iframe.setAttribute("onload", `parentNode${onload}`);
      this.append(iframe);
    }
  }
}
customElements.define("render-partial", RenderPartial);

使用時は上記スクリプト自体の読み込みが必要になりますが、タグの記述はかなり圧縮できます。

index.html
<script src="render_partial.js" defer></script>

<render-partial src="header.html"></render-partial>
<render-partial src="main.html"></render-partial>
<render-partial src="footer.html"></render-partial>

なお、<render-partial capsule src="./footer.html"></render-partial>のようにcapsule属性を指定することで、パーシャルhtml内のスタイリングを外部と遮断することもできます。

Discussion