vue2.x学习笔记(二十三)
接着前面的内容:https://www.cnblogs.com/yanggb/p/12639440.html。
渲染函数&JSX
基础
vue推荐在绝大多数的情况下使用模板来创建html。然而在一些场景中,你真的需要javascript的完全编程能力。因此这时你就可以使用渲染函数,它比模板更接近编译器。
这里先深入一个简单的例子,这个例子里面的render函数十分实用,假设我们要生成一些带锚点的标题:
<h1> <a name="hello-world" href="#hello-world"> Hello world! </a> </h1>
对于上面的html,我们决定这样定义组件接口:
<anchored-heading :level="1">Hello world!</anchored-heading>
当开始写一个只能通过level这个prop动态生成标题(heading)的组件的时候,你可能很快想到要这样实现:
<script type="text/x-template" id="anchored-heading-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> </script>
Vue.component('anchored-heading', { template: '#anchored-heading-template', props: { level: { type: Number, required: true } } })
这里用模板并不是最好的选择:不但代码冗长,而且在每一个级别的标题中重复书写了<slot></slot>,在要插入锚点元素的时候还要再次重复。
虽然模板在大多数的组件中都非常好用,但是显然在这里并不合适。那么,我们尝试着使用render函数来重写上面的例子:
Vue.component('anchored-heading', { render: function (createElement) { return createElement( 'h' + this.level, // 标签名称 this.$slots.default // 子节点数组 ) }, props: { level: { type: Number, required: true } } })
这样看起来就简单得多了,至少代码精简了很多。但是使用render函数是需要非常熟悉vue的实例属性的,比如在这个例子中,你就需要知道,向组件中传递不带【v-slot】指令的子节点的时候,比如anchored-heading中的hello world,这些子节点被存储在组件实例中的【$slot.default】中。
节点、树以及虚拟dom
在深入渲染函数之前,了解一些浏览器的工作原理是非常必要且重要的。以下面这一段html为例:
<div> <h1>My title</h1> Some text content <!-- TODO: Add tagline --> </div>
当浏览器读到这些代码的时候,它就会建立一个dom节点树,来保持追踪所有的内容,就好像你会画一张家谱树来追踪家庭成员的发展一样。
这是上面的html对应的dom节点树。每个元素都对应的一个节点,每段文字也是对应着一个节点,甚至注释也是对应着一个节点,每一个节点就是页面的一个部分。就像家谱树一样,每个节点都可以有孩子节点(也就是说,每个部分都可以包含其它的一些部分)。
要去高效地更新所有的这些节点是十分困难的,传统的页面开发一般是通过手动操作dom节点来更新页面。而在vue中则不需要手动完成这项工作,只需要告诉vue你希望页面上的html是什么,这可以是在一个模板里:
<h1>{{ blogTitle }}</h1>
也可以是在渲染函数里:
render: function (createElement) { return createElement('h1', this.blogTitle) }
在这两种情况下,只要blogTitle发生了改变,vue都会自动保持页面的更新。
虚拟dom
vue是通过建立一个虚拟dom来追踪自己要如何改变真实的dom的,请仔细看这行代码:
return createElement('h1', this.blogTitle)
在这里,createElement函数实际上返回的并不是一个真实的dom元素。这个函数更准确的名字可能是creatNodeDescription,因为它所包含的信息会告诉vue页面上需要渲染什么样的节点,包括其子节点的描述信息。官方文档把这样的节点描述为【虚拟节点(virtual node)】,也常简写它为vnode。虚拟dom是我们对由vue组件树建立起来的整个vnode树的称呼。
createElement参数
接下来你需要熟悉的是如何在createElement函数中使用模板中的那些功能,这里是createElement接受的参数:
// @returns {VNode} createElement( // {String | Object | Function} // 一个 HTML 标签名、组件选项对象,或者 // resolve 了上述任何一种的一个 async 函数。必填项。 'div', // {Object} // 一个与模板中属性对应的数据对象。可选。 { // (详情见下一节) }, // {String | Array} // 子级虚拟节点 (VNodes),由 `createElement()` 构建而成, // 也可以使用字符串来生成“文本虚拟节点”。可选。 [ '先写一些文字', createElement('h1', '一则头条'), createElement(MyComponent, { props: { someProp: 'foobar' } }) ] )
深入数据对象
有一点需要特别注意:正如【v-bind:class】和【v-bind:style】在模板语法中会被特别对待一样,它们在vnode数据对象中也有对应的顶层字段。该对象也允许你绑定普通的html属性,也允许绑定像innerHTML这样的dom属性(会覆盖【v-html】指令)。
{ // 与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 }
完整示例
有了上面的这些知识,我们现在就可以完成我们最开始想要实现的组件:
var getChildrenTextContent = function (children) { return children.map(function (node) { return node.children ? getChildrenTextContent(node.children) : node.text }).join('') } Vue.component('anchored-heading', { render: function (createElement) { // 创建kebab-case风格的ID var headingId = getChildrenTextContent(this.$slots.default) .toLowerCase() .replace(/\W+/g, '-') .replace(/(^-|-$)/g, '') return createElement( 'h' + this.level, [ createElement('a', { attrs: { name: headingId, href: '#' + headingId } }, this.$slots.default) ] ) }, props: { level: { type: Number, required: true } } })
约束
组件树中的所有vnode必须是唯一的。这就意味着,下面的渲染函数是不合法的:
render: function (createElement) { var myParagraphVNode = createElement('p', 'hi') return createElement('div', [ // 错误:重复的VNode myParagraphVNode, myParagraphVNode ]) }
如果你真的需要重复很多次的元素/组件的话,可以使用工厂函数来实现。例如,下面的这个渲染函数就用了完全合法的方式渲染了20个相同的段落:
render: function (createElement) { return createElement('div', Array.apply(null, { length: 20 }).map(function () { return createElement('p', 'hi') }) ) }
使用javascript代替模板功能
只要在原生的javascript中可以轻松完成的操作,vue的渲染函数就不会提供专有的替代方法。比如,在模板中使用的【v-if】和【v-for】指令。
<ul v-if="items.length"> <li v-for="item in items">{{ item.name }}</li> </ul> <p v-else>No items found.</p>
这两个指令可以在渲染函数中用javascript中的if/else和map来重写:
props: ['items'], render: function (createElement) { if (this.items.length) { return createElement('ul', this.items.map(function (item) { return createElement('li', item.name) })) } else { return createElement('p', 'No items found.') } }
渲染函数中没有与【v-model】指令的直接对应,因此开发者必须自己实现相应的逻辑:
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) } } }) }
所有交互逻辑都要自己手动去实现,就是深入底层的代价。但是这样与使用【v-model】相比,则是可以让你更好地控制交互细节,其中的得失需要自己去衡量。
而对于【.passive】、【.capture】和【.once】这些事件修饰符,vue则是提供了相应的前缀可以用于【on】选项:
修饰符 | 前缀 |
.passive | & |
.capture | ! |
.once | ~ |
.capture.once或.once.capture | ~! |
例如:
on: { '!click': this.doThisInCapturingMode, '~keyup': this.doThisOnce, '~!mouseover': this.doThisOnceInCapturingMode }
对于所有的其他修饰符,私有前缀都不是必须的,因为你可以在事件处理函数中使用事件方法:
修饰符 | 处理函数中的等价操作 |
.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) |
这是一个使用所有修饰符的例子:
on: { keyup: function (event) { // 如果触发事件的元素不是事件绑定的元素 // 则返回 if (event.target !== event.currentTarget) return // 如果按下去的不是 enter 键或者 // 没有同时按下 shift 键 // 则返回 if (!event.shiftKey || event.keyCode !== 13) return // 阻止 事件冒泡 event.stopPropagation() // 阻止该元素默认的 keyup 事件 event.preventDefault() // ... } }
插槽
在render函数中我们可以通过【this.$slots】来访问静态插槽的内容,每个插槽都是一个vnode数组:
render: function (createElement) { // <div><slot></slot></div> return createElement('div', this.$slots.default) }
也可以通过【this.$scopedSlots】访问作用域插槽,每个作用域插槽都是一个返回若干vnode的函数:
props: ['message'], render: function (createElement) { // <div><slot :text="message"></slot></div> return createElement('div', [ this.$scopedSlots.default({ text: this.message }) ]) }
如果要用渲染函数向子组件中传递作用域插槽,可以利用vnode数据对象中的【scopeSlots】字段:
render: function (createElement) { return createElement('div', [ createElement('child', { // 在数据对象中传递 `scopedSlots` // 格式为 { name: props => VNode | Array<VNode> } scopedSlots: { default: function (props) { return createElement('span', props.text) } } }) ]) }
jsx
如果你写了很多的render函数,就可能会觉得下面这样的代码写起来很痛苦:
createElement( 'anchored-heading', { props: { level: 1 } }, [ createElement('span', 'Hello'), ' world!' ] )
特别是对应的模板如此简单的情况下:
<anchored-heading :level="1"> <span>Hello</span> world! </anchored-heading>
这就是为什么会有一个Babel插件,用于在vue中使用jsx语法。它可以让我们回到更接近于模板的语法上。
import AnchoredHeading from './AnchoredHeading.vue' new Vue({ el: '#demo', render: function (h) { return ( <AnchoredHeading level={1}> <span>Hello</span> world! </AnchoredHeading> ) } })
将h作为createElement的别名,是vue生态系统中的一个通用惯例,实际上也是jsx所要求的。从vue的Babel插件的3.4.0版本开始,vue会在es2015语法声明的含有jsx的任何方法和getter中(不是函数或箭头函数中)自动注入const h = this.$createElement,这样你就可以去掉(h)参数了。而对于更早版本的插件,如果h在当前作用域中不可用,应用就会报错。
函数式组件
之前创建的锚点标题组件是比较简单的,没有管理任何的状态,也没有监听任何传递给它的状态,也没有生命周期方法。实际上,它只是一个接受一些prop的函数。在这样的场景下,我们可以将组件标记为functional,这意味着它无状态(没有响应式数据),也没有实例(没有this上下文)。一个函数式组件就像这样:
Vue.component('my-component', { functional: true, // Props是可选的 props: { // ... }, // 为了弥补缺少的实例 // 提供第二个参数作为上下文 render: function (createElement, context) { // ... } })
这里要注意,在2.3.0之前的版本中,如果一个函数式的组件想要接收prop,则props选项是必须的。在2.3.0或以上的版本中,你可以省略props选项,所有组件上的attribute都会被自动隐式解析为prop。
而当使用函数式组件的时候,该引用将会是htmlelement,因为他们是无状态的也是无实例的。
在2.5.0以及以上的版本中,如果你使用了单文件组件,那么基于模板的函数式组件可以这样声明:
<template functional> </template>
组件需要的一切都是通过context参数来传递,它是一个包含以下字段的对象:
1.props:提供所有prop的对象。
2.children:vnode子节点的数组。
3.slots:一个函数,返回了包含所有插槽的对象。
4.scopedSlots:2.6.0+新增的一个暴露传入的作用域插槽的对象,也以函数的形式暴露普通插槽。
5.data:传递给组件的整个数据对象,作为createElement的第二个参数传入组件。
6.parent:对父组件的引用。
7.listeners:2.3.0+中新增的一个包含了所有父组件为当前组件注册的事件监听器的对象,这是data.on的一个别名。
8.injections:2.3.0+中新增的一个包含了应当被注入的属性的对象。
在添加了【functional:true】之后,需要更新我们的锚点标题组件的渲染函数,为其增加context参数,并将【this.$slots.default】更新为【context.children】,然后将【this.level】更新为【context.props.level】。
因为函数式组件只是函数,所以渲染的开销也低了很多。
在作为包装组件的时候它们也同样非常有用,比如,当你需要做这些的时候:
1.程序化地在多个组件中选择一个来代为渲染;
2.在将children、props或data传递给子组件之前操作它们。
下面是一个smart-list组件的例子,它能根据传入prop的值来代为渲染更具体的组件:
var EmptyList = { /* ... */ } var TableList = { /* ... */ } var OrderedList = { /* ... */ } var UnorderedList = { /* ... */ } Vue.component('smart-list', { functional: true, props: { items: { type: Array, required: true }, isOrdered: Boolean }, render: function (createElement, context) { function appropriateListComponent () { var items = context.props.items if (items.length === 0) return EmptyList if (typeof items[0] === 'object') return TableList if (context.props.isOrdered) return OrderedList return UnorderedList } return createElement( appropriateListComponent(), context.data, context.children ) } })
向子元素或子组件传递attribute和事件
在普通的组件中,没有被定义为prop的属性会自动添加到组件的根元素上,将已有的同名属性进行替换或与其进行智能合并。
然而函数式组件要求必须显式定义该行为:
Vue.component('my-functional-button', { functional: true, render: function (createElement, context) { // 完全透传任何attribute、事件监听器、子节点等。 return createElement('button', context.data, context.children) } })
通过向createElement传入context.data作为第二个参数,我们就把my-functonal-button上面所有的attribute和事件监听器都传递下去了。事实上这是非常透明的,以至于那些事件甚至并不要求【.native】修饰符。
如果你使用基于模板的函数式组件,那么你还需要手动添加attribute和监听器。因为我们可以访问到其独立的上下文内容,所以我们可以使用【data.attrs】传递任何html属性,也可以使用【listeners】(即【data.on】的别名)传递任何事件监听器。
<template functional> <button class="btn btn-primary" v-bind="data.attrs" v-on="listeners"> <slot/> </button> </template>
【slots()】和【children】的对比
你可能会想知道为什么同时需要【slots()】和【children】,你会觉得【slots().default】不是和【children】类似的吗?事实上,在一些场景中(比如带有子节点的函数式组件)是同时需要这两个的了。
<my-functional-component> <p v-slot:foo> first </p> <p>second</p> </my-functional-component>
对于这个组件,【children】会给你两个段落标签,而【slots().default】只会传递第二个匿名段落标签,【slots().foo】则是传递第一个具名段落标签。同时拥有【children】和【slots()】可以让你选择是让组件去感知某个插槽机制,还是简单地通过传递children去移交给其他组件去处理。
模板编译
vue的模板实际上是被编译成了渲染函数,这是一个vue的实现细节(底层)。虽然使用vue开发业务的开发者通常并不需要关心这些内容,但是如果你想看看模板的功能具体是怎样被编译的,你可能会发现这是非常有意思的,而且对于你自身的成长也有很大的帮助。
"我还是很喜欢你,像星辰闪耀苍穹顶,不甘孤寂。"