htmx:Gopher 走向全棧的完美搭檔?

在傳統的 Web 開發領域,前端和後端開發通常被明確劃分。前端主要負責用戶界面的交互和視覺呈現,運用 HTML、CSS 和 JavaScript 等技術;後端則專注於服務器邏輯、數據庫管理和核心功能實現,常用 Go、Java、PHP、Ruby 等語言。

然而,隨着技術的不斷演進和開發流程的優化,全棧開發逐漸成爲一種趨勢。全棧開發者能夠在項目的不同階段靈活轉換角色,有效降低溝通成本和縮短開發週期。他們對系統的整體架構和工作原理有更深入的理解,從而能更高效地解決問題。此外,全棧技能也使得開發者在就業市場上更具競爭力,能夠承擔更多樣化的職責。

儘管如此,對於許多專注後端的工程師(包括衆多 Gopher)來說,前端開發仍然是一個不小的挑戰。它不僅要求熟悉 JavaScript 等語言,還需要理解複雜的前端框架和工具鏈。這使得不少後端開發者在面對全棧開發時感到力不從心。

幸運的是,技術的進步爲我們提供了更簡單、高效的開發途徑。Go 語言以其簡潔和高效著稱,而 htmx 庫則通過 HTML 屬性實現豐富的前端交互。將兩者結合,開發者可以在無需深入學習 JavaScript 的情況下,輕鬆實現全棧開發。這種組合不僅能夠顯著提升開發效率,還能充分利用服務器端渲染(SSR)的優勢,在性能和用戶體驗方面取得顯著提升。

那麼,htmx 是否真的是 Gopher 走向全棧的完美搭檔呢?在本文中,我們就將探討一下這個問題,介紹一下 htmx 的核心理念和工作原理,並結合代碼示例和使用場景,詳細分析 Go 和 htmx 如何協同工作。至於 Go+htmx 究竟有多能打,相信在本文最後,你會得出自己的評價!

  1. htmx:爲簡化前端開發而生

傳統的前端開發通常依賴於 JavaScript 框架,例如 React、Vue 或 Angular。這些框架雖然功能強大,但往往伴隨着高昂的學習成本和複雜的開發流程。對於那些主要從事後端開發的程序員來說,學習和掌握這些框架不僅需要花費大量時間,還需要深入理解前端生態系統中的各種概念和工具鏈。這種學習曲線和開發複雜性成爲了許多後端開發者的阻礙,同時也成爲了阻礙 Go 開發者邁向全棧的絆腳石。

htmx 的誕生正是爲了簡化前端開發,特別是對於那些不願意或沒有時間深入學習 JavaScript 的開發者。

htmx 的核心理念是通過擴展 HTML,使其具備更強大的功能,從而減少對 JavaScript 的依賴。它遵循了 "HTML 優先" 的設計原則,允許開發者直接在 HTML 元素中添加特殊的屬性來定義與服務器交互的行爲,比如動態加載、表單處理、局部刷新等,從而實現動態交互,而無需編寫任何 JavaScript 代碼。可以說,htmx 的出現爲後端開發者 (包括 Gopher) 提供了一種新的選擇,使得 Web 應用的開發變得更加直觀和簡便。

不過,htmx 自身卻是一個輕量級的 JavaScript 庫,這與 Go 的設計哲學有些 “異曲同工”,即簡單留給大家,複雜留給自己。作爲 js 庫,它提供了一組簡潔而強大的 API,通過設置 HTML 屬性,開發者就可以實現多種交互功能。以下是 htmx 的一些核心特性:

通過指定請求類型,htmx 可以在用戶觸發事件時向服務器發送請求,並處理響應。

支持指定服務器響應數據要插入的 DOM 元素,支持部分頁面更新而無需刷新整個頁面。

支持定義請求觸發的條件,例如點擊、鼠標懸停、表單提交等事件。

支持定義響應內容插入 DOM 的方式,可以選擇替換、插入、刪除等操作。

