vue中的渲染函数/jsx和插槽slot

1. 前言

这段时间重新看了下vue的文档,发现还有很多使用使用频率不是那么高,或者简单使用过但不那么清晰的知识点。今天我们就来看一下其中的渲染函数render,jsx语法和插槽slot的用法。

2. 模板语法的弊端

熟悉vue单文件组件写法的同学们都知道,vue文件的html部分是由<template></template>组成,这种方法使用起来比较简单,配合vue指令可以实现大多数情况下的需求。不过还是存在模板语法不方便的时候,比如需要开发一个组件,这个组件要根据父组件传过来的值来选择渲染的html标签,来看一个示例:

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
// hLabel.vue
<template>
<h1 v-if="level === 1">
<slot></slot>
</h1>
<h2 v-else-if="level === 2">
<slot></slot>
</h2>
<h3 v-else-if="level === 3">
<slot></slot>
</h3>
<h4 v-else-if="level === 4">
<slot></slot>
</h4>
<h5 v-else-if="level === 5">
<slot></slot>
</h5>
<h6 v-else-if="level === 6">
<slot></slot>
</h6>
</template>
<script>
export default{
props: {
level:{
type: Number
}
}
}
</script>

上面这个组件虽然能够实现根据level值来渲染对应的<h1>,<h2>...标签,但是冗余代码也很多,而且在每个级别的标题标签中都有一个<slot>标签。

为了解决这个问题,我们需要用到vue中的渲染函数render

3. 渲染函数render

先来看下如何使用render函数来实现上面要求的组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// hLabel.vue
<script>
export default {
props: {
level: {
type: Number,
}
},
render: function(createElement){
return createElement(
'h' + this.level, // 标签名称,根据父组件传入的level值确定
this.$slots.default // 子节点数组
)
}
}
</script>

上面的代码十分精简,通过render函数就可以渲染一个标签模板。同时如果此时需要向组件中传递原来<slot>接收的内容,这时候要使用$slots.default,关于slot的用法我们后面会专门提及。

