《Vue.js设计与实现》—Vue3响应系统的原理

news2024/11/22 8:39:12

一、响应式数据与副作用函数

1. 副作用函数

1-1 指令材料

在JavaScript中,副作用函数是指在执行过程中对外部环境产生可观察的变化或影响的函数。这种函数通常会修改全局变量、修改传入的参数、执行I/O操作(如读写文件或发送网络请求)、修改浏览器的DOM结构、订阅事件等。

1-2 限定条件

修改全局变量、修改传入的参数、执行I/O操作(如读写文件或发送网络请求)、修改浏览器的DOM结构、订阅事件等

1-3 有效实例

  1. 修改全局变量:
let count = 0;

function increment() {
	count++;
}
console.log(count); // 0
increment();
console.log(count) // 1

总结规律: 修改了全局变量的函数,叫做副作用函数

  1. 修改传入的参数:
function addOne(arr) {
  arr.push(1);
}

let numbers = [1, 2, 3];
console.log(numbers); // [1, 2, 3]
addOne(numbers);
console.log(numbers); // [1, 2, 3, 1]

总结规律:修改了入参的值的函数,叫做副作用函数

  1. 发送网络请求:
function fetchData() {
  fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error(error));
}

fetchData(); // 发送网络请求并打印返回的数据

发送网络请求涉及到与外部资源的交互,例如向服务器发送数据并等待响应。这个过程会导致网络连接的建立和断开,数据的传输和处理等。这些操作都会改变系统的状态,例如网络连接状态、数据的传输状态等。

由于发送网络请求会引起系统状态的改变,因此它被认为是副作用函数。

总结规律: 改变网络连接状态、数据的传输状态的函数,叫做副作用函数

  1. 修改浏览器的DOM结构:
<button id="myButton">Click me</button>

<script>
function changeButtonText() {
  document.getElementById('myButton').textContent = 'Clicked!';
}

document.getElementById('myButton').addEventListener('click', changeButtonText);
</script>

总结规律:修改了浏览器的DOM结构(包括文本内容)的函数,叫做副作用的函数

  1. 订阅事件:
function handleKeyPress(event) {
  console.log('Key pressed:', event.key);
}

document.addEventListener('keypress', handleKeyPress);

当我们订阅一个事件时,我们正在告诉系统在特定条件下执行某些操作。这些操作可能涉及到与外部资源的交互,例如向数据库写入数据、发送通知、更新UI等。这些操作会改变系统的状态,例如数据的更新、UI的变化等。

由于订阅事件会引起系统状态的改变,因此它被认为是副作用函数。
总结规律: 使用addEventListener订阅事件的函数,叫做副作用函数。

总结:调用函数时,函数改变了函数外的变量或系统状态,包括全局变量、入参的值、改变网络连接状态、数据的传输状态、浏览器的DOM结构、可能涉及与外部资源进行交互的函数,统称为副作用函数。
简而言之:函数影响了外部的变化,就产生了副作用,即该函数就叫做副作用函数。

1-4 新知识与旧知识的关联

副作用函数和全局样式覆盖导致页面的错位
本质上:都是外部环境发生变化,从而产生了副作用

1-5 表达转述

副作用函数就像吃感冒药,感冒药能治好你的病(函数本身的作用),同时也让你原本身体的抵抗力下降了(副作用)。
在这里插入图片描述

2. 响应式数据

假设在一个副作用函数中读取了某个对象的属性:

const obj = {text: 'hello world'}
function effect() {
	// effect函数执行时会读取obj.text
	document.body.innerHTML = obj.text
}

当obj.text的值发生变化时,我们希望副作用函数effect会重新执行,如果能实现这个目标,那么对象obj就是响应式数据。

obj.text = 'hello Vue3' // 当执行这段代码时,自动触发effect()方法,obj就是响应式数据

总结规律:当某个对象的属性发生改变时,如果读取该对象属性的副作用函数自动执行,那么这个对象就是响应式数据。

二、响应式数据的基本实现

1. 观察发现两点线索

  1. 当副作用函数effect执行时,会触发字段obj.text的读取操作
  2. 当修改obj.text的值时,会触发字段obj.text的设置操作。

2. 实现思路

  1. 拦截对象读取和设置操作

  2. 当我们读取obj.text时,我们可以把副作用函数effect存储到一个“桶”中
    在这里插入图片描述

  3. 当我们设置obj.text时,再把副作用函数effect从“桶”里取出来并执行即可
    在这里插入图片描述

3. 拦截器的实现方式

Vue2 使用 Object.defineProperty 函数实现
Vue3 中使用ES6的 代理对象Proxy实现

// 存储副作用函数的桶
const bucket = new Set()

// 原始数据
const data = {text: 'hellowold'}

// 对原始数据的代理
const obj = new Proxy(data, {
	// 拦截读取操作
	get(target, key) {
		// 将副作用函数 effect 添加到存储副作用函数的桶中
		bucket.add(effect)
		return target[key]
	},
	// 拦截设置操作
	set(target, key, newVal) {
		// 设置属性值
		target[key] = newVal
		// 把副作用函数从桶中取出并执行
		bucket.forEach(fn => fn())
		// 返回true 代表设置操作成功
		return true
	}
})

function effect() {
	document.body.innerHTML = obj.text
}

// 执行副作用函数, 触发读取
effect()
// 1秒后修改响应式数据
setTimeout (() => {
	obj.text = 'hello vue3'
}, 1000)

