Skip to content

vue-composable-outer-variable-trap

AI 可能會寫的 Vue Composable 地雷,你踩過了嗎?

大家有沒有請 AI 幫忙寫過 Vue composable?

甚麼?你問現在誰還在手寫程式?抱歉我多問了。(´;ω;`)

請 AI 寫一個「多個元件共用狀態」的 composable,雖然不是每次,但是他有可能會給你這樣的程式碼(尤其是降智的時候 ( ◔ ௰◔))。

ts
import { ref } from 'vue'

const count = ref(0)

export function useCounter() {
  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  return { count, increment, decrement }
}

ref 放在函式外面,這樣不管哪個元件呼叫 useCounter(),拿到的都是同一份 count,完美實現共用狀態。

AI 還會貼心地解釋:「把變數宣告在模組層級,所有呼叫者就能共用同一份響應式狀態。」...(́⊙◞౪◟⊙‵)

為什麼放外面就能共用?

ES Module 只會被執行一次,之後不管多少地方 import,拿到的都是同一個模組實例。

所以宣告在模組頂層的變數,自然就變成全域共用的單例(Singleton)。

看起來很合理對吧?但其實這段程式碼可能在無意間埋下地雷。ԅ( ˘ω˘ԅ)

這段程式碼有什麼問題?

既然知道這是利用 ES Module 的單例特性,讓我們想想這代表什麼。

count 這個 ref 從程式啟動到結束,永遠只有一份。

如果你的專案用了 Nuxt 這類有 SSR 的框架,要特別注意。

在 SSR 環境中,server 端的模組被所有請求共用。

SPA 就不用擔心了吧?

純 SPA 的確不會有跨請求污染的問題,不過難保未來專案不會加上 SSR,到時候就變成埋在深處的地雷了。

既然有更安全的寫法,不如一開始就避開這顆潛在的地雷。

甚麼?你說你就是想埋?我不知道,我甚麼都沒聽到(。-`ω´-)

也就是說,Request A 的使用者修改了 count,Request B 的使用者進來時,拿到的 count 就是 A 改過的值。

ts
// 模組層級的變數,server 端所有請求共用
const count = ref(0)

export function useCounter() {
  // Request A 呼叫 increment(),count 變成 1
  // Request B 進來時,count 已經是 1 了
  function increment() {
    count.value++
  }

  return { count, increment }
}

這就是 Vue 官方文件中特別警告的 Cross-Request State Pollution(跨請求狀態污染)。

使用者 A 的資料洩漏到使用者 B 的頁面上,輕則顯示錯誤,重則變成資安事件。

正確的替代方案

經典用法是請出我們可愛的鳳梨 Pinia。( ´ ▽ ` )ノ🍍

Pinia 的 store 綁定在 Vue 的 app instance 上,SSR 時每個請求都會拿到獨立的 store 實例,從根本上解決跨請求污染問題。

ts
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)

  function increment() {
    count.value++
  }

  return { count, increment }
})

如果你用的是 Nuxt,官方提供了 useState 這個 composable,專門用來建立 SSR 安全的共用狀態。

以剛才的 useCounter 為例,可以改寫成:

ts
export function useCounter() {
  const count = useState('count', () => 0)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  return { count, increment, decrement }
}

useState 底層會將狀態綁定在請求的 context 上,server 端不同請求之間不會互相污染,同時還會自動將 server 端的狀態序列化傳遞給 client 端,避免 hydration mismatch。

一定要用 Pinia 嗎?

有些時候你只是想讓幾個元件共用一個 composable 的狀態,又覺得為了這點小事特地開一顆鳳梨(Pinia store)感覺有點太重了。

這時候可以試試 VueUse 提供的 createSharedComposable

ts
import { createSharedComposable } from '@vueuse/core'
import { ref } from 'vue'

function _useCounter() {
  const count = ref(0)

  function increment() {
    count.value++
  }

  return { count, increment }
}

export const useCounter = createSharedComposable(_useCounter)

createSharedComposable 會確保不管多少個元件呼叫 useCounter(),底層的 _useCounter 只會執行一次,所有元件拿到的都是同一份狀態。

而且在 SSR 環境下會自動退回非共用模式,每個請求都拿到獨立的實例,不會有跨請求污染問題。( •̀ ω •́ )✧

簡單整理一下選擇方式:

需求方案
各元件獨立狀態變數放函式內部
輕量共用,不想開 storecreateSharedComposable
複雜共用狀態、需要 devtoolsPinia

總結 🐟

  • ref 放在 composable 函式外面,利用 ES Module 單例特性共用狀態,在 SSR 環境下會造成跨請求狀態污染
  • 即使目前是純 SPA,也建議避開這種寫法,為未來可能加入 SSR 預留彈性
  • 需要跨元件共用狀態時,優先考慮 Pinia,Nuxt 專案可用 useState
  • 覺得開 store 太重,可以試試 VueUse 的 createSharedComposable,輕量且 SSR 安全

感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。

有錯誤或任何想法還請多多指教。( ´∀`)~♥