深入源码设计!Vue3.js核心API——watch实现原理

如果您觉得这篇文章有帮助的话!给个点赞和评论支持下吧,感谢~

作者:前端小王hs

阿里云社区博客专家/清华大学出版社签约作者/csdn百万访问前端博主/B站千粉前端up主

此篇文章是博主于2022年学习《Vue.js设计与实现》时的笔记整理而来

书籍:《Vue.js设计与实现》 作者:霍春阳

本篇博文将在书第4.9节第4.11的基础上进一步解析,附加了测试的代码运行示例,以及对书籍中提到的ES6中的数据结构及其特点进行阐述,方便正在学习Vue3想分析Vue3源码的朋友快速阅读

如有帮助,不胜荣幸

前置章节:

  1. 深入理解Vue3.js响应式系统基础逻辑
  2. 深入理解Vue3.js响应式系统设计之栈结构和循环问题
  3. 深入理解Vue3.js响应式系统设计之调度执行
  4. 深入源码设计!Vue3.js核心API——Computed实现原理

核心watch

watch简单实现

watch的作用是在于侦听(监听)的对象getter(数据源)发生改变时,重新调用其所给的回调函数,我们直接看书中给出的代码:

watch(obj, () => {  console.log('数据变了')  
})  // 修改响应数据的值,会导致回调函数执行  
obj.foo++

其基本的实现逻辑是在watch内部封装了effect——副作用函数,且该effect具有调度器。当obj.foo读取时会和回调函数相关联(track);而当修改时,触发trigger时执行schedular中的逻辑,进而拿出关联的回调函数进行执行,代码如下:

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

cb即callback,回调

常量接收参数——避免硬编码

从上述代码可知,source.foo是固定写死的,关于这一点我们曾经在 深入理解Vue3.js响应式系统基础逻辑提到过,也就是硬编码,解决的办法是在函数内定义一个函数,用于遍历接收source,代码如下:

function watch(source, cb) {effect(// 调用 traverse 递归地读取() => traverse(source),{scheduler: () => {cb()}});
}function traverse(value, seen = new Set()) {if (typeof value !== 'object' || value === null || seen.has(value)) return;seen.add(value);// 暂时不考虑数组等其他结构// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理for (const k in value) {traverse(value[k], seen);}return value
}

其实用遍历不太详细,我们可以假设传入的是一个对象,那么traverse就可以把它的每一个key都与effectFn建立相关联

接收getter函数作为参数

前面的代码接收的都是obj,那如果是getter,应该如何设计呢?如下所示:

watch(  // getter 函数  () => obj.foo,  // 回调函数  () => {  console.log('obj.foo 的值变了')  }  
)

设计方案是在watch内定义了一个常量用于接收传入的getter,并且在effect中的第一个参数传入getter

function watch(source, cb) {// 定义 getterlet getter// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getterif (typeof source === 'function') {getter = source} else {// 否则按照原来的实现调用 traverse 递归地读取getter = () => traverse(source)}effect(// 执行 getter() => getter(),{scheduler: () => {cb()}})
}

获取旧值

watch的回调函数中可以拿到旧值新值,代码如下所示:

watch(() => obj.foo,(newValue, oldValue) => {console.log(newValue, oldValue)}
);obj.foo++

其实现的过程是利用了懒加载,代码如下:

function watch(source, cb) {let getter;if (typeof source === 'function') {getter = source;} else {getter = () => traverse(source);}// 定义旧值与新值let oldValue, newValue;const effectFn = effect(() => getter(),{lazy: true,scheduler() {newValue = effectFn();// 将旧值和新值作为回调函数的参数  cb(newValue, oldValue);  oldValue = newValue;  }})oldValue = effectFn();
}

变化是从直接执行effect变为了需要手动调用,现在来分析一下整个流程:

  1. 调用effectFn()返回了旧值——oldValue,同时在这一过程中,gettereffectFn相关联了
  2. 如果getter发生了变化,那么会触发trigger,然后执行scheduler
  3. 那么此时,又一次调用effectFn获取到的就是newValue
  4. newValueoldValue传入回调函数
  5. 将最新的值,变为旧值,这一点很关键,是为下一次变化做的准备

那么现在,就可以拿到旧值新值

初始化调用——immediate

我们知道在watch中有个重要的属性——immediate,也就是立即的意思,就是说当我们创建watch时,就让他执行,那这里如果我们联系上一节获取旧值的内容,可以知道此时旧值是没有的,也就是undefined,这一点在书中也提到了

watch立即执行的代码示例如下:

watch(obj, () => {console.log('变化了')
}, {immediate: true
})

那这应该怎么实现?

我们要明确一个东西,就是watch的作用是什么?产生这个作用的是function watch(){}的哪一部分?如果这样去想,那么逻辑其实很好理解

watch就是当source改变后会进行触发,执行第二个参数——回调函数,这是作用,注意!是改变后。而改变后触发的就是effect.options.scheduler,也就是产生作用的就是这一部分,那我们可以怎么做?可以直接在创建watch时就执行这一部分,那么就实现了立即调用

也就是说我们在分析时,要明白是在哪发生的这个获取旧值新值又或是其他逻辑的,进而单独拎出来调用一次就行,在书中拎出来的独立函数为job。我们直接看代码:

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}const effectFn = effect(// 执行 getter() => getter(),{lazy: true,// 使用 job 函数作为调度器函数scheduler: job});if (options.immediate) {    job();  } else {  oldValue = effectFn();}
}