总结:

  1. 设置一个拦截器,读取拦截时,把读取该属性的副作用函数放入桶中
  2. 设置拦截时,把该副作用函数从桶中并执行,从而实现响应式数据

4. 新知识与旧知识关联

响应式数据 和 垃圾回收机制的引用计数
属性被读取时,副作用函数被存储在“桶”中,属性被设置时,副作用函数从“桶”取出来,并执行
对象在被引用时,对象的引用计数 +1,当对象不被引用时,引用计数就是为0,所使用的内存即被垃圾回收机制回收
本质上:被引用时,进行内存存储,无需使用时,进行内存释放。

// 把 {name: ‘John’} 代指A
//  A 对象被obj1 引用1次,A 引用计数=1次
let obj1 = { name: 'John' };
//  A 对象被obj2 引用1次,A 引用计数=2次
let obj2 = obj1; 


// 解除obj1 的引用对象A, A 引用计数 = 1 次
obj1 = null;
// 解除obj2 的引用对象A, A 引用计数 = 0 次
obj2 = null; 

// 对象A 没有被任何变量引用,将被垃圾回收机制回收


5. 转换表述

响应式数据就像是一个关联器,把生活影像和某个词语进行关联,我们生活中事情片段都会被大脑记录下来,突然有人问你,什么是幸福时,大脑就会自动把相关的影像自动的播放出来。
在这里插入图片描述

三、设置一个完善的响应式系统

1. 响应系统的工作流程

  1. 读取操作发生时,将副作用函数收集到“桶”中;
  2. 设置操作发生时,从“桶”中取出副作用函数并执行

2. 副作用函数的匿名化

  1. 硬编码副作用函数的名称,一旦副作用函数的名字改名了,代码无法正常工作
  2. 希望副作用函数是一个匿名函数,也能够被正确的收集到“桶”中
  3. 需要提供一个用来注册副作用函数的机制
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用来注册副作用函数
function effect(fn) {
	// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
	activeEffect = fn
	// 执行副作用函数
	fn()
}

调用优化

effect (
	//  一个匿名的副作用函数
	() => {
		document.body.innerHTML = obj.text
	}
)

梳理逻辑:

  1. 使用一个匿名副作用函数作为effect函数的参数
  2. 当effect函数执行时,首先会把匿名的副作用函数fn 赋值给全局变量 activeEffect。
  3. 接着执行被注册的匿名副作用函数fn,这将会触发响应式数据的obj.text的读取操作,进而触发代理对象Proxy的get拦截函数
const obj = new Proxy (data, {
	get(target, key) {
		// 将activeEffect 中存储的副作用函数收集到“桶”中
		if (activeEffect) {
			bucket.add(activeEffect)
		}
		return target[key]
	},
	set (target, key, newVal) {
		target[key] = newVal
		bucket.forEach(fn => fn())
		return true
	}
})

整合所有代码,放入控制台测试一下

// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用来注册副作用函数
function effect(fn) {
	// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
	activeEffect = fn
	// 执行副作用函数
	fn()
}

// 存储副作用函数的桶
const bucket = new Set()

// 原始数据
const data = {text: 'hellowold'}

const obj = new Proxy (data, {
	get(target, key) {
		// 将activeEffect 中存储的副作用函数收集到“桶”中
		if (activeEffect) {
			bucket.add(activeEffect)
		}
		return target[key]
	},
	set (target, key, newVal) {
		target[key] = newVal
		bucket.forEach(fn => fn())
		return true
	}
})

effect (
	//  一个匿名的副作用函数
	() => {
		document.body.innerHTML = obj.text
	}
)

// 1秒后修改响应式数据
setTimeout (() => {
	obj.text = 'hello vue3'
}, 1000)

2-1 新知识与旧知识关联

命名函数转换为匿名函数 与 类的使用
本质上:通过对代码的封装,把多种实例的相同属性,抽象成一类公共事物,并提高代码的可维护性和可复用性

// 命名函数
function add(a, b) {
  return a + b;
}

// 将命名函数转换为匿名函数
var addFunc = function(a, b) {
  return a + b;
};

// 使用匿名函数
console.log(addFunc(2, 3)); // 输出: 5

通过将命名函数转换为匿名函数,我们可以将函数赋值给变量,并根据需要在代码中使用它。这种转换可以提供更大的灵活性和代码封装性,使得代码更易于维护和复用。

// 学生类
class Student {
  constructor(name, age, grade) {
    this.name = name;
    this.age = age;
    this.grade = grade;
  }

  introduce() {
    console.log(`我是${this.name},今年${this.age}岁,读${this.grade}年级。`);
  }
}

// 创建学生实例
const student1 = new Student("张三", 15, 9);
const student2 = new Student("李四", 16, 10);
const student3 = new Student("王五", 14, 8);

// 调用学生实例方法
student1.introduce(); // 输出: 我是张三,今年15岁,读9年级。
student2.introduce(); // 输出: 我是李四,今年16岁,读10年级。
student3.introduce(); // 输出: 我是王五,今年14岁,读8年级。

通过抽象一个学生类,创建了多个学生实例,并且可以方便地调用实例的方法和访问实例的属性。这种面向对象的编程方式可以帮助我们更好地组织和管理代码,并提高代码的可维护性和可复用性。

2-2 表达转述

