【《Vue.js设计与实现》】第4章-响应系统作用与实现

4.1 响应式数据和副作用函数

4.1.1 副作用函数

js 复制代码
function effect(){
  document.getElementById("app").innerText = "Hello Vue3"
}

当执行effect方法,它会设置<div id="app"></div>的文本内容,但除了effect之外的任何函数都可以读取或者设置<div id="app"></div>的文本内容。effect的执行会直接或间接地影响其他函数的执行,这时我们就说effect函数产生了副作用。

4.1.2 响应式数据

js 复制代码
const obj = {
  name: "张三",
};
function effect() {
  document.getElementById("app").innerText = obj.name;
}

上面🌰中effect函数会设置<div id="app"></div>的文本内容,当我们修改了obj.name的值,希望effect函数能自动重新执行,如果能实现这个目标,那么对象obj就是响应式数据。

4.2 响应式数据的基本实现

如何才能让obj变成响应式数据呢?这里有两点线索:

  1. 当副作用函数effect执行时,会触发字段obj.name的Getter操作;
  2. 当修改obj.name时,会触发字段obj.name的Setter操作; 如果我们能拦截一个对象的读取和设置操作,当读取字段obj.name时,我们可以把effect函数存储到一个"桶"里。

当设置obj.name时,再把副作用函数effect从"桶"里取出并执行。

我们如何去拦截一个对象的读取和设置操作呢?在ES2015之前,只能通过Object.defineProperty函数实现,这是Vue2的实现方式。在ES2015+中,我们可以使用代理对象Proxy来实现,这也是Vue3的实现方式。接着我们就用Proxy实现:

// 代码自己想

我们已经初步实现了响应式数据(◍˃̶ᗜ˂̶◍)✩,尽管还有一些问题等待处理。

4.3 设计一个完整的响应系统

上面的🌰中我们硬编码了副作用函数的名字(effect),这显然是不合理的,我们希望的是副作用函数哪怕是一个匿名函数,也能够被成功地收集到"桶"里。

为了实现这一点,可以通过传参的方式,将匿名函数传递给effect方法。

// 代码自己想

当我们对这个系统稍加测试,例如在响应式数据proxy上设置一个不存在的属性时:

js 复制代码
effect(() => {
  console.log("effect run");
  document.getElementById("app").innerText = proxy.name;
    });
setTimeout(() => {
  proxy.age = 30;
}, 2000);

上图中匿名副作用函数执行了两次。定时器中proxy中不存在age属性,但还是触发了Proxy的Setter行为,从而执行了副作用函数,这是不正确的。为了解决这个问题,我们需要重新设计"桶"的结构。

上一节中,我们使用Set实现"桶",它不能把副作用函数和proxy的属性关联起来。无论我们修改proxy哪个属性,都会触发Getter,从而执行匿名副作用函数。因此我们需要建立起副作用函数和被操作的属性之间的联系。首先我们看一下下面的代码:

js 复制代码
effect(() => {
  console.log("effect run");
  document.getElementById("app").innerText = proxy.name;
});

这段代码中有三个角色:

  1. 被读取的代理对象proxy,用target表示。
  2. 被读取的字段名name,用key表示。
  3. 使用effect函数注册的匿名副作用函数,用effectFn表示。

如果两个副作用函数读取同一个属性值:

js 复制代码
effect(() => {
  target.key;
    });
effect(() => {
  target.key;
});

如果一个副作用函数读取了同一个对象的两个属性值:

js 复制代码
effect(() => {
  target.key1;
  target.key2;
});

两个不同的副作用函数中读取了两个不同对象的属性值:

js 复制代码
effect(() => {
  target1.key1;
    });
effect(() => {
  target2.key2;
});

下图是bucket新的结构图。

首先它从一个Set改为了WeakMap。按照上图修改一下之前的代码。

// 代码自己想

接着我们将一些逻辑进行封装,在get方法中,把副作用函数放入"桶"的逻辑封装成track方法;在set方法中,把副作用函数查找并执行的逻辑封装成trigger方法。

// 代码自己想

4.4 分支切换与cleanup

js 复制代码
const obj = {
  ok: true,
  text: "Hello Vue3",
};
effect(function effectFn{
  document.getElementById("app").innerText = obj.ok ? obj.text : null;
});

