每个Vue应用都有自己的状态,但是要想简单且可预期的管理这些状态却并不容易。在现在的前端开发生态中有许多状态管理的方法,但是面对这些方法时我们时常会迷失在各种状态管理的模式和工具库中而觉得无从下手。

在这篇文章中,我会深入阐述常见的状态管理模式和工具,同时解释其存在的原因。这篇文章会比较深入,但是我相信你至少能从中学会状态管理是怎么回事。

什么是应用的状态

我们经常在不同的上下文中讨论“应用的状态”,但是我们真的理解什么是“应用的状态”吗?简单来说,我们可以这么理解:一个应用的状态是指一组唯一的数据,这组数据可以用来重建应用在给定时刻的样子。

展示在屏幕上的数据属于应用的状态,打开的弹窗也是应用的状态,甚至一个错误也是应用的状态。我们在应用中掌握了这些数据信息,而这些数据信息使得应用可以呈现各个给定时刻的样子。

但是即便每种给定时刻的数据仅仅是数据,这些数据的用处却是非常不同的,也正是这个原因,我们需要有区别的来管理他们。我们可以把应用的状态分为两种类型,本地状态和共享状态。一旦我们分好了类, 我们会发现本地状态的管理需求是有限的、好处理的,共享状态的管理需求则是很有挑战性的。

本地数据

本地状态是我们经常用在Vue组件中的,我们把它放在Vue组件的data属性中。本地状态的值是声明式的,并且其作用域仅限于某个组件。本地状态的一个典型应用就是和用户界面交互相关的信息展示和临时存储,比如一个下拉框 组件的展开和收起。

上面所说展开和收起、loading这两种状态,仅仅和特定的组件相关,我们把这种称为本地状态,下面的代码示例也展示了这一点。

// Home.vue
<template>
  <div>
    <p>
        Once upone a time...
        <button @click="showMore = true">Read more</button>
    </p>
    <p v-show="showMore">...in Vueland!</p>
  </div>
</template>

<script>
export default {
  data () {
    return {
      showMore: false
    }
  }
}
</script>

除了上面讨论的例子,你还可以把其他类型的数据作为本地状态的一部分。比如从服务端API接口获取到的响应数据,见下面的代码示例。

// Home.vue
<template>
  <div>
    <h1> Hello {{ user.name }}! Here's a story for you!</h1>
    <p>
        Once upone a time... 
        <button @click="showMore true">Read more</button>
    </p>
    <p v-show="showMore">...in {{ user.location }}!</p>
  </div>
</template>

<script>
import { getUser } from './api'

export default {
  data () {
    return {
      user: {}
      showMore: false
    }
  },
  async created () {
    this.user = await getUser()
  }
}
</script>

随着你的应用规模不断增长,你或许会对只把数据放在和他们相关的组件里(只使用本地状态)这种做法产生疑惑,那么我们就需要把一些数据放到组件的外部。否则,随着我们应用规模的增长我们会遇到问题,把状态只局限在相关的组件里会造成两个主要的问题(下面会提到)。

本地状态的管理问题

首先是重复

假设你有一个header组件AppHeader.vue,就像上面代码示例中所示的那样用来在Home.vue中展示用户信息。即使你已经在应用中获取到了用户数据,你仍然需要调用getUser方法,因为你无法在AppHeader.vue组件外部访问到AppHeader.vue中的用户数据--毕竟它是AppHeader.vue中的本地状态。

把用户数据作为AppHeader.vue组件的本地状态这种做法不仅仅使代码重复,而且产生了不必要的网络请求。(每次在页面中调用一次组件就会产生一次网络请求)

// AppHeader.vue
<template>
  <header>
    <img src="./logo.svg" />
    <div v-if="user">
      Welcome back, {{ user.name }}! 
      <button @click="logOut">Log Out</button>
    </div>
    <div v-else>
      <LogInForm />
    </div>
  </header>
</template>

<script>
import { getUser, logOut } from './api'

export default {
  data () {
    return {
      user: {}
    }
  },
  async created () {
    this.user = await getUser()
  },
  methods: {
   async logOut () {
     await logOut()
     this.user = null
   }
  }
}
</script>

问题不止于此,如果用户在某个页面登出了应用,也没有办法通知其他引用了AppHeaer.vue组件的其他组件!因此,我们构建的应用就很容易产生这种问题:同一份数据却有不同的版本,一个组件呈现为用户登出的状态,而另一个组件仍然呈现为登录状态。

所以,最后就呈现为不一致,具体来说就是应用的状态不同步,这是只使用本地状态的第二个问题。

应用状态的不同步(不一致)是重复导致的。