命名函数就像,周一吃猪脚饭、周二吃杀猪粉、周三吃大盘鸡、周四吃哈迪斯、周五吃炒菜。
匿名函数就像,找到每个实例的共性,统称为出去吃饭
在这里插入图片描述

3. 建立副作用函数与被操作的字段的关联性

假如在响应式数据obj设置一个不存在的属性时

effect (
	// 匿名副作用函数
	() => {
		console.log('effect run')
		// 会打印2次
		document.body.innerHTML = obj.text
	}
) 

setTimeout(() => {
	// 副作用函数中并没有读取notExist 属性的值
	obj.notExist = 'hello vue3'
}, 1000)

分析现象:
可以看到,匿名副作用函数内部读取了字段obj.text的值,于是匿名副作用函数与字段obj.text之间会建立响应联系。
接着,开启一个定时器,1秒后对为对象obj添加新的notExist属性。
我们知道在匿名副作用函数内没有读取obj.notExist属性的值,所以理论上,字段obj.notExist 并没有与副作用函数建立联系,
因此,定时器内的语句执行不应该触发匿名副作用函数重新执行。实际上,obj.notExist 的赋值也触发了副作用函数的执行

找到原因:
然后导致这个问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的关系。

解决方法:
只需要在副作用函数与被操作的字段之间建立联系即可,这需要我们重新设计“桶”的数据结构

提出问题:
应该设计怎样的设计数据结构?

观察代码

effect (function effectFn() {
	document.body.innerHTML = obj.text
})

这段代码中存在三个角色

  1. 被操作(读取)的代理对象obj;
  2. 被操作(读取)的字段名text;
  3. 使用effect函数注册的副作用函数effectFn

如果用target 表示一个代理对象所代理的原始对象,用key来表示被操作的字段名,用effectFn来表示被注册的副作用函数
三个角色关系如下:
在这里插入图片描述
如果有两个副作用函数同时读取同一个对象的属性值

effect (function effectFn1 () {
	obj.text
})
effect (function effectFn2 () {
	obj.text
})

关系如下:
在这里插入图片描述
如果一个副作用函数中读取了同一个对象的两个不同属性

effect(function effectFn() {
	obj.text1
	obj.text2
})

在这里插入图片描述

如果在不同的副作用函数中读取了两个不同对象的不同属性

effect (function effectFn1 () {
	obj1.text1
})
effect (function effectFn2 () {
	obj2.text2
})

在这里插入图片描述
总之,其实就是一个树型结构。如图所示,如果设置了obj2.text2的值,就只会触发 effectFn2 函数的重新执行,并不会导致effectFn1函数重新执行。

3-1 设置新桶的数据结构

// 存储副作用函数的桶
const bucket = new WeakMap()

3-2 修改get/set拦截器代码

在JavaScript中,WeakMap是一种特殊的Map数据结构,它的键只能是对象,并且对于键引用的对象是弱引用。这意味着,如果一个键引用的对象在其他地方没有被引用,那么它将会被垃圾回收。
在JavaScript中,Map是一种键值对的数据结构,它可以用来存储和操作各种类型的值
在JavaScript中,Set是一种集合数据结构,它允许存储不重复的值。

const obj = new Proxy(data, {
	// 拦截读取操作
	get(target, key) {
		// 没有 activeEffect,直接return
		if(!activeEffect) return target[key]
		// 根据target 从"桶"中取得depsMap, 它也是一个Map类型: key => effects
		let depsMap = bucket.get(target)
		// 如果不存在depsMap,那么新建一个map并与target关联
		if(!depsMap) {
			bucket.set(target, (
				depsMap = new Map()
			))
		}
		// 再根据key 从depsMap中取得deps,它是一个Set类型
		// 里面存储着所有与当前key相关联的副作用函数effects
		let deps = depsMap.get(key)
		// 如果deps不存在,同样新建一个Set 并与key关联
		if(!deps) {
			depsMap.set(key, (
				deps = new Set()
			))
		}
		// 最后将当前激活的副作用函数添加到"桶"里
		deps.add(activeEffect)
		
		// 返回属性值
		return target[key]
	},
	
	// 拦截设置操作
	set(target, key, newVal) {
		// 设置属性值
		target[key] = newVal
		// 根据target从桶中取得depsMap, 它是key --> effects
		const depsMap = bucket.get(target)
		if (!depsMap) return
		// 根据key 取得所有副作用函数effects
		const effects = depsMap.get(key)
		// 执行副作用函数
		effects && effects.forEach (fn => fn())
	}
})

从这段代码可以看出构建数据结构的方式,分别使用了WeakMap、Map 和Set
WeakMap 由 target --> Map 构成
Map由 key --> Set 构成

  1. 其中WeakMap的键是原始对象target, WeakMap的值是一个Map 实例
  2. 而Map的键是原始对象target的key,Map的值是一个由副作用函数组成的Set
    它们的关系如下图

在这里插入图片描述
为了方便描述,上图中的Set数据结构所存储的副作用函数集合称为key的依赖集合

4. WeakMap和Map的区别

const map = new Map();
const weakmap = new WeakMap();

(function (){
	const foo = {foo: 1};
	const bar = {bar: 2};
	
	map.set(foo, 1);
	weakmap.set(bar, 2);
})()

foo 和 bar,这个两个对象,分别作为map和weakmap的key, 当该函数表达式执行完毕后。
对于foo 对象来说,它还是作为map的key被引用, 不会把它从内存中移除。
对于bar对象来说,weakmap的key是弱引用,不影响垃圾回收机制,一旦key被垃圾回收器回收,那么对应的键和值就访问不到了。
所以WeakMap经常用于存储哪些只有当key所引用的对象存在时才有价值的信息,例如上述场景,如果target对象没有任何引用了,这时垃圾回收器会完成回收任务。