effectFn函数中有一个三元表达式,根据obj.ok的值来执行不同的代码分支,当obj.ok的值发生变化时,这就是分支切换

分支切换可能会产生遗留的副作用函数。当obj.ok的初始值是true时,会读取obj.text的值,所以当effectFn函数执行时会触发obj.ok和obj.text的读取,此时他们的关系如图所示:

当obj.ok是false时,此时字段obj.text不会被读取,只会触发字段obj.ok的读取操作,所以理想情况下effectFn不会被字段obj.text所对应的依赖集合收集:

现在我们的代码,当obj.ok改成false,obj.text和对应的依赖集合的关系依旧会保存下来,这就产生了遗留的副作用函数。遗留的副作用函数会导致不必要的更新:当obj.ok为false时,修改了obj.text,仍然会导致副作用函数重新执行,即使document.getElementById("app").innerText的值不需要变化。
因此我们需要在每次副作用函数执行时,先把它从所有与之关联的依赖集合中删除。

步骤:

  1. 在effect内部我们定义了新的effectFn函数,并为其添加了effectFn.deps属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合;
  2. effectFn.deps数组中的依赖合集是如何收集的呢?在track函数中,将deps添加到activeEffect.deps数组中。
  3. 在effectFn函数中,在每次副作用函数执行时,删除相关的依赖,实现一个cleanup方法,依次删除依赖合集中的effectFn。

// 代码自己想

上面的代码成功解决了副作用函数遗留的问题,但是还带来了一个新的问题,那就是它会无限循环 。问题出在trigger方法中deps.forEach,它的执行过程类似于下面这段代码。

