Vue指南的要点笔记(十三)

本篇学习Vue渲染函数的知识。这个是Vue进阶的要点,学好对今后开发更高级的组件、功能有帮助。本篇要点有:

  1. render函数基本认识
  2. createElement函数的参数详解

render函数基本认识

render函数跟Vue组件或实例的option是并列的关系:

1
2
3
4
5
6
7
8
9
new Vue({
data: {
},
methods:{
},
render(createElement) {
// todo return vnodes.
}
})

如果配置了renderoption,el template这两个option会被忽略。因为el template这两个option是render函数的表层形式,在Vue内部,仍然会将它俩的相关内容编译成render函数。可以说render函数是Vue渲染逻辑的底层机制。

render函数有什么作用?它的作用就是通过创建vnode实例,构建虚拟dom。跟浏览器标准的dom类似,虚拟dom依然是树形结构,与标准dom不同的是,虚拟dom的节点称为vnode,它是vue的基石。我们负责根据自己的需求创建vnode和vdom,vue负责帮助我们进行vdom与标准dom之间的互相同步。

vnode是vdom的节点,可以是文本节点、注释节点、虚拟节点、组件节点等,vnode节点可通过render函数的第一个参数createElement来生成,这个参数是一个函数。render函数最重要的知识点就是这个createElement函数。

为什么要使用render函数?因为单纯的template模板方式编写组件,在部分场景不合适,除了官方举得那个例子,我还有一个例子可以来说明。我们经常会遇到父子组件的场景,比如tab组件,可以分为tab-contenttab-pane组件。类似这样使用:

1
2
3
4
5
<tab-content :index="0">
<tab-pane>1</tab-pane>
<tab-pane>2</tab-pane>
<tab-pane>3</tab-pane>
</tab-content>

要实现这两个组件,需要想办法将父组件的index数据状态,传递到子组件,并控制子组件是否渲染(if),或者是否显示(show)。如果我们采用模板来编写这两个组件,你可以试试看,你会怎么做;按我的思路,我会在父子组件通过互相地引用,调用各自的一些methods,来得到对方的状态,从而自己根据状态做调整。这样最终是能做出效果的,但是从代码的角度来说不太好:第一,它把应该内聚在一起的代码,分离到了两个组件里面;第二,因为代码被分离到了两个组件,所以这两个组件,形成了耦合关系,如果有修改的需求,两个都需要改。所以更好的做法是,将代码全部都放到tab-content组件里面实现,tab-pane就是一个普通的组件,它不需要关心自己该不该渲染,什么时候渲染的事情,这些都是父组件该处理的。render函数可以帮助我们写出类似这样更有质量的代码,我写完这个例子,可以点此查看。在了解了本篇
全部内容之后,这个例子的源码就很好理解了。

createElement函数的参数详解

createElement有三个参数,第二个和第三个是可选的:

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
// @returns {VNode}
createElement(
// {String | Object | Function}
// 一个 HTML 标签名、组件选项对象,或者
// resolve 了上述任何一种的一个 async 函数。必填项。
'div',

// {Object}
// 一个与模板中属性对应的数据对象。可选。
{
// (详情见下一节)
},

// {String | Array}
// 子级虚拟节点 (VNodes),由 `createElement()` 构建而成,
// 也可以使用字符串来生成“文本虚拟节点”。可选。
[
'先写一些文字',
createElement('h1', '一则头条'),
createElement(MyComponent, {
props: {
someProp: 'foobar'
}
})
]
)

第一个参数支持三种类型,不管是哪种类型,第一个参数的作用都是为了指明当前createElement调用是要创建一个什么样的虚拟节点。如果是一个标准的html元素,如div,则要创建的就是一个div虚拟节点;如果是一个自定义组件,如tab-content,则就是要创建一个该组件类型的虚拟节点。
第二个参数是一个对象,是可选的,它会作用于第一个参数所指定的虚拟节点。这是重点,后面一一学习它的每个属性的作用。
第三个参数是支持两种类型:文本或数组。如果是文本,则表示第一个参数所指定节点的子节点为一个普通的文本虚拟节点;如果是一个数组,则表示第一个参数所指定节点的子节点内容,将由这个数组的内容来确定,数组的元素类型是文本或由createElement函数创建的其它虚拟节点。

明白了第一个和第三个参数的作用,就知道render函数该怎么用createElement函数来创建一个vdom的结构了。比如下面这个结构:

