实现vue3响应式系统核心-增强对象拦截

增强对象拦截

简介

在之前的文章中我们实现一个响应式系统的 MVP 模型,也实现了 computedwatch 等。 今天再来看看对于对象的拦截,我们思考以下几个问题:

  • 如何拦截 in操作符呢?
  • 如何拦截 for in 循环呢?
  • 如何拦截对象的删除操作呢?

接下来我们会一步步实现这些功能,进一步增强 MVP 模型。

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

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

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

对象读取操作

先看看一个普通对象所有可能的读取操作有哪些?

  • 访问属性:obj.foo
  • 判断对象或原型上是否存在给定的 key:key in obj
  • 使用 for…in 循环遍历对象:for (const key in obj){}

Proxy 内部方法

Proxy对象部署的所有内部方法以及用来自定义内部方法和行为的拦截函数名字

得到App_2024-01-21_21-43-48

拦截 in 操作符

现给出结论:我们可以通过 has 拦截函数实现对 in 操作符。

为什么是 has 呢?

在 ECMA-262 规范的 13.10.1 节中,明确定义了 in 操作符的运行时逻辑,如图所示:

得到App_2024-01-21_21-47-31

关键点在第 6 步,可以发现,in 操作符的运算结果是通过调用一个叫作 HasProperty的抽象方法得到的。关于 HasProperty抽象方法,可以在 ECMA-262 规范的 7.3.11 节中找到,它的操作如图所示:

得到App_2024-01-21_21-48-30

可以看到 HasProperty 抽象方法的返回值是通过调用对象的内部方法 [[HasProperty]]得到的。而[[HasProperty]]内部方法可以在Proxy内部方法中找到,它对应的拦截函数名叫 has,因此我们可以通过 has拦截函数实现对 in 操作符的代理。

看不懂也无所谓,只需要知道 has 可以拦截 in 操作。

单元测试

it("拦截 in 操作符", () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100 });effect(function effectFn1() {mockFn()console.log('foo' in obj);})expect(mockFn).toHaveBeenCalledTimes(1);delete obj.fooexpect(mockFn).toHaveBeenCalledTimes(2);
});

代码实现

// 拦截 in 操作符
has(target, key) {track(target, key);return Reflect.has(target, key);
},

按照以往,这里应该是运行 case 的时间,但是我们还并没有实现拦截删除,所以这里无法跑通,等到文末在运行单测。

但是可以通过调试看到,已经被收集到了。

image-20240121221632115

接下来看一下 for in循环如何去拦截。

拦截 for...in 循环

这里直接给出答案:可以使用ownKeys拦截函数来拦截。

单元测试

it("拦截 for in", () => {// 创建响应式对象const obj = reactive({ foo: 100 });const mockFn = vi.fn();effect(function effectFn1() {mockFn()for (const key in obj) {console.log(key);}})expect(mockFn).toHaveBeenCalledTimes(1);obj.bar = 2expect(mockFn).toHaveBeenCalledTimes(2);obj.foo = 100expect(mockFn).toHaveBeenCalledTimes(2);
});

代码实现

const ITERATE_KEY = "iterate-key";// 拦截 for in 循环
ownKeys(target) {track(target, ITERATE_KEY);return Reflect.ownKeys(target);
},

原因分析

ITERATE_KEY 作为追踪的 key ,为什么这么做呢?

这是因为 ownKeys 拦截函数与 get/set 拦截函数不同,在set /get中,我们可以得到具体操作的 key,但是在ownKeys中,我们只能拿到目标对象 targetownKeys 用来获取一个对象的所有属于自己的键值,这个操作明显不与任何具体的键进行绑定,因此我们只能够构造唯一的 key 作为标识,即 ITERATE_KEY

既然追踪的是 ITERATE_KEY,那么相应地,在触发响应的时候也应该触发它才行。但是在什么情况下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行呢?

为对象添加了新属性。因为,当为对象添加新属性时,会对 for...in 循环产生影响,所以需要触发与ITERATE_KEY相关联的副作用函数重新执行。

在我们之前写的 set函数中,当为对象 obj 添加新的 bar 属性时,会触发 set拦截函数执行。此时 set拦截函数接收到的 key就是字符串 bar,因此最终调用 trigger函数时也只是触发了与 bar相关联的副作用函数重新执行。

我们知道 for...in循环是在副作用函数与 ITERATE_KEY之间建立联系,这和 bar一点儿关系都没有,因此当我们尝试执行 obj.bar = 2操作时,并不能正确地触发响应。

通过调试可以看到:

image-20240121230921453

解决

当添加属性时,我们将那些与 ITERATE_KEY 相关联的副作用函数也取出来执行就可以了:

function trigger(target, key) {const depsMap = bucket.get(target);if (!depsMap) return;// 取得与 key 相关联的副作用函数const effects = depsMap.get(key);// 取得与 ITERATE_KEY 相关联的副作用函数const iterateEffects = depsMap.get(ITERATE_KEY);const effectsToRun = new Set();// 将与 key 相关联的副作用函数添加到 effectsToRuneffects && effects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn);}});// 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRuniterateEffects && iterateEffects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn);}});effectsToRun.forEach(effectFn => {if (effectFn.options.scheduler) {effectFn.options.scheduler(effectFn);} else {effectFn();}});
}

运行单测

image-20240121231759224

我们可以看到,单测并没有通过。从单测可以看出来,当我们修改值的时候,也触发了副作用函数的执行。

这又是怎么回事呢?

问题分析-修改 foo

与添加新属性不同,修改属性不会产生新的 key ,所以不会对 for...in 循环产生影响。所以在这种情况下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。

解决

那么我们在set 拦截函数内能够区分操作的类型,到底是添加新属性还是设置已有属性:

// 拦截设置操作
set(target, key, newVal, receiver) {// 如果属性不存在,则说明是在添加新属性,否则是设置已有属性const type = Object.prototype.hasOwnProperty.call(target, key)? TriggerType.SET: TriggerType.ADD;// 设置属性值const res = Reflect.set(target, key, newVal, receiver);// 派发更新trigger(target, key, type);return res;
},

我们优先使用Object.prototype.hasOwnProperty检查当前操作的属性是否已经存在于目标对象上,如果存在,则说明当前操作类型为 SET,即修改属性值;否则认为当前操作类型为 ADD,即添加新属性。

trigger 函数内就可以通过类型 type来区分当前的操作类型,并且只有当操作类型 typeADD时,才会触发与ITERATE_KEY相关联的副作用函数重新执行,这样就避免了不必要的性能损耗:

function trigger (target, key, type) {// ... // 只有当操作类型为 'ADD' 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行if (type === TriggerType.ADD) {// 取得与 ITERATE_KEY 相关联的副作用函数const iterateEffects = depsMap.get(ITERATE_KEY);iterateEffects &&iterateEffects.forEach((effectFn) => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn);}});}// ...
}

再次运行单测

image-20240121232707982

单测就已经通过!

如何拦截对象的删除操作呢?

规范的 13.5.1.2 节中明确定义了 delete 操作符的行为,如图所示:

得到App_2024-01-21_22-01-24

由第 5 步中的 d 子步骤可知,delete 操作符的行为依赖 [[Delete]]内部方法。根据 Proxy 内部方法可知,该内部方法可以使用 deleteProperty 拦截:

单元测试

it("拦截对象删除操作", () => {const mockFn = vi.fn();// 创建响应式对象const obj = reactive({ foo: 100 });effect(function effectFn1() {mockFn()console.log(obj.foo);})expect(mockFn).toHaveBeenCalledTimes(1);delete obj.fooexpect(mockFn).toHaveBeenCalledTimes(2);
});

代码实现

// 拦截删除
deleteProperty(target, key) {// 检查被操作的属性是否是对象自己的属性const hadKey = Object.prototype.hasOwnProperty.call(target, key);// 使用 Reflect.deleteProperty 完成属性的删除const res = Reflect.deleteProperty(target, key);if (res && hadKey) {// 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新trigger(target, key, TriggerType.DEL);}return res;
},

检查被删除的属性是否属于对象自身,然后调用Reflect.deleteProperty函数完成属性的删除工作,只有当这两步的结果都满足条件时,才调用trigger函数触发副作用函数重新执行。

⚠️注意: 由于删除操作会使得对象的键变少,它会影响 for...in 循环的次数,因此当操作类型为 DELETE时,我们也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行:

// 增加一个条件判断
if (type === TriggerType.ADD || type === TriggerType.DEL) {// 取得与 ITERATE_KEY 相关联的副作用函数const iterateEffects = depsMap.get(ITERATE_KEY);// 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRuniterateEffects &&iterateEffects.forEach((effectFn) => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn);}});
}

运行单测

image-20240121233327263

回顾 in 操作符单测

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

也可以跑通!

运行测试

pnpm test

image-20240121233501751

没有问题!

到此我们就解决了开头我们提出的这几个问题:

  • 如何拦截 in操作符呢?
  • 如何拦截 for in 循环呢?
  • 如何拦截对象的删除操作呢?

进一步完善了我们的响应式系统。

引导扫码关注

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

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

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

相关文章

QT 使用XML保存操作记录

