实现vue3响应式系统核心-watch

在这里插入图片描述

简介

今天我们来看看 watch 的实现。 watch本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。实际上,watch的实现本质上就是利用了 effect 以及 options.scheduler选项。

代码地址: https://github.com/SuYxh/share-vue3

代码并没有按照源码的方式去进行组织,目的是学习、实现 vue3 响应式系统的核心,用最少的代码去实现最核心的能力,减少我们的学习负担,并且所有的流程都会有配套的图片,图文 + 代码,让我们学习更加轻松、快乐。

每一个功能都会提交一个 commit ,大家可以切换查看,也顺变练习练习 git 的使用。

watch 实现

在一个副作用函数中访问响应式数据 obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。但有一个例外,即如果副作用函数存在 scheduler选项,当响应式数据发生变化时,会触发 scheduler调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler调度函数就相当于一个回调函数,而 watch的实现就是利用了这个特点。

编写单测

假设obj是一个响应数据,使用 watch 函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。

it("base watch", () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100 });watch(obj, () => {mockFn()});obj.foo ++expect(mockFn).toHaveBeenCalledTimes(1);
});

代码实现

下面是最简单的 watch 函数的实现:

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {effect(// 触发读取操作,从而建立联系() => source.foo,{scheduler() {// 当数据变化时,调用回调函数 cbcb();}});
}

运行单测

image-20240118183001505

是不是很简单!

支持所有属性监听

在来看一个 case

 it("watch 多个属性", () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });watch(obj, () => {mockFn()});obj.bar ++obj.age ++expect(mockFn).toHaveBeenCalledTimes(2);});

执行一下

image-20240118191155795

修改了 2 个属性值,回调函数应该执行 2 次,但是回调函数并没有执行,这是为什么呢?

前面的watch函数中写死了 source.foo, source.bar没有进行依赖收集,自然回调函数就不会执行了。

那么就需要封装一个通用的读取操作:

function traverse(value, seen = new Set()) {// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做if (typeof value !== 'object' || value === null || seen.has(value)) return;// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环seen.add(value);// 暂时不考虑数组等其他结构// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理for (const k in value) {traverse(value[k], seen);}return value;
}

修改 watch 如下:

function watch(source, cb) {effect(// 触发读取操作,从而建立联系() => traverse(source),{scheduler() {// 当数据变化时,调用回调函数 cbcb();}});
}

traverse 方法的作用,读取传入对象的所有属性,然后构建依赖关系,任何一个属性值发生变化,都会执行回调函数。

再次执行单测:

image-20240118191058109

相关代码在 commit: (5063b6b)watch 基础实现 ,git checkout 5063b6b 即可查看。

支持函数参数

看一个 case

it('支持 getter 函数', () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });watch(() => obj.age, () => {mockFn()});obj.age ++expect(mockFn).toHaveBeenCalledTimes(1);
})

运行一下

image-20240118191803479

发现没有通过,因为我们之前也没有实现对函数的支持,肯定不会通过。

在 watch 中增加一个对第一个参数的判断就好:

export function watch(source, cb) {let getter;if (typeof source === 'function') {getter = source;} else {getter = () => traverse(source);}effect(// 触发读取操作,从而建立联系() => getter(),{scheduler() {// 当数据变化时,调用回调函数 cbcb();},});
}

再次运行单测

image-20240118192047176

这样就通过了。

相关代码在 commit: (0acd398)watch 支持函数参数 ,git checkout 0acd398 即可查看。

获取新值与旧值

看这个 case

it('get newVal and oldVal', () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });let newValue = nulllet oldValue = nullwatch(() => obj.age, (newVal, oldVal) => {newValue = newValoldValue = oldVal});obj.age ++expect(newValue).toBe(11);expect(oldValue).toBe(10);
})

不用运行,肯定跑不过,因为我们都没有去实现。