总结:WeakMap适用于需要存储临时数据或私有数据,不会影响垃圾回收的场景;而Map适用于需要持久存储的数据,不会被垃圾回收。

5. 封装追踪函数track和触发函数trigger

把get拦截函数中,把副作用函数收集到“桶”中,封装成track函数,表示追踪
把set拦截函数中,副作用函数重新执行的逻辑,封装成trigger函数,表示触发

// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用来注册副作用函数
function effect(fn) {
	// 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
	activeEffect = fn
	// 执行副作用函数
	fn()
}

// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = {text: 'hellowold'}

const obj = new Proxy(data, {
	// 拦截读取操作
	get(target, key) {
		// 将副作用函数activeEffect 添加到存储副作用函数的桶中
		track(target, key) 
		// 返回属性值
		return target[key]
	},
	// 拦截设置操作
	set(target, key, newVal) {
		// 设置属性值
		target[key] = newVal
		// 把副作用函数从桶里取出来并执行
		trigger(target, key)
	}
})

// 在get拦截函数内调用track函数追踪变化
function track(target, key) {
	// 没有activeEffect, 直接return
	if(!activeEffect) return
	let depsMap = bucket.get(target)
	if(!depsMap) {
		bucket.set(target, (
			depsMap = new Map()
		))
	}
	let deps = depsMap.get(key)
	if (!deps) {
		depsMap.set(key, (
			deps = new Set()
		))
	}
	deps.add(activeEffect)
}

// 在set 拦截函数内调用trigger函数触发变化
function trigger (target, key) {
	const depsMap = bucket.get(target)
	if (!depsMap) return
	const effects = depsMap.get(key)
	effects && effects.forEach(fn => fn())
}

effect (
	//  一个匿名的副作用函数
	() => {
		console.log('effect run')
		document.body.innerHTML = obj.text
	}
)

// 1秒后修改响应式数据
setTimeout (() => {
	obj.text = 'hello vue3'
}, 1000)

6. 新知识与旧知识关联

新桶的数据结构 与 ?

7. 转换表述

WeaKMap 就像是一个购物中心, 购物中心的key就是所有店铺的分类,比如美食、购物、亲子、休闲娱乐等。

购物中心的Value(Map)就是某一类所有的店铺集合,比如,美食类:臭臭火锅、鲍师傅、木屋烧烤等。

店铺集合的key就是一个具体店铺的名称,比如,木屋烧烤
店铺集合的Value 就是你想吃这家店的什么食物,比如,一打生蚝、两个香辣猪蹄、10串牛板筋、一扎啤酒等等(set依赖集合)

吃完饭后,消消食,陪老婆逛街买衣服

MeakMap的Key和Value(Map)
找到服装类:歌莉娅、古驰、热风、爱依服、等等
摸了摸口袋,拉着老婆走进热风
坐在店铺凳子打完一把王者后,老婆兴高采烈的提着大包小包的袋子,跟我说买好了
Map的Key 和 Value (Set)
热风:购买的物品

总结:新桶的数据结构,就是一次周末的购物之旅。
在这里插入图片描述

四、分支切换与cleanup

1. 分支切换

const data = {
	ok: true,
	text: 'hello world'
}
const obj = new Proxy(data, {/*...*/})

effect(function effectFn() {
	document.body.innerHTML = obj.ok ? obj.text : 'not'
})

在effectFn 函数内部存在一个三元表达式,根据字段obj.ok值的不同会执行不同的代码分支。
当字段obj.ok的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。

总结:分支切换就是对象的字段发生变化时,所关联的副作用函数也发生了变化

分支切换可能会产生遗留的副作用函数。拿上面这段代码来说,字段 obj.ok 的初始值为 true,这时会读取字段 obj.text 的值,所以当 effectFn 函数执行时会触发字段 obj.ok 和字段 obj.text 这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之间建立的联系如下:
在这里插入图片描述
在这里插入图片描述
可以看到,副作用函数 effectFn 分别被字段 data.ok 和字段 data.text 所对应的依赖集合收集。当字段 obj.ok 的值修改为 false,并触发副作用函数重新执行后,由于此时字段 obj.text 不会被读取,只会触发字段 obj.ok 的读取操作,所以理想情况下副作用函数 effectFn 不应该被字段 obj.text 所对应的依赖集合收集,理想情况下如下图
在这里插入图片描述
实际情况是,在做分支切换时,会产生了遗留的副作用函数(obj.text 的effectFn),如果我们尝试修改obj.text的值

obj.text = 'hello vue3'

就会导致副作用函数重新执行, 即使document.body.innerHTML 的值不需要变化

总结:分支切换时,产生遗留的副作用函数

1-1 新知识与旧知识关联

分支切换和定时器
分支切换时,产生遗留的副作用函数
定时器执行后,产生了内存的消耗
本质上:完成某个功能后,都产生一定副作用

let count = 0;

function delayedIncrement() {
  count++;
  console.log(count);
}

setTimeout(delayedIncrement, 1000);

1-2 表达转述

分支切换就像吃饭,吃完后,总会留下一片狼藉。
在这里插入图片描述

2.cleanup