闭包解决”竞态问题“

什么是竞态问题?我们直接看书中的例子:

let finalData;watch(obj, async () => {  // 发送并等待网络请求const res = await fetch('/path/to/request');// 将请求结果赋值给 datafinalData = await res.json();
});  

假定的情况是,obj在第一次发生改变时,触发了watch,然后执行了回调,发起了请求,但在请求的数据还没返回时,obj就发生了第二次改变,那么同样会触发watch执行回调,在第一次的请求数据还没回来时,第二次的数据就已经回来了。在这样这样的情况下,第一次返回的数据就变成了旧数据,第二次返回的数据就是新数据,而合理的情况是,此时finalData的值应该是新数据,所以,旧数据就报废了

所以设计的思路是,需要判断当前的副作用函数是否已经过期了,如果其过期了,那么理所应当,其返回的数据就是没有用了

看到这里不要混淆,我们执行watch,归根到底还是执行其封装的effect,更为确切的说,是执行其cb

我们先来看初始化的watch是怎么样的:

watch(obj, async (newValue, oldValue, onInvalidate) => {// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期let expired = false;// 调用 onInvalidate() 函数注册一个过期回调onInvalidate(() => {// 当过期时,将 expired 设置为 trueexpired = true})const res = await fetch('/path/to/request');const responseData = await res.json();// 只有当该副作用函数的执行没有过期时,才会执行后续操作  if (!expired) {finalData = responseData;}
});

我们在分析的时候,一定要清楚参数async (...) =>{}都是cb

在这段代码中,定义了一个expired,去判断当前的副作用函数是否过期,那么这里可以看到是利用了闭包的操作

我们在学习JavaScript高级内容时都会去了解这个内容,但在初级前端开发中还是比较少见的,所以我们来看一下这个闭包到底带来了什么

从这段代码中,我们可以看到如果expired过期的,那么是不会执行finalData = responseData;的,也就是说,第一次请求的数据即使返回了,也不会进行赋值操作

现在的关键是onInvalidate,我们来看一下onInvalidate是如何执行的,其实非常简单:

function watch(source, cb, options = {}) {let getterif (typeof source === 'function') {getter = source} else {getter = () => traverse(source)}let oldValue, newValue// cleanup 用来存储用户注册的过期回调let cleanup// 定义 onInvalidate 函数  function onInvalidate(fn) {// 将过期回调存储到 cleanup 中cleanup = fn}const job = () => {newValue = effectFn()// 在调用回调函数 cb 之前,先调用过期回调if (cleanup) {cleanup()} // 将 onInvalidate 作为回调函数的第三个参数,以便用户使用 cb(newValue, oldValue, onInvalidate)oldValue = newValue}const effectFn = effect(// 执行 getter() => getter(),{lazy: true,scheduler: () => {if (options.flush === 'post') {const p = Promise.resolve()p.then(job)} else {job()}}})if (options.immediate) {job()} else {oldValue = effectFn()}
}

在这段代码中,定义了一个cleanup去保存传过去的() => { expired = true },并且在关键代码job中进行了调用,我们结合例子来分析一下具体的执行过程,首先是例子:

watch(obj, async (newValue, oldValue, onInvalidate) => {let expired = falseonInvalidate(() => {expired = true})const res = await fetch('/path/to/request')if (!expired) {finalData = await res.json()}  
})// 第一次修改 obj.foo初始值为1
obj.foo++;setTimeout(() => {// 200ms 后做第二次修改obj.foo++;
}, 200);

在书中的例子中,是假设第一次修改的结果在1000ms后返回,这个是前提

