Vue组件
一、组件基础
组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。在实际应用中,组件常常被组织成层层嵌套的树状结构:
这和我们嵌套 HTML 元素的方式类似,Vue 实现了自己的组件模型,使我们可以在每个组件内封装自定义内容与逻辑。Vue 同样也能很好地配合原生 Web Component。如果你想知道 Vue 组件与原生 Web Components 之间的关系,可以阅读此章节。
1、定义一个组件
当使用构建步骤时,我们一般会将 Vue 组件定义在一个单独的 .vue
文件中,这被叫做单文件组件 (简称 SFC):
<script>
export default {
data() {
return {
count: 0
}
}
}
</script>
<template>
<button @click="count++">You clicked me {{ count }} times.</button>
</template>
当不使用构建步骤时,一个 Vue 组件以一个包含 Vue 特定选项的 JavaScript 对象来定义:
export default {
data() {
return {
count: 0
}
},
template: `
<button @click="count++">
You clicked me {{ count }} times.
</button>`
}
这里的模板是一个内联的 JavaScript 字符串,Vue 将会在运行时编译它。你也可以使用 ID 选择器来指向一个元素 (通常是原生的 <template>
元素),Vue 将会使用其内容作为模板来源。
上面的例子中定义了一个组件,并在一个 .js
文件里默认导出了它自己,但你也可以通过具名导出在一个文件中导出多个组件。
2、使用组件
TIP
我们会在接下来的指引中使用 SFC 语法,无论你是否使用构建步骤,组件相关的概念都是相同的。示例一节中展示了两种场景中的组件使用情况。
要使用一个子组件,我们需要在父组件中导入它。假设我们把计数器组件放在了一个叫做 ButtonCounter.vue
的文件中,这个组件将会以默认导出的形式被暴露给外部。
<script>
import ButtonCounter from './ButtonCounter.vue'
export default {
components: {
ButtonCounter
}
}
</script>
<template>
<h1>Here is a child component!</h1>
<ButtonCounter />
</template>
若要将导入的组件暴露给模板,我们需要在 components
选项上注册它。这个组件将会以其注册时的名字作为模板中的标签名。
当然,你也可以全局地注册一个组件,使得它在当前应用中的任何组件上都可以使用,而不需要额外再导入。关于组件的全局注册和局部注册两种方式的利弊,我们放在了组件注册这一章节中专门讨论。
组件可以被重用任意多次:
<h1>Here is a child component!</h1>
<ButtonCounter />
<ButtonCounter />
<ButtonCounter />
你会注意到,每当点击这些按钮时,每一个组件都维护着自己的状态,是不同的 count
。这是因为每当你使用一个组件,就创建了一个新的实例。
在单文件组件中,推荐为子组件使用 PascalCase
的标签名,以此来和原生的 HTML 元素作区分。虽然原生 HTML 标签名是不区分大小写的,但 Vue 单文件组件是可以在编译中区分大小写的。我们也可以使用 />
来关闭一个标签。
如果你是直接在 DOM 中书写模板 (例如原生 <template>
元素的内容),模板的编译需要遵从浏览器中 HTML 的解析行为。在这种情况下,你应该需要使用 kebab-case
形式并显式地关闭这些组件的标签。
<!-- 如果是在 DOM 中书写该模板 -->
<button-counter></button-counter>
<button-counter></button-counter>
<button-counter></button-counter>
3、传递props
如果我们正在构建一个博客,我们可能需要一个表示博客文章的组件。我们希望所有的博客文章分享相同的视觉布局,但有不同的内容。要实现这样的效果自然必须向组件中传递数据,例如每篇文章标题和内容,这就会使用到 props。
Props 是一种特别的 attributes,你可以在组件上声明注册。要传递给博客文章组件一个标题,我们必须在组件的 props 列表上声明它。这里要用到 props
选项:
<!-- BlogPost.vue -->
<script>
export default {
props: ['title']
}
</script>
<template>
<h4>{{ title }}</h4>
</template>
当一个值被传递给 prop 时,它将成为该组件实例上的一个属性。该属性的值可以像其他组件属性一样,在模板和组件的 this
上下文中访问。
一个组件可以有任意多的 props,默认情况下,所有 prop 都接受任意类型的值。
当一个 prop 被注册后,可以像这样以自定义 attribute 的形式传递数据给它:
<BlogPost title="My journey with Vue" />
<BlogPost title="Blogging with Vue" />
<BlogPost title="Why Vue is so fun" />
在实际应用中,我们可能在父组件中会有如下的一个博客文章数组:
export default {
// ...
data() {
return {
posts: [
{ id: 1, title: 'my as'},
{ id: 2, title: 'my as uui'},
{ id: 3, title: 'my as one'}
]
}
}
}
这种情况下,我们可以使用 v-for
来渲染它们:
<BlogPost
v-for="post in posts"
:key="post.id"
:title="post.title"
/>
留意我们是如何使用 v-bind
来传递动态 prop 值的。当事先不知道要渲染的确切内容时,这一点特别有用。
4、监听事件
让我们继续关注我们的 <BlogPost>
组件。我们会发现有时候它需要与父组件进行交互。例如,要在此处实现 A11y 的需求,将博客文章的文字能够放大,而页面的其余部分仍使用默认字号。
在父组件中,我们可以添加一个 postFontSize
数据属性来实现这个效果:
data() {
return {
posts: [
//...
],
postFontSize: 1
}
}
在模板中用它来控制所有博客文章的字体大小:
<div :style="{ fontSize: postFontSize + 'em' }">
<BlogPost
v-for="post in posts"
:key="post.id"
:title="post.title"
/>
</div>
然后,给 <BlogPost>
组件添加一个按钮:
<!-- BlogPost.vue, 省略了 <script> -->
<template>
<div class='blog-post'>
<h4>
{{ title }}
</h4>
<button>
Enlarge text
</button>
</div>
</template>
这个按钮目前还没有做任何事情,我们想要点击这个按钮来告诉父组件它应该放大所有博客文章的文字。要解决这个问题,组件实例提供了一个自定义事件系统。父组件可以通过 v-on
或 @
来选择性地监听子组件上抛的事件,就像监听原生 DOM 事件那样:
<BlogPost
...
@enlarge-text="postFontSize += 0.1"
/>
子组件可以通过调用内置的 $emit
方法,通过传入事件名称来抛出一个事件:
<!-- BlogPost.vue, 省略了 <script> -->
<template>
<div class="blog-post">
<h4>{{ title }}</h4>
<button @click="emit('enlarge-text')">
Enlarge text
</button>
</div>
</template>
因为有了 @enlarge-text="postFontSize += 0.1"
的监听,父组件会接收这一事件,从而更新 postFontSize
的值。
我们可以通过 emits
选项来声明需要抛出的事件:
<!-- BlogPost.vue -->
<script>
export default {
props: ['title'],
emits: ['enlarge-text']
}
</script>
这声明了一个组件可能触发的所有事件,还可以对事件的参数进行验证。同时,这还可以让 Vue 避免将它们作为原生事件监听器隐式地应用于子组件的根元素。
5、通过插槽来分配内容
一些情况下我们会希望能和 HTML 元素一样向组件中传递内容:
这可以通过 Vue 的自定义 <slot>
元素来实现:
<template>
<div class="alert-box">
<strong>This is an Error for Demo Purposes</strong>
<slot />
</div>
</template>
<style scoped>
.alert-box {
/*...*/
}
</style>
如上所示,我们使用 <slot>
作为一个占位符,父组件传递进来的内容就会渲染在这里。
6、动态组件
有些场景会需要在两个组件间来回切换,比如 Tab 界面:
上面的例子是通过 Vue 的 <component>
元素和特殊的 is
attribute 实现的:
<!-- currentTab 改变时组件也改变 -->
<component :is="currentTab"></component>
在上面的例子中,被传给 :is
的值可以是以下几种:
-
被注册的组件名
-
导入的组件对象
你也可以使用 is
attribute 来创建一般的 HTML 元素。
当使用 <component :is="...">
来在多个组件间作切换时,被切换掉的组件会被卸载。我们可以通过 `` 组件强制被切换掉的组件仍然保持“存活”的状态。
7、DOM 模板解析注意事项
如果你想在 DOM 中直接书写 Vue 模板,Vue 则必须从 DOM 中获取模板字符串。由于浏览器的原生 HTML 解析行为限制,有一些需要注意的事项。
TIP
请注意下面讨论只适用于直接在 DOM 中编写模板的情况。如果你使用来自以下来源的字符串模板,就不需要顾虑这些限制了:
-
单文件组件
-
内联模板字符串 (例如
template: '...'
) -
<script type="text/x-template">
大小写区分
HTML 标签和属性名称是不分大小写的,所以浏览器会把任何大写的字符解释为小写。这意味着当你使用 DOM 内的模板时,无论是 PascalCase 形式的组件名称、camelCase 形式的 prop 名称还是 v-on 的事件名称,都需要转换为相应等价的 kebab-case (短横线连字符) 形式:
// JavaScript 中的 camelCase
const BlogPost = {
props: ['postTitle'],
emits: ['updatePost'],
template: `
<h3>{{ postTitle }}</h3>
`
}
<!-- HTML 中的 kebab-case -->
<blog-post post-title="hello!" @update-post="onUpdatePost"></blog-post>
闭合标签
我们在上面的例子中已经使用过了闭合标签 (self-closing tag):
<MyComponent />
这是因为 Vue 的模板解析器支持任意标签使用 />
作为标签关闭的标志。
然而在 DOM 模板中,我们必须显式地写出关闭标签:
<my-component></my-component>
这是由于 HTML 只允许一小部分特殊的元素省略其关闭标签,最常见的就是 <input>
和 <img>
。对于其他的元素来说,如果你省略了关闭标签,原生的 HTML 解析器会认为开启的标签永远没有结束,用下面这个代码片段举例来说:
<my-component /> <!-- 我们想要在这里关闭标签... -->
<span>hello</span>
将被解析为:
<my-component>
<span>hello</span>
</my-component> <!-- 但浏览器会在这里关闭标签 -->
元素位置限制
某些 HTML 元素对于放在其中的元素类型有限制,例如 <ul>
,<ol>
,<table>
和 <select>
,相应的,某些元素仅在放置于特定元素中时才会显示,例如 <li>
,<tr>
和 <option>
。
这将导致在使用带有此类限制元素的组件时出现问题。例如:
<table>
<blog-post-row></blog-post-row>
</table>
自定义的组件 <blog-post-row>
将作为无效的内容被忽略,因而在最终呈现的输出中造成错误。我们可以使用特殊的 is
attribute 作为一种解决方案:
<table>
<tr is="vue:blog-post-row"></tr>
</table>
TIP
当使用在原生 HTML 元素上时,is
的值必须加上前缀 vue:
才可以被解析为一个 Vue 组件。这一点是必要的,为了避免和原生的自定义内置元素相混淆。
二、组件注册
一个 Vue 组件在使用前需要先被“注册”,这样 Vue 才能在渲染模板时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。
1、全局注册
我们可以使用 Vue 应用实例的 app.component()
方法,让组件在当前 Vue 应用中全局可用。
import { createApp } from 'vue'
const app = createApp({})
app.component(
// 注册的名字
'MyComponent',
// 组件的实现
{
/* ... */
}
)
如果使用单文件组件,你可以注册被导入的 .vue
文件:
import MyComponent from './App.vue'
app.component('Mycomponent', MyComponent)
app.component()
方法可以被链式调用:
app
.component('ComponentA', ComponentA)
.component('ComponentB', ComponentB)
.component('ComponentC', ComponentC)
全局注册的组件可以在此应用的任意组件的模板中使用:
<!-- 这在当前应用的任意组件中都可用 -->
<ComponentA/>
<ComponentB/>
<ComponentC/>
所有的子组件也可以使用全局注册的组件,这意味着这三个组件也都可以在彼此内部使用。
2、局部注册
全局注册虽然很方便,但有以下几个问题:
-
全局注册,但并没有被使用的组件无法在生产打包时被自动移除 (也叫“tree-shaking”)。如果你全局注册了一个组件,即使它并没有被实际使用,它仍然会出现在打包后的 JS 文件中。
-
全局注册在大型项目中使项目的依赖关系变得不那么明确。在父组件中使用子组件时,不太容易定位子组件的实现。和使用过多的全局变量一样,这可能会影响应用长期的可维护性。
相比之下,局部注册的组件需要在使用它的父组件中显式导入,并且只能在该父组件中使用。它的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。
局部注册需要使用 components
选项:
<script>
import ComponentA from './ComponentA.vue'
export default {
components: {
ComponentA
}
}
</script>
<template>
<ComponentA />
</template>
对于每个 components
对象里的属性,它们的 key 名就是注册的组件名,而值就是相应组件的实现。上面的例子中使用的是 ES2015 的缩写语法,等价于:
export default {
components: {
ComponentA: ComponentA
}
// ...
}
请注意:局部注册的组件在后代组件中并*不*可用。在这个例子中,ComponentA
注册后仅在当前组件可用,而在任何的子组件或更深层的子组件中都不可用。
3、组件名格式
在整个指引中,我们都使用 PascalCase 作为组件名的注册格式,这是因为:
-
PascalCase 是合法的 JavaScript 标识符。这使得在 JavaScript 中导入和注册组件都很容易,同时 IDE 也能提供较好的自动补全。
-
<PascalCase />
在模板中更明显地表明了这是一个 Vue 组件,而不是原生 HTML 元素。同时也能够将 Vue 组件和自定义元素 (web components) 区分开来。
在单文件组件和内联字符串模板中,我们都推荐这样做。但是,PascalCase 的标签名在 DOM 模板中是不可用的,详情参见 DOM 模板解析注意事项。
为了方便,Vue 支持将模板中使用 kebab-case 的标签解析为使用 PascalCase 注册的组件。这意味着一个以 MyComponent
为名注册的组件,在模板中可以通过 <MyComponent>
或 <my-component>
引用。这让我们能够使用同样的 JavaScript 组件注册代码来配合不同来源的模板。
三、Props
1、Props 声明
一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute (关于透传 attribute,我们会在专门的章节中讨论)。
props 需要使用 props
选项来定义:
export default {
props: ['foo'],
created() {
// props 会暴露到`this`上
console.log(this.foo)
}
}
除了使用字符串数组来声明 prop 外,还可以使用对象的形式:
export default {
props: {
title: String,
likes: Number
}
}
对于以对象形式声明中的每个属性,key 是 prop 的名称,而值则是该 prop 预期类型的构造函数。比如,如果要求一个 prop 的值是 number
类型,则可使用 Number
构造函数作为其声明的值。
对象形式的 props 声明不仅可以一定程度上作为组件的文档,而且如果其他开发者在使用你的组件时传递了错误的类型,也会在浏览器控制台中抛出警告。
TypeScript 用户请参考:为组件 Props 标注类型
2、传递prop的细节
Prop名字格式
如果一个 prop 的名字很长,应使用 camelCase 形式,因为它们是合法的 JavaScript 标识符,可以直接在模板的表达式中使用,也可以避免在作为属性 key 名时必须加上引号。
export default {
props: {
greetingMessage: String
}
}
<span>{{ greetingMessage }}</span>
虽然理论上你也可以在向子组件传递 props 时使用 camelCase 形式 (使用 DOM 模板时例外),但实际上为了和 HTML attribute 对齐,我们通常会将其写为 kebab-case 形式:
<MyComponent greeting-message="hello" />
对于组件名我们推荐使用 PascalCase,因为这提高了模板的可读性,能帮助我们区分 Vue 组件和原生 HTML 元素。然而对于传递 props 来说,使用 camelCase 并没有太多优势,因此我们推荐更贴近 HTML 的书写风格。
静态 vs. 动态 Prop
至此,你已经见过了很多像这样的静态值形式的 props:
<BlogPost title="My journey with Vue" />
相应地,还有使用 v-bind
或缩写 :
来进行动态绑定的 props:
<!-- 根据一个变量的值动态传入 -->
<BlogPost :title="post.title" />
<!-- 根据一个更复杂表达式的值动态传入 -->
<BlogPost :title="post.title + ' by ' + post.author.name" />
传递不同的值类型
在上述的两个例子中,我们只传入了字符串值,但实际上任何类型的值都可以作为 props 的值被传递。
Number
<!-- 虽然 `42` 是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :likes="42" />
<!-- 根据一个变量的值动态传入 -->
<BlogPost :likes="post.likes" />
Boolean
<!-- 仅写上 prop 但不传值,会隐式转换为 `true` -->
<BlogPost is-published />
<!-- 虽然 `false` 是静态的值,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :is-published="false" />
<!-- 根据一个变量的值动态传入 -->
<BlogPost :is-published="post.isPublished" />
Array
<!-- 虽然这个数组是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :comment-ids="[234, 266, 273]" />
<!-- 根据一个变量的值动态传入 -->
<BlogPost :comment-ids="post.commentIds" />
Object
<!-- 虽然这个对象字面量是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost
:author="{
name: 'Veronica',
company: 'Veridian Dynamics'
}"
/>
<!-- 根据一个变量的值动态传入 -->
<BlogPost :author="post.author" />
使用一个对象绑定多个 prop
如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind
,即只使用 v-bind
而非 :prop-name
。例如,这里有一个 post
对象:
export default {
data() {
return {
post: {
id: 1,
title: 'My Journey with Vue'
}
}
}
}
以及下面的模板:
<BlogPost v-bind="post" />
而这实际上等价于:
<BlogPost :id="post.id" :title="post.title" />
单向数据流
所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。
另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告:
export default {
props: ['foo'],
created() {
// ❌ 警告!prop 是只读的!
this.foo = 'bar'
}
}
导致你想要更改一个 prop 的需求通常来源于以下两种场景:
-
prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性。在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可:
export default {
props: ['initialCounter'],
data() {
return {
// 计数器只是将 this.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
counter: this.initialCounter
}
}
} -
需要对传入的 prop 值做进一步的转换。在这种情况中,最好是基于该 prop 值定义一个计算属性:
export default {
props: ['size'],
computed: {
// 该 prop 变更时计算属性也会自动更新
normalizedSize() {
return this.size.trim().toLowerCase()
}
}
}
更改对象 / 数组类型的 props
当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。
这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。
3、Prop校验
Vue 组件可以更细致地声明对传入的 props 的校验要求。比如我们上面已经看到过的类型声明,如果传入的值不满足类型要求,Vue 会在浏览器控制台中抛出警告来提醒使用者。这在开发给其他开发者使用的组件时非常有用。
要声明对 props 的校验,你可以向 props
选项提供一个带有 props 校验选项的对象,例如:
export default {
props: {
// 基础类型检查
//(给出 `null` 和 `undefined` 值则会跳过任何类型检查)
propA: Number,
// 多种可能的类型
propB: [String, Number],
// 必传,且为 String 类型
propC: {
type: String,
required: true
},
// Number 类型的默认值
propD: {
type: Number,
default: 100
},
// 对象类型的默认值
propE: {
type: Object,
// 对象或者数组应当用工厂函数返回。
// 工厂函数会收到组件所接收的原始 props
// 作为参数
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() {
return 'Default function'
}
}
}
}
一些补充细节:
-
所有 prop 默认都是可选的,除非声明了
required: true
。 -
除
Boolean
外的未传递的可选 prop 将会有一个默认值undefined
。 -
Boolean
类型的未传递 prop 将被转换为false
。这可以通过为它设置default
来更改——例如:设置为default: undefined
将与非布尔类型的 prop 的行为保持一致。 -
如果声明了
default
值,那么在 prop 的值被解析为undefined
时,无论 prop 是未被传递还是显式指明的undefined
,都会改为default
值。
当 prop 的校验失败后,Vue 会抛出一个控制台警告 (在开发模式下)。
注意
注意 prop 的校验是在组件实例被创建之前,所以实例的属性 (比如 data
、computed
等) 将在 default
或 validator
函数中不可用。
运行时类型检查
校验选项中的 type
可以是下列这些原生构造函数:
-
String
-
Number
-
Boolean
-
Array
-
Object
-
Date
-
Function
-
Symbol
另外,type
也可以是自定义的类或构造函数,Vue 将会通过 instanceof
来检查类型是否匹配。例如下面这个类:
class Person {
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
}
你可以将其作为一个 prop 的类型:
export default {
props: {
author: Person
}
}
Vue 会通过 instanceof Person
来校验 author
prop 的值是否是 Person
类的一个实例。
4、Boolean 类型转换
为了更贴近原生 boolean attributes 的行为,声明为 Boolean
类型的 props 有特别的类型转换规则。以带有如下声明的 <MyComponent>
组件为例:
export default {
props: {
disabled: Boolean
}
}
该组件可以被这样使用:
<!-- 等同于传入 :disabled="true" -->
<MyComponent disabled />
<!-- 等同于传入 :disabled="false" -->
<MyComponent />
当一个 prop 被声明为允许多种类型时,例如:
export default {
props: {
disabled: [Boolean, Number]
}
}
无论声明类型的顺序如何,Boolean
类型的特殊转换规则都会被应用。
四、组件事件
1、触发和监听事件
在组件的模板表达式中,可以直接使用 $emit
方法触发自定义事件 (例如:在 v-on
的处理函数中):
<!-- MyComponent -->
<button @click="$emit('someEvent')">click me</button>
$emit()
方法在组件实例上也同样以 this.$emit()
的形式可用:
export default {
methods: {
submit() {
this.$emit('someEvent')
}
}
}
父组件可以通过 v-on
(缩写为 @
) 来监听事件:
<MyComponent @some-event="callback" />
同样,组件的事件监听器也支持 .once
修饰符:
<MyComponent @some-event.once="callback" />
像组件与 prop 一样,事件的名字也提供了自动的格式转换。注意这里我们触发了一个以 camelCase 形式命名的事件,但在父组件中可以使用 kebab-case 形式来监听。与 prop 大小写格式一样,在模板中我们也推荐使用 kebab-case 形式来编写监听器。
TIP
和原生 DOM 事件不一样,组件触发的事件没有冒泡机制。你只能监听直接子组件触发的事件。平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案。
2、事件参数
有时候我们会需要在触发事件时附带一个特定的值。举例来说,我们想要 <BlogPost>
组件来管理文本会缩放得多大。在这个场景下,我们可以给 $emit
提供一个额外的参数:
<button @click="$emit('increaseBy', 1)">
Increase by 1
</button>
然后我们在父组件中监听事件,我们可以先简单写一个内联的箭头函数作为监听器,此函数会接收到事件附带的参数:
<MyButton @increase-by="(n) => count += n" />
或者,也可以用一个组件方法来作为事件处理函数:
<MyButton @increase-by="increaseCount" />
该方法也会接收到事件所传递的参数:
methods: {
increaseCount(n) {
this.count += n
}
}
3、声明触发的事件
组件可以显式地通过 emits
选项来声明它要触发的事件:
export default {
emits: ['inFocus', 'submit']
}
这个 emits
选项还支持对象语法,它允许我们对触发事件的参数进行验证:
export default {
emits: {
submit(payload) {
// 通过返回值为 `true` 还是为 `false` 来判断
// 验证是否通过
}
}
}
尽管事件声明是可选的,我们还是推荐你完整地声明所有要触发的事件,以此在代码中作为文档记录组件的用法。同时,事件声明能让 Vue 更好地将事件和透传 attribute 作出区分,从而避免一些由第三方代码触发的自定义 DOM 事件所导致的边界情况。
TIP
如果一个原生事件的名字 (例如 click
) 被定义在 emits
选项中,则监听器只会监听组件触发的 click
事件而不会再响应原生的 click
事件。
4、事件校验
和对 props 添加类型校验的方式类似,所有触发的事件也可以使用对象形式来描述。
要为事件添加校验,那么事件可以被赋值为一个函数,接受的参数就是抛出事件时传入 this.$emit
的内容,返回一个布尔值来表明事件是否合法。
export default {
emits: {
// 没有校验
click: null,
// 校验 submit 事件
submit: ({ email, password }) => {
if (email && password) {
return true
} else {
console.warn('Invalid submit event payload!')
return false
}
}
},
methods: {
submitForm(email, password) {
this.$emit('submit', { email, password })
}
}
}
五、组件v-model
v-model
可以在组件上使用以实现双向绑定。
首先让我们回忆一下 v-model
在原生元素上的用法:
<input v-model="searchText" />
在代码背后,模板编译器会对 v-model
进行更冗长的等价展开。因此上面的代码其实等价于下面这段:
<input
:value="searchText"
@input="searchText = $event.target.value"
/>
而当使用在一个组件上时,v-model
会被展开为如下的形式:
<CustomInput
:modelValue="searchText"
@update:modelValue="newValue => searchText = newValue"
/>
要让这个例子实际工作起来,<CustomInput>
组件内部需要做两件事:
-
将内部原生
<input>
元素的value
attribute 绑定到modelValue
prop -
当原生的
input
事件触发时,触发一个携带了新值的update:modelValue
自定义事件
这里是相应的代码:
<!-- CustomInput.vue -->
<script>
export default {
props: ['modelValue'],
emits: ['update:modelValue']
}
</script>
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue',$event.target.value)"
/>
</template>
现在 v-model
可以在这个组件上正常工作了:
<CustomInput v-model="searchText" />
另一种在组件内实现 v-model
的方式是使用一个可写的,同时具有 getter 和 setter 的 computed
属性。get
方法需返回 modelValue
prop,而 set
方法需触发相应的事件:
<!-- CustomInput.vue -->
<script>
export default {
props: ['modelValue'],
emits: ['update:modelValue'],
computed: {
value: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
}
}
}
}
</script>
<template>
<input v-model="value" />
</template>
1、v-model
的参数
默认情况下,v-model
在组件上都是使用 modelValue
作为 prop,并以 update:modelValue
作为对应的事件。我们可以通过给 v-model
指定一个参数来更改这些名字:
<MyComponent v-model:title="bookTitle" />
在这个例子中,子组件应声明一个 title
prop,并通过触发 update:title
事件更新父组件值:
<!-- MyComponent.vue -->
<script>
export default {
props: ['title'],
emits: ['update:title']
}
</script>
<template>
<input
type='text'
:value="title"
@input="$emit('update:title', $event.target.value)" />
</template>
2、多个v-model
绑定
利用刚才在 v-model
参数小节中学到的指定参数与事件名的技巧,我们可以在单个组件实例上创建多个 v-model
双向绑定。
组件上的每一个 v-model
都会同步不同的 prop,而无需额外的选项:
<script>
import UserName from './UserName.vue'
export default {
components: {UserName},
date() {
return {
first: 'John',
last: 'Doe'
}
}
}
</script>
<template>
<h1>
{{ first }} {{ last }}
</h1>
<UserName
v-model:first-name="first"
v-model:last-name="last"
/>
</template>
<script>
export default {
props: {
firstName: String,
lastName: String
},
emits: ['update:firstName', 'update:lastName']
}
</script>
<template>
<input
type="text"
:value="firstName"
@input="$emit('update:firstName', $event.target.value)"
/>
<input
type="text"
:value="lastName"
@input="$emit('update:lastName', $event.target.value)"
/>
</template>
3、处理 v-model
修饰符
在学习输入绑定时,我们知道了 v-model
有一些内置的修饰符,例如 .trim
,.number
和 .lazy
。在某些场景下,你可能想要一个自定义组件的 v-model
支持自定义的修饰符。
我们来创建一个自定义的修饰符 capitalize
,它会自动将 v-model
绑定输入的字符串值第一个字母转为大写:
<MyComponent v-model.capitalize="myText" />
组件的 v-model
上所添加的修饰符,可以通过 modelModifiers
prop 在组件内访问到。在下面的组件中,我们声明了 modelModifiers
这个 prop,它的默认值是一个空对象:
<script>
export default {
props: {
modelValue: String,
modelModifiers: {
default: () => ({})
}
},
emits: ['update:modelValue'],
created() {
console.log(this.modelModifiers)// { capitalize: true }
}
}
</script>
<template>
<input
type="text"
:value="modelValue"
@input="$emit('update:modelValue',$event.target.value)"
/>
</template>
注意这里组件的 modelModifiers
prop 包含了 capitalize
且其值为 true
,因为它在模板中的 v-model
绑定 v-model.capitalize="myText"
上被使用了。
有了这个 prop,我们就可以检查 modelModifiers
对象的键,并编写一个处理函数来改变抛出的值。在下面的代码里,我们就是在每次 <input />
元素触发 input
事件时将值的首字母大写:
<script>
export default {
props: {
modelValue: String,
modelModifiers: {
default: () => ({})
}
},
emits: ['update:modelValue'],
methods: {
emitValue(e) {
let value= e.target.value
if(this.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
this.$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">
相应的声明应该是:
export default {
props: ['title', 'titleModifiers'],
emits: ['update:title'],
created() {
console.log(this.titleModifiers) // { capitalize: true }
}
}
六、透传 Attributes
1、Attributes 继承
“透传 attribute”指的是传递给一个组件,却没有被该组件声明为 props 或 emits 的 attribute 或者 v-on
事件监听器。最常见的例子就是 class
、style
和 id
。
当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。举例来说,假如我们有一个 <MyButton>
组件,它的模板长这样:
<!-- <MyButton> 的模板 -->
<button>click me</button>
一个父组件使用了这个组件,并且传入了 class
:
<MyButton class="large" />
最后渲染出的 DOM 结果是:
<button class="large">click me</button>
这里,<MyButton>
并没有将 class
声明为一个它所接受的 prop,所以 class
被视作透传 attribute,自动透传到了 <MyButton>
的根元素上。
①对 class
和 style
的合并
如果一个子组件的根元素已经有了 class
或 style
attribute,它会和从父组件上继承的值合并。如果我们将之前的 <MyButton>
组件的模板改成这样:
<!-- <MyButton> 的模板 -->
<button class="btn">click me</button>
则最后渲染出的 DOM 结果会变成:
<button class="btn large">click me</button>
②v-on
监听器继承
同样的规则也适用于 v-on
事件监听器:
<MyButton @click="onClick" />
click
监听器会被添加到 <MyButton>
的根元素,即那个原生的 <button>
元素之上。当原生的 <button>
被点击,会触发父组件的 onClick
方法。同样的,如果原生 button
元素自身也通过 v-on
绑定了一个事件监听器,则这个监听器和从父组件继承的监听器都会被触发。
③深层组件继承
有些情况下一个组件会在根节点上渲染另一个组件。例如,我们重构一下 <MyButton>
,让它在根节点上渲染 <BaseButton>
:
<!-- <MyButton/> 的模板,只是渲染另一个组件 -->
<BaseButton />
此时 <MyButton>
接收的透传 attribute 会直接继续传给 <BaseButton>
。
请注意:
-
透传的 attribute 不会包含
<MyButton>
上声明过的 props 或是针对emits
声明事件的v-on
侦听函数,换句话说,声明过的 props 和侦听函数被<MyButton>
“消费”了。 -
透传的 attribute 若符合声明,也可以作为 props 传入
<BaseButton>
。
2、禁用 Attributes 继承
如果你不想要一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false
。
最常见的需要禁用 attribute 继承的场景就是 attribute 需要应用在根节点以外的其他元素上。通过设置 inheritAttrs
选项为 false
,你可以完全控制透传进来的 attribute 被如何使用。
这些透传进来的 attribute 可以在模板的表达式中直接用 $attrs
访问到。
<span>Fallthrough attribute: {{ $attrs }}</span>
这个 $attrs
对象包含了除组件所声明的 props
和 emits
之外的所有其他 attribute,例如 class
,style
,v-on
监听器等等。
有几点需要注意:
-
和 props 有所不同,透传 attributes 在 JavaScript 中保留了它们原始的大小写,所以像
foo-bar
这样的一个 attribute 需要通过$attrs['foo-bar']
来访问。 -
像
@click
这样的一个v-on
事件监听器将在此对象下被暴露为一个函数$attrs.onClick
。
现在我们要再次使用一下之前小节中的 <MyButton>
组件例子。有时候我们可能为了样式,需要在 <button>
元素外包装一层 <div>
:
<div class="btn-wrapper">
<button class="btn">click me</button>
</div>
我们想要所有像 class
和 v-on
监听器这样的透传 attribute 都应用在内部的 <button>
上而不是外层的 <div>
上。我们可以通过设定 inheritAttrs: false
和使用 v-bind="$attrs"
来实现:
<div class="btn-wrapper">
<button class="btn" v-bind="$attrs">click me</button>
</div>
小提示:没有参数的 v-bind
会将一个对象的所有属性都作为 attribute 应用到目标元素上。
3、多根节点的 Attributes 继承
和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。如果 $attrs
没有被显式绑定,将会抛出一个运行时警告。
<CustomLayout id="custom-layout" @click="changeValue" />
如果 <CustomLayout>
有下面这样的多根节点模板,由于 Vue 不知道要将 attribute 透传到哪里,所以会抛出一个警告。
<header>...</header>
<main>...</main>
<footer>...</footer>
如果 $attrs
被显式绑定,则不会有警告:
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>
4、在 JavaScript 中访问透传 Attributes
如果需要,你可以通过 $attrs
这个实例属性来访问组件的所有透传 attribute:
export default {
created() {
console.log(this.$attrs)
}
}
七、插槽 Slots
1、插槽内容与出口
如何接收模板内容呢?在某些场景中,我们可能想要为子组件传递一些模板片段,让子组件在它们的组件中渲染这些片段。
举例来说,这里有一个 <FancyButton>
组件,可以像这样使用:
<FancyButton>
Click me! <!-- 插槽内容 -->
</FancyButton>
而 <FancyButton>
的模板是这样的:
<button class="fancy-btn">
<slot></slot><!-- 插槽出口 -->
</button>
<slot>
元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。
最终渲染出的 DOM 是这样:
<button class="fancy-btn">Click me!</button>
通过使用插槽,<FancyButton>
仅负责渲染外层的 <button>
(以及相应的样式),而其内部的内容由父组件提供。
理解插槽的另一种方式是和下面的 JavaScript 函数作类比,其概念是类似的:
// 父元素传入插槽内容
FancyButton('Click me!')
// FancyButton 在自己的模板中渲染插槽内容
function FancyButton(slotContent) {
return `<button class="fancy-btn">
${slotContent}
</button>`
}
插槽内容可以是任意合法的模板内容,不局限于文本。例如我们可以传入多个元素,甚至是组件:
<FancyButton>
<span style="color:red">Click me!</span>
<AwesomeIcon name="plus" />
</FancyButton>
通过使用插槽,<FancyButton>
组件更加灵活和具有可复用性。现在组件可以用在不同的地方渲染各异的内容,但同时还保证都具有相同的样式。
2、渲染作用域
插槽内容可以访问到父组件的数据作用域,因为插槽内容本身是在父组件模板中定义的。举例来说:
<span>{{ message }}</span>
<FancyButton>{{ message }}</FancyButton>
这里的两个 {{ message }}
插值表达式渲染的内容都是一样的。
插槽内容无法访问子组件的数据。Vue 模板中的表达式只能访问其定义时所处的作用域,这和 JavaScript 的词法作用域规则是一致的。换言之:
父组件模板中的表达式只能访问父组件的作用域;子组件模板中的表达式只能访问子组件的作用域。
3、默认内容
在外部没有提供任何内容的情况下,可以为插槽指定默认内容。比如有这样一个 <SubmitButton>
组件:
<button type="submit">
<slot></slot>
</button>
如果我们想在父组件没有提供任何插槽内容时在 <button>
内渲染“Submit”,只需要将“Submit”写在 <slot>
标签之间来作为默认内容:
<button type="submit">
<slot>
Submit<!-- 默认内容 -->
</slot>
</button>
现在,当我们在父组件中使用 <SubmitButton>
且没有提供任何插槽内容时:
<SubmitButton />
“Submit”将会被作为默认内容渲染:
<button type="submit">
Submit
</button>
但如果我们提供了插槽内容:
<SubmitButton>Save</SubmitButton>
那么被显式提供的内容会取代默认内容:
<button type="submit">
Save
</button>
4、具名插槽
有时在一个组件中包含多个插槽出口是很有用的。举例来说,在一个 <BaseLayout>
组件中,有如下模板:
<div class="container">
<header>
<!-- 标题内容放这里 -->
</header>
<main>
<!-- 主要内容放这里 -->
</main>
<footer>
<!-- 底部内容放这里 -->
</footer>
</div>
对于这种场景,<slot>
元素可以有一个特殊的 attribute name
,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容:
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
这类带 name
的插槽被称为具名插槽 (named slots)。没有提供 name
的 <slot>
出口会隐式地命名为“default”。
在父组件中使用 <BaseLayout>
时,我们需要一种方式将多个插槽内容传入到各自目标插槽的出口。此时就需要用到具名插槽了:
要为具名插槽传入内容,我们需要使用一个含 v-slot
指令的 <template>
元素,并将目标插槽的名字传给该指令:
<BaseLayout>
<template v-slot:header>
<!-- header 插槽的内容放这里 -->
</template>
</BaseLayout>
v-slot
有对应的简写 #
,因此 <template v-slot:header>
可以简写为 <template #header>
。其意思就是“将这部分模板片段传入子组件的 header 插槽中”。
下面我们给出完整的、向 <BaseLayout>
传递插槽内容的代码,指令均使用的是缩写形式:
<BaseLayout>
<template #header>
<h1>Here might be a page title</h1>
</template>
<template #default>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</template>
<template #footer>
<p>Here's some contact info</p>
</template>
</BaseLayout>
当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template>
节点都被隐式地视为默认插槽的内容。所以上面也可以写成:
<BaseLayout>
<template #header>
<h1>Here might be a page title</h1>
</template>
<!-- 隐式的默认插槽 -->
<p>A paragraph for the main content.</p>
<p>And another one.</p>
<template #footer>
<p>Here's some contact info</p>
</template>
</BaseLayout>
现在 <template>
元素中的所有内容都将被传递到相应的插槽。最终渲染出的 HTML 如下:
<div class="container">
<header>
<h1>
Here might be a page title
</h1>
</header>
<main>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</main>
<footer>
<p>Here's some contact info</p>
</footer>
</div>
使用 JavaScript 函数来类比可能更有助于你来理解具名插槽:
// 传入不同的内容给不同名字的插槽
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>`
}
5、动态插槽名
动态指令参数在 v-slot
上也是有效的,即可以定义下面这样的动态插槽名:
<base-layout>
<template v-slot:[dynamicSlotName]>
...
</template>
<!-- 缩写为 -->
<template #[dynamicSlotName]>
...
</template>
</base-layout>
注意这里的表达式和动态指令参数受相同的语法限制。
6、作用域插槽
在上面的渲染作用域中我们讨论到,插槽的内容无法访问到子组件的状态。
然而在某些场景下插槽的内容可能想要同时使用父组件域内和子组件域内的数据。要做到这一点,我们需要一种方法来让子组件在渲染时将一部分数据提供给插槽。
我们也确实有办法这么做!可以像对组件传递 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
指令的值,可以在插槽内的表达式中访问。
你可以将作用域插槽类比为一个传入子组件的函数。子组件会将相应的 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>`
}
实际上,这已经和作用域插槽的最终代码编译结果、以及手动编写渲染函数时使用作用域插槽的方式非常类似了。
v-slot="slotProps"
可以类比这里的函数签名,和函数的参数类似,我们也可以在 v-slot
中使用解构:
<MyComponent v-slot="{ text, count }">
{{ text }} {{ count }}
</MyComponent>
具名作用域插槽
具名作用域插槽的工作方式也是类似的,插槽 props 可以作为 v-slot
指令的值被访问到:v-slot:name="slotProps"
。当使用缩写时是这样:
<MyComponent>
<template #header="headerProps">
{{ headerProps }}
</template>
<template #default="defaultProps">
{{ defaultProps }}
</template>
<template #footer="footerProps">
{{ footerProps }}
</template>
</MyComponent>
向具名插槽中传入 props:
<slot name="header" message="hello"></slot>
注意插槽上的 name
是一个 Vue 特别保留的 attribute,不会作为 props 传递给插槽。因此最终 headerProps
的结果是 { message: 'hello' }
。
如果你同时使用了具名插槽与默认插槽,则需要为默认插槽使用显式的 <template>
标签。尝试直接为组件添加 v-slot
指令将导致编译错误。这是为了避免因默认插槽的 props 的作用域而困惑。举例:
<!-- 该模板无法编译 -->
<template>
<MyComponent v-slot="{ message }">
<p>{{ message }}</p>
<template #footer>
<!-- message 属于默认插槽,此处不可用 -->
<p>{{ message }}</p>
</template>
</MyComponent>
</template>
为默认插槽使用显式的 <template>
标签有助于更清晰地指出 message
属性在其他插槽中不可用:
<template>
<MyComponent>
<!-- 使用显式的默认插槽 -->
<template #default="{ message }">
<p>{{ message }}</p>
<template>
<template #footer>
<p>Here's some contact info</p>
<template>
</MyComponent>
</template>
高级列表组件示例
你可能想问什么样的场景才适合用到作用域插槽,这里我们来看一个 <FancyList>
组件的例子。它会渲染一个列表,并同时会封装一些加载远端数据的逻辑、使用数据进行列表渲染、或者是像分页或无限滚动这样更进阶的功能。然而我们希望它能够保留足够的灵活性,将对单个列表元素内容和样式的控制权留给使用它的父组件。我们期望的用法可能是这样的:
<FancyList :api-url="url" :per-page="10">
<template #item="{ body, username, likes }">
<div class="item">
<p>{{ body }}</p>
<p>by {{ username }} | {{ likes }} likes</p>
</div>
</template>
</FancyList>
在 <FancyList>
之中,我们可以多次渲染 <slot>
并每次都提供不同的数据 (注意我们这里使用了 v-bind
来传递插槽的 props):
<ul>
<li v-for="item in items">
<slot name="item" v-bind="item"></slot>
</li>
</ul>
无渲染组件
上面的 <FancyList>
案例同时封装了可重用的逻辑 (数据获取、分页等) 和视图输出,但也将部分视图输出通过作用域插槽交给了消费者组件来管理。
如果我们将这个概念拓展一下,可以想象的是,一些组件可能只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件。我们将这种类型的组件称为无渲染组件。
这里有一个无渲染组件的例子,一个封装了追踪当前鼠标位置逻辑的组件:
<MouseTracker v-slot="{ x, y }">
Mouse is at: {{ x }}, {{ y }}
</MouseTracker>
虽然这个模式很有趣,但大部分能用无渲染组件实现的功能都可以通过组合式 API 以另一种更高效的方式实现,并且还不会带来额外组件嵌套的开销。之后我们会在组合式函数一章中介绍如何更高效地实现追踪鼠标位置的功能。
尽管如此,作用域插槽在需要同时封装逻辑、组合视图界面时还是很有用,就像上面的 <FancyList>
组件那样。
八、依赖注入
1、Prop 逐级透传问题
通常情况下,当我们需要从父组件向子组件传递数据时,会使用 props。想象一下这样的结构:有一些多层级嵌套的组件,形成了一颗巨大的组件树,而某个深层的子组件需要一个较远的祖先组件中的部分数据。在这种情况下,如果仅使用 props 则必须将其沿着组件链逐级传递下去,这会非常麻烦:
注意,虽然这里的 <Footer>
组件可能根本不关心这些 props,但为了使 <DeepChild>
能访问到它们,仍然需要定义并向下传递。如果组件链路非常长,可能会影响到更多这条路上的组件。这一问题被称为“prop 逐级透传”,显然是我们希望尽量避免的情况。
provide
和 inject
可以帮助我们解决这一问题。 [1] 一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。
2、Provide (提供)
要为组件后代提供数据,需要使用到 provide
选项:
export default {
provide: {
message: 'hello!'
}
}
对于 provide
对象上的每一个属性,后代组件会用其 key 为注入名查找期望注入的值,属性的值就是要提供的数据。
如果我们需要提供依赖当前组件实例的状态 (比如那些由 data()
定义的数据属性),那么可以以函数形式使用 provide
:
export default {
data() {
return {
message: 'hello!'
}
},
provide() {
// 使用函数的形式,可以访问到 `this`
return {
message: this.message
}
}
}
然而,请注意这不会使注入保持响应性。
3、应用层 Provide
除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖:
import { createApp } from 'vue'
const app = createApp({})
app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
在应用级别提供的数据在该应用内的所有组件中都可以注入。这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来提供值。
4、Inject (注入)
要注入上层组件提供的数据,需使用 inject
选项来声明:
export default {
inject: ['message'],
created() {
console.log(this.message) //injected value
}
}
注入会在组件自身的状态之前被解析,因此你可以在 data()
中访问到注入的属性:
export default {
inject: ['message'],
data() {
return {
// 基于注入值的初始数据
fullMessage: this.message
}
}
}
注入别名
当以数组形式使用 inject
,注入的属性会以同名的 key 暴露到组件实例上。在上面的例子中,提供的属性名为 "message"
,注入后以 this.message
的形式暴露。访问的本地属性名和注入名是相同的。
如果我们想要用一个不同的本地属性名注入该属性,我们需要在 inject
选项的属性上使用对象的形式:
export default {
inject: {
/* 本地属性名 */localMessage: {
from:/* 注入来源名 */'message'
}
}
}
这里,组件本地化了原注入名 "message"
所提供的属性,并将其暴露为 this.localMessage
。
注入默认值
默认情况下,inject
假设传入的注入名会被某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,则会抛出一个运行时警告。
如果在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值,和 props 类似:
export default {
// 当声明注入的默认值时
// 必须使用对象形式
inject: {
message: {
from: 'message', // 当与原注入名同名时,这个属性是可选的
default: 'default value'
},
user: {
// 对于非基础类型数据,如果创建开销比较大,或是需要确保每个组件实例
// 需要独立数据的,请使用工厂函数
default: () => ({ name: 'John' })
}
}
}
5、和响应式数据配合使用
为保证注入方和供给方之间的响应性链接,我们需要使用 computed() 函数提供一个计算属性:
import { computed } from 'vue'
export default {
data() {
return {
message: 'hello!'
}
},
provide() {
return {// 显式提供一个计算属性
message: computed(() => this.message)
}
}
}
computed()
函数常用于组合式 API 风格的组件中,但它同样还可以用于补充选项式 API 风格的某些用例。
6、使用 Symbol 作注入名
至此,我们已经了解了如何使用字符串作为注入名。但如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。
我们通常推荐在一个单独的文件中导出这些注入名 Symbol:
// keys.js
export const myInjectionKey = Symbol()
// 在供给方组件中
import { myInjectionKey} from './keys.js'
export default {
provide() {
return {
[myInjectionKey]: {
/* 要提供的数据 */
}
}
}
}
// 注入方组件
import { myInjectionKey } from './keys.js'
export default {
inject: {
injected: { from: myInjectionKey }
}
}
九、异步组件
1、基本用法
在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件。Vue 提供了 defineAsyncComponent
方法来实现此功能:
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => {
return new Promise((resolve, reject) => {
// ...从服务器获取组件
resolve(/* 获取到的组件 */)
})
})
// ... 像使用其他一般组件一样使用 `AsyncComp`
如你所见,defineAsyncComponent
方法接收一个返回 Promise 的加载函数。这个 Promise 的 resolve
回调方法应该在从服务器获得组件定义时调用。你也可以调用 reject(reason)
表明加载失败。
ES 模块动态导入也会返回一个 Promise,所以多数情况下我们会将它和 defineAsyncComponent
搭配使用。类似 Vite 和 Webpack 这样的构建工具也支持此语法 (并且会将它们作为打包时的代码分割点),因此我们也可以用它来导入 Vue 单文件组件:
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
)
最后得到的 AsyncComp
是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。它会将接收到的 props 和插槽传给内部组件,所以你可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。
与普通组件一样,异步组件可以使用 app.component()
全局注册:
app.component('MyComponent', defineAsyncComponent(() =>
import('./components/MyComponent.vue')
))
你也可以在局部注册组件时使用 defineAsyncComponent
:
<script>
import { defineAsyncComponent } from 'vue'
export default {
components: {
AdminPage: defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
}
}
</script>
<template>
<AdminPage />
</template>
2、加载与错误状态
异步操作不可避免地会涉及到加载和错误状态,因此 defineAsyncComponent()
也支持在高级选项中处理这些状态:
const AsyncComp = defineAsyncComponent({
// 加载函数
loader: () => import('./Foo.vue'),
// 加载异步组件时使用的组件
loadingComponent: LoadingComponent,
// 展示加载组件前的延迟时间,默认为 200ms
delay: 200,
// 加载失败后展示的组件
errorComponent: ErrorComponent,
// 如果提供了一个 timeout 时间限制,并超时了
// 也会显示这里配置的报错组件,默认值是:Infinity
timeout: 3000
})
如果提供了一个加载组件,它将在内部组件加载时先行显示。在加载组件显示之前有一个默认的 200ms 延迟——这是因为在网络状况较好时,加载完成得很快,加载组件和最终组件之间的替换太快可能产生闪烁,反而影响用户感受。
如果提供了一个报错组件,则它会在加载器函数返回的 Promise 抛错时被渲染。你还可以指定一个超时时间,在请求耗时超过指定时间时也会渲染报错组件。
3、搭配 Suspense 使用
异步组件可以搭配内置的 <Suspense>
组件一起使用,若想了解 <Suspense>
和异步组件之间交互,请参阅 `` 章节。