那么如何获得新值与旧值呢?这需要充分利用 effect 函数的 lazy 选项,如以下代码所示:

function watch(source, cb) {let getter;if (typeof source === 'function') {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用const effectFn = effect(() => getter(),{lazy: true,scheduler() {// 在 scheduler 中重新执行副作用函数,得到的是新值newValue = effectFn();// 将旧值和新值作为回调函数的参数cb(newValue, oldValue);// 更新旧值,不然下一次会得到错误的旧值oldValue = newValue;}});// 手动调用副作用函数,拿到的值就是旧值oldValue = effectFn();
}

其中最核心的改动是使用 lazy 选项创建了一个懒执行的 effect 。注意上面代码中最下面的部分,我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数 cb 就可以了。最后一件非常重要的事情是,不要忘记使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值。

运行单测

image-20240118192900720

相关代码在 commit: (5ac39a6)watch 获取新值与旧值 ,git checkout 5ac39a6 即可查看。

支持 immediate

看看这个 case

it('支持 immediate', () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100, bar: 200, age: 10 });let newValue = undefinedlet oldValue = undefinedwatch(() => obj.age, (newVal, oldVal) => {mockFn()newValue = newValoldValue = oldVal}, {immediate: true});expect(mockFn).toHaveBeenCalledTimes(1);expect(newValue).toBe(10);expect(oldValue).toBe(undefined);obj.age ++expect(mockFn).toHaveBeenCalledTimes(2);expect(newValue).toBe(11);expect(oldValue).toBe(10);
})

又是熟悉的老套路,增加一个 options,代码如下:

export function watch(source, cb, options) {let getter;if (typeof source === "function") {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用const effectFn = effect(() => getter(), {lazy: true,scheduler() {// 在 scheduler 中重新执行副作用函数,得到的是新值newValue = effectFn();// 将旧值和新值作为回调函数的参数cb(newValue, oldValue);// 更新旧值,不然下一次会得到错误的旧值oldValue = newValue;},});if (options.immediate) {// 当 immediate 为 true 时立即执行 effectFn,从而触发回调执行newValue = effectFn();cb(newValue, oldValue);oldValue = newValue;} else {// 手动调用副作用函数,拿到的值就是旧值oldValue = effectFn();}
}

再次运行单测:

image-20240118194634966

相关代码在 commit: (fd0e845)watch 支持 immediate ,git checkout fd0e845 即可查看。

重构

我们可以发现 scheduler 方法中的逻辑和 options.immediatetrue 时执行的逻辑一样,那么就可以进行封装:

export function watch(source, cb, options) {let getter;if (typeof source === "function") {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;// 提取 scheduler 调度函数为一个独立的 job 函数const job = () => {newValue = effectFn();cb(newValue, oldValue);oldValue = newValue;}// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用const effectFn = effect(() => getter(), {lazy: true,scheduler: job,});if (options.immediate) {// 当 immediate 为 true 时立即执行 job,从而触发回调执行job()} else {// 手动调用副作用函数,拿到的值就是旧值oldValue = effectFn();}
}

执行测试命令

pnpm test

我们可以看到,我们修改了代码,之前的 case 出了问题

image-20240118195124727

原因是当我们没有传 options 的时候,options 相当于是 undefined, 取值自然会出错,我们添加一个默认值就好。

image-20240118195319687

可以看到就全部通过了,单测为我们的代码保驾护航!

相关代码在 commit: (c0721bd)watch 代码优化 ,git checkout c0721bd 即可查看。

流程图

整体流程图如下:

image-20240118200856264

引导扫码关注

一个前端小学生的学习之路,如果你喜欢前端,我们可以一起进行学习、交流、共建。可以添加好友,结伴学习,成长的路上不孤单!

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

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

相关文章

flask基于python的个人理财备忘录记账提醒系统vue

在当今高度发达的信息中,信息管理改革已成为一种更加广泛和全面的趋势。 “备忘记账系统”是基于Mysql数据库,在python程序设计的基础上实现的。为确保中国经济的持续发展,信息时代日益更新,蓬勃发展。同时,随着信息社…

【智能家居入门2】(MQTT协议、微信小程序、STM32、ONENET云平台)

此篇智能家居入门与前两篇类似,但是是使用MQTT协议接入ONENET云平台,实现微信小程序与下位机的通信,这里相较于使用http协议的那两篇博客,在主程序中添加了独立看门狗防止程序卡死和服务器掉线问题。后续还有使用MQTT协议连接MQTT…

输入和输出

按字符输入输出 按字符输出putchar&#xff08;&#xff09; 格式 #include <stdio.h> int putchar(int c); 功能&#xff1a;向终端输出一个字符 参数&#xff1a;要输出的字符的ASCII码值 返回值&#xff1a; 成功&#xff0c;返回输出字符的ASCII码值 失败&#xff…

基于springboot汽车租赁系统源码和论文

首先,论文一开始便是清楚的论述了系统的研究内容。其次,剖析系统需求分析,弄明白“做什么”,分析包括业务分析和业务流程的分析以及用例分析,更进一步明确系统的需求。然后在明白了系统的需求基础上需要进一步地设计系统,主要包括软件架构模式、整体功能模块、数据库设计。本项…

Missing or invalid credentials.(Git push报错解决方案)

前言 本文主要讲解git push后报错Missing or invalid credentials的解决方案。这里针对的是windows的。 编程环境&#xff1a;VsCode 问题原因 问题翻译起来就是 凭据缺失或无效。这里我们解决方案是取消vscode里面默认的控制终端git凭据来解决,具体方案如下. 解决方案 1…

3D效果图加树进去太卡,渲染太慢怎么办?

周末的时候&#xff0c;有个朋友私信来问&#xff1a;3dmax模型加树进去打开时特别的卡&#xff0c;是怎么回事。 不知道有没有朋友遇上这么个情况。 3dmax加树建议就用代理&#xff0c;这样相比于直接加而言&#xff0c;会流畅许多。 在3D效果图中&#xff0c;“树代理”是…

萝卜视频源码前后端带视频演示

萝卜影视源码前端是用JAVA开发的全原生APP源码&#xff0c;后端用的是二次开发的苹果CMS&#xff0c;支持局域网投屏&#xff0c;视频软解硬解&#xff0c;播放器自带弹幕功能。支持解析官方视频&#xff0c;支持M3U8&#xff0c;MP4。 开屏广告&#xff0c;全局广告&#xff0…

GitHub国内打不开(解决办法有效)

最近国内访问github.com经常打不开&#xff0c;无法访问。 github网站打不开的解决方法 1.打开网站http://tool.chinaz.com/dns/ &#xff0c;在A类型的查询中输入 github.com&#xff0c;找出最快的IP地址。 2.修改hosts文件。 在hosts文件中添加&#xff1a; # localhost n…

从0开始搭建若依微服务项目 RuoYi-Cloud(保姆式教程完结)

文章接上一章&#xff1a; 从0开始搭建若依微服务项目 RuoYi-Cloud&#xff08;保姆式教程 一&#xff09;-CSDN博客 四. 项目配置与启动 当上面环境全部准备好之后&#xff0c;接下来就是项目配置。需要将项目相关配置修改成当前相关环境。 数据库配置 新建数据库&#xff…

element ui组件 el-date-picker设置default-time的默认时间

default-time &#xff1a;选择日期后的默认时间值。 如未指定则默认时间值为 00:00:00 默认值修改 <el-form-item label"计划开始时间" style"width: 100%;" prop"planStartTime"><el-date-picker v-model"formData.planStart…

TortoiseSVN各版本汉化包下载

首先进入下载版本列表 1.下载地址&#xff1a;https://sourceforge.net/projects/tortoisesvn/files ​ 2.选择自己版本进入​ 3.选择Language Packs进入&#xff0c;选择对应语言包下载。 ​ 4.在TortoiseSVN根目录下点击安装即可。 ​

解密数据清洗,SQL中的数据分析

大家好&#xff0c;数据库表中的数据经常会很杂乱。数据可能包含缺失值、重复记录、异常值、不一致的数据输入等&#xff0c;在使用SQL进行分析之前清洗数据是非常重要的。 当学习SQL时&#xff0c;可以随意地创建数据库表&#xff0c;更改它们&#xff0c;根据需要更新和删除…

canvas测量文字长度(measureText)

查看专栏目录 canvas实例应用100专栏&#xff0c;提供canvas的基础知识&#xff0c;高级动画&#xff0c;相关应用扩展等信息。canvas作为html的一部分&#xff0c;是图像图标地图可视化的一个重要的基础&#xff0c;学好了canvas&#xff0c;在其他的一些应用上将会起到非常重…

数据结构——栈和队列(C语言)

栈种常见的数据结构&#xff0c;它用来解决一些数据类型的问题&#xff0c;那么好&#xff0c;我来带着大家来学习一下栈 文章目录 栈对栈的认识栈的模拟实现栈的练习方法一方法二 栈 对栈的认识 栈&#xff08;stack&#xff09;是限定只能在表的一端进行插入删除操作的线性…

SpringCloud LoadBalancer

SpringCloud LoadBalancer 1.什么是LoadBalancer LoadBalancer&#xff08;负载均衡器&#xff09;是一种网络设备或软件机制&#xff0c;用于分发传入的网络流量负载请求到多个后端目标服务器上&#xff0c;从而实现系统资源的均衡利用和提高系统的可用性和性能。 负载均衡器…

【Tomcat与网络4】Tomcat的连接器设计

目录 1 如何设计一个灵活可靠的连接器 2 主要组件介绍 在上一篇&#xff0c;我们介绍了Tomcat提供服务的整体结构&#xff0c;本文我们一起来看一下Tomcat的连接器的设计。 在前面我们提到Tomcat主要完成两个功能&#xff1a; 处理 Socket 连接&#xff0c;负责网络字节流与…

wifi配网(esp8266和esp32)-http get和post方式

wifi配网(esp8266和esp32)-http get和post方式 通过http get和post方式来给esp芯片配网 步骤&#xff1a; 开机&#xff0c;指示灯亮起后(需要灯闪烁3下后)&#xff0c;需在3s内&#xff08;超过3s则会正常启动&#xff09;&#xff0c;按一下按键&#xff08;注&#xff1a;切…

Springboot做查询数据库某个表的数据时,后台一切正常前台显示不了数据

当我在用springboot做项目的时候查询整个表的数据或者条件查询的时候发现我的后台功能一切正常但是我的前台界面就是显示不了数据&#xff0c;这个问题解决也很简单&#xff0c;就是需要我们平时多加注意&#xff0c;不要漏代码&#xff01;&#xff01;&#xff01; Builder …

Visual Studio 2022 打开“程序包管理器控制台”失败

Visual Studio 2022 打开“程序包管理器控制台”失败 昨天下午&#xff0c;正在用Visual studio 2022写代码&#xff0c;当使用EF core 做数据迁移时&#xff0c;需要用到“程序包管理器控制台”&#xff0c;打开失败&#xff0c;前一秒还好好的&#xff0c;怎么突然就用不了了…

互联网加竞赛 基于深度学习的人脸性别年龄识别 - 图像识别 opencv

文章目录 0 前言1 课题描述2 实现效果3 算法实现原理3.1 数据集3.2 深度学习识别算法3.3 特征提取主干网络3.4 总体实现流程 4 具体实现4.1 预训练数据格式4.2 部分实现代码 5 最后 0 前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天要分享的是 &#x1f6a9; 毕业设计…