Vue 学习笔记(三)

学习依赖注入、组合式函数、路由和状态管理

#Vue #学习笔记

依赖注入

我们在前面已经学习过 props,它是父组件向子组件传递数据的主要方式,但它只能用于父子组件之间的数据传递,如果我们需要在祖先组件和后代组件之间传递数据,只能一层一层向下传,无法跨级传递,显然这是一件非常麻烦的事情。

好在 provide 和 inject 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

Provide

要为组件后代提供数据,需要使用到 provide() 函数:

<script setup>
import { provide } from 'vue'

provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
</script>

如果不使用 <script setup>,请确保 provide() 是在 setup() 同步调用的:

import { provide } from 'vue'

export default {
  setup() {
    provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
  }
}

provide() 函数接收两个参数。第一个参数被称为注入名,可以是一个字符串或是一个 Symbol。后代组件会用注入名来查找期望注入的值。一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

第二个参数是提供的值,值可以是任意类型,包括响应式的状态,比如一个 ref:

import { ref, provide } from 'vue'

const count = ref(0)
provide('key', count)

提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。

Inject

要注入上层组件提供的数据,需使用 inject() 函数:

<script setup>
import { inject } from 'vue'

const message = inject('message')
</script>

如果有多个父组件提供了相同键的数据,注入将解析为组件链上最近的父组件所注入的值。

如果提供的值是一个 ref,注入进来的会是该 ref 对象,而不会自动解包为其内部的值。这使得注入方组件能够通过 ref 对象保持了和供给方的响应性链接。

同样的,如果没有使用 <script setup>inject() 需要在 setup() 内同步调用:

import { inject } from 'vue'

export default {
  setup() {
    const message = inject('message')
    return { message }
  }
}

默认情况下,inject 假设传入的注入名会被某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,则会抛出一个运行时警告。

如果在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值,和 props 类似:

// 如果没有祖先组件提供 "message"
// `value` 会是 "这是默认值"
const value = inject('message', '这是默认值')

在一些场景中,默认值可能需要通过调用一个函数或初始化一个类来取得。为了避免在用不到默认值的情况下进行不必要的计算或产生副作用,我们可以使用工厂函数来创建默认值:

const value = inject('key', () => new ExpensiveClass(), true)

第三个参数表示默认值应该被当作一个工厂函数。

配合响应式数据

当提供 / 注入响应式的数据时,我们最好将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。

有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:

<!-- 在供给方组件内 -->
<script setup>
import { provide, ref } from 'vue'

const location = ref('North Pole')

function updateLocation() {
  location.value = 'South Pole'
}

provide('location', {
  location,
  updateLocation
})
</script>
<!-- 在注入方组件 -->
<script setup>
import { inject } from 'vue'

const { location, updateLocation } = inject('location')
</script>

<template>
  <button @click="updateLocation">{{ location }}</button>
</template>

如果想确保提供的数据不能被注入方的组件更改,我们可以使用 readonly() 来包装提供的值:

<script setup>
import { ref, provide, readonly } from 'vue'

const count = ref(0)
provide('read-only-count', readonly(count))
</script>

组合式函数

概念

组合式函数(Composables)是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。

这里先解释一下什么是有状态逻辑,什么是无状态逻辑:

  • 无状态逻辑:像是一个纯函数,只针对传入的参数输出对应的计算结果,无副作用,不修改外部变量,也不操作 DOM;
  • 有状态逻辑:内部拥有响应式数据,比如 ref,会发送请求、操作 DOM 等,执行依赖于生命周期钩子。

在 Vue 中,约定组合式函数以 use 开头,且采用驼峰命名法。我们可以将响应式状态、方法、计算属性、生命周期钩子等封装在组合式函数中,并且返回需要暴露的状态和方法。

下面是一个简单的示例:

import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  function reset() {
    count.value = initialValue
  }
  
  return {
    count, increment, decrement, reset
  }
}
<script setup>
import { useCounter } from './composables/useCounter'

const { count, increment, decrement, reset } = useCounter(10)
</script>
<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="decrement">-</button>
    <button @click="reset">Reset</button>
    <button @click="increment">+</button>
  </div>
</template>

副作用与使用限制

在组合式函数中可以执行副作用 (例如:添加 DOM 事件监听器或者请求数据),但也要注意以下规则:

  • 如果应用用到了服务端渲染(SSR),需确保在组件挂载后才调用的生命周期钩子中执行 DOM 相关的副作用,例如:onMounted()。这些钩子仅会在浏览器中被调用,因此可以确保能访问到 DOM。
  • 确保在 onUnmounted() 时清理副作用。举例来说,如果一个组合式函数设置了一个事件监听器,它就应该在 onUnmounted() 中被移除。

组合式函数只能在 <script setup> 或 setup() 钩子中被调用。在这些上下文中,它们也只能被同步调用。在某些情况下,我们可以在像 onMounted() 这样的生命周期钩子中调用它们。

