Skip to content

4.5 Vue.js 全家桶

在一个中大型的 Vue 工程化项目中,光有 Vue 是不够的,还要有周边的配套工具。其中最主要的有三个:

  • 路由管理
  • 状态管理
  • 统一请求管理

接下来我们分别介绍这几个工具。

4.5.1 路由管理

单页面应用(SPA)中只有一个 html 文件,所有的页面都是以组件切换的方式出现。当页面组件切换时,可能会伴随着浏览器刷新,此时我们需要有一个特定的 URL 路径与组件匹配,以确保页面的正确展示。

在前端中,此类 URL 被称为路由,处理路由与组件的关联我们称之为路由管理。

Vue 提供了官方路由管理框架 Vue Router。Vue Router 通过一个配置文件,配置路由与组件的绑定关系,并提供了页面的前进、后退、重定向等导航 API。

注册路由

下面创建两个简单的函数组件,并创建一个路由配置:

js
const Home = () => {
  return <div>首页</div>;
};
const About = () => {
  return <div>关于我们</div>;
};

const routes = [
  { path: "/", component: Home },
  { path: "/about", component: About },
];

路由配置是一个数组,每一个数组项定义一个路由规则。Vue Router 提供了 createRouter() 方法用于创建路由对象,在方法中传入定义好的路由配置:

js
import Vue from "vue";
import VueRouter from "vue-router";
const router = VueRouter.createRouter({
  routes,
});
// 创建Vue实例并注册路由
const app = Vue.createApp({});
app.use(router);

路由配置中支持的对象属性以及含义如下:

  • name:路由名称,必须唯一,可用于跳转路由。
  • component:路由组件,从外部导入。
  • path:路由地址。
  • meta:路由源数据。
  • children:嵌套子路由,数组。
js
const routes = [
  {
    path: '/users',
    meta: { tag: 'teacher' },
    name: 'user'
    component: xxx,
    children: [
      {
        path: '/info/:id',
        component: xxx,
      }
    ]
  }
]

嵌套子路由可以通过 path 连接的方式访问到子路由,如上述代码中子路由地址为 /users/info/:id。这里的 :id 表示路径参数,实际访问时可以动态替换,如:/users/info/6。

路由对象

注册路由之后,在整个 Vue 中通过以下两种方式访问到路由对象:

  1. this.$router:全局路由对象。
  2. this.$route:当前路由对象。

this.$router 指向 createRouter() 函数创建的路由对象,是一个访问全局路由的快捷方式;this.$route 包含了当前页面的路由数据,比如路径,地址,参数等等。

我们先看全局路由对象 this.$router,它多半用于页面导航控制:

js
// 页面跳转(字符串参数)
this.$router.push("/about");
// 页面跳转带参数(对象参数)/about?id=1
this.$router.push({ path: "/about", query: { id: 1 } });
// 替换当前页面
this.$router.replace("/about");
// 返回上一页
this.$router.back();

页面跳转 router.push() 和 router.replace() 的区别是:前者会创建一个新路由,可以返回上一页;后者是替换当前路由,不可以返回上一页。

提示:在登录页面就非常适合使用 router.replace()。当登录成功后,我们要跳转到首页,且不能再返回到登录页,使用 router.replace() 替换登录页后,浏览器的返回上一页按钮就不可点击了。

假设一个页面的路由配置如下,我们用 this.$route 获取该页面的路由信息:

js
// 路由配置
{
  path: '/detail/:id',
  meta: { tag: 'teacher' },
  name: 'user_detail'
  component: xxx,
}

// 页面地址
http://api.test.com/detail/5?name=王小五

// 组件内获取路由信息
console.log(this.$route)
{
  name: 'user_detail',
  path: '/detail/5',
  query: {
    name: '王小五'
  },
  params: {
    id: '5'
  },
  meta: {
    tag: 'teacher'
  }
}

多数场景下 this.$route 用于获取页面参数,一些特定数据可以在定义路由时放到 meta 源数据里。

路由组件

Vue 只会加载根组件 App.vue,其他需要渲染的组件要在根组件中导入。但使用 Vue Router 之后,组件要根据路由地址动态渲染,因此我们需要一个入口来展示在 Vue Router 中匹配到的组件。

Vue Router 提供了一个 组件,用于展示动态匹配到的组件。将该组件加入 App.vue 中,路由匹配到的组件就能渲染到这里了。

js
// app.vue
<div class="mian">
  <h2>XX应用</h2>
  <router-view />
</div>

除了在 JavaScript 中使用 this.$router 切换路由,Vue Router 也提供了一个 组件用于在模版中跳转路由:

js
<div>
  <router-link to="/">去首页</router-link>
  <router-link to="/list">去列表页</router-link>
</div>

如果某个组件拥有子路由,那么要在该组件中添加 用于展示匹配到的子组件。