解决问题的思路:每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除
在这里插入图片描述
当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。
要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我需要重新设计副作用函数。
在effect内部我们定义了新的effectFn函数,并为其添加了effectFn.deps属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
	const effectFn = () => {
		// 当effectFn 执行时,将其设置为当前激活的副作用函数
		activeEffect = effectFn
		fn()
	}
	// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
	effectFn.deps = []
	// 执行副作用函数
	effectFn()
}

effectFn.deps 数组如何收集依赖集合

function track(target, key) {
	// 没有activeEffect return
	if (!activeEffect) return

	let depsMap = bucket.get(target)
	if(!depsMap) {
		bucket.set(target, (
			depsMap = new Map()
		))
	}
	let deps = depsMap.get(key) 
	if (!deps) {
		depsMap.set(key, (
			deps = new Set()
		))
	}
	// 把当前激活的副作用函数添加到依赖集合deps中
	deps.add(activeEffect)
	// deps 就是一个与当前副作用函数存在联系的依赖集合
	// 将其添加到activeEffect.deps数组中
	activeEffect.deps.push(deps) // 新增

}

上述代码,在track函数中我们将当前执行的副作用函数activeEffect添加到依赖集合deps中,这说明deps就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps数组中,这样就完成了对依赖集合的收集。
在这里插入图片描述
有了这个联系后,我们就可以在每次副作用函数执行时,根据effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect (fn) {
	const effectFn = () => {
		// 调用 cleanup 函数完成清除工作
		cleanup(effectFn) // 新增
		activeEffect = effectFn
		fn()
	}
	effectFn.deps = []
	effectFn()
}

cleanup的函数的实现

function cleanup(effectFn) {
	遍历effectFn.deps数组
	for (let i =0; i <effectFn.deps.length; i++) {
		// deps 是依赖集合
		const deps = effectFn.deps[i]
		// 将effectFn从依赖集合中移除
		deps.delete(effectFn)
	}
	// 最后需要重置 effectFn.deps数组
	effectFn.deps.length = 0
}

cleanup 函数接受副作用函数作为参数,遍历副作用函数的effect.deps数组,该数组的每一项都是一个依赖集合,然后将该副作用函数从依赖集合中移除,最后重置effectFn.deps数组

整合代码测试

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect (fn) {
	const effectFn = () => {
		// 调用 cleanup 函数完成清除工作
		cleanup(effectFn) 
		// 新增
		activeEffect = effectFn
		fn()
	}
	effectFn.deps = []
	effectFn()
}

function cleanup(effectFn) {
	// 遍历effectFn.deps数组
	for (let i =0; i <effectFn.deps.length; i++) {
		// deps 是依赖集合
		const deps = effectFn.deps[i]
		// 将effectFn从依赖集合中移除
		deps.delete(effectFn)
	}
	// 最后需要重置 effectFn.deps数组
	effectFn.deps.length = 0
}

// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = {text: 'hellowold'}

const obj = new Proxy(data, {
	// 拦截读取操作
	get(target, key) {
		// 将副作用函数activeEffect 添加到存储副作用函数的桶中
		track(target, key) 
		// 返回属性值
		return target[key]
	},
	// 拦截设置操作
	set(target, key, newVal) {
		// 设置属性值
		target[key] = newVal
		// 把副作用函数从桶里取出来并执行
		trigger(target, key)
	}
})

// 在get拦截函数内调用track函数追踪变化
function track(target, key) {
	// 没有activeEffect return
	if (!activeEffect) return

	let depsMap = bucket.get(target)
	if(!depsMap) {
		bucket.set(target, (
			depsMap = new Map()
		))
	}
	let deps = depsMap.get(key) 
	if (!deps) {
		depsMap.set(key, (
			deps = new Set()
		))
	}
	// 把当前激活的副作用函数添加到依赖集合deps中
	deps.add(activeEffect)
	// deps 就是一个与当前副作用函数存在联系的依赖集合
	// 将其添加到activeEffect.deps数组中
	activeEffect.deps.push(deps) // 新增

}

// 在set 拦截函数内调用trigger函数触发变化
function trigger (target, key) {
	const depsMap = bucket.get(target)
	if (!depsMap) return
	const effects = depsMap.get(key)
	effects && effects.forEach(fn => fn())
}

effect (
	//  一个匿名的副作用函数
	() => {
		console.log('effect run')
		document.body.innerHTML = obj.text
	}
)

// 1秒后修改响应式数据
setTimeout (() => {
	obj.text = 'hello vue3'
}, 1000)

如果你尝试运行代码,会发现目前的实现会导致无限循环执行,问题出现在trigger函数中,
在trigger函数内部,我们遍历effects集合,它是一个set集合,里面存储着副作用函数。
当副作用函数执行时,会调用clean进行清除,实际上就是从effects集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致重新被收集到集合中,而此时对于effects集合的遍历仍在进行。
简短代码表达如下:

const set = new Set([1])

set.forEach(item => {
	set.delete(1)
	set.add(1)
	console.log('遍历中')
})

语言规范中对此有明确的说明:
在遍历forEach遍历Set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会重新被访问。因此上述代码会无限执行。

解决方法:新建一个Set集合并遍历它

const set = new Set([1])

const newSet = new Set(set)

newSet.forEach(item => {
	set.delete(1)
	set.add(1)
	console.log('遍历中')
})

回到trigger函数,用同样的手段来避免无限执行

