# 第11章 全局数据管理与 Vuex

本章节相关代码存放在Github (opens new window)中。

通常来说,我们刚开始一个项目的时候,项目规模都比较少。这样的情况下,我们并不会引入过于复杂的数据和状态管理工具,而随着我们项目在不断地变大、各个页面中需要共享和通信的内容越来越多,同时也加入了新成员、不同开发之间的习惯不一致,于是到后期可能会出现事件的传递、全局数据满天飞的情况。

这样会有什么隐患呢?大概就是当我们想要改某个全局数据的时候,发现影响到了其他的页面。更让人头疼的是在测试的时候并没有发现(毕竟有在项目规模较大的情况下,回归测试的成本也会越来越重),然后触发了线上事故。

当然,即使在这样的情况下,也并不意味着我们需要在一开始就需要使用很完善的工具库。除非是项目在最初就已经定位为较大规模,这种情况下我们需要根据预期来选型。否则项目在不断维护和壮大的过程中,我们需要不断地进行优化甚至重构,同时根据需要引进不同的工具库来进行管理。

我们来看一下,一般全局数据要用怎样的方式来管理会比较好。

# 11.1 组件间通信

在 Vue 中,不管是页面也好,是模块或是公共组件也好,相互之间的通信几乎是必不可少的。我们先来看一下前面讲过的一些数据通信的方式。

# 11.1.1 父子组件通信

第4章中,我们其实也已经简单介绍过父子组件之间的通信,一般来说会通过 Prop 从父组件往子组件传递数据,同时子组件可以通过$emit()把数据传递到父组件。除此之外,父组件还可以通过ref来引用和访问子组件。同样的,还可以使用$parent$children$root等 API 来分别获取父实例、子实例和根实例。

那么,除了父子以外的组件要怎么处理呢?

# 11.1.2 全局事件管理

除了不断地使用$parent$children$root等 API 来叠加使用获取想要的实例(不推荐这样的操作,因为一旦布局调整就变得很被动了),我们可以在根实例上提供统一的事件处理,同时封装成公共库的方式来提供。这里直接使用第8章中 Todo List 弹窗来作为例子,通过在最外层导出根实例:

// main.js
// 默认 export 该 Vue 实例
export default new Vue({
  el: "#app",
  router, // 传入路由能力
  render: h => h(App)
});

然后在需要的组件或者公共函数方法里引入,同时直接使用$emit触发事件、$on监听事件:

// confirm.js
// 获取该实例
import vm from "../main";
// 传入标题、内容、确认按钮和取消按钮的文案
export function confirmDialog({ title, text, cancelText, confirmText }) {
  return new Promise((resolve, reject) => {
    // 把 reject 和 resolve 通过 $emit 事件传参带过去,方便进行 Promise 状态扭转
    vm.$emit("setDialog", {
      title,
      text,
      cancelText,
      confirmText,
      resolve,
      reject
    });
  });
}

// App.vue
import vm from "../main";
export default {
  // 其他选型省略
  mounted() {
    this.$nextTick(() => {
      vm.$on("setDialog", dialogInfo => {
        // 将弹窗相关信息、弹窗组件添加进 component 数组中
        this.items.push({ dialogInfo, component: ConfirmDialog });
      });
    });
  }
};

点击此处查看页面效果 (opens new window) 点击此处查看源码 (opens new window)

通过这样的方式,我们可以在每个需要的组件里,引入根实例,然后再在需要的时候分别进行事件的监听和触发就可以了。但是这个方法的缺点在本章开头也有提到,我们在新增一个事件监听和触发的时候,很容易和已有的事件名冲突,同时我们想要查看到底有哪些地方进行了某个事件的监听,也只能通过全局搜索的方式来查找,多人协作的情况下很容易出现问题。

# 11.2 全局数据管理

除了通过事件通知数据变更,常见的还有其他方式来管理全局数据,我们一起来看一下。

# 11.2.1 数据的流动