如果每个组件都有应用不同版本的状态,那么它们很容易保存同一数据实体的不同版本。如果我们去除这种重复并且为应用状态保存唯一的单一数据源,这样应用的状态在各个地方就可以保持一致了。其实所有的状态管理库所做的事情也就是保持单一数据源的正确性。

但是怎么实现单一数据源呢?幸运的是,这里就有一个非常简单的解决方案,这个方案不需要引入任何第三方库。

在Vue应用中,如果两个组件之间是父子关系,那么我们只要把数据从父组件传递给子组件就可以了。通过props传递的数据是响应式和只读的(对于子组件),我们只能在父组件中修改它。这正是我们期望的行为,因为父组件可以作为数据的单一数据源。

所以,这么看来我们可以通过把应用状态数据抽到顶层组件中来实现状态的一致,这样整个应用中的各个组件就都可以访问状态数据了。

通过使用props来保证应用中状态的一致性是一个很好的方法,Vue框架自身就给我们提供了一个非常强大的工具来帮助我们管理应用状态,使用这个工具我们不需要其他第三方库。

在上面的代码示例中,App.vue就可以作为单一数据源来使用。

// App.vue
<template>
  <div>
    <AppHeader :user="user" @logout="logOut" @login="logIn" />
    <HomePage :user="user" />
  </div>
</template>

<script>
import { getUser } from './api'

export default {
  data () {
    return {
      user: {}
    }
  },
  methods: {
   logOut () {
     this.user = null
   },
   logIn () {
     this.user = await getUser()
   }
  } 
}
</script>

现在你可能会想:“好吧(通过props来传递状态)这个方法可行,但是这和我们真实的应用还是不一样呀,真实的应用大部分都是有路由的”,没错是这样的!幸运的是,这个方法也适用于你使用vue-router的情况。

揭开<router-view>组件的面纱,你会发现它其实是一个动态的Vue组件open in new window,也就是它可以作为另一个组件的占位。这样,我们传递给<router-view>的每个属性都会被传递给当前页面的路由组件,所以如果你在用vue-router,你的App.vue模板大概看起来会是这样的:

<template>
  <div>
    <AppHeader :user="user" @logout="logOut" @login="logIn" />
    <router-view :user="user" />
  </div>
</template>

所以看起来我们可以在不使用任何像Vuex之类的第三方库的情况下把应用的状态集中管理!这是很棒的,因为每引入一个新库总是会把我们的技术栈变得复杂,而且会把应用的性能削弱。

通过传递props来实现状态集中管理会带来什么问题

关于SPA架构的最好的实践通常是我们应该将组件分为两类 -- 灵活组件和哑组件(你或许听过另一种说法:表现型的和容器型的 -- 它们是同一种东西的不同名称)。灵活的组件可以重新获取、保持和修改其内部的数据,哑组件只是接收从父组件传递进来的数据并向父组件发出事件并且大部分都没有自己的状态。哑组件可以单独使用,它对于组件外部的环境是无感知的。Dan Abramov写过一篇很好的文章open in new window深入讨论了这个话题并说明了为什么把组件分为灵活组件和哑组件可以让SPA应用具有更好的可维护性,所以我在这里只做简短的解释。

如果你的应用不是很复杂,灵活组件通常是页面组件和根组件,哑组件通常位于它们的内部。

在一个完美的世界里,每个应用都是简单的、易于管理的,所以上面这种状态管理方式是足够的。这种状态管理方式是可预期的、简单的、性能友好的,最重要的是它是可信赖的。

在一个完美的世界里,当然也不会存在一些不好的事情,例如:贫穷、压迫等,但是我们毕竟不是生活在一个完美的世界。我们的现实世界是这样的:项目通常由一个大的团队来开发,开发周期通常要数年,开发者经常要为了交付节点和经常变动的业务目标而赶工,所以在现实世界的应用中我们会很少在教科书中看见这种“干净”的架构。

我们的应用规模随着时间流逝而增长,也变得越来越复杂。哑组件会变成灵活组件,灵活组件也会变成哑组件,而我们开发者也会变得抓狂,组件层次结构就像是美国大峡谷那么深。

如果只是为了上面的这种组件架构需求而向20个组件传递一个单一的值,那么这时的代码也就不是干净的代码了。每个(架构或代码的)最佳实践一开始都是好的,但是它会变的不好,当变得不好时我们需要时常的回过头去质疑它以确认它们是否仍让我们的工作和生活变得更容易。如果我们正在遵循的(架构或代码)实践过了一段时间后变得更复杂了,同时有一种更好的但是不符合我们架构设想的解决方案,而其实这正是一个我们需要重新评估架构设想的信号。