這些 API 的設計目標是讓開發者能夠通過聲明式的方式來實現前端邏輯,而不必依賴 JavaScript 代碼,以簡化開發過程。

由於幾乎無需後端開發者寫 JavaScript,HTMX 很容易被認爲是 **SSR(服務器端渲染)**的一種實現。它們看似很相似,但它們的思路並不完全一致。SSR 的渲染過程是在服務器上完成的,服務器生成整個 HTML 頁面的內容,並將其發送給客戶端。客戶端接收到完整的 HTML 直接展示給用戶。這也使得 SSR 通常可以提供更快的初始加載體驗,因爲用戶可以立即看到頁面內容,而不必等待 JavaScript 加載和執行。此外,由於 HTML 內容在服務器上渲染,搜索引擎更容易抓取和索引內容。

而 HTMX 的大部分渲染也是在服務端完成的,但它支持在客戶端通過 AJAX 請求動態更新頁面的某些部分,而不需要重新加載整個頁面,只是它是通過簡單的 HTML 屬性 (外加自身 js) 實現這些功能的,而無需用戶手工寫 JavaScript 實現。HTMX 還使得頁面能夠更具交互性,用戶可以在不離開當前頁面的情況下與應用程序進行交互。

因此,htmx 可以視爲一種結合 SSR 和 ** 局部 CSR(客戶端渲染)** 的技術,它讓你通過服務器端渲染 HTML,同時在客戶端實現靈活的動態交互功能。這使得開發者能夠在 SSR 提供的性能優勢和 SEO 友好性基礎上,提升用戶體驗而不必依賴完整的客戶端框架。

雖然保留了 CSR,但與傳統的 JavaScript 框架(如 React、Vue、Angular)相比,htmx 非常輕量,體積非常小,以撰寫本文時的最新 2.0.2 版本 htmx[1] 爲例,它的 js 包大小如下,壓縮版才 10 幾 k:

此外,傳統框架雖然功能強大,但往往需要複雜的配置和較高的學習成本,尤其對於習慣後端開發的開發者來說,更是如此。而使用 HTMX,只需掌握 HTML 和少量的 htmx API 即可開始開發,適合後端開發者快速上手。

說了這麼多 htmx 的優點,那基於 htmx 的開發究竟是怎樣的呢?下面我們就以 htmx 的幾個核心特性爲例,看看如何基於 htmx 開發簡單 web 應用。

  1. htmx 的基本用法

在前面我們瞭解了 htmx 的幾個核心特性,包括請求類型、目標更新等。下面我們就針對這些核心特性,舉幾個例子,大家初步瞭解一下基於 htmx 的開發 web 應用的流程。

我們先從請求類型開始,瞭解一下基於 htmx 如何向後端發起 POST/GET/PUT/DELETE 等請求。

2.1 示例 1:請求類型

在這第一個示例中,我們使用 Go 語言創建一個簡單的服務器,並使用 htmx 在前端實現不同類型的請求。下面是我們定義的 html 模板,其中包含了 htmx 的自定義屬性:

// go-htmx/demo1/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>HTMX Go Example</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        .row {
            margin-bottom: 10px;
        }
        button {
            width: 120px;
            margin-right: 10px;
        }
        .result {
            display: inline-block;
            width: 300px;
            border: 1px solid #ccc;
            padding: 5px;
            min-height: 20px;
        }
    </style>
</head>
<body>
    <h1>HTMX Request Types Demo</h1>

    <div class="row">
        <button hx-get="/api/get" hx-target="#get-result">GET Request</button>
        <span id="get-result" class="result"></span>
    </div>
    <div class="row">
        <button hx-post="/api/post" hx-target="#post-result">POST Request</button>
        <span id="post-result" class="result"></span>
    </div>
    <div class="row">
        <button hx-put="/api/put" hx-target="#put-result">PUT Request</button>
        <span id="put-result" class="result"></span>
    </div>
    <div class="row">
        <button hx-delete="/api/delete" hx-target="#delete-result">DELETE Request</button>
        <span id="delete-result" class="result"></span>
    </div>