1
2
3
4
5
6
7
8
<div>
<h1>My title</h1>
Some text content
<div>
<p>1</p>
<p>2</p>
</div>
</div>

用render函数来描述的话,就是下面这个形式:

1
2
3
4
5
6
7
8
9
10
render(createElement) {
return createElement('div', [
createElement('h1', 'My title'),
'Some text content',
createElement('div', [
createElement('p', 1),
createElement('p', 2)
])
]);
}

render函数使用就是这么简单吗?当然不是的,如你所见,当我们在一个组件使用render函数完成渲染逻辑的时候,就没法使用template了,那原先我们能利用template做到的那些事情怎么办呢?比如指令、props传递、事件、class、style等,这些都需要借助createElement的第二个参数来完成。这也正是render函数难点和缺点所在,它让我们深入更加底层的方式来构建组件,但是也给我们在表达逻辑的方式增加了难度,原来通过模板很简单做到的事情,都必须用新的方式来写。

createElement第二个参数,是一个对象,它支持的属性有:

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
{
// 与 `v-bind:class` 的 API 相同,
// 接受一个字符串、对象或字符串和对象组成的数组
'class': {
foo: true,
bar: false
},
// 与 `v-bind:style` 的 API 相同,
// 接受一个字符串、对象,或对象组成的数组
style: {
color: 'red',
fontSize: '14px'
},
// 普通的 HTML 特性
attrs: {
id: 'foo'
},
// 组件 prop
props: {
myProp: 'bar'
},
// DOM 属性
domProps: {
innerHTML: 'baz'
},
// 事件监听器在 `on` 属性内,
// 但不再支持如 `v-on:keyup.enter` 这样的修饰器。
// 需要在处理函数中手动检查 keyCode。
on: {
click: this.clickHandler
},
// 仅用于组件,用于监听原生事件,而不是组件内部使用
// `vm.$emit` 触发的事件。
nativeOn: {
click: this.nativeClickHandler
},
// 自定义指令。注意,你无法对 `binding` 中的 `oldValue`
// 赋值,因为 Vue 已经自动为你进行了同步。
directives: [
{
name: 'my-custom-directive',
value: '2',
expression: '1 + 1',
arg: 'foo',
modifiers: {
bar: true
}
}
],
// 作用域插槽的格式为
// { name: props => VNode | Array<VNode> }
scopedSlots: {
default: props => createElement('span', props.text)
},
// 如果组件是其它组件的子组件,需为插槽指定名称
slot: 'name-of-slot',
// 其它特殊顶层属性
key: 'myKey',
ref: 'myRef',
// 如果你在渲染函数中给多个元素都应用了相同的 ref 名,
// 那么 `$refs.myRef` 会变成一个数组。
refInFor: true
}

接下来就一一去了解它们对于createElement能够起到什么效果。

在了解这些属性之前,先要知道一下this.$slots在render函数里面到底反馈的是什么内容,this.$slots在render函数中非常重要,通过它才能拿到组件被用到其它模板中的时候,组件标签内部是些什么内容;组件可以在声明时用render函数来定义如何渲染,不使用模板,但是这样的组件最终还是会在别的使用模板的组件中被使用。看这个例子:

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
<div id="vue">
<layout>
<template v-slot:header>
<div>header</div>
</template>
<template v-slot:footer>
<div>footer</div>
</template>
<div>body</div>
</layout>
</div>
<script type="text/javascript">
Vue.component('layout', {
render(h) {
console.log(this.$slots);
// default: Array(3) [VNode, VNode, VNode]
// footer: Array(1) [VNode]
// header: Array(1) [VNode]
}
});

new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

上面例子中layout这个组件是用render函数写的,它在被使用的时候,标签内部包含的是带有具名插槽的内容和非具名插槽的内容。通过打印可以看到this.$slots会将组件标签内部具名插槽以及默认插槽的内容都包含进来,这样render函数中,就可以根据这些不同插槽的内容,来定义渲染结构:

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
<div id="vue">
<layout>
<template v-slot:header>
<div>header</div>
</template>
<template v-slot:footer>
<div>footer</div>
</template>
<div>body</div>
</layout>
</div>
<script type="text/javascript">
Vue.component('layout', {
render(createElement) {
return createElement('div', [
createElement('header', this.$slots.header),
createElement('main', this.$slots.default),
createElement('footer', this.$slots.footer),
])
}
});