第10章我们介绍了如何将应用和界面抽象成数据管理,其中说到一个应用包含了静态的模板和从外部注入的数据(见 10.2 章节)。数据在注入到我们的应用中后,并不只是简单地存在,它可能会影响应用的状态、同时也会影响其他同样注入的数据。数据与数据之间的交互,其实在某方面相等于我们组件之间的通信,包括但不限于以下的一些方式:

  • 事件通知
  • 共享对象
  • 单方向流动

# 事件通知

事件的监听和触发机制,在许多的场景下都能适用,如浏览器点击、输入框的输入操作,则是典型的事件机制。而在很多时候,我们也可以通过事件通知的方式,来进行数据间的交互,如 Websocket 机制。在 Vue 中,除了使用根实例来绑定作为事件中心,更常见的就是我们自行创建一个事件中心来进行管理:

// eventBus.js
const events = [];

function on(eventName, callback) {
  let event = events.find(x => x.eventName === eventName);
  if (event) {
    // 如果已有该事件,添加到监听者中
    event.addListener(callback);
  } else {
    // 如果没有该事件,则添加新事件,并添加到监听者
    event = new MyEvent(eventName);
    event.addListener(callback);
    events.push(event);
  }
}

function emit(eventName, ...params) {
  let event = events.find(x => x.eventName === eventName);
  // 如果已有该事件,则触发
  if (event) {
    event.trigger(...params);
  }
}

class MyEvent {
  constructor(eventName) {
    // 创建一个事件,传入事件名
    this.eventName = eventName;
    // 同时动态生成一个监听者管理
    this.listeners = [];
  }
  // 触发事件,传入参数
  trigger(...params) {
    // 遍历监听者,触发回调
    this.listeners.forEach(x => {
      x(...params);
    });
  }
  // 添加监听者
  addListener(callback) {
    this.listeners.push(callback);
  }
}
export default {
  on,
  emit
};

同样的我们要实现前面 Todo List 的能力,只需要调整触发和监听的地方:

// confirm.js
// 获取事件中心
import eventBus from "./eventBus";
export function confirmDialog({ title, text, cancelText, confirmText }) {
  return new Promise((resolve, reject) => {
    // 把 reject 和 resolve 通过 emit 事件传参带过去,方便进行 Promise 状态扭转
    eventBus.emit("setDialog", {
      title,
      text,
      cancelText,
      confirmText,
      resolve,
      reject
    });
  });
}

// App.vue
// 获取事件中心
import eventBus from "./utils/eventBus";
export default {
  // 其他选型省略
  mounted() {
    this.$nextTick(() => {
      eventBus.on("setDialog", dialogInfo => {
        // 将弹窗相关信息、弹窗组件添加进 component 数组中
        this.items.push({ dialogInfo, component: ConfirmDialog });
      });
    });
  }
};

事件通知机制很方便,可以随意地定义触发的时机,也可以任意地点使用监听或是触发。但前面也说过,事件机制的弊端也是很明显,就是每一个事件的触发对应一个监听,关系是一一对应。在整个应用中看,则是散落在各处,随意乱窜的数据流动。需要定位的时候,只能通过全局搜索的方式来跟踪数据的去向。

我们来看个示例图(图 11-1),这里只有事件 1 和事件 2 的触发和监听,存在跨组件甚至跨页面的事件通知。如果在一个规模较大的应用中,可能会导致满屏的事件,同时事件之间并没有什么规律可循。这样我们每次改动事件相关的代码,例如多传一个参数、改变某个参数,都可能会导致未知的错误,需要全局搜索出相关的事件,并一一进行回归测试才可以。
image
图 11-1 事件通知通信机制

当然,也有些人会定义一个中转站,所有的事件数据流都会经过那,这样的维护方式会有所改善。

imgae
图 11-2 事件通知增加中转站

类似这种,所有的数据流都可以在中转站找到,所有的数据都会储存在这里,数据的更新也必会经过这里。这样我们在新增一个事件的时候,就不会担心和已有的事件名冲突,同时也可以使用 Typescript 来定义每个事件的参数类型,就可以稍微避免一些改动引起不必要的错误了。

# 共享对象

共享对象是很简单的一种方式,当我们需要多个地方使用相同的数据,我们就把它们放置在一个地方,大家都去那理获取和更新。

imgae
图 11-3 共享对象通信机制

