使用 htmx 構建單頁應用

人們談論 htmx 好像它正在拯救網絡免於單頁應用的困擾。React 讓開發人員陷入了複雜性的泥潭 (據說是這樣), 而 htmx 則提供了一個急需的救生索。

htmx 的創建者 Carson Gross 以一種幽默的方式 這樣解釋 [1] 這種動態:

不, 這是一個黑格爾辯證法:

  • 正題: 傳統的多頁應用
  • 反題: 單頁應用
  • 合題 (更高形式): 具有交互性島嶼的超媒體驅動應用

好吧, 看來我錯過了備忘錄, 因爲 我用 htmx 構建了一個單頁應用 [2] 。

這是一個簡單的概念驗證待辦事項列表。一旦頁面加載完成, 就不再與服務器進行任何額外的通信。所有操作都在客戶端本地進行。

考慮到 htmx 專注於管理網絡上的超媒體交換, 這是如何實現的呢?

通過一個簡單的技巧: 1"服務器端" 代碼在 service worker[3] 中運行。

簡而言之, service worker 充當網頁和更廣泛的互聯網之間的代理。它攔截網絡請求並允許你操作它們。你可以更改請求, 緩存響應以便離線服務, 甚至在不向瀏覽器之外發送請求的情況下憑空創建新的響應。

最後一種能力就是驅動這個單頁應用的關鍵。當 htmx 發出網絡請求時, service worker 會攔截它。然後 service worker 運行業務邏輯並生成新的 HTML,htmx 再將其替換到 DOM 中。

與使用 React 等構建的傳統單頁應用相比, 這還有幾個優勢。Service worker 必須使用 IndexedDB[4] 進行存儲, 這在頁面加載之間是有狀態的。如果你關閉頁面然後再回來, 應用會保留你的數據 - 這是 "免費" 發生的, 是選擇這種架構的 成功陷阱 [5] 結果。該應用還可以離線工作, 這不是免費的, 但一旦設置好 service worker 就很容易添加。

當然, service worker 也有很多陷阱。一個是開發者工具中絕對糟糕的支持, 它們似乎會間歇性地吞掉console.log, 並且不可靠地報告 service worker 何時安裝。另一個是 Firefox 缺乏對 ES 模塊的支持, 這迫使我將所有代碼 (包括 IDB Keyval[6] 的供應商版本, 我包含它是因爲 IndexedDB 同樣令人煩惱) 放在一個文件中。

這不是一個詳盡的列表! 我會將使用 service worker 的一般體驗描述爲 "不好玩"。

但是! 儘管如此, htmx 單頁應用還是可以工作。讓我們深入瞭解一下!

幕後原理

讓我們從 HTML 開始:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta  />
    <title>htmx spa</title>
    <script src="https://unpkg.com/htmx.org@1.9.9"></script>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body
    hx-boost="true"
    hx-push-url="false"
    hx-get="./ui"
    hx-target="body"
    hx-trigger="load"
  >
    <script>
      // 註冊service worker的代碼
    </script>
  </body>
</html>

如果你曾經構建過單頁應用, 這應該看起來很熟悉: 一個空殼的 HTML 文檔, 等待 JavaScript 填充。那個長長的內聯<script>標籤只是設置 service worker, 並且 大部分是從 MDN 偷來的 [7] 。

這裏有趣的部分是<body>標籤, 它使用 htmx 設置了應用的主要部分:

所以基本上:/ui返回應用的實際標記, 此時 htmx 接管任何鏈接和表單以使其具有交互性。

/ui是什麼? 進入 service worker! 它使用一個小型的自制 Express 風格 "庫" 來處理路由請求和返回響應的樣板代碼。該庫的實際工作原理超出了本文的範圍, 但它是這樣使用的:

app.get("/ui", async (req, res) => {
  const filter = new URL(req.url).searchParams.get("filter") ?? "all";
  await setFilter(filter);
  res.header("HX-Push", `?filter=${filter}`);
  const todos = await listTodos();
  const html = App({ filter, todos });
  return res.html(html);
});

當對/ui發出GET請求時, 這段代碼...

  1. 獲取查詢字符串中的過濾器
  2. 將過濾器保存在 IndexedDB 中
  3. 告訴 htmx 相應地更新 URL
  4. 使用活動過濾器和待辦事項列表將App"組件" 渲染爲 HTML
  5. 將渲染的 HTML 返回給瀏覽器

setFilterlistTodos是包裝 IDB Keyval 的簡單函數:

async function setFilter(filter) {
  return set("filter", filter);
}

