「译」使用Vue组合式的api来管理状态

前端小站2022年4月9日
  • 翻译
  • composition api
  • state
大约 11 分钟

之前我写了一篇关于Vue状态管理模式的文章,你或许从中了解到我过去几个月在开发新版本的Vue Storefrontopen in new window[1]时一直在紧张的用组合式api,我想把我的经验分享给你应该是个不错的主意。我觉得其中最有趣的话题是如何使用组合式API已经改变了我管理应用状态的方式--不管是本地状态还是全局状态。如果你好奇它与主流的方式有多不同,那么就给自己倒杯咖啡来满足自己的好奇心吧,因为我今天会分享给你如何使用组合式API来代替众所周知的状态管理模式和库🙂。

状态管理的高级模式是什么?Vuex吗?才不是呢!

我从组合式API(和其他Vue3 api)中注意到的第一件事情是它简化了许多事情。

Vue2.6中推出Vue.observable这个API使得在应用中共享状态变得如此容易,在此之前没有人可以想像没有状态管理库,如Vuex,的生活。Vue.observable证明一个简单的、可以返回响应式对象的函数相比我们一直认为不可缺少的复杂的工具库是一个更好的选择。

响应式API更进一步,它给我们提供了一个新的思路来考虑全局和局部状态。因为它不要求有宿主组件来添加更多的状态管理的逻辑,比如watcher或者计算属性,它允许我们将状态和一段特定的业务逻辑绑定。正是如此,我们可以从完全独立的、可插拔的微型应用中来构建整个应用程序,而这些微型应用有自己的私有状态。

为展示使用组合式API来管理状态是多么简单,我在这篇文章中将只使用两个钩子api来举例:

  • ref 用来通过原始类型数据或对象类型数据来创建响应式对象
const isLoading = ref(true)
  • computed 用来通过同步其他响应式属性来创建响应式对象,功能就像Options API中的computed
const product = ref(product)
const productName = computed(() => product.name)

现在让我们看看我用上面的代码做些什么🙂。

构建你自己的Vuex

在我们完全释放ref和computed的潜力之前,我们先看个"简单"的东西,看它们是如何复制Vuex的功能的。

一个简单的Vuex store包括4个方面:

  • state
  • mutations
  • actions
  • getters

state是最容易实现的部分。我们只需要用ref创建一个响应式的属性就可以了。

创建state

const state = ref({ post: {} })

现在我们创建一个mutation,这样我们就可以控制state的更改了。与Vuex中使用字符串来实现mutation不同,这里我将写一个函数来实现。这样我们可以获取自动完成和tree shakingopen in new window(这和传统的Vuex mutation是完全相反的)的好处。

创建mutation

function setPost(post) { 
  state.value.post = post
}

我们可以用完全同样的方式来创建action——只要记住action可以是异步的:

创建action

async function loadPost(id) {
  const post = await fetchPost(id)
  setPost(post)
}

对于getter,我们需要追踪一个state对象的特定子属性的变化,用computed函数来实现是最完美的!

创建getter

  const getPost = computed(() => state.value.post)

不把state对象作为公开的对象暴露出去,我们对外暴露一个getter来访问它(getPost),然后通过action来获取新的文章(loadPost),而获取新文章时用到了(setPost)mutation来更新状态。通过这种方式,我们无法直接修改state对象,也就能够控制如何、何时来修改状态。

就是这样!我们使用组合式API中的两个函数,实现了类似Vuex的状态管理,整个状态管理逻辑看起来是这样的:

const state = ref({ post: {} })

function setPost(post) { 
  state.value.post = post
}

async function loadPost(id) {
  const post = await fetchPost(id)
  setPost(post)
}

const getPost = computed(() => state.value.post)

export {
  loadPost
  getPost
}

你必须得承认,这比理解Vuex要简单的多,是不是?这几行简单的代码足以应付大多数简单Vue应用的状态管理。更关键的是整个方案是灵活可扩展的。由于我们的应用程序会随着时间的推移而增长,而且需求也会变化,因此你可以使用其他特性来扩展上面的代码。

For example you can save state history with watch function (which works exactly like watch property from Options API):

比如你可以用watch函数来将状态历史保存,watch函数可以像Options API中的watch属性一样工作。

const history = []
history.push(state) // push initial state

watch(state, (newState, oldState) => {
  history.push(newState)
})

