如何在Vue 3中管理共享状态

846 阅读10分钟

状态可能是很难的。当我们开始一个简单的Vue项目时,只在一个特定的组件上保持我们的工作状态可能很简单。

setup() {
  let books: Work[] = reactive([]);

  onMounted(async () => {
    // Call the API
    const response = await bookService.getScienceBooks();
    if (response.status === 200) {
      books.splice(0, books.length, ...response.data.works);
    }
  });

  return {
    books
  };
},

当你的项目是一个显示数据的单页时(也许是为了排序或过滤),这可能是引人注目的。但在这种情况下,这个组件将在每次请求时获得数据。如果你想把它留在身边呢?这就是状态管理开始发挥作用的地方。由于网络连接通常是昂贵的,偶尔也是不可靠的,所以在你浏览应用程序的时候,最好是把这个状态保留在周围。

另一个问题是组件之间的通信。虽然你可以使用事件和道具来与直接的子代父代沟通,但当你的每个视图/页面都是独立的时候,处理简单的情况,如错误处理和繁忙标志,会很困难。例如,想象一下,你有一个顶层的控件被连接起来,以显示错误和加载动画。

// App.vue
<template>
  <div class="container mx-auto bg-gray-100 p-1">
    <router-link to="/"><h1>Bookcase</h1></router-link>
    <div class="alert" v-if="error">{{ error }}</div>
    <div class="alert bg-gray-200 text-gray-900" v-if="isBusy">
      Loading...
    </div>
    <router-view :key="$route.fullPath"></router-view>
  </div>
</template>

如果没有一个有效的方法来处理这个状态,可能会建议使用发布/订阅系统,但事实上在很多情况下,共享数据是更直接的。如果想拥有共享状态,你该如何去做呢?让我们来看看一些常见的方法。

Vue 3中的共享状态

自从搬到Vue 3,我已经完全迁移到使用Composition API。在这篇文章中,我也使用了TypeScript,尽管这不是我向你展示的例子所需要的。虽然你可以以任何方式分享状态,但我将向你展示我发现最常用的几种技术模式。每一种都有它自己的优点和缺点,所以不要把我在这里讲的任何东西当作教条。

这些技术包括。

  • [工厂]。
  • [共享单体]。
  • [Vuex 4],
  • [Vuex 5]。

注意Vuex 5,截至本文写作时,它正处于RFC(征求意见稿)阶段,所以我想让你为Vuex的发展做好准备,但现在还没有这个选项的工作版本。

让我们深入了解一下...

工厂

注意本节的代码在GitHub上的示例项目的 "Factories "分支中。

工厂模式只是为了创建一个你所关心的状态的实例。在这个模式中,你返回一个函数,这个函数很像Composition API中的start函数。你会创建一个范围,并构建你所寻找的组件。比如说。

export default function () {

  const books: Work[] = reactive([]);

  async function loadBooks(val: string) {
      const response = await bookService.getBooks(val, currentPage.value);
      if (response.status === 200) {
        books.splice(0, books.length, ...response.data.works);
      }
  }

  return {
    loadBooks,
    books
  };
}

你可以只要求你需要的工厂创建的对象的部分,像这样。

// In Home.vue
  const { books, loadBooks } = BookFactory();

如果我们添加一个isBusy 标志来显示网络请求发生时,上面的代码不会改变,但你可以决定在哪里显示isBusy

export default function () {

  const books: Work[] = reactive([]);
  const isBusy = ref(false);

  async function loadBooks(val: string) {
    isBusy.value = true;
    const response = await bookService.getBooks(val, currentPage.value);
    if (response.status === 200) {
      books.splice(0, books.length, ...response.data.works);
    }
  }

  return {
    loadBooks,
    books,
    isBusy
  };
}

在另一个视图(vue?)中,你可以只要求isBusy标志,而不需要知道工厂的其他部分是如何工作的。

// App.vue
export default defineComponent({
  setup() {
    const { isBusy } = BookFactory();
    return {
      isBusy
    }
  },
})

但是你可能已经注意到了一个问题;每次我们调用工厂的时候,我们都会得到一个所有对象的新实例。有时你会希望工厂返回新的实例,但在我们的案例中,我们讨论的是共享状态,所以我们需要将创建工作移到工厂之外。

const books: Work[] = reactive([]);
const isBusy = ref(false);

async function loadBooks(val: string) {
  isBusy.value = true;
  const response = await bookService.getBooks(val, currentPage.value);
  if (response.status === 200) {
    books.splice(0, books.length, ...response.data.works);
  }
}

export default function () {
 return {
    loadBooks,
    books,
    isBusy
  };
}