async function listTodos() {
  return get("todos") ?? [];
}

App組件看起來像這樣:

function App({ filter, todos }) {
  const filtered = todos.filter(todo => {
    if (filter === "active") return !todo.done;
    if (filter === "completed") return todo.done;
    return true;
  });

  return html`
    <main>
      <h1>todos</h1>

      <form>
        <label>
          <input type="radio"  checked=${filter === "all"} />
          All
        </label>
        <label>
          <input type="radio"  checked=${filter === "active"} />
          Active
        </label>
        <label>
          <input type="radio"  checked=${filter === "completed"} />
          Completed
        </label>
      </form>

      <ul>
        ${filtered.map(todo => Todo({ todo, editing: false }))}
      </ul>

      <form hx-post="/todos/add" hx-target=".todos" hx-select=".todos">
        <input type="text" What needs to be done?" />
      </form>
    </main>
  `;
}

(同樣, 我們將跳過一些實用函數, 如html, 它只是在插值值時提供一些小便利。)

App可以大致分爲三個部分:

讓我們看看那個/todos/add路由:

app.post("/todos/add", async (req, res) => {
  const text = new URLSearchParams(await req.text()).get("text");
  const todos = await listTodos();
  todos.push({ id: crypto.randomUUID(), text, done: false });
  await set("todos", todos);
  const filter = await getFilter();
  const html = App({ filter, todos });
  return res.html(html);
});

非常簡單! 它只是保存待辦事項並返回重新渲染的 UI 的響應, 然後 htmx 將其交換到 DOM 中。

現在, 讓我們看看之前的Todo組件:

function Todo({ todo, editing }) {
  return html`
    <li>
      <input
        type="checkbox"
        checked=${todo.done}
        hx-get="/todos/${todo.id}/update?done=${!todo.done}"
        hx-target="body"
      />
      <button hx-delete="/todos/${todo.id}" hx-target="body">×</button>
      ${
        editing
          ? html`
              <form hx-put="/todos/${todo.id}/update" hx-target="body">
                <input type="text" ${todo.text}" />
              </form>
            `
          : html`
              <span hx-get="/ui/todos/${todo.id}?editable=true" hx-target="closest li" hx-swap="outerHTML">
                ${todo.text}
              </span>
            `
      }
    </li>
  `;
}

這裏有三個主要部分: 複選框、刪除按鈕和待辦事項文本。

首先是複選框。每次選中或取消選中時, 它都會觸發對/todos/${id}/updateGET請求, 查詢字符串done與其當前狀態匹配; htmx 將完整響應交換到<body>中。

以下是該路由的代碼:

app.get("/todos/:id/update", async (req, res) => {
  const id = req.params.id;
  const done = new URL(req.url).searchParams.get("done") === "true";
  const todos = await listTodos();
  const todo = todos.find(todo => todo.id === id);
  if (!todo) return res.status(404);
  todo.done = done;
  await set("todos", todos);
  const filter = await getFilter();
  const html = App({ filter, todos });
  return res.html(html);
});

(注意, 該路由還支持更改待辦事項文本。我們稍後會講到這一點。)

刪除按鈕更簡單: 它向/todos/${id}發出DELETE請求。與複選框一樣, htmx 將完整響應交換到<body>中。

以下是該路由:

app.delete("/todos/:id", async (req, res) => {
  const id = req.params.id;
  const todos = await listTodos();
  const index = todos.findIndex(todo => todo.id === id);
  if (index === -1) return res.status(404);
  todos.splice(index, 1);
  await set("todos", todos);
  const filter = await getFilter();
  const html = App({ filter, todos });
  return res.html(html);
});

最後一部分是待辦事項文本, 由於支持編輯文本而變得更加複雜。有兩種可能的狀態:"正常", 只顯示帶有待辦事項文本的簡單<span>(我很抱歉這不可訪問!) 和 "編輯", 顯示允許用戶編輯的<input>Todo組件使用editing"prop" 來確定要渲染哪個狀態。

然而, 與 React 等客戶端框架不同, 我們不能只是在某處切換狀態並讓它進行必要的 DOM 更改。htmx 爲新 UI 發出網絡請求, 我們需要返回一個超媒體響應, 然後它可以將其交換到 DOM 中。

以下是該路由:

app.get("/ui/todos/:id", async (req, res) => {
  const id = req.params.id;
  const editable = new URL(req.url).searchParams.get("editable") === "true";
  const todos = await listTodos();
  const todo = todos.find(todo => todo.id === id);
  if (!todo) return res.status(404);
  const html = Todo({ todo, editing: editable });
  return res.html(html);
});