常见的方式是,我们可以简单的使用一个叫globalData.js的文件来管理全局用的数据,文件共享的方式,其实和前面说到的共享对象的方式比较相似:

// globalData.js
// globalData 用来存全局数据
let globalData = {};

// 获取全局数据
// 传 key 获取对应的值
// 不传 key 获取全部值
export function getGlobalData(key) {
  return key ? globalData[key] : globalData;
}

// 设置全局数据
export function setGlobalData(key, value) {
  // 需要传键值对
  if (key === undefined || value === undefined) {
    return;
  }
  globalData = { ...globalData, [key]: value };
  return globalData;
}

// 清除全局数据
// 传 key 清除对应的值
// 不传 key 清除全部值
export function clearGlobalData(key) {
  // 需要传键值对
  if (key === undefined) {
    globalData = {};
  } else {
    delete globalData[key];
  }
  return globalData;
}

使用这种方式的全局数据,是会在页面刷新之后丢失的。如果像用户的登录态信息这种需要缓存的,更好的方式是存到 cookie 或者缓存里。

通过注入对象的引用,来在不同组件中获取相同的数据源。在服务端开发的时候使用这样的方式,需要考虑锁的问题,当然单线程的 JS 里面这样的情况比较少,几乎可以不考虑。同时,很多时候我们定义了一个对象,某些地方想要共享这个对象包括它的状态数据,有些地方又想要获取一个全新初始化的对象。这种情况下,我们需要考虑怎样去维护一套这种数据与实例。

# 单方向流动

在全局数据的使用变频繁之后,我们在定位问题的时候还会遇到不知道这个数据为何改变的情况,因为所有引用到这个全局数据的地方都可能对它进行改变。这种情况下,给数据的流动一个方向,则可以方便地跟踪数据的来源和去处。通过流的方式来管理状态,常见的状态管理工具像 Vuex、Redux 等,都是这样管理的。

imgae
图 11-4 单方向流动通信机制

当然,赋予数据流方向,但是数据的存放呢,可以通过共享对象的方式,也可以维护一个数据中心。所有的数据变更方式、触发逻辑、影响范围,都在数据中心里面管理和维护。后面我们会介绍 Vuex,所以这里先不过多讲解单方向流动的数据管理实现。

# 树状作用域

很多时候,我们的应用通过一层层地封装和隔离,最终会呈现为树状。我们可以根据组件的树状作用域,结合共享对象的管理,来注入树状的数据结构。典型如 Angular 里,则是通过提供通用的依赖注入方式,配合树状的模块管理,可通过局部注入实例来获取共享或是隔离的数据。当然,这样的做法在其他框架中并不常见,而 Vue 中使用的更多是平级的单向数据流动,毕竟在 Angular 中树状结构的出现部分是为了优化脏检查机制,Vue 中我们只需要将数据变动更新到界面中就可以了。

# 11.3 使用 Vuex

前面我们讲到,在 Vue 组件中是通过prop属性和自定义事件$on$emit可以实现父子组件间的通信。但很多时候我们的数据是需要跨组件、甚至跨页面进行共享,这种情况下我们需要更好的方式来维护这些全局的数据。我们已经讲了关于全局数据状态管理的一些常用方法,各种方式的优势和缺点也都有说到,这里我们就直接介绍 Vuex。

Vuex 是官方推荐的状态管理工具,通过单向数据流的方式来维护状态变更。所有的数据都会存到 Store 里,只能通过提交 Mutation 或者触发 Action 的方式来改变数据,在这个过程中就会形成一个单向流动:(Action) -> Mutation -> Store -> update view。关于什么是 Mutation 和 Action,什么又是 Store,可以参考下官方的配图,后面会有详细介绍:

imgae
图 11-5 Vuex 状态更新

关于 Vuex 的用途,可以理解一下官方描述:它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

# 11.3.1 Vuex 核心概念

关于 Vuex,有一些基本的核心概念需要先理解一下。

# State

State 为状态,我们可以理解为上一章内容中抽象的数据。Vuex 中的 State,也可以理解为一个绑定到界面或组件的状态变量,就像是data中的变量一样:

new Vue({
  // state
  data() {
    return {
      count: 0
    };
  }
});