</body>
</html>

在這個 HTML 模板文件中包含了四個按鈕,每個按鈕對應一種 http 請求類型(GET、POST、PUT、DELETE),具體的實現方式是每個按鈕都使用了相應的 htmx 屬性(hx-get、hx-post、hx-put、hx-delete)來指定請求類型和目標 URL。此外,所有按鈕都使用了 hx-target 來設置服務器的響應將被顯示的元素 id。以 get 請求 button 爲例,響應的值將被放到 id 爲 get-result 的 span 中。

對應的 Go 後端程序就非常簡單了,下面是代碼摘錄:

// go-htmx/demo1/main.go

package main

import (
 "fmt"
 "net/http"
 "os"
 "path/filepath"
)

func main() {
 http.HandleFunc("/", handleIndex)
 http.HandleFunc("/api/get", handleGet)
 http.HandleFunc("/api/post", handlePost)
 http.HandleFunc("/api/put", handlePut)
 http.HandleFunc("/api/delete", handleDelete)

 fmt.Println("Server is running on http://localhost:8080")
 http.ListenAndServe(":8080", nil)
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
 currentDir, _ := os.Getwd()
 filePath := filepath.Join(currentDir, "index.html")
 http.ServeFile(w, r, filePath)
}

func handleGet(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "Received a GET request")
}

func handlePost(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "Received a POST request")
}

func handlePut(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "Received a PUT request")
}

func handleDelete(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "Received a DELETE request")
}

運行該 server 後,用瀏覽器打開 localhost:8080,我們將看到下面頁面:

逐一點擊各個 Button,htmx 會將從服務器收到的響應內容放入對應的 span 中:

2.2 示例 2:觸發條件

在這個示例 2 中,我們將基於 htmx 實現對各種觸發條件的響應與處理,htmx 提供了 hx-trigger 屬性來應對這些不同的事件觸發,包括點擊、鼠標懸停和表單提交等。我們看下面 html 模板代碼:

// go-htmx/demo2/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>HTMX Trigger Demo</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        .demo-section {
            margin-bottom: 20px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .result {
            margin-top: 10px;
            padding: 5px;
            background-color: #f0f0f0;
            min-height: 20px;
        }
    </style>
</head>
<body>
    <h1>HTMX Trigger Demo</h1>

    <div class="demo-section">
        <h2>Click Trigger</h2>
        <button hx-get="/api/click" hx-trigger="click" hx-target="#click-result">
            Click me
        </button>
        <div id="click-result" class="result"></div>
    </div>

    <div class="demo-section">
        <h2>Hover Trigger</h2>
        <div hx-get="/api/hover" hx-trigger="mouseenter" hx-target="#hover-result" style="display: inline-block; padding: 10px; background-color: #e0e0e0;">
            Hover over me
        </div>
        <div id="hover-result" class="result"></div>
    </div>

    <div class="demo-section">
        <h2>Form Submit Trigger</h2>
        <form hx-post="/api/submit" hx-trigger="submit" hx-target="#form-result">
            <input type="text" >
            <button type="submit">Submit</button>
        </form>
        <div id="form-result" class="result"></div>
    </div>

    <div class="demo-section">
        <h2>Custom Delay Trigger</h2>
        <input type="text"  
               hx-get="/api/search" 
               hx-trigger="keyup changed delay:500ms" 
               hx-target="#search-result" 
               placeholder="Type to search...">
        <div id="search-result" class="result"></div>
    </div>
</body>
</html>

通過模板代碼,我們可以看到 hx-trigger 的多種用法:

下面是該示例的後端 go 代碼,邏輯非常簡單,針對每個事件調用,簡單返回一個字符串:

// go-htmx/demo2/main.go

... ...

