0%

Vue学习(二)——组件

一、组件注册

1.1 组件名

在注册一个组件的时候,我们始终需要给它一个名字。比如在全局注册的时候我们已经看到了:

1
2
3
4
5
const app = Vue.createApp({...})

app.component('my-component-name', {
/* ... */
})

该组件名就是 app.component 的第一个参数,在上面的例子中,组件的名称是“my-component-name”。

强烈推荐遵循 W3C 规范来给自定义标签命名:

  1. 全部小写
  2. 包含连字符 (及:即有多个单词与连字符符号连接)

1.1.1 组件名大小写

1.1.1.1 使用 kebab-case

1
2
3
app.component('my-component-name', {
/* ... */
})

当使用 kebab-case (短横线分隔命名) 定义一个组件时,你在引用这个自定义元素时也必须使用 kebab-case,例如 <my-component-name>

1.1.1.2 使用 PascalCase

1
2
3
app.component('MyComponentName', {
/* ... */
})

当使用 PascalCase (首字母大写命名) 定义一个组件时,你在引用这个自定义元素时两种命名法都可以使用。也就是说 <my-component-name><MyComponentName> 都是可接受的。注意,尽管如此,直接在 DOM (即非字符串的模板) 中使用时只有 kebab-case 是有效的。

1.2 全局注册

app.component 来创建组件:

1
2
3
Vue.createApp({...}).component('my-component-name', {
// ... 选项 ...
})

这些组件是全局注册的。也就是说它们在注册之后可以用在任何新创建的组件实例的模板中。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
const app = Vue.createApp({})

app.component('component-a', {
/* ... */
})
app.component('component-b', {
/* ... */
})
app.component('component-c', {
/* ... */
})

app.mount('#app')
1
2
3
4
5
<div id="app">
<component-a></component-a>
<component-b></component-b>
<component-c></component-c>
</div>

在所有子组件中也是如此,也就是说这三个组件在各自内部也都可以相互使用。

1.3 局部注册

全局注册往往是不够理想的。比如,如果你使用一个像 webpack 这样的构建系统,全局注册所有的组件意味着即便你已经不再使用其中一个组件了,它仍然会被包含在最终的构建结果中。这造成了用户下载的 JavaScript 的无谓的增加。

在这些情况下,你可以通过一个普通的 JavaScript 对象来定义组件:

1
2
3
4
5
6
7
8
9
const ComponentA = {
/* ... */
}
const ComponentB = {
/* ... */
}
const ComponentC = {
/* ... */
}

然后在 components 选项中定义你想要使用的组件:

1
2
3
4
5
6
const app = Vue.createApp({
components: {
'component-a': ComponentA,
'component-b': ComponentB
}
})

对于 components 对象中的每个 property 来说,其 property 名就是自定义元素的名字,其 property 值就是这个组件的选项对象。

注意局部注册的组件在其子组件中不可用。例如,如果你希望 ComponentAComponentB 中可用,则你需要这样写:

1
2
3
4
5
6
7
8
9
10
const ComponentA = {
/* ... */
}

const ComponentB = {
components: {
'component-a': ComponentA
}
// ...
}

或者如果你通过 Babel 和 webpack 使用 ES2015 模块,那么代码看起来像这样:

1
2
3
4
5
6
7
8
import ComponentA from './ComponentA.vue'

export default {
components: {
ComponentA
}
// ...
}

注意在 ES2015+ 中,在对象中放一个类似 ComponentA 的变量名其实是 ComponentA: ComponentA 的缩写,即这个变量名同时是:

  • 用在模板中的自定义元素的名称
  • 包含了这个组件选项的变量名

1.4 模块系统

通过 import/require 使用一个模块系统。

1.4.1 在模块系统中局部注册

使用了诸如 Babel 和 webpack 的模块系统。在这些情况下,创建一个 components 目录,并将每个组件放置在其各自的文件中。

然后你需要在局部注册之前导入每个你想使用的组件。例如,假设在 ComponentB.jsComponentB.vue 文件中:

1
2
3
4
5
6
7
8
9
10
import ComponentA from './ComponentA'
import ComponentC from './ComponentC'

export default {
components: {
ComponentA,
ComponentC
}
// ...
}

现在 ComponentAComponentC 都可以在 ComponentB 的模板中使用了。

二、Props

2.1 Prop类型

以字符串数组形式列出的 prop:

1
props: ['title', 'likes', 'isPublished', 'commentIds', 'author']

每个 prop 都有指定的值类型。这时,可以以对象形式列出 prop,这些 property 的名称和值分别是 prop 各自的名称和类型:

1
2
3
4
5
6
7
8
9
props: {
title: String,
likes: Number,
isPublished: Boolean,
commentIds: Array,
author: Object,
callback: Function,
contactsPromise: Promise // 或任何其他构造函数
}

这不仅为你的组件提供了文档,还会在它们遇到错误的类型时从浏览器的 JavaScript 控制台提示用户。你会在这个页面接下来的部分看到类型检查和其它 prop 验证

2.2 传递静态或动态的Prop

给 prop 传入一个静态的值:

1
<blog-post title="My journey with Vue"></blog-post>

prop 可以通过 v-bind 或简写 : 动态赋值,例如:

1
2
3
4
5
<!-- 动态赋予一个变量的值 -->
<blog-post :title="post.title"></blog-post>

<!-- 动态赋予一个复杂表达式的值 -->
<blog-post :title="post.title + ' by ' + post.author.name"></blog-post>

在上述两个示例中,我们传入的值都是字符串类型的,但实际上任何类型的值都可以传给一个 prop。

2.2.1 传入一个数字

1
2
3
4
5
6
<!-- 即便 `42` 是静态的,我们仍需通过 `v-bind` 来告诉 Vue     -->
<!-- 这是一个 JavaScript 表达式而不是一个字符串。 -->
<blog-post :likes="42"></blog-post>

<!-- 用一个变量进行动态赋值。-->
<blog-post :likes="post.likes"></blog-post>

2.2.2 传入一个布尔值

1
2
3
4
5
6
7
8
9
10
11
<!-- 包含该 prop 没有值的情况在内,都意味着 `true`。          -->
<!-- 如果没有在 props 中把 is-published 的类型设置为 Boolean,
则这里的值为空字符串,而不是“true”。 -->
<blog-post is-published></blog-post>

<!-- 即便 `false` 是静态的,我们仍需通过 `v-bind` 来告诉 Vue -->
<!-- 这是一个 JavaScript 表达式而不是一个字符串。 -->
<blog-post :is-published="false"></blog-post>

<!-- 用一个变量进行动态赋值。 -->
<blog-post :is-published="post.isPublished"></blog-post>

2.2.3 传入一个数组