js 复制代码
const set = new Set([1])
set.forEach(item => {
  set.delete(1)
  set.add(1)
  console.log('遍历中')
}

在调用forEach遍历Set集合时,如果一个值已经被方法问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,该值会重新被访问。

如何解决呢?很简单,我们可以新建一个Set。

// 代码自己想

4.5 嵌套的effect与effect栈

effect是可以发生嵌套的,例如:

js 复制代码
effect(function effectFn1() {
  effect(function effectFn2() {
    /** **/
  });
});

在Vue项目中,组件嵌套时就会发生effect嵌套。

js 复制代码
const Bar = () => {
  render(){ /*...*/}
}

// Foo组件渲染了Bar组件
const Foo = () => {
  render(){
    return <Bar />
  }
}

我们来看一下现在的响应系统是否支持effect的嵌套。我们希望的是当修改obj.foo时会触发effectFn1。由于effectFn2嵌套在effectFn1中,所以会间接触发effectFn2。当我们修改obj.bar时,只会触发effectFn2执行。

js 复制代码
// 原始数据
const data = { foo:  true, bar: true}

// 代理对象
const obj = new Proxy(data, {/*...*/})

let temp1, temp2;
effect(function effectFn1() {
  console.log("effectFn1执行");

  effect(function effectFn2() {
    console.log("effectFn2执行");
    // 在effectFn2中读取obj.bar
    temp2 = obj.bar;
  });
  // 在effectFn1中读取obj.foo
  temp1 = obj.foo;
});

在理想情况下,我们希望副作用函数与对象属性之间的联系如下:

当我们修改obj.foo时,打印如下图所示。打印出了effectFn2,但是没打印出effectFn1,这显然不符合预期了。

js 复制代码
function effect(fn) {
  const effectFn = () => {
    // 调用cleanup函数完成清除工作
    cleanup(effectFn);
    activeEffect = effectFn;
    fn();
  };
  // effectFn.deps用来存储所有与该副作用函数相关联的依赖集合
  effectFn.deps = [];
  effectFn();
}

问题出在哪呢?问题出在track这里,会执行两次track,最后一次activeEffect会把前面的都覆盖掉。

为了解决这个问题,我们需要一个副作用函数栈effectStack,在副作用函数执行时,将当前的副作用函数压入栈,待副作用函数执行完成将其从栈中弹出,并始终让activeEffect指向栈顶的元素。

// 代码自己想

我们定义了effectStack数组,用来模拟栈。当副作用发生嵌套时,栈底存储的是外层副作用函数,而栈顶存储的是内层副作用函数。

当内层副作用函数effectFn2执行完成后,会被弹出栈。并将副作用函数effectFn1设置为activeEffect。

4.6 避免无限递归循环

js 复制代码
effect(() => {
  proxy.foo++;
});

有一个🌰,在effect注册的副作用函数内有一个自增操作,该操作会引起栈溢出。

我们可以看一下它的执行过程:首先读取obj.foo的值,这会触发track操作,把当前副作用函数收集到"桶"中,接着增加1后赋值给obj.foo,此时会触发trigger操作,即把"桶"中的副作用函数取出并执行。但问题是该副作用函数还在执行中,还没有执行完成,就要开始下一次执行。这会导致无限递归地调用自己,于是就产生了溢栈。

那我们怎么解决呢?我们可以在trigger方法中增加一个条件:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。

// 代码自己想

4.7 调度执行

可调度性 ,指的是当trigger动作触发副作用函数执行时,有能力决定副作用函数执行的时机、次数以及方式

js 复制代码
effect(() => {
  console.log(proxy.foo);
});

proxy.foo++;
console.log("结束了");

假如我们想打印出下面这样的顺序,在不调整代码顺序的情况下:

  • 1
  • 结束了
  • 2

这样就需要我们的响应系统支持调度了。我们可以为effect函数设计一个参数options,允许用户指定调度器。

js 复制代码
effect(
  () => {
    console.log(proxy.foo);
  },
  {
    scheduler(fn) {
      setTimeout(fn);
    },
  }
);
proxy.foo++;
console.log("结束了");
js 复制代码
function effect(fn, options = {}) {
  // otpions挂载到effectFn上
  effectFn.options = options;
}

在trigger方法触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制如何执行;否则直接执行副作用函数。

// 代码自己想

除了控制副作用函数的执行顺序,通过调度器还需要控制副作用函数的执行次数。

js 复制代码
effect(() => {
  console.log(proxy.foo);
});
proxy.foo++;
proxy.foo++;

我们知道proxy.foo是一定会从1加到3的,2只是它的过渡状态。如果我们只想关心结果而不是过程,我们期望的打印结果是:

  • 1
  • 3

如何用调度器来实现呢?

js 复制代码
// 定义一个任务队列

// 将一个任务添加到微任务队列

// 一个标志代表是否正在刷新队列

function flushJob(){

}

effect(() => {
  console.log(obj.foo)
}, {
  scheduler(fn){
    jobQueue.add(fn)
    
    flushJob()
  }
})
obj.foo++
obj.foo++

// 代码自己补充

flushJob方法的作用就是为了延迟执行queueJob,等所有的Setter操作执行完成,统一执行一次副作用函数。大家肯定又有疑问了,为什么要微任务,而不是宏任务SetTimeout。

微任务比宏任务有更高的优先级。

每次循环,浏览器只会取一个宏任务执行,而微任务则是执行全部。如果queueJob使用宏任务,可能会有多个宏任务在queueJob前面,但每次只会取一个宏任务,所以queueJob执行的时机非常延后。

这个功能类似于Vue.js中连续多次修改响应式数据但只会触发一次更新,解决思路也是类似的。

4.8 计算属性computed与lazy

先来看看🌰懒执行的effect。

js 复制代码
effect(() => {
  // 这个函数会立即执行
  console.log(obj.foo);
});

有些时候,我们不希望它立即执行,而是希望它在需要的时候再去执行。比如计算属性,我们可以在options中添加lazy属性来达到目的。

js 复制代码
effect(
      () => {
        // 指定lazy选项
        console.log(obj.foo);
      },
      {
        lazy: true,
      }
    );

这时我们就可以修改effect函数的实现逻辑了,当options.lazy为true时,则不立即执行副作用函数。

js 复制代码
function effect(fn, options = {}){
  const effectFn = () => {}
  effectFn.deps = []
  effectFn.options = options
  if(!options.lazy){
    effectFn()
  }
  return effectFn
}

上面的代码中返回了effectFn,所以当调用effect函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行它了。

js 复制代码
const effectFn = effect(() => {
  // 这个函数会立即执行
  console.log(obj.foo);
});
effectFn()

如果我们可以把传递给effect的函数看作是一个getter,那么这个getter函数可以返回任何值,这样手动执行副作用函数时,就能拿到它的返回值。

js 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    let res = fn();
    
    return res;
  };
}