func main() {
 http.HandleFunc("/", handleIndex)
 http.HandleFunc("/api/click", handleClick)
 http.HandleFunc("/api/hover", handleHover)
 http.HandleFunc("/api/submit", handleSubmit)
 http.HandleFunc("/api/search", handleSearch)

 fmt.Println("Server is running on http://localhost:8080")
 http.ListenAndServe(":8080", nil)
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
 currentDir, _ := os.Getwd()
 filePath := filepath.Join(currentDir, "index.html")
 http.ServeFile(w, r, filePath)
}

func handleClick(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "Button was clicked!")
}

func handleHover(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "You hovered over the element!")
}

func handleSubmit(w http.ResponseWriter, r *http.Request) {
 message := r.FormValue("message")
 fmt.Fprintf(w, "Form submitted with message: %s", message)
}

func handleSearch(w http.ResponseWriter, r *http.Request) {
 query := r.URL.Query().Get("search")
 fmt.Fprintf(w, "Searching for: %s", query)
}

運行該 server 後,用瀏覽器打開 localhost:8080,我們將看到下面頁面:

接下來,我們可以嘗試點擊按鈕、懸停在元素上、提交表單和在搜索框中輸入,看看每個操作如何觸發 HTMX 請求並更新頁面的相應部分,下面是觸發後的結果:

2.3 示例 3:交換方式

在示例 3 中,我們將展示如何使用 htmx 的 hx-swap 屬性實現不同的內容更新方式,包括替換、插入和刪除操作,其中還包含多種替換方式。下面是 html 模板:

// go-htmx/demo3/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>HTMX Swap Demo - All Attributes</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        .demo-section {
            margin-bottom: 20px;
            padding: 10px;
            border: 1px solid #ccc;
        }
        .content-box {
            margin-top: 10px;
            padding: 10px;
            border: 1px solid #ddd;
            min-height: 50px;
        }
        .item {
            margin: 5px 0;
            padding: 5px;
            background-color: #f0f0f0;
        }
    </style>
</head>
<body>
    <h1>HTMX Swap Demo - All Attributes</h1>

    <div class="demo-section">
        <h2>innerHTML (Default)</h2>
        <button hx-get="/api/swap/inner" hx-target="#inner-content">
            Swap innerHTML
        </button>
        <div id="inner-content" class="content-box">
            <p>This is the original content. The entire inner HTML will be replaced.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>outerHTML</h2>
        <button hx-get="/api/swap/outer" hx-target="#outer-content" hx-swap="outerHTML">
            Swap outerHTML
        </button>
        <div id="outer-content" class="content-box">
            <p>This entire div will be replaced, including its container.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>textContent</h2>
        <button hx-get="/api/swap/text" hx-target="#text-content" hx-swap="textContent">
            Swap textContent
        </button>
        <div id="text-content" class="content-box">
            <p>This <strong>text</strong> will be replaced, but HTML tags will be treated as plain text.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>beforebegin</h2>
        <button hx-get="/api/swap/before" hx-target="#before-content" hx-swap="beforebegin">
            Insert before
        </button>
        <div id="before-content" class="content-box">
            <p>New content will be inserted before this div.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>afterbegin</h2>
        <button hx-get="/api/swap/afterbegin" hx-target="#afterbegin-content" hx-swap="afterbegin">
            Insert at beginning
        </button>
        <div id="afterbegin-content" class="content-box">
            <p>New content will be inserted at the beginning of this div, before this paragraph.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>beforeend</h2>
        <button hx-get="/api/swap/beforeend" hx-target="#beforeend-content" hx-swap="beforeend">
            Insert at end
        </button>
        <div id="beforeend-content" class="content-box">
            <p>New content will be inserted at the end of this div, after this paragraph.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>afterend</h2>
        <button hx-get="/api/swap/after" hx-target="#after-content" hx-swap="afterend">
            Insert after
        </button>
        <div id="after-content" class="content-box">
            <p>New content will be inserted after this div.</p>
        </div>
    </div>

    <div class="demo-section">
        <h2>delete</h2>
        <button hx-get="/api/swap/delete" hx-target="#delete-content" hx-swap="delete">
            Delete content
        </button>
        <div id="delete-content" class="content-box">
            <p>This content will be deleted when the button is clicked.</p>
        </div>
    </div>
