Vue响应式原理和本质 | 实现一个完善的响应式系统

文章目录

  • 前言
  • 响应式
    • 响应式的本质
    • 基本实现和工作原理
    • 完善的响应式

前言

本篇文章代码思路来自Vue3.0源码, 部分理解来源于霍春阳 《Vue.js设计与实现》这本书的理解, 感兴趣的小伙伴可以自行购买阅读。可以非常明确的感受到作者对 Vue 的深刻理解以及用心, 富含非常全面的 Vue 的知识点, 强烈推荐给大家。

响应式

响应式的本质

为了防止有小伙伴不知道副作用函数的, 在开讲之前我先来介绍一个副作用函数。副作用函数, 顾名思义指的是会产生副作用的函数。例如一个函数, 它修改了全局变量, 那么就产生了一个副作用, 它就是一个副作用函数(更加详细的可以自行查阅纯函数、副作用相关概念)。

let value = 1 // 全局变量function foo() {value = 100 // 修改全局变量
}

什么是响应式? 相信大家都能够回答, 所谓响应式无非就是数据发生变换时, 页面自动更新嘛。这样的回答是没有问题的, 但是并不是响应式的本质。这里给大家抛出一个结论, 响应式本质: 当页面数据发生变化时, 会自动的运行相关函数

举个栗子, 我们有如下一个obj对象, 和一个effect副作用函数, 副作用函数effect中, 获取到body并向其添加一个文本, 文本内容为obj.name, 最终效果会在浏览器中会显示"chenyq"。

const obj = { name: "chenyq" }function effect() {document.body.innerText = obj.name
}
effect(obj)

此时, 我们再对obj.name的值进行修改, 我们可以发现页面中显式的内容并没有发生变化, 仍然为"chenyq"。

// 对name属性修改
obj.name = "abc"

现在我们修改了obj.name的值, 我们期望的是页面中也能够进行同步更新, 如果能够完成这个目标, 那么obj对象就是一个响应式数据。那么如何能否完成这个目标呢? 如果再修改完obj.name属性之后, 再次调用effect函数, 那么页面中的数据就会随之变化。

obj.name = "abc"
// 修改后再次调用effect函数
effect(obj)

但是, 我们期望是能够自动调用effect函数, 而不是我们自己手动调用。经过这个例子, 我们完全可理解到响应式的本质, 以及为什么说响应式的本质是, 当页面数据发生变化时, 自动运行相关函数。但是我们目前是手动调用的, 并不是自动运行的。

基本实现和工作原理

接上文, 我们如何让obj变成一个响应式数据呢? 或者说, 我们如何自动的运行obj的相关函数? 其实我们可以通过以下两点思路出发:

  • 当effect函数执行时, 会触发obj.name的get(读取)操作
  • 当修改name属性时, 会触发obj.name的set(设置)操作

如果我们能够拦截obj对象的get和set操作, 那么我们就可以实现了。具体的做法是: 当我们进行obj.name触发get操作时, 就可以将effect函数存入到一个桶中, 因为effect函数可能不止一个, 所以我们需要存放到一个桶中; 当我们触发set操作的时候, 我们再从中桶取出全部相关函数进行执行。

现在我们就将问题转变为如何才能拦截一个对象的get和set操作, 相信大家很快就反应过来, 我们可以通过Object.defineProperty或Proxy实现。在Vue.js2中就是通过Object.defineProperty函数实现的响应式, 而ES6新增了一个代理对象Proxy, 它相对于Object.defineProperty来说更具有优势, 我们可以通过Proxy代理对象来实现, Vue.js3也是使用的Proxy实现的。

下面我们就根据上面的思路, 使用Proxy进行实现, 首先我们创建一个桶bucket, 用于存放副作用函数。这里的bucket我为什么使用Set而不是数组呢? 这是因为get这个操作我们是可能不仅仅触发一次的, 当触发了get就会将effect函数添加到bucket中, 如果是数组的话, 当再次触发get我们又会将effect函数添加到bucket中; 这样数组中就存放了两个相同的effect函数, 在触发set操作时, 就会对同一个函数进行两次调用。除非是对数组进行去重, 不然就会存在一个函数调用多次的问题, 因此使用Set集合, 保证存放的effect函数不会重复。

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

接着定义一个原始数据data, 并使用Proxy对原始数据data进行代理, 在代理对象中, 通过get和set方法分别用于拦截读取和设置的操作。在get操作中, 将effect函数添加到bucket, 再返回属性值; 在set操作中, 设置属性值, 并遍历执行bucket中的副作用函数。

