誰偷了我的鳳梨!聊聊意外修改 Pinia 資料問題

拿著鳳梨的鱈魚

相信大家用 Vue 3 後應該都改用 Pinia 了吧?沒用過的人趕快試試看吧。(´,,•ω•,,)

Pinia 最簡單的用法就像這樣:

counter.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useStore = defineStore('counter', () => {
  const n = ref(0)

  return { n }
})

接著在要使用的地方呼叫 useStore:

App.vue

<script setup lang="ts">
import { useStore } from './counter.ts'

const store = useStore()

function increment() {
  store.n++;
}
</script>

<template>
  <button @click="increment">
    Increment {{ store.n }}
  </button>
</template>

其實像這樣直接操作 store.n 的方式,方便歸方便,苓膏龜苓膏

但不是個好做法,這種方式容易讓資料流混亂,想像一下你有多個元件都使用 store.n,然後想改就改。…( ・ิω・ิ)

比較推薦的方式通常為由 store 提供一個修改資料的 function,例如:

counter.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'

interface User {
  name: string;
  price: number;
}

export const useStore = defineStore('counter', () => {
  const user = ref<User>({
    name: 'cod',
    price: 100,
  })

  function updateUser(data: Partial<User>) {
    user.value = {
      ...user.value,
      ...data,
    }
  }

  return {
    user,
    updateUser,
  }
})

呼叫的部份改為:

App.vue

<script setup lang="ts">
import { useStore } from './counter.ts'

const store = useStore()

function increment() {
  const price = store.user.price + 1;
  store.updateUser({ price });
}
</script>

<template>
  <button @click="increment">
    price: {{ store.user.price }}
  </button>
</template>

這樣如果未來要新增邏輯、權限甚至重構,都容易得多。

但是問題來了,有時候流程中會有「確認」的按鈕,也就是要按下確認後,才修改 store 的資料。

假設有一個負責修改 User 資料的元件:

UserCard.vue

<script setup lang="ts">
import { ref } from 'vue'
import { useStore } from './counter.ts'

const store = useStore();
const user = ref(store.user);

function increment() {
  user.value.price++;
}
function cancel() {
  user.value = store.user;
}
function submit() {
  store.updateUser(user.value);
}
</script>

<template>
  <div>
    <button class="button" @click="increment">
      遞增
    </button>
    <button class="button" @click="submit">
      確認
    </button>

    <div>
      current price: {{ user.price }}
    </div>
  </div>
</template>

<style>
.button {
  margin: 0px 4px;
}
</style>

App.vue

<script setup lang="ts">
import UserCard from './UserCard.vue';

import { useStore } from './counter.ts'

const store = useStore();
</script>

<template>
  <div>
    store price: {{ store.user.price }}
  </div>

  <hr />

  <UserCard />
</template>

目前畫面如下圖。

Untitled

這時候你會發現出事啦!╭(°A ,°`)╮

按下遞增的時候,不只元件內的 user 數值發生變化,連 store 的數值也一起變啦!Σ(ˊДˋ;)

熟悉 JS 的朋友們一定都知道發生甚麼事,這是因為直接指派物件是 Call by Reference,所以:

const user = ref(store.user);

這個部分的程式會讓 user 依舊指向 store 的 user,結果就意外改到鳳梨裡面的資料了。(›´ω`‹ )

這時候聰明的讀者們一定也想到解法,在 ref 的時候拷貝一次不就行了?

UserCard.vue

<script setup lang="ts">
...
const user = ref(clone(store.user));

function clone<Data>(data: Data): Data {
  return JSON.parse(JSON.stringify(data));
}

...
</script>
...

這時候會發現世界恢復和平了,資料一切正常!◝( •ω• )◟


鱈魚:「但是阿。(´● ω ●`)」

路人:「怎麼那麼多但是?…(›´ω`‹ )」


實際上協作開發的時候難保大家都會注意到這件事情,所以保險起見,可以在 Pinia 提供資料時先拷貝一次。

counter.ts

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  name: string;
  price: number;
}

function clone<Data>(data: Data): Data {
  return JSON.parse(JSON.stringify(data));
}

export const useStore = defineStore('counter', () => {
  const user = ref<User>({
    name: 'cod',
    price: 100,
  })

  function updateUser(data: Partial<User>) {
    user.value = {
      ...user.value,
      ...data,
    }
  }

  return {
    user: computed(() => clone(user.value)),
    updateUser,
  }
})

這樣即使元件中使用

const user = ref(store.user);

也不會意外修改 Pinia 中的資料了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

當然如果使用 immutable.js 這類保證資料不變性的套件也是沒問題。

就看大家喜歡哪一種了。♪( ◜ω◝و(و

以上程式可以來這裡取得:範例程式

總結 🐟

  • 小心 call by reference 導致意外修改 Pinia 資料
  • Pinia 提供資料時先拷貝一次,避免意外修改