他们说编程中有两件难事:命名和缓存失效。我想再增加一项:现代 Web 应用中的状态管理!
今天,我们将深入探讨 Vue 中的状态管理策略,并介绍直观的存储库 Pinia。
Vue 状态管理:注意事项和限制
从 Vue 2 开始的时代起,我们使用 data
选项来定义一个方法,该方法返回一个包含组件所需的所有响应式变量的对象。
<template>
<div>{{ user.name }}</div>
<template>
<script>
export default {
data () {
return {
user: { name: 'John', age: 25 }
}
}
}
</script>
这个组件定义,被称为 Options API,在 Vue 3 中仍受支持,该版本还引入了 Composition API。这个新 API 提供了诸如 reactive
和 ref
等方法来定义响应式数据。通过利用 Composition API 和 script setup
,我们可以像这样重写单文件组件的脚本部分:
<script setup>
import { reactive } from 'vue'
const user = reactive({ name: 'John', age: 25 })
</script>
现在,如果我们需要从多个组件中 访问用户数据 怎么办?例如,在导航栏中显示用户名称,在个人资料页面上显示详细信息,在结账过程中显示地址等等。
通常,父组件可以将这些数据作为 props
传递给它的子组件。然而,当需要数据的子组件嵌套了三四层深时,你可能会发现自己不得不将这个 prop 添加到层次结构中的每个组件中,无论它们是否直接使用该数据。这种做法被称为 prop drilling,通常不建议使用,因为它会影响代码的可维护性。
当你需要从多个地方 更新 共享的数据时,情况会变得更加复杂。子组件无法直接修改 prop,而是必须发出自定义事件来通知有关更改。然后父组件可以监听该自定义事件并更新其数据,进而传播到 prop 链中。是的,这可能更好一些。
幸运的是,Vue 3 提供了一个直接的解决方案来应对这一挑战:由于 Composition API 的存在,我们不再受限于在 Vue 组件的范围内使用 Vue 的响应性方法。现在,我们可以在任何脚本中使用 ref
和 reactive
,并将这些响应式变量导出以在整个应用程序中使用它们。
我们可以将这些响应式状态的模块称为 存储库。例如,我们可以创建一个 store/user.js
存储库:
import { reactive } from 'vue'
const user = reactive({ name: 'John', age: 25 })
export { user }
…然后将其导入到 Profile
组件中:
<script setup>
import { user } from './stores/user.js'
</script>
<template>
<h1>你好,{{ user.name }}!记住,你已经 {{ user.age }} 岁了。</h1>
</template>
…或 HappyBirthday
组件中:
<script setup>
import { user } from './stores/user.js'
const blowCandles = () => user.age++
</script>
<template>
<button @click="blowCandles">
我是 {{ user.name }},今天是我的生日!
</button>
</template>
…或者任何其他需要引用用户的地方。
不错,对吧?现在,我们拥有了一个响应式的数据单一来源,不再局限于特定组件。
正如你所看到的,任何组件也可以修改该数据,更改将立即反映在所有读取它的组件上。因此,如果 John 吹灭了蜡烛,他的 Profile
将会显示:“你好,John!记住,你已经 26 岁了。”
拥有一个全局可变状态,任何组件都可以更新,这很方便,但可能会使事情变得棘手。为了保持组织清晰,最好在存储库本身上定义状态变更逻辑:
import { reactive } from 'vue'
const user = reactive({ name: 'John', age: 25 })
const blowCandles = () => user.age++
export { user, blowCandles }
…然后像这样使用它:
<script setup>
import { user, blowCandles } from './stores/user.js'
</script>
<template>
<button @click="blowCandles">
我是 {{ user.name }},今天是我的生日!
</button>
</template>
它的功能与以前一样,但现在代码更加有组织。
你可以拥有小型存储库(想象一下管理一小段用户偏好设置的存储库)或更复杂的存储库,处理大量数据,并使用各种方法进行读取和更新(例如,用于管理电子商务网站上产品的存储库)。
这种简单的状态管理模式很方便,但:
- 它不适用于服务器端渲染应用程序。共享状态存在于 JavaScript 模块的根作用域中,因此在应用程序生命周期内仅创建一个响应式对象的实例。这对于单页面应用程序来说没问题,因为模块在每次访问时都会重新初始化,但对于服务器端渲染来说
不行,因为模块只在服务器启动时初始化一次。这可能会导致数据泄漏和安全问题。想象一下,用户访问您的应用程序… 然后获取到 另一个 用户的数据。虽然你可以配置你的 SSR 应用程序以支持这些基本存储库,但这个过程可能会很麻烦。
- 即使你的应用程序不使用服务器端渲染,随着应用程序的增长,你可能需要更强大的解决方案。例如,想象需要为 所有 你的存储库添加一个方法、将它们的状态与本地存储同步或通过 Websockets 流传递每个更改。创建一个所有其他存储库都从中扩展的基础存储库是可能的,但它可能会变得非常复杂。
为了解决这些问题并获得丰富的、开发者友好的体验,我们可以使用 Pinia,Vue 3 的官方状态管理解决方案。让我们仔细看看!
Pinia,现代 Vue 应用的存储解决方案
Pinia 不仅提供了对 SSR 的开箱即用支持,还带来了一系列其他好东西,包括 Vue Devtools 集成、热模块替换、TypeScript 支持,以及易于安装的插件,可以处理我们提到的功能,比如与 LocalStorage 同步。
由 Eduardo 创建,他是 Vue Router 的开发者,它取代了 Vuex 成为 Vue 3 的官方推荐状态管理解决方案。
安装和设置
要在项目中安装 Pinia,你可以运行:
npm install pinia
然后创建一个 Pinia 实例并将其传递给你的 Vue 应用程序:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
搞定!现在你已经准备好创建和使用存储库了。
创建存储库
你可以使用 defineStore
方法创建一个 Pinia 存储库。第一个参数是存储库的名称(必须是唯一的),第二个参数是一个选项对象。让我们用 Pinia 重新编写我们的 stores/user.js
存储库,并在此过程中添加一个计算属性,以确定用户是否具有投票权的年龄。
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({ name: 'John', age: 25 }),
getters: {
canVote: (state) => state.age >= 18,
},
actions: {
blowCandles () {
this.age++
}
}
})
正如你所看到的,Pinia 存储库有:
state
,响应式数据getters
,基于该数据的计算属性actions
,与该数据交互的方法
这个 Pinia 存储库的初步了解可能会让你想起一个 Vue 2 组件或使用 Options API 的 Vue 3 组件。这种类型的定义称为 Option Stores。
如果你更喜欢 Composition API,你会高兴地知道 Pinia 提供了将你的存储库定义为设置函数的能力。在 Setup Stores 中,你可以将一个函数作为第二个参数传递,并使用 ref
、computed
和 方法 来分别定义 state
、getters
和 actions。这个函数必须返回一个包含你想要公开的变量的对象。
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const name = ref('John')
const age = ref(25)
const canVote = computed(() => age.value >= 18)
const blowCandles = () => age.value++
return { name, age, canVote, blowCandles }
}
使用 Setup Stores 有一些优点,比如在存储库本身中定义 watchers、使用 其他组合 和 在我们的设置函数中注入提供的属性。
使用存储库
现在我们已经定义了 Pinia 存储库,我们可以轻松地将其导入到我们的组件或组合中。让我们更新我们的示例:
<script setup>
import useUserStore from './stores/user.js'
const user = useUserStore()
</script>
<template>
<button @click="user.blowCandles">
我是 {{ user.name }},今天是我的生日!
</button>
</template>
通过 user
对象访问存储库的状态和操作非常方便。然而,如果你想要解构它来整理你的代码,有一个要记住的诀窍。当解构响应式属性(使用 ref
、reactive
或 computed
创建的属性)时,你需要使用 storeToRefs
辅助函数来确保它们保持响应性。例如:
<script setup>
import { storeToRefs } from 'pinia'
import useUserStore from './stores/user.js'
const user = useUserStore()
// 当解构使用 `ref` 或 `computed` 创建的属性时,你需要 storeToRefs
const { name, age, canVote } = storeToRefs(user)
// 但是当解构方法时,你不需要它
const { blow
Candles } = user
</script>
一个实际的例子
现在,除非你所有的访问者都是名叫 John 的 25 岁的人,否则你不会用这些默认值初始化你的存储库。让我们解决这个问题,并借此机会展示一个更接近于你可能在实际应用程序中找到的存储库:
// 让我们使用 Ofetch 进行 AJAX 请求
// (https://npmjs.com/package/ofetch)
import { ofetch } from 'ofetch'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const data = ref()
const token = ref()
const isLoggedIn = computed(() => Boolean(token.value))
async function login ({ email, password }) {
const payload = await ofetch('https://example.com/login', {
method: 'POST',
body: { email, password }
})
data.value = payload.data
token.value = payload.token
}
async function logout () {
await ofetch('https://example.com/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` }
})
data.value = null
token.value = null
}
return { data, token, isLoggedIn, login, logout }
})
正如你在这个例子中看到的,存储库是封装你应用程序的一部分逻辑的好地方。在这里,login
动作向 API 发送请求,将用户数据和令牌保存在存储库状态中。logout
动作与相应的端点进行交互,清除状态。我们返回这两个方法以及状态 data
和 token
,以及 isLoggedIn
计算属性。
然后,我们可以在组件中使用存储库,如下所示:
<script setup>
import useUserStore from './stores/user.js'
const user = useUserStore()
const form = reactive({ email: null, password: null })
const error = ref(null)
async function handleSubmit() {
try {
await user.login(form)
} catch {
error.value = true
}
}
</script>
<template>
<div v-if="user.isLoggedIn">
欢迎回来,{{ user.data.name }}!
<button @click="user.logout">退出登录</button>
</div>
<form v-else @submit.prevent="handleSubmit">
<input type="email" v-model="form.email">
<input type="password" v-model="form.password">
<span v-if="error">出错了,请重试</span>
<button>登录</button>
</form>
</template>
这看起来很棒!然而,这仍然是一个简单的例子。我们可以使用 Pinia 不仅来管理用户会话,还可以跟踪他们需要访问的数据,比如通知、待办事项、产品等。一旦从 API 请求了数据,在用户浏览应用程序时将其保留在存储库中会减少对服务器的后续请求,从而使应用程序更快。
import { ofetch } from 'ofetch'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const usePostsStore = defineStore('posts', () => {
let loaded = false
const endpoint = 'https://jsonplaceholder.typicode.com/posts'
const list = ref([])
async function get (params = {}) {
if (loaded && !params.forceReload) return
list.value = await ofetch(endpoint)
loaded = true
}
async function add (body) {
list.value.push(await ofetch(endpoint, { method: 'POST', body }))
}
async function remove (id) {
await ofetch(`${endpoint}/${id}`, { method: 'DELETE' })
const index = list.value.findIndex(post => post.id === id)
if (index >= 0) list.value.splice(index, 1)
}
return { list, get, add, remove }
})
Pinia 适用于 Vue 2
我们从讨论 Vue 2 开始了这篇文章,现在我们又回到了这一点:Pinia 完全兼容 Vue 2。因此,如果你有一个使用 Vuex 进行存储管理的 Vue 2 应用程序,将这些存储迁移到 Pinia 可能是升级到 Vue 3 的第一步。
然而,从 Vue 2 升级到 Vue 3 是一项复杂的任务,如果你觉得需要帮助,请不要犹豫 联系我们 😉
还有一件事:Pinia Vue Devtools 插件
如果你在浏览器中安装了 Vue Devtools 扩展程序(对于 Vue 应用程序开发来说是非常推荐的),并且你正在使用 Pinia,你会注意到一个新的选项卡,你可以在其中浏览你的存储库:
这个插件允许你浏览你的存储库,检查 getters 的状态和值,将状态序列化为 JSON 并保存,或将其复制到剪贴板… 甚至可以从 JSON 文件中导入状态!
总结
在 Vue 应用程序中开发有效的状态管理策略起初可能会让人望而生畏,但一旦你理解了这些核心概念,一切就会变得容易得多。
Pinia 提供了一个优秀的解决方案,帮助你保持数据组织良
好,并使你的 Vue 应用程序状态更容易理解和维护。无论是你第一次构建 Vue 应用程序还是已经是一位老手,我鼓励你尝试一下 Pinia,看看它是否能够提高你的开发效率和快乐程度。
Happy coding! 🚀