Vue2
版本历史
Vue2 的最后一个版本是 2.7.16
2.7 系列的版本包含了对 Vue 3 的一些特性的兼容性改进,同时也保持了与 Vue 2 生态系统的兼容性。
2.6.14
是 Vue2 最后一个用 JS 写的版本。
工具库与 Vue2 的兼容性:
- Vue CLI:应使用
4.4.6
或更低版本的@vue/cli
- Vuex:应使用 Vuex 3.x,https://v3.vuex.vuejs.org/zh/
- Vue Router:通常是 3.x 系列,https://v3.router.vuejs.org/zh/
可以继续使用 Vue Cli 来创建 Vue2 应用,如在终端执行:vue create hello-world
,然后选择 vue2,会创建一个 vue 版本为 ^2.6.14
的项目。
虚拟 DOM、Diff 算法
虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构「虚拟」地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。
例如如下的一个虚拟节点,它代表一个 div 元素,是一个纯 JS 对象。至少包含标签名、属性和子元素对象
const vnode = {
type: 'div',
props: {
id: 'hello'
},
children: [
/* 更多 vnode */
]
};
实际渲染到浏览器上的 DOM 结构是:
<div id="hello">
<!-- 更多 DOM 结构 -->
</div>
一个运行时渲染器将会遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树。这个过程被称为挂载 (mount)。
如果有两份虚拟 DOM 树,渲染器将会对比遍历它们,找出二者的区别,并应用这其中的变化到真实的 DOM 上。这个过程被称为更新 (patch),又被称为「比对」(diffing) 或「协调」(reconciliation)。
那为什么要使用虚拟 DOM 呢?vue 需要先做 diff 对比,然后去操作实际 DOM,这样不是比直接去操作 DOM 更消耗性能吗?
- 减少 JS 操作真实 DOM 的性能消耗
- 开发者体验更好,专注于数据和逻辑,不用关注 DOM 操作
- 跨平台兼容性,虚拟 DOM 就是一个对象,可以跨平台
虚拟 DOM 进行 diff 对比会增加一些计算开销,但这种开销通常远小于直接操作真实 DOM 的开销。当在一次操作需要更新 10 个 DOM 节点时,浏览器收到第一个更新 DOM 请求后会马上执行流程,最终执行 10 次流程。而虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地的一个 JS 对象中,最终将这个对象一次性渲染到 DOM 树上,减少实际 DOM 操作的数量。
Diff 算法采用了一种称为「双端比较」的策略,通过比较新旧虚拟 DOM 树的节点,找出最小的变更集。相比于直接操作 DOM,这种算法可以显著减少实际 DOM 操作的数量。
Vue 模版编译原理
vue 中的模版无法被浏览器解析,因为 template 不是 html 标签,需要将其编译成 js 函数,将其执行后渲染出 html 元素。
vue 的模版编译是将模板字符串转换为渲染函数 的过程,主要有三步:
- 解析(parse):将模版字符串转换为 AST 抽象语法树。使用正则表达式对 template 字符串进行解析,将标签、指令、属性等转化为 AST
- 优化(optimize):标记静态节点,便于后续的渲染优化。具体是遍历 AST,找到静态节点并标记,在页面重渲染进行 diff 比较时,跳过这些静态节点,优化 runtime 的性能
- 生成代码(generate):将 AST 转换为 render 渲染函数
可以查看源码:src/compiler
data 为何声明为函数
因为组件可能被用来创建多个实例,若 data 声明为对象则所有的实例将共享引用同一个数据对象。
data 声明为函数则每次创建一个新实例后,调用 data 函数会返回初始数据的一个全新副本数据对象。
data 声明为对象:
function VueComponent() {}
VueComponent.prototype.$options = {
data: { name: 'Vue' }
};
let f1 = new VueComponent();
f1.$options.data.name = 'React';
let f2 = new VueComponent();
console.log(f2.$options.data.name); // React
data 声明为函数:
function VueComponent() {}
VueComponent.prototype.$options = {
data: () => ({ name: 'Vue' })
};
let f1 = new VueComponent();
let res = f1.$options.data();
res.name = 'React';
console.log(res); // {name: "React"}
let f2 = new VueComponent();
console.log(f2.$options.data()); // {name: "Vue"}
new Vue()
可以将data
声明为一个普通对象是因为这个类创建的实例不会被复用,只会 new 一次。而App.vue
同样是因为整个系统中App.vue
只会被使用一次,所以不存在上述的问题。
v-if 和 v-show 区别
v-if
是真正的条件渲染,会有性能开销,每次插入或者移除元素时都必须要生成元素内部的 DOM 树v-show
则不管条件是什么都会渲染元素,基于display:none
显示隐藏
一般来说,v-if
有更高的切换开销,而 v-show
有更高的初始渲染开销。
因此,如果需要非常频繁地切换,则使用 v-show
较好;如果在运行时条件很少改变,则使用 v-if
较好
v-model 原理
v-model 用于在表单输入元素和应用状态之间创建双向数据绑定。v-model
其实是一个语法糖,做了两件事:
v-bind
绑定:将输入元素的值绑定到数据模型v-on
监听:监听输入事件(如 input、change 等),并在事件触发时更新数据模型
基本用法
<template>
<input v-model="message" />
</template>
<script>
export default {
data() {
return {
message: ''
};
}
};
</script>
等效于:
<template>
<input :value="message" @input="message = $event.target.value" />
</template>
<script>
export default {
data() {
return {
message: ''
};
}
};
</script>
自定义组件中的 v-model
在自定义组件中使用 v-model
时,Vue 会默认将 value
作为 prop 传递,并监听 input
事件来更新父组件的数据。
<template>
<input :value="value" @input="$emit('input', $event.target.value)" />
</template>
<script>
export default {
props: ['value']
};
</script>
<template>
<child-component v-model="message"></child-component>
<p>{{ message }}</p>
</template>
<script>
import ChildComponent from './ChildComponent.vue';
export default {
components: { ChildComponent },
data() {
return {
message: ''
};
}
};
</script>
手写 v-model
原理:使用Object.defineProperty()
实现响应式数据劫持,监听事件触发视图更新。
<input type="text" id="input" />
<p id="output"></p>
<script>
const input = document.getElementById('input');
const output = document.getElementById('output');
// 数据对象
const data = {
message: 'hello'
};
// 响应式数据劫持
function defineReactive(obj, key) {
let value = obj[key];
Object.defineProperty(obj, key, {
get() {
return value;
},
set(newValue) {
value = newValue;
// 更新视图
input.value = newValue;
output.textContent = newValue;
}
});
}
// 初始化响应式数据
defineReactive(data, 'message');
// 初始化视图
input.value = data.message;
output.textContent = data.message;
// 监听输入框的输入事件,更新数据对象
input.addEventListener('input', function (e) {
data.message = e.target.value;
});
</script>
数据双向绑定原理
1. 双向绑定原理
vue2 是采用数据劫持结合发布-订阅模式,通过Object.defineProperty()
来劫持各个属性的setter
、getter
,在数据变动时发布消息给订阅者,触发响应的监听回调。
数据变化更新视图,视图变化更新数据。
视图变化可以通过事件监听去更新数据,如 input 事件。
那怎么知道数据变化了?通过Object.defineProperty()
实现数据劫持,当数据发生变化时,会触发setter
,然后通知订阅者,订阅者会触发它的update
方法,对视图进行更新。
2. Object.defineProperty()
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
语法:
Object.defineProperty(obj, prop, descriptor)
参数:
- obj 要在其上定义属性的对象。
- prop 要定义或修改的属性的名称。
- descriptor 将被定义或修改的属性描述符。
返回值: 被传递给函数的对象。
MDN 地址: Object.defineProperty()
3. 如何实现
observer
用来实现对每个组件中的 data 中定义的属性,循环用Object.defineProperty()
实现数据劫持,以便利用其中的 setter 和 getter,然后通知订阅者,订阅者会触发它的 update 方法,对视图进行更新。
4. 代码实现
1. 实现 observer
使用 Object.defineProperty()
,只要取值与赋值就会进入 get 和 set 函数内,在这里面便可以实现某些功能。
function defineReactive(data, key, value) {
let dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// 添加订阅者watcher到主题对象Dep
if (Dep.target) {
// JS的浏览器单线程特性,保证这个全局变量在同一时间内,只会有同一个监听器使用
dep.addSub(Dep.target);
}
return value;
},
set(newValue) {
if (newValue === value) return;
value = newValue;
// 发出通知
dep.notify(); // 通知后,dep会循环调用各自的update方法更新视图
}
});
}
function observe(obj, vm) {
Object.keys(obj).forEach(key => {
defineReactive(vm, key, obj[key]);
});
}
2. 实现 compile
compile 的目的就是解析各种指令成为真正的 html
function Compile(node, vm) {
if (node) {
this.$frag = this.nodeToFragment(node, vm);
return this.$frag;
}
}
Compile.prototype = {
nodeToFragment: function (node, vm) {
var self = this;
var frag = document.createDocumentFragment();
var child;
while ((child = node.firstChild)) {
console.log([child]);
self.compileElement(child, vm);
frag.append(child); // 将所有子节点添加到fragment中
}
return frag;
},
compileElement: function (node, vm) {
var reg = /\{\{(.*)\}\}/;
// 节点类型为元素(input元素这里)
if (node.nodeType === 1) {
var attr = node.attributes;
// 解析属性
for (var i = 0; i < attr.length; i++) {
if (attr[i].nodeName == 'v-model') {
// 遍历属性节点找到v-model的属性
var name = attr[i].nodeValue; // 获取v-model绑定的属性名
node.addEventListener('input', function (e) {
// 给相应的data属性赋值,进而触发该属性的set方法
vm[name] = e.target.value;
});
// 创建新的watcher,会触发函数向对应属性的dep数组中添加订阅者
new Watcher(vm, node, name, 'value');
}
}
}
// 节点类型为text
if (node.nodeType === 3) {
if (reg.test(node.nodeValue)) {
var name = RegExp.$1; // 获取匹配到的字符串
name = name.trim();
new Watcher(vm, node, name, 'nodeValue');
}
}
}
};
3. 实现 watcher
function Watcher(vm, node, name, type) {
Dep.target = this;
this.name = name;
this.node = node;
this.vm = vm;
this.type = type;
this.update();
Dep.target = null;
}
Watcher.prototype = {
update: function () {
this.get();
this.node[this.type] = this.value; // 订阅者执行相应操作
},
// 获取data的属性值
get: function () {
console.log(1);
this.value = this.vm[this.name]; // 触发相应属性的get
}
};
4. 实现 Dep 来为每个属性添加订阅者
function Dep() {
this.subs = [];
}
Dep.prototype = {
addSub: function (sub) {
this.subs.push(sub);
},
notify: function () {
this.subs.forEach(function (sub) {
sub.update();
});
}
};
5. 总结
- 给每个 vue 属性用
Object.defineProperty()
实现数据劫持,为每个属性分配一个订阅者集合的管理数组 dep - 在编译的时候在该属性的数组 dep 中添加订阅者,v-model 会添加一个订阅者,
{{}}
也会,v-bind 也会,只要用到该属性的指令理论上都会 - 为 input 添加监听事件,修改值就会为该属性赋值,触发该属性的 set 方法,在 set 方法内通知订阅者数组 dep,订阅者数组循环调用各订阅者的 update 方法更新视图
参考文章: vue 的双向绑定原理及实现
简易版本 Vue2 双向数据绑定
<div id="app">
订阅视图1:
<span class="box1"></span>
订阅视图2:
<span class="box2"></span>
</div>
<script src="index.js"></script>
<script>
let obj = {};
dataRes({ data: obj, tag: 'view1', dataKey: 'one', selector: '.box1' });
dataRes({ data: obj, tag: 'view2', dataKey: 'two', selector: '.box2' });
obj.one = '这是视图一';
obj.two = '这是视图二';
</script>
// 订阅器模型
const Dep = {
// 容器
container: {},
// 添加订阅
listen(key, fn) {
(this.container[key] || (this.container[key] = [])).push(fn);
},
// 发布
trigger() {
let key = Array.prototype.shift.call(arguments),
fns = this.container[key];
if (!fns || fns.length === 0) {
return;
}
for (let i = 0, len = fns.length; i < len; i++) {
fns[i].apply(this, arguments);
}
// for (let i = 0, fn; (fn = fns[i++]); ) {
// fn.apply(this, arguments);
// }
}
};
// 数据劫持
const dataRes = ({ data, tag, dataKey, selector }) => {
let value = '',
el = document.querySelector(selector);
Object.defineProperty(data, dataKey, {
get() {
return value;
},
set(val) {
value = val;
Dep.trigger(tag, val);
}
});
Dep.listen(tag, text => {
el.innerHTML = text;
});
};
Object.defineProperty()的局限性
Object.defineProperty()
能够监听对象属性的获取和修改,但无法监听属性的添加和删除。
let data = {
name: 'zgh'
};
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('属性被获取');
return value;
},
set(newValue) {
console.log('属性被修改');
value = newValue;
}
});
}
function observe(data) {
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key]);
});
}
observe(data);
data.name; // 属性被获取
data.name = 'John'; // 属性被修改
// 添加属性,无法监听
data.age = 23;
console.log(data.age);
// 删除属性,无法监听
delete data.name;
console.log(data.name);
Vue 为了弥补这块的不足,提供了 Vue.set
全局 API 和 vm.$set
实例方法,用于添加响应式属性。
https://v2.cn.vuejs.org/v2/api/#Vue-set
Vue.set(app, 'b', 2);
// 或者
this.$set(this, 'b', 2);
看一个具体的例子:
<template>
<div id="app">
<HelloWorld msg="Welcome to Your Vue.js App" />
<h1>{{ obj }}</h1>
<div>{{ obj.a }}</div>
<div>{{ obj.b }}</div>
<button type="button" @click="handleObj">obj</button>
<div>{{ list }}</div>
<button type="button" @click="updateItem">updateItem</button>
<button type="button" @click="updateLength">updateLength</button>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
obj: {
a: 1
},
list: [1, 2, 3]
};
},
methods: {
handleObj() {
this.obj.b = 2;
console.log(this.obj);
// this.$set(this.obj, 'b', 2);
},
updateItem() {
this.list[1] = 'a';
console.log(this.list);
// this.$set(this.list, 1, 'a');
},
updateLength() {
this.list.length = 2;
// this.list.splice(2);
}
}
};
</script>
当点击按钮时,想给 obj 添加一个属性 b,但是 obj 并未在 data 中声明,结果就是可以打印出 obj.b,但页面不会更新。这时使用 $set
就可以解决这个问题。
vue 不能检测到数组索引变化、数组长度变化。
watch 和 computed 的区别
computed 是一个计算属性,基于其依赖进行缓存,当依赖的数据发生变化时,会重新计算属性的值。
- 支持缓存
- 不支持异步,当 computed 中有异步操作时,无法监听数据的变化
- 依赖的数据包括:组件的
data
和父组件传入的props
new Vue({
el: '#app',
data: {
a: 1,
b: 2
},
computed: {
sum() {
return this.a + this.b;
}
}
});