1
2
3
4
5
6
<!-- 即便数组是静态的,我们仍需通过 `v-bind` 来告诉 Vue        -->
<!-- 这是一个 JavaScript 表达式而不是一个字符串。 -->
<blog-post :comment-ids="[234, 266, 273]"></blog-post>

<!-- 用一个变量进行动态赋值。 -->
<blog-post :comment-ids="post.commentIds"></blog-post>

2.2.4 传入一个对象

1
2
3
4
5
6
7
8
9
10
11
<!-- 即便对象是静态的,我们仍需通过 `v-bind` 来告诉 Vue        -->
<!-- 这是一个 JavaScript 表达式而不是一个字符串。 -->
<blog-post
:author="{
name: 'Veronica',
company: 'Veridian Dynamics'
}"
></blog-post>

<!-- 用一个变量进行动态赋值。 -->
<blog-post :author="post.author"></blog-post>

2.2.5 传入一个对象的所有 property

如果想要将一个对象的所有 property 都作为 prop 传入,可以使用不带参数的 v-bind (用 v-bind 代替 :prop-name)。例如,对于一个给定的对象 post

1
2
3
4
post: {
id: 1,
title: 'My Journey with Vue'
}

下面的模板:

1
<blog-post v-bind="post"></blog-post>

等价于:

1
<blog-post v-bind:id="post.id" v-bind:title="post.title"></blog-post>

2.3 单向数据流

所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。这样会防止从子组件意外变更父级组件的状态,从而导致你的应用的数据流向难以理解。

另外,每次父级组件发生变更时,子组件中所有的 prop 都将会刷新为最新的值。这意味着你应该在一个子组件内部改变 prop。如果你这样做了,Vue 会在浏览器的控制台中发出警告。

这里有两种常见的试图变更一个 prop 的情形:

  1. 这个 prop 用来传递一个初始值;这个子组件接下来希望将其作为一个本地的 prop 数据来使用。在这种情况下,最好定义一个本地的 data property 并将这个 prop 作为其初始值:
1
2
3
4
5
6
props: ['initialCounter'],
data() {
return {
counter: this.initialCounter
}
}
  1. 这个 prop 以一种原始的值传入且需要进行转换。在这种情况下,最好使用这个 prop 的值来定义一个计算属性:
1
2
3
4
5
6
props: ['size'],
computed: {
normalizedSize() {
return this.size.trim().toLowerCase()
}
}

注意在 JavaScript 中对象和数组是通过引用传入的,所以对于一个数组或对象类型的 prop 来说,在子组件中改变这个对象或数组本身将会影响到父组件的状态,且 Vue 无法为此向你发出警告。作为一个通用规则,应该避免修改任何 prop,包括对象和数组,因为这种做法无视了单向数据绑定,且可能会导致意料之外的结果。

2.4 Prop验证

我们可以为组件的 prop 指定验证要求,例如你知道的这些类型。如果有一个要求没有被满足,则 Vue 会在浏览器控制台中警告你。这在开发一个会被别人用到的组件时尤其有帮助。

为了定制 prop 的验证方式,你可以为 props 中的值提供一个带有验证要求的对象,而不是一个字符串数组。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
app.component('my-component', {
props: {
// 基础的类型检查 (`null` 和 `undefined` 值会通过任何类型验证)
propA: Number,
// 多个可能的类型
propB: [String, Number],
// 必填的字符串
propC: {
type: String,
required: true
},
// 带有默认值的数字
propD: {
type: Number,
default: 100
},
// 带有默认值的对象
propE: {
type: Object,
// 对象或数组的默认值必须从一个工厂函数返回
default() {
return { message: 'hello' }
}
},
// 自定义验证函数
propF: {
validator(value) {
// 这个值必须与下列字符串中的其中一个相匹配
return ['success', 'warning', 'danger'].includes(value)
}
},
// 具有默认值的函数
propG: {
type: Function,
// 与对象或数组的默认值不同,这不是一个工厂函数——这是一个用作默认值的函数
default() {
return 'Default function'
}
}
}
})

当 prop 验证失败的时候,(开发环境构建版本的) Vue 将会产生一个控制台的警告。

注意 prop 会在一个组件实例创建之前进行验证,所以实例的 property (如 datacomputed 等) 在 defaultvalidator 函数中是不可用的。

2.4.1 类型检查

type 可以是下列原生构造函数中的一个:

  • String
  • Number
  • Boolean
  • Array
  • Object
  • Date
  • Function
  • Symbol

此外,type 还可以是一个自定义的构造函数,并且通过 instanceof 来进行检查确认。例如,给定下列现成的构造函数:

1
2
3
4
function Person(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}

验证 author prop 的值是否是通过 new Person 创建的。

1
2
3
4
5
app.component('blog-post', {
props: {
author: Person
}
})

2.5 Prop的大小写命名(camelCase vs kebab-case)

HTML 中的 attribute 名是大小写不敏感的,所以浏览器会把所有大写字符解释为小写字符。这意味着当你使用 DOM 中的模板时,camelCase (驼峰命名法) 的 prop 名需要使用其等价的 kebab-case (短横线分隔命名) 命名:

1
2
3
4
5
6
7
const app = Vue.createApp({})

app.component('blog-post', {
// 在 JavaScript 中使用 camelCase
props: ['postTitle'],
template: '<h3>{{ postTitle }}</h3>'
})
1
2
<!-- 在 HTML 中使用 kebab-case -->
<blog-post post-title="hello!"></blog-post>

重申一次,如果你使用字符串模板,那么这个限制就不存在了。

三、非Prop的Attribute

一个非 prop 的 attribute 是指传向一个组件,但是该组件并没有相应 propsemits 定义的 attribute。常见的示例包括 classstyleid attribute。可以通过 $attrs property 访问那些 attribute。

3.1 Attribute继承

当组件返回单个根节点时,非 prop 的 attribute 将自动添加到根节点的 attribute 中。例如,在 date-picker 组件的实例中:

1
2
3
4
5
6
7
app.component('date-picker', {
template: `
<div class="date-picker">
<input type="datetime-local" />
</div>
`
})

如果我们需要通过 data-status attribute 定义 <date-picker> 组件的状态,它将应用于根节点 (即 div.date-picker)。

1
2
3
4
5
6
7
<!-- 具有非 prop 的 attribute 的 date-picker 组件-->
<date-picker data-status="activated"></date-picker>

<!-- 渲染后的 date-picker 组件 -->
<div class="date-picker" data-status="activated">
<input type="datetime-local" />
</div>

同样的规则也适用于事件监听器:

1
<date-picker @change="submitChange"></date-picker>
1
2
3
4
5
app.component('date-picker', {
created() {
console.log(this.$attrs) // { onChange: () => {} }
}
})