function trigger(target, key) {
	cosnt depsMap = bucket.get(target)
	if(!depsMap) return
	const effects = depsMap.get(key)

	const effectsToRun = new Set(effects) 
	// 新增
	effectsToRun.forEach(effectFn => effectFn())
	// 删除
	// effects && effects.forEach(effectFn => effectFn())
	
}

新增一个effectsToRun集合并遍历它,代替直接遍历effects集合,从而避免无限执行。

2-1 新知识与旧知识关联

cleanup 和 clearTimeout 清除定时器
cleanup 清除遗留的副作用函数,避免执行不必要的代码
clearTimeout 清除定时器,释放内存
本质上:都是清除代码执行后的副作用

let count = 0;
let timer= null;

function delayedIncrement() {
  count++;
  console.log(count);
  clearTimeout(timer);
}

timer = setTimeout(delayedIncrement, 1000);

2-2 表达转述

cleanup 就是饭后收拾桌子
在这里插入图片描述

五、嵌套的effect 与effect栈

1. 嵌套的effect

effect 是可以发生嵌套的,例如:

effect(function effectFn1 () {
	effect(function effectFn2() {
		/*...*/
	})
	/*...*/
})

实际例子:

// Foo 组件
const Foo = {
	render() {
		return /*...*/
	}
}

在一个effect中执行Foo组件的渲染函数:

effect(() => {
	Foo.render()
})

当组件发生嵌套时,例如Foo组件渲染了Bar组件:

// Bar 组件
const Bar = {
	render () {
		/*...*/
	}
}
// Foo组件渲染了Bar组件
const Foo = {
	render() {
		return <Bar /> // jsx语法
	}
}

此时就发生了effect嵌套,它相当于

effect(() => {
	Foo.render()
	// 嵌套
	effect(() => {
		Bar.render()
	})
})

这个例子说明了为什么effect要设计成可嵌套的

我们需要搞清楚,如果effect不支持嵌套会发生什么,用下面代码进行测试

// 原始数据
const data = {
	foo: true,
	bar: true
}
// 代理对象
const obj = new Proxy(data, {/*...*/})

// 全局变量
let temp1, temp2

// effectFn1 嵌套了effectFn2
effect( function effectFn1() {
	console.log('effectFn1 执行')
	effect(function effectFn2 () {
		console.log('effectFn2 执行')
		// 在effectFn2 中读取obj.bar属性
		temp2 =  obj.bar
		
	})
	// 在effectFn1 中读取obj.foo属性
	temp1 = obj.foo
})

上述代码中,effectFn1内部嵌套了effectFn2,很明显effectFn1的执行会导致effectFn2的执行。
需要注意的是,我们在effectFn2中读取了字段obj.bar,在effectFn1中读取了字段obj.foo,并且effectFn2的执行先于字段obj.foo的读取操作。在理想的情况下,我们希望副作用函数与对象属性之间的联系如下:
在这里插入图片描述
在这种情况下,我们希望当修改obj.foo 时会触发effectFn1 执行。由于effectFn2嵌套在effectFn1里,所以会间接触发effectFn2执行,而当修改obj.bar时,只会触发effectFn2执行。
但结果不是这样的,我们尝试修改 obj.foo的值,会发现输出为:

effectFn1 执行
effectFn2 执行
effectFn2 执行

一共打印了三次,前两次分别是副作用函数effectFn1 与 effectFn2 初始执行的打印结果,
第三次打印,我们修改了字段obj.foo的值,发现effectFn1 并没有重新执行,反而使得effectFn重新执行了,这显然不符合预期。
其实问题就出在我们实现的effect函数与activeEffect上,观察下面这段代码

// 用一个全局变量存储当前激活的effect函数
let activeEffect
function effect(fn) {
	const effectFn = () => {
		clearnup(effectFn)
		// 当调用effect注册副作用函数时,将副作用函数赋值给activeEffect
		activeEffect = effectFn
		fn()
	}
	// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
	effectFn.deps = []
	// 执行副作用函数
	effectFn()
}

我们用全局变量activeEffec来存储通过effect函数注册的副作用函数,这意味着同一时刻activeEffect所存储的副作用函数只能有一个。
当副作用函数发生嵌套事,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数也都会是内层副作用函数,这就是问题所在。

1-1. 新知识与旧知识关联

嵌套的effect 和 接口依赖嵌套
本质上:只有一个入口,先执行外层方法,再执行内层方法

1-2. 表达转述

嵌套的effect 就像俄罗斯套娃,大娃套小娃,一层接一层。
在这里插入图片描述

2. effect栈

为了解决这个问题,我需要一个副作用函数栈effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。
这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况,代码如下:

// 用一个全局变量存储当前激活的effect 函数
let activeEffect
// effect栈
const effectStack = [] // 新增
function effect(fn) {
	const effectFn = () => {
		cleanup(effectFn)
		// 当调用effect注册副作用函数时,将副作用函数赋值给activeEffect
		activeEffect = effectFn
		// 在调用副作用函数之前将当前副作用函数压入栈中
		effectStack.push(effectFn) // 新增
		fn()
		// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把activeEffect 还原为之前的值
		effectStack.pop() // 新增
		activeEffect = effectStack[effectStack.length - 1] // 新增
	}
	// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
	effectFn.deps = []
	// 执行副作用函数
	effectFn()
}

activeEffect没有变化,它仍然指向当前正在执行的副作用函数。不同的是当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈低存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数,下图所示

