返璞歸真!使用 alpinejs 開發互動式 web 應用,拋棄 node_modules 和 webpack 吧!

程序设计实验室發表於2024-11-21

前言

最近一直在使用 DjangoStarter 開發各種小專案,之前我是比較喜歡前後端分離的,後端用 Ninja API,前端 nextjs,開發起來也挺舒服的,互動體驗也比較好。

不過我在網上衝浪的時候也瞭解到有 htmx 和 alpine.js 這些和 Django 很搭配的輕量級前端開發庫,於是來新的玩具專案裡嘗試一下。(本文先來試試 alpine.js,以最近正在開發的 LiveChat 聊天專案為例)

Django template 這種後端渲染的開發方式是很方便的,不過有時候又需要在頁面上做一些互動,老一點的專案會選擇 jQuery,不過現在都停止維護了,我自然不可能選擇這個;我用的比較多的是在頁面裡引入 vue.js 來做簡單的互動,可以在一定程度上代替 jQuery,後面甚至還引入了 react,直接寫 jsx,不過沒有 webpack,直接引入 babel 來渲染,效能有點差。

參考:在 HTML 中引入 React 和 JSX

傳統的前端框架如 React、Vue.js 和 Angular 功能強大,但對於一些中小型專案或簡單的互動需求而言,它們可能顯得過於笨重,學習曲線也較為陡峭。這就促使開發者們尋找一種既輕量又靈活的解決方案,能夠在不引入大量複雜性的情況下實現動態互動。

Alpine.js 正是在這樣的背景下誕生的。它是一款輕量級的 JavaScript 框架,旨在提供像 Vue.js 和 React 那樣的宣告式和可組合的特性,但卻以極簡的方式實現。Alpine.js 允許開發者直接在 HTML 模板中嵌入互動邏輯,無需複雜的構建工具和配置,即可實現豐富的動態效果。

關於 LiveChat 專案

其實這個專案用到的技術和上次的文章 使用 Django-Channels 實現 websocket 通訊+大模型對話 裡差不多,不過這個是多人聊天,不是和大模型對話。

專案還在開發中,放個簡單的截圖,現在實現了文字和語音聊天。

關於 Alpine.js

Alpine.js 是一款輕量級的 JavaScript 框架,旨在為開發者提供一種簡單、高效的方法來為網頁新增互動功能。它受到了 Vue.js 的啟發,但比 Vue.js 更加輕量和簡潔。透過直接在 HTML 模板中嵌入簡潔的指令,Alpine.js 允許開發者以最小的程式碼量實現動態互動,而無需引入龐大的框架或複雜的構建工具。

官網: https://alpinejs.dev/

發展背景

Alpine.js 由開發者 Caleb Porzio 在 2020 年推出,旨在填補 jQuery 和現代前端框架之間的空白。它結合了 jQuery 的直接性和 Vue.js 的宣告式優點,提供了一種簡潔而強大的工具來構建互動式網頁。

核心特點

  • 輕量級:核心庫大小僅為幾 KB,不會顯著增加頁面的載入時間,非常適合對效能有嚴格要求的專案。
  • 直觀的語法:使用類似於 Vue.js 的指令(如 x-datax-bindx-on 等),讓開發者可以直接在 HTML 中編寫互動邏輯,程式碼更直觀易讀。
  • 易於整合:無需配置複雜的構建工具或專案結構,只需引入 Alpine.js 的指令碼檔案,即可在任何現有專案中使用。
  • 宣告式渲染:採用宣告式程式設計風格,專注於描述介面應該“是什麼”而非“如何”更新,大大簡化了開發流程。
  • 靈活性強:適用於從簡單的互動效果到中等複雜度的網頁應用,能夠滿足大多數前端開發需求。

與其他框架的比較

與 Vue.js 和 React 相比

  • 體積更小:Alpine.js 的大小僅為 Vue.js 和 React 的一個零頭,更加輕量。
  • 學習曲線平緩:不需要學習複雜的框架概念或 JSX 語法,熟悉 HTML 和基本的 JavaScript 即可上手。
  • 無需構建工具:省去了 Webpack、Babel 等構建工具的配置,降低了專案的複雜度。

與 jQuery 相比

  • 現代化的程式設計方式:Alpine.js 採用宣告式渲染和資料繫結,避免了繁瑣的 DOM 操作。
  • 更好的可維護性:程式碼結構清晰,邏輯與檢視緊密結合,方便後期維護和迭代。

