vue响应式实现原理
前言
上一章我们实现了最简单的effect响应式,当时是有很多地方需要改进的,后面的都是在此基础上进行完善
一、链表
在讲解effect后续的内容之前,我们必须先了解一个数据结构:链表 ,这是一个非常重要的东西,因为源码里面effect就是依靠链表结构来处理的;
当然我们这里只是简单说一下链表的结构,具体的大家可自行查看
1.链表的结构
链表的结构其实不算复杂,一张图就能看懂
这就是一个链表,就这么简单,当然,这是一个单向链表,因为只有一个指向下一节点的指针;
javascript
const node1 = {
data: 10, // 数据域
next: null // 指针域,初始指向null
};
const node2 = {
data: 20,
next: null
};
const node3 = {
data: 30,
next: null
};
// 构建链表关系(节点1 → 节点2 → 节点3)
node1.next = node2;
node2.next = node3;
// 此时链表结构为:
// node1 → node2 → node3 → null
用js来表示,就是如上所示;
当然看到单向链表那就有双向链表(vue源码是用的是双向链表 ),那我们再画张图演示一下
这就是双向链表,我们用js来表示一下
javascript
const node1 = {
data: 10,
prev: null, // 前向指针(节点1是头节点,无前驱)
next: null // 后向指针
};
const node2 = {
data: 20,
prev: null,
next: null
};
const node3 = {
data: 30,
prev: null,
next: null
};
// 建立双向连接
node1.next = node2; // 节点1的next指向节点2
node2.prev = node1; // 节点2的prev指向节点1
node2.next = node3; // 节点2的next指向节点3
node3.prev = node2; // 节点3的prev指向节点2
以上就是链表的简单概述,具体请自行查阅资料
二、将链表使用到effect中
1.问题
上一篇文章我们编写了一个最简单的effect响应式,细心的小伙伴就会发现有问题
javascript
<script type="module">
// import {
// effect,
// ref,
// } from "./node_modules/vue/dist/vue.esm-browser.prod.js";
import { effect, ref } from "./reactive/effect1.js";
const name = ref("vue");
effect(() => {
const time = new Date().getTime();
console.log(time);
console.log(name.value);
});
effect(() => {
const time = new Date().getTime();
console.log(time);
console.log(name.value);
});
setTimeout(() => {
name.value = "react";
}, 1000);
如果我effect监听两次,那么修改之后effect有且只有第二个effect执行了,为什么呢;
javascript
class RefImpl {
_v_isRef = true;
subs;
constructor(value) {
this.value = value;
}
get value() {
// 收集依赖
// 第二次get之后subs变了
if (activeSub) {
this.subs = activeSub;
}
return this._value;
}
set value(newVal) {
// 触发更新
this._value = newVal;
console.log(123);
this.subs?.();
}
}
function ref(val) {
return new RefImpl(val);
}
let activeSub = null;
function effect(fn) {
activeSub = fn;
activeSub();
activeSub = null;
}
export { effect, ref };
因为effect执行第二次的时候activeSub变成了最后一次执行的effect,所以导致只执行了一次,那么如何解决这个问题呢
2.解决
在vue3前面的版本中,是用数组的方式去处理的,后面考虑到性能问题,就转成的链表形式;
有一篇文章详细的说明了性能问题(但是我忘了文章的地址了 QAQ,如果后面我找到了会补充的)
那现在我们开始改造之前的依赖收集和派发更新的代码
依赖收集
javascript
class RefImpl {
_v_isRef = true;
/**
* 订阅者链表头节点
*/
subs;
/**
* 订阅者链表尾节点
*/
subsTail;
constructor(value) {
this.value = value;
}
get value() {
if (activeSub) {
trackRef(this);
}
return this._value;
}
set value(newVal) {
// 触发更新
this._value = newVal;
// this.subs?.();
triggerRef(this);
}
}
我们在原来的ref的类中添加两个属性,subs和subsTail,代表的是订阅者头节点和尾节点,然后再get方法中执行依赖收集
以下是trackRef的代码
javascript
function trackRef(dep) {
// 收集依赖
if (activeSub) {
const newLink = {
sub: activeSub,
nextSub: null,
prevSub: null,
};
// this.subs = newLink;
// 尾插法
if (dep.subsTail) {
dep.subsTail.nextSub = newLink;
newLink.prevSub = dep.subsTail;
dep.subsTail = newLink;
} else {
// 没有的话头尾一样
dep.subs = newLink;
dep.subsTail = newLink;
}
}
}
当开始收集依赖的时候,我们创建一个新的节点newLink,既然是双向链表那就肯定有上一个节点和下一个节点这两个参数,在这里分别对应nextSub和prevSub,当然还需要记录当前的订阅者(即effect的回调函数 );
然后我们默认是用尾插法,即在最后面插入节点,此时我们判断当前的节点是否有尾节点,如果有我们需要进行双向链表的插入
即将该尾节点的下一个节点指向当前节点,将原本的尾节点指向该节点,将该节点的上一个节点指向原来的尾节点
听起来有点绕啊,我们画个图演示一下就明白了
这是默认的双向链表,此时我们需要在节点3中插入节点4,节点3是原本这个链表的尾节点,在这里就是dep.subsTail,我们最后要变成下面这种链表
那么按照图例所示,是不是需要将节点3(原dep.subsTail 尾节点 )的next指向节点4,同时将节点4的prev指向节点3,对应的就是 dep.subsTail.nextSub = newLink;newLink.prevSub = dep.subsTail;
,最后节点4变成这个链表的尾节点,就是将原来的dep.subsTail 指向节点4 ,对应的是 dep.subsTail = newLink;
节点4在代码中就是newLink;
上面这种是如果有尾节点的情况,如果我没有呢,就是第一次收集;
此时头尾的节点都是指向同一个节点,即 dep.subs = newLink; dep.subsTail = newLink;
头尾相同
以上是依赖收集的更新
派发更新
派发更新就是遍历这个链表,分别执行链表的sub
javascript
function triggerRef(dep) {
if (dep.subs) {
propagate(dep.subs); //触发更新
}
}
function propagate(subs) {
// 依次执行
let link = subs; //保存头节点
let queuedEffects = []; //保存需要执行的effect
while (link) {
// 注意:这里不能直接执行effect,因为effect可能会再次触发set,导致死循环
queuedEffects.push(link.sub); //将effect保存到数组中
link = link.nextSub; //指向下一个节点
}
// 依次执行
queuedEffects.forEach((effect) => {
effect(); //执行effect
});
}
这里需要注意的是,不能在遍历的过程中执行effect的回调函数,为什么呢,给个案例就知道了
javascript
function propagate(subs) {
// 依次执行
let link = subs; //保存头节点
let queuedEffects = []; //保存需要执行的effect
while (link) {
// 注意:这里不能直接执行effect,因为effect可能会再次触发set,导致死循环
link.sub();
link = link.nextSub; //指向下一个节点
}
// 依次执行
// queuedEffects.forEach((effect) => {
// effect(); //执行effect
// });
}
假设我们在遍历的时候执行effect回调
javascript
<script type="module">
// import {
// effect,
// ref,
// } from "./node_modules/vue/dist/vue.esm-browser.prod.js";
import { effect, ref } from "./reactive/effect2.js";
const name = ref("vue");
effect(() => {
const time = new Date().getTime();
console.log(time);
name.value = "react";
console.log(name.value);
});
// effect(() => {
// const time = new Date().getTime();
// console.log(time);
// console.log(name.value);
// });
setTimeout(() => {
name.value = "react";
}, 1000);
此时我们在effect中修改响应式的值,然后你就会看到
进入无限循环导致栈溢出,所以我们只能等到循环结束的时候才能执行
总结
以上就是链表运用到响应式中,当然,这个还只是很小且比较容易理解的一部分,后面的部分会很难理解;
希望大家结合源码多看几遍