而在 Vuex 中,State 通常是指全局的应用状态。因为 Vuex 使用单一状态树,用一个对象就包含了全部的应用层级状态。这样所有 State 的集合,就是 Store。

# Store

每一个 Vuex 应用的核心就是仓库(Store),它包含着我们应用中大部分的状态(State)。状态也是数据的一种,相比直接展示在页面中的内容,状态更多时候是控制展示方式和逻辑的一些数据。像我们常说的用户登录态、地理位置等,一般来说我们存到 Store 中的数据,大多数都是需要多组件、多页面或是整个应用中共享的数据。

一个简单的 Store 是怎样注入到我们的应用中的呢,我们来看一下:

// 引入Vuex库
Vue.use(Vuex);

// 创建一个Store
const store = new Vuex.Store({
  // 设置状态
  state: {
    count: 0
  },
  // Mutation用于更新状态
  mutations: {
    increment(state) {
      state.count++;
    }
  }
});

// Vuex通过Store选项,提供了一种机制将状态从根组件“注入”到每一个子组件中(需调用 ):
const app = new Vue({
  // 其他选项省略
  // 把store对象提供给“store”选项,这可以把store的实例注入所有的子组件
  store
});

当然,我们也可以通过创建单文件或是全局对象的方式来维护这些全局的状态。但这样的方式中,数据的变化不能得到监听,需要使用方自行地去获取。我们还可以使用事件、getter/setter的方式来进行,但依然存在一个问题,数据的变化无法直接更新到组件和页面中。所以就有了 Vuex,使用 Vuex 我们可以获得以下效果:
(1) 当 Vue 组件从 Store 中读取状态的时候,若 Store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。
(2) 改变 Store 中的状态只能通过 Action 或者 Mutation,这样使得我们可以方便地跟踪每一个状态的变化,来定位具体问题。

每个应用将仅仅包含一个 Store 实例,单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。但如果每个应用只包含了一个 Store,我们要怎样避免在不断引入全局状态的时候,避免 Store 变得臃肿呢?

# Module

为了解决应用变得复杂时 Store 难以维护,Vuex 允许我们将 Store 分割成模块 Module。每个模块拥有自己的 State、Mutation、Action 等,其实相当于我们将一个 Store 单一对象分成多个对象来维护,但最终也会合并为一个来进行更新,这样即使是多个模块也能够对同一 Mutation 或 Action 作出响应。

那如果我们在不同的页面中,有相同的一些状态命名发生了冲突,要如何解决呢?我们还可以通过添加namespaced: true的方式,来创建带命名空间的模块,同时 Module 还支持嵌套使用:

const store = new Vuex.Store({
  modules: {
    account: {
      namespaced: true, // 带命名空间
      // 模块内容(module assets)
      state: { ... }, // 模块内的状态已经是嵌套的了,使用namespaced属性不会对其产生影响
      // 可以拥有自己的Action和Mutation
      actions: {
        login () { ... } // -> dispatch('account/login')
      },
      mutations: {
        login () { ... } // -> commit('account/login')
      },
      // 嵌套模块
      modules: {
        // 继承父模块的命名空间
        myPage: {
          state: { ... }
        },
        // 进一步嵌套命名空间
        posts: {
          namespaced: true,
          state: { ... }
        }
      }
    }
  }
})

# Mutation

前面在 Store 的代码示例中也有出现过 Mutation,我们也说过,数据流的单向流动可以让开发者掌握所有数据状态的变更来源和去向。而 Mutation 的作用类似于一个守卫,所有的状态变更都必须来自 Mutation:

const store = new Vuex.Store({
  state: {
    count: 1
  },
  mutations: {
    // 每个 Mutation 都有一个字符串的事件类型 type 和一个回调函数 handler
    // 这个回调函数就是我们实际进行状态更改的地方
    increment(state) {
      // 变更状态
      state.count++;
    }
  }
});

// 要调用 Mutation handler,你需要以相应的 type 调用 store.commit 方法
store.commit("increment");