適用場景

  • 小型專案或元件:當專案不需要龐大的框架時,Alpine.js 是理想的選擇。
  • 現有專案的增強:在傳統的後端渲染專案中,想要新增一些動態互動,可以直接引入 Alpine.js,無需重構專案結構。
  • 快速原型開發:適合於需要快速驗證想法或構建原型的場景。

安裝

相比起需要 webpack 的 vue 和 react 專案,使用 alpine.js 非常容易,只需要在 HTML 裡引入一個 js 檔案就行了

使用 CDN

最簡單的就是在頁面底部新增 js 引用,直接用 jsdelivr CDN

<html>
    <head>
        ...
 
        <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
    </head>
    ...
</html>

使用本地資源

DjangoStarter 使用 npm 來管理前端資源,所以在 DjangoStarter 裡整合會多一點步驟,不過也很簡單的

首先使用 npm 來安裝依賴

pnpm i alpinejs

npm 安裝完是在 node_modules 裡面,DjangoStarter 使用 gulp 來收集前端資源,所以接下來需要把 alpinejs 新增到 gulp 任務裡。

編輯 gulpfile.js

// 使用 npm 下載的前端元件包
const libs = [
  {name: 'alpinejs', dist: './node_modules/alpinejs/dist/**/*.*'},
]

執行 gulp 任務收集靜態資源

gulp move

這樣 alpinejs 就在專案的 static 目錄,可以在模板裡引用了

編輯模板,引入靜態資源,記得要載入 static 這個 template tag

{% load static %}

<html>
  <body>
    
    <script defer src="{% static 'lib/alpinejs/dist/cdn.min.js' %}"></script>
    {% block extra_js %}{% endblock %}
  </body>
</html>

這樣就搞定了,看似很複雜,實際上這些是在初始化專案的時候做的,接下來我也會把 alpinejs 和 htmx 整合到 DjangoStarter 新版本里面,所以這些步驟實際開發中是不需要重複去做的。

初始化

在需要使用 alpinejs 接管的元素新增 x-data 屬性

就像這樣

<div class="grid grid-cols-1 lg:grid-cols-4 gap-2 mb-4" x-data="chatApp()">
</div>

然後 chatApp() 在 js 裡定義

function chatApp() {
  return {
    messages: [],
    init() {
      // 初始化程式碼
    },
  }
}

程式碼已經簡化過了,比較容易閱讀

訊息框

直接上程式碼會比較直觀瞭解到我們是如何使用 alpinejs 開發的

後端渲染也能元件化,我編寫了一個 messages_container.html 元件

<!-- Messages -->
<div x-ref="messages"
     class="flex-1 space-y-4 overflow-y-auto rounded-xl bg-slate-200 px-4 py-2 text-sm leading-6 text-slate-900 shadow-sm dark:bg-slate-900 dark:text-slate-300 sm:text-base sm:leading-7">
  <template x-for="item in messages">
    <div>
      <template x-if="item.role==='system'">
        <div class="text-center text-sm text-gray-500" x-text="item.message"></div>
      </template>
      <template x-if="item.role==='user'">
        <div class="flex flex-row-reverse items-start gap-2">
          <div class="p-2 h-12 w-12 rounded-full bg-blue-500 flex justify-center items-center">
            <i class="fa-solid fa-user text-white"></i>
          </div>
          <div class="flex min-h-[85px] rounded-b-xl rounded-tl-xl bg-slate-50 p-4 dark:bg-slate-800 sm:min-h-0 sm:max-w-md md:max-w-2xl">
            <template x-if="item.type==='text'">
              <p x-text="item.message"></p>
            </template>
            <template x-if="item.type==='audio'">
              <div class="flex flex-col gap-2">
                <div>Audio <span x-text="item.length"></span>s</div>
                <button type="button" @click="playAudio(item)"
                        class="px-3 py-2 text-xs font-medium text-center inline-flex gap-2 items-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 ">
                  <i class="fa-solid fa-play text-white"> </i>
                  play
                </button>
              </div>
            </template>
          </div>
        </div>
      </template>
      <template x-if="item.role==='ai'">
        <div class="flex items-start gap-2">
          <div class="p-2 h-12 w-12 rounded-full bg-blue-500 flex justify-center items-center">
            <i class="fa-solid fa-robot text-white"> </i>
          </div>
          <div class="flex rounded-b-xl rounded-tr-xl bg-slate-50 p-4 dark:bg-slate-800 sm:max-w-md md:max-w-2xl">
            <p x-text="item.message"> </p>
          </div>
        </div>
      </template>
    </div>
  </template>
</div>