</body>
</html>

這個示例略複雜,它涵蓋了 hx-swap 的所有屬性:

爲了配合這個演示,我們編寫了一個簡單的 go 後端程序:

// go-htmx/demo3/main.go
... ...

func main() {
 http.HandleFunc("/", handleIndex)
 http.HandleFunc("/api/swap/inner", handleInner)
 http.HandleFunc("/api/swap/outer", handleOuter)
 http.HandleFunc("/api/swap/text", handleText)
 http.HandleFunc("/api/swap/before", handleBefore)
 http.HandleFunc("/api/swap/afterbegin", handleAfterBegin)
 http.HandleFunc("/api/swap/beforeend", handleBeforeEnd)
 http.HandleFunc("/api/swap/after", handleAfter)
 http.HandleFunc("/api/swap/delete", handleDelete)

 fmt.Println("Server is running on http://localhost:8080")
 http.ListenAndServe(":8080", nil)
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
 currentDir, _ := os.Getwd()
 filePath := filepath.Join(currentDir, "index.html")
 http.ServeFile(w, r, filePath)
}

func handleInner(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "<p>This content replaced the inner HTML at %s</p>", time.Now().Format(time.RFC1123))
}

func handleOuter(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "<div id=\"outer-content\" class=\"content-box\"><p>This div replaced the entire outer HTML at %s</p></div>", time.Now().Format(time.RFC1123))
}

func handleText(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "This replaced the text content at %s. <strong>HTML tags</strong> are not parsed.", time.Now().Format(time.RFC1123))
}

func handleBefore(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "<p class=\"item\">This content was inserted before the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleAfterBegin(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "<p class=\"item\">This content was inserted at the beginning of the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleBeforeEnd(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "<p class=\"item\">This content was inserted at the end of the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleAfter(w http.ResponseWriter, r *http.Request) {
 fmt.Fprintf(w, "<p class=\"item\">This content was inserted after the target div at %s</p>", time.Now().Format(time.RFC1123))
}

func handleDelete(w http.ResponseWriter, r *http.Request) {
 // For delete, we don't need to send any content back
 w.WriteHeader(http.StatusOK)
}

運行該 server 後,用瀏覽器打開 localhost:8080,你應該能看到一個包含八個不同部分的頁面,每個部分演示了 hx-swap 的一種屬性。你可以點擊每個部分的按鈕,觀察內容如何以不同的方式更新或變化。這個綜合示例展示了 hx-swap 的強大功能和靈活性,讓你可以精確控制如何更新頁面的不同部分。下面是你可以看到的效果呈現:

以上就是 htmx 核心屬性的用法,基於這些核心屬性,我們可以實現更多更爲複雜和高級的場景功能。在下一節,我們會舉兩個複雜一些的示例,供大家參考。

  1. 高級用法

3.1 基於 token 的身份認證

在使用 HTMX 作爲前端與後端進行交互時,通常會涉及到用戶身份認證 [2] 及鑑權 [3],其中一個常見場景是通過前端獲取的 Token(如 JWT)去訪問後端的受保護的 API。下面我們看看使用 HTMX 該如何實現這一常見功能。

下面是網站首頁的 html 模板,包含用戶登錄的 Form:

// go-htmx/demo4/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>HTMX Auth Example - Login</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <script>
        htmx.on('htmx:afterRequest'function(event) {
            if (event.detail.elt.id === 'login-form') {
                var xhr = event.detail.xhr;
                if (xhr.status === 200) {
                    var response = JSON.parse(xhr.responseText);
                    if (response.success) {
                        localStorage.setItem('auth_token', response.token);
                        window.location.href = response.redirect;
                    } else {
                        document.getElementById('message').innerText = response.message;
                    }
                } else {
                    document.getElementById('message').innerText = "An error occurred. Please try again.";
                }
            }
        });
    </script>
