为什么组件的Data必须是一个函数
来自官方文档的解释:
当 data 的值是一个对象时,它会在这个组件的所有实例之间共享。想象一下,假如一个 TodoList 组件的数据是这样的:
data: {
listTitle: '',
todos: []
}
我们可能希望重用这个组件,允许用户维护多个列表 (比如分为购物、心愿单、日常事务等)。这时就会产生问题。因为每个组件的实例都引用了相同的数据对象,更改其中一个列表的标题就会改变其它每一个列表的标题。增删改一个待办事项的时候也是如此。
取而代之的是,我们希望每个组件实例都管理其自己的数据。为了做到这一点,每个实例必须生成一个独立的数据对象。在 JavaScript 中,在一个函数中返回这个对象就可以了:
data: function () {
return {
listTitle: '',
todos: []
}
}
或
export default {
data () {
return {
foo: 'bar'
}
}
}
v-show&v-if
相同点:v-if 与 v-show 都可以动态控制 dom 元素显示隐藏
不同点:v-if 显示隐藏是将 dom 元素整个添加或删除,而 v-show 隐藏则是为该元素添加 css--display:none,dom 元素还在。
需要注意的是,当一个元素默认在 css 中加了 display:none 属性,这时通过 v-show 修改为 true 是无法让元素显示的。原因是显示隐藏切换,只是会修改element style 为 display:""或者 display:none,并不会覆盖掉或修改已存在的 css 属性。
更详细的区别:
1.手段:v-if 是动态的向 DOM 树内添加或者删除 DOM 元素;v-show 是通过设置 DOM 元素的 display 样式属性控制显隐;
2.编译过程:v-if 切换有一个局部编译/卸载的过程,切换过程中合适地销毁和重建内部的事件监听和子组件;v-show 只是简单的基于 css 切换;
3.编译条件:v-if 是惰性的,如果初始条件为假,则什么也不做;只有在条件第一次变为真时才开始局部编译(编译被缓存?编译被缓存后,然后再切换的时候进行局部卸载); v-show 是在任何条件下(首次条件是否为真)都被编译,然后被缓存,而且 DOM 元素保留;
4.性能消耗: v-if 有更高的切换消耗;v-show 有更高的初始渲染消耗;
5.使用场景: v-if 适合运营条件不大可能改变;v-show 适合频繁切换。
MVC、MVP、MVVM
常见的几种 MVVM 的实现方式
发布者-订阅者模式(backbone.js)
脏值检查(angular.js)
数据劫持(vue.js)
methods、computed、watch
触发执行的方式
1.computed 是在 HTML DOM 加载后马上执行的,如赋值;
2.methods 则必须要有一定的触发条件才能执行,如点击事件;
3.watch 呢?它用于观察 Vue 实例上的数据变动。对应一个对象,键是观察表达式,值是对应回调。值也可以是方法名,或者是对象,包含选项。
所以他们的执行顺序为:
默认加载的时候先 computed 再 watch,不执行 methods;
等触发某一事件后,则是先 methods 再 watch。
computed
computed 用来监控自己定义的变量,该变量不在 data 里面声明,直接在 computed 里面定义,然后就可以在页面上进行双向数据绑定展示出结果或者用作其他处理;
适用场景: computed 比较适合对多个变量或者对象进行处理后返回一个结果值,也就是数多个变量中的某一个值发生了变化则我们监控的这个值也就会发生变化
优点: 如果我们有一个计算属性,那么 Vue 会记住计算的属性所依赖的值。通过这样做,Vue 只会在监听到依赖变化时才重新计算值。否则,将返回以前缓存的值。只要 results 还没有发生改变,多次访问计算属性会立即返回之前的计算结果,而不必再次执行函数。总结:在 Vue 中计算属性是基于它们的依赖进行缓存的,而方法是不会基于它们的依赖进行缓存的。从而使用计算属性要比方法性能更好。
watch
watch 主要用于监控 vue 实例的变化,它监控的变量当然必须在 data 里面声明才可以,它可以监控一个变量,也可以是一个对象,但是我们不能监控对象中某个指定属性
data:{
a:1,
b:{
c:1
}
},
watch:{
//普通的watch监听,第一个参数为新值,第二个参数为旧值
a(val, oldVal){
console.log("a: "+val, oldVal);
},
//深度监听,可监听到对象、数组的变化
b:{
handler(val, oldVal){
console.log("b.c: "+val.c, oldVal.c);
},
deep:true //true 深度监听
}
}
双向绑定
双向绑定原理
vue 数据双向绑定是通过ES5 的 Object.defineProperty()进行数据劫持,并结合发布者-订阅者模式来实现的。
数据劫持主要通过
Object.defineProperty来实现
vue里的每个对象都有自己的get、set方法,这个方法可以通过Object.defineProperty来重写,它还可以来控制一个对象属性的一些特有操作,比如属性值、读写权、是否可以枚举等。
var Book = {};
var name = "";
Object.defineProperty(Book, "name", {
set: function (value) {
name = value;
console.log("你取了一个书名叫做" + value);
},
get: function () {
return "《" + name + "》";
},
});
Book.name = "vue权威指南"; // 打印:你取了一个书名叫做vue权威指南
console.log(Book.name); // 打印:《vue权威指南》
我们通过 Object.defineProperty( )设置了对象 Book 的 name 属性,对其 get 和 set 进行重写操作,顾名思义,get 就是在读取 name 属性这个值触发的函数,set 就是在设置 name 属性这个值触发的函数,所以当执行 Book.name = 'vue 权威指南' 这个语句时,控制台会打印出 "你取了一个书名叫做 vue 权威指南",紧接着,当读取这个属性时,就会输出 "《vue 权威指南》",因为我们在 get 函数里面对该值做了加工了。
附:Object.defineProperty里的属性可以有数据描述符和存储描述符两种,公用的可选键值:
-
configurable当且仅当该属性的
configurable键值为true时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。 默认为false。 -
enumerable当且仅当该属性的
enumerable键值为true时,该属性才会出现在对象的枚举属性中。 默认为false。
数据描述符还具有以下可选键值:
-
value该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。 默认为
undefined -
writable当且仅当该属性的
writable键值为true时,属性的值,也就是上面的value,才能被赋值运算符(en-US)改变。 默认为false。
存取描述符还具有以下可选键值:
-
get属性的 getter 函数,如果没有 getter,则为
undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入this对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。 默认为undefined -
set属性的 setter 函数,如果没有 setter,则为
undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的this对象。 默认为undefined
发布者订阅者模式
我们已经知道实现数据的双向绑定,首先要对数据进行劫持监听,所以我们需要设置一个监听器 Observer,用来监听所有属性。如果属性发上变化了,就需要告诉订阅者 Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器 Dep 来专门收集这些订阅者(有订阅器dep就是发布者订阅者模式,如果没有这一层那就是简单的观察者模式),然后在监听器 Observer 和订阅者 Watcher 之间进行统一管理。接着,我们还需要有一个指令解析器 Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者 Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者 Watcher 接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。因此接下去我们执行以下 3 个步骤,实现数据的双向绑定:
1.实现一个监听器 Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。
2.实现一个订阅者 Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。
3.实现一个解析器 Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
流程图如下:
实现 mvvm 主要包含两个方面,数据变化更新视图,视图变化更新数据:
关键点在于 data 如何更新 view,因为 view 更新 data 其实可以通过事件监听即可,比如 input 标签监听 'input' 事件就可以实现了。所以我们着重来分析下,当数据改变,如何更新视图的。
数据更新视图的重点是如何知道数据变了,只要知道数据变了,那么接下去的事都好处理。如何知道数据变了,其实上文我们已经给出答案了,就是通过 Object.defineProperty( )对属性设置一个 set 函数,当数据改变了就会来触发这个函数,所以我们只要将一些需要更新的方法放在这里面就可以实现 data 更新 view 了。
思路有了,接下去就是实现过程了。
Observer
Observer 是一个数据监听器,其实现核心方法就是前文所说的Object.defineProperty( ) 。它的主要功能是做数据劫持,在数据获得更新的时候(拦截 set 方法),执行主题对象(Dep)的 notify 方法,通知所有的订阅者(Watcher)。Observer 类定义在src/core/observer/index.js中。
如果要对所有属性都进行监听的话,那么可以通过递归方法遍历所有属性值,并对其进行 Object.defineProperty( )处理。如下代码,实现了一个 Observer。
function defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
Object.defineProperty(data, key, {
get: function () {
return val;
},
set: function (newVal) {
val = newVal;
console.log(
"属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”"
);
},
});
}
function observe(data) {
if (!data || typeof data !== "object") {
return;
}
Object.keys(data).forEach(function (key) {
defineReactive(data, key, data[key]);
});
}
var library = {
book1: {
name: "",
},
book2: "",
};
observe(library);
library.book1.name = "vue权威指南"; // 属性name已经被监听了,现在值为:“vue权威指南”
library.book2 = "没有此书籍"; // 属性book2已经被监听了,现在值为:“没有此书籍”
Dep 对象
Dep 类定义在src/core/observer/dep.js中。 Dep 是 Observer 与 Watcher 之间的纽带,也可以认为 Dep 是服务于 Observer 的订阅系统。Watcher 订阅某个 Observer 的 Dep,当 Observer 观察的数据发生变化时,通过 Dep 通知各个已经订阅的 Watcher。
Dep 提供了如下几个方法:
- addSub: 接收的参数为 Watcher 实例,并把 Watcher 实例存入记录依赖的数组中
- removeSub: 与 addSub 对应,作用是将 Watcher 实例从记录依赖的数组中移除
- depend: Dep.target 上存放这当前需要操作的 Watcher 实例,调用 depend 会调用该 Watcher 实例的 addDep 方法,addDep 的功能可以看下面对 Watcher 的介绍
- notify: 通知依赖数组中所有的 watcher 进行更新操作
需要创建一个可以容纳订阅者的消息订阅器 Dep,订阅器 Dep 主要负责收集订阅者,然后再属性变化的时候执行对应订阅者的更新函数。所以显然订阅器需要有一个容器,这个容器就是 list,将上面的 Observer 稍微改造下,植入消息订阅器:
function defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
var dep = new Dep();
Object.defineProperty(data, key, {
get: function () {
if (是否需要添加订阅者) {
dep.addSub(watcher); // 在这里添加一个订阅者
}
return val;
},
set: function (newVal) {
if (val === newVal) {
return;
}
val = newVal;
console.log(
"属性" + key + "已经被监听了,现在值为:“" + newVal.toString() + "”"
);
dep.notify(); // 如果数据变化,通知所有订阅者
},
});
}
function Dep() {
this.subs = [];
}
Dep.prototype = {
addSub: function (sub) {
this.subs.push(sub);
},
notify: function () {
this.subs.forEach(function (sub) {
sub.update();
});
},
};
从代码上看,我们将订阅器 Dep 添加一个订阅者设计在 getter 里面,这是为了让 Watcher 初始化进行触发,因此需要判断是否要添加订阅者,至于具体设计方案,下文会详细说明的。在 setter 函数里面,如果数据变化,就会去通知所有订阅者,订阅者们就会去执行对应的更新的函数。到此为止,一个比较完整 Observer 已经实现了,接下来我们开始设计 Watcher。
Watcher 对象
watcher 实例有这些方法:
- get: 将 Dep.target 设置为当前 watcher 实例,在内部调用 this.getter,如果此时某个被 Observer 观察的数据对象被取值了,那么当前 watcher 实例将会自动订阅数据对象的 Dep 实例
- addDep: 接收参数 dep(Dep 实例),让当前 watcher 订阅 dep
- cleanupDeps: 清除 newDepIds 和 newDep 上记录的对 dep 的订阅信息
- update: 立刻运行 watcher 或者将 watcher 加入队列中等待统一 flush
- run: 运行 watcher,调用 this.get()求值,然后触发回调
- evaluate: 调用 this.get()求值
- depend: 遍历 this.deps,让当前 watcher 实例订阅所有 dep
- teardown: 去除当前 watcher 实例所有的订阅
订阅者 Watcher 在初始化的时候需要将自己添加进订阅器 Dep 中,那该如何添加呢?我们已经知道监听器 Observer 是在 get 函数执行了添加订阅者 Wather 的操作的,所以我们只要在订阅者 Watcher 初始化的时候触发对应的 get 函数去执行添加订阅者操作即可,那要如何触发 get 的函数,再简单不过了,只要获取对应的属性值就可以触发了,核心原因就是因为我们使用了 Object.defineProperty( )进行数据监听。这里还有一个细节点需要处理,我们只要在订阅者 Watcher 初始化的时候才需要添加订阅者,所以需要做一个判断操作,因此可以在订阅器上做一下手脚:在 Dep.target 上缓存下订阅者,添加成功后再将其去掉就可以了。订阅者 Watcher 的实现如下:
function Watcher(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
this.value = this.get(); // 将自己添加到订阅器的操作
}
Watcher.prototype = {
update: function() {
this.run();
},
run: function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
},
get: function() {
Dep.target = this; // 缓存自己
var value = this.vm.data[this.exp] // 强制执行监听器里的get函数
Dep.target = null; // 释放自己
return value;
}
};
这时候,我们需要对监听器 Observer 也做个稍微调整,主要是对应 Watcher 类原型上的 get 函数。需要调整地方在于 defineReactive 函数:
function defineReactive(data, key, val) {
observe(val); // 递归遍历所有子属性
var dep = new Dep();
Object.defineProperty(data, key, {
get: function() {
if (Dep.target) {. // 判断是否需要添加订阅者
dep.addSub(Dep.target); // 在这里添加一个订阅者
}
return val;
},
set: function(newVal) {
if (val === newVal) {
return;
}
val = newVal;
console.log('属性' + key + '已经被监听了,现在值为:“' + newVal.toString() + '”');
dep.notify(); // 如果数据变化,通知所有订阅者
}
});
}
Dep.target = null;
到此为止,简单版的 Watcher 设计完毕,这时候我们只要将 Observer 和 Watcher 关联起来,就可以实现一个简单的双向绑定数据了。因为这里没有还没有设计解析器 Compile,所以对于模板数据我们都进行写死处理,假设模板上又一个节点,且 id 号为'name',并且双向绑定的绑定的变量也为'name',且是通过两个大双括号包起来(这里只是为了演示,暂时没什么用处),模板如下:
<body>
<h1 id="name">{{name}}</h1>
</body>
这时候我们需要将 Observer 和 Watcher 关联起来:
function SelfVue (data, el, exp) {
this.data = data;
observe(data);
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
然后在页面上 new 以下 SelfVue 类,就可以实现数据的双向绑定了:
<body>
<h1 id="name">{{name}}</h1>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
var ele = document.querySelector('#name');
var selfVue = new SelfVue({
name: 'hello world'
}, ele, 'name');
window.setTimeout(function () {
console.log('name值改变了');
selfVue.data.name = 'canfoo';
}, 2000);
</script>
这时候打开页面,可以看到页面刚开始显示了是'hello world',过了 2s 后就变成'canfoo'了。到这里,总算大功告成一半了,但是还有一个细节问题,我们在赋值的时候是这样的形式 ' selfVue.data.name = 'canfoo' ' 而我们理想的形式是' selfVue.name = 'canfoo' '为了实现这样的形式,我们需要在 new SelfVue 的时候做一个代理处理,让访问 selfVue 的属性代理为访问 selfVue.data 的属性,实现原理还是使用 Object.defineProperty( )对属性值再包一层:
function SelfVue (data, el, exp) {
var self = this;
this.data = data;
Object.keys(data).forEach(function(key) {
self.proxyKeys(key); // 绑定代理属性
});
observe(data);
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
SelfVue.prototype = {
proxyKeys: function (key) {
var self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function proxyGetter() {
return self.data[key];
},
set: function proxySetter(newVal) {
self.data[key] = newVal;
}
});
}
}
这下我们就可以直接通过' selfVue.name = 'canfoo' '的形式来进行改变模板数据了。
Compile
虽然上面已经实现了一个双向数据绑定的例子,但是整个过程都没有去解析 dom 节点,而是直接固定某个节点进行替换数据的,所以接下来需要实现一个解析器 Compile 来做解析和绑定工作。解析器 Compile 实现步骤:
1.解析模板指令,并替换模板数据,初始化视图
2.将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器
为了解析模板,首先需要获取到 dom 元素,然后对含有 dom 元素上含有指令的节点进行处理,因此这个环节需要对 dom 操作比较频繁,所有可以先建一个 fragment 片段,将需要解析的 dom 节点存入 fragment 片段里再进行处理:
function nodeToFragment (el) {
var fragment = document.createDocumentFragment();
var child = el.firstChild;
while (child) {
// 将Dom元素移入fragment中
fragment.appendChild(child);
child = el.firstChild
}
return fragment;
}
接下来需要遍历各个节点,对含有相关指定的节点进行特殊处理,这里咱们先处理最简单的情况,只对带有 '{{变量}}' 这种形式的指令进行处理,先简道难嘛,后面再考虑更多指令情况:
function compileElement (el) {
var childNodes = el.childNodes;
var self = this;
[].slice.call(childNodes).forEach(function(node) {
var reg = /{{(.*)}}/;
var text = node.textContent;
if (self.isTextNode(node) && reg.test(text)) { // 判断是否是符合这种形式{{}}的指令
self.compileText(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
self.compileElement(node); // 继续递归遍历子节点
}
});
},
function compileText (node, exp) {
var self = this;
var initText = this.vm[exp];
updateText(node, initText); // 将初始化的数据初始化到视图中
new Watcher(this.vm, exp, function (value) { // 生成订阅器并绑定更新函数
self.updateText(node, value);
});
},
function updateText (node, value) {
node.textContent = typeof value == 'undefined' ? '' : value;
}
获取到最外层节点后,调用 compileElement 函数,对所有子节点进行判断,如果节点是文本节点且匹配{{}}这种形式指令的节点就开始进行编译处理,编译处理首先需要初始化视图数据,对应上面所说的步骤 1,接下去需要生成一个并绑定更新函数的订阅器,对应上面所说的步骤 2。这样就完成指令的解析、初始化、编译三个过程,一个解析器 Compile 也就可以正常的工作了。为了将解析器 Compile 与监听器 Observer 和订阅者 Watcher 关联起来,我们需要再修改一下类 SelfVue 函数:
function SelfVue (options) {
var self = this;
this.vm = this;
this.data = options;
Object.keys(this.data).forEach(function(key) {
self.proxyKeys(key);
});
observe(this.data);
new Compile(options, this.vm);
return this;
}
更改后,我们就不要像之前通过传入固定的元素值进行双向绑定了,可以随便命名各种变量进行双向绑定了:
<body>
<div id="app">
<h2>{{title}}</h2>
<h1>{{name}}</h1>
</div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
var selfVue = new SelfVue({
el: '#app',
data: {
title: 'hello world',
name: ''
}
});
window.setTimeout(function () {
selfVue.title = '你好';
}, 2000);
window.setTimeout(function () {
selfVue.name = 'canfoo';
}, 2500);
</script>
如上代码,在页面上可观察到,刚开始 titile 和 name 分别被初始化为 'hello world' 和空,2s 后 title 被替换成 '你好' 3s 后 name 被替换成 'canfoo' 了。
到这里,一个数据双向绑定功能已经基本完成了,接下去就是需要完善更多指令的解析编译,在哪里进行更多指令的处理呢?答案很明显,只要在上文说的 compileElement 函数加上对其他指令节点进行判断,然后遍历其所有属性,看是否有匹配的指令的属性,如果有的话,就对其进行解析编译。这里我们再添加一个 v-model 指令和事件指令的解析编译,对于这些节点我们使用函数 compile 进行解析处理:
function compile (node) {
var nodeAttrs = node.attributes;
var self = this;
Array.prototype.forEach.call(nodeAttrs, function(attr) {
var attrName = attr.name;
if (self.isDirective(attrName)) {
var exp = attr.value;
var dir = attrName.substring(2);
if (self.isEventDirective(dir)) { // 事件指令
self.compileEvent(node, self.vm, exp, dir);
} else { // v-model 指令
self.compileModel(node, self.vm, exp, dir);
}
node.removeAttribute(attrName);
}
});
}
上面的 compile 函数是挂载 Compile 原型上的,它首先遍历所有节点属性,然后再判断属性是否是指令属性,如果是的话再区分是哪种指令,再进行相应的处理,处理方法相对来说比较简单,这里就不再列出来。
最后我们在稍微改造下类 SelfVue,使它更像 vue 的用法:
function SelfVue (options) {
var self = this;
this.data = options.data;
this.methods = options.methods;
Object.keys(this.data).forEach(function(key) {
self.proxyKeys(key);
});
observe(this.data);
new Compile(options.el, this);
options.mounted.call(this); // 所有事情处理好后执行mounted函数
}
这时候我们可以来真正测试了,在页面上设置如下东西:
<body>
<div id="app">
<h2>{{title}}</h2>
<input v-model="name">
<h1>{{name}}</h1>
<button v-on:click="clickMe">click me!</button>
</div>
</body>
<script src="js/observer.js"></script>
<script src="js/watcher.js"></script>
<script src="js/compile.js"></script>
<script src="js/index.js"></script>
<script type="text/javascript">
new SelfVue({
el: '#app',
data: {
title: 'hello world',
name: 'canfoo'
},
methods: {
clickMe: function () {
this.title = 'hello world';
}
},
mounted: function () {
window.setTimeout(() => {
this.title = '你好';
}, 1000);
}
});
</script>
虚拟 DOM
JS 操作真实 DOM 的代价
用我们传统的开发模式,原生 JS 或 JQ 操作 DOM 时,浏览器会从构建 DOM 树开始从头到尾执行一遍流程。在一次操作中,我需要更新 10 个 DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行 10 次。例如,第一次计算完,紧接着下一个 DOM 更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算 DOM 节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作 DOM 的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验。
虚拟 DOM 原理
Web 界面由 DOM 树(树的意思是数据结构)来构建,当其中一部分发生变化时,其实就是对应某个 DOM 节点发生了变化,
虚拟 DOM 就是为了解决浏览器性能问题而被设计出来的。如前,若一次操作中有 10 次更新 DOM 的动作,虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地一个 JS 对象中,最终将这个 JS 对象一次性 attch 到 DOM 树上,再进行后续操作,避免大量无谓的计算量。所以,用 JS 对象模拟 DOM 节点的好处是,页面的更新可以先全部反映在 JS 对象(虚拟 DOM)上,操作内存中的 JS 对象的速度显然要更快,等更新完成后,再将最终的 JS 对象映射成真实的 DOM,交由浏览器去绘制。
什么是虚拟 DOM
例如一个真实的 DOM 节点。
我们用 JS 来模拟 DOM 节点实现虚拟 DOM。
虚拟 DOM 是用 JavaScript 对象描述 DOM 的层次结构。DOM 中的一切属性都在虚拟 DOM 中有对应的属性。
其中的 Element 方法具体怎么实现的呢?
Element 方法实现
第一个参数是节点名(如 div),第二个参数是节点的属性(如 class),第三个参数是子节点(如 ul 的 li)。除了这三个参数会被保存在对象上外,还保存了key 和 count。其相当于形成了虚拟 DOM 树。
有了 JS 对象后,最终还需要将其映射成真实 DOM
我们已经完成了创建虚拟 DOM 并将其映射成真实 DOM,这样所有的更新都可以先反应到虚拟 DOM 上,如何反应?需要用到Diff 算法。
Diff 操作
diff 的目的就是比较新旧 Virtual DOM Tree 找出差异并更新.
可见 diff 是直接影响 Virtual DOM 性能的关键部分.
要比较 Virtual DOM Tree 的差异,理论上的时间复杂度高达 O(n^3),这是一个奇高无比的时间复杂度,很显然选择这种低效的算法是无法满足我们对程序性能的基本要求的.
好在我们实际开发中,很少会出现跨层级的 DOM 变更,通常情况下的 DOM 变更是同级的,因此在现代的各种 Virtual DOM 库都是只比较同级差异,在这种情况下我们的时间复杂度是 O(n).
平层 Diff,只有以下 4 种情况:
1、节点类型变了,例如下图中的 P 变成了 H3。我们将这个过程称之为REPLACE。直接将旧节点卸载并装载新节点。旧节点包括下面的子节点都将被卸载,如果新节点和旧节点仅仅是类型不同,但下面的所有子节点都一样时,这样做效率不高。但为了避免 O(n^3)的时间复杂度,这样是值得的。这也提醒了开发者,应该避免无谓的节点类型的变化,例如运行时将 div 变成 p 没有意义。
2、节点类型一样,仅仅属性或属性值变了。 我们将这个过程称之为PROPS。此时不会触发节点卸载和装载,而是节点更新。
查找不同属性方法
3、文本变了,文本对也是一个 Text Node,也比较简单,直接修改文字内容就行了,我们将这个过程称之为TEXT。
4、移动/增加/删除 子节点,我们将这个过程称之为REORDER。看一个例子,在 A、B、C、D、E 五个节点的 B 和 C 中的 BC 两个节点中间加入一个 F 节点。
我们简单粗暴的做法是遍历每一个新虚拟 DOM 的节点,与旧虚拟 DOM 对比相应节点对比,在旧 DOM 中是否存在,不同就卸载原来的按上新的。这样会对 F 后边每一个节点进行操作。卸载 C,装载 F,卸载 D,装载 C,卸载 E,装载 D,装载 E。效率太低。
粗暴做法
最终 Diff 出来的结果
映射成真实 DOM
虚拟 DOM 有了,Diff 也有了,现在就可以将 Diff 应用到真实 DOM 上了。深度遍历 DOM 将 Diff 的内容更新进去。
根据 Diff 更新 DOM
根据 Diff 更新 DOM
我们会有两个虚拟 DOM(js 对象,new/old 进行比较 diff),用户交互我们操作数据变化 new 虚拟 DOM,old 虚拟 DOM 会映射成实际 DOM( js 对象生成的 DOM 文档)通过DOM fragment操作给浏览器渲染。当修改 new 虚拟 DOM,会把 newDOM 和 oldDOM 通过 diff 算法比较,得出 diff 结果数据表(用 4 种变换情况表示)。再把 diff 结果表通过DOM fragment更新到浏览器 DOM中。
虚拟 DOM 的存在的意义?vdom 的真正意义是为了实现跨平台,服务端渲染,以及提供一个性能还算不错 Dom 更新策略。vdom 让整个 mvvm 框架灵活了起来
Diff 算法只是为了虚拟 DOM 比较替换效率更高,通过 Diff 算法得到 diff 算法结果数据表(需要进行哪些操作记录表)。原本要操作的 DOM 在 vue 这边还是要操作的,只不过用到了 js 的DOM fragment来操作 dom(统一计算出所有变化后统一更新一次 DOM)进行浏览器 DOM 一次性更新。其实DOM fragment我们不用平时发开也能用,但是这样程序员写业务代码就用把 DOM 操作放到 fragment 里,这就是框架的价值,程序员才能专注于写业务代码 。
父子兄弟组件通信
Vue 组件之间的通信大概归类为:
- 父子组件通信: props/attrs / parent / $children
- 兄弟组件通信: eventBus;vuex
- 跨级通信: eventBus;Vuex;listeners
一、props 父->子 / $emit 子->父
1.父组件向子组件传值
通过 props 传值。
父组件:
<template>
<div class="section">
<child :list="list"></child>
</div>
</template>
<script>
import child from './child.vue'
export default {
components: { child },
data() {
return {
list: ['a1', 'b2', 'c3']
}
}
}
</script>
子组件child.vue:
<template>
<div>
<span v-for="(item, index) in list" :key="index">{{item}}</span>
</div>
</template>
<script>
export default {
props: ['list'],
}
</script>
2.子组件向父组件传值
$emit 绑定一个自定义事件, 当这个语句被执行时, 就会将参数 arg 传递给父组件,父组件通过 v-on 监听并接收参数。
父组件:
<template>
<div class="section">
<child :list="list" @onEmitIndex="onEmitIndex"></child>
<p>{{item}} => {{currentIndex}}</p>
</div>
</template>
<script>
import child from './child.vue'
export default {
components: { child },
data() {
return {
currentIndex: -1,
item: '',
list: ['a1', 'b2', 'c3']
}
},
methods: {
onEmitIndex(arg) {
this.currentIndex = arg.index;
this.item = arg.item;
}
}
}
</script>
子组件 child.vue:
<template>
<div>
<button v-for="(item, index) in list" :key="index" @click="emitIndex(index, item)">{{item}}</button>
</div>
</template>
<script>
export default {
props: ['list'],
methods: {
emitIndex(index, item) {
this.$emit('onEmitIndex', {index, item})
}
}
}
</script>
二、ref&refs 子->父
ref 如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例,可以通过在父组件中实例直接调用组件的方法或访问数据。也是子组件向父组件传值的一种。
父组件 parent.vue:
<template>
<div>
<button @click="sayHello">sayHello</button>
<child ref="childForRef"></child>
</div>
</template>
<script>
import child from './child.vue'
export default {
components: { child },
data () {
return {
childForRef: null,
}
},
mounted() {
this.childForRef = this.$refs.childForRef;
console.log(this.childForRef.name);
// this.childForRef.sayHello();
},
methods: {
sayHello() {
this.childForRef.sayHello()
}
}
}
</script>
子组件 child.vue:(子组件不需要额外的代码)
<template>
<div>child 的内容</div>
</template>
<script>
export default {
data () {
return {
name: '我是 child',
}
},
methods: {
sayHello () {
console.log('hello');
alert('hello');
}
}
}
</script>
三、eventBus 父子/兄弟/跨级
eventBus 又称为事件总线,这种方法通过一个空的Vue实例作为事件总线,用$emit/$on来触发事件和监听事件,巧妙而轻量地实现了任何组件间的通信,包括父子、兄弟、跨级。
首先需要创建一个事件总线并将其导出, 以便其他模块可以使用或者监听它。
bus.js:
import Vue from 'vue'
export const bus = new Vue()
parent.vue:(没什么特别的)
<template>
<div>
<child1></child1>
<child2></child2>
</div>
</template>
<script>
import child1 from './child1.vue'
import child2 from './child2.vue'
export default {
components: { child1, child2 }
}
</script>
发送事件,假设有 child1、child2 两个兄弟组件,在 child1.vue 中使用bus.$emit()发送事件。
child1.vue:
<template>
<div>
<button @click="additionHandle">+加法器</button>
</div>
</template>
<script>
import {bus} from '@/bus.js'
// console.log(bus)
export default {
data(){
return{
num:1
}
},
methods:{
additionHandle(){
//往bus总线发送addition事件
bus.$emit('addition', {
num:this.num++
})
}
}
}
</script>
在 child2.vue 中使用bus.$on()接收事件。因为有时不确定何时会触发事件,一般会在 mounted 或 created 钩子中来监听。
child2.vue:
<template>
<div>计算和: <br>child1Num => {{child1Num}}<br>count + child1Num => {{count}}</div>
</template>
<script>
import { bus } from '@/bus.js'
export default {
data() {
return {
child1Num: 0,
count: 0,
}
},
mounted() {
//监听bus总线中的事件addition
bus.$on('addition', arg=> {
this.child1Num = arg.num;
this.count = this.count + arg.num;
})
}
}
</script>
如果想移除事件的监听, 可以使用$off:
import { bus } from './bus.js'
bus.$off('addition', {})
四、Vuex 父子/兄弟/跨级
直接参考vuex一章
Vuex实现了一个单向数据流,在全局拥有一个State存放数据,当组件要更改State中的数据时,必须通过Mutation进行,Mutation同时提供了订阅者模式供外部插件调用获取State数据的更新。而当所有异步操作(常见于调用后端接口异步获取更新数据)或批量的同步操作需要走Action,但Action也是无法直接修改State的,还是需要通过Mutation来修改State的数据。最后,根据State的变化,渲染到视图上。
和eventbus一样可以进行任何组件之间的通信,但vuex适用于规模更大的使用场景。
五、attrs&listeners 跨级/父->子
多级组件嵌套需要传递数据时,通常使用的方法是通过vuex。但如果仅仅是传递数据,而不做中间处理,使用 vuex 处理,未免有点大材小用。为此Vue2.4 版本提供了另一种方法----$attrs/$listeners
$attrs:包含了父作用域中不被 prop 所识别 (且获取) 的特性绑定 (class 和 style 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (class 和 style 除外),并且可以通过 v-bind="$attrs" 传入内部组件。通常配合 interitAttrs 选项一起使用。$listeners:包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件
简单来说:$attrs与$listeners 是两个对象,$attrs 里存放的是父组件中绑定的非 Props 属性,$listeners里存放的是父组件中绑定的非原生事件。
parent.vue:
<template>
<div>
<child-a :name="name" :age="age" :gender="gender" :height="height" title="嘿嘿嘿"> </child-a>
</div>
</template>
<script>
import ChildA from './ChildA'
export default {
components: { ChildA },
data() {
return {
name: "zhang",
age: "18",
gender: "女",
height: "158"
};
}
};
</script>
childA.vue
<template>
<div>
<p>props 接收到的 name: {{ name}}</p>
<p>childA 的 $attrs: {{$attrs}}</p>
<child-b v-bind="$attrs"></child-b>
</div>
</template>
<script>
const childB = () => import("./childB.vue");
export default {
components: {
childB
},
inheritAttrs: false, // 可以关闭自动挂载到组件根元素上的没有在props声明的属性
props: {
name: String // name作为props属性绑定
},
created() {
console.log(this.$attrs);
// { "age": "18", "gender": "女", "height": "158", "title": "嘿嘿嘿" }
}
};
</script>
childB.vue
<template>
<div class="border">
<p>props 接收到的 age: {{age}}</p>
<p>childB 的 $attrs: {{$attrs}}</p>
</div>
</template>
<script>
export default {
inheritAttrs: false,
props: {
age: String
},
created() {
console.log(this.$attrs);
// { "gender": "女", "height": "158", "title": "嘿嘿嘿" }
}
};
</script>
<style scoped>
.border{border: 1px solid #000;}
</style>
六、parent 父子
通过this.parent获得父组件实例,类似ref获得子组件的原理。不能访问兄弟或跨级组件。
parent.vue:
<template>
<div>
<div>{{msg}}</div>
<child></child>
<button @click="changeA">点击改变子组件值</button>
</div>
</template>
<script>
import Child from './child'
export default {
components: { Child },
data() {
return {
msg: 'Welcome'
}
},
methods: {
changeA() {
this.$children[0].messageA = 'this is new value'
}
}
}
</script>
child.vue:
<template>
<div class="com_a">
<span>{{messageA}}</span>
<p>获取父组件的值为: {{parentVal}}</p>
</div>
</template>
<script>
export default {
data() {
return {
messageA: 'this is old'
}
},
computed:{
parentVal(){
return this.$parent.msg;
}
}
}
</script>
要注意边界情况,如在
#app上拿$parent得到的是new Vue()的实例,在这实例上再拿$parent得到的是undefined,而在最底层的子组件拿$children是个空数组。也要注意得到$parent和$children的值不一样,$children的值是数组,而$parent是个对象。
七、provide & inject 跨级/父->子
1.简介
Vue2.2.0新增API,这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。一言而蔽之:祖先组件中通过provide选项来提供变量,然后在子孙组件中通过inject选项来注入变量。 provide / inject API 主要解决了跨级组件间的通信问题,并且主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系。
2.举个例子
假设有两个组件: A.vue 和 B.vue,B 是 A 的子组件
// A.vue
export default {
provide: {
name: '浪里行舟'
}
}
// B.vue
export default {
inject: ['name'],
mounted () {
console.log(this.name); // 浪里行舟
}
}
可以看到,在 A.vue 里,我们设置了一个 provide: name,值为 浪里行舟,它的作用就是将 name 这个变量提供给它的所有子组件。而在 B.vue 中,通过 inject 注入了从 A 组件中提供的 name 变量,那么在组件 B 中,就可以直接通过 this.name 访问这个变量了,它的值也是 浪里行舟。这就是 provide / inject API 最核心的用法。
需要注意的是:provide 和 inject 绑定并不是可响应的
所以,上面 A.vue 的 name 如果改变了,B.vue 的 this.name 是不会改变的,仍然是 浪里行舟。
3.provide与inject 怎么实现数据响应式
一般来说,有两种办法:
- provide祖先组件的实例,然后在子孙组件中注入依赖,这样就可以在子孙组件中直接修改祖先组件的实例的属性,不过这种方法有个缺点就是这个实例上挂载很多没有必要的东西比如props,methods
- 使用2.6最新API Vue.observable 优化响应式 provide(推荐)
我们来看个例子:孙组件D、E和F获取A组件传递过来的color值,并能实现数据响应式变化,即A组件的color变化后,组件D、E、F会跟着变(核心代码如下:)
// A 组件
<div>
<h1>A 组件</h1>
<button @click="() => changeColor()">改变color</button>
<ChildrenB />
<ChildrenC />
</div>
......
data() {
return {
color: "blue"
};
},
// provide() {
// return {
// theme: {
// color: this.color //这种方式绑定的数据并不是可响应的
// } // 即A组件的color变化后,组件D、E、F不会跟着变
// };
// },
provide() {
return {
theme: this//方法一:提供祖先组件的实例
};
},
methods: {
changeColor(color) {
if (color) {
this.color = color;
} else {
this.color = this.color === "blue" ? "red" : "blue";
}
}
}
// 方法二:使用2.6最新API Vue.observable 优化响应式 provide
// provide() {
// this.theme = Vue.observable({
// color: "blue"
// });
// return {
// theme: this.theme
// };
// },
// methods: {
// changeColor(color) {
// if (color) {
// this.theme.color = color;
// } else {
// this.theme.color = this.theme.color === "blue" ? "red" : "blue";
// }
// }
// }
// F 组件
<template functional>
<div class="border2">
<h3 :style="{ color: injections.theme.color }">F 组件</h3>
</div>
</template>
<script>
export default {
inject: {
theme: {
//函数式组件取值不一样
default: () => ({})
}
}
};
</script>
虽说provide 和 inject 主要为高阶插件/组件库提供用例,但如果你能在业务中熟练运用,可以达到事半功倍的效果!
vue-loader
本篇文章主要介绍了 vue-loader 教程介绍,vue-loader 就是告诉 webpack 如何将 vue 格式的文件转换成 js。有兴趣的可以了解一下
在最初使用 webpack+vue 时,看到 vue 里面各种语法,包括 import,export,html 和 css 的写作方式,我都能依葫芦画瓢地实现各种功能,但是为什么能这样写,一直不太理解,直到我了解了 vue-loader。
vue-loader 功能
如图,webpack 的功能就是将左侧用户编写的代码(只要有相应的 loader,可以使用任何符合自己习惯的编写方式)转换成右侧浏览器能识别的 js。
vue-loader 就是告诉 webpack 如何将 vue 格式的文件转换成 js。
vue-loader:解析和转换 .vue 文件,提取出其中的逻辑代码 script、样式代码 style、以及 HTML 模版 template,再分别把它们交给对应的 Loader 去处理。css-loader:加载由 vue-loader 提取出的 CSS 代码。vue-template-compiler:把 vue-loader 提取出的 HTML 模版编译成对应的可执行的 JavaScript 代码,这和 React 中的 JSX 语法被编译成 JavaScript 代码类似。预先编译好 HTML 模版相对于在浏览器中再去编译 HTML 模版的好处在于性能更好。
总结:vue-loader 的作用就是提取。
vue 组件格式
.vue 文件是一个自定义的文件类型,用类 HTML 语法描述一个 Vue 组件。每个 .vue 文件包含三种类型的顶级语言块