我们已经实现懒执行的副作用函数了,并且能拿到副作用函数的执行结果,接下来就可以实现计算属性了。

js 复制代码
function computed(fn) {
  const effectFn = effect(fn, {
    lazy: true,
  });
  const obj = {
    // 当读取value时才会执行effectFn
    get value() {
      return effectFn();
    },
  };
  return obj;
}

我们可以使用computed函数来创建一个计算属性。

js 复制代码
const sumRes = computed(() => proxy.foo + proxy.bar);

console.log(sumRes.value);

当我们读取sumRes.vaule的值时,它才会重新计算并得到值。但还做不到对值进行缓存,假如我们多次访问sumRes.value的值,会导致effectFn进行多次计算,即使proxy.foo和proxy.bar的值本身没有变化。

js 复制代码
console.log(sumRes.value); // 2
console.log(sumRes.value); // 2
console.log(sumRes.value); // 2

为了解决这个问题,就需要我们在实现computed函数时,添加值的缓存功能。

// 代码自己想

js 复制代码
const sumRes = computed(() => proxy.foo + proxy.bar);

console.log(sumRes.value); // 2
console.log(sumRes.value); // 2
proxy.foo = 3;
console.log(sumRes.value); // 2

中间我们修改了proxy.foo的值,但sumRes.value打印出来还是2。因为我们的dirty被改为了false。所以我们得在proxy.foo和proxy.bar的值发生变化时,将dirty重置成true。这时我们就可以用到上一节实现的调度器了。

js 复制代码
function computed(fn) {
  let value;
  let dirty = true;
  const effectFn = effect(fn, {
    lazy: true,
    scheduler(fn) {
      // 添加调度器,在调度器中将dirty置为true
      dirty = true;
    },
  });
}

如果我们在另外一个effect中去读取计算属性的值时。当我们修改proxy.foo时,发现并不会触发副作用函数的渲染,此时这就有问题了。

js 复制代码
const sumRes = computed(() => proxy.foo + proxy.bar);

effect(() => {
  console.log(sumRes.value); // 还是打印2
});
proxy.bar = 3;

分析一下问题的原因,这个本质是effect嵌套的问题。一个计算属性内部有自己的effect,并且它是懒执行,执行getter时才会执行computed内部的effect。但如果把计算属性用于另外一个effect时,就会发生effect嵌套,外层的effect不会被内层的effect中的响应式数据收集。

所以我们得在计算属性的getter行为中可以手动track,然后当属性依赖的响应式数据发生变化时,手动trigger。

4.9 watch的实现原理

watch的本质是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。

js 复制代码
watch(obj, () => {
  console.log('数据变了')
})

实际上,watch的实现本质上就是利用了effect以及options.scheduler选项,如下代码所示:

js 复制代码
effect(() => {
  console.log(obj.foo)
}, {
  scheduler(){
    // 当obj.foo的值变化时,会执行scheduler调度函数
  }
})
相关推荐
工业互联网专业40 分钟前
毕业设计选题:基于ssm+vue+uniapp的校园水电费管理小程序
vue.js·小程序·uni-app·毕业设计·ssm·源码·课程设计
计算机学姐1 小时前
基于SpringBoot+Vue的在线投票系统
java·vue.js·spring boot·后端·学习·intellij-idea·mybatis
twins35202 小时前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky2 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
杨荧3 小时前
【JAVA开源】基于Vue和SpringBoot的洗衣店订单管理系统
java·开发语言·vue.js·spring boot·spring cloud·开源
Front思4 小时前
vue使用高德地图
javascript·vue.js·ecmascript
花花鱼6 小时前
@antv/x6 导出图片下载,或者导出图片为base64由后端去处理。
vue.js
流烟默7 小时前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch
蒲公英10018 小时前
vue3学习:axios输入城市名称查询该城市天气
前端·vue.js·学习
杨荧10 小时前
【JAVA开源】基于Vue和SpringBoot的旅游管理系统
java·vue.js·spring boot·spring cloud·开源·旅游