new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

如果换成模板来编写这个组件,是这样的:

1
2
3
4
5
6
7
8
9
Vue.component('layout', {
template: `
<div>
<header><slot name="header"></slot></header>
<main><slot></slot></main>
<header><slot name="footer"></slot></header>
</div>
`
});

下面开始学习createElement函数第二个参数的各个属性。

class

class属性是一个对象,用来动态设置createElement函数第一个参数节点实例的css class。在模板中,我们这样绑定class:

1
<laytout :class="{'class-a': true}"></laytout>

在渲染函数中,就必须借助class

1
2
3
4
5
6
7
8
9
Vue.component('layout', {
render(createElement) {
return createElement('div', {
class: {
'class-a': true
}
}, this.$slots.default);
}
});

最终结果都渲染出:

1
<div class="class-a"></div>

style

style作用与class类似:

1
2
3
4
5
6
7
8
9
Vue.component('layout', {
render(createElement) {
return createElement('div', {
style: {
'fontSize': '14px'
}
}, this.$slots.default);
}
});

会渲染出:

1
<div style="font-size: 14px;"></div>

attrs

attrs的作用是给第一个参数的节点实例,传递普通的html属性,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="vue">
<base-input type="number" placeholder="请输入文本"></base-input>
</div>
<script type="text/javascript">
Vue.component('base-input', {
render(createElement) {
return createElement('input', {
attrs: this.$attrs
});
}
});

new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

最终会渲染出:

1
<input type="number" placeholder="请输入文本">

props

props在第一个参数,是一个自定义组件类型,且有声明props的时候有用,它用于指定要传递给第一个参数节点实例的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
<div id="vue">
<page>
<template v-slot:title>page title</template>
<p>lorem</p>
</page>
</div>
<script type="text/javascript">
Vue.component('heading', {
props: {
level: Number
},
render(createElement) {
return createElement('h'+this.level, this.$slots.default);
}
});


Vue.component('page', {
render(createElement) {
return createElement('div', [
createElement('heading', {
props: {
level: 1
}
}, this.$slots.title),
this.$slots.default
]);
}
});

new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

最终会渲染出:

1
<div><h1>page title</h1><p>lorem</p></div>

domProps

domProps用来给第一个参数的节点实例,指定dom对象的属性值,这里的dom对象属性,是特指那些能够通过拿到dom对象以后,就可直接访问的属性值,比如option元素的selected属性,input元素的value属性,其它的一些特性,比如placeholder这种,即使拿到input的dom对象,也无法直接访问(这类普通的html特性可通过dom对象的getAttribute()方法来获取),所以不属于domProps。常见的domProps: innerHTML innerText disabled checked selected value

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
<div id="vue">
<origin-html tag="div" :content="content"></origin-html>
</div>
<script type="text/javascript">

Vue.component('origin-html', {
props: {
tag: String,
content: String
},
render(createElement) {
return createElement(this.tag, {
domProps: {
innerHTML: this.content
}
});
}
});

new Vue({
el: '#vue',
data() {
return {
content: '<p>lorem</p>'
}
}
});
</script>

最终会渲染出:

1
<div><p>lorem</p></div>

domProps在第一个参数是标准的html元素的时候比较有用。attrs vs domProps,我觉得这两个对于描述渲染逻辑的角度不一样,attrs更多是以标签上的特性角度来影响渲染;domProps是从浏览器dom对象的角度来设置元素渲染;它们本身在表现是相通的,用attrs也可以实现domProps同样的功能,比如这个用attrs来同步input对象的value值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div id="vue">
<base-input type="text" :value="value"></base-input>
</div>
<script type="text/javascript">
Vue.component('base-input', {
attrs: this.$attrs,
render(createElement) {
return createElement('input');
}
});

let vm = new Vue({
el: '#vue',
data() {
return {
value: ''
}
}
});
</script>

on & nativeOn

这两个分别用来取代原来在模板中v-on指令的作用。比如我们原来在模板里,这样编写事件监听:

1
<div @click.left="clickLeft('aa',$event)" @click.native="nativeClick"></div>

那么在render函数中,要借助on以及nativeOn才能给第一个参数指定的节点实例,添加指定的事件监听:

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
<div id="vue">
<base-input-2 type="text" :value="value"></base-input-2>
</div>
<script type="text/javascript">
Vue.component('base-input', {
attrs: this.$attrs,
render(createElement) {
return createElement('input', {
on: {
"click": this.clickLeft
}
});
},
methods: {
clickLeft() {
console.log('base-input clickLeft');
}
}
});

