Vue 学习笔记(十三):处理边界情况

Handling Edge Cases
处理边界情况

处理边界情况:一些需要对 Vue 的规则做一些小调整的特殊情况。不过注意这些功能都是有劣势或危险的场景的。

访问元素 & 组件 Element & Component Access

一般最好不要触达另一个组件实例内部或手动操作 DOM 元素。不过也有例外。

访问根实例 Accessing the Root Instance

在每个 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 来管理应用的状态。

访问父级组件实例 Accessing the Parent Component Instance

类似 $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

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

访问子组件实例或子元素

Accessing Child Component Instances & Child Elements

尽管有 prop 和事件,有时仍需要在 JavaScript 里直接访问一个子组件:通过 ref attribute 为子组件赋予一个 ID 引用。例:

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

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

1
this.$refs.usernameInput

来访问这个 <base-input> 实例。比如程序化地从一个父级组件聚焦这个输入框(programmatically focus this input from a parent)。刚才例子中,<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

依赖注入 Dependency Injection

前面展示了类似这样的例子:

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']

完整代码:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<!DOCTYPE html>
<html>
<head>
<title>Dependency Injection Google Maps Demo</title>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAHbknPTCvUSgWwU0jJ68m4h6b7vpyP6hM"></script>
<script src="https://unpkg.com/vue"></script>
<style>
.map {
width: 100%;
height: 400px;
}
</style>
</head>
<body>
<div id="app">
<google-map>
<google-map-marker v-bind:places="vueConfCities"></google-map-marker>
</google-map>
</div>

<script>
Vue.component("google-map", {
provide: function() {
return {
getMap: this.getMap
};
},
data: function() {
return {
map: null
};
},
mounted: function() {
this.map = new google.maps.Map(this.$el, {
center: { lat: 0, lng: 0 },
zoom: 1
});
},
methods: {
getMap: function(found) {
var vm = this;
function checkForMap() {
if (vm.map) {
found(vm.map);
} else {
setTimeout(checkForMap, 50);
}
}
checkForMap();
}
},
template: '<div class="map"><slot></slot></div>'
});

Vue.component("google-map-marker", {
inject: ["getMap"],
props: ["places"],
created: function() {
var vm = this;
vm.getMap(function(map) {
vm.places.forEach(function(place) {
new google.maps.Marker({
position: place.position,
map: map
});
});
});
},
render(h) {
return null;
}
});

new Vue({
el: "#app",
data: {
vueConfCities: [
{
name: "Wrocław",
position: {
lat: 51.107885,
lng: 17.038538
}
},
{
name: "New Orleans",
position: {
lat: 29.951066,
lng: -90.071532
}
}
]
}
});
</script>
</body>
</html>

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

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

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

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

程序化的事件侦听器

Programmatic Event Listeners

类似 $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()
})
}
}

完整代码:

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
<!DOCTYPE html>
<html>
<head>
<title>Programmatic Event Listeners using Pikaday</title>
<script src="https://unpkg.com/pikaday@1.7.0"></script>
<script src="https://unpkg.com/vue"></script>
</head>
<body>
<div id="app">
<input ref="dateInput" v-model="date" type="date" />
</div>

<script>
new Vue({
el: "#app",
data: {
date: null
},
mounted: function() {
var picker = new Pikaday({
field: this.$refs.dateInput,
format: "YYYY-MM-DD"
});

this.$once("hook:beforeDestroy", function() {
picker.destroy();
});
}
});
</script>
</body>
</html>

注意,即便如此,如果不得不在单个组件里做很多建立和清理的工作,最好的方式通常还是创建更多的模块化组件。在这个例子中,推荐创建一个可复用的 <input-datepicker> 组件。

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

循环引用 Circular References

递归组件 Recursive Components

组件可以在它们自己的模板中调用自身,只能通过 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)。

组件之间的循环引用 Circular References Between Components

假设需要构建一个文件目录树,像 Finder 或资源管理器那样的。你可能有一个 <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')
}

模板定义的替代品 Alternate Template Definitions

内联模板 Inline Templates

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 元素来定义模板。

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 或极小型的应用,但是其它情况下请避免使用,因为这会将模板和该组件的其它定义分离开。

控制更新 Controlling Updates

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

强制更新 Forcing an Update

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

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

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

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

Cheap Static Components with 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 或漏看了它在模板中,就会花很多个小时去找出模板为什么无法正确更新。