组合式 API

组合式 API 不能通过 this 访问组件,因此路由对象 this.$router 和 this.$route 也都失效。但 Vue Router 给我们提供了更便捷但方式,如下:

vue
<script setup>
import { useRouter, useRoute } from "vue-router";
const router = useRouter(); // 全局路由对象
const route = useRoute(); // 当前路由对象

const fun = () => {
  console.log(route.path);
  router.push("/");
};
</script>

注意:如果使用模版语法,尽管在 JavaScript 中不能访问 this.$router 和 this.$route,但在模版中确是可以的。

4.5.2 状态管理

组件内定义的响应式状态只对当前组件生效,如果我们需要在多个组件内共享状态,优先考虑使用 props:

js
// Parent.vue
setup() {
  const list = ref([])
  return <Children list={list}/>
}

// Children.vue
setup() {
  const props = defineProps<{
    list: any[]
  }>()
  console.log(props.list)
}

使用 props 必须遵循单向数据流的原则,状态只能由父组件向子组件传递。如果多个需要共享状态的组件并不是父子关系,那么就要将状态提取到离它们最近的父元素。

如果共享状态跨越了多个层级的组件,此时使用 props 可能就没有那么友好了,因为状态必须层层传递。也许某个中间的组件并不需要该状态,但是它的子组件需要,因此只能接收。

为了解决这种错综复杂的状态共享问题,“状态管理”应运而生。状态管理的方案就是将一些响应式状态单独抽取,变成任何组件都可访问的全局状态。当组件需要使用时直接导入,不需要由父组件层层传递。

在 Vue2 中,官方推荐的状态管理方案时 Vuex,但在 Vue3 中官方更推荐一个全新的解决方案 —— Pinia。Pinia 支持组合式 API,并对服务器端渲染有更好的支持。

提示:Pinia 官方文档地址:https://pinia.vuejs.org/zh/。

使用之前,首先安装 Pinia:

sh
$ yarn add pinia

在入口文件中,使用 createPinia() 方法创建一个 pinia 实例,并将其绑定到 Vue 实例上:

js
import { createApp } from "vue";
import { createPinia } from "pinia";

const app = createApp({});
const pinia = createPinia();

app.use(pinia);

此时 pinia 初始化已经完成,接着我们了解 pinia 中的基本概念。

基本概念

Pinia 是如何管理状态的呢?在 Pinia 中有以下几个核心概念:

  • Store:仓库,里面包含状态和修改状态的方法。
  • State:仓库中定义的状态,组件中真正需要的数据。
  • Action:用于修改仓库中的状态。
  • Getter:状态的计算值,可以当作计算属性。

Pinia 的整体使用结构如下图:

Store

状态管理主要用于定义全局可访问的状态。当这些状态随着业务发展变得庞大之后,一定会带来管理混乱的问题。

Store 相当于是全局状态的“模块化”,它将全局状态拆分到不同的仓库中,和我们在项目中拆分组件是一个道理。这些仓库互相独立,仓库内的状态互不影响,逻辑清晰且避免了全局污染。

Store 使用 defineStore() 函数定义,第一个参数是仓库名,要求必须唯一,仓库名不可重复:

js
import { defineStore } from "pinia";
const userStore = defineStore("users");

第二个参数就是用选项的方式定义仓库内的 State、Action 和 Getter,如下:

js
// store.js
import { defineStore } from "pinia";
const userStore = defineStore("users", {
  state: () => ({
    user_name: "王大拿",
    user_id: 423,
    sex: 1,
    phome: "18855556666",
  }),
  actions: {
    changeName(name) {
      this.user_name = name;
    },
  },
  getters: {
    sexStr: (state) => (state.sex == 1 ? "男" : "女"),
  },
});
export default { userStore };

上门代码用 defineStore() 函数定义了一个名为 users 的仓库,并在仓库内定义了状态和方法。从代码中可以看出,这种定义方式与 Vue 组件定义的状态和方法基本一致,只是名称不同。

State、Action 和 Getter 分别对应组件中的 data、methods 和 computed。在 Action 中同样也能通过 this 访问状态。Getter 中定义的计算状态,参数为状态 state,这里可避免使用 this。

定义好了仓库和状态,接下来我们在组件中使用。

vue
<template>
  <div>
    <span>{{ store.user_name }}</span>
    <span>{{ store.sexStr }}</span>
  </div>
  <button @click="store.changeName('李二')">测试</button>
</template>
<script setup lang="ts">
import { userStore } from "./store.js";
const store = userStore();
</script>

使用方式很简单,只要导入定义好的仓库并实例化,返回的 store 就是该仓库的实例。可以使用 store.* 的方式访问到 State、Action 和 Getter,并可以绑定到模版上。