可以看到這裡面使用幾個不屬於標準HTML的屬性和元素

  • x-ref
  • x-for
  • x-if
  • template

接下來一個個介紹

x-ref

這個和 vue、react 裡的有點像

相當於直接使用 getElementById 或者 querySelector 來訪問元素

這裡主要是要實現訊息自動滾動到底部(這個等下再介紹)

x-for 和 x-if

顧名思義,就是迴圈和判斷,和 vue 的用法差不多

注意

  • 這倆語句只能搭配 template 元素使用
  • template 元素裡只能有一個元素
  • x-if 只能在有 x-data 的元素下才能生效

x-text

顯示訊息的時候,不像 vue 使用 {item.message} 來渲染,而是使用了 x-text 指令

<p x-text="item.message"></p>

這個指令可以設定元素內的文字,同理還有 x-html 指令,可以直接設定 innerHtml

實現訊息自動滾動到底部

來回憶一下之前在 react 裡是咋實現的

React.useEffect(() => {
  // 自動滾動到訊息容器底部
  messagesRef.current.scrollIntoView({behavior: 'smooth'})
}, [messages]);

透過使用 useEffect hook,監聽 messages 的變化,有變化的時候就滾動到底部

在 alpine.js 裡同樣能實現監聽,編輯 chatApp 裡面的 init() 方法,使用 $watch 魔法屬性

init() {
  this.$watch('messages', () => {
    console.log('$watch messages changed', this.messages)

    // 滾動到最新訊息
    this.$nextTick(() => {
      // this.$refs.messages.scrollIntoView({behavior: 'smooth'});
      // 另一種方法滾動到底部
      this.$refs.messages.scrollTop = this.$ refs.messages.scrollHeight;
    });
  })
}

這裡改成 scrollTop = scrollHeight 來實現滾動到底部,之前用過的 scrollIntoView 似乎不生效

Magic Properties

$ 開頭的在 alpine.js 裡叫做 magic property ,很多功能都是透過這類屬性來實現的。

常用魔法屬性:

  • $el:當前元素的引用。
  • $refs:引用其他帶有x-ref指令的元素。
  • $event:當前事件物件。
  • $nextTick:在下一次DOM更新後執行函式。

輸入框與資料繫結

繼續來透過程式碼來了解 Alpine.js 的核心概念

來看程式碼

<form class="mt-2">
  <label for="chat-input" class="sr-only">Enter your prompt</label>
  <div class="relative">
    <button
            type="button" @click="audioMode=!audioMode"
            class="absolute inset-y-0 left-0 flex items-center pl-6 text-slate-500 hover:text-blue-600 dark:text-slate-400 dark:hover:text-blue-600"
            >
      <i class="fa-solid fa-microphone text-2xl"></i>
      <span class="sr-only">use voice input</span>
    </button>
    <textarea
              id="chat-input"
              class="block w-full resize-none rounded-xl border-none bg-slate-200 p-4 pl-14 pr-20 text-sm text-slate-900 focus:outline-none focus:ring-1 focus:ring-blue-400 dark:bg-slate-900 dark:text-slate-200 dark:placeholder-slate-400 dark:focus:ring-blue-600 sm:text-base"
              placeholder="Please enter your message"
              rows="1"
              required
              x-model="message"
              :disabled="!receiver"
              @keydown.enter.prevent="sendMessage()"
              ></textarea>
    <button
            type="button" @click="sendMessage()" :disabled="!receiver"
            class="absolute bottom-2 right-2.5 rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-slate-200 hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 sm:text-base"
            >
      Send <span class="sr-only">Send</span>
    </button>
  </div>
</form>

資料雙向繫結

首先是資料繫結,這個與 Vue、Blazor 非常像

這個專案的輸入框使用了 <textarea> 元素,使用 x-model 指令實現表單輸入和資料狀態之間的雙向繫結。

修飾符

  • .lazy:在change事件而非input事件時更新資料。
  • .debounce:為輸入事件新增防抖,例如 x-model.debounce.500ms="query"

屬性繫結

使用 x-bind 指令(可簡寫為:),可以將資料繫結到元素的屬性上,實現屬性的動態更新。

在上面的程式碼中,體現為 <textare><button> 元素的 :disabled="!receiver"

事件處理

Alpine.js 透過 x-on 指令(也可以簡寫為 @)來監聽DOM事件,並觸發相應的處理函式。

比如

<button @click="sendMessage()">
  傳送資訊
</button>

修飾符