在这里插入图片描述
当内层副作用函数EffectFn2 执行完毕后,它会被弹出栈,并将副作用函数effectFn1 设置为activeEffect,如图所示
在这里插入图片描述
响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。

2-1. 新知识与旧知识关联

effect栈 和任务队列
本质上:都是把多个任务按顺序排列执行

2-2. 表达转述

effect栈像极了排队上班的我们
在这里插入图片描述

六、避免无限递归循环

1. 无限递归循环的例子

cosnt data = {foo: 1}
const obj = new Proxy(data, {/*...*/})
effect(() => obj.foo++)

在effect注册的副作用函数内有一个自增操作obj.foo++,该操作会引起栈溢出:

Uncaught RangeError: Maxim um call stack size exceeded
effecy(() => {
	// 语句
	obj.foo = obj.foo +1
})

2.分析原因:

  1. 这个语句,即会读取obj.foo的值,又会设置obj.foo的值,这是导致问题的根本原因

3.分析代码执行流程:

  1. 首先读取obj.foo的值,这会触发track操作,将当前副作用函数收集到"桶"中
  2. 接着将其加1后再赋值给obj.foo,此时会触发trigger操作,即把"桶"中的副作用函数取出并执行。
  3. 但问题是该副作用函数正在执行中,还没有执行完毕,就要开始下一次的执行。
  4. 这样会导致无限递归地调用自己,于是就产生了栈溢出。

4. 解决方案

分析逻辑:

  1. 读取和设置操作是在同一个副作用函数内进行的。此时无论是track时收集的副作用函数,还是trigger时要触发执行副作用函数,都是activeEffect.
  2. 如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
function trigger (target, key) {
	const depsMap = ducket.get(target)
	if (!depsMap) return 
	const effects = depsMap.get(key)
	
	const effectsToRun = new Set()
	effects && effects.forEach(effectFn => {
		// 如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
		if (effectFn !== activeEffect) {
			effectsToRun.add(effectFn)
		}
	})
	effectsToRun.forEach(effectFn => effectFn())
	// effects && effects.forEach(effectFn => effectFn())
}

5. 新知识与旧知识关联

无限递归循环 和 无限for循环
本质上:都没有循环截止点,最终导致内存溢出

6. 表达转述

无限递归循环就像一个人在循环跑道跑步,永远没有终点。
在这里插入图片描述
思维导图总结

在这里插入图片描述

因篇幅原因,后面还有较多知识点没有深入讲解,有兴趣的小伙伴可以深入了解一下。
如调度执行、计算属性computed与lazy、watch的实现原理、立即执行的watch与回调执行时机、过期的副作用等。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1304797.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Dockerfile创建镜像LNMP+WordPress

实验部署 INMPwordpress nginx 172.111.0.10 docker-nginx mysql 172.111.0.20 docker-mysql php 172.111.0.30 docker-php nginx 关闭防火墙和安全机制在opt目录创建nginx MySQL php目录 cd nginx mysql php vim Dockerfile#声明基础镜像 FROM centos:7 #安装依赖环境 R…

Datawhale 12月组队学习 leetcode基础 day1 枚举

这是一个新的专栏&#xff0c;主要是一些算法的基础&#xff0c;对想要刷leedcode的同学会有一定的帮助&#xff0c;如果在算法学习中遇到了问题&#xff0c;也可以直接评论或者私信博主&#xff0c;一定倾囊相助 进入正题&#xff0c;今天咱们要说的枚举算法&#xff0c;这是个…

NGINX高性能服务器与关键概念解析

目录 1 NGINX简介2 NGINX的特性3 正向代理4 反向代理5 负载均衡6 动静分离7 高可用8 结语 1 NGINX简介 NGINX&#xff08;“engine x”&#xff09;在网络服务器和代理服务器领域备受推崇。作为一款高性能的 HTTP 和反向代理服务器&#xff0c;它以轻量级、高并发处理能力以及…

供配电运维智能监控系统

供配电运维智能监控系统是一种针对供配电系统的智能监控设备&#xff0c;依托电易云-智慧电力物联网&#xff0c;旨在实现对供配电系统的实时监控、数据记录和分析&#xff0c;以及异常预警和故障检测等功能&#xff0c;以提高供配电系统的可靠性和稳定性。 该系统通常由以下部…

WhatsApp全球获客怎么做?

一、导语 随着全球数字化趋势的加速&#xff0c;WhatsApp作为一种即时通讯工具&#xff0c;已经成为了连接全球用户的桥梁。 对于企业和营销人员来说&#xff0c;利用WhatsApp拓展全球业务是一种非常有效的策略&#xff0c;本文将为您揭示WhatsApp全球获客的秘密&#xff0c;…

【小尘送书-第十三期】Python从入门到精通(第3版)(软件开发视频大讲堂)

大家好&#xff0c;我是小尘&#xff0c;欢迎你的关注&#xff01;大家可以一起交流学习&#xff01;欢迎大家在CSDN后台私信我&#xff01;一起讨论学习&#xff0c;讨论如何找到满意的工作&#xff01; &#x1f468;‍&#x1f4bb;博主主页&#xff1a;小尘要自信 &#x1…

python:六种算法(RFO、SSA、WOA、GWO、PSO、GA)求解23个测试函数(python代码)