现在工厂给了我们一个共享的实例,如果你愿意的话,就是一个单子。虽然这种模式可行,但如果每次都返回一个不创建新实例的函数,就会让人感到困惑。

因为底层对象被标记为const ,你不应该能够替换它们(并破坏单子的性质)。所以这段代码应该抱怨。

// In Home.vue
  const { books, loadBooks } = BookFactory();

  books = []; // Error, books is defined as const

所以确保易变的状态能够被更新可能是很重要的(例如使用books.splice() ,而不是分配书本)。

另一种处理方式是使用共享实例。

共享实例

这一部分的代码在GitHub上示例项目的 "SharedState "分支中。

如果你要共享状态,不妨明确一下状态是一个单子的事实。在这种情况下,它可以直接作为一个静态对象被导入。例如,我喜欢创建一个可以作为一个反应式对象导入的对象。

export default reactive({

  books: new Array<Work>(),
  isBusy: false,

  async loadBooks() {
    this.isBusy = true;
    const response = await bookService.getBooks(this.currentTopic, this.currentPage);
    if (response.status === 200) {
      this.books.splice(0, this.books.length, ...response.data.works);
    }
    this.isBusy = false;
  }
});

在这种情况下,你只需导入这个对象(在这个例子中我称之为存储)。

// Home.vue
import state from "@/state";

export default defineComponent({
  setup() {

    // ...

    onMounted(async () => {
      if (state.books.length === 0) state.loadBooks();
    });

    return {
      state,
      bookTopics,
    };
  },
});

然后,与状态的绑定就变得很容易。

<!-- Home.vue -->
<div class="grid grid-cols-4">
  <div
    v-for="book in state.books"
    :key="book.key"
    class="border bg-white border-grey-500 m-1 p-1"
  >
  <router-link :to="{ name: 'book', params: { id: book.key } }">
    <BookInfo :book="book" />
  </router-link>
</div>

像其他模式一样,你得到的好处是你可以在视图之间共享这个实例。

// App.vue
import state from "@/state";

export default defineComponent({
  setup() {
    return {
      state
    };
  },
})

然后这就可以绑定到相同的对象(不管它是Home.vue 的父对象还是路由器中的另一个页面)。

<!-- App.vue -->
  <div class="container mx-auto bg-gray-100 p-1">
    <router-link to="/"><h1>Bookcase</h1></router-link>
    <div class="alert bg-gray-200 text-gray-900"   
         v-if="state.isBusy">Loading...</div>
    <router-view :key="$route.fullPath"></router-view>
  </div>

无论你使用工厂模式还是共享实例,它们都有一个共同的问题:可变的状态。你可能会因为绑定或代码在你不希望的时候改变状态而产生意外的副作用。在像我这里使用的一个琐碎的例子中,这并不复杂,不必担心。但当你构建越来越大的应用程序时,你会想更仔细地考虑状态突变的问题。这就是Vuex可以帮助你的地方。

Vuex 4

Vuex是Vue的状态管理器。它是由核心团队建立的,尽管它是作为一个单独的项目管理的。Vuex的目的是将状态与你想对状态进行的操作分开。所有的状态变化都要经过Vuex,这意味着它更复杂,但你可以得到保护,避免意外的状态变化。

Vuex的理念是提供一个可预测的状态管理流程。视图流向行动,而行动又使用突变来改变状态,反过来又更新视图。通过限制状态变化的流程,你应该有更少的副作用来改变你的应用程序的状态;因此更容易建立更大的应用程序。Vuex有一个学习曲线,但在这种复杂性下,你会得到可预测性。

此外,Vuex确实支持开发时的工具(通过Vue工具)来处理状态管理,包括一个叫做时间旅行的功能。这允许你查看状态的历史,并向前和向后移动,看看它是如何影响应用程序的。

也有一些时候,Vuex也很重要。

要把它添加到你的Vue 3项目中,你可以把这个包添加到项目中。

> npm i vuex

或者,你也可以通过使用Vue CLI来添加它。

> vue add vuex

通过使用CLI,它将为你的Vuex商店创建一个起点,否则你将需要手动将它连接到项目中。让我们来看看这是如何进行的。

首先,你需要一个用Vuex的createStore函数创建的状态对象。

import { createStore } from 'vuex'

export default createStore({
  state: {},
  mutations: {},
  actions: {},
  getters: {}
});

正如你所看到的,这个存储需要定义几个属性。状态只是一个你想让你的应用程序访问的数据的列表。

import { createStore } from 'vuex'

export default createStore({
  state: {
    books: [],
    isBusy: false
  },
  mutations: {},
  actions: {}
});

注意,状态不应该使用refreactive包装器。这些数据与我们在共享实例或工厂中使用的共享数据是一样的。这个商店在你的应用程序中会是一个单子,因此状态中的数据也会被共享。