const data = { name: "chenyq" }
const obj = new Proxy(data, {get(target, key) {// 添加副作用函数到bucket中bucket.add(effect)// 返回属性值return target[key]},set(target, key, newVal) {// 设置属性值target[key] = newVal// 遍历执行bucket中的副作用函数bucket.forEach(fn => fn())return true}
})

这样我们就实现了一个响应式数据, 我们可以通过setTimeout测试一下, 在等待一秒后, 对obj.name属性进行修改。

function effect() {document.body.innerText = obj.name
}
// 执行副作用函数, 触发读取
effect()// 1秒后对obj.name属性进行修改
setTimeout(() => {obj.name = "abc"
}, 1000)

运行上面代码, 发现可以得到我们期望的效果。到这里我们实现了最简单的响应式, 但是它依然存在着问题和缺陷。比如添加到bucket中的effect函数的函数名是硬编码的, 不具备通用性且不灵活。但是这里主要目的也不是实现响应式, 而是帮助大家理解响应式及响应式的工作原理。

完善的响应式

上面的响应式是存在着缺陷, 不够完善的, 现在我们尝试构建一个更加完善的响应式系统。

下面代码是我们已经实现的响应式。

// 原始数据
const data = { name: "chenyq" };
// 存储副作用函数的桶
const bucket = new Set();
const obj = new Proxy(data, {get(target, key) {// 添加副作用函数到bucket中bucket.add(effect);// 返回属性值return target[key];},set(target, key, newVal) {// 设置属性值target[key] = newVal;// 遍历执行bucket中的副作用函数bucket.forEach((fn) => fn());return true;},
});function effect() {document.body.innerText = obj.name;
}
// 执行副作用函数, 触发读取
effect();// 1秒后对obj.name属性进行修改
setTimeout(() => {obj.name = "abc";
}, 1000);

上面代码中, effect的函数名我们是硬编码的, 如果副作用函数不叫effect, 那么这段代码就不能正确地工作了, 没有办法将副作用收集到桶bucket中的。既然思考一下, 我们该如何收集副作用函数呢? 甚至如果副作用函数effect是一个匿名函数, 我们还能够正常将副作用函数effect收集到桶bucket中吗?

针对以上问题, 我们可以定义一个全局变量activeEffect, 它的初始值是undefined, 用来表示当前正在执行的副作用函数。当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect, 接着在正常调用fn函数, 调用完成后, 再将activeEffect修改回undefined, 如下所示:

let activeEffect;
function effect(fn) {activeEffect = fn;fn();activeEffect = undefined;
}

同时Proxy中的get拦截器也需要做对应的修改, 当activeEffect有值的时候, 将activeEffect中存储的副作用函数收集到桶bucket中。

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函数, 调用effect传入一个函数, 甚至是匿名函数。当effect函数执行时, 首先会将接收到的函数参数fn赋值给activeEffect。接着执行传入的fn函数, 执行fn函数时会读取obj的属性, 进而触发代理对象Proxy的get操作。在get拦截器中, 当activeEffect有值的时候, 会将activeEffect存放的副作用函数添加到bucket桶中, 这样响应系统就不依赖副作用函数的名字了。

// 执行副作用函数, 触发读取
effect(() => {document.body.innerText = obj.name;
});

但是我们的响应式系统依然有漏洞, 比如数据源obj有name, 和age两个属性, 当我们直接修改age或设置一个不存在的属性时, 依然会将副作用函数执行一次。如下代码, effect是和obj.name属性相关的, 我们期望做到的是只有操作effect函数依赖的属性时, 才会重新执行effect函数。而不是像现在这样, 操作其他属性, 也会执行只依赖obj.name的effect函数, 理论上age和notExist属性并没有与副作用函数之间建立起关系, 因此定时器中的操作不应该触发副作用函数执行。

const data = { name: "chenyq", age: 18 };effect(() => {console.log("effect is running"); // 会执行两次document.body.innerText = obj.name;
});setTimeout(() => {obj.age = 19; // 操作其他属性// obj.notExist = "abc"; 或操作不存在的属性
}, 1000);

其实上面这个问题导致的原因是, 收集的副作用函数effect并没有和被操作的目标属性之间建立明确的关系。为了解决这个问题, 我们就需要对桶bucket重新进行设计, 直接使用一个Set集合, 是没有办法明确的描述effect和被操作目标之间的关系。当读取属性时, 无论读取的是哪一个属性, 其实实现效果上来说是一样的, 都会把副作用函数收集到桶bucket里;当设置属性时, 无论设置的是哪一个属性, 也都会把桶bucket里的副作用函数effect取出并执行。

我们再来看看副作用函数effect执行的代码, 这段代码中有三个角色:

  • 被操作的代理对象: obj
  • 被操作的字段/属性: name
  • 使用effect注册的副作用函数: effectFn
effect(function effectFn() {document.body.innerText = obj.name;
});

这三种角色我们可以分别表示一下: 使用target来表示代理对象的原始对象, 使用key来表示被操作的属性, 使用effectFn来表示要被注册的副作用函数, 那么这三个角色就可以建立如下所示的关系, 一种树型结构。

target└── key└── effectFn

也会存在下面一些情况(方便理解, 可以看看其他例子):

  • 有两个副作用函数操作了同一个属性(两个函数都依赖同一个属性), 那么它的关系表示如下:
effect(function effectFn1() {document.body.innerText = obj.name;
});
effect(function effectFn2() {document.body.innerText = obj.name;
});
target└── name└── effectFn1└── effectFn2
  • 一个副作用函数同时操作了两个属性, 那么它的关系表示如下:
effect(function effectFn() {obj.name;obj.age;
});
target└── name└── effectFn└── age└── effectFn
  • 两个不同的副作用函数中分别读取了两个不同对象的属性, 那么它的关系表示如下:
effect(function effectFn1() {obj1.name;
});
effect(function effectFn2() {obj2.age;
});
target1└── name└── effectFn1
target2└── age└── effectFn2

按照这种结构, 我们就可以在任何情况下, 对副作用函数和被操作对象的属性直接建立明确的关系。我们创建使用WeekMap代替Set来创建一个桶bucket, 具体的做法如下:

  • WeekMap由一个target对应一个Map构成: key --> target、value --> Map, 每一个被操作对象的原始对象都会对应一个Map;
  • Map中由一个key对应一个Set构成: key --> key(被操作的字段/属性)、value --> Set, 对象的每一个属性都会对应一个Set集合, Set中存放着当前属性的副作用函数。

它们的关系如下所示:

WeekMap└── target1 --> Map1└── target2 --> Map2└── ...└── target3 --> Map3└── key1 --> Set1└── key2 --> Set2└── ...└── key3 --> Set3└── effectFn1└── effectFn2└── effectFn3└── ...

接下来我们就在代码中实现这个新的桶bucket, 以及修改Proxy的get/set拦截器:

const obj = new Proxy(data, {get(target, key) {// 没有activeEffect, 直接returnif (!activeEffect) return target[key];// 根据target从桶中取出depsMaplet depsMap = bucket.get(target);// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联if (!depsMap) bucket.set(target, (depsMap = new Map()));// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数let deps = depsMap.get(key);// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中if (!deps) depsMap.set(key, (deps = new Set()));// 最后将当前激活的副作用函数添加到桶里deps.add(activeEffect);// 返回属性值return target[key];},set(target, key, newVal) {// 设置属性值target[key] = newVal;// 根据target从桶中取出depsMapconst depsMap = bucket.get(target);if (!depsMap) return;// 取出与key相关的副作用函数const deps = depsMap.get(key);// 执行副作用函数deps && deps.forEach((fn) => fn());},
});

下面我们在对上面的代码进行一个封装, 好的做法是, 我们将get和set中的逻辑分别封装到一个单独的函数中。在get操作中, 将依赖搜集到桶中的逻辑, 我们可以封装到一个track的函数中, 表示追踪的意思; 在set操作中, 我们把触发副作用函数这个操作封装到一个trigger中, 表示触发的意思。

const obj = new Proxy(data, {get(target, key) {// 将副作用函数收集到桶中track(target, key);// 返回属性值return target[key];},set(target, key, newVal) {// 设置属性值target[key] = newVal;// 从桶中取出副作用函数执行trigger(target, key);},
});function track(target, key) {// 没有activeEffect, 直接returnif (!activeEffect) return target[key];// 根据target从桶中取出depsMaplet depsMap = bucket.get(target);// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联if (!depsMap) bucket.set(target, (depsMap = new Map()));// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数let deps = depsMap.get(key);// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中if (!deps) depsMap.set(key, (deps = new Set()));// 最后将当前激活的副作用函数添加到桶里deps.add(activeEffect);
}function trigger(target, key) {// 根据target从桶中取出depsMapconst depsMap = bucket.get(target);if (!depsMap) return;// 取出与key相关的副作用函数const deps = depsMap.get(key);// 执行副作用函数deps && deps.forEach((fn) => fn());
}

现在我们就实现了一个基本完善的响应式系统, 事实上的响应式系统还会更加复杂, 比如三元运算符分支切换会有哪些影响? 遗留的副作用函数如何处理? 如何避免无限递归循环? 问题等等一系列的, 我会在后面的文章进行更新, 不管怎么说目前我们已经实现了比较完善的响应式系统, 最后把本文的最终代码给到大家。

const data = { name: "chenyq", age: 18 };
const bucket = new WeakMap();
const obj = new Proxy(data, {get(target, key) {// 将副作用函数收集到桶中track(target, key);// 返回属性值return target[key];},set(target, key, newVal) {// 设置属性值target[key] = newVal;// 从桶中取出副作用函数执行trigger(target, key);},
});function track(target, key) {// 没有activeEffect, 直接returnif (!activeEffect) return target[key];// 根据target从桶中取出depsMaplet depsMap = bucket.get(target);// 如果depsMap不存在, 那么就需要创建一个depsMap与之关联if (!depsMap) bucket.set(target, (depsMap = new Map()));// 再根据key, 从depsMap中取出deps, deps是一个Set集合, 里面存放的是与当前key相关的所有副作用函数let deps = depsMap.get(key);// 如果deps不存在, 则创建一个deps, 并将其添加到depsMap中if (!deps) depsMap.set(key, (deps = new Set()));// 最后将当前激活的副作用函数添加到桶里deps.add(activeEffect);
}function trigger(target, key) {// 根据target从桶中取出depsMapconst depsMap = bucket.get(target);if (!depsMap) return;// 取出与key相关的副作用函数const deps = depsMap.get(key);// 执行副作用函数deps && deps.forEach((fn) => fn());
}let activeEffect;
function effect(fn) {activeEffect = fn;fn();activeEffect = undefined;
}// 测试部分
// 执行副作用函数, 触发读取
effect(() => {document.body.innerText = obj.name;
});// 1秒后对obj.name属性进行修改
setTimeout(() => {obj.name = "abc";// obj.age = 19;// obj.notExist = "abc";
}, 1000);

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

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

相关文章

Linux进程终止

文章目录 进程退出场景进程退出码strerrorerrno浅谈进程异常exit && _exit 进程退出场景 代码运行完毕,结果正确代码运行完毕,结果不正确代码异常 进程退出码 我们写的C/C的代码,main函数每次都需要返回0,而这个return…

C++:类的默认成员函数------拷贝构造函数赋值运算符重载

目录 一、前言 二、拷贝构造函数 💦拷贝构造函数概念 💦拷贝构造函数特性 🍎 解释特性2:拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用 🍐解释特性3:…

Spring注解RequestBody与RequestParam详解

要注意的问题: RequestBody 前端的请求方法要使用post方式来进行提交;一个请求只能有一个RequestBody,也就是说,当一个方法中有两个参数的时候,最多只能有一个参数加RequestBody注解用来接受request body中的参数&am…

Qt之自定义QStringListModel设置背景色和前景色

一.效果 二.实现 QStringListModel里只实现了Qt::EditRole和Qt::DisplayRole,不能直接设置背景色和前景色,所以我们要继承QStringListModel,重写其中的data和setData方法,使其支持Qt::ForegroundRole和Qt::BackgroundRole。 QHStringListModel.h #ifndef QHSTRINGLISTMO…

Java集成支付宝支付流程

在这篇博客中,我们将详细介绍如何在Java应用中集成支付宝支付。我们将从支付宝支付的基本概念开始,然后讲解如何在Java中使用支付宝的SDK进行支付操作,最后我们将通过一个具体的例子来演示整个过程。 1. 支付宝支付简介 支付宝是中国最大的…

P1966 [NOIP2013 提高组] 火柴排队

洛谷的一道原题,方法有很多,树状数组以及排序,对刚学树状数组的人来说用排序会比较好理解。 本题最重要的结论就是,要保证两个数组中相同位置的差最小,但是不一定两个数组中数值相同,所以只需要保证相同位…

ChatGPT/GPT4科研技术与AI绘图及论文高效写作

2023年我们进入了AI2.0时代。微软创始人比尔盖茨称ChatGPT的出现有着重大历史意义,不亚于互联网和个人电脑的问世。360创始人周鸿祎认为未来各行各业如果不能搭上这班车,就有可能被淘汰在这个数字化时代,如何能高效地处理文本、文献查阅、PPT…

C语言每日一题(20)最大公因数等于 K 的子数组数目

力扣 2447 最大公因数等于 K 的子数组数目 题目描述 给你一个整数数组 nums 和一个整数 k ,请你统计并返回 nums 的子数组中元素的最大公因数等于 k 的子数组数目。 子数组 是数组中一个连续的非空序列。 数组的最大公因数 是能整除数组中所有元素的最大整数。 …

王道p40 1.设计一个递归算法,删除不带头结点的单链表L中的所有值为x的结点(c语言代码实现)图解递归

视频讲解(献丑了):p40 第1题 王道数据结构课后代码题c语言代码实现_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV1Xa4y1Q7ui/?spm_id_from333.999.0.0 首先它是一个不带头结点的单链表 我们就得特殊处理 我们先让*LNULL; 然后为s开辟一个新…

大数据-Storm流式框架(二)--wordcount案例

一、编写wordcount案例 1、新建java项目 2、添加storm的jar包 storm软件包中lib目录下的所有jar包 3、编写java类 WordCountTopology.java package com.bjsxt.storm.wc;import backtype.storm.Config; import backtype.storm.LocalCluster; import backtype.storm.genera…

Pytorch代码入门学习之分类任务(三):定义损失函数与优化器

一、定义损失函数 1.1 代码 criterion nn.CrossEntropyLoss() 1.2 损失函数简介 神经网络的学习通过某个指标表示目前的状态,然后以这个指标为基准,寻找最优的权重参数。神经网络以某个指标为线索寻找最优权重参数,该指标称为损失函数&am…

IP地址规划的基本方法

IP地址规划是构建和管理网络的关键步骤,它涉及到为网络中的设备分配合适的IP地址,以确保网络的高效性、安全性和可管理性。在本文中,我们将探讨IP地址规划的基本方法,以帮助网络管理员和工程师更好地设计和维护网络。 1. 理解IP地…

基于JAVA的天猫商场系统设计与实现,springboot+jsp,MySQL数据库,前台用户+后台管理,完美运行,有一万五千字论文

目录 演示视频 基本介绍 论文目录 系统截图 演示视频 基本介绍 基于JAVA的天猫商场系统设计与实现,springbootjsp,MySQL数据库,前台用户后台管理,完美运行,有一万五千字论文。 本系统在HTML和CSS的基础上&#xf…

Antv G6入门之旅--combo图

目录 什么是AntV G6 G6 的特性 G6 文档 安装 1 在项目中使用 NPM 包引入 2 在 HTML 中使用 CDN 引入 使用 Step 1 创建容器 Step 2 数据准备 Step 3 创建关系图 Step 4 配置数据源,渲染 React 中使用 G6 Combo图 什么是AntV G6 G6 是一个图可视化引擎…

OpenCV学习(二)——OpenCV中绘图功能

2. OpenCV中绘图功能2.1 画线2.2 画矩形2.3 画圆2.4 画多边形2.5 添加文本 2. OpenCV中绘图功能 绘图可以实现画线、画矩形、画圆、画多边形和添加文本等操作。 import cv2 import numpy as np# 读取图像 img cv2.imread(lena.jpg)# 画直线 cv2.line(img, (0, 0), (512, 512…

还不知道光场相机吗?

1.什么是光场? 光场(light field):就是指光在每一个方向通过每一个点的光量。 从概念里,你至少可以得到两点信息: 光场包含光的方向光场包含一个点的光量 2.什么是光场相机 我们知道普通的相机拍照成像…

Windows环境下Apache安装部署说明及常见问题解决

一、软件准备 1.1 Python的下载与安装 见博客 链接: Python下载安装 1.2 Pycharm的下载与安装 见博客 链接: pycharm安装 1.3 Mysql的下载与安装 见博客 链接: MySQL安装 1.4 Navicat的下载与安装 可参考软件安装管家。 解释说明:Pycharm是Python的集成编译环境,Nav…

SpringBoot2.7.14整合redis7

需要的依赖库&#xff1a; <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</gro…

中文编程开发语言工具编程实际案例:台球棋牌混合计时计费软件使用的编程构件说明

中文编程开发语言工具编程实际案例&#xff1a;台球棋牌混合计时计费软件使用的编程构件说明 上图说明&#xff1a;该软件可以用于桌球和棋牌同时计时计费&#xff0c;在没有开台的时候&#xff0c;图片是处于等待状态&#xff0c;这使用编程工具中的固定图像构件&#xff0c;在…

PG14启动报错“max_stack_depth“ must not exceed 7680kB

问题描述 PG14编译安装后启动报错"max_stack_depth" must not exceed 7680kB [roottop132:/pgdb/data]$ systemctl start postgres Job for postgres.service failed because the control process exited with error code. See "systemctl status postgres.se…