Vue.component('base-input-2', {
attrs: this.$attrs,
render(createElement) {
return createElement('base-input', {
nativeOn: {
"click": this.nativeClick
}
});
},
methods: {
nativeClick() {
console.log('base-input-2 nativeClick');
}
}
});

let vm = new Vue({
el: '#vue',
data() {
return {
value: ''
}
}
});

</script>

注意点如下:
on的作用是用于添加监听,但它不是v-on,所以v-on以前那些修饰符的用法,它这里通通不支持;
nativeOn仅可用于组件,而不是原生的html标签;

on为了也能使用v-on指令中一些修饰符的功能,可以使用以下前缀来表示几个修饰符的作用:

修饰符 前缀
.passive &
.capture !
.once ~
.capture.once 或 .once.capture ~!

使用举例:

1
2
3
4
5
on: {
'!click': this.doThisInCapturingMode,
'~keyup': this.doThisOnce,
'~!mouseover': this.doThisOnceInCapturingMode
}

其它的修饰符而言,前缀都不是必需的,可以用特定js代码来实现:

修饰符 处理函数中的等价操作
.stop event.stopPropagation()
.prevent event.preventDefault()
.self if (event.target !== event.currentTarget) return
按键:.enter, .13 if (event.keyCode !== 13) return (对于别的按键修饰符来说,可将 13 改为另一个按键码)
修饰键:.ctrl, .alt, .shift, .meta if (!event.ctrlKey) return (将 ctrlKey 分别修改为 altKey、shiftKey 或者 metaKey)

实际上,有一个更简单的办法来找出vue底层如何实现这些修饰符的功能的。Vue提供了一个静态方法compile,这个方法不干别的,就是把字符串模板,转换成render函数的形式, 这样的话,假如我们想知道在render函数中,带有特定修饰符功能的事件监听怎么写,只需要简单的用compile函数测试一下就行了。比如我用compile函数编译一下这个模板,然后打印出来:

1
console.log(Vue.compile('<base-input @click.stop.prevent.enter="click" @keyup.native.space="keyup" />').render.toString())

最后可以得到这样一个render函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function anonymous() {
with (this) {
return _c('base-input', {
on: {
"click": function ($event) {
if (!$event.type.indexOf('key') && _k($event.keyCode, "enter", 13, $event.key, "Enter")) return null;
$event.stopPropagation();
$event.preventDefault();
return click($event)
}
}, nativeOn: {
"keyup": function ($event) {
if (!$event.type.indexOf('key') && _k($event.keyCode, "space", 32, $event.key, [" ", "Spacebar"])) return null;
return keyup($event)
}
}
})
}
}

在学习或使用render函数的过程中,碰到不知道该怎么在render函数中处理的逻辑时,把它等效的模板方式实现用Vue.compile处理打印一下,说不定会有奇效。

directives

directives是当需要对第一个参数指定的节点实例添加指令的时候有用,模板方式中我们可以这么用:

1
<input type="text" v-cmd:arg.prod="command">

render函数中,得这么用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Vue.component('base-input', {
render(createElement) {
return createElement('input', {
attrs: this.$attrs,
directives: [{
name: "cmd",
value: this.command,
expression: "command",
arg: "arg",
modifiers: {"prod": true}
}]
});
}
});

测试:

1
console.log(Vue.compile('<input type="text" v-cmd:arg.prod="command">').render.toString());

scopedSlots

scopedSlots用于向第一个参数指定的节点实例,传递作用域插槽。这个属性在第一个参数的组件类型,配置了作用域插槽的时候有用。scopedSlots是一个对象,它的键名是default或者是第一个参数组件定义中声明有的插槽名字。它的值是一个函数,用来创建作用域插槽对应的vnodes;这个函数内,可通过函数的参数,访问到组件内的数据。比如下面这个例子中的return createElement('span', state.user.firstName);,实际上是在home组件的渲染中,访问到了current-user的内部数据。曾经在学习插槽的时候,就学过作用域插槽最终会被转换为一个函数,所以能够在v-slot的绑定上使用解构,如:v-slot:default="{user: user}"。现在掌握了scopedSlots属性,就明白插槽那边的原理了。

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
<div id="vue">
<home></home>
</div>
<script type="text/javascript">
Vue.component('current-user', {
data() {
return {
user: {
firstName: 'Tom',
lastName: 'Green'
}
}
},
template: `<span><slot v-bind:user="user">{{ user.lastName }}</slot></span>`
});