也就是说,在 Vuex 中状态的变更只能通过提交 Mutation 来更新状态,而提交 Mutation 也只能通过 commit 对应的的事件 type 来进行。在 Vuex 中,Mutation 都是同步函数,如果是异步的话,我们的状态变更就不实时,这会影响后续其他地方获取状态的实时性。那如果我们需要异步操作,例如从后台接口拉取的数据更新,这种情况下我们可以使用 Action。

# Action

相比与 Mutation,Action 的不同之处在于:
(1) Action 提交的是 Mutation,而不是直接变更状态。
(2) Action 可以包含任意异步操作。

我们来看一下 Action 的使用:

const store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment(state) {
      state.count++;
    }
  },
  actions: {
    // Action 函数接受一个与 store 实例具有相同方法和属性的 context 对象
    increment(context) {
      // 因此可以调用 context.commit 提交一个 mutation
      context.commit("increment");
      // 其他的,也可以通过 context.state 来获取 state
    }
  }
});

关于 Vuex,还有 Getter 等概念,Getter 类似于 computed 之类的,Getter 的返回值会根据它的依赖被缓存起来,且只有当它的依赖值发生了改变才会被重新计算。这里就不过多讲解,大家可以去官网上补充更多的内容,我们来讲一些实战的用法吧。

# 11.3.2 Vuex 使用

Vuex 使用单一状态树,即用一个对象就包含了全部的应用层级状态。Vuex 主要在什么时候用呢?当我们一些数据需要共享的时候,例如用户信息,可能会在多个组件中使用到的。一些 ajax 请求的数据,可通过 Action 来触发请求,完成后更新到 State,页面可直接更新。至于 Vuex 的使用方式和管理模式多种多样,如:
(1) 根据 Vuex 的核心概念分文件管理,像把所有的 Action 放一起管理、Mutation 放一起。
(2) 根据业务功能分文件管理,相似业务数据维护在一个 Store 中。

# 全局弹窗 Vuex 实现

前面在第8章的时候,我们使用了在根实例上注册事件监听和触发的方式来实现全局弹窗,在本章介绍 Event Bus 的时候,也使用自制的事件管理来实现全局弹窗。现在,我们来使用 Vuex 重新实现一遍吧。

(1) 设计弹窗 Store。

为了实现每次调用confirmDialog事件的时候,都新增一个弹窗,我们可以使用一个弹窗列表的方式来管理:

// dialogStore.js
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);

const dialogStore = new Vuex.Store({
  state: {
    // 弹窗列表,用来保存可能弹窗的一系列弹窗
    dialogList: []
  },
  mutations: {
    removeDialog(state, index) {
      // 移除弹窗
      state.dialogList.splice(index, 1);
    },
    setDialog(
      state,
      { title, text, cancelText, confirmText, resolve, reject }
    ) {
      // 添加新的弹窗
      state.dialogList.push({
        title,
        text,
        cancelText,
        confirmText,
        resolve,
        reject
      });
    }
  }
});

export default dialogStore;

(2) 新增弹窗的方法。

我们需要实现一个用来添加弹窗确认的方法,同时返回一个 Promise,当用户进行了操作之后,就更新 Promise 的状态:

// confirm.js
import dialogStore from "../components/ConfirmDialog/dialogStore";

// 传入标题、内容、确认按钮和取消按钮的文案
export function confirmDialog({ title, text, cancelText, confirmText }) {
  return new Promise((resolve, reject) => {
    // 调用 dialogStore.commit 提交 setDialog
    // 把 reject 和 resolve 通过事件传参带过去,方便进行 Promise 状态扭转
    dialogStore.commit("setDialog", {
      title,
      text,
      cancelText,
      confirmText,
      resolve,
      reject
    });
  });
}

export default confirmDialog;

(3) 使用动态组件添加。

第8章一样,我们可以使用动态组件的方式,来支持同时多个弹窗的交互确认:

<!-- App.vue -->
<template>
  <div>
    <!-- 使用 <router-view></router-view> 来渲染最高级路由匹配到的组件 -->
    <router-view></router-view>
    <!-- 动态组件由 vm 实例的 component 控制 -->
    <!-- done 事件绑定用户操作完毕 -->
    <component
      v-for="(dialogInfo, index) in dialogList"
      :key="index"
      :is="ConfirmDialog"
      :dialogInfo="dialogInfo"
      :index="index"
    ></component>
  </div>