一、六种算法简介 1、红狐优化算法RFO 2、麻雀搜索算法SSA 3、鲸鱼优化算法WOA 4、灰狼优化算法GWO 5、粒子群优化算法PSO 6、遗传算法GA 二、6种算法求解23个函数 &#xff08;1&#xff09;23个函数简介 参考文献&#xff1a; [1] Yao X, Liu Y, Lin G M. Evolution…

简单了解RNN循环神经网络

为什么需要RNN&#xff08;循环神经网络&#xff09; 将神经网络模型训练好之后&#xff0c;在输入层给定一个x&#xff0c;通过网络之后就能够在输出层得到特定的y&#xff0c;那么既然有了这么强大的模型&#xff0c;为什么还需要RNN&#xff08;循环神经网络&#xff09;呢…

资深测试总结,性能测试目的如何做?主要看什么指标?

目录&#xff1a;导读 前言一、Python编程入门到精通二、接口自动化项目实战三、Web自动化项目实战四、App自动化项目实战五、一线大厂简历六、测试开发DevOps体系七、常用自动化测试工具八、JMeter性能测试九、总结&#xff08;尾部小惊喜&#xff09; 前言 1、性能测试是什么…

学生管理系统 数据库版

如题&#xff1a; 一.SQL部分 a. b. c. 修改&#xff1a; 二.Java部分 代码&#xff1a; 结果

STM32CubeIDE串口空闲中断实现不定长数据接收

STM32F051空闲中断实现串口不定长数据接收 目的编程软件配置串口开中断中断程序 运行结果 目的 在串口输入不定长数据时&#xff0c;通过串口空闲中断来断帧接收数据。 编程软件 STM32CubeIDE STM32CubeMX配置MCU。通过对端口配置&#xff0c;自动生成程序&#xff0c;减少编…

Godot Engine:跨平台游戏开发的新境界 | 开源日报 No.92

godotengine/godot Stars: 62.6k License: MIT Godot Engine 是一个功能强大的跨平台游戏引擎&#xff0c;可用于创建 2D 和 3D 游戏。它提供了一套全面的常见工具&#xff0c;让用户可以专注于制作游戏而不必重复造轮子。该引擎支持将游戏一键导出到多个平台上&#xff0c;包…

3D漫游:沉浸式数字空间,步进式漫游新体验

5G时代的普及&#xff0c;使得人们对信息获取的要求越来越高&#xff0c;同时标准也越来越严格&#xff0c;3D漫游虚拟空间的体验&#xff0c;为很多用户提供了更加直观、生动的视觉体验。3D步进式漫游以其生动逼真的视觉效果和沉浸式体验&#xff0c;吸引着人们的关注以及多种…

lwIP 细节之四:recv 回调函数是何时调用的

使用 lwIP 协议栈进行 TCP 裸机编程&#xff0c;其本质就是编写协议栈指定的各种回调函数。将你的应用逻辑封装成函数&#xff0c;注册到协议栈&#xff0c;在适当的时候&#xff0c;由协议栈自动调用&#xff0c;所以称为回调。 注&#xff1a;除非特别说明&#xff0c;以下内…

射频功率放大器的参数有哪些

射频功率放大器是射频通信系统中重要的组件&#xff0c;用于将输入的射频信号放大到需要的功率水平。在设计和选择射频功率放大器时&#xff0c;需要考虑多种参数。下面西安安泰将详细介绍射频功率放大器的常见参数。 1、P1dB功率压缩点 当放大器的输入功率比较低时&#xff0c…

如何通过控制台排查定位EasyCore?

过去当数据库出现了问题&#xff0c;我们只能通过日志去查看问题&#xff0c;可能是数据库有了重启、主节点发生了漂移或查询语句太复杂&#xff0c;这一整套逻辑走下来只能winsen, karel。 我们经常说数据库负载过高&#xff0c;请求很多&#xff0c;但我们却不能直接告诉哪个…

MyBatis-Plus - 论自定义 BaseMapper 方法『逻辑删』失效解决方案

问题描述 在上一篇我们讲过 MyBatis-Plus - 论 1 个实体类被 N 个DAO 类绑定&#xff0c;导致 MP 特性&#xff08;逻辑删&#xff09;失效的解决方案-CSDN博客 所以在这个基础上&#xff0c;我们可以很好定位到源码的分析位置。 但是今天这个问题就更奇怪了&#xff0c;已经…

springMVC 学习总结(三) 拦截器及统一异常处理

一.拦截器 1.拦截器与过滤器的区别 拦截器 Interceptor 和 过滤器 Filter类似&#xff0c;主要用于拦截用户请求并作出一定处理操作&#xff0c; 但两则也有不同之处&#xff0c;如过滤器只在Servlet前后起作用&#xff0c;是Servlet规范定义的&#xff0c;是Servlt容器才支…

基于ssm的珠宝首饰交易平台开发论文

摘 要 随着科学技术的飞速发展&#xff0c;各行各业都在努力与现代先进技术接轨&#xff0c;通过科技手段提高自身的优势&#xff1b;对于珠宝首饰交易平台当然也不能排除在外&#xff0c;随着网络技术的不断成熟&#xff0c;带动了珠宝首饰交易平台&#xff0c;它彻底改变了过…

从头到尾的数据之旅

目录 引言 链表介绍 单向链表的接口实现 结构 创建节点 头插 尾插 头删 尾删 打印 节点查找 节点前插入 节点删除 内存释放 总结 引言 在前面的学习中&#xff0c;我们深入了解了顺序表&#xff0c;通过其增删查改的操作&#xff0c;我们发现了顺序表在某些情况…