Vue2响应式原理浅析
0.什么是响应式
vue响应式,我们都很熟悉了。当我们修改vue中data对象中的属性时,页面中引用该属性的地方就会发生相应的改变。避免了我们再去操作dom,进行数据绑定。要实现一个自己的响应式系统,我们首先要明白要做什么事情:
- 数据劫持:通过
Object.defineProperty
来对属性进行监听 - 依赖收集:页面在渲染的过程中用到了哪些数据,每个数据生成一个对应的
watcher
- 派发更新:数据变化时,通过
dep
来执行内部watcher
的notify
方法
下面我们就来一步步实现数据的响应式.
1.创建vue实例
首先我们先创建一个Vue的实例,传入数据。
const app = new Vue({
el: "#app",
data: {
name: "dawn",
age: 12,
friend: {
friendName: "ibuki",
},
colors: ["red", "orange", "yellow"],
},
});
new Vue()
后会调用我们自己定义的Vue
类
class Vue {
constructor(options) {
//添加Vue实例属性
this.$options = options;
this.$data = options.data;
this.$el = options.el;
//将实例的data加入响应式系统中
new Observer(this.$data);
//代理this.$data的数据
Object.keys(this.$data).forEach((key) => {
this._proxy(key);
});
//处理el
new Compiler(this.$el, this);
}
_proxy(key) {
// 将this.$data上的数据定义在Vue实例上,这样我们可以自己通过Vue.value来使用数据
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
set(newValue) {
this.$data[key] = newValue;
},
get() {
return this.$data[key];
},
});
}
}
如果你对Observer,Compiler
这些类什么时候实例化有疑问的话,没关系,下面马上就会讲到.\
可以看到new Vue
后我们挂载属性到vue实例上,之后通过Observer
实现数据响应式,然后将数据代理到Vue
实例上,代理后,对el进行处理.
这就是响应式实现的过程,了解了过程,下面我们来介绍详细的类.
2.数据劫持(observer)
当我们实例化vue的时候,首先Vue
会使用Object.defineProperty
来对data数据进行数据劫持,下面我们来介绍数据劫持的过程。
首先介绍一下Object.defineProperty
方法:
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。\
Object.defineProperty(obj, prop, descriptor)
descriptor
属性可以是对象原本的值Value
也可以一个对象,其中可以设置get
,set
属性。
const data = { name: "dawn" };
Object.defineProperty(data, "name", {
//用到data.name时触发
get() {
return value;
},
//改变data.name时触发
set(newValue) {
if (newValue !== value) {
value = newValue;
}
},
});
经过这样设置后,当data.name
发生变化时就会触发set
方法,当外界使用data.name
时就会触发get
方法。这样当数据发生改变时,我们就可以对其进行相应的操作了。
如果data有多个属性呢?我们可以新建一个类Observer来遍历该对象
class Observer {
constructor(data) {
this.data = data;
//不是对象或空值则返回
if (typeof data !== "object" || data === null) {
return data;
}
//Object.keys(data)取得data对象keys的数组
Object.keys(data).forEach((key) => {
this.defineReactive(this.data, key, data[key]);
});
}
defineReactive(data, key, value) {
//对属性进行响应式处理
Object.defineProperty(data, key, {
//当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
configurable: true,
//当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
enumerable: true,
get() {
return value;
},
set(newValue) {
if (newValue !== value) {
value = newValue;
console.log("refresh");
}
},
});
}
}
这样做还有缺陷,当传入的data包含对象,或是赋值时给一个对象值。对象里的属性并不能进行数据劫持,因为这时data
里对象的属性压根没有做这个 Object.defineProperty
方法 。
如果data内有嵌套的属性(对象,数组)呢?我们可以使用递归来完成嵌套属性的数据劫持
class Observer {
constructor(data) {
this.data = data;
//不是对象或空值则返回
if (typeof data !== "object" || data === null) {
return data;
}
//Object.keys(data)取得data对象keys的数组
Object.keys(data).forEach((key) => {
//当data里的数据是对象时,再次调用Observer对其进行遍历,确保每个属性都被劫持
if (typeof data[key] === "object") {
new Observer(data[key]);
} else {
this.defineReactive(this.data, key, data[key]);
}
});
}
defineReactive(data, key, value) {
//对属性进行响应式处理
Object.defineProperty(data, key, {
//当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
configurable: true,
//当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
enumerable: true,
get() {
return value;
},
set(newValue) {
new Observer(newValue);
if (newValue !== value) {
value = newValue;
console.log("refresh");
}
},
});
}
}
数组也属于对象,所以
typeof data[key] === "object"
对数组也是成立的,这样每个数组属性也可以实现数据劫持,Object.keys(Array)
返回的是数组下标组成的数组,至此对data的每个数据进行了劫持.\
这部分,可能有同学会晕,我用例子来梳理一下:
data: {
name: "dawn",
age: 12,
friend: {
friendName: "ibuki",
},
},
new Observer(data)
当传入这个data
数据时,Observer
会取得他所有的keys
进行判断,当key对应的值为普通数据时,会直接调用defineReactive
来进行数据劫持,而如果碰过对象(friend
),就会在次调用Observer
取出这个对象的值(ibuki
)进行判断和监听,这样完成对data每个属性的监听。
当你对data
赋值时,会触发set
方法,如果你赋的值为对象,则会再次调用Observer
对其里面的属性进行监听(不是对象就返回了!),这样即使你赋的值为对象,当对象里的属性发生改变时,也可以触发set
数组方法的重写
delete data.age//无法监听
data.colors.pop()
虽然data的value完成了数据劫持,但当我们通过数组的方法去更改数组时或是直接删除data数据,数据并不能实现响应式,因为Object.defineProperty
是没有办法处理属性删除和新增的.
因此vue2的响应式,通过数组方法( pop
, push
),或是删除,vue是无法监听的
解决办法:vue2中可以通过vue.detele和vue.set这些vue内置api来改变属性,实现响应式。
而数组增加或减少则需要进行如下操作:
- 对于数组,vue 会更改数组的隐式原型,将其变为 vue 自定义的对象,对象中对原先一些能改变数组的方法进行了重写,这样 vue 就能监听到数组内容的变化,最后将将自定义对象的隐式原型指向 Array.prototype
代码如下:
Object.create()
方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。\
//取得数组的原型对象
const oldArrayProtype = Array.prototype;
//通过 Object.create()方法将newArrayProtype的原型对象设为数组的原型对象
const newArrayProtype = Object.create(oldArrayProtype);
// 对数组方法进行重写
["push", "pop", "shift", "unshift", "splice", "reduce", "filter"].forEach(
(methodName) => {
//给我们自定义对象添加数组方法
newArrayProtype[methodName] = function () {
//当使用以上方法时,就会触发
console.log("refresh");
//用call将this指向当前对象,调用数组原型上的方法,实现原先数组操作
oldArrayProtype[methodName].call(this, ...arguments);
};
}
);
在observer
中加入数组的判断
Object.keys(data).forEach((key) => {
//当data里的数据是对象时,再次调用Observer对其进行遍历,确保每个属性都被劫持
if (Array.isArray(data[key])) {
Object.setPrototypeOf(data[key], newArrayProtype);
}
if (typeof data[key] === "object") {
new Observer(data[key]);
} else {
this.defineReactive(this.data, key, data[key]);
}
});
首先通过上部分代码,定义我们自己的原型对象,这个对象__proto__
数组的原型对象。相当于是一个数组的实例。而后在这个实例上定义一些原先数组的方法,通过call调用数组的原型对象来实现数组的方法,此外我们还可以进行一些其他的操作。
在循环遍历时,当遇到数组,则将其__proto__指向我们定义的原型,这样我们调用数组方法时,实际调用的是我们自己定义的原型对象。可以在其之上进行更新的操作。
这时我们调用数组的方法(pop
,push
),不仅仅会执行数组原本的操作,还会输出refresh
3.模板解析(Compiler)
还记得第一步的过程吗,当数据进行响应式处理后,我们会对数据进行代理,让我们可以直接在vue实例上调用属性,而后我们会调用Compiler
来处理el.
接下来,我们就来讲讲Compiler
与el
.
首先是我们Vue
管理的el
<div id="app"> <input type="text" v-model="message" /> {{name}} </div>
然后讲讲正则表达式:
const reg = /{{(.*)}}/;
.表示任意字符,*表示0或多出,()是一个区块,{}在正则中有特殊含义,因此需要\转义
这个正则的意思是匹配{{name}}里的name\
最后我们来看Compiler
类
class Compiler {
//el -> #app vm当前vue实例
constructor(el, vm) {
// 获取#app div元素
this.el = document.querySelector(el);
this.vm = vm;
this.frag = this.createFragment();
this.el.appendChild(this.frag);
}
createFragment() {
// 创建文档片段
const frag = document.createDocumentFragment();
let child;
//获取#app 的第一个节点
while ((child = this.el.firstChild)) {
this.compile(child);
frag.appendChild(child);
}
return frag;
}
compile(node) {
//(node.nodeType === 1) 一个元素节点,例如 <p> 和 <div>
if (node.nodeType === 1) {
//拿到该节点上的属性 ( type="text" v-model="name")
const attrs = node.attributes;
if (attrs.hasOwnProperty("v-model")) {
//取得v-model绑定的值,即name
const name = attrs["v-model"].nodeValue;
//对该元素节点(input)进行事件监听
node.addEventListener("input", (e) => {
//通过事件对象e来取得input输入框内的值
//因为做过代理,vm[name],即使data里的name属性
this.vm[name] = e.target.value;
});
}
}
//(node.nodeType === 3) Element或者Attr中实际的文字
if (node.nodeType === 3) {
//通过正则来匹配用到的数据
console.log(reg.test(node.nodeValue));
if (reg.test(node.nodeValue)) {
// RegExp.$1 去匹配到的第一个区域 即(.*)里的值name
const name = RegExp.$1.trim();
console.log(name);
//关键点,此处用到了name,我们创建一个wather对象,形成依赖
new Watcher(node, name, this.vm);
}
}
}
}
document.createDocumentFragment()
创建一个新的空白的文档片段
因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(对元素位置和几何上的计算)。因此,使用文档片段通常会带来更好的性能。\
Node.appendChild()
方法将一个节点附加到指定父节点的子节点列表的末尾处。如果将被插入的节点已经存在于当前文档的文档树中,那么 appendChild()
只会将它从原先的位置移动到新的位置(不需要事先移除要移动的节点)。\
介绍完了类的内容,接下来我们来解释一下类执行的过程
首先我们获取#app,然后创建文档片段,通过while来获取#app中的节点.node.firstChild
会获取第一个元素node.appendChild
插入时,原先#app的节点会被移到我们创建的文档片段里,下一次循环时拿到的就是下一个节点了,通过这样的方法,我们可以取到#app中的每一个节点。
之后我们会对节点进行判断,如果是元素节点,则获取节点上的属性,如果属性中有v-model,那么就拿到v-model绑定的值,对这个节点进行事件监听,通过事件对象来将input中的值赋给data中对应的属性。
如果是文本节点,我们通过正则取得该节点使用的变量name,然后new Watcher
创建一个依赖。
我们再来理一理思路:首先new Vue
对数据进行响应式处理,但这时我们并不知道谁用了我们的数据。然后我们对#app进行解析,获取其节点来判断用了哪些data值,当有data值被引用时,创建watcher实例,来作为依赖.
4.生成依赖(watcher)
class Watcher {
constructor(node, name, vm) {
this.node = node;
this.name = name;
this.vm = vm;
Dep.target = this;
this.update();
Dep.target = null;
}
update() {
this.node.nodeValue = this.vm[this.name];
}
}
new watcher
时,传过三个参数,文本节点,用到的属性,以及vue实例。将参数赋给实例后,设置Dep.target = this;
this为当前watcher实例,随后调用update方法,将vue实例上的数据赋给当前节点,这时节点上的{{name}}
就会变成data里的值。
this.vm[this.name]
当我们去vue实例上去data的值时,会触发属性的get方法,这时我们就可以对这个依赖进行操作,但之前我们的get方法并没有添加依赖的操作,我们需要对其进行修改。\
5.依赖收集(Dep)
vue 会为每个响应式数据生成一个 Dep 实例,Dep 实例会做两件事\
- 记录依赖:当有人访问这个数据时,把它记录下来
- 派发更新:当数据被修改时通知所有的依赖数据更改了
我们定义一个Dep类:
class Dep {
constructor() {
// 定义数组,存放依赖
this.subs = [];
}
//当watcher生成时调用sub即使watcher
addSub(sub) {
//将watcher加入数组
this.subs.push(sub);
}
notify() {
this.subs.forEach((sub) => {
//遍历数组,调用watcher上定义的update方法
sub.update();
});
}
}
然后再修改Observer
类中
defineReactive
里的代码
Object.defineProperty(data, key, {
//当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
configurable: true,
//当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
enumerable: true,
get() {
if (Dep.target) {
dep.addSub(Dep.target);
}
return value;
},
set(newValue) {
new Observer(newValue);
if (newValue !== value) {
value = newValue;
dep.notify();
console.log("refresh");
}
},
});
原先的get方法,只会返回值。我们要对其进行修改。
首先再defineReactive
函数中new Dep
来生成一个记录依赖的数组。每有一个属性便会调用一次defineReactive
便会创建一个dep数组,这样每个属性都会有一个自己的dep数组来记录依赖。
然后再get方法中判断Dep.target
的值,当有值时执行dep.addSub(Dep.target)
,将该watcher
加入该值的dep数组中
最后再set方法中添加 dep.notify()
;这样当我们设置值时,会通知dep
上的所有watcher
,执行他们的update
方法
同样我们来理一理思路,首先new Watcher
时让Dep.target = this
然后调用update
方法,这时会使用data
上的name
属性,就会触发这个属性的get
方法,在get
方法中,我们判断是否有Dep.target
来将其加入这个属性的dep
数组中.这样这个watcher
实例就完成了依赖的添加,之后当我们改变这个name
属性时,就会触发set
方法,这时通过dep.notify()
实现数组内所有watcher
的更新,最后让Dep.target = null
。
6.总结代码
现将代码总结如下:\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<input type="text" v-model="name" />
{{name}}
</div>
<script>
//取得数组的原型对象
const oldArrayProtype = Array.prototype;
//通过 Object.create()方法将newArrayProtype的原型对象设为数组的原型对象
const newArrayProtype = Object.create(oldArrayProtype);
// 对数组方法进行重写
["push", "pop", "shift", "unshift", "splice", "reduce", "filter"].forEach(
(methodName) => {
//给我们自定义对象添加数组方法
newArrayProtype[methodName] = function () {
//当使用以上方法时,就会触发
console.log("refresh");
//用call将this指向当前对象,调用数组原型上的方法,实现原先数组操作
oldArrayProtype[methodName].call(this, ...arguments);
};
}
);
//定义Vue实例的类
class Vue {
constructor(options) {
//添加Vue实例属性
this.$options = options;
this.$data = options.data;
this.$el = options.el;
//将实例的data加入响应式系统中
new Observer(this.$data);
//代理this.$data的数据
Object.keys(this.$data).forEach((key) => {
this._proxy(key);
});
//处理el
new Compiler(this.$el, this);
}
_proxy(key) {
// 将this.$data上的数据定义在Vue实例上,这样我们可以自己通过Vue.value来使用数据
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
set(newValue) {
this.$data[key] = newValue;
},
get() {
return this.$data[key];
},
});
}
}
class Observer {
constructor(data) {
this.data = data;
//不是对象或空值则返回
if (typeof data !== "object" || data === null) {
return data;
}
//Object.keys(data)取得data对象keys的数组
Object.keys(data).forEach((key) => {
//当data里的数据是对象时,再次调用Observer对其进行遍历,确保每个属性都被劫持
if (Array.isArray(data[key])) {
Object.setPrototypeOf(data[key], newArrayProtype);
}
if (typeof data[key] === "object") {
new Observer(data[key]);
} else {
this.defineReactive(this.data, key, data[key]);
}
});
}
defineReactive(data, key, value) {
//一个key -> 对应一个dep对象
const dep = new Dep();
//对属性进行响应式处理
Object.defineProperty(data, key, {
//当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
configurable: true,
//当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
enumerable: true,
get() {
if (Dep.target) {
dep.addSub(Dep.target);
}
return value;
},
set(newValue) {
new Observer(newValue);
if (newValue !== value) {
value = newValue;
dep.notify();
console.log("refresh");
}
},
});
}
}
class Dep {
constructor() {
// 定义数组,存放依赖
this.subs = [];
}
//当watcher生成时调用sub即使watcher
addSub(sub) {
//将watcher加入数组
this.subs.push(sub);
}
notify() {
this.subs.forEach((sub) => {
//遍历数组,调用watcher上定义的update方法
sub.update();
});
}
}
class Watcher {
constructor(node, name, vm) {
this.node = node;
this.name = name;
this.vm = vm;
Dep.target = this;
this.update();
Dep.target = null;
}
update() {
this.node.nodeValue = this.vm[this.name];
}
}
const reg = /\{\{(.*)\}\}/;
class Compiler {
//el -> #app vm当前vue实例
constructor(el, vm) {
// 获取#app div元素
this.el = document.querySelector(el);
this.vm = vm;
this.frag = this.createFragment();
this.el.appendChild(this.frag);
}
createFragment() {
// 创建文档片段
const frag = document.createDocumentFragment();
let child;
//获取#app 的第一个节点
while ((child = this.el.firstChild)) {
this.compile(child);
frag.appendChild(child);
}
return frag;
}
compile(node) {
//(node.nodeType === 1) 一个元素节点,例如 <p> 和 <div>
if (node.nodeType === 1) {
//拿到该节点上的属性 ( type="text" v-model="name")
const attrs = node.attributes;
if (attrs.hasOwnProperty("v-model")) {
//取得v-model绑定的值,即name
const name = attrs["v-model"].nodeValue;
//对该元素节点(input)进行事件监听
node.addEventListener("input", (e) => {
//通过事件对象e来取得input输入框内的值
//因为做过代理,vm[name],即使data里的name属性
this.vm[name] = e.target.value;
});
}
}
//(node.nodeType === 3) Element或者Attr中实际的文字
if (node.nodeType === 3) {
//通过正则来匹配用到的数据
console.log(reg.test(node.nodeValue));
if (reg.test(node.nodeValue)) {
// RegExp.$1 去匹配到的第一个区域 即(.*)里的值name
const name = RegExp.$1.trim();
console.log(name);
//关键点,此处用到了name,我们创建一个wather对象,形成依赖
new Watcher(node, name, this.vm);
}
}
}
}
</script>
<script>
const app = new Vue({
el: "#app",
data: {
name: "dawn",
age: 12,
friend: {
friendName: "ibuki",
},
colors: ["red", "orange", "yellow"],
},
});
</script>
</body>
</html>
7. 注意事项
1.函数作用域
在defineReactive
每次创建一个dep都是独立的个体,因为是在函数作用域中创建的,这样每个对象的每个属性就能保存自己的值value
和依赖对象dep
。\
2. 为什么要Dep.target = null
我们看到,只有Dep.target
为真时才会添加依赖。比如在派发更新时会触发watcher
的update
方法,该方法也会触发
get
来取值,但是此时的Dep.target
为null
,不会添加依赖。如果没有置为null
的话,会继续添加watcher
导致混乱\
8.final
那么现在一个简单的响应式就实现了,我们最后来理一遍思路:
- 当new Vue时,我们会用
Object.defineProperty
来对数据继续响应式处理,通过递归的方法来让数组,对象的属性也能进行监听。当然Object.defineProperty并不能监听删除和增加,解决方法是用vue.delete或vue.set来删除,增加属性。然后自己定义一个数组原型来对数组方法进行重写。 - 随后我们开始解析dom,通过
node.firstChild
和node.appendChild
来取得每一个节点,对节点进行判断,通过正则匹配出节点中使用的属性(比如上面的name),以此创建watcher
实例 - 然后在
watcher
中通过Dep.target
和update
来实现watcher
的添加,update
时用到data
中的响应式数据,触发get
,在get
中new Dep
判断Dep.target
的值进行依赖的添加。 - 添加后将
Dep.target = nul
l以免数据更新时再次添加watcher
实例
这种实现方法,每遇到一个插值表达式就会新建一个watcher
,这样每个节点就会对应一个watcher
。实际上这是vue1.x
的做法,以节点为单位进行更新,粒度较细。而vue2.x
的做法是每个组件对应一个watcher
,实例化watcher
时传入的也不再是一个expression
,而是渲染函数,渲染函数由组件的模板转化而来,这样一个组件的watcher
就能收集到自己的所有依赖,以组件为单位进行更新,是一种中等粒度的方式。要实现vue2.x
的响应式系统涉及到很多其他的东西,比如组件化,虚拟DOM
等,而这个系列文章只专注于数据响应式的原理,因此不能实现vue2.x
,但是两者关于响应式的方面,原理相同。
当然,以上例子只是Vue响应式最简单的实现,真正的响应式代码要比这复杂的多,希望这篇文章可以帮你初步理解Vue响应式,感谢观看!
转载自:https://juejin.cn/post/7084200479005081608