Vue.component('home', {
render(createElement) {
return createElement('current-user', {
scopedSlots: {
default: state => {
return createElement('span', state.user.firstName);
}
}
})
}
});

let vm = new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

这个例子等价于:

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
<div id="vue">
<current-user>
<template v-slot:default="state"><span>{{state.user.firstName}}</span></template>
</current-user>
</div>
<script type="text/javascript">
Vue.component('current-user', {
data() {
return {
user: {
firstName: 'Tom',
lastName: 'Green'
}
}
},
template: `<span><slot v-bind:user="user">{{ user.lastName }}</slot></span>`
});

let vm = new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

scopedSlots这个属性用于向子组件传递作用域插槽相对应的,在render函数中,可以类似this.$slots一样,通过this.$scopedSlots来访问作用域插槽,与this.$slots不一样,this.$scopedSlots都是以函数调用的形式访问。基于这一点,上面的例子,可以把current-user组件,也改写为render函数的形式:

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
<div id="vue">
<home></home>
</div>
<script type="text/javascript">
Vue.component('current-user', {
data() {
return {
user: {
firstName: 'Tom',
lastName: 'Green'
}
}
},
render(createElement){
if(this.$scopedSlots.default) {
return createElement('span', this.$scopedSlots.default({user: this.user}));
} else {
return createElement('span', this.user.lastName);
}
}
});
Vue.component('home', {
render(createElement) {
return createElement('current-user', {
scopedSlots: {
default: state => {
return createElement('span', state.user.firstName);
}
}
})
}
});

let vm = new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

掌握scopedSlots以及this.$scopedSlots,就明白该怎么在render函数中使用插槽了。

slot

slot指定createElement创建的节点,将应用于父节点特定的slot当中,父节点的render函数中,通过this.$slots加上slot配置的名称,才能拿到这样的子节点。这是一个使用例子:

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
<div id="vue">
<page>
<template v-slot:header>header</template>
<template v-slot:footer>footer</template>
body
</page>
</div>
<script type="text/javascript">
Vue.component('layout', {
template: `
<div>
<header><slot name="header"></slot></header>
<main><slot></slot></main>
<header><slot name="footer"></slot></header>
</div>
`
});

Vue.component('page', {
render(createElement){
return createElement('layout', [
createElement('div', {
slot: 'header'
}, this.$slots.header),
createElement('div', {
slot: 'default'
}, this.$slots.default),
createElement('div', {
slot: 'footer'
}, this.$slots.footer),
])
}
});

new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

再看一个例子:

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
<div id="vue">
<page>
<p>lorem</p>
</page>
</div>
<script type="text/javascript">
Vue.component('layout', {
render(createElement){
return createElement('div', this.$slots.main);//注意此处不是this.$slots.default
}

// 等价于
// <div><slot name="main"></slot></div>
});

Vue.component('page', {
render(createElement){
return createElement('layout', this.$slots.default.map(vnode=>{
return createElement(vnode.tag, {
slot: 'main'
}, vnode.children)
}));
}
});

new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

key

key给第一个参数要创建的节点实例一个唯一标识。在vue中,所有vnode节点实例,都有唯一的key值,只要key值不变,vue就会选择复用节点实例,而不是重新创建。

ref

ref给第一个参数要创建的节点实例一个引用标识。通过this.$refs可访问有ref标识的节点实例。

refInFor

如果给不同的节点,使用了相同的ref名称,那么这个参数就要设置为true,这样通过this.$refs访问相同ref名称时,得到的就是一个数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<div id="vue">
<msg-list :items="[{msg: '1'}, {msg: '2'}]"></msg-list>
</div>
<script type="text/javascript">

Vue.component('msg-list', {
props: ['items'],
mounted() {
console.log(this.$refs.nodes);// [li, li]
},
render(createElement) {
return createElement('div', [createElement('ul', this.items.map(item =>
createElement('li', {ref: 'nodes', refInFor: true}, item.msg)
))]);
}
});

let vm = new Vue({
el: '#vue',
data() {
return {}
}
});
</script>

后续

render函数要点还没结束,下篇继续。