</template>

<script>
  // 弹窗组件
  import ConfirmDialog from "./components/ConfirmDialog/ConfirmDialog.vue";
  import dialogStore from "./components/ConfirmDialog/dialogStore";

  export default {
    name: "app",
    computed: {
      dialogList() {
        // 绑定 dialogStore 的 dialogList 列表
        return dialogStore.state.dialogList;
      }
    },
    data() {
      return {
        // 用来绑定 component
        ConfirmDialog
      };
    }
  };
</script>

(4) 弹窗数据绑定。

因为通过列表的方式来管理弹窗的 Store,同时我们在动态组件中使用 Prop 的方式传入弹窗的信息,所以我们可以从 Prop 中获取到弹窗的内容:

<!-- ConfirmDialog.vue -->
<template>
  <!-- 强制出现 display: block -->
  <div class="modal" tabindex="-1" role="dialog" style="display: block">
    <div class="modal-dialog" role="document">
      <div class="modal-content">
        <div class="modal-header">
          <button type="button" class="close" aria-label="Close">
            <span aria-hidden="true">&times;</span>
          </button>
          <!-- 弹窗标题 -->
          <h4 class="modal-title">{{dialogInfo.title || '提示'}}</h4>
        </div>
        <div class="modal-body">
          <!-- 弹窗内容 -->
          <p>{{dialogInfo.text}}</p>
        </div>
        <div class="modal-footer">
          <!-- 取消按钮,点击取消,cancelText 可设置按钮文案 -->
          <button type="button" class="btn btn-default" @click="cancel()">
            {{dialogInfo.cancelText || '取消'}}
          </button>
          <!-- 确认按钮,点击确认,confirmText 可设置按钮文案 -->
          <button type="button" class="btn btn-primary" @click="confirm()">
            {{dialogInfo.confirmText || '确认'}}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
  import dialogStore from "./dialogStore";

  export default {
    props: {
      // 弹窗相关信息
      dialogInfo: {
        type: Object,
        default: () => {}
      },
      // 弹窗的序号,移除的时候需要
      index: {
        type: Number
      }
    },
    methods: {
      // 点击取消
      cancel() {
        // 要先判断下 reject 方法在不在
        if (this.dialogInfo.reject) {
          // 取消就 reject 掉呀
          this.dialogInfo.reject();
          // 移除掉这个弹窗
          dialogStore.commit("removeDialog", this.index);
        }
      },
      // 点击确认
      confirm() {
        // 要先判断下 resolve 方法在不在
        if (this.dialogInfo.resolve) {
          // 确认就 resolve 掉
          this.dialogInfo.resolve();
          // 移除掉这个弹窗
          dialogStore.commit("removeDialog", this.index);
        }
      }
    }
  };
</script>

(5) 弹窗确认。

最后,我们就可以使用confirmDialog方法来进行弹窗确认:

// 传入弹窗的内容,并通过 then 和 catch 来获取用户的操作,以便继续后续的逻辑处理
confirmDialog({
  text: "确认要删除吗?"
})
  .then(res => {
    // 用户点击确认
    // 二次弹窗确认
    confirmDialog({
      text: "真的确认要删除吗?"
    })
      .then(res => {
        // 用户点击确认
      })
      .catch(() => {
        // 用户点击取消
      });
  })
  .catch(() => {
    // 用户点击取消
  });

通过这种方式,我们可以进行二次弹窗,也可以进行多个弹窗叠加。同时使用 Promise 的方式,也可以更有连贯性地编写代码。

点击此处查看页面效果 (opens new window) 点击此处查看源码 (opens new window)

关于 Vuex,还有更多的使用方式,这里只是展示了绑定弹窗的一个使用方式。而在大型项目的管理中,除了将 Store 模块化,我们还可以根据需要来对 Action 和 Mutation 进行管理。除此之外,我们也还是可以自行开发全局数据、事件管理等方式来进行。但为了避免多人协作的一些问题,一般我们在选型的时候,更多会考虑规范性和维护性,所以选用官方提供的一些库也是很好的选择。