我们来看两次调用watch发生了什么:

  1. 执行watch,由于不是immediate,所以调用了effectFn,建立了obj.fooeffectFn的关联,并且返回了1为``oldValue
  2. 执行obj.foo++,发生自增操作触发了trigger,会执行effectFn.options.scheduler
  3. 进而执行job,把自增后的2赋值给newValue,此时cleanup()没有赋值不执行,然后执行cb,在cb中,调用了执行onInvalidate() => {expired = true}赋值给cleanup,然后发起请求,假设异步请求的结果还没回来,最后oldValue变为了2(异步还在执行,但下一个是同步函数
  4. setTimeout中执行obj.foo++,触发trigger,执行effectFn.options.scheduler,再次执行job,通过track返回newValue3,然后现在claenup()存在,所以执行cleanup(),把第三步中的cb中的expired置为了ture
  5. 执行cb,执行onInvalidate() => {expired = true}赋值给cleanup,然后发起请求,返回数据,由于此时的expiredfalse,所以把返回的值赋值给了finalData
  6. 第一次自增触发的回调数据返回了,但由于expiredtrue,所以不会进行赋值

关于expired,这里再说一下变化的过程,在第一次job执行之后,就把其为ture的结果存起来了,然后在第二次jobcb还没执行,修改第一次jobcb内的expired状态为true

可以分为expiredAexpiredB去理解

我们要清楚两个回调是有先后顺序

通过闭包,我们可以在某个节点去修改定义在函数外的值,这就是闭包的作用

小记

至此,我们整个第四章就分析完了!

值得一提的是,通过前面的分析我们可以发现整个第四章都是好像连续剧一样,所以在分析时一定要对前面的内容充分理解之后才能继续看下去,我记得我当时看的时候,就是由于看了之后隔了几天没看,加上笔记不清晰,又得重新看,不过这也养成了我看书时专注一本书的习惯,也就是看了就看下去,不停顿

关于这篇,如果认真阅读了,最起码可以达到以下效果

  1. 了解和学习vue.js团队是如何设计watch的,了解其基本实现原理
  2. 理解watch是怎么实现immediate执行的
  3. 什么是竞态问题
  4. 什么是闭包和闭包在实际开发中的用处
  5. 其他…

谢谢大家的阅读,如有错误的地方请私信笔者

笔者会在近期整理后续章节的笔记发布至博客中,希望大家能多多关注前端小王hs

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

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

相关文章

QT实现人脸识别

QT实现人脸识别 Face.pro文件: QT core guigreaterThan(QT_MAJOR_VERSION, 4): QT widgetsCONFIG c11# The following define makes your compiler emit warnings if you use # any Qt feature that has been marked deprecated (the exact warnings # d…

「C系列」C 内存管理

文章目录 一、C 内存管理1. 静态内存分配2. 栈内存分配3. 堆内存分配注意事项 二、C 内存管理的函数和描述1. malloc()2. calloc()3. realloc()4. free() 三、相关链接 一、C 内存管理 C 语言的内存管理主要由程序员负责,这意味着你需要手动分配和释放内存以避免内…

通俗范畴论4 范畴的定义

注:由于CSDN无法显示本文章源文件的公式,因此部分下标、字母花体、箭头表示可能会不正常,请读者谅解 范畴的正式定义 上一节我们在没有引入范畴这个数学概念的情况下,直接体验了一个“苹果1”范畴,建立了一个对范畴的…

【新闻】AI程序员要来了吗?阿里云官宣

内容提要 6 月 21 日,在阿里云上海 AI 峰会上,阿里云宣布推出首个AI 程序员。 据介绍,这个AI程序员具备架构师、开发工程师、测试工程师等多种岗位的技能,能一站式自主完成任务分解、代码编写、测试、问题修复、代码提交整个过程…

跨界收益:企业数字化转型的致胜秘密!

在数字化的浪潮中,企业如何突破传统,面向未来?跨界收益,这个看似神秘的力量,正成为企业数字化转型的关键所在!让我们一同揭开它的神秘面纱,探寻其中的奥秘吧! 在企业数字化转型中&am…

集群环境中分发文件的脚本

由于在集群中手动分发文件较为麻烦,这里记录一下自动分发脚本。 1 脚本说明 该脚本用于将文件分发到远程设备。默认分发文件的路径为脚本所在路径,默认分发目标地址为node1和node2。可以在启动脚本时手动输入分发文件的路径和目标设备地址,…

教育护眼灯品牌排行有哪些上榜?中国十大教育照明品牌分享

在当前的时代背景下,孩子们的课业负担依然沉重。随着他们年龄的增长,作业量不断增加,对视力的需求也随之上升。加之,现今许多作业需借助电子屏幕完成,孩子们面临视力问题的风险因而愈加提早。家长们逐渐认识到&#xf…

一个端口配置两个vue和后端服务,nginx以及前后端服务怎么配?

nginx配置重点看server中的内容: worker_processes 8; pid /usr/local/nginx/logs/nginx.pid;events {# 此为 Linux 系统特为处理大批量文件描述符而作改进的 poll 事件模型use epoll;worker_connections 512; # 工作进程的最大连接数量# 允许同时接受多个网络连…

注意,华为HCIP考试近期变题频繁,请密切关注

最近,华为HCIP考试频繁变题,不少人在后台咨询小编,在此统一回复下。 具体情况是,继HCIP Datacom H12-831在4月份变题后,上周HCIP-Security H12-725也发生了变题。 而本周,HCIP-WLAN H12-323再次变题。 01 变…

静态文件及模板

自学python如何成为大佬(目录):https://blog.csdn.net/weixin_67859959/article/details/139049996?spm1001.2014.3001.5501 1 静态文件 动态Web应用也会需要静态文件,通常是CSS和JavaScript文件。Flask可以向已经配置好的Web服务器提供静态文件,只要…

制作微信小程序“飞翔的小鸟”

微信小程序为开发者提供了一个强大的平台,可以快速创建各种有趣的应用。在这篇博客中,我们将介绍如何制作一个简单的微信小程序——“飞翔的小鸟”。 项目介绍 “飞翔的小鸟”是一款基于微信小程序的小游戏,玩家需要控制一只小鸟在障碍物之间…

目前什么牌子充电宝适合入手?性价比高充电宝推荐

在如今这个快节奏的时代,手机已经成为我们生活中不可或缺的一部分。然而,手机电量的消耗却常常让我们陷入焦虑之中。当我们身处户外,急需给手机充电时,共享充电宝或许是个应急的选择,但您是否注意到,共享充…

监控系统的4个黄金指标

最近被问到一个问题,是关于监控系统的4个黄金信号(也被称为黄金指标)的,不太记得了,看了一些资料,做个笔记。 来源 监控系统的4个黄金指标来源于《SRE:Google运维解密》这本书的第六章 分布式…

面试题3:GET 和 POST 有什么区别?

[!]高频面试题。 GET 和 POST 没有本质区别,可以进行相互代替。 1、GET语义:“从服务器获取数据”;POST语义:“往服务器上提交数据”。[设计初衷,不一定要遵守] 2、发请求时,给服务器传递的数据&#xff…

通用大模型VS垂直大模型:你更青睐哪一方?

目录 引言 背景介绍 国内外垂直大模型的发展情况 国内外通用大模型的发展情况 哪一路径更为火热? 能力分析 通用大模型的独特能力 垂直大模型的独特能力 两者的差异与互补 难点探究 算力的挑战 数据的挑战 算法的挑战 结论 表格总结 引言 AI大模型的战…

CMake 详解

CMake 说明 cmake的定义是什么 ?-----高级编译配置工具 当多个人用不同的语言或者编译器开发一个项目,最终要输出一个可执行文件或者共享库(dll,so等等)这时候神器就出现了-----CMake! 所有操作都是通过…

Qt封装号的数据库操作类

连接QMYSQL&#xff0c;包含断线重连 头文件&#xff08;xxx.h&#xff09; #ifndef MAINWINDOW_H #define MAINWINDOW_H#include <QMainWindow> #include <QThread.h> #include <QQueue> #include <QMutex> #include <QSqlDatabase> #include …

用一个实例看如何分享大量照片 续篇一

继续上篇的实例分享&#xff0c;在此罗列一些应该注意的细节&#xff0c;以便在下次可以更加省时省力。 最重要的是呈现构想&#xff0c;如按活动/主题、班级/板块、地区/国家等等&#xff0c;这些都应该事先计划好&#xff0c;事后改动工作量巨大&#xff0c;因为太容易出错&a…

中东文明史

转自&#xff1a;想要了解完整的中东文明史&#xff1f;这篇文章成全你 - 知乎 (zhihu.com) 写在前面 中东文明是人类历史上最古老的文明。人类祖先从东非大裂谷走出之后&#xff0c;首先选择定居在中东地区的新月沃土上&#xff0c;并建立了人类历史上有文字记载的第一个文明…

java课程设计GUI学生信息管理系统

目录 系统内容.. 3 用户界面模块... 4 数据存储模块... 4 信息管理模块... 4 管理模块.. 4 主要模块的算法描述... 4 –简要的语言描述... 4 运行及调试分析&#xff08;测试数据及测试结果&#xff09;.. 5 课程设计总结... 7 参考文献&#xff08;至少三个&#xf…