Alpine.js 支援事件修飾符,用於控制事件的行為:

  • .prevent:呼叫 event.preventDefault()
  • .stop:呼叫 event.stopPropagation()
  • .window:監聽全域性 window 物件的事件。

例如

<button @click.prevent="sendMessage()">
  傳送資訊
</button>

老生常談的防抖與節流,也是有的

<input @input.debounce.500ms="fetchResults">

節流

<div @scroll.window.throttle.750ms="handleScroll">...</div>

擴充套件

本專案用到的 Alpine.js 的功能不多,我看了下官網這個庫的功能還挺豐富的,這裡介紹幾個。

全域性狀態管理

同樣是使用魔法屬性實現的

以切換深色模式為例(搬運了一下官網的例子)

<button x-data @click="$store.darkMode.toggle()">Toggle Dark Mode</button>

...

<div x-data :class="$store.darkMode.on && 'bg-black'">
  ...
</div>


<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('darkMode', {
      on: false,

      toggle() {
        this.on = ! this.on
      }
    })
  })
</script>

動畫

使用 x-transition 指令可以實現動畫,不過目前可以用的動畫不多,真要動畫還是用 tailwindcss 或者直接寫 CSS 吧

<div x-data="{ open: false }">
    <button @click="open = ! open">Toggle</button>
 
    <div x-show="open" x-transition>
        Hello 👋
    </div>
</div>

更多

寫到這裡的時候,我把 alpine.js 文件讀了一遍,還有更多其他的功能就不一一復讀了,都是跟互動有關的,例如:

  • 用於實現 modal 的 x-teleport 指令
  • 批次建立元素的搭配生成 id 的 x-id 指令
  • 跨標籤頁狀態外掛(基於 localStorage)
  • 還有很多外掛都是用於實現互動的(dropdown、collapse、sort這類)

alpine.js 提供的這些擴充套件可以讓我們實現需要的元件,不過很多功能我還是搭配元件庫(例如 flowbite 之類基於 tailwindcss 的庫)來做,避免造輪子

如何傳送語音?

這裡再引申一下,這個 LiveChat 專案不單可以發文字,還能發語音。

語音相對於文字還是麻煩一些的,我這裡也只是 demo 版本,還不太完善。

錄音

主要使用 MediaRecorder 來實現錄音,瀏覽器上會彈出來許可權申請,使用者允許了才能開始錄音。

參考 MDN 文件: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder

開始錄音

// 開始錄音
startRecording() {
  navigator.mediaDevices.getUserMedia({audio: true})
    .then(stream => {
    this.audioStream = stream;
    this.mediaRecorder = new MediaRecorder(stream);

    this.mediaRecorder.ondataavailable = event => {
      this.audioChunks.push(event.data);
    };

    this.mediaRecorder.onstop = () => {
      const audioBlob = new Blob(this.audioChunks, {type: 'audio/wav'});
      const reader = new FileReader();
      reader.onloadend = () => {
        const arrayBuffer = reader.result;

        // 建立 AudioContext 例項
        const audioContext = new (window.AudioContext || window.webkitAudioContext)();

        // 解碼音訊資料
        audioContext.decodeAudioData(arrayBuffer)
          .then((audioBuffer) => {
          const duration = audioBuffer.duration;
          console.log(`音訊時長:${duration} 秒`);
          this.socket.send(arrayBuffer);
          this.messages.push(new Message(
            'user', audioBuffer, 'audio', duration
          ))
          audioContext.close();
        })
          .catch((error) => {
          console.error('解碼音訊資料出錯', error);
        })
      }
      reader.readAsArrayBuffer(audioBlob);
      this.audioChunks = [];
    };

    this.mediaRecorder.start();
    console.log("錄音已開始");
  })
    .catch(err => {
    console.error("獲取麥克風許可權失敗: ", err);
  });
}

停止錄音

// 停止錄音
stopRecording() {
  if (this.mediaRecorder) {
    this.mediaRecorder.stop();
    // 停止麥克風流
    this.audioStream.getTracks().forEach(track => track.stop());
    console.log("錄音已停止");
  }
}

播放音訊

playAudio(message) {
  console.log('playAudio', message)
  if (message.type === 'audio' && message.message) {
    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
    const source = audioContext.createBufferSource();
    source.buffer = message.message;
    source.connect(audioContext.destination);
    source.start(0);
  }
}

小結

前端框架層出不窮,專案越做越大,alpine.js 和 htmx 這種庫是反其道而行,可以用最簡單的方法來開發現代化的 web 應用。

接下來我會把這倆東西加入新版 DjangoStarter 裡,這才是最適合全站開發者的技術~

相關文章