</head>
<body>
    <h1>HTMX Auth Example - Login</h1>
    <form id="login-form" hx-post="/login" hx-target="#message">
        <label for="username">Username:</label>
        <input type="text" id="username"  required><br><br>
        <label for="password">Password:</label>
        <input type="password" id="password"  required><br><br>
        <button type="submit">Login</button>
    </form>
    <div id="message"></div>
</body>
</html>

這個代碼片段結合了 HTMX 和 JavaScript,處理登錄表單的提交,以及登錄成功後將令牌(Token)存儲到瀏覽器的本地存儲中,並在登錄成功後重定向到 dashboard 頁面。

這段代碼監聽了 HTMX 的 htmx:afterRequest 事件。此事件在 HTMX 請求完成(即請求已經發出並接收到響應)後觸發,event.detail.elt 表示觸發事件的元素。代碼檢查該元素的 id 是否爲 login-form,確認這次請求來自登錄表單。如果是其他表單或元素觸發的請求,它將忽略。如果服務器的身份驗證成功,它以 json 格式返回 token 和重定向地址,前端會解析響應,並將 Token 存儲到本地存儲,然後自動跳轉到登錄後的 dashboard 頁面。

下面是 dashboard 頁面的 html 模板:

// go-htmx/demo4/dashboard.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>HTMX Auth Example - Dashboard</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <script>
        document.addEventListener('DOMContentLoaded'function() {
            htmx.on('htmx:configRequest'function(event) {
                var token = localStorage.getItem('auth_token');
                if (token) {
                    event.detail.headers['Authorization'] = 'Bearer ' + token;
                }
            });
        });
    </script>
</head>
<body>
    <h1>Welcome to Your Dashboard</h1>
    <button hx-get="/protected" hx-target="#protected-content">Access Protected Content</button>
    <div id="protected-content"></div>
</body>
</html>

這段代碼最值得關注的地方就是在後續發出的 Request 中自動加入之前獲取到的 token。這裏是使用了 htmx:configRequest 事件實現的。監聽 HTMX 的 htmx:configRequest 事件,該事件在 HTMX 發出請求之前觸發,它允許你修改即將發出的請求。這裏的 configRequest 的處理邏輯是:如果 Token 存在,將它添加到即將發出的請求的 Authorization 頭中,並格式化爲標準的 Bearer Token 形式(即 "Authorization: Bearer your_token_here")。這樣,後端在處理請求時可以從請求頭中提取出 Token,用於驗證用戶身份。

整個示例的後端 go 程序如下:

// go-htmx/demo4/main.go
package main

import (
 "encoding/json"
 "fmt"
 "html/template"
 "net/http"
 "strings"
 "sync"

 "github.com/google/uuid"
)

var (
 tokens   = make(map[string]bool)
 tokensMu sync.Mutex
)

type LoginResponse struct {
 Success  bool   `json:"success"`
 Token    string `json:"token,omitempty"`
 Message  string `json:"message"`
 Redirect string `json:"redirect,omitempty"`
}

func main() {
 http.HandleFunc("/", indexHandler)
 http.HandleFunc("/login", loginHandler)
 http.HandleFunc("/dashboard", dashboardHandler)
 http.HandleFunc("/protected", protectedHandler)
 fmt.Println("Server is running on http://localhost:8080")
 http.ListenAndServe(":8080", nil)
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
 if r.URL.Path != "/" {
  http.NotFound(w, r)
  return
 }
 http.ServeFile(w, r, "index.html")
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
 if r.Method != http.MethodPost {
  http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
  return
 }

 username := r.FormValue("username")
 password := r.FormValue("password")

 response := LoginResponse{}

 if username == "admin" && password == "password" {
  token := uuid.New().String()

  tokensMu.Lock()
  tokens[token] = true
  tokensMu.Unlock()

  response.Success = true
  response.Token = token
  response.Message = "Login successful"
  response.Redirect = "/dashboard"
 } else {
  response.Success = false
  response.Message = "Login failed. Please check your credentials and try again."
 }

 w.Header().Set("Content-Type""application/json")
 json.NewEncoder(w).Encode(response)
}

