vue3学习小札之(二):深入组件

之前的一片文章介绍了 vue3 的一些基础知识:
vue3学习小札之(一):基础
可以前往专栏阅读该系列其他文章:传送门
这篇文章将针对基础内容中组件的部分,深入学习。

注册组件

vue 中使用组件前,需要先定义,并注册组件。
关于组件的定义,最常用的就是通过 SFC 的方式。
关于组件的组册,分为全局注册局部注册

全局注册

使用 Vue 应用实例的 app.component() 方法,让组件在当前 Vue 应用中全局可用。

import { createApp } from 'vue'
import MyComponent from './App.vue'
// 创建应用实例
const app = createApp({})

// 方式一
app.component(
  // 注册的名字
  'MyComponent',
  // 组件的实现
  {
    /* ... */
  }
)
// 方式二
// 注册被导入的 `.vue` 文件
app.component('MyComponent', MyComponent)

局部注册

局部注册组件的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。

在使用 <script setup> 的单文件组件中,导入的组件可以直接在模板中使用,无需注册。
如果没有使用 <script setup>,则需要使用 components 选项来显式注册。

Props

声明 Props

一个子组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute(透传 attribute 会在本文下面的章节详细介绍)。

没有使用 <script setup> 的组件中,prop 可以使用 props 选项来声明
在使用 <script setup> 的单文件组件中,props 可以使用 defineProps() 宏来声明:
字符串数组的形式

<script setup>
const props = defineProps(['foo'])

console.log(props.foo)
</script>

对象的形式
key 是 prop 的名称,而则是该 prop 预期类型的构造函数

defineProps({
  title: String,
  likes: Number
})

声明 Props 校验

要声明对 props 的校验,你可以向 defineProps() 宏提供一个带有 props 校验选项的对象,例如:

defineProps({
  // 基础类型检查
  // `Boolean` 类型的未传递 prop 将被转换为 `false`
  // 应该为它设置一个 `default` 值来确保行为符合预期
  propX: Boolean,
  
  // 可选 Props(给出 `null` 和 `undefined` 值则会跳过任何类型检查)
  // 除 `Boolean` 外的未传递值的可选 prop 将会有一个默认值 `undefined`
  propA: Number,
  
  // 多种可能的类型
  propB: [String, Number],
  
  // 所有 prop 默认都是可选的,除非声明了 `required: true`
  // 必传,且为 String 类型
  propC: {
    type: String,
    required: true
  },
  
  // 如果声明了 `default` 值,那么无论 prop 是未被传递还是显式指明的 `undefined`,
  // 只要被解析为 `undefined`
  // 都会改为 `default` 值
  
  // Number 类型的默认值
  propD: {
    type: Number,
    default: 100
  },
  
  // 对象类型的默认值
  propE: {
    type: Object,
    // 对象或数组的默认值
    // 必须从一个工厂函数返回。
    // 该函数接收组件所接收到的原始 prop 作为参数。
    default(rawProps) {
      return { message: 'hello' }
    }
  },
  
  // 自定义类型校验函数
  propF: {
    validator(value) {
      // The value must match one of these strings
      return ['success', 'warning', 'danger'].includes(value)
    }
  },
  
  // 函数类型的默认值
  propG: {
    type: Function,
    // 不像对象或数组的默认,这不是一个工厂函数。这会是一个用来作为默认值的函数
    // 当没有传递值时,default函数就是 propG
    default() {
      return 'Default function'
    }
  }
})

defineProps() 宏中的参数不可以访问 <script setup> 中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。

除了原生的构造函数,type 也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配。

传递 Props

一个父组件可以通过子组件定义的 props 属性,向子组件传递数据
任何类型的值都可以作为 props 的值被传递。
如果想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind,即只使用 v-bind 而非 :prop-name

单向数据流

单向绑定,避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。
每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop。

但是实际开发中,难免会存在需要改变 props 的场景:

prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性
这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可

const props = defineProps(['initialCounter'])

// 计数器只是将 props.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
const counter = ref(props.initialCounter)