还有第二种使用方法,就是将 store 通过解构赋值的方式单独取出需要的 State、Action,这样更清晰明确,如下:

vue
<template>
  <div>{{ user_name }}、{{ sexStr }}</div>
</template>
<script setup lang="ts">
import { userStore } from "./store.js";
const store = userStore();
const { user_name, sexStr, changeName } = store;
</script>

这样看起来没有问题,但其实有一个隐藏的 bug,那就是 user_name 和 sexStr 丢失了响应性。为什么?因为 store 是一个用 reactive 包装的对象。前面我们介绍过,reactive 的弊端就是丢失响应性。

那怎么办呢?Pinia 提供了一个 storeToRefs() 方法包裹被解构的状态,此时就可以让解构出来的 State、Getter 保持响应性。storeToRefs() 方法不能应用与 Action。

因此,使用解构赋值正确的姿势如下:

vue
<script setup lang="ts">
import { userStore } from "./store.js";
import { storeToRefs } from "pinia";
const store = userStore();
const { user_name, sexStr } = storeToRefs(store); // 解构State和Getter
const { changeName } = store; // 解构Action
</script>

4.5.3 统一请求管理

前后端分离的单页面中,获取数据都要通过 AJAX 发起接口请求。多数情况下我们不会直接用 XMLHttpRequests API 去发起网络请求,而是会选择功能强大、使用简单的第三方库。

Axios 是一个基于 Promise 的网络请求库,几乎一半以上的前端应用都在使用 Axios。Axios 支持配置基础 URL,统一错误处理等,可以满足各种网络请求的需求。

首先在应用中安装 axios:

sh
$ yarn add axios

axios 可直接发起请求,并返回 Promise。基本用法如下:

js
import axios from "axios";
// 发起一个post请求
axios({
  method: "post",
  url: "/user",
  params: {
    id: 4455,
  },
  data: {
    firstName: "Fred",
    lastName: "Flintstone",
  },
}).then((res) => {
  console.log(res.data);
});

axios 函数的参数是一个请求配置对象,常用的可配置选项如下:

  • method:请求方法,get、post、put、delete。
  • url:请求地址,字符串。
  • query:GET 请求参数,对象。
  • data:POST 请求参数,对象。
  • params:URL 参数,对象。
  • timeout:请求超时时间,毫秒。

更多配置项参考官网介绍:https://axios-http.com/zh/docs/req_config。

直接使用 axios() 方法略微有些繁琐,Axios 提供了各类请求的快捷方式,这是我们常用的方式,如下:

js
import axios from "axios";
// GET请求
axios.get("http://api.test.com", { params: { id: 1 } });
// POST请求
axios.post("http://api.test.com", { name: "数据" });
// PUT请求
axios.put("http://api.test.com", { name: "数据" });
// DELETE请求
axios.delete("http://api.test.com");

获取请求结果、捕获异常可以通过 Promise 的方式实现,或者使用 async/await 语法更直观,代码如下:

js
// Promise
axios.get('xxx').then(res=> {
  console.log(res)
}).catch(err=> {
  console.log(err)
})

// async/await
async ()=> {
  try {
    let res = await axios.get('xxx')
  } catch(err)=> {
    console.log(err)
  }
}

axios 实例

在实际的前端项目中,发起接口请求的基础 URL、异常响应的处理应该是通用的。如果每次请求都要写一遍,就会造成大量的冗余,并且难以统一修改。

Axios 提供了 create() 方法用于创建一个实例,在实例中可配置统一的请求 URL、请求头等。下面我们创建 request.js 文件,在该文件中创建并导出 axios 实例。

js
// request.js
import axios from "axios";
const instance = axios.create({
  baseURL: "http://api.test.com",
  timeout: 10000,
  headers: {
    "Content-Type": "application/json",
  },
});
export default instance;

上面代码创建的实例可在任意 JavaScript 文件中导入使用,使用方式和 axios 本身一样,如下:

js
// a.js
import http from "./request.js";
http.get("/home").then((res) => {});
http.post("/insert", { name: "数据" }).then((res) => {});

使用 axios 实例发起请求,默认会拼接实例中定义的 baseURL。并且在实例中定义的请求头、超时时间等设置对所有请求生效。这让我们封装统一的请求规则非常容易。

假设要在每一个请求上加一个请求头,直接在实例中定义;如果某个请求要覆盖实例中的配置,只需要在请求中指定一个同名参数即可:

js
// 实例
const instance = axios.create({
  headers: {
    "Content-Type": "application/json",
    role: "teacher",
  },
});

instance.get("/getone", {
  params: { id: 1 },
  headers: {
    role: "student",
  },
});

instance.post(
  "/insert",
  { name: "数据" },
  {
    headers: {
      role: "student",
    },
  }
);

请求拦截器

