2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HTMXを試してみた

Last updated at Posted at 2024-01-17

はじめに

近年注目を集めたHTMXに興味を持って、ついに試す時間が出来ましたので、
その経験や感想を共有させていただきます。

HTMXとは?

HTMXの目的はJSを最低限にして、高度なUXを作ることです。
それはどういう実現できるのを見てみましょう。

前提

PugExpressを使っていますが、どんなサーバー、バックエンドやテンプレートエンジンを使っても問題ありません。

今回作るTwitterみたいなメッセージ投稿ページです。

リポジトリ

実装

レイアウトを定義

doctype html
html(lang="en")
  head
    title Twitter clone in htmx
    link(href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css", rel="stylesheet", crossorigin="anonymous")
    link(href="/css/global.css", rel="stylesheet", crossorigin="anonymous")
  body
    nav.navbar.navbar-dark.bg-dark.shadow-sm.py-0
        .container
            a.navbar-brand(href="#") htmx-twitter
            block navbar
    .container
        .row.justify-content-center
            main.col-10
                block content
    script(src="https://unpkg.com/htmx.org@1.9.5")
    script(src="https://unpkg.com/hyperscript.org@0.9.12")
    block script
    script(src="/js/main.js")

こちらは特に特別な実装が含まれていない、但し見える通りhtmxをscriptとして定義してます。
hyperscriptはよくHTMXと使われているので、入れてます。
main.jsはクライアント側で使うJSになります、HTMXの仕組みをつかっているので、後から説明説明させていただきます。

ページ

extends layout/main.pug
block navbar
  span.navbar-text.text-white  #{name}
block content
  p.text-center.mt-2 A Twitter clone in <a href="https://htmx.org">htmx</a> and Node
  div(hx-ext="ws,redirect-ws" ws-connect="/tweet")
    form(hx-ws="send" _="on submit reset() me")
      .mb-3.row
        label(for="txtMessage") Message:
        textarea#txtMessage.form-control(rows="3", name="message", required="true")
      .d-grid.gap-2.col-3.mx-auto.mb-3
          button.btn.btn-primary.text-center(type="submit") Tweet
  div
  #timeline(hx-get="/posts" hx-trigger="load, every 60s")
block script
  script(src="https://unpkg.com/htmx.org@1.9.5/dist/ext/ws.js")

こちらは面白い点が三つあります:
div(hx-ext="ws,redirect-ws" ws-connect="/tweet")

  • hx-ext: どんなHTMXのExtensionを利用するための設定です。
    • ws: WebSocketのExtensionを利用する(関連なjsファイルを含める必要がありますので、ext/ws.jsのスクリプトを入れてます)
    • redirect-ws: 自作のExtensionです。後から説明します。
  • ws-connect: WebSocketのEndpointの定義

これだけでWebSocketの接続が可能になります。
そのWebSocketを使って、もちろんメッセージの送信・受取は可能ですが、実はDOMの変化も可能です。

form(hx-ws="send" _="on submit reset() me")

  • hx-ws=send: こちらの設定でFormがsubmitされたら、WebSocketに送られる様にことになります。
  • _="on submit reset() me": こちらはHyperScriptなので、HTMXとは別ですが、相性がいいですので、よく一緒につかわれています。今回はただフォームのsubmitイベントの時にreset()ファンクションを実行する様に定義してます。

#timeline(hx-get="/posts" hx-trigger="load, every 60s")

  • hx-get="/posts": こちらの設定は/postsというhtmlファイルを取得する意味です。他の設定(hx-target)がなければ取得されたものは現タグの中身を入替えます。
  • hx-trigger="load, every 60s": こちらの設定は/postsを取得タイミングが指定されてます。
    • load: ページのロード時に取得されることを意味します。
    • every 60s: 60秒毎に取得されることを意味します。

サブページ(フラグメント)

/postsposts.pug

each t in [...tweets].reverse()
    - var replace = true
    include post

こちらは特に特別なことを入れていないのですが、replace変数をtrueに指定されていることを覚えてください。

post.pug

div(hx-swap-oob=replace ? "" : "afterbegin:#timeline")
  .card.mb-2.shadow-sm(id='tweet-' + t.id)
    .card-body
      .d-flex
        img.me-4(src=t.avatar, width="108")
        div
          h5.card-title.text-muted
            | #{t.username}
            small : #{t.time}
          .card-text.lead.mb-2
            | #{t.message}
          include retweets
          include likes

こちらはHTMXの設定一つあります:
hx-swap-oob=replace ? "" : "afterbegin:#timeline"

  • hx-swap-oob: この設定のおかげで親が指定している入替の場所を完全に違う場所に変えることが出来ます。
    • 今回はreplaceがtrueの場合、指定しないので、そのまま入れ替えられることになります。
    • ただし他の場合、afterbegin:#timelineに指定します。後で役に立ちます。
      • その意味は#timelineに該当するタグの中の最初のポジション(afterbegin)になります。

HTMXの入替はswapと言います。

retweets.pug

button.btn.btn-link.ps-0.text-decoration-none(id='retweet-' + t.id, type="button", hx-post="/retweet/" + t.id hx-swap="outerHTML") Retweet (#{t.retweets})

こちらもHTMXの設定二つあります:

  • hx-post="/retweet/ + t.id": この設定はボタンはクリックされたら、/retweet/:idのURLをPOSTでコールします。詳細はExpressJSの設定に説明します。
  • hx-swap="outerHTML": POSTがHTMLを返却した場合(WebSocket経由でしたら、idで入れ替えるので、この設定は不要です)、タグが入れ替える様に指定します。(デフォルトはinnerHTMLで、タグの中身が入れ替えられます)

ご気づきされたと思いますが、likes.pugも存在します。
ただretweets.pugと似たようなものですので、スキップさせてください。

クライアント側のJS(main.js

htmx.defineExtension('redirect-ws', {
    onEvent: function(name, event) {
        if (name === 'htmx:wsAfterMessage' && event.detail.message?.startsWith('{')) {
            const message = JSON.parse(event.detail.message)
            const redirect = message?.HEADERS?.['HX-Redirect']
            if (redirect) {
                alert(`Redirecting to ${redirect}`)
                window.location.pathname = redirect
            }
        }
    }
});

漸う唯一のブラウザーJSを書きました。
最初にhx-extredirect-wsを覚えていますか?今回のコードはそのredirect-wsを定義するコードになります。
それを登録するためのファンクションはhtmx.defineExtensionになります。

  • 最初のパラメーターは見える通りextensionの名称。
  • 二つ目はその実装:
    • 今回やりたかったのはWebSocketのメッセージがリダイレクトメッセージが届いたら、該当ページに遷移する様に。
    • それを実現するためにはhtmx:wsAfterMessageというイベント(WebSocketからのメッセージが届いた後に発行されるイベント)を使います。

サーバー側(ExpressJS)

ExpressJSの記事ではありませんので、一般な設定は説明をスキップさせてください。
コード全体を確認したいのでしたら、リポジトリにあるindex.jsになります。

WebSocket(/tweet)

const tweetChannel = expressWs.getWss("/tweet");

const tweets = [];

const addTweet = (message, username) => {
  const tweet = {
    id: v4(),
    message,
    username,
    retweets: 0,
    likes: 0,
    time: dayjs().to(dayjs(new Date().toString())),
    avatar:
      "https://ui-avatars.com/api/?background=random&rounded=true&name=" +
      username,
  };
  tweets.push(tweet);
  const markup = pug.compileFile("views/components/post.pug", {
    globals: ["global"],
  })({ t: tweet });

  tweetChannel.clients.forEach((client) => client.send(markup));
  return markup;
};

app.ws("/tweet", (ws, req, res) => {
  ws.on("message", function (msg) {
    const username = getJWT(req)?.username;
    if (username) {
      const { message } = JSON.parse(msg);
      addTweet(message, username);
    } else {
      ws.send(JSON.stringify({ HEADERS: { "HX-Redirect": "/login" } }));
    }
  });
});

先ずはページに使われているWebSocketから説明させていただきます。

  1. ユーザーネームを取得して、tweetsの配列(普通はデータベース等)に新しいtweetを追加、ユーザー情報がなければリダイレクトメッセージを返却。(redirect-wsを覚えてますか?)
  2. 新しく作成されたtweetを基にpost.pugを使って、HTMLは作成してWebSocketで各ユーザーに送信。
  • post.pugのreplaceを指定しないので、WebSocketで送信されたHTMLはHTMXの仕組みで自動的に取り入れて、hx-swap-oobのおかげただし場所に追加されます。

POST (/retweet/:id)

app.post("/retweet/:id", (req, res) => {
  const { id } = req.params;
  const tweet = tweets.find((t) => t.id === id);
  tweet.retweets += 1;

  const retweets = pug.compileFile("views/components/retweets.pug");
  const markup = retweets({ t: tweet });
  tweetChannel.clients.forEach((client) => client.send(markup));
  res.sendStatus(200);
});

単純に該当のtweetを取得して、retweets項目を上げて、HTMLを作成して、WebSocket経由でHTMLを各ユーザーに送信します。
/tweetと同じく、これで全ユーザーのページにHTMLが入れ替えられます。

PS: WebSocketに送信されなかったらPOSTの返却で同じHTMLを返却していることで、現ユーザーのHTMLが入れ替えられます。(res.sendStatus(200) → res.send(markup))

ボナース

本記事に詳細を含まれていないですが、HTMXのリダイレクト機能を使った、単純なログインロジックもリポジトリに含まれているので、興味があれば

感想

ちょっとしたプロジェクトですが、結果は想像以上に速いです。
今まで、React、Angular等を中心にしてましたので、慣れるまで、時間がかかるかもしれませんが、
複雑さの低いやSEOスコアを重要しているプロジェクトでしたら、十分価値があると思います。

長かったのですが、ここまで読んで頂いて誠にありがとうございます。お疲れ様です。
皆さん、HTMXはどうでしたか?興味になれましたのでしょうか?
既にHTMXを利用しているを

参考

htmx:JavaScriptなしでサーバーとの通信を実現する
参考のリポジトリ

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?