需要对传入的 prop 值做进一步的转换
这种情况中,最好是基于该 prop 值定义一个计算属性

const props = defineProps(['size'])

// 该 prop 变更时计算属性也会自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase())

更改对象 / 数组类型的 props

在最佳实践中,应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。

组件事件

上一章节中,提到当需要更改对象/数组类型的 props 时,需要通过子组件抛出一个事件给父组件。
所以就需要子组件触发事件、父组件监听事件:

子组件触发事件

在组件的模板表达式中:
可以直接使用 $emit 方法触发自定义事件,并传递参数

<button @click="$emit('increaseBy', 1)">
  Increase by 1
</button>

在<script setup>模块中:
在 < template > 中使用的 $emit 方法不能在组件的 <script setup> 部分中使用,但 defineEmits() 会返回一个相同作用的函数供我们使用

<script setup>
// 组件要触发的事件可以显式地通过 defineEmits() 宏来声明
const emit = defineEmits(['inFocus', 'submit'])

function buttonClick() {
  emit('submit')
}

// 也可以传入一个对象,用于对后续 emit 调用时的传参进行校验
const emit = defineEmits({
  submit(payload) {
    // 通过返回值为 `true` 还是为 `false` 来判断
    // 验证是否通过
  }
})
</script>

父组件监听事件

内联的箭头函数方式监听:

<MyButton @increase-by="(n) => count += n" />

事件处理函数方式监听:

<MyButton @increase-by="increaseCount" />

function increaseCount(n) {
  count.value += n
}

事件校验

和对 props 添加类型校验的方式类似,所有触发的事件也可以使用对象形式来描述。

<script setup>
const emit = defineEmits({
  // click 没有校验
  click: null,

  // 校验 submit 事件
  // 返回一个布尔值来表明事件是否合法
  submit: ({ email, password }) => {
    if (email && password) {
      return true
    } else {
      console.warn('Invalid submit event payload!')
      return false
    }
  }
})

function submitForm(email, password) {
  emit('submit', { email, password })
}
</script>

配合 v-model 使用

在上一篇文章中,介绍到 DOM 元素上使用 v-model 时,其实是展开成了一个属性绑定和一个事件监听(用来更新属性值)。
当 v-model 用在组件上时,效果类似,v-model 会被展开为如下的形式

<CustomInput
  :modelValue="searchText"
  @update:modelValue="newValue => searchText = newValue"
/>

所以为了这个封装的组件运作起来,让内部的 input 标签生效,<CustomInput> 组件内部需要做两件事:
将内部原生 input 元素的 value attribute 绑定到组件内声明的 modelValue prop;
输入新的值时在 input 元素上触发组件内声明的 update:modelValue 事件。

<!-- CustomInput.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

这样,
v-model 绑定的 searchText 属性,就会通过 Props 的形式,以属性名 modelValue (默认)传递给组件内的 input 的 value属性
v-model 绑定的 update:modelValue 事件,就会通过事件监听的形式,监听到子组件内触发的 update:modelValue 事件,并获取到参数(子组件内 input 输入时的 value),并赋值给父组件的 searchText 属性
而 searchText 属性又是 Props,所以就会自上而下,更新子组件内定义的 modelValue Props。

上述的子组件内实现方式,是直接将定义的 Props 和 emit 都作用在 DOM 元素上。

另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 getter 和 setter 的计算属性。 get 方法需返回 modelValue prop
set 方法需触发相应的事件

// CustomInput.vue -->
<script setup>
import { computed } from 'vue'

const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const value = computed({
  get() {
    return props.modelValue
  },
  set(value) {
    emit('update:modelValue', value)
  }
})
</script>
// 通过 input 元素上 v-model 的数据双向绑定
// 触发 计算属性的 get set 方法
<template>
  <input v-model="value" />
</template>

默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件。
我们可以通过给 v-model 指定一个参数来更改这些名字:

// 此时
// `title` 作为 prop
// `update:title` 作为对应的事件
<MyComponent v-model:title="bookTitle" />

可以在一个组件上创建多个 v-model 双向绑定,每一个 v-model 都会同步不同的 prop

