vue学到后面需要对响应式源码原理进行剖析,里面蕴含着观察者模式和发布-订阅的模式,对提高编程思维有很多的帮助哦
vue2.x版本和vue3.x版本的响应式实现有所不同,我们将分别进行详解。首先我们来了解这两个基本事实
(1)vue2.x版本响应式基于ES5的Object.defineProperty实现
(2)Vue3.x版本响应式基于ES6(es2015)的Proxy实现
接下来我们先认识Object.defineProperty基本用法
我们定义一个obj对象
var obj = {
name: 'william',
age: 18
}
如果我们想给obj对象增加一个值,通常是使用【obj.name2=“张三”】方式新建一个键值对。如果我们希望对新键值对进行一些配置,比如说不可修改、不可枚举、不可重新定义,这又该怎么办呢?
此时需要用到Object.defineProperty
var obj = {
name: 'william',
age: 18
}
// Object.defineProperty() 操作演示与回顾
Object.defineProperty(obj, 'gender', {
value: '男', // 设置键gender对应的值
writable: true, // 设置键gender的值可修改
enumerable: true, // 设置gender可遍历的,比如可以被for遍历
configurable: true // 可以配置的。即可以重新定义gender,就可以执行下面的代码了
})
当设置enumerable(可枚举)、configurable(可重新配置)都为false时,那么以下两种方法都不可使用:
以下代码,【第1种方法】:重新对obj.gender进行配置不生效,因为一开始就设置了configurable: false不可重新配置;【第2种方法】:遍历obj不生效,因为设置了enumerable: false不可枚举
var obj = {
name: 'william',
age: 18
}
// Object.defineProperty() 操作演示与回顾
Object.defineProperty(obj, 'gender', {
value: '男', // 设置键gender对应的值
writable: false, // 设置键gender的值不可修改
enumerable: false, // 设置gender不可枚举,比如不可以被for遍历
configurable: false // 设置gender不可以重新配置的。
})
// 【第1种方法】:重新配置obj.gender可以枚举
Object.defineProperty(obj, 'gender', {
enumerable: false // 重新设置gender不可枚举。前提是上面代码设置了configurable: true,因此这个重新给gender进行配置无效。
})
// 【第2种方法】:遍历obj不生效,因为设置了enumerable: false不可枚举
for (var k in obj) {
console.log(k, obj[k])
}
接下来我们再来看看如何对obj对象进行自定义操作
对象自定义操作可以通过get和set实现,接下来我们通过代码去理解他们的用法
var obj = {
name: 'william',
age: 18
}
var genderValue = '男'
Object.defineProperty(obj, 'gender', {
get () { // 获取gender值时候触发
console.log('任意获取时需要的自定义操作')
return genderValue
},
set (newValue) { // 设置gender值时候触发,newValue就是设置的值
console.log('任意设置时需要的自定义操作')
genderValue = newValue
}
})
当我们尝试获取obj.gender时,就会触发get方法
当我们尝试给obj对象进行设置值时,就会触发set方法
由此不难看出,get方法在获取对象值时触发,set方法在给obj设置时触发,这个意味着我们可以在获取/设置元素时候自定义一些操作逻辑。
另外,get()除了obj.gender获取值时候触发,通过点击查看值就会触发,因为这样也是一种get值
var obj = {
name: 'william',
age: 18
}
Object.defineProperty(obj, 'gender', {
get () { // 获取gender值时候触发
console.log('任意获取时需要的自定义操作')
return '男'
},
set (newValue) { // 设置gender值时候触发
this.gender = newValue // this指向obj,因为是obj调用gender
}
})
newValue是设置新值的参数,那么this.gender = newValue又重新设置值,那么又会重新触发set方法,由此进入死循环了。
认识了Object.defineProperty基本用法后,我们开始写一个简单的vue2响应式原理
代码如下(示例):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">原始内容</div>
<script>
// 声明数据对象,模拟 Vue 实例的 data 属性
let data = {
msg: 'hello'
}
// 模拟 Vue 实例的对象
let vm = {}
// 通过数据劫持的方式,将 data 的属性设置为 getter/setter
Object.defineProperty(vm, 'msg', { // 前两个参数连起来表示为vm对象设置键msg,最后1个参数表示获取/设置msg键时候会触发的操作。
// 可遍历
enumerable: true,
// 可配置
configurable: true,
get () {
console.log('访问了属性')
return data.msg
},
set (newValue) {
// 更新数据
data.msg = newValue
// 数据更改,更新视图中 DOM 元素的内容
document.querySelector('#app').textContent = data.msg
}
})
</script>
</body>
</html>
我们简单讲一下这个代码实现功能:给vm实例对象注入"msg"键,然后通过(get)访问msg返回的是data内的msg值,(set)设置新的值时候,修改data内的msg值,并且更新到视图。
问题:该版本只是雏形,操作中只监听了一个属性,多个属性无法处理 → 问题解决:下面是处理多个属性方法,修改了某个属性就会更新视图。
代码如下(示例):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">原始内容</div>
<script>
// 声明数据对象,模拟 Vue 实例的 data 属性
let data = {
msg: "hello",
ms2: "world",
};
// 模拟 Vue 实例的对象
let vm = {};
// 遍历被劫持对象的所有属性
Object.keys(data).forEach((key) => { // Object.keys可以获取所有data对象所有属性值
// Vue实例背后底层逻辑:通过数据劫持的方式,将 data 的属性设置为 getter/setter
Object.defineProperty(vm, key, { // key是被劫持对象data的其中一个属性
// 可遍历
enumerable: true,
// 可配置
configurable: true,
get() {
console.log("访问了属性");
return data[key];
},
set(newValue) {
console.log("更新/修改了属性");
// 更新数据
data[key] = newValue;
// 数据更改,更新视图中 DOM 元素的内容
document.querySelector("#app").textContent = data[key];
},
});
});
</script>
</body>
</html>
多个属性数据驱动视图和单个属性驱动视图是基本一致的,只需要额外关注一下这个方法:Object.keys可以获取所有data对象所有属性值。
另外,需要注意的是【我们给vm新增了1个属性,但是vm的属性,并不是data的】 → 因此,通过访问data是无法访问到新属性的
问题又出现:我们无法监听data.arr数组内部数据变化,比如
这需要更深层次地鉴定数组内部元素变化
代码如下(示例):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">原始内容</div>
<script>
// 声明数据对象,模拟 Vue 实例的 data 属性
let data = {
msg1: 'hello',
msg2: 'world',
arr: [1, 2, 3]
}
// 模拟 Vue 实例的对象
let vm = {}
// --- 添加数组方法支持 ---
const arrMethodName = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
// 用于存储处理结果的对象,准备替换掉数组实例的原型指针 __proto__
const customProto = {}
// 为了避免数组实例无法再使用其他的数组方法
customProto.__proto__ = Array.prototype
arrMethodName.forEach(method => {
customProto[method] = function () { //思想:customProto = { "push" : push方法+自定义更新视图 } → arr实例._proto__ = customProto(__proto__指针:就是往上面找方法) => 开始操作:arr实例.push(100,200) → data['arr']: [1,2,3,100,200]
// 确保原始功能可以使用(this 为数组实例)
const result = Array.prototype[method].apply(this, arguments)
// 进行其他自定义功能设置,上一行代码更新arr后,再用新的this更新视图
document.querySelector('#app').textContent = this
// 有的数组操作有返回值比如pop,有的数组操作没有返回值,比如push
return result
}
})
// 遍历被劫持对象的所有属性
Object.keys(data).forEach(key => {
// 检测是否为数组
if (Array.isArray(data[key])) {
// 将当前数组实例的 __proto__ 更换为 customProto 即可
data[key].__proto__ = customProto // __proto__指针:就是往上面找方法
}
// 通过数据劫持的方式,将 data 的属性设置为 getter/setter
Object.defineProperty(vm, key, {
enumerable: true,
configurable: true,
get () {
console.log('访问了属性')
return data[key]
},
set (newValue) {
// 更新数据
data[key] = newValue
// 数据更改,更新视图中 DOM 元素的内容
document.querySelector('#app').textContent = data[key]
}
})
})
</script>
</body>
</html>
简单梳理一下代码方法:
为数组方法push、pop等方法增加一个方法,此处只是增加了document.querySelector(‘#app’).textContent = this,其他保持push原始方法不变。→ 然后遍历data,如果是数组就更改指针__proto__指针指向customProto。
如果data数据内部的内部还有对象,那么再继续进行数据劫持即可,因此下面实现封装与递归操作:
代码如下(示例):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>vue2响应式原理</title>
</head>
<body>
<div id="app">原始内容</div>
<script>
// vuejs程序看到的data数据
let data = {
msg1: "hello",
msg2: "world",
arr: [1, 2, 3],
obj: {
name: "苏苏",
age: 18,
},
};
// 真正操作的对象,间接操作
let vm = {};
// 封装为函数,用于对数据进行响应式处理。在外部函数嵌套了一个内部函数,并且使用了外部函数的变量,由此就会形成一个闭包。
const createReactive = (function () {
const arrMethodName = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
const customProto = {};
customProto.__proto__ = Array.prototype;
arrMethodName.forEach((method) => {
customProto[method] = function () {
const result = Array.prototype[method].apply(this, arguments);
document.querySelector("#app").textContent = this;
return result;
};
});
// 需要进行数据劫持的主体功能,也是递归时需要的功能
return function (data, vm) {
Object.keys(data).forEach((key) => {
// 检测是否为数组。typeof数组返回也是object,所以先判断是否为数组;其次,data数据中只有数组or对象,因此排除了数组,那么下一个else if必然是对象。最后,需要注意的是,typeof null返回的也是object,因此需要加上data[key] != null判断
if (Array.isArray(data[key])) {
// 将当前数组实例的__proto__更换为customProto即可
data[key].__proto__ = customProto;
} else if (typeof data[key] === "object" && data[key] !== null) {
// 检测是否为对象,如果为对象,进行递归操作。
vm[key] = {}; // 类似【let vm={}】,在内部继续增加一个实例。★新增一个内存地址,Object.defineProperty操作就是这个内存地址。
createReactive(data[key], vm[key]);
return;
}
// 通过数据劫持的方式,将data的属性设置为getter/setter
Object.defineProperty(vm, key, {
// vm={},key是data每个键
enumerable: true,
configurable: true,
// writable: true, // 配置了这个就无法配置set
get() {
document.querySelector("#app").textContent = data[key];
return data[key];
},
set(newValue) {
data[key] = newValue;
document.querySelector("#app").textContent = data[key];
},
});
});
};
})();
createReactive(data, vm);
</script>
</body>
</html>
vue2响应式原理基于简单的Object.defineProperty方法,由此实现vm实例通过劫持data数据实时更新视图。