接下来,让我们看一下行动。行动是你想启用的涉及状态的操作。比如说。

  actions: {
    async loadBooks(store) {
      const response = await bookService.getBooks(store.state.currentTopic,
      if (response.status === 200) {
        // ...
      }
    }
  },

行动是通过一个存储的实例,这样你就可以获得状态和其他操作。通常情况下,我们只对我们需要的部分进行结构化。

  actions: {
    async loadBooks({ state }) {
      const response = await bookService.getBooks(state.currentTopic,
      if (response.status === 200) {
        // ...
      }
    }
  },

最后一块是突变。突变是可以突变状态的函数。只有突变才能影响状态。所以,对于这个例子,我们需要改变改变状态的突变。

  mutations: {
    setBusy: (state) => state.isBusy = true,
    clearBusy: (state) => state.isBusy = false,
    setBooks(state, books) {
      state.books.splice(0, state.books.length, ...books);
    }
 },

突变函数总是传入状态对象,以便你可以突变该状态。在前两个例子中,你可以看到我们在明确地设置状态。但在第三个例子中,我们传入的是要设置的状态。变异总是需要两个参数:状态和调用变异时的参数。

为了调用突变,你会在存储空间上使用提交函数。在我们的例子中,我只是把它添加到析构中。

  actions: {
    async loadBooks({ state, commit }) {
      commit("setBusy");
      const response = await bookService.getBooks(state.currentTopic, 
      if (response.status === 200) {
        commit("setBooks", response.data);
      }
      commit("clearBusy");
    }
  },

你将在这里看到的是commit是如何要求动作的名称的。有一些技巧可以使这不只是使用魔法字符串,但我现在要跳过这一点。使用魔法字符串是使用Vuex的限制之一。

虽然使用commit似乎是一个不必要的包装,但请记住Vuex是不会让你变异状态的,除了在变异里面,因此只有通过commit的调用才会。

你还可以看到,对setBooks的调用需要一个第二个参数。这就是调用突变的第二个参数。如果你需要更多的信息,你需要把它打包成一个参数(目前Vuex的另一个限制)。假设你需要在书籍列表中插入一本书,你可以像这样调用它。

commit("insertBook", { book, place: 4 }); // object, tuple, etc.

然后你就可以直接将结构解构为你需要的部分。

mutations: {
  insertBook(state, { book, place }) => // ...    
}

这很优雅吗?不是很好,但它可以工作。

现在我们的动作与突变一起工作了,我们需要在代码中使用Vuex存储。其实有两种方法可以接触到存储。首先,通过在应用程序中注册存储(例如main.ts/js),你可以访问一个集中的存储,你可以在你的应用程序中的任何地方访问。

// main.ts
import store from './store'

createApp(App)
  .use(store)
  .use(router)
  .mount('#app')

注意,这不是在添加Vuex,而是你正在创建的实际商店。一旦添加了这个,你就可以直接调用useStore 来获取存储对象。

import { useStore } from "vuex";

export default defineComponent({
  components: {
    BookInfo,
  },
  setup() {
    const store = useStore();
    const books = computed(() => store.state.books);
    // ...
  

这样做很好,但我更喜欢直接导入商店。

import store from "@/store";

export default defineComponent({
  components: {
    BookInfo,
  },
  setup() {
    const books = computed(() => store.state.books);
    // ...
  

现在你已经可以访问商店对象了,你如何使用它呢?对于状态,你需要用计算函数来包装它们,这样变化就会被传播到你的绑定上。

export default defineComponent({
  setup() {

    const books = computed(() => store.state.books);

    return {
      books
    };
  },
});

要调用动作,你将需要调用dispatch方法。

export default defineComponent({
  setup() {

    const books = computed(() => store.state.books);

    onMounted(async () => await store.dispatch("loadBooks"));

    return {
      books
    };
  },
});

动作可以有参数,你可以在方法的名字后面添加。最后,要改变状态,你需要调用commit,就像我们在Actions里面做的那样。例如,我在商店里有一个分页属性,然后我可以用commit来改变状态。

const incrementPage = () =>
  store.commit("setPage", store.state.currentPage + 1);
const decrementPage = () =>
  store.commit("setPage", store.state.currentPage - 1);

注意,这样调用会产生一个错误(因为你不能手动改变状态)。

const incrementPage = () => store.state.currentPage++;
  const decrementPage = () => store.state.currentPage--;

这就是真正的力量,我们希望控制状态的改变,而不是在开发过程中产生副作用,产生错误。

你可能会被Vuex中的移动部件的数量所淹没,但它确实可以帮助管理更大、更复杂的项目中的状态。我不会说你在每一种情况下都需要它,但在一些大型项目中,它对你有全面的帮助。

Vuex 4的最大问题是,在TypeScript项目中使用它有很多不足之处。你当然可以制作TypeScript类型来帮助开发和构建,但它需要大量的移动部件。

这就是Vuex 5旨在简化Vuex在TypeScript(以及一般的JavaScript项目)中的工作方式。让我们看看,一旦它接下来发布,将如何运作。

Vuex 5

在写这篇文章时,Vuex 5还不是真的。它是一个RFC(征求意见稿)。它是一个计划。它是一个讨论的起点。所以我在这里解释的很多东西可能会有一些变化。但为了让你对Vuex的变化有所准备,我想让你了解一下它的发展方向。正因为如此,与这个例子相关的代码并没有建立。

自Vuex诞生以来,其工作方式的基本概念在某种程度上没有改变。随着Vue 3的引入,Vuex 4的创建主要是为了让Vuex在新项目中发挥作用。但该团队正试图关注Vuex的真正痛点,并解决它们。为此,他们正在计划一些重要的变化。

  • 不再有突变:动作可以突变状态(可能还有人)。
  • 更好的TypeScript支持。
  • 更好的多存储功能。

那么,这将如何工作?让我们从创建商店开始。

export default createStore({
  key: 'bookStore',
  state: () => ({
    isBusy: false,
    books: new Array<Work>()
  }),
  actions: {
    async loadBooks() {
      try {
        this.isBusy = true;
        const response = await bookService.getBooks();
        if (response.status === 200) {
          this.books = response.data.works;
        }
      } finally {
        this.isBusy = false;
      }
    }
  },
  getters: {
    findBook(key: string): Work | undefined {
      return this.books.find(b => b.key === key);
    }
  }
});

首先要看到的变化是,每个商店现在都需要它自己的键。这是为了让你能够检索到多个商店。接下来,你会注意到状态对象现在是一个工厂(例如,从一个函数返回,而不是在解析时创建)。而且不再有突变部分。最后,在动作里面,你可以看到我们在访问状态时,只是把属性放在this 指针上。不再需要传入状态并提交给动作。这不仅有助于简化开发,而且也使TypeScript的类型推断更容易。

要将Vuex注册到你的应用程序中,你要注册Vuex而不是你的全局存储。

import { createVuex } from 'vuex'

createApp(App)
  .use(createVuex())
  .use(router)
  .mount('#app')

最后,为了使用存储,你将导入存储,然后创建它的一个实例。

import bookStore from "@/store";

export default defineComponent({
  components: {
    BookInfo,
  },
  setup() {
    const store = bookStore(); // Generate the wrapper
    // ...
  

注意,从商店返回的是一个工厂对象,无论你调用工厂多少次,都会返回这个商店的实例。返回的对象只是一个对象,它的动作、状态和获取器都是一等公民(有类型信息)。

onMounted(async () => await store.loadBooks());

const incrementPage = () => store.currentPage++;
const decrementPage = () => store.currentPage--;

你在这里看到的是,状态(例如:currentPage )只是简单的属性。而动作(例如:loadBooks )只是函数。事实上,你在这里使用一个存储是一个副作用。你可以把Vuex对象当作只是一个对象,然后去做你的工作。这是对API的一个重大改进。

另一个需要指出的变化是,你也可以使用类似Composition API的语法来生成你的商店。

export default defineStore("another", () => {

  // State
  const isBusy = ref(false);
  const books = reactive(new ArrayWork>());

  // Actions
  async function loadBooks() {
    try {
      this.isBusy = true;
      const response = await bookService.getBooks(this.currentTopic, this.currentPage);
      if (response.status === 200) {
        this.books = response.data.works;
      }
    } finally {
      this.isBusy = false;
    }
  }

  findBook(key: string): Work | undefined {
    return this.books.find(b => b.key === key);
  }

  // Getters
  const bookCount = computed(() => this.books.length);

  return {
    isBusy,
    books,
    loadBooks,
    findBook,
    bookCount
  }
});

这让你可以像使用Composition API的视图一样构建你的Vuex对象,可以说它更简单。

这种新设计的一个主要缺点是,你失去了状态的不可变性。目前正在讨论能否启用这一点(仅适用于开发,就像Vuex 4一样),但对这一点的重要性还没有达成共识。我个人认为这是Vuex的一个关键优势,但我们必须看到它是如何发展的。

我们到哪里了?

在单页应用中管理共享状态是大多数应用开发的关键部分。在Vue中制定一个关于如何进行的游戏计划是设计解决方案的重要步骤。在这篇文章中,我向你展示了几种管理共享状态的模式,包括Vuex 5即将推出的内容。希望你现在有足够的知识来为自己的项目做出正确的决定。