【Vue】响应式中的渲染 watcher

2024-01-03 20:44

本文主要是介绍【Vue】响应式中的渲染 watcher,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

Vue 响应式 — 渲染 watcher

前三节内容:

Vue 数据劫持

Vue 响应式初步

Vue 响应式中数组的特殊处理

在第二节中,我们简单了解了 watcher 类,当时我们说到,其一般用在渲染函数、计算属性以及侦听属性中,其一般用于在数据发生变化时接收通知,并给出相应的行为。

在前面的 demo 中,我们也实现了一个简单的 watcher 类,这个类更类似于 Vue 1.x 版本中 $watch ,它们都是需要传入要监听的属性、回调函数…等等,然后在依赖发生变化时,执行回调函数。

而在这一节,我们要学习的是渲染 watcher,其不需要传入监听的属性,而是接收一个渲染函数,当依赖发生变化时,执行渲染函数。

1. 修改 watcher 类

由于渲染 watcher 中,接收的是需要实现响应式的对象以及一个渲染函数,所以,我们需要对 watcher 类的构造函数进行修改:

// watcher 类
class Watcher {// 此时第二个参数可能为依赖属性的路径字符串,也可能为一个渲染函数constructor(data, expOrFn, cb) {this.data = data;	    // 要实现响应式的对象if (typeof expOrFn === 'function') {// 传入的是一个渲染函数this.getter = expOrFn} else {// 传入的是依赖属性的访问路径,通过工具函数处理,得到一个取值函数this.getter = parsePath(expOrFn)}this.cb = cb;	    // 依赖的回调this.value = this.get() // 访问目标属性以触发getter从而发起依赖收集流程}...
}

同时对工具函数 parsePath 进行修改,保证 this.getter 是一个函数:

// 工具函数,返回一个用于根据指定访问路径,取出某一对象下的指定属性的函数
function parsePath(expression) {const segments = expression.split('.')return function (obj) {for (let key of segments) {if (!obj) returnobj = obj[key]}return obj}
}

由于工具函数修改了,需要对原来的 get 方法和 update 方法进行修改:

// Watcher 类
class Watcher {...// 访问当前实例依赖的属性,并将自身加入响应式对象的依赖中get() {pushTarget(this);// 注意,当 getter 为渲染函数时,是没有返回值的,即 value 为 undefinedconst value = this.getter.call(this.data, this.data);popTarget();return value;}// 收到更新通知后,进行更新,并触发依赖回调update() {// 原本的逻辑是,先将旧值存下来,然后通过工具函数去取新值,然后再触发回调函数。// const oldValue = this.value;// this.value = parsePath(this.data, this.expression);// this.cb.call(this.data, this.value, oldValue);// 现在需要先通过 get 方法获取新值,且这个值可能是 undefinedconst newValue = this.get();/* 只有当:1. 新值与当前 watcher 实例中存放的旧值 this.value 不等时2. 该值为对象类型时才触发回调函数。当传入的是一个渲染函数时,newValue 是 undefined,this.value 也是 undefined,自然不会进入下面的逻辑那么对于渲染 watcher,在哪里触发更新呢?实际上,在前面重新执行 get 方法的时候,就会通过 this.getter.call 完成渲染函数的调用!*/if (newValue !== this.value || isObject(newValue)) {const oldValue = this.value;this.value = newValue;this.cb.call(this.data, this.value, oldValue);}}
}// 工具函数,判断一个值是否是对象
function isObject(target) {return typeof target === "object" && target !== null;
}

这里有一个问题:为什么在 update 方法中,不直接使用 this.getter.call(this.data, this.data)访问依赖属性获取新值或者重新调用渲染函数呢?

看起来,使用 this.getter.call(this.data, this.data) 和重新执行 get 方法的区别并不大,但实际上涉及到了依赖的重新收集

但我们先把这个问题放到一边,先来考虑另一个问题:依赖的重复收集

2. 解决依赖重复收集的问题

考虑下面的场景:

<template><div>第一次依赖数据: {{ target }}第二次依赖数据:{{ target }}</div>
</template><script>export default {name: 'xxx',data() {return {target: 'xxxxxxx'}}}
</script>

在渲染模板时,由于我们在模板中使用了两次 target ,那么在解析模板时,会读取两次 target 的值,即触发两次我们为其定义的 getter ,进而触发依赖收集即 dep.depend()

// 依赖收集
depend() {if (Dep.target) {this.addSub(Dep.target);}
}

而此时的 Dep.target 始终为我们的渲染 watcher,

所以渲染 watcher 会被收集两次!这样,在 target 发生更新时,就会调用两次渲染 watcher 中的渲染函数,即重新渲染两次,这样显然是不对的。

为了解决这一问题,Vue 中采用了下面的方式来避免重复收集依赖:

  1. 首先为每一个 dep 实例添加一个id

    // 准备一个全局变量用于为dep实例添加id
    let uuid = 0// Dep 类
    class Dep {constructor() {this.subs = [];this.id = uuid++    // 为每一dep实例加上唯一标识id}...
    }
    
  2. 接下来修改 watcher,先准备4个属性:

    • deps — 上次取值时,已经收集过该 watcher 的 dep 实例
    • depIds — 上次取值时,已经收集过该 watcher 的 dep 实例的 id 集合
    • newDeps — 本次取值时,需要收集该 watcher 的 dep 实例
    • newDepIds — 本次取值时,需要收集该 watcher 的 dep 实例的 id 集合
    // Watcher 类
    class Watcher {// 此时第二个参数可能为依赖属性的路径字符串,也可能为一个渲染函数constructor(data, expOrFn, cb) {this.deps = []; // 上次取值时,已经收集过该watcher的dep实例this.depIds = new Set(); // 上次取值时,已经收集过该watcher的dep实例的id集合this.newDeps = []; // 本次取值时,需要收集该watcher的dep实例this.newDepIds = new Set(); // 本次取值时,需要收集该watcher的dep实例的id集合...}...
    }
    

    整体的思路是:触发依赖收集后,在 watcher 中判断自身是否已经被该数据的 dep 实例收集过,如果已经被收集过,则不再重复收集

    首先需要修改 Dep 类的 depend 方法,因为此时是否收集依赖已经不再由 dep 实例决定,而是由当前 Dep.target 指向的 watcher 实例自身来决定

    // Dep 类
    class Dep {...// 依赖收集depend() {if (Dep.target) {// 调用Dep.target指向的watcher实例身上的方法,让watcher实例自己决定是否订阅该dep实例Dep.target.addDep(this)}}...
    }
    

    然后需要在 Watcher 类中添加一个方法:

    // Watcher 类
    class Watcher {...// 决定是否订阅某一dep实例addDep(dep) {const id = dep.id// 本次取值过程中,处理过当前dep实例,则进入if (!this.newDepIds.has(id)) {this.newDeps.push(dep)this.newDepIds.add(id)// 若上次取值时,没有订阅过该dep实例,则订阅该dep实例if (!this.depIds.has(id)) {dep.addSub(this)}}}
    }
    
  3. 最后,需要在完成取值后,交换 deps、depIdsnewDeps、newDepIds 的内容,并清空 newDeps、newDepIds

    // Watcher 类
    class Watcher {...// 访问当前实例依赖的属性,并将自身加入响应式对象的依赖中get() {pushTarget(this);// 注意,当 getter 为渲染函数时,是没有返回值的,即 value 为 undefinedconst value = this.getter.call(this.data, this.data);popTarget();// 完成取值后,更新内容this.clearUpDeps()return value;}...// 交换deps、depIds与newDeps、newDepIds的内容,并清空newDeps、newDepIdscleanUpDeps() {// 交换depIds和newDepIdslet tmp = this.depIdsthis.depIds = this.newDepIdsthis.newDepIds = tmp// 清空newDepIdsthis.newDepIds.clear()// 交换deps和newDepstmp = this.depsthis.deps = this.newDepsthis.newDeps = tmp// 清空newDepsthis.newDeps.length = 0}
    }
    

为什么这样做能够防止依赖的重复收集?

  1. newDepsnewDepIds用来在一次解析模板过程中避免重复依赖,比如:{{ name }} -- {{ name }}
  2. depsdepIds用来再重新渲染的取值过程中避免重复依赖

先来看第一点,首先数据 name 对应一个 dep 实例,接下来在解析模板时,会创建一个渲染 watcher 实例,执行一次 get() 方法,然后渲染函数内部读取两次 name 的值:

第一次时,取值,触发 getter ,进而触发依赖收集 dep.depend() ,此时的 Dep.target 指向渲染 watcher,执行 watcher 实例身上的方法 addDep(),此时,watcher 身上的 newDepIds 是空的,所以会处理该 dep 实例,将其加入 newDeps,并将其id加入 newDepIds 中,且此时 depIds 必然为空,所以会订阅该 dep 实例(执行 dep.addSub())。

到第二次取值时,由于仍然为 name ,其对应的 dep 实例并没有变化,重复上述流程直到执行渲染 watcher 身上的 addDep() 方法时,此时,watcher 身上的 newDepIds 中已经收集到了这个 Dep 实例的 id,所以不会再进行处理!

这就是所谓在一次解析模板的过程中,避免重复依赖

再来看第二点,当 name 发生变化时,此时,前面的依赖收集已经完成,即 watcher 中已经执行完一次 get() 方法,也执行了一次 cleanUpDeps() ,所以,此时的 depsdepIds 中,并不是空的,而是存放有上一次解析模板取值时收集过渲染 watcher 的 dep 实例,所以,在本次重新取值时,name 对应的 dep 实例不变,即不会进入最后一个 if 判断中,也就不会重复订阅(执行 dep.addSub())

也就是在重新渲染时,避免重复依赖

重复依赖的问题解决了,但不要忘记,我们还有一个遗留的的问题没有解决。

3. 依赖的重新收集

前面在修改 Watcher 类时,我们提到过,为什么在 update 方法中,不直接使用 this.getter.call(this.data, this.data)访问依赖属性获取新值或者重新调用渲染函数呢?

前面也说过,这里实际上涉及到了依赖的重新收集,依赖的重新收集是必要的,如果我们在模板中使用了 v-if 等指令,那么可能在重新渲染时,模板中依赖的数据也会发生变化,此时就需要重新收集依赖了。

重新收集依赖实际上包括了两个方面:

  1. 收集新的依赖
  2. 删除无效的依赖

关于第一方面,首先我们进行依赖收集的前提条件就是 Dep.target 的指向不为空,当 Dep.target 指向为空时,是不会执行 dep.depend() 方法的。而纵观整个demo,只有在 watcher 的 get() 方法中,我们会调用 pushTarget() 方法,将 Dep.target 指向自身,并在完成取值后,将 Dep.target 指向复原。而在仅考虑渲染 watcher 的情况下,这实际上就意味着只有在 get() 方法执行期间,Dep.target 的指向才不为空!

所以,这就是为什么我们在 update 方法中,需要调用 get() 方法,其目的就在于使得依赖的重新收集得以进行。

至于第二方面,我们可以在 Dep 类身上添加一个方法:

// Dep 类
class Dep {...// 清除无用订阅removeSub(sub) {remove(this.subs, sub)}
}...// 工具函数,用于删除数组中的指定元素
function remove(arr, item) {if (!arr.length) returnconst index = arr.indexOf(item)if (index > -1) {return arr.splice(index, 1)}
}

然后,在取值完毕后,更新 deps、depIds、newDeps以及newDepIds 时,将无用的依赖删除。

// Watcher 类
class Watcher {...// 交换deps、depIds与newDeps、newDepIds的内容,并清空newDeps、newDepIdscleanUpDeps() {// 删除无用的依赖let i = this.deps.lengthwhile (i--) {const dep = this.deps[i]if (!this.newDepIds.has(dep.id)) {dep.removeSub(this)}}// 交换depIds和newDepIdslet tmp = this.depIdsthis.depIds = this.newDepIdsthis.newDepIds = tmp// 清空newDepIdsthis.newDepIds.clear()// 交换deps和newDepstmp = this.depsthis.deps = this.newDepsthis.newDeps = tmp// 清空newDepsthis.newDeps.length = 0}
}

仓库地址:github

这一版中做了比较大的改动。

下一节中,我们会尝试实现一个能够看到实际效果的响应式系统。

这篇关于【Vue】响应式中的渲染 watcher的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/567013

相关文章

前端高级CSS用法示例详解

《前端高级CSS用法示例详解》在前端开发中,CSS(层叠样式表)不仅是用来控制网页的外观和布局,更是实现复杂交互和动态效果的关键技术之一,随着前端技术的不断发展,CSS的用法也日益丰富和高级,本文将深... 前端高级css用法在前端开发中,CSS(层叠样式表)不仅是用来控制网页的外观和布局,更是实现复杂交

Python将博客内容html导出为Markdown格式

《Python将博客内容html导出为Markdown格式》Python将博客内容html导出为Markdown格式,通过博客url地址抓取文章,分析并提取出文章标题和内容,将内容构建成html,再转... 目录一、为什么要搞?二、准备如何搞?三、说搞咱就搞!抓取文章提取内容构建html转存markdown

在React中引入Tailwind CSS的完整指南

《在React中引入TailwindCSS的完整指南》在现代前端开发中,使用UI库可以显著提高开发效率,TailwindCSS是一个功能类优先的CSS框架,本文将详细介绍如何在Reac... 目录前言一、Tailwind css 简介二、创建 React 项目使用 Create React App 创建项目

vue使用docxtemplater导出word

《vue使用docxtemplater导出word》docxtemplater是一种邮件合并工具,以编程方式使用并处理条件、循环,并且可以扩展以插入任何内容,下面我们来看看如何使用docxtempl... 目录docxtemplatervue使用docxtemplater导出word安装常用语法 封装导出方

一文详解SpringBoot响应压缩功能的配置与优化

《一文详解SpringBoot响应压缩功能的配置与优化》SpringBoot的响应压缩功能基于智能协商机制,需同时满足很多条件,本文主要为大家详细介绍了SpringBoot响应压缩功能的配置与优化,需... 目录一、核心工作机制1.1 自动协商触发条件1.2 压缩处理流程二、配置方案详解2.1 基础YAML

Vue中组件之间传值的六种方式(完整版)

《Vue中组件之间传值的六种方式(完整版)》组件是vue.js最强大的功能之一,而组件实例的作用域是相互独立的,这就意味着不同组件之间的数据无法相互引用,针对不同的使用场景,如何选择行之有效的通信方式... 目录前言方法一、props/$emit1.父组件向子组件传值2.子组件向父组件传值(通过事件形式)方

css中的 vertical-align与line-height作用详解

《css中的vertical-align与line-height作用详解》:本文主要介绍了CSS中的`vertical-align`和`line-height`属性,包括它们的作用、适用元素、属性值、常见使用场景、常见问题及解决方案,详细内容请阅读本文,希望能对你有所帮助... 目录vertical-ali

如何解决Spring MVC中响应乱码问题

《如何解决SpringMVC中响应乱码问题》:本文主要介绍如何解决SpringMVC中响应乱码问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录Spring MVC最新响应中乱码解决方式以前的解决办法这是比较通用的一种方法总结Spring MVC最新响应中乱码解

浅析CSS 中z - index属性的作用及在什么情况下会失效

《浅析CSS中z-index属性的作用及在什么情况下会失效》z-index属性用于控制元素的堆叠顺序,值越大,元素越显示在上层,它需要元素具有定位属性(如relative、absolute、fi... 目录1. z-index 属性的作用2. z-index 失效的情况2.1 元素没有定位属性2.2 元素处

Python实现html转png的完美方案介绍

《Python实现html转png的完美方案介绍》这篇文章主要为大家详细介绍了如何使用Python实现html转png功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 1.增强稳定性与错误处理建议使用三层异常捕获结构:try: with sync_playwright(