func dashboardHandler(w http.ResponseWriter, r *http.Request) {
 tmpl, err := template.ParseFiles("dashboard.html")
 if err != nil {
  http.Error(w, err.Error(), http.StatusInternalServerError)
  return
 }
 tmpl.Execute(w, nil)
}

func protectedHandler(w http.ResponseWriter, r *http.Request) {
 authHeader := r.Header.Get("Authorization")
 if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
  http.Error(w, "Unauthorized", http.StatusUnauthorized)
  return
 }

 token := strings.TrimPrefix(authHeader, "Bearer ")

 tokensMu.Lock()
 valid := tokens[token]
 tokensMu.Unlock()

 if !valid {
  http.Error(w, "Invalid token", http.StatusUnauthorized)
  return
 }

 fmt.Fprintf(w, `<div>
        <h2>Protected Content</h2>
        <p>This is sensitive information only for authenticated users.</p>
        <p>Your token: %s</p>
    </div>`, token)
}

注:這裏僅是示例,因此只是用了一個 uuid 作爲 token,沒有使用通用的 jwt。

運行程序,登錄並在 Dashboard 中點擊訪問 protected data,我們會看到下面圖中呈現的效果:

下面我們再來看一個略複雜一些的示例,這次我們基於 htmx 來實現 SSE(Server-Sent Event),即服務端事件。

3.2 SSE

Server-Sent Events (SSE) 是一種輕量級的實時通信技術,允許服務器通過 HTTP 協議持續向客戶端推送更新數據。與 WebSocket[4] 不同,SSE 是單向通信,服務器可以推送數據到客戶端,但客戶端無法通過同一連接向服務器發送數據。這種機制非常適合需要頻繁更新數據但對雙向通信要求不高的場景,如股票價格、新聞推送、社交媒體通知等。

htmx 對 SSE 的支持是通過擴展包實現的,下面就是本示例的 index.html 模板代碼:

// go-htmx/demo5/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>HTMX SSE Notifications</title>
    <script src="https://unpkg.com/htmx.org@1.9.6"></script>
    <script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
</head>
<body>
    <h1>實時通知</h1>
    <div hx-ext="sse" sse-connect="/events" sse-swap="message">
        <ul id="notifications">
            <!-- 通知將在這裏動態添加 -->
        </ul>
    </div>

    <script>
        htmx.on("htmx:sseMessage"function(event) {
            var ul = document.getElementById("notifications");
            var li = document.createElement("li");
            li.innerHTML = event.detail.message;
            ul.insertBefore(li, ul.firstChild);
        });
    </script>
</body>
</html>

這個代碼片段通過 HTMX 和 Server-Sent Events (SSE) 實現了實時通知的功能。它會動態將服務器端發送的通知添加到頁面的通知列表中。具體來說:

下面是示例對應的 Go 後端程序:

// go-htmx/demo5/main.go

func main() {
 http.HandleFunc("/", serveHTML)
 http.HandleFunc("/events", handleSSE)

 fmt.Println("Server starting on http://localhost:8080")
 log.Fatal(http.ListenAndServe(":8080", nil))
}

func serveHTML(w http.ResponseWriter, r *http.Request) {
 http.ServeFile(w, r, "index.html")
}

func handleSSE(w http.ResponseWriter, r *http.Request) {
 w.Header().Set("Content-Type""text/event-stream")
 w.Header().Set("Cache-Control""no-cache")
 w.Header().Set("Connection""keep-alive")

 flusher, ok := w.(http.Flusher)
 if !ok {
  http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
  return
 }

 notificationCount := 1

 for {
  notification := fmt.Sprintf("新通知 #%d: %s", notificationCount, time.Now().Format("15:04:05"))
  fmt.Fprintf(w, "data: <li>%s</li>\n\n", notification)
  flusher.Flush()

  notificationCount++
  time.Sleep(3 * time.Second)

  if r.Context().Err() != nil {
   return
  }
 }
}

