什么是虚拟DOM
本章我们就来探索Vue.js用于实现Virtual Dom的Render函数用法,在介绍Render函数前,我们先来看看什么是Virtual Dom。
Virtual Dom并不是真正意义上的DOM,而是一个轻量级的JavaScript对象,在状态发生变化时,Virtual Dom会进行Diff 运算,来更新只需要被替换的DOM,而不是全部重绘。
与DOM操作相比,Virtual Dom是基于JavaScript计算的,所以开销会小很多。
vNode对象通过一些特定的选项描述了真实的DOM结构。
在Vue.js 2中,Virtual Dom就是通过一种VNode类表达的,每个DOM元素或组件都对应一个VNode对象,
VNode 主要可以分为如下几类,
- TextVNode 文本节点。
- ElementVNode 普通元素节点。
- ComponentVNode 组件节点。
- EmptyVNode 没有内容的注释节点。
- CloneVNode 克隆节点,可以是以上任意类型的节点,唯一的区别在于isCloned属性为true。
初步使用
自定义锚点组件
<div id="app">
<div>
<anchor :level="2" title="title-01">
slot-标题-1
</anchor>
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<br /> <br /> <br /> <br /> <br /> <br /> <br /> <br /> <br />
<p id="title-01">标题-1</p>
</div>
</div>
<script src="../lib/vue.2.6.11.js"></script>
<script>
Vue.component('anchor', {
props: {
level: {
type: Number,
required: true
},
title: {
type: String,
default: ''
}
},
render: function (createElement) {
return createElement(
'h' + this.level,
[
createElement(
'a',
{
domProps: {
href: '#' + this.title
}
},
this.$slots.default
)
]
)
}
});
var app = new Vue({
el: '#app'
})
</script>
</body>
Render函数通过createElement参数来创建Virtual Dom,结构精简了很多。在第7章组件中介绍slot时,有提到过访问slot的用法,使用场景就是在Render函数。
createElement用法
基本参数
createElement构成了Vue Virtual Dom的模板,它有3个参数:
createElement(
// {String | Object | Function}
// 一个HTML标签,组件选项,或一个函数
//必须Return上述其中一个
'div',
// {Object}
// 一个对应属性的数据对象,可选
// 您可以在template中使用
{
// 稍后详细介绍
},
// {String | Array}
// 子节点(VNodes),可选
[
createElement('h1', 'hello world'),
createElement(MyComponent, {
props: {
someProp: 'foo'
}
}),
'bar'
]
)
-
第一个参数必选,可以是一个HTML标签,也可以是一个组件或函数;
-
第二个是可选参数,数据对象,在template中使用。
-
第三个是子节点,也是可选参数,用法一致。
对于第二个参数“数据对象”,具体的选项如下:
{
//和v-bind:class一样的API
'class': {
foo: true,
bar: false
},
//和v-bind:style一样的API
style: {
color: 'red',
fontSize: '14px'
},
// 正常的HTML特性
attrs: {
id: 'foo'
id: 'foo'
},
//组件props
props: {
myProp: 'bar'
},
// DOM属性
domProps: {
innerHTML: 'baz'
},
// 自定义事件监听器"on"
//不支持如v-on:keyup.enter的修饰器
// 需要手动匹配 keyCode
on: {
click: this.clickHandler
},
// 仅对于组件,用于监听原生事件
// 而不是组件使用vm.$emit触发的自定义事件
nativeOn: {
click: this.nativeClickHandler
},
// 自定义指令
directives: [
{
name: 'my-custom-directive',
value: '2'
expression: '1 + 1',
arg: 'foo',
modifiers: {
bar: true
}
}
],
// 作用域slot
// { name: props => VNode | Array<VNode> }
scopedSlots: {
default: props => h('span', props.text)
},
// 如果子组件有定义slot的名称
slot: 'name-of-slot'
// 其他特殊顶层属性
key: 'myKey',
ref: 'myRef'
}
示例:
<body>
<div id="app">
<div>
<ele-1></ele-1>
<ele-2></ele-2>
</div>
</div>
<script src="../lib/vue.2.6.11.js"></script>
<script>
Vue.component('ele-1', {
template: '
<div id="element-1" :class="{show: show}" @click="handleClick">文本内容</div>',
data: function () {
return {
show: true
}
},
methods: {
handleClick: function () {
console.log('clicked!');
}
}
});
Vue.component('ele-2', {
data: function () {
return {
show: true
}
},
render: function (createElement) {
return createElement('div'
, {
// 动态绑定class,同:class
class: {
'show': this.show
},
// 普通html 特性
attrs: {
id: 'element-2'
},
//给div绑定click事件
on: {
click: this.handleClick
}
}
, "文本内容")
},
methods: {
handleClick: function () {
console.log('clicked!');
}
}
});
var app = new Vue({
el: '#app'
})
</script>
</body>
示例中,使用template
和render()
两种方式实现相同的效果
约束
有的组件树中,如果VNode是组件或含有组件的slot,那么VNode必须唯一。
对于重复渲染多个组件(或元素)的方法有很多
通过一个循环和工厂函数就可以渲染5个重复的子组件Child。对于含有组件的slot,复用就要稍微复杂一点了,需要将slot的每个子节点都克隆一份。
使用JavaScript代替模板功能
在Render函数中,不再需要Vue内置的指令,比如v-if、v-for,当然,也没办法使用它们。无论要实现什么功能,都可以用原生JavaScript
函数化组件
Vue.js提供了一个functional的布尔值选项,设置为true可以使组件无状态和无实例,也就是没有data和this上下文。这样用render函数返回虚拟节点可以更容易渲染,因为函数化组件只是一个函数,渲染开销要小很多。
使用函数化组件时,Render函数提供了第二个参数context来提供临时上下文。组件需要的data、props、slots、children、parent都是通过这个上下文来传递的,比如this.level要改写为context.props.level,this.$slots.default改写为context.children。
函数化组件在业务中并不是很常用,而且也有其他类似的方法来实现,比如上例也可以用组件的is特性来动态挂载。总结起来,函数化组件主要适用于以下两个场景:
- 序化地在多个组件中选择一个。
- 在将children, props, data 传递给子组件之前操作它们。
<body>
<div id="app">
<smart-item :data="data"></smart-item>
<button @click="change('img')">切换为图片组件</button>
<button @click="change('video')">切换为视频组件</button>
<button @click="change('text')">切换为文本组件</button>
</div>
<script src="../lib/vue.2.6.11.js"></script>
<script>
// 图片组件选项
var ImgItem = {
props: ['data'],
render: function (createElement) {
return createElement('div', [
createElement('p', '图片组件'),
createElement('img', {
attrs: {
src: this.data.url
}
})
]);
}
};
// 视频组件选项
var VideoItem = {
props: ['data'],
render: function (createElement) {
return createElement('div', [
createElement('p', '视频组件'),
createElement('video', {
attrs: {
src: this.data.url,
controls: 'controls',
autoplay: 'autoplay'
}
})
]);
}
};
// 纯文本组件选项
var TextItem = {
props: ['data'],
render: function (createElement) {
return createElement('div', [
createElement('p', '纯文本组件'),
createElement('p', this.data.text)
]);
}
};
Vue.component('smart-item', {
//函数化组件
functional: true,
render: function (createElement, context) {
// 根据传入的数据,智能判断显示哪种组件
function getComponent() {
var data = context.props.data;
// 判断 prop: data的type 字段是属于哪种类型的组件
if (data.type === 'img') return ImgItem;
if (data.type === 'video') return VideoItem;
return TextItem;
}
return createElement(
getComponent(),
{
props: {
//把smart-item的prop: data传给上面智能选择的组件
data: context.props.data
}
},
context.children
)
},
props: {
data: {
type: Object,
required: true
}
}
})
var app = new Vue({
el: '#app',
data: {
data: {}
},
methods: {
// 切换不同类型组件的数据
change: function (type) {
if (type === 'img') {
this.data = {
type: 'img',
url: 'https://raw.githubusercontent.com/iview/iview/master/assets/logo.png'
}
} else if (type === 'video') {
this.data = {
type: 'video',
url: 'http://vjs.zencdn.net/v/oceans.mp4'
}
} else if (type === 'text') {
this.data = {
type: 'text',
content: '这是一段纯文本'
}
}
}
},
created: function () {
// 初始化时,默认设置图片组件的数据
this.change('img');
}
})
</script>
</body>
JXS
使用Render函数最不友好的地方就是在模板比较简单时,写起来也很复杂,而且难以阅读出DOM结构,尤其当子节点嵌套较多时,嵌套的createElement就像盖楼一样一层层延伸下去。
为了让Render函数更好地书写和阅读,Vue.js提供了插件babel-plugin-transform-vue-jsx来支持JSX语法。
JSX是一种看起来像HTML,但实际是JavaScript的语法扩展,它用更接近DOM结构的形式来描述一个组件的UI和状态信息,最早在React.js里大量应用。
示例:使用Render函数开发可排序的表格组件
html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>可排序的表格组件</title>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<div id="app" v-cloak>
<button @click="handleAddData">添加数据</button>
<v-table :data="data" :columns="columns"></v-table>
</div>
<script src="../../lib/vue.2.6.11.js"></script>
<script src="table.js"></script>
<script src="index.js"></script>
</body>
</html>
index.js
var app = new Vue({
el: '#app',
data: {
columns: [
{
title: '姓名',
key: 'name'
},
{
title: '年龄',
key: 'age',
sortable: true
},
{
title: '出生日期',
key: 'birthday',
sortable: true
},
{
title: '地址',
key: 'address'
}
],
data: [
{
name: '王小明',
age: 18,
birthday: '1999-02-21',
address: '北京市朝阳区芍药居'
},
{
name: '张小刚',
age: 25, age: 25,
birthday: '1992-01-23',
address: '北京市海淀区西二旗'
},
{
name: '李小红',
age: 30,
birthday: '1987-11-10',
address: '上海市浦东新区世纪大道'
},
{
name: '周小伟',
age: 26,
birthday: '1991-10-10',
address: '深圳市南山区深南大道'
}
]
},
methods: {
handleAddData: function () {
this.data.push({
name: '刘小天',
age: 19,
birthday: '1998-05-30',
address: '北京市东城区东直门'
});
}
}
});
table.js
Vue.component('vTable', {
render: function (h) {
var _this = this;
//数据行
var trs = [];
this.currentData.forEach(function (row) {
var tds = [];
_this.currentColumns.forEach(function (cell) {
tds.push(h('td', row[cell.key]));
});
trs.push(h('tr', tds));
});
//表头行
var ths = [];
this.currentColumns.forEach(function (col, index) {
if (col.sortable) {
ths.push(h('th', [
h('span', col.title),
// 升序
h('a', {
class: {
on: col._sortType === 'asc'
},
on: {
click: function () {
_this.handleSortByAsc(index)
}
}
}, '↑'),
// 降序
h('a', {
class: {
on: col._sortType === 'desc'
},
on: {
click: function () {
_this.handleSortByDesc(index)
}
}
}, '↓')
]));
} else {
ths.push(h('th', col.title));
}
});
return h('table', [
h('thead', [
h('tr', ths)
]),
h('tbody', trs)
])
},
props: {
columns: {
type: Array,
default: function () {
return [];
}
},
data: {
type: Array,
default: function () {
return [];
}
}
},
data: function () {
return {
/*
为了让排序后的columns和data不影响原始数据,
给v-table组件的data选项添加两个对应的数据,
组件所有的操作将在这两个数据上完成,不对原始数据做任何处理
*/
currentColumns: [],
currentData: []
}
},
methods: {
makeColumns: function () {
this.currentColumns = this.columns.map(function (col, index) {
//添加一个字段标识当前列排序的状态,后续使用
col._sortType = 'normal';
//添加一个字段标识当前列在原始数组中的索引,后续使用
col._index = index;
return col;
});
},
makeData: function () {
this.currentData = this.data.map(function (row, index) {
//添加一个字段标识当前行在原始数组中的索引,后续使用
row._index = index;
return row;
});
},
handleSortByAsc: function (index) {
var key = this.currentColumns[index].key;
this.currentColumns.forEach(function (col) {
col._sortType = 'normal'; //排序前,先将所有列的排序状态都重置为normal,
});
this.currentColumns[index]._sortType = 'asc';
this.currentData.sort(function (a, b) {
return a[key] > b[key] ? 1 : -1;
});
},
handleSortByDesc: function (index) {
var key = this.currentColumns[index].key;
this.currentColumns.forEach(function (col) {
col._sortType = 'normal'; //排序前,先将所有列的排序状态都重置为normal,
});
this.currentColumns[index]._sortType = 'desc';
this.currentData.sort(function (a, b) {
return a[key] < b[key] ? 1 : -1;
});
}
},
mounted() {
// v-table 初始化时调用
this.makeColumns();
this.makeData();
},
watch: {
/**
* 当渲染完表格后,父级修改了data数据,
* 比如增加或删除,v-table的currentData也应该更新,
* 如果某列已经存在排序状态,更新后应该直接处理一次排序。
*/
data: function () {
this.makeData();
//通过遍历currentColumns来找出是否按某一列进行过排序,
//如果有,就按照当前排序状态对更新后的数据做一次排序操作
var sortedColumn = this.currentColumns.filter(function (col) {
return col._sortType !== 'normal';
});
if (sortedColumn.length > 0) {
if (sortedColumn[0]._sortType === 'asc') {
this.handleSortByAsc(sortedColumn[0]._index);
} else {
this.handleSortByDesc(sortedColumn[0]._index);
}
}
}
}
});
style.css
[v-cloak]{
display: none;
}
table{
100%;
margin-bottom: 24px;
border-collapse: collapse;
border-spacing: 0;
empty-cells: show;
border: 1px solid #e9e9e9;
}
table th{
background: #f7f7f7;
color: #5c6b77;
font-weight: 600;
white-space: nowrap;
}
table td, table th{
padding: 8px 16px;
border: 1px solid #e9e9e9;
text-align: left;
}
table th a{
display: inline-block;
margin: 0 4px;
cursor: pointer;
}
table th a.on{
color: #3399ff;
}
table th a:hover{
color: #3399ff;
}
示例:留言列表
与之前的几个实战案例不同的是,留言列表更偏向于业务,而之前的实战(数字输入框、标签页、表格)都是独立的功能组件
出现问题
按照原书的代码,会出现如下bug:
Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value"
大概意思是:通过props传递给子组件的value,不能在子组件内部修改props中的value值。
需要定义一个本地的 data 属性并将这个 prop 用作其初始值,同步对组件的修改,再通知父组件更新:
解决方案:
Vue.component('vTextarea', {
props: {
value: {
type: String,
default: ''
}
},
data: function () {
return {
currValue: this.value
}
},
watch: {
currValue: function (val) {
this.$emit('input', val);
},
value: function (val) {
this.currValue = val;
}
},
render: function (h) {
var _this = this;
return h('div', [
h('span', '留言内容:'),
h('textarea', {
...
on: {
input: function (event) {
_this.currValue = event.target.value;
_this.$emit('input', event.target.value);
}
}
})
]);
},
-
定义一个变量
data.currValue
,并用props.value
初始化, -
props.value
的值是单向地由父组件流向子组件,并且是随着父组件的变量data.message
变化而变化 -
data.currValue
与父组件的变量data.message
,没有任何关系,两者相互独立 -
data.currValue
与父组件的变量data.message
其中任何一个值发生变化,要通知对方,可以在子组件中添加监听
watch
:
watch: {
currValue: function (val) {
this.$emit('input', val);
},
value: function (val) {
this.currValue = val;
}
}
5 . $emit('input', val)
: input
事件与 v-model
结合可以自动修改绑定值
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>留言列表</title>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<div id="app" v-cloak style=" 500px;margin: 0 auto;">
<div class="message">
<v-input v-model="username"></v-input>
<v-textarea v-model="message" ref="message"></v-textarea>
<button @click="handleSend">发布</button>
</div>
<list :list="list" @reply="handleReply"></list>
</div>
<script src="../../lib/vue.2.6.11.js"></script>
<script src="input.js"></script>
<script src="list.js"></script>
<script src="index.js"></script>
</body>
</html>
index.js
var app = new Vue({
el: '#app',
data: {
username: '',
message: '',
list: []
},
methods: {
handleSend: function () {
this.list.push({
name: this.username,
message: this.message
});
this.message = '';
}
},
methods: {
handleSend: function () {
if (this.username === '') {
window.alert('请输入昵称');
return;
}
if (this.message === '') {
window.alert('请输入留言内容');
return;
}
this.list.push({
name: this.username,
message: this.message
});
this.message = '';
},
handleReply: function (index) {
var name = this.list[index].name;
this.message = '回复@' + name + ':';
this.$refs.message.focus();
}
}
});
input.js
Vue.component('vInput', {
props: {
value: {
type: [String, Number],
default: ''
}
},
data: function () {
return {
currValue: this.value
}
},
render: function (h) {
var _this = this;
return h('div', [
h('span', '昵称:'),
h('input', {
attrs: {
type: 'text'
},
domProps: {
value: this.currValue
},
on: {
//使用v-model:动态绑定value,并且监听input事件,把输入的内容通过$emit('input')派发给父组件。
input: function (event) {
_this.currValue = event.target.value;
_this.$emit('input', event.target.value);
}
}
})
]);
}
});
Vue.component('vTextarea', {
props: {
value: {
type: String,
default: ''
}
},
data: function () {
return {
currValue: this.value
}
},
render: function (h) {
var _this = this;
return h('div', [
h('span', '留言内容:'),
h('textarea', {
attrs: {
placeholder: '请输入留言内容'
},
domProps: {
value: this.currValue
},
ref: 'message',
on: {
input: function (event) {
_this.currValue = event.target.value;
_this.$emit('input', event.target.value);
}
}
})
]);
},
methods: {
focus: function () {
this.$refs.message.focus();
}
},
watch: {
currValue: function (val) {
this.$emit('input', val);
},
value: function (val) {
this.currValue = val;
}
},
});
list.js
Vue.component('list', {
props: {
list: {
type: Array,
default: function () {
return [];
}
}
},
render: function (h) {
var _this = this;
var list = [];
this.list.forEach(function (msg, index) {
var node = h('div', {
attrs: {
class: 'list-item'
}
}, [
h('span', msg.name + ':'),
h('div', {
attrs: {
class: 'list-msg'
}
}, [
h('p', msg.message),
h('a', {
attrs: {
class: 'list-reply'
},
on: {
click: function () {
_this.handleReply(index);
}
}
}, '回复')
])
])
list.push(node); list.push(node);
});
if (this.list.length) {
return h('div', {
attrs: {
class: 'list'
},
}, list);
} else {
return h('div', {
attrs: {
class: 'list-nothing'
}
}, '留言列表为空');
}
},
methods: {
handleReply: function (index) {
this.$emit('reply', index);
}
}
});
style.css
[v-cloak]{
display: none;
}
*{
padding: 0;
margin: 0;
}
.message{
450px;
text-align: right;
}
.message div{
margin-bottom: 12px;
}
.message span{
display: inline-block;
100px;
vertical-align: top;
}
.message input, .message textarea{
300px;
height: 32px;
padding: 0 6px;
color: #657180;
border: 1px solid #d7dde4;
border-radius: 4px;
cursor: text;
outline: none;
}
.message input:focus, .message textarea:focus{
border: 1px solid #3399ff;
}
.message textarea{
height: 60px;
padding: 4px 6px;
}
.message button{
display: inline-block;
padding: 6px 15px;
border: 1px solid #39f;
border-radius: 4px;
color: #fff;
background-color: #39f;
cursor: pointer;
outline: none;
}
.list{
margin-top: 50px;
}
list-item{
padding: 10px;
border-bottom: 1px solid #e3e8ee;
overflow: hidden;
}
.list-item span{
display: block;
60px;
float: left;
color: #39f;
}
.list-msg{
display: block;
margin-left: 60px;
text-align: justify;
}
.list-msg a{
color: #9ea7b4;
cursor: pointer;
float: right;
}
.list-msg a:hover{
color: #39f;
}
.list-nothing{
text-align: center;
color: #9ea7b4;
padding: 20px;
}