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
事件监听器。
最常见的例子就是 class
、style
和 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 中组件的使用。
组件的注册、数据传递、事件触发和监听可以实现基本的组件间通信
。
插槽
帮助我们封装更加灵活、高复用性的组件。
依赖注入
帮助我们进行更加方便、高效的跨层级组件间通信。
异步组件
提升了项目的打包优化,组件加载时更好的体验。