運行程序,打開瀏覽器訪問 localhost:8080,在加載的頁面中會自動建立 sse 連接,頁面上的通知消息區便會如下面這樣每 3 秒一變化:

不過這個示例的程序有個 “瑕疵”,那就是如果將 htmx 的版本從 1.9.6 換作最新的 2.0.2,那麼示例就將不工作了,翻看了一下 htmx 文檔,應該是 sseMessage 這個 htmx 擴展屬性被刪除了。

如果要讓示例更具通用性,可以將 index.html 換成下面的代碼:

// go-htmx/demo6/index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>HTMX SSE Notifications</title>
    <script src="https://unpkg.com/htmx.org@2.0.2"></script>
    <style>
        #notification {
            padding: 10px;
            border: 1px solid #ccc;
            background-color: #f8f8f8;
            margin-top: 20px;
        }
    </style>
</head>
<body>
    <h1>實時通知</h1>
    <div id="notification-container">
        <div id="notification">等待通知...</div>
    </div>

    <script>
        document.body.addEventListener('htmx:load'function() {
            var notificationDiv = document.getElementById('notification');
            var evtSource = new EventSource("/events");

            evtSource.onmessage = function(event) {
                notificationDiv.textContent = event.data;
            };

            evtSource.onerror = function(err) {
                console.error("EventSource failed:", err);
            };
        });
    </script>
</body>
</html>

當然這個代碼更多使用 js 來實現事件的處理。

  1. 小結

本文探討了 Go 與 htmx 這一全棧組合的簡潔優勢。對於後端開發者而言,這一組合提供了一種無需深入掌握前端技術即可開發現代 Web 應用的高效途徑。

然而,從兩個高級示例中可以看出,JavaScript 代碼仍難以完全避免,雖然數量不多,但在稍複雜的場景下依然不可或缺。

因此,htmx 目前更多被中小型團隊或個人開發者所青睞。這類開發者通常沒有專職的前端人員,但希望快速構建並部署功能完善的 Web 應用。

綜上所述,在我這個對前端開發瞭解甚少的 Go 開發者看來,Go 與 htmx 的組合的確降低了開發門檻,同時提供了性能和 SEO 優勢,使其成爲現代 Web 開發中值得推薦的技術棧之一。不過,對於複雜的 Web 應用,開發者可能需要結合 htmx 和 JavaScript,或更可能直接採用 vue、react 或 angular 等框架。

目前 Go 社區對 htmx 的支持也越來越多,比如 html 模板引擎 templ[5] 可以用於生成 htmx 模板,當然也有專有的 htmx 框架,比如:ghtmx[6]、pagoda[7]、go-htmx[8] 等。

本文涉及的源碼可以在這裏 [9] 下載。

  1. 參考資料

參考資料

[1] 

最新 2.0.2 版本 htmx: https://unpkg.com/browse/htmx.org@2.0.2/dist/

[2] 

用戶身份認證: https://tonybai.com/2023/10/23/understand-go-web-authn-by-example/

[3] 

鑑權: https://tonybai.com/2023/11/04/understand-go-web-authz-by-example

[4] 

WebSocket: https://tonybai.com/2019/09/28/how-to-build-websockets-in-go

[5] 

html 模板引擎 templ: https://templ.guide

[6] 

ghtmx: https://gitlab.com/go-htmx/go-htmx

[7] 

pagoda: https://github.com/mikestefanello/pagoda

[8] 

go-htmx: https://github.com/donseba/go-htmx

[9] 

這裏: https://github.com/bigwhite/experiments/tree/master/go-htmx

[10] 

htmx.org: https://htmx.org/

[11] 

htmx sucks: https://htmx.org/essays/htmx-sucks/

[12] 

《HYPERMEDIA SYSTEMS》: https://hypermedia.systems/book/contents/

[13] 

Gopher 部落知識星球: https://public.zsxq.com/groups/51284458844544

[14] 

鏈接地址: https://m.do.co/c/bff6eed92687

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JINnuAODG2uPgwBXulIeZA