中央仓库

FaceBook了解web应用程序状态管理容易造成“灾难”的本质,并提出了一个状态管理的解决方案 -- Flux架构。Flux架构的主要思想是应用的状态被集中存放到一个仓库中,但是仓库中的状态不能被直接修改。如果我们要更新状态,我们只能在一个特定的、预先定义的方法中通过分发的方式来更新。而Vuex正是一个基于Flux模式专门给Vue.js用的状态管理库。

// direct mutation
const store = { count: 0 }
store.count++

// controlled mutation
const store = { count: 0 }
function increment () { store.count++ }
increment()

在上面的代码中,increment函数是用来更新count值的一个分发器。这样,当我们完全控制了状态的改变,我们可以很容易的追踪状态的变化也让应用开发更容易调试。使用Vue开发者工具我们不仅可以检查状态的变化,甚至可以回到上个状态(类似时间旅行)。

title_img

一个Vuex store 并不和任何组件绑定,所以我们可以直接和每个store交互,而不是通过根组件向下传值的方法。

使用Vuex store的方式如下:

const store = {
  state: { count: 0 },
  mutations: {
   increment: function (state) {
     state.count++
   }
 }
}

我们可以在每个组件中通过调用store的commit方法来更新状态:

this.$store.commit('increment')

这对于向超过20层的组件传值绝对是一个很大的简化!

为什么Vuex也可能会是个不好的选择

Flux架构的介绍是我们向可信赖的状态管理迈进的一大步,但这是有成本的 -- 这使得每个开发任务都变得更加复杂。

使用Vuex使得简单的开发任务需要很多不必要的代码,理解应用是如何工作的也变得更加困难。

看下这个无辜的计数组件,计数的值是通过点击获取外部接口来获取的。