当一个具有 change 事件的 HTML 元素作为 date-picker 的根元素时,这可能会有帮助。

1
2
3
4
5
6
7
8
9
app.component('date-picker', {
template: `
<select>
<option value="1">Yesterday</option>
<option value="2">Today</option>
<option value="3">Tomorrow</option>
</select>
`
})

在这种情况下,change 事件监听器将从父组件传递到子组件,它将在原生 <select>change 事件上触发。我们不需要显式地从 date-picker 发出事件:

1
2
3
<div id="date-picker" class="demo">
<date-picker @change="showChange"></date-picker>
</div>
1
2
3
4
5
6
7
const app = Vue.createApp({
methods: {
showChange(event) {
console.log(event.target.value) // 将打印所选选项的值
}
}
})

3.2 禁用Attribute继承

如果你希望组件的根元素继承 attribute,可以在组件的选项中设置 inheritAttrs: false

禁用 attribute 继承的常见场景是需要将 attribute 应用于根节点之外的其他元素。

通过将 inheritAttrs 选项设置为 false,你可以使用组件的 $attrs property 将 attribute 应用到其它元素上,该 property 包括组件 propsemits property 中未包含的所有属性 (例如,classstylev-on 监听器等)。

使用上一节中的 date-picker 组件示例,如果需要将所有非 prop 的 attribute 应用于 input 元素而不是根 div 元素,可以使用 v-bind 缩写来完成。

1
2
3
4
5
6
7
8
app.component('date-picker', {
inheritAttrs: false,
template: `
<div class="date-picker">
<input type="datetime-local" v-bind="$attrs" />
</div>
`
})

有了这个新配置,data-status attribute 将应用于 input 元素!

1
2
3
4
5
6
7
<!-- date-picker 组件使用非 prop 的 attribute -->
<date-picker data-status="activated"></date-picker>

<!-- 渲染后的 date-picker 组件 -->
<div class="date-picker">
<input type="datetime-local" data-status="activated" />
</div>

3.3 多个根节点上的Attribute继承

与单个根节点组件不同,具有多个根节点的组件不具有自动 attribute fallthrough (隐式贯穿) 行为。如果未显式绑定 $attrs,将发出运行时警告。

1
<custom-layout id="custom-layout" @click="changeValue"></custom-layout>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 这将发出警告
app.component('custom-layout', {
template: `
<header>...</header>
<main>...</main>
<footer>...</footer>
`
})

// 没有警告,$attrs 被传递到 <main> 元素
app.component('custom-layout', {
template: `
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>
`
})

四、自定义事件

4.1 事件名

与组件和 prop 一样,事件名提供了自动的大小写转换。如果在子组件中触发一个以 camelCase (驼峰式命名) 命名的事件,你将可以在父组件中添加一个 kebab-case (短横线分隔命名) 的监听器。

1
this.$emit('myEvent')
1
<my-component @my-event="doSomething"></my-component>

props 的命名一样,当你使用 DOM 模板时,我们建议使用 kebab-case 事件监听器。如果你使用的是字符串模板,这个限制就不适用。

4.2 定义自定义事件

可以通过 emits 选项在组件上定义发出的事件。

1
2
3
app.component('custom-form', {
emits: ['inFocus', 'submit']
})

当在 emits 选项中定义了原生事件 (如 click) 时,将使用组件中的事件替代原生事件侦听器。

建议定义所有发出的事件,以便更好地记录组件应该如何工作。

4.2.1 验证抛出的事件

与 prop 类型验证类似,如果使用对象语法而不是数组语法定义发出的事件,则可以对它进行验证。

要添加验证,请为事件分配一个函数,该函数接收传递给 $emit 调用的参数,并返回一个布尔值以指示事件是否有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
app.component('custom-form', {
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 })
}
}
})

4.3 v-model参数

默认情况下,组件上的 v-model 使用 modelValue 作为 prop 和 update:modelValue 作为事件。我们可以通过向 v-model 传递参数来修改这些名称:

1
<my-component v-model:title="bookTitle"></my-component>

在本例中,子组件将需要一个 title prop 并发出 update:title 事件来进行同步:

1
2
3
4
5
6
7
8
9
10
11
12
app.component('my-component', {
props: {
title: String
},
emits: ['update:title'],
template: `
<input
type="text"
:value="title"
@input="$emit('update:title', $event.target.value)">
`
})

4.4 多个v-model绑定

正如我们之前在 v-model 参数中所学的那样,通过利用以特定 prop 和事件为目标的能力,我们现在可以在单个组件实例上创建多个 v-model 绑定。

每个 v-model 将同步到不同的 prop,而不需要在组件中添加额外的选项:

1
2
3
4
<user-name
v-model:first-name="firstName"
v-model:last-name="lastName"
></user-name>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.component('user-name', {
props: {
firstName: String,
lastName: String
},
emits: ['update:firstName', 'update:lastName'],
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)">
`
})

4.5 处理v-model修饰符

当我们学习表单输入绑定时,我们看到 v-model内置修饰符——.trim.number.lazy。但是,在某些情况下,你可能还需要添加自己的自定义修饰符。

让我们创建一个示例自定义修饰符 capitalize,它将 v-model 绑定提供的字符串的第一个字母大写。

添加到组件 v-model 的修饰符将通过 modelModifiers prop 提供给组件。在下面的示例中,我们创建了一个组件,其中包含默认为空对象的 modelModifiers prop。

请注意,当组件的 created 生命周期钩子触发时,modelModifiers prop 会包含 capitalize,且其值为 true——因为 capitalize 被设置在了写为 v-model.capitalize="myText"v-model 绑定上。

1
<my-component v-model.capitalize="myText"></my-component>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.component('my-component', {
props: {
modelValue: String,
modelModifiers: {
default: () => ({})
}
},
emits: ['update:modelValue'],
template: `
<input type="text"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)">
`,
created() {
console.log(this.modelModifiers) // { capitalize: true }
}
})

现在我们已经设置了 prop,我们可以检查 modelModifiers 对象键并编写一个处理器来更改发出的值。在下面的代码中,每当 <input/> 元素触发 input 事件时,我们都将字符串大写。

1
2
3
4
<div id="app">
<my-component v-model.capitalize="myText"></my-component>
{{ myText }}
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const app = Vue.createApp({
data() {
return {
myText: ''
}
}
})

app.component('my-component', {
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)
}
},
template: `<input
type="text"
:value="modelValue"
@input="emitValue">`
})

app.mount('#app')

对于带参数的 v-model 绑定,生成的 prop 名称将为 arg + "Modifiers"

1
<my-component v-model:description.capitalize="myText"></my-component>
1
2
3
4
5
6
7
8
9
10
11
12
app.component('my-component', {
props: ['description', 'descriptionModifiers'],
emits: ['update:description'],
template: `
<input type="text"
:value="description"
@input="$emit('update:description', $event.target.value)">
`,
created() {
console.log(this.descriptionModifiers) // { capitalize: true }
}
})

五、插槽

5.1 插槽内容

Vue 实现了一套内容分发的 API,这套 API 的设计灵感源自 Web Components 规范草案,将 <slot> 元素作为承载分发内容的出口。

它允许你像这样合成组件:

1
2
3
<todo-button>
Add todo
</todo-button>

然后在 <todo-button> 的模板中,你可能有:

1
2
3
4
<!-- todo-button 组件模板 -->
<button class="btn-primary">
<slot></slot>
</button>

当组件渲染的时候,<slot></slot> 将会被替换为“Add todo”。

1
2
3
4
<!-- 渲染 HTML -->
<button class="btn-primary">
Add todo
</button>

不过,字符串只是开始!插槽还可以包含任何模板代码,包括 HTML:

1
2
3
4
5
<todo-button>
<!-- 添加一个 Font Awesome 图标 -->
<i class="fas fa-plus"></i>
Add todo
</todo-button>

如果 <todo-button> 的 template 中没有包含一个 <slot> 元素,则该组件起始标签和结束标签之间的任何内容都会被抛弃。

1
2
3
4
5
<!-- todo-button 组件模板 -->

<button class="btn-primary">
Create a new item
</button>
1
2
3
4
<todo-button>
<!-- 以下文本不会渲染 -->
Add todo
</todo-button>

5.2 渲染作用域

当你想在一个插槽中使用数据时,例如:

1
2
3
<todo-button>
Delete a {{ item.name }}
</todo-button>

该插槽可以访问与模板其余部分相同的实例 property (即相同的“作用域”)。

Slot explanation diagram

插槽不能访问 <todo-button> 的作用域。例如,尝试访问 action 将不起作用:

1
2
3
4
5
6
7
8
<todo-button action="delete">
Clicking here will {{ action }} an item
<!--
`action` 将会是 undefined,因为这个内容是
传递到 <todo-button>,
而不是在 <todo-button> 中定义的。
-->
</todo-button>

父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。

5.3 备用内容

有时为一个插槽指定备用 (也就是默认的) 内容是很有用的,它只会在没有提供内容的时候被渲染。例如在一个 <submit-button> 组件中:

1
2
3
<button type="submit">
<slot></slot>
</button>

我们可能希望这个 <button> 内绝大多数情况下都渲染“Submit”文本。为了将“Submit”作为备用内容,我们可以将它放在 <slot> 标签内:

1
2
3
<button type="submit">
<slot>Submit</slot>
</button>

现在当我们在一个父级组件中使用 <submit-button> 并且不提供任何插槽内容时:

1
<submit-button></submit-button>

备用内容“Submit”将会被渲染:

1
2
3
<button type="submit">
Submit
</button>

但是如果我们提供内容:

1
2
3
<submit-button>
Save
</submit-button>

则这个提供的内容将会被渲染从而取代备用内容:

1
2
3
<button type="submit">
Save
</button>

5.4 具名插槽

有时我们需要多个插槽。例如对于一个带有如下模板的 <base-layout> 组件:

1
2
3
4
5
6
7
8
9
10
11
<div class="container">
<header>
<!-- 我们希望把页头放这里 -->
</header>
<main>
<!-- 我们希望把主要内容放这里 -->
</main>
<footer>
<!-- 我们希望把页脚放这里 -->
</footer>
</div>

对于这样的情况,<slot> 元素有一个特殊的 attribute:name。通过它可以为不同的插槽分配独立的 ID,也就能够以此来决定内容应该渲染到什么地方:

1
2
3
4
5
6
7
8
9
10
11
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>

一个不带 name<slot> 出口会带有隐含的名字“default”。

在向具名插槽提供内容的时候,我们可以在一个 <template> 元素上使用 v-slot 指令,并以 v-slot 的参数的形式提供其名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<base-layout>
<template v-slot:header>
<h1>Here might be a page title</h1>
</template>

<template v-slot:default>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</template>

<template v-slot:footer>
<p>Here's some contact info</p>
</template>
</base-layout>

现在 <template> 元素中的所有内容都将会被传入相应的插槽。

渲染的 HTML 将会是:

1
2
3
4
5
6
7
8
9
10
11
12
<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>

注意,**v-slot 只能添加在 <template> 上** (只有一种例外情况)。

5.5 作用域插槽

有时让插槽内容能够访问子组件中才有的数据是很有用的。当一个组件被用来渲染一个项目数组时,这是一个常见的情况,我们希望能够自定义每个项目的渲染方式。

例如,我们有一个组件,包含一个待办项目列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.component('todo-list', {
data() {
return {
items: ['Feed a cat', 'Buy milk']
}
},
template: `
<ul>
<li v-for="(item, index) in items">
{{ item }}
</li>
</ul>
`
})

我们可能会想把 {{ item }} 替换为 <slot>,以便在父组件上对其自定义。

1
2
3
4
<todo-list>
<i class="fas fa-check"></i>
<span class="green">{{ item }}</span>
</todo-list>

但是,这是行不通的,因为只有在 <todo-list> 组件中可以访问 item,且插槽内容是在它的父组件上提供的。

要使 item 在父级提供的插槽内容上可用,我们可以添加一个 <slot> 元素并将其作为一个 attribute 绑定:

1
2
3
4
5
<ul>
<li v-for="( item, index ) in items">
<slot :item="item"></slot>
</li>
</ul>

可以根据自己的需要将任意数量的 attribute 绑定到 slot 上:

1
2
3
4
5
<ul>
<li v-for="( item, index ) in items">
<slot :item="item" :index="index" :another-attribute="anotherAttribute"></slot>
</li>
</ul>

绑定在 <slot> 元素上的 attribute 被称为插槽 prop。现在,在父级作用域中,我们可以使用带值的 v-slot 来定义我们提供的插槽 prop 的名字:

1
2
3
4
5
6
<todo-list>
<template v-slot:default="slotProps">
<i class="fas fa-check"></i>
<span class="green">{{ slotProps.item }}</span>
</template>
</todo-list>

Scoped slot diagram

在这个例子中,我们选择将包含所有插槽 prop 的对象命名为 slotProps,但你也可以使用任意你喜欢的名字。

5.5.1 独占默认插槽的缩写语法

在上述情况下,当被提供的内容只有默认插槽时,组件的标签才可以被当作插槽的模板来使用。这样我们就可以把 v-slot 直接用在组件上:

1
2
3
4
<todo-list v-slot:default="slotProps">
<i class="fas fa-check"></i>
<span class="green">{{ slotProps.item }}</span>
</todo-list>

这种写法还可以更简单。就像假定未指明的内容对应默认插槽一样,不带参数的 v-slot 被假定对应默认插槽:

1
2
3
4
<todo-list v-slot="slotProps">
<i class="fas fa-check"></i>
<span class="green">{{ slotProps.item }}</span>
</todo-list>

注意默认插槽的缩写语法不能和具名插槽混用,因为它会导致作用域不明确:

1
2
3
4
5
6
7
8
9
<!-- 无效,会导致警告 -->
<todo-list v-slot="slotProps">
<i class="fas fa-check"></i>
<span class="green">{{ slotProps.item }}</span>

<template v-slot:other="otherSlotProps">
slotProps 在此处不可用
</template>
</todo-list>

只要出现多个插槽,请始终为所有的插槽使用完整的基于 <template> 的语法:

1
2
3
4
5
6
7
8
9
10
<todo-list>
<template v-slot:default="slotProps">
<i class="fas fa-check"></i>
<span class="green">{{ slotProps.item }}</span>
</template>

<template v-slot:other="otherSlotProps">
...
</template>
</todo-list>

5.5.2 解构插槽Prop

作用域插槽的内部工作原理是将你的插槽内容包括在一个传入单个参数的函数里:

1
2
3
function (slotProps) {
// ... 插槽内容 ...
}

这意味着 v-slot 的值实际上可以是任何能够作为函数定义中的参数的 JavaScript 表达式。因此你也可以使用 ES2015 解构 来传入具体的插槽 prop,如下:

1
2
3
4
<todo-list v-slot="{ item }">
<i class="fas fa-check"></i>
<span class="green">{{ item }}</span>
</todo-list>

这样可以使模板更简洁,尤其是在该插槽提供了多个 prop 的时候。它同样开启了 prop 重命名等其它可能,例如将 item 重命名为 todo

1
2
3
4
<todo-list v-slot="{ item: todo }">
<i class="fas fa-check"></i>
<span class="green">{{ todo }}</span>
</todo-list>

你甚至可以定义备用内容,用于插槽 prop 是 undefined 的情形:

1
2
3
4
<todo-list v-slot="{ item = 'Placeholder' }">
<i class="fas fa-check"></i>
<span class="green">{{ item }}</span>
</todo-list>

5.6 动态插槽名

动态指令参数也可以用在 v-slot 上,来定义动态的插槽名:

1
2
3
4
5
<base-layout>
<template v-slot:[dynamicSlotName]>
...
</template>
</base-layout>

5.7 具名插槽的缩写

v-onv-bind 一样,v-slot 也有缩写,即把参数之前的所有内容 (v-slot:) 替换为字符 #。例如 v-slot:header 可以被重写为 #header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<base-layout>
<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>
</base-layout>

然而,和其它指令一样,该缩写只在其有参数的时候才可用。这意味着以下语法是无效的:

1
2
3
4
5
6
<!-- 这将触发一个警告 -->

<todo-list #="{ item }">
<i class="fas fa-check"></i>
<span class="green">{{ item }}</span>
</todo-list>

如果希望使用缩写的话,你必须始终以明确的插槽名取而代之:

1
2
3
4
<todo-list #default="{ item }">
<i class="fas fa-check"></i>
<span class="green">{{ item }}</span>
</todo-list>

5.8 其它示例

插槽 prop 允许我们将插槽转换为可复用的模板,这些模板可以基于输入的 prop 渲染出不同的内容。这在设计封装数据逻辑同时允许父级组件自定义部分布局的可复用组件时是最有用的。

例如,我们要实现一个 <todo-list> 组件,它是一个列表且包含布局和过滤逻辑:

1
2
3
4
5
6
7
8
<ul>
<li
v-for="todo in filteredTodos"
v-bind:key="todo.id"
>
{{ todo.text }}
</li>
</ul>

我们可以将每个 todo 作为父级组件的插槽,以此通过父级组件对其进行控制,然后将 todo 作为一个插槽 prop 进行绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ul>
<li
v-for="todo in filteredTodos"
v-bind:key="todo.id"
>
<!--
我们为每个 todo 准备了一个插槽,
将 `todo` 对象作为一个插槽的 prop 传入。
-->
<slot name="todo" v-bind:todo="todo">
<!-- 后备内容 -->
{{ todo.text }}
</slot>
</li>
</ul>

现在当我们使用 <todo-list> 组件的时候,我们可以选择为 todo 定义一个不一样的 <template> 作为替代方案,并且可以从子组件获取数据:

1
2
3
4
5
6
<todo-list v-bind:todos="todos">
<template v-slot:todo="{ todo }">
<span v-if="todo.isComplete"></span>
{{ todo.text }}
</template>
</todo-list>

这只是作用域插槽用武之地的冰山一角。想了解更多现实生活中的作用域插槽的用法,我们推荐浏览诸如 Vue Virtual ScrollerVue PromisedPortal Vue 等库。

5.9 废弃了的语法

5.9.1 带有 slot attribute 的具名插槽

自 2.6.0 起被废弃。新推荐的语法请查阅这里

<template> 上使用特殊的 slot attribute,可以将内容从父级传给具名插槽 (把这里提到过的 <base-layout> 组件作为示例):

1
2
3
4
5
6
7
8
9
10
11
12
<base-layout>
<template slot="header">
<h1>Here might be a page title</h1>
</template>

<p>A paragraph for the main content.</p>
<p>And another one.</p>

<template slot="footer">
<p>Here's some contact info</p>
</template>
</base-layout>

或者直接把 slot attribute 用在一个普通元素上:

1
2
3
4
5
6
7
8
<base-layout>
<h1 slot="header">Here might be a page title</h1>

<p>A paragraph for the main content.</p>
<p>And another one.</p>

<p slot="footer">Here's some contact info</p>
</base-layout>

这里其实还有一个未命名插槽,也就是默认插槽,捕获所有未被匹配的内容。上述两个示例的 HTML 渲染结果均为:

1
2
3
4
5
6
7
8
9
10
11
12
<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>

5.9.2 带有 slot-scope attribute 的作用域插槽

自 2.6.0 起被废弃。新推荐的语法请查阅这里

<template> 上使用特殊的 slot-scope attribute,可以接收传递给插槽的 prop (把这里提到过的 <slot-example> 组件作为示例):

1
2
3
4
5
<slot-example>
<template slot="default" slot-scope="slotProps">
{{ slotProps.msg }}
</template>
</slot-example>

这里的 slot-scope 声明了被接收的 prop 对象会作为 slotProps 变量存在于 <template> 作用域中。你可以像命名 JavaScript 函数参数一样随意命名 slotProps

这里的 slot="default" 可以被忽略为隐性写法:

1
2
3
4
5
<slot-example>
<template slot-scope="slotProps">
{{ slotProps.msg }}
</template>
</slot-example>

slot-scope attribute 也可以直接用于非 <template> 元素 (包括组件):

1
2
3
4
5
<slot-example>
<span slot-scope="slotProps">
{{ slotProps.msg }}
</span>
</slot-example>

slot-scope 的值可以接收任何有效的可以出现在函数定义的参数位置上的 JavaScript 表达式。这意味着在支持的环境下 (单文件组件现代浏览器),你也可以在表达式中使用 ES2015 解构,如下:

1
2
3
4
5
<slot-example>
<span slot-scope="{ msg }">
{{ msg }}
</span>
</slot-example>

使用这里描述过的 <todo-list> 作为示例,与它等价的使用 slot-scope 的代码是:

1
2
3
4
5
6
<todo-list v-bind:todos="todos">
<template slot="todo" slot-scope="{ todo }">
<span v-if="todo.isComplete"></span>
{{ todo.text }}
</template>
</todo-list>

六、动态组件&异步组件

6.1 在动态组件上使用keep-alive

我们之前在一个多标签的界面中使用 is attribute 来切换不同的组件:

1
<component v-bind:is="currentTabComponent"></component>

当在这些组件之间切换的时候,你有时会想保持这些组件的状态,以避免反复重新渲染导致的性能问题。例如我们来展开说一说这个多标签界面:

image-20220305205750109

你会注意到,如果你选择了一篇文章,切换到 Archive 标签,然后再切换回 Posts,是不会继续展示你之前选择的文章的。这是因为你每次切换新标签的时候,Vue 都创建了一个新的 currentTabComponent 实例。

重新创建动态组件的行为通常是非常有用的,但是在这个案例中,我们更希望那些标签的组件实例能够被在它们第一次被创建的时候缓存下来。为了解决这个问题,我们可以用一个 <keep-alive> 元素将其动态组件包裹起来。

1
2
3
4
<!-- 失活的组件将会被缓存!-->
<keep-alive>
<component v-bind:is="currentTabComponent"></component>
</keep-alive>

image-20220305210024036

现在这个 Posts 标签保持了它的状态 (被选中的文章) 甚至当它未被渲染时也是如此。你可以在这个示例查阅到完整的代码。

注意这个 <keep-alive> 要求被切换到的组件都有自己的名字,不论是通过组件的 name 选项还是局部/全局注册。

你可以在 API 参考文档查阅更多关于 <keep-alive> 的细节。

6.2 异步组件

在大型应用中,我们可能需要将应用分割成小一些的代码块,并且只在需要的时候才从服务器加载一个模块。为了简化,Vue 允许你以一个工厂函数的方式定义你的组件,这个工厂函数会异步解析你的组件定义。Vue 只有在这个组件需要被渲染的时候才会触发该工厂函数,且会把结果缓存起来供未来重渲染。例如:

1
2
3
4
5
6
7
8
Vue.component('async-example', function (resolve, reject) {
setTimeout(function () {
// 向 `resolve` 回调传递组件定义
resolve({
template: '<div>I am async!</div>'
})
}, 1000)
})

如你所见,这个工厂函数会收到一个 resolve 回调,这个回调函数会在你从服务器得到组件定义的时候被调用。你也可以调用 reject(reason) 来表示加载失败。这里的 setTimeout 是为了演示用的,如何获取组件取决于你自己。一个推荐的做法是将异步组件和 webpack 的 code-splitting 功能一起配合使用:

1
2
3
4
5
6
Vue.component('async-webpack-example', function (resolve) {
// 这个特殊的 `require` 语法将会告诉 webpack
// 自动将你的构建代码切割成多个包,这些包
// 会通过 Ajax 请求加载
require(['./my-async-component'], resolve)
})

你也可以在工厂函数中返回一个 Promise,所以把 webpack 2 和 ES2015 语法加在一起,我们可以这样使用动态导入:

1
2
3
4
5
Vue.component(
'async-webpack-example',
// 这个动态导入会返回一个 `Promise` 对象。
() => import('./my-async-component')
)

当使用局部注册的时候,你也可以直接提供一个返回 Promise 的函数:

1
2
3
4
5
6
new Vue({
// ...
components: {
'my-component': () => import('./my-async-component')
}
})

如果你是一个 Browserify 用户同时喜欢使用异步组件,很不幸这个工具的作者明确表示异步加载“并不会被 Browserify 支持”,至少官方不会。Browserify 社区已经找到了一些变通方案,这些方案可能会对已存在的复杂应用有帮助。对于其它的场景,我们推荐直接使用 webpack,以拥有内置的头等异步支持。

6.2.1 处理加载状态

2.3.0+ 新增

这里的异步组件工厂函数也可以返回一个如下格式的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
const AsyncComponent = () => ({
// 需要加载的组件 (应该是一个 `Promise` 对象)
component: import('./MyComponent.vue'),
// 异步组件加载时使用的组件
loading: LoadingComponent,
// 加载失败时使用的组件
error: ErrorComponent,
// 展示加载时组件的延时时间。默认值是 200 (毫秒)
delay: 200,
// 如果提供了超时时间且组件加载也超时了,
// 则使用加载失败时使用的组件。默认值是:`Infinity`
timeout: 3000
})

注意如果你希望在 Vue Router 的路由组件中使用上述语法的话,你必须使用 Vue Router 2.4.0+ 版本。

七、处理边界情况

这里记录的都是和处理边界情况有关的功能,即一些需要对 Vue 的规则做一些小调整的特殊情况。不过注意这些功能都是有劣势或危险的场景的。我们会在每个案例中注明,所以当你使用每个功能的时候请稍加留意。

7.1 访问元素&组件

在绝大多数情况下,我们最好不要触达另一个组件实例内部或手动操作 DOM 元素。不过也确实在一些情况下做这些事情是合适的。

7.1.1 访问根实例

在每个 new Vue 实例的子组件中,其根实例可以通过 $root property 进行访问。例如,在这个根实例中:

1
2
3
4
5
6
7
8
9
10
11
12
// Vue 根实例
new Vue({
data: {
foo: 1
},
computed: {
bar: function () { /* ... */ }
},
methods: {
baz: function () { /* ... */ }
}
})

所有的子组件都可以将这个实例作为一个全局 store 来访问或使用。

1
2
3
4
5
6
7
8
9
10
11
// 获取根组件的数据
this.$root.foo

// 写入根组件的数据
this.$root.foo = 2

// 访问根组件的计算属性
this.$root.bar

// 调用根组件的方法
this.$root.baz()

对于 demo 或非常小型的有少量组件的应用来说这是很方便的。不过这个模式扩展到中大型应用来说就不然了。因此在绝大多数情况下,我们强烈推荐使用 Vuex 来管理应用的状态。

7.1.2 访问父级组件实例

$root 类似,$parent property 可以用来从一个子组件访问父组件的实例。它提供了一种机会,可以在后期随时触达父级组件,以替代将数据以 prop 的方式传入子组件的方式。

在绝大多数情况下,触达父级组件会使得你的应用更难调试和理解,尤其是当你变更了父级组件的数据的时候。当我们稍后回看那个组件的时候,很难找出那个变更是从哪里发起的。

另外在一些可能适当的时候,你需要特别地共享一些组件库。举个例子,在和 JavaScript API 进行交互而不渲染 HTML 的抽象组件内,诸如这些假设性的 Google 地图组件一样:

1
2
3
<google-map>
<google-map-markers v-bind:places="iceCreamShops"></google-map-markers>
</google-map>

这个 <google-map> 组件可以定义一个 map property,所有的子组件都需要访问它。在这种情况下 <google-map-markers> 可能想要通过类似 this.$parent.getMap 的方式访问那个地图,以便为其添加一组标记。你可以在这里查阅这种模式。

请留意,尽管如此,通过这种模式构建出来的那个组件的内部仍然是容易出现问题的。比如,设想一下我们添加一个新的 <google-map-region> 组件,当 <google-map-markers> 在其内部出现的时候,只会渲染那个区域内的标记:

1
2
3
4
5
<google-map>
<google-map-region v-bind:shape="cityBoundaries">
<google-map-markers v-bind:places="iceCreamShops"></google-map-markers>
</google-map-region>
</google-map>

那么在 <google-map-markers> 内部你可能发现自己需要一些类似这样的 hack:

1
var map = this.$parent.map || this.$parent.$parent.map

很快它就会失控。这也是我们针对需要向任意更深层级的组件提供上下文信息时推荐依赖注入的原因。

7.1.3 访问子组件实例或子元素

尽管存在 prop 和事件,有的时候你仍可能需要在 JavaScript 里直接访问一个子组件。为了达到这个目的,你可以通过 ref 这个 attribute 为子组件赋予一个 ID 引用。例如:

1
<base-input ref="usernameInput"></base-input>

现在在你已经定义了这个 ref 的组件里,你可以使用:

1
this.$refs.usernameInput

来访问这个 <base-input> 实例,以便不时之需。比如程序化地从一个父级组件聚焦这个输入框。在刚才那个例子中,该 <base-input> 组件也可以使用一个类似的 ref 提供对内部这个指定元素的访问,例如:

1
<input ref="input">

甚至可以通过其父级组件定义方法:

1
2
3
4
5
6
methods: {
// 用来从父级组件聚焦输入框
focus: function () {
this.$refs.input.focus()
}
}

这样就允许父级组件通过下面的代码聚焦 <base-input> 里的输入框:

1
this.$refs.usernameInput.focus()

refv-for 一起使用的时候,你得到的 ref 将会是一个包含了对应数据源的这些子组件的数组。

$refs 只会在组件渲染完成之后生效,并且它们不是响应式的。这仅作为一个用于直接操作子组件的“逃生舱”——你应该避免在模板或计算属性中访问 $refs

7.1.4 依赖注入

在此之前,在我们描述访问父级组件实例的时候,展示过一个类似这样的例子:

1
2
3
4
5
<google-map>
<google-map-region v-bind:shape="cityBoundaries">
<google-map-markers v-bind:places="iceCreamShops"></google-map-markers>
</google-map-region>
</google-map>

在这个组件里,所有 <google-map> 的后代都需要访问一个 getMap 方法,以便知道要跟哪个地图进行交互。不幸的是,使用 $parent property 无法很好的扩展到更深层级的嵌套组件上。这也是依赖注入的用武之地,它用到了两个新的实例选项:provideinject

provide 选项允许我们指定我们想要提供给后代组件的数据/方法。在这个例子中,就是 <google-map> 内部的 getMap 方法:

1
2
3
4
5
provide: function () {
return {
getMap: this.getMap
}
}

然后在任何后代组件里,我们都可以使用 inject 选项来接收指定的我们想要添加在这个实例上的 property:

1
inject: ['getMap']

你可以在这里看到完整的示例。相比 $parent 来说,这个用法可以让我们在任意后代组件中访问 getMap,而不需要暴露整个 <google-map> 实例。这允许我们更好的持续研发该组件,而不需要担心我们可能会改变/移除一些子组件依赖的东西。同时这些组件之间的接口是始终明确定义的,就和 props 一样。

实际上,你可以把依赖注入看作一部分“大范围有效的 prop”,除了:

  • 祖先组件不需要知道哪些后代组件使用它提供的 property
  • 后代组件不需要知道被注入的 property 来自哪里

然而,依赖注入还是有负面影响的。它将你应用程序中的组件与它们当前的组织方式耦合起来,使重构变得更加困难。同时所提供的 property 是非响应式的。这是出于设计的考虑,因为使用它们来创建一个中心化规模化的数据跟使用 $root做这件事都是不够好的。如果你想要共享的这个 property 是你的应用特有的,而不是通用化的,或者如果你想在祖先组件中更新所提供的数据,那么这意味着你可能需要换用一个像 Vuex 这样真正的状态管理方案了。

7.2 程序化的事件侦听器

现在,你已经知道了 $emit 的用法,它可以被 v-on 侦听,但是 Vue 实例同时在其事件接口中提供了其它的方法。我们可以:

  • 通过 $on(eventName, eventHandler) 侦听一个事件
  • 通过 $once(eventName, eventHandler) 一次性侦听一个事件
  • 通过 $off(eventName, eventHandler) 停止侦听一个事件

你通常不会用到这些,但是当你需要在一个组件实例上手动侦听事件时,它们是派得上用场的。它们也可以用于代码组织工具。例如,你可能经常看到这种集成一个第三方库的模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 一次性将这个日期选择器附加到一个输入框上
// 它会被挂载到 DOM 上。
mounted: function () {
// Pikaday 是一个第三方日期选择器的库
this.picker = new Pikaday({
field: this.$refs.input,
format: 'YYYY-MM-DD'
})
},
// 在组件被销毁之前,
// 也销毁这个日期选择器。
beforeDestroy: function () {
this.picker.destroy()
}

这里有两个潜在的问题:

  • 它需要在这个组件实例中保存这个 picker,如果可以的话最好只有生命周期钩子可以访问到它。这并不算严重的问题,但是它可以被视为杂物。
  • 我们的建立代码独立于我们的清理代码,这使得我们比较难于程序化地清理我们建立的所有东西。

你应该通过一个程序化的侦听器解决这两个问题:

1
2
3
4
5
6
7
8
9
10
mounted: function () {
var picker = new Pikaday({
field: this.$refs.input,
format: 'YYYY-MM-DD'
})

this.$once('hook:beforeDestroy', function () {
picker.destroy()
})
}

使用了这个策略,我甚至可以让多个输入框元素同时使用不同的 Pikaday,每个新的实例都程序化地在后期清理它自己:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mounted: function () {
this.attachDatepicker('startDateInput')
this.attachDatepicker('endDateInput')
},
methods: {
attachDatepicker: function (refName) {
var picker = new Pikaday({
field: this.$refs[refName],
format: 'YYYY-MM-DD'
})

this.$once('hook:beforeDestroy', function () {
picker.destroy()
})
}
}

查阅这个示例可以了解到完整的代码。注意,即便如此,如果你发现自己不得不在单个组件里做很多建立和清理的工作,最好的方式通常还是创建更多的模块化组件。在这个例子中,我们推荐创建一个可复用的 <input-datepicker> 组件。

想了解更多程序化侦听器的内容,请查阅实例方法 / 事件相关的 API。

注意 Vue 的事件系统不同于浏览器的 EventTarget API。尽管它们工作起来是相似的,但是 $emit$on, 和 $off 并不是 dispatchEventaddEventListenerremoveEventListener 的别名。

7.3 循环引用

7.3.1 递归组件

组件是可以在它们自己的模板中调用自身的。不过它们只能通过 name 选项来做这件事:

1
name: 'unique-name-of-my-component'

当你使用 Vue.component 全局注册一个组件时,这个全局的 ID 会自动设置为该组件的 name 选项。

1
2
3
Vue.component('unique-name-of-my-component', {
// ...
})

稍有不慎,递归组件就可能导致无限循环:

1
2
name: 'stack-overflow',
template: '<div><stack-overflow></stack-overflow></div>'

类似上述的组件将会导致“max stack size exceeded”错误,所以请确保递归调用是条件性的 (例如使用一个最终会得到 falsev-if)。

7.3.2 组件之间的循环引用

假设你需要构建一个文件目录树,像访达或资源管理器那样的。你可能有一个 <tree-folder> 组件,模板是这样的:

1
2
3
4
<p>
<span>{{ folder.name }}</span>
<tree-folder-contents :children="folder.children"/>
</p>

还有一个 <tree-folder-contents> 组件,模板是这样的:

1
2
3
4
5
6
<ul>
<li v-for="child in children">
<tree-folder v-if="child.children" :folder="child"/>
<span v-else>{{ child.name }}</span>
</li>
</ul>

当你仔细观察的时候,你会发现这些组件在渲染树中互为对方的后代祖先——一个悖论!当通过 Vue.component 全局注册组件的时候,这个悖论会被自动解开。如果你是这样做的,那么你可以跳过这里。

然而,如果你使用一个模块系统依赖/导入组件,例如通过 webpack 或 Browserify,你会遇到一个错误:

1
Failed to mount component: template or render function not defined.

为了解释这里发生了什么,我们先把两个组件称为 A 和 B。模块系统发现它需要 A,但是首先 A 依赖 B,但是 B 又依赖 A,但是 A 又依赖 B,如此往复。这变成了一个循环,不知道如何不经过其中一个组件而完全解析出另一个组件。为了解决这个问题,我们需要给模块系统一个点,在那里“A 反正是需要 B 的,但是我们不需要先解析 B。”

在我们的例子中,把 <tree-folder> 组件设为了那个点。我们知道那个产生悖论的子组件是 <tree-folder-contents> 组件,所以我们会等到生命周期钩子 beforeCreate 时去注册它:

1
2
3
beforeCreate: function () {
this.$options.components.TreeFolderContents = require('./tree-folder-contents.vue').default
}

或者,在本地注册组件的时候,你可以使用 webpack 的异步 import

1
2
3
components: {
TreeFolderContents: () => import('./tree-folder-contents.vue')
}

这样问题就解决了!

7.4 模板定义的替代品

7.4.1 内联模板

inline-template 这个特殊的 attribute 出现在一个子组件上时,这个组件将会使用其里面的内容作为模板,而不是将其作为被分发的内容。这使得模板的撰写工作更加灵活。

1
2
3
4
5
6
<my-component inline-template>
<div>
<p>These are compiled as the component's own template.</p>
<p>Not parent's transclusion content.</p>
</div>
</my-component>

内联模板需要定义在 Vue 所属的 DOM 元素内。

不过,inline-template 会让模板的作用域变得更加难以理解。所以作为最佳实践,请在组件内优先选择 template 选项或 .vue 文件里的一个 <template> 元素来定义模板。

7.4.2 X-Template

另一个定义模板的方式是在一个 <script> 元素中,并为其带上 text/x-template 的类型,然后通过一个 id 将模板引用过去。例如:

1
2
3
<script type="text/x-template" id="hello-world-template">
<p>Hello hello hello</p>
</script>
1
2
3
Vue.component('hello-world', {
template: '#hello-world-template'
})

x-template 需要定义在 Vue 所属的 DOM 元素外。

这些可以用于模板特别大的 demo 或极小型的应用,但是其它情况下请避免使用,因为这会将模板和该组件的其它定义分离开。

7.5 控制更新

感谢 Vue 的响应式系统,它始终知道何时进行更新 (如果你用对了的话)。不过还是有一些边界情况,你想要强制更新,尽管表面上看响应式的数据没有发生改变。也有一些情况是你想阻止不必要的更新。

7.5.1 强制更新

如果你发现你自己需要在 Vue 中做一次强制更新,99.9% 的情况,是你在某个地方做错了事。

你可能还没有留意到数组对象的变更检测注意事项,或者你可能依赖了一个未被 Vue 的响应式系统追踪的状态。

然而,如果你已经做到了上述的事项仍然发现在极少数的情况下需要手动强制更新,那么你可以通过 $forceUpdate 来做这件事。

7.5.2 通过v-once创建低开销的静态组件

渲染普通的 HTML 元素在 Vue 中是非常快速的,但有的时候你可能有一个组件,这个组件包含了大量静态内容。在这种情况下,你可以在根元素上添加 v-once attribute 以确保这些内容只计算一次然后缓存起来,就像这样:

1
2
3
4
5
6
7
8
Vue.component('terms-of-service', {
template: `
<div v-once>
<h1>Terms of Service</h1>
... a lot of static content ...
</div>
`
})

再说一次,试着不要过度使用这个模式。当你需要渲染大量静态内容时,极少数的情况下它会给你带来便利,除非你非常留意渲染变慢了,不然它完全是没有必要的——再加上它在后期会带来很多困惑。例如,设想另一个开发者并不熟悉 v-once 或漏看了它在模板中,他们可能会花很多个小时去找出模板为什么无法正确更新。