Axios 中最强大的功能要属拦截器了。什么是拦截器?顾名思义,拦截器的作用就是拦截请求。

使用 Axios 可以轻松发起请求。如果能在每次请求前做一些事情,比如打印请求的时间,这样会使请求操作更灵活。Axios 提供了请求拦截器的功能,在请求拦截器中可以访问到请求对象,在请求执行前可以操作该对象。

请求拦截器通过 interceptors.request 属性定义,如下:

js
const instance = axios.create({});

// 添加请求拦截器
instance.interceptors.request.use((config) => {
  config.headers["token"] = "xxx";
  return config;
});

代码如上,在请求拦截器中修改请求头 token 并返回配置对象,这样一个基本的拦截功能就实现了。

在实际的场景中,请求拦截器主要的作用就是添加/修改请求头,总之自定义请求对象都可以在这里实现。

响应拦截器

有请求拦截器,自然也有响应拦截器。响应拦截器主要的作用是在接口数据或异常返回之前做的一些统一处理,包括重组响应数据、判断响应状态码、统一错误处理等。

响应拦截器通过 interceptors.response 属性定义,如下:

js
const instance = axios.create({});

// 添加响应拦截器
instance.interceptors.response.use(
  (result) => {
    // 请求成功返回数据
    return result;
  },
  (error) => {
    // 请求失败返回异常
    return Promise.reject(res);
  }
);

响应拦截器的使用场景之一是重组响应数据。假设请求接口返回的数据格式如下:

js
{
  code: 200,
  data: {
    data: [],
    total: 10,
    current: 1
  }
}

上门的响应结构中,data 属性下包含了分页数据 total、current 和列表数据 data,我们希望分页数据和列表数据平行展示,减少嵌套,因此要将响应数据格式修改如下:

js
{
  code: 200,
  data: [],
  page: {
    total: 10,
    current: 1
  }
}

此时可以在响应拦截器里操作,将修改后的格式将应用到所有请求。如下:

js
const instance = axios.create({});

// 添加响应拦截器
instance.interceptors.response.use((result) => {
  let resdata = result.data;
  let { data } = resdata;
  resdata.page = {
    total: data.total,
    current: data.current,
  };
  resdata.data = data.data;
  return resdata;
});

上面代码中统一修改了响应返回的数据格式。除此之外,响应拦截器还可以拦截到响应异常,然后做统一的错误处理。

错误处理是根据返回的 HTTP 状态码判断错误类型,比如 200 是正常,500 是服务器错误,然后针对不同的错误做不同的处理。如下:

js
const instance = axios.create({});

instance.interceptors.response.use(
  (result) => {
    return result.data;
  },
  (error) => {
    let res = error.response;
    if (res && res.status) {
      // 有状态码
      switch (res.status) {
        case 404:
          alert("请求的网址不存在");
          break;
        case 401:
          location.href = "/login";
          break;
        case 400:
          alert(res.data.message);
          break;
        default:
          alert(`服务器异常(code: ${res.status})`);
          break;
      }
    } else {
      alert("服务器无响应");
    }
    return Promise.reject(error);
  }
);

上述代码中,针对不同的错误码弹框提示错误信息,这样的用户体验会非常好。比较特别的情况是 401,一般用于表示登录失效,要求重新登录,因此我们要跳转到登录页。

状态码判断异常可以灵活运用,可以使用 HTTP 状态码,也可以使用判断业务状态码,具体情况视接口而定。

本章小节

本章我们由浅入深一步步剖析了 Vue3 的知识点。开始介绍了 Vue 的基础概念,接着介绍了 Vue 最重要的组件体系,然后又介绍了 Vue3 新增的组合式 API。我们会发现 Vue3 其实是在旧版 Vue 的基础上,用一套更接近 JavaScript 的语法实现了功能的重写;但这种重写不是强制性的,它可以很自然的与选项式 API 结合使用,因此叫做组合式 API。

响应系统方面,Vue3 用更先进的 Proxy 代理器替换了之前的 getter/setter,使得状态的深层监听不再是问题;组件方面,Vue3 直接支持 .jsx/.tsx 文件,函数式的 JavaScript 语法获得了更好的 TypeScript 支持。

当然,得益于组合式 API 的更新,Vue 周边生态也发生了变化。Vue Router 最新版支持在组合式 API 中使用,Pinia 代替 Vuex 成为了官方推荐的状态管理方案。

工程方面最大的变化是使用 Vite 替代 Webpack 使构建速度飞速提升,一些列的小工具都变成了 Vite 插件。这些变化我们会在后面的章节逐步展开介绍。

本章我们依然焦距于 Vue3 的基础开发。虽然各个知识点难度不是很深,但是内容繁多,还是需要多多练习。下一章我们用 Vue3 开发一个小的实战项目练手,帮助我们快速巩固本章的基础知识。