<template>
  <div>
    <button @click="increment++">You clicked me {{ count }times.</button>
    <button @click="saveCount">Save</button
  </div>
</template>

<script>
export default {
  data () {
    return {
      count: 0
    }
  },
  methods: {
    increment: () { this.count++ )
    getCount () { /* call api, update count*/ },
    saveCount () { /* call api */ }
  },
  async created () {
    this.count = await getCount()
  }
}
</script>

如果我们在Vuex集中管理couter,那么相关代码是下面这样的:

const store = {
  state: { count: 0 },
  mutations: {
   increment: function (state) {
     state.count++
   }
  },
  actions: {
    getCount: function ({ commit }) { /* call api, update stat*/ },
    saveCount: function ({ commit }, payload) { /* call api */ }
  }
}

计数组件将是下面这样的:

<template>
  <div>
    <button v-on:click="increment++">You clicked me {{ count }times.</button>
    <button v-on:click="saveCount">Save</button
  </div>
</template>

<script>
export default {
  methods: {
    increment: () { this.$store.commit('increment') )
    saveCount () { this.$store.dispatch('saveCount', count) }
  },
  async created () {
    this.$store.dispatch('getCount')
  },
  computed: {
   count () [
     return this.$store.state.count
   }
  }
}
</script>

你看到问题了吗?即使我们把所有和状态相关的逻辑都放到Vuex中,组件也没有变得更简单。实际上组件变得更加复杂了,因为代码变多了,而且我们需要切换到其他文件来理解这些状态管理的代码逻辑。

使用Vuex也使得把和某个相关特性相关的东西都放在同一个地方变得更加困难。虽然Vuex支持模块,但是我们把模块注册到了不同的地方而不是组件里,所以如果我们向从tree-shaking和延迟加载中获益,就需要额外的扩展了,这会使代码变得更加复杂,更不用说极易出错的基于字符串的键名了。

使用Vuex造成这么多问题,是不是就意味着它没用了呢?

我知道许多人在纠结Vuex,也期望我写下上面所写的内容,但是Vuex实际上还是很棒的。

我们很容易因为一个库的缺点来贬低一个库,但是不要忘了编程总是和取舍相关。Vuex有缺点,但是它绝不是一个差的第三方库。它被Vue开发者工具集成的很好,提供了很多有用的特性如插件和订阅机制,它已经被很好的在开发中实践并且为几乎每个Vue开发者所熟知--这也降低了开发者学习该技术栈所需付出的努力。同时,如果你正在使用服务端渲染,Vuex是唯一可以将服务端状态放入服务端然后渲染HTML的简单方法。

许多项目并不需要Vuex所有的特性,如果你只是需要Vuex功能的一个小的子集,可能不值得使用它。我们做每件事情都是需要付出代价的--使用Vuex是有好处的,但是它也使你的项目变得更加复杂。你的应用状态是复杂到需要一个额外的抽象层来管理它还是只是需要更好的组织它?你需要清楚的认识Vuex对你的应用产生的积极影响和负面影响,同时也要考虑你的项目规范来决定使用它是否合适。

如果你在使用服务端渲染那么Vuex在很大程度上是一个正确的选择,如果不是的话,那么你应该基于你项目的复杂度和组件层次结构的复杂性来做决定。

比Vuex更简单的选择 -- Vue observables

如果你觉得Vuex对于你的应用来说过于复杂,那么该怎么办?传递全局属性的方法是一个非常优雅和简单的方法,但是它不能够很好的扩展。如果你的应用随着时间的增长而增长,那么投资这个在某个时候需要删除的解决方案是不值得的。更好的方法是找到一个可以随着业务增长而扩展的新功能。很长一段时间,在Vue生态中都没有一个简单且惯用的方法来解决这个问题,这也迫使许多开发者甚至为了开发一个简单的app而使用Vuex。

在Vue 2.6之前我们只能在Vue组件中创建响应式的属性,这导致我们除非用一些比较hack、不优雅的方法,比如把Vue组件作为store--类似我们使用一个全局的事件总线,否则我们就无法集中应用的状态。在Vue组件外的所有东西都不是响应式的,如果他们更新了并不会触发组件的重新渲染。

比如在下面的例子中,点击按钮之后会执行组件的increment方法,但是我们会发现屏幕上展示的count值仍然为0,即使count的值已经被改变了,这是因为count这个状态不是响应式的。

// state.js
export const state = { count: 0 }
<template>
  <div> 
    Count is {{ count }} 
    <button @click="increment">Increment</button>
  <div>
</template>

<script>
import { state } from 'state'

export default {
  computed: {
    count () {
      return state.count
    }
  },
  methods: {
    increment() {
      state.count++
    }
  }
}
<script>

感谢Vue 2.6推出了一个革命性的特性--Vue observables,它改变了一切。只要将变量声明为observable,这个变量就会立即变为响应式的,这意味着变量值的变化会触发Vue组件的重新渲染,所以我们始终可以看到响应式属性值的最新版本,这同样适用于依赖于observables的计算属性,它们的值会随着observables的变化而变化。

所以如果我们想把上面的代码改成响应式的,需要做的就是把状态对象变成Vue Observable的。

// state.js
import Vue from 'vue'

export const state = Vue.observable({ count: 0 })

哈哈!现在如预期的那样,我们如果点击增加按钮组件就会重新渲染了,屏幕上的就会由0变为1了。使用Vue observables你就可以通过一行代码来实现集中状态管理。这非常简单,但是它完成了该做的工作,而且对于许多应用是足够的。

与Vuex相比,使用observable的state对象的一大优势是,我们可以从自动完成、简单切换到状态文件(使用cmd+click)中获益,而不是使用容易出错的字符串,而且我们不会污染全局Vue原型!

使用observables另一个益处是我们可以为应用渐进的添加新的功能,比如我们可以追加新的功能使用mutations而不是直接修改状态对象,并且可以在控制台追踪状态的改变。

// state.js
import Vue from 'vue'

export const state = Vue.observable({ count: 0 })

export const mutations = {
  setCount(newVal) {
    console.log('Setting "count": ', newVal)
    state.count = newVal
  }
}
<script>
import { state, mutations } from './state'

export default {
  computed: {
    count () {
      return state.count
    }
  },
  methods: {
    increment() {
      mutations.setCount(state.count+1)
    }
  }
}
<script>

随着状态变得复杂,我们可以添加像插件、actions或其他任何可以帮助我们更加容易管理状态的特性。这种可能性是无限的,我们可以使状态管理工具完美的适配我们的需要。

这种方法的缺点是它缺少开发者工具的集成,你可以简单而直接的编写你自己的mutation追踪系统,但是你仍然缺少用户界面的管理工具。

接下来 -- Vue 3中的状态管理

状态管理是一个非常复杂而且有趣的话题。好的状态管理可以显著的加速开发并使应用代码更容易理解,而不好的状态管理起到的作用则正好相反。当然有不止一种正确的方法来管理状态,我们应该总是考虑不同方法的灵活性和我们项目的需求和复杂性。

在下一篇文章中,我会深入探讨Vue 3中新的管理状态的方法。我有幸使用了Vue 3的Composition API,我发现它比Vuex更倾向于使用声明式的方法来管理状态。很期待将我学到的分享给你们!

Last Updated:
Contributors: yanghongdong