除了v-model 的一些内置的修饰符。也可以给自定义组件的 v-model 使用自定义的修饰符。
例子:

// 使用自定义修饰符 capitalize
<MyComponent v-model.capitalize="myText" />

<script setup>
// 组件的 `v-model` 上所添加的修饰符,可以通过 `modelModifiers` prop 在组件内访问到
// 所以声明 `modelModifiers` 这个 prop 默认值是一个空对象
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})

const emit = defineEmits(['update:modelValue'])

function emitValue(e) {
  let value = e.target.value
  // 如果修饰符在模板上被使用了
  // 那么 modelModifiers 对象的 capitalize 属性就是 true
  // 就可以在有修饰符时 返回特殊处理的参数
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>

<template>
  <input type="text" :value="modelValue" @input="emitValue" />
</template>

对于又有参数又有修饰符的 v-model 绑定,生成的 prop 名将是 arg + "Modifiers"

<MyComponent v-model:title.capitalize="myText">

const props = defineProps(['title', 'titleModifiers'])
defineEmits(['update:title'])

console.log(props.titleModifiers) // { capitalize: true }

透传 Attributes

“透传 attribute”指的是传递给一个组件,却没有被该组件声明为 props 或 emits 的 attribute 或者 v-on 事件监听器。
最常见的例子就是 classstyle 和 id

当一个组件以单个元素为根作渲染时:
透传的 attribute 会自动被添加到根元素上。
同样的规则也适用于 v-on 事件监听器。
假如,当组件 A 内的单个根元素是另一个组件 B 时,组件 A 接收到的 attribute 中的 “透传 attribute” 会继续传递给内部的另一个组件 B。并且 B 组件可以在传递给他的 attribute 中,定义一些符合 props 或者 emits。

当一个组件有多个根节点元素时:
由于 Vue 不知道要将 attribute 透传到哪里,所以 $attrs 需要被显式绑定

// 组件内根元素通过没有参数 不简写的 v-bind 
// 将一个对象的所有属性都作为 attribute 应用到目标元素上
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

禁用 Attributes 继承

当我们需要将 attribute 应用在根节点以外的其他元素上时,就可以禁用 attribute 继承,完全控制透传进来的 attribute 被如何使用
选项式:
在组件选项中设置 inheritAttrs: false
组合式:
如果使用的是 <script setup>,需要一个额外的 <script> 块来书写这个选项声明

<script>
// 使用普通的 <script> 来声明选项
export default {
  inheritAttrs: false
}
</script>

<script setup>
// ...setup 部分逻辑
</script>

禁用 attribute 继承以后,attribute 不会自动绑定在根元素上,但是可以手动获取到透传进来的 attribute:
模板的表达式中直接用 $attrs 以对象的形式访问到:

<span>Fallthrough attribute: {{ $attrs }}</span>

在 <script setup> 中使用 useAttrs() API 来访问一个组件的所有透传 attribute:

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

const attrs = useAttrs()
</script>

没有使用 <script setup>attrs 会作为 setup() 上下文对象(第二个参数)的一个属性暴露:

export default {
  setup(props, ctx) {
    // 透传 attribute 被暴露为 ctx.attrs
    console.log(ctx.attrs)
  }
}

注意:
这里的 attrs 对象总是反映为最新的透传 attribute,但它并不是响应式的 (考虑到性能因素)。
如果需要响应式的效果:
声明成 props
使用 onUpdated() 使得在每次更新时结合最新的 attrs 执行副作用。

插槽 Slots

之前的章节,介绍了组件如何接收数据(Props)、事件触发与监听(emits)。
这一章就是介绍组件如何接收模板内容
这一功能就是靠插槽实现,我们可以通过插槽为子组件传递一些模板片段,让子组件在它们的组件中渲染这些片段。
在一些封装的 UI 组件,如 elementUI 系列,就大量用到了插槽的概念,让我们可以在使用 UI 组件的同时,也渲染出额外的元素、样式。

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。
可以理解为<slot> 元素就是一个占位符,将父组件引用子组件后提供的内容,渲染到占位符的位置。
并且写在<slot>标签之间的内容是一个默认内容,当父组件没有为子组件提供插槽内容的时候,就会渲染默认内容。

// 父组件
<FancyButton>
  Click me! // 插槽内容 
</FancyButton>

// 子组件
<button class="fancy-btn">
  <slot>
    Submit <!-- 默认内容 -->
  </slot> <!-- 插槽出口 -->
</button>

// 最终渲染的 DOM
<button class="fancy-btn">Click me!</button>

类比 JavaScript 函数理解 插槽

子组件可以看作是一个函数,slot 元素是一个动态内容,需要被形参(插槽内容)替换,用于函数体内
插槽的默认内容,就类似于函数参数的默认
父组件调用子组件,传入实参(插槽内容)
子组件渲染完整内容就是函数的返回值

// 父元素传入插槽内容
FancyButton('Click me!')

// FancyButton 在自己的模板中渲染插槽内容
function FancyButton(slotContent) {
  return `<button class="fancy-btn">
      ${slotContent}
    </button>`
}

具名插槽

当子组件定义了多个插槽出口的时候,就需要用到具名插槽了。
子组件内:
<slot> 元素可以有一个特殊的 attribute: name,用来给各个插槽分配唯一的 ID。
没有提供 name 的 <slot> 出口会隐式地命名为“default”

// BaseLayout组件
<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <!-- 这是一个默认插槽 name="default" -->
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

父组件内:
要为具名插槽传入插槽内容,需要使用一个含 v-slot 指令的 <template> 元素,并将目标插槽的名字作为指令参数传给该指令:

<BaseLayout>
  <template v-slot:header>
    <!-- header 插槽的内容放这里 -->
    <h1>Here might be a page title</h1>
  </template>

  // 隐式的默认插槽 -->
  // 所有位于顶级的非 `<template>` 节点 都被隐式地视为默认插槽的内容
  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  // v-slot 指令的简写方式
  <template #footer>
    <!-- footer 插槽的内容放这里 -->
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

类比 JavaScript 函数理解 具名插槽

子组件可以看作是一个函数,slot 元素是一个动态内容,需要被形参(插槽内容)替换,用于函数体内
父组件调用子组件,传入实参(插槽内容)
区别在于:
具名插槽的参数定义成了对象
参数对象的key,对应每个具名插槽的 name
子组件内部通过 name(参数对象的key)去获取 value(插槽内容)
子组件渲染完整内容就是函数的返回值

// 传入不同的内容给不同名字的插槽
BaseLayout({
  header: `...`,
  default: `...`,
  footer: `...`
})

// <BaseLayout> 渲染插槽内容到对应位置
function BaseLayout(slots) {
  return `<div class="container">
      <header>${slots.header}</header>
      <main>${slots.default}</main>
      <footer>${slots.footer}</footer>
    </div>`
}

作用域插槽

目前为止,普通插槽、具名插槽已经能满足绝大多数的场景。
但是由于插槽内容和插槽出口分别位于父组件和子组件,受作用域的限制,父组件给子组件传递的插槽内容,只能获取父组件作用域下的数据,无法获取到子组件作用域下的数据。
所以,作用域插槽就应运而生了。

作用域插槽的用法就是:
像对组件传递 props 那样,向一个插槽的出口上传递 attributes:

<!-- <MyComponent> 的模板 -->
<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>

这样我们就可以在父组件中,接收插槽 props,接收方式分两种:

默认插槽接受 props

通过子组件标签上的 v-slot 指令,直接接收到了一个插槽 props 对象(指令的值):

<MyComponent v-slot="slotProps">
  // 接受的参数只在该插槽作用域内有效
  {{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

具名插槽接受 props

同样是作为指令的值,区别是 v-slot 指令是写在 template 元素上,并且 slotProps 会排除 name 属性

<MyComponent>
  <template #header="headerProps">
    {{ headerProps }}
  </template>

  <template #default="defaultProps">
    {{ defaultProps }}
  </template>

  <template #footer="footerProps">
    {{ footerProps }}
  </template>
</MyComponent>

类比 JavaScript 函数理解 作用域插槽

子组件
可以看作是一个函数 A,slot 元素是一个动态内容,需要被形参(插槽内容)替换,用于函数体内
父组件
调用子组件,传入实参(插槽内容)
区别在于:
作用域插槽的函数 A 参数定义成了对象
参数对象的key,对应每个具名插槽的 name
但是为了能够在父组件使用子组件的数据,所以对象的 value 需要是一个函数 B
并且 函数 B的参数是子组件传递过来的插槽 props 对象
子组件内部通过 name(参数对象的key)去获取 value(函数形式的插槽内容)
子组件在获取到插槽内容是一个函数,需要将 props 对象传入,才能得到最终的插槽内容
子组件渲染完整内容就是函数的返回值

MyComponent({
  // 类比默认插槽,将其想成一个函数
  default: (slotProps) => {
    return `${slotProps.text} ${slotProps.count}`
  }
})

function MyComponent(slots) {
  const greetingMessage = 'hello'
  return `<div>${
    // 在插槽函数调用时传入 props
    slots.default({ text: greetingMessage, count: 1 })
  }</div>`
}

作用域插槽的应用场景

无渲染组件:
子组件只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件。
但大部分能用无渲染组件实现的功能都可以通过组合式 API 以另一种更高效的方式实现,并且还不会带来额外组件嵌套的开销。这在后续通过组合式函数进行逻辑复用的文章中会深入介绍。

依赖注入

依赖注入是为了解决通过 Props 传递数据时,组件层级跨越太多,导致的心智负担。
因为 Props 的形式,会导致中间层级的组件其实无需这些数据,但为了服务目标子组件,而书写冗余的 Props 代码。
一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

Provide (提供)

要为组件后代提供数据,需要使用到 provide() 函数:
第一个参数被称为注入名,可以是一个字符串或是一个 Symbol
第二个参数是提供的值,值可以是任意类型,包括响应式的状态,比如一个 ref。
提供的响应式状态使后代组件可以由此和提供者建立响应式的联系

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

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

// 在整个应用层面提供依赖
// 编写插件时会特别有用
import { createApp } from 'vue'

const app = createApp({})

app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')

Inject (注入)

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

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

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

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

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

和响应式数据配合使用

当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。
所以需要在供给方组件内声明并提供一个更改数据的方法函数:

<!-- 在供给方组件内 -->
<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>

当依赖提供变得很多的时候,为了避免重名冲突的情况,可以使用 Symbol 来作为注入名。
推荐在一个单独的文件中导出这些注入名 Symbol:

// keys.js
export const myInjectionKey = Symbol()
// 在供给方组件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, { /*
  要提供的数据
*/ });

// 注入方组件
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const injected = inject(myInjectionKey)

异步组件

Vue 提供了 defineAsyncComponent 方法来实现仅在需要时再从服务器加载相关组件的功能:

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...从服务器获取组件
    resolve(/* 获取到的组件 */)
  })
})
// ... 像使用其他一般组件一样使用 `AsyncComp`

ES 模块动态导入也会返回一个 Promise,所以多数情况下我们会将它和 defineAsyncComponent 搭配使用。
如下,得到的 AsyncComp 是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。它会将接收到的 props 和插槽传给内部组件,所以你可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)

完整用法

const AsyncComp = defineAsyncComponent({
  // 加载函数
  loader: () => import('./Foo.vue'),

  // 加载异步组件时使用的组件
  loadingComponent: LoadingComponent,
  // 展示加载组件前的延迟时间,默认为 200ms
  delay: 200,

  // 加载失败后展示的组件
  errorComponent: ErrorComponent,
  // 如果提供了一个 timeout 时间限制,并超时了
  // 也会显示这里配置的报错组件,默认值是:Infinity
  timeout: 3000
})

总结

本篇文章详细介绍了 vue 中组件的使用。
组件的注册、数据传递、事件触发和监听可以实现基本的组件间通信
插槽帮助我们封装更加灵活、高复用性的组件。
依赖注入帮助我们进行更加方便、高效的跨层级组件间通信。
异步组件提升了项目的打包优化,组件加载时更好的体验。