vue给render函数提供了一个参数createElement,这个参数也是一个函数方法,接受一定的参数,返回的是虚拟DOM(Virtual Dom) VNode,而且在vue中我们一般约定可以把createElement简写为h。下面来看下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
render: function(createElement){
// @returns {VNode}
createElement(
// {String | Object | Function}
// 一个 HTML 标签名、组件选项对象,或者
// resolve 了上述任何一种的一个 async 函数。必填项。
'div',

// {Object}
// 一个与模板中属性对应的数据对象。可选。
{
// 主要是html模板标签中的属性值的写法,下面单独介绍
},

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

来单独看下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,这个属性是当createElement渲染的是一个组件时使用
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
}

通过上面的示例,我们可以看到正如 v-bind:class 和 v-bind:style 在模板语法中会被特别对待一样,它们在 VNode 数据对象中也有对应的顶层字段。该对象也允许你绑定普通的 HTML 特性,也允许绑定如 innerHTML 这样的 DOM 属性 (这会覆盖 v-html 指令)。

相信说到这里大家肯定会觉得render的使用方法太麻烦了,如果需要写一个稍微复杂点的html模版,那我的render函数要写到死了,所以自然就引出了jsx的使用。

4. jsx语法

相信写过react的人对这种语法肯定不会陌生。通过babel插件的支持,在vue的render函数中也可以直接使用jsx语法。如果你使用的是vue-cli 3.x创建的项目,那么不需要任何配置,直接就把jsx用起来吧。

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
// hLabel.vue
<script>
export default{
props: {
level: {
type: Number,
}
},
methods: {
clickHandler(){

},
nativeClickHandler(){

}
},
render:function(h) { // createElement约定可简写为h
let tag = `h${this.level}`
return (
<tag
key="key"
ref="ref"
id='title'
class={{'foo':true,, 'bar':false}}
style={{margin: '10px', color:'red'}}
onClick={this.clickHandler}
nativeOnClick={this.nativeClickHandler} // 监听组件内的原生事件
>{this.$slots.default}
</tag>
)
}
}
</script>

上面的例子给出了jsx语法和在标签上添加属性的一个简单示例。不过如果我们使用了render函数之后vue中自带的一些指令就不在生效了,包括v-if,v-forv-model,需要我们自己实现。

5. render函数中vue指令的实现

v-if和v-for:

1
2
3
4
<ul v-if="items.length">
<li v-for="item in items">{{ item.name }}</li>
</ul>
<p v-else>No items found.</p>
1
2
3
4
5
6
7
8
9
10
11
// 在渲染函数中需要使用 if/else 和 map 来重写
props: ['items'],
render: function (h) {
if (this.items.length) {
return h('ul', this.items.map(function (item) {
return h('li', item.name)
}))
} else {
return h('p', 'No items found.')
}
}

v-model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
props: ['value'],
render: function (createElement) {
var self = this
return createElement('input', {
domProps: {
value: self.value
},
on: {
input: function (event) {
self.$emit('input', event.target.value)
}
}
})
}

其实上面的代码就是vue中v-model指令双向绑定的原理,只是v-model对不同的绑定元素做了兼容处理。同时v-model也是可以绑定在组件上的,具体用法可以点击这里查看

同时在vue中绑定事件时,事件和按键修饰符也不能使用了,因为这些事件修饰符都是vue替我们做了处理的语法糖。关于如何在render函数中使用事件/按键修饰符比较简单,可以去官方文档查看。

6. 函数式组件

如果我们所需的组件比较简单,没有管理任何状态,也没有监听任何传递给它的状态,也没有生命周期方法。实际上,它只是一个接受一些 prop 的函数。在这样的场景下,我们可以将组件标记为functional,这意味它无状态 (没有响应式数据),也没有实例 (没有 this 上下文)。

一个函数式组件就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
export default{
functional: true, // 添加属性functional: true,表示该组件为函数式组件
// Props 是可选的
props: {
// ...
},
// 为了弥补缺少的实例
// 提供第二个参数作为上下文
render: function (createElement, context) {
// ...
},
}
</script>

在2.5.0及以上版本的单文件组件,那么基于模板的函数式组件可以这样声明:

1
2
<template functional>
</template>

因为函数式组件是无状态的,也没有this上下文,没有data等属性,所以如果所需要的数据都是由render函数的第二个参数context获得的:

  • props:提供所有 prop 的对象
  • data:传递给组件的整个数据对象,作为 createElement 的第二个参数传入组件
  • children: VNode 子节点的数组
  • parent:对父组件的引用
  • slots: 一个函数,返回了包含所有插槽的对象
  • scopedSlots: (2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。
  • listeners: (2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是 data.on 的一个别名。
  • injections: (2.3.0+) 如果使用了 inject 选项,则该对象包含了应当被注入的属性。

在改成函数式组件之后,需要修改一下我们组件的渲染函数,为其增加context参数,并且如果有this.$slots.default 要改为context.children,然后将this.level 要改为context.props.level等。

7. 插槽, $slots 和 $scopedSlots

上面我们在render函数中,反复看到插槽slot的使用。所以这次也来顺便看下slot到底是什么的东西。

插槽内容:

Vue 实现了一套内容分发的 API,将 <slot> 元素作为承载分发内容的出口,即可以将在组件内填写的内容渲染在子组件的slot之间。

1
2
3
4
5
6
7
8
9
10
11
<!-- 父组件 -->
<navigation-link url="/profile">
Your Profile
</navigation-link>
<!-- navigation-link组件 -->
<a
v-bind:href="url"
class="nav-link"
>
<slot></slot>
</a>

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

插槽内可以包含任何模板代码,包括 HTML或者其他组件:

1
2
3
4
5
6
7
8
9
10
11
12
<navigation-link url="/profile">
<!-- 添加一个 Font Awesome 图标 -->
<span class="fa fa-user"></span>
Your Profile
</navigation-link>

<!-- 插槽内为组件 -->
<navigation-link url="/profile">
<!-- 添加一个图标的组件 -->
<font-awesome-icon name="user"></font-awesome-icon>
Your Profile
</navigation-link>

编译作用域:

编译作用域是指在引用组件内部写的内容和子组件内部的内容,所能获取的都只能是其当前作用域下的值。有一个原则是父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。结合代码来看:

1
2
3
4
5
<!-- 如果想在插槽中使用数据user -->
<!-- user必须是navigation-link组件坐在的作用域可以访问到的值 -->
<navigation-link url="/profile">
Logged in as {{ user.name }}
</navigation-link>

下面是个访问不到错误例子:

1
2
3
4
5
6
<!-- 这里是访问不到url的 -->
<!-- 因为当前的url值"/profile"是在navigation-link组件内部定义的 -->
<!-- 在navigation-link组件所在的作用域,是访问不到url的 -->
<navigation-link url="/profile">
Clicking here will send you to: {{ url }}
</navigation-link>

后备内容和具名插槽:

直接看后备内容的示例代码:

1
2
3
4
5
6
7
8
9
<!-- 父组件 -->
<submit-button>
Save
</submit-button>

<!-- submit-button组件 -->
<button type="submit">
<slot>Submit</slot>
</button>

后备内容就是说我在slot之间也写入内容作为后备内容,当如果在父组件内使用submit-button且之间有内容时,会优先显示这个值。如果submit-button之间没有内容时,则会显示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
<!-- 父组件 -->
<base-layout>
<template v-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 #footer>
<p>Here's some contact info</p>
</template>
</base-layout>
<!-- base-layout 组件 -->
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>

上面是具名插槽的用法示例。如果需要指定多个插槽的渲染内容,可以给slot添加name属性,同时在向插槽提供内容的时候,可以使用template包裹住内容,而且在template之上写入v-slot指定,并且以参数的形式在v-slot上提供要渲染的插槽的名称。这样template的内容就可以渲染到指定name的slot之内。template上的v-slot: name可以简写为#name。如果template没有指定名称的话,默认name为default。

作用域插槽:

上面我们说到,插槽是有作用域的,父级模板里的内容只能访问到父级模板的作用域,子级组件内的内容只能在子级的作用域内渲染。假如我想在父级模板内使用子级组件内的值如何实现呢,这个时候就需要用到作用于插槽,:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- 错误示范 -->
<!-- 父组件想要使用current-user组件内的值user -->
<current-user>
{{ user.firstName }}
</current-user>

<!-- current-user组件 -->
<span>
<slot>{{ user.lastName }}</slot>
</span>

<!-- 正确示范 -->
<current-user>
<template v-slot:default="slotProps">
{{ slotProps.user.firstName }}
</template>
</current-user>
<span>
<slot v-bind:user="user">
{{ user.lastName }}
</slot>
</span>

$slots:

vue中用来访问被插槽分发的内容的api,相当于模板中的<slot></slot>。每个具名插槽有其相应的属性 (例如:v-slot:foo 中的内容将会在 vm.$slots.foo 中被找到)。default 属性包括了所有没有被包含在具名插槽中的节点,或 v-slot:default 的内容。

$scopedSlots:

用来访问作用域插槽,相当可以给<slot>提供值的插槽作用域。对于包括默认 slot 在内的每一个插槽,该对象都包含一个返回相应 VNode 的函数。

8. 总结

以上就是这次我要介绍的内容了,虽然比较基础,但是基本用法都有涉及到,希望对大家之后的开发或者面试都能有所帮助。

9. 参考文章

vue官方文档:渲染函数&jsx

vue官方文档:插槽

vue官方api

在vue中使用jsx语法

vue jsx 不完全指北


作者简介: 宫晨光,人和未来大数据前端工程师。