文章目录
前言
承接上文:【vue回顾系列】18-数据响应式原理之Object的变化侦测
乞丐版实现原理
因为数组原型上改变数组内容的方法push、pop、shift、unshift、splice、sort、reverse
等是不会触发getter和setter的。所以不能完全使用监测Object那一套。
实现的切入点在原型方法上,只要在调用api的时候做更新视图等动作,就能实现响应式。但es6之前,js没有提供元编程能力,就是没有提供可以拦截原型方法的能力。而vue2的构建大多用的是es5的语法,所以只能够把改造好后的数组原型替换到数组变量原型上。
为了减轻心智负担,写个乞丐版的实现原理:
// 触发更新视图
function updateView() {
console.log("视图更新");
}
// 重新定义数组原型
const oldArrayProperty = Array.prototype;
// 创建新对象,原型指向 oldArrayProperty ,再扩展新的方法不会影响原型
const arrProto = Object.create(oldArrayProperty);
["push", "pop", "shift", "unshift", "splice"].forEach((methodName) => {
arrProto[methodName] = function () {
updateView(); // 触发视图更新
oldArrayProperty[methodName].call(this, ...arguments); // 上原型上的api指向arrProto
// Array.prototype.push.call(this, ...arguments)
};
});
// 重新定义属性,监听起来
function defineReactive(target, key, value) {
// 递归深度监听
observer(value);
// 核心 API
Object.defineProperty(target, key, {
get() {
return value;
},
set(newValue) {
if (newValue !== value) {
// 深度监听
observer(newValue);
// 设置新值
// 注意,value 一直在闭包中,此处设置完之后,再 get 时也是会获取最新的值
value = newValue;
// 触发更新视图
updateView();
}
},
});
}
// 监听对象属性
function observer(target) {
if (typeof target !== "object" || target === null) {
// 不是对象或数组
return target;
}
// 如果在这里直接修改原型,会污染全局的 Array 原型
// Array.prototype.push = function () {
// updateView()
// ...
// }
if (Array.isArray(target)) { // 在这里改变数组原型
target.__proto__ = arrProto;
}
// 重新定义各个属性(for in 也可以遍历数组)
for (let key in target) {
defineReactive(target, key, target[key]);
}
}
// 测试数据
const data = {
arr: [1, 2, 3],
};
// 监听数据
observer(data);
当使用数组api时,data.arr.push(4);
,通过深度递归函数,直接改写了数组原型,所以触发了视图更新函数。
乞丐版视图更新
上面的视线过程为了突出重点没有详细写怎么去具体更新到视图的每个响应式节点上,这里单独讲下。
目前数组的视图更新我还没搞明白,这里就暂时只分享对象的视图更新。
首先index.html:
<!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>
<ul>
<li id="name"></li>
<li id="age"></li>
</ul>
<script src="./vue.js">
</script>
<script>
let dataObj = {
name: '小米',
age: 1
}
function renderName() {
document.querySelector('#name').textContent = dataObj.name
}
function renderAge() {
document.querySelector('#age').textContent = dataObj.age
}
// 监听数据
observer(dataObj);
// 自动第一次获取数据更新视图
addWatcher(renderName)
addWatcher(renderAge)
</script>
</body>
</html>
其次是我们单独封装的vue.js:
// 触发更新视图(新增代码)
let watchers = new Set(); // 要触发更新具体位置的函数集合
function updateView() {
console.log("视图更新");
for (let watcher of watchers) watcher(); // 遍历执行
}
// 传入一个读取函数,通过defineProperty把函数添加到watchers里(新增代码)
function addWatcher(fn) {
window.__watcher = fn; // 暂时挂在全局上,在get中可以读取到
fn(); // 第一次读取
window.__watcher = null;
}
// 重新定义属性,监听起来
function defineReactive(target, key, value) {
// 递归深度监听
observer(value);
// 核心 API
Object.defineProperty(target, key, {
get() {
if (window.__watcher) watchers.add(window.__watcher); // 第一次读取就埋入依赖 !!!!!新增代码
return value;
},
set(newValue) {
if (newValue !== value) {
// 深度监听
observer(newValue);
// 设置新值
// 注意,value 一直在闭包中,此处设置完之后,再 get 时也是会获取最新的值
value = newValue;
// 触发更新视图
updateView();
}
},
});
}
// 监听对象属性
function observer(target) {
if (typeof target !== "object" || target === null) {
// 不是对象或数组
return target;
}
// 重新定义各个属性(for in 也可以遍历数组)
for (let key in target) {
defineReactive(target, key, target[key]);
}
}
源码实现的思路
为了方便理解和记忆,就不贴源码了,实现思路我也进行了一些删减。
拦截器
拦截器本质是一种逻辑处理,例如上面乞丐版的这一部分就是拦截器的逻辑:
// 重新定义数组原型
const oldArrayProperty = Array.prototype;
// 创建新对象,原型指向 oldArrayProperty ,再扩展新的方法不会影响原型
const arrProto = Object.create(oldArrayProperty);
["push", "pop", "shift", "unshift", "splice"].forEach((methodName) => {
arrProto[methodName] = function () {
updateView(); // 触发视图更新
oldArrayProperty[methodName].call(this, ...arguments); // 上原型上的api指向arrProto
// Array.prototype.push.call(this, ...arguments)
};
});
然后把这个新对象替换掉数组变量上原来的原型,这一步会交给一个Observer的类去实现,例如乞丐版的这个片段:
if (Array.isArray(target)) { // 在这里改变数组原型
target.__proto__ = arrProto;
}
注意:拦截器不是加在全局的Array原型上。
拦截器的作用就是当数组使用了api,可以不走原生数组原型上的,走我们自己定义的新数组原型,在这个新原型上的api逻辑中可以做一些其他事情,比如发送变化通知。
收集依赖
虽然Array的变化监测与Object不一样,但是依赖收集的原理和Object很像,也是在getter中收集。
同理,data()中的一个数组变量被用到,就会被读取数据,触发getter,watcher被创建,被dep收集起来。
当这个数组调用了带有拦截器的方法,拦截器中会让dep遍历通知它收集到的依赖。其他的基本上就和对象响应式原理一样了。
新增元素
当用了类似push方法去新增元素的时候,通过拦截器可以获取到新增的这个元素,然后再把这个元素放入Observer,就变成响应式的了。
缺陷
当以这样的形式修改数组时,时不会触发拦截或监听的,因为没有调用api:
this.arr[3] = 1
this.arr.length = 2
解决方法:
this.set(this.arr, 1, 2)
this.arr.splice(2)
defineProperty数组真的监听不到吗
这是我后面了解到的,原来defineProperty是可以监听到arr[0]=1这种形式的修改的。
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function defineGet() {
console.log(`get key: ${key} value: ${value}`)
return value
},
set: function defineSet(newVal) {
console.log(`set key: ${key} value: ${newVal}`)
value = newVal
}
})
}
function observe(data) {
Object.keys(data).forEach(function (key) {
defineReactive(data, key, data[key])
})
}
let arr = [1, 2, 3]
observe(arr)
arr[1] = 9
那为什么vue2不去实现反而煞费苦心的使用$set呢,原因尤大大亲自说过,大概意思就是数组的数据量有可能过于庞大,每次响应式处理都要遍历一遍,更新也要遍历一遍,性能太差。
对象和数组响应式原理总结
组件更新过程
大致分为两个阶段。
最好先了解vdom:【vue回顾系列】03-什么是模板编译,vdom以及它的更新机制是怎么样的
首先经历的是初次渲染过程:
- 解析模板,生成render函数。
- 触发响应式前半段流程,监听data属性(第一次只会触发getter)。
- 执行render函数,生成vNode,视图层渲染。
数据更新过程:
- 修改data触发了setter,走响应式后半段流程。
- 重新执行render函数,生成新的vNode
- 执行patch(旧vNode, 新vNode),diff算法更新视图。
整体大致流程图(这里直接偷官方的一张图):
异步渲染优化
当有很多dom的增删改情况发生时,vue会汇总dom的修改和操作,一次性更新视图,提升性能,这也是vue为什么做异步渲染的原因。
如何回答响应式原理的过程
其实就是回答组件初次加载和更新的过程,再把中间的响应式原理细说即可。
尾巴
文章有错误欢迎指出。
vue3完美的解决了对象和数组响应式实现的缺点:【vue3学习系列】浅谈proxy实现的响应式原理