这些限制很重要,因为这些是 Vue 用于确定当前活跃的组件实例的上下文。访问活跃的组件实例很有必要,这样才能做到将生命周期钩子注册到该组件实例上并且将计算属性和监听器注册到该组件实例上,以便在该组件被卸载时停止监听,避免内存泄漏。

路由

概念

在 Web 开发领域,路由是指根据用户访问的 URL 路径,返回对应内容或视图的机制。它是 Web 应用中的“交通调度员”,决定了用户在浏览器地址栏输入什么地址,或点击什么链接时,页面上应该呈现什么内容。

在传统的多页面应用(MPA)中,路由是由服务端控制的。用户每次点击链接,浏览器都会向服务器发送请求,服务器根据 URL 匹配对应的 HTML 文件并返回,这个过程伴随着整页刷新。而在现代前端开发中,尤其是单页面应用(SPA) 里,路由的工作被迁移到了浏览器端,由前端 JavaScript 接管,这就是前端路由。

前端路由的核心思想是:不刷新页面,仅替换页面中的核心内容区域。这可以带来更流畅的用户体验

Vue Router

在 Vue 生态中,路由功能由官方库 Vue Router 实现。它通过三个核心要素将路由概念落地为具体代码:

  • 路由映射表(Routes 配置):通过 JavaScript 对象定义 URL 路径与 Vue 组件之间的映射关系。例如,告诉程序:当用户访问 /user 时,就渲染 User.vue 组件。
  • 路由占位符(<router-view>):在页面的模板中放置一个容器组件,当路由发生变化时,匹配到的 Vue 组件会被动态渲染到这个位置,页面其余部分(如侧边栏、头部导航)则保持不变。
  • 路由导航(<router-link>):提供声明式或编程式的跳转方式。<router-link> 会渲染为 <a> 标签,但点击时触发的是前端路由逻辑,阻止了浏览器的默认刷新行为,从而实现无感跳转。

下面是 Vue Router 的一个简单示例:

首先我们在项目终端输入 npm install vue-router@4 安装 Vue Router。

路由配置:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
  { path: '/', component: Home },
  { path: '/about', component: () => import('../views/About.vue') }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

注册路由:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(router)
app.mount('#app')

使用路由:

<!-- App.vue -->
<template>
  <div>
    <router-link to="/">首页</router-link>
    <router-link to="/about">关于</router-link>
    <router-view />
  </div>
</template>

状态管理

概念

状态管理是一种前端架构模式,它把多个组件需要共享的响应式数据集中存储在一个全局仓库中,并提供规范的读写接口,让所有组件都能访问和修改同一份数据,且任何修改都会自动触发所有使用该数据的组件视图更新。

当多个组件需要共享同一份数据时,如果使用 props,不仅会使代码冗长,而且会导致 Prop 逐级透传问题。如果使用 provide / inject,虽然能跳过中间层级,但数据分散在各个祖先组件中,来源不清晰,项目变大后难以追踪数据流向。

状态管理可以很好的解决这个问题,它将共享数据集中存放到一个仓库中,任何组件都可以读取或修改,所有使用该数据的组件自动同步更新。 它让数据流向变得可预测、可追踪,同时避免了重复请求和代码冗余。

Pinia

Pinia 是 Vue 官方推荐的状态管理库,用于替代 Vuex 成为 Vue 3 生态中的标准方案。它提供了更简洁的 API 和更友好的 TypeScript 支持。其设计理念是:去繁就简,让状态管理变得直观自然。

Pinia 的基础用法:

安装:

npm install pinia

注册:

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

定义 Store:

// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
  // 数据
  state: () => ({
    count: 0,
    name: 'Vue'
  }),
  
  // 计算(派生数据)
  getters: {
    doubleCount: (state) => state.count * 2,
    welcome: (state) => `你好,${state.name}`
  },
  
  // 修改方法(支持同步和异步)
  actions: {
    increment() {
      this.count++
    },
    async fetchCount() {
      const res = await fetch('/api/count')
      this.count = await res.json()
    }
  }
})

组件中使用:

<template>
  <div>
    <p>计数:{{ store.count }}</p>
    <p>两倍:{{ store.doubleCount }}</p>
    <p>{{ store.welcome }}</p>
    <button @click="store.increment()">+1</button>
    <button @click="store.fetchCount()">从接口获取</button>
  </div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>

注意,如果直接从 store 解构会失去响应性:

// 错误:count 不再是响应式
const { count } = useCounterStore()

正确做法:使用 storeToRefs

// 正确:保持响应式
import { storeToRefs } from 'pinia'
const store = useCounterStore()
const { count, name } = storeToRefs(store)

总的来说,Pinia 就是用 defineStore 定义一个"全局响应式对象",任何组件导入它就能读写数据,且所有引用处自动同步更新。比 Vuex 更简单、更现代,是 Vue 3 项目的官方标配。