---
url: /blog-vue/vue-composable-outer-variable-trap.md
description: 把 ref 放在 composable 外面共用狀態，方便是方便，但是可能會埋下地雷喔。(´・ω・`)
---

![vue-composable-outer-variable-trap](/vue-composable-outer-variable-trap.webp){.cover}

# 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 還會貼心地解釋：「把變數宣告在模組層級，所有呼叫者就能共用同一份響應式狀態。」%...(́⊙◞౪◟⊙‵)%

::: tip 為什麼放外面就能共用？
ES Module 只會被執行一次，之後不管多少地方 `import`，拿到的都是同一個模組實例。

所以宣告在模組頂層的變數，自然就變成全域共用的單例（Singleton）。
:::

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

## 這段程式碼有什麼問題？

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

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

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

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

::: tip 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**](https://vuejs.org/guide/scaling-up/ssr.html#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 環境下會自動退回非共用模式，每個請求都拿到獨立的實例，不會有跨請求污染問題。%( •̀ ω •́ )✧%

簡單整理一下選擇方式：

| 需求 | 方案 |
|---|---|
| 各元件獨立狀態 | 變數放函式內部 |
| 輕量共用，不想開 store | `createSharedComposable` |
| 複雜共用狀態、需要 devtools | Pinia |

## 總結 🐟

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

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

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