文章目录 1 实现程序保存操作记录的思路2 XML文档基本结构3 QDomDocument实现XML读写3.1 QDomDocument实现生成XML文件3.2 QDomDocument实现读取XML文件 4 QXmlStreamWriter实现读写4.1 QXmlStreamWriter实现生成XML4.2 QXmlStreamWriter实现读取XML 1 实现程序保存操作记录的思…

Android悬浮窗的实现

最近想做一个悬浮窗秒表的功能,所以看下悬浮窗具体的实现步骤 1、初识WindowManager 实现悬浮窗主要用到的是WindowManager SystemService(Context.WINDOW_SERVICE) public interface WindowManager extends ViewManager {... }WindowManager是接口类&#xff0c…

云计算底层技术、磁盘技术揭秘虚拟化管理、公有云概述

查看本机是否具备虚拟化支持 硬件辅助虚拟化 处理器里打开 虚拟化Inter VT-x/EPT 或AMD-V 构建虚拟化平台工具软件包 yum 与 dnf Yum和DNF都是用于管理Linux系统中的软件包的工具,但它们在许多方面存在一些差异。以下是一些可能的区别: 依赖解…

TensorFlow2实战-系列教程7:TFRecords数据源制作1

🧡💛💚TensorFlow2实战-系列教程 总目录 有任何问题欢迎在下面留言 本篇文章的代码运行界面均在Jupyter Notebook中进行 本篇文章配套的代码资源已经上传 1、TFRecords 在训练过程中,基本都是使用GPU来计算,但是取一个…

匿名管道和命名管道

管道是进程通信的一种方式。&#xff08;进程通信需要让不同进程看到同一份资源&#xff09; 管道分为匿名管道和命名管道两种。 管道只允许单向通信。 一.匿名管道 #include<iostream> #include <unistd.h> #include<cassert> #include<cstring> #i…

小白水平理解面试经典题目LeetCode 455 Assign Cookies【Java实现】

455 分配cookies 小白渣翻译&#xff1a; 假设你是一位很棒的父母&#xff0c;想给你的孩子一些饼干。但是&#xff0c;你最多应该给每个孩子一块饼干。 每个孩子 i 都有一个贪婪因子 g[i] &#xff0c;这是孩子满意的 cookie 的最小大小&#xff1b;每个 cookie j 都有一个…

SOME/IP SD 协议介绍(三)服务发现消息

服务发现消息 使用先前指定的头部格式&#xff0c;可以构建不同的条目和由一个或多个条目组成的消息。具体的条目和它们的头部布局在下面的章节中进行解释。 对于所有的条目&#xff0c;应满足以下条件&#xff1a; • Index First Option Run、Index Second Option Run、Nu…

260:vue+openlayers 通过webgl方式加载矢量图层

第260个 点击查看专栏目录 本示例介绍如何在vue+openlayers中通过webgl方式加载矢量图层。在做这个示例的时候,采用vite的方式而非webpack的方式。这里的基础设置需要改变一下。 ol的版本7.5.2或者更高。 直接复制下面的 vue+openlayers源代码,操作2分钟即可运行实现效果 文…

Git安装,Git镜像,Git已安装但无法使用解决经验

git下载地址&#xff1a; Git - 下载 (git-scm.com) <-git官方资源 Git for Windows (github.com) <-github资源 CNPM Binaries Mirror (npmmirror.com) <-阿里镜像&#xff08;推荐&#xff0c;镜…

防御保护笔记02

防火墙 防火墙的主要职责在于&#xff1a;控制和防护 ---- 安全策略 --- 防火墙可以根据安全策略来抓取流量 防火墙分类 按物理特性划分 软件防火墙 硬件防火墙 按性能划分 百兆级防火墙 吞吐量&#xff1a;指对网络、设备、端口、虚电路或其他设施&#xff0c;单位时间内成…

数学公式OCR识别php 对接mathpix api 使用公式编译器

数学公式OCR识别php 对接mathpix api 一、注册账号官网网址&#xff1a;https://mathpix.com 二、该产品支持多端使用注意说明&#xff08;每月10次&#xff09; 三、api 对接第一步创建create keyphp对接api这里先封装两个请求函数&#xff0c;get 和post &#xff0c;通过官方…

Python 数据分析实战——社交游戏的用户流失?酒卷隆治_案例2

# 什么样的顾客会选择离开 # 数据集 DAU : 每天至少来访问一次的用户数据 数据内容 数据类型 字段名 访问时间 string&#xff08;字符串&#xff09; log_data 应用名称 string&#xff08;字符串&#xff09; app_name 用户 ID int&#xff08;数值&#xff09; user_id…