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, 直接return
    if (!activeEffect) return target[key];
    // 根据target从桶中取出depsMap
    let 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从桶中取出depsMap
    const 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, 直接return
  if (!activeEffect) return target[key];
  // 根据target从桶中取出depsMap
  let 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从桶中取出depsMap
  const depsMap = bucket.get(target);
  if (!depsMap) return;

  // 取出与key相关的副作用函数
  const effects = depsMap.get(key);
  // 执行副作用函数
  effects && effects.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, 直接return
  if (!activeEffect) return target[key];
  // 根据target从桶中取出depsMap
  let 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从桶中取出depsMap
  const depsMap = bucket.get(target);
  if (!depsMap) return;

  // 取出与key相关的副作用函数
  const effects = depsMap.get(key);
  // 执行副作用函数
  effects && effects.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);

猜你喜欢

转载自blog.csdn.net/m0_71485750/article/details/134127326