**小提示:**我们使用watch效果函数open in new window时不做history数组初始化的工作也可以实现实现同样的效果,就像Options API中将immediate设置为true那样。

在[Vue Storefront]中我们使用了同样的解决方案来管理UI状态,不同的是用到了reactive而不是ref:

import { reactive, computed } from '@vue/composition-api';

const state = reactive({
  isCartSidebarOpen: false,
  isLoginModalOpen: false
});

const isCartSidebarOpen = computed(() => state.isCartSidebarOpen);
const toggleCartSidebar = () => {
  state.isCartSidebarOpen = !state.isCartSidebarOpen;
};

const isLoginModalOpen = computed(() => state.isLoginModalOpen);
const toggleLoginModal = () => {
  state.isLoginModalOpen = !state.isLoginModalOpen;
};

const uiState = {
  isCartSidebarOpen,
  isLoginModalOpen,
  toggleCartSidebar,
  toggleLoginModal
};

export default uiState;

好了。所以我们知道了我们可以用组合式API做到Vuex能做的事情,但实际上组合式API能够让我们做到之前(Options API)做不到的事情(或者如果能做也需要更多的工作)。

保持状态的本地化

组合式API最好的一个好处就是它允许将Vue组件相关的逻辑编写在Vue组件外部。于是你就可以将你的代码分割为可复用、独立和自包含的片段,然后将业务逻辑隐藏到一个优雅的API背后。

比如,当我们有一段负责获取产品和管理加载状态的代码,我们可以将它放到一个返回响应式属性的函数中,而不是在每个组件中重复它:

export default function useProduct() {
  const loading = ref(false)
  const products = ref([])

  async function search (params) {
    loading.value = true
    products.value = await fetchProduct(params)
    loading.value = false
  }
  return {
    loading: computed(() => loading.value)
    products: computed(() => products.value)
    search
  }
}

你注意到了么,上面的函数不是直接返回了产品和加载状态,而是返回了计算属性的产品和加载状态?通过这种方式我们可以确保在useProduct函数外部这两个状态不会被修改。

**小提示:**你可以通过选项式API中的只读属性来实现同样的效果。

现在当我们想要在一个组件中获取产品时我们可以像下面使用useProduct函数:

<template>
  <div>
    <span v-if="loading">Loading product</span>
    <span v-else>Loaded {{ product.name }}</span>
  </div>
</template>

<script>
import { useProduct } from './useProduct'

export default {
  setup (props, context) {
    const id = context.root.$route.params.id
    const { products, loading, search } = useProduct()

    search({ id })

    return {
      product: computed(() => products.value[0]),
      loading
    }
  }
}
</script>

将应用按功能分割为各自独立的部分,各个部分只通过严格定义的API来通信--理想情况下,应用各个部分及其通信不受各自的实现细节影响,这是保证代码有组织、可维护的一种有效方式。而通过使用组合式API我们证明了之前我们对业务逻辑做的事情(分割功能、通过严格的API来调用),同样可以对状态来做。

让我们回到useProduct函数。什么是其中的状态?显然是产品和加载状态属性,但是你注意到其中真正酷的地方了么?

useProduct函数的状态被紧密的和管理它的业务逻辑绑定到一块了,状态仅仅属于useProduct本地。它,甚至当它是空对象时,在被使用之前不会被app访问到。它也只能在“内部”被修改。

所有与这个特定业务逻辑相关的东西都通过useProduct函数暴露给应用。将功能保持为独立组合式的函数(或者composables,我喜欢称之为composables),让功能极其容易更改,甚者完全移除某个功能也不对应用的其他功能造成破坏性的风险,也不会遗留一些无用的代码片段,比如store中的空产品对象--即使我们不需要它了。遗留的无用代码如果日积月累,就会让我们的代码变得混乱。

const globalState = ref({
  products: {}, // easy to forget if we remove useProduct
  categories: {},
  user: {},
  // ... other state properties
})

使用组合式函数很酷的是当我们来做状态管理时,它可以很容易的让我们选择状态应该是全局的还是本地的。这种自由减少了一些复杂性和像Vuex这样基于根状态对象解决方案的依赖。