在高層次上, 網頁和 service worker 之間的協調看起來像這樣:

  1. htmx 監聽待辦事項文本<span>上的雙擊事件
  2. htmx 向/ui/todos/${id}?editable=true發出請求
  3. service worker 返回包含<input>而不是<span>Todo組件的 HTML
  4. htmx 將當前待辦事項列表項與響應中的 HTML 交換

當用戶更改輸入時, 會發生類似的過程, 調用/todos/${id}/update端點並交換整個<body>。如果你使用過 htmx, 這應該是一個非常熟悉的模式。

就是這樣!我們現在已經用 htmx(和 service workers) 構建了一個不依賴遠程 web 服務器的單頁應用。爲簡潔起見省略的代碼可以在 GitHub[8] 上找到。

總結

從技術上講, 這確實可行。但這是個好主意嗎? 這是基於超媒體應用的頂峯嗎? 我們應該放棄 React 而改用這種方式構建應用嗎?

htmx 通過爲 UI 添加間接性來工作, 從網絡邊界加載新的 HTML。這在客戶端 - 服務器應用中可能有意義, 因爲它通過將數據庫與渲染放在一起來減少對數據庫的間接訪問。另一方面, 像 React 這樣的框架中的客戶端 - 服務器模式可能會很痛苦, 需要通過一個笨拙的數據交換通道在客戶端和服務器之間進行仔細協調。

然而, 當所有交互都是本地的時候, 渲染和數據已經是 (在內存中) 並置的, 用像 React 這樣的框架同步更新它們很容易。在這種情況下, htmx 所需的間接性開始感覺更像是一種負擔而不是解放。3 對於完全本地的應用, 我認爲這種方式不值得。

當然, 大多數應用並不是完全本地的 - 通常會混合本地交互和網絡請求。我的感覺是, 即使在這種情況下, 交互性孤島 [9] 模式也比將你的 "服務器端" 代碼分割在 service worker 和實際服務器之間要好。

無論如何, 這主要是一個練習, 看看使用超媒體而不是命令式或函數式編程來構建一個完全本地的單頁應用會是什麼樣子。

請注意, 超媒體是一種技術而不是特定的工具。我選擇 htmx 是因爲它是當前流行的超媒體~ 庫~ 框架 [10] , 我想盡可能地拓展它的能力。還有其他明確專注於這種用例的工具, 如 Mavo[11] , 事實上你可以看到 Mavo 實現的 TodoMVC[12] 比我在這裏構建的要簡單得多。更好的是某種類似 HyperCard 的應用, 你可以在其中以可視化的方式構建整個應用。

總的來說, 我的這個小型 htmx 單頁待辦事項應用很有趣。如果沒有其他收穫, 就把這當作一個提醒, 你可以而且應該偶爾嘗試以奇怪和意想不到的方式使用你的工具!

  1. React 開發者討厭他! ↩

  2. 你可能注意到表單方法是GET而不是POST。這是因爲 Firefox 中的 service workers 似乎不支持請求體, 這意味着我們需要在 URL 中包含任何相關數據。↩

  3. htmx 實際上不是這種架構的必需組件。理論上, 你可以構建一個完全客戶端的單頁應用, 除了 service worker 之外不使用任何 JavaScript, 只需將每個按鈕包裝在一個<form>標籤中, 並在每個操作時替換整個頁面。由於響應都來自 service worker, 它仍然會非常快; 你甚至可以使用 跨文檔視圖轉換 [13] 添加一些流暢的動畫。↩

參考鏈接

  1. 這樣解釋: https://twitter.com/htmx_org/status/1736849183112360293
  2. 我用 htmx 構建了一個單頁應用: https://jakelazaroff.github.io/htmx-spa/
  3. service worker: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
  4. IndexedDB: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
  5. 成功陷阱: https://blog.codinghorror.com/falling-into-the-pit-of-success/
  6. IDB Keyval: https://github.com/jakearchibald/idb-keyval
  7. 大部分是從 MDN 偷來的: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
  8. GitHub: https://github.com/jakelazaroff/htmx-spa
  9. 交互性孤島: https://www.patterns.dev/vanilla/islands-architecture/
  10. 框架: https://htmx.org/essays/is-htmx-another-javascript-framework/
  11. Mavo: https://mavo.io/
  12. Mavo 實現的 TodoMVC: https://mavo.io/demos/todo/
  13. 跨文檔視圖轉換: https://developer.chrome.com/docs/web-platform/view-transitions#cross-document_view_transitions
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/O_Fhjb_RmlSDeenqzY62DQ