这种方法还避免了Vuex模块通常会遇到的一些问题:如果我们为文章设置一个集中式状态,那么它可能是一个对象或数组,每当我们执行搜索方法时,我们都会推到state.posts状态中或添加具有文章id的子属性。这可能会使访问某些文章变得更加困难,因为我们总是需要知道它们的id(当我们有可能基于其他属性(如sku、name或slug)获取它们时,这可能会产生问题)。我们也不知道某个属性何时不再需要,何时可以删除以释放一些内存。

在useProduct中则没有这些问题。每次被调用时它都会创建自己的、带有产品和加载属性的本地状态,这样就可以方便地多次使用它。例如,我们可以使用它两次—首先获取某个产品,然后获取与这个产品相关的同类产品:

async setup (props, context) {
  const id = context.root.$route.params.id
  const { products, search } = useProduct()
  const { products: relatedProducts, search: relatedSearch } = useProduct()

  await search({ id }) // fetch main product
  await relatedSearch({ categoryId: products.value[0].catId }) // fetch some otheproducts from this category

  return {
    product: computed(() => products.value[0]),
    relatedProducts
  }
}

通过组合式函数来共享状态

按需创建状态属性无疑是非常有用的,但是在某些情景下我们也想要在多个组合式函数之间使用一个特定状态属性的单一实例。

在Vue Storefront中我们有个useCart属性,它是负责购物车交互状态的。你会期望购物车功能无论在哪里被使用时,购物车的状态始终是引用的同一份购物车数据。

如果我们在SomeComponent.vue中写下如下代码:

<template>
  <div>
    Items in cart<b>{{ cart.items.length }}</b>
  </div>
</template>

<script>
export default {
  setup () {
    const { cart } = useCart()
    return { cart }
  }
</script>

然后在OtherComponent.vue中添加产品到购物车中:

<template>
  <div>
    <button @click="addToCart(product)">Add to cart</button>
  </div>
</template>

<script>
export default {
  setup () {
    const { products, search } = useProduct()
    const { cart, addToCart } = useCart()

    search({ id: '123' })

    return { 
      cart, 
      addToCart.
      product: computed(() products.value[0])
     }
  }
</script>

我们想要上面的两个cart都引用同样的对象,这样当我们点击OtherComponent组件中的“添加到购物车”按钮时,被添加的结果才会立即在SomeComponent中可见。

我们有两种方法来实现上面的需求:其中一种就是使用来自外部store中的一个购物车对象,就像文章开头展示的那样:

// store.js
const state = ref({ cart: {} })
const setCart = (cart) => { state.value.cart = cart }

export { setCart }
// useCart.js
import { setCart, cart } from './store.js'

export function useCart () {
  // use setCart and cart here
}

即使我们可以解决问题,从架构的角度来看这种方法有两个缺点:

  1. 我们的useCart方法依赖于外部的store.js,这让它缺少了自包含和可重用性。
  2. state对象中的cart属性独立于useCart函数之外,所以当移除或修改useCart时很容易忽视cart属性。

那么,我们该如何改进上面的方案以解决这些问题呢?

所有我们需要做的,只是轻量的在useProduct改进上面的模式:

export default function useProduct() {
  const loading = ref(false)
  const products = ref([])

  async function search (params) {
    loading.value = true
    products.value = await fetchProduct(params)
    loading.value = false
  }
  return {
    loading: computed(() => loading.value)
    products: computed(() => products.value)
    search
  }
}

我们用来实现新需求的方法唯一错误的地方是我们在每次调用函数时都创建了新的状态。为了保持每次使用useCart实例时都用的是同一个状态,我们仅仅需要将这个状态提取到函数外面,这样它就只会被创建一次:

const cart = ref({})

function useCart () {
  // super complicated cart logic
  return {
    cart: computed(() => cart.value)
  }
}

哈哈--这就是如何用组合式API创建共享状态的方法!这个解决方案和之前的例子一样,极其简单。我希望你能够像我一样喜欢组合式API的这一点。

总结

组合式API不仅是一个新的、革命性的可以在组件间复用代码的方法,同时也给流行的状态管理库,如Vuex,提供了一种新的选择。这个新的API不仅可以简化你的代码,同时也可以通过它的模块化能力优化你的项目结构。看组合式如何和Pinia一起工作并影响我们将来管理状态的方式,这将是很有趣的一件事。

参考

Pinia--基于Vue Composition API的轻量状态管理库open in new window


  1. Vue Storefront是笔者开发的一个移动端电商网站 ↩︎

Loading...