返回顶部
首页 > 资讯 > 前端开发 > JavaScript >Vue源码学习之响应式是如何实现的
  • 580
分享到

Vue源码学习之响应式是如何实现的

2024-04-02 19:04:59 580人浏览 独家记忆
摘要

目录前言一、一个响应式系统的关键要素1、如何监听数据变化2、如何进行依赖收集——实现 Dep 类3、数据变化时如何更新——实现 Watcher 类二、虚拟 DOM 和 diff1、虚

前言

作为前端开发,我们的日常工作就是将数据渲染到页面➕处理用户交互。在 Vue 中,数据变化时页面会重新渲染,比如我们在页面上显示一个数字,旁边有一个点击按钮,每次点击一下按钮,页面上所显示的数字会加一,这要怎么去实现呢?
按照原生 js 的逻辑想一想,我们应该做三件事:监听点击事件,在事件处理函数中修改数据,然后手动去修改 DOM 重新渲染,这和我们使用 Vue 的最大区别在于多了一步【手动去修改DOM重新渲染】,这一步看起来简单,但我们得考虑几个问题:

  • 需要修改哪个 DOM ?
  • 数据每变化一次就需要去修改一次 DOM 吗?
  • 怎么去保证修改 DOM 的性能?

所以要实现一个响应式系统并不简单🍳,来结合 Vue 源码学习一下 Vue 中优秀的思想叭~

一、一个响应式系统的关键要素

1、如何监听数据变化

显然通过监听所有用户交互事件来获取数据变化是非常繁琐的,且有些数据的变动也不一定是用户触发的,那Vue是怎么监听数据变化的呢?—— Object.defineProperty

Object.defineProperty 方法为什么能监听数据变化?该方法可以直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象,先来看一下它的语法:


Object.defineProperty(obj, prop, descriptor)
// obj是传入的对象,prop是要定义或修改的属性,descriptor是属性描述符

这里比较核心的是 descriptor,它有很多可选键值。这里我们最关心的是 get 和 set,其中 get 是一个给属性提供的 getter 方法,当我们访问了该属性的时候会触发 getter 方法;set 是一个给属性提供的 setter 方法,当我们对该属性做修改的时候会触发 setter 方法。

简言之,一旦一个数据对象拥有了 getter 和 setter,我们就可以轻松监听它的变化了,并可将其称之为响应式对象。具体怎么做呢?


function observe(data) {
  if (isObject(data)) {
    Object.keys(data).forEach(key => {
      defineReactive(data, key)
    })
  }
}

function defineReactive(obj, prop) {
  let val = obj[prop]
  let dep = new Dep() // 用来收集依赖
  Object.defineProperty(obj, prop, {
    get() {
      // 访问对象属性了,说明依赖当前对象属性,把依赖收集起来
      dep.depend()
      return val
    }
    set(newVal) {
      if (newVal === val) return
      // 数据被修改了,该通知相关人员更新相应的视图了
      val = newVal
      dep.notify()     
    }
  }) 
  // 深层监听
  if (isObject(val)) {
    observe(val)
  }
  return obj
}

这里我们需要一个 Dep 类(dependency)来做依赖收集🎭

PS:Object.defineProperty 只能监听已存在的属性,对于新增的属性就无能为力了,同时无法监听数组的变化(Vue2中通过重写数组原型上的方法解决这一问题),所以在 vue3 中将其换成了功能更强大的Proxy。

2、如何进行依赖收集——实现 Dep 类

基于构造函数实现:


function Dep() {
  // 用deps数组来存储各项依赖
  this.deps = []
}
// Dep.target用来记录正在运行的watcher实例,这是一个全局唯一的 Watcher 
// 这是一个非常巧妙的设计,因为JS是单线程的,在同一时间只能有一个全局的 Watcher 被计算
Dep.target = null

// 在原型上定义depend方法,每个实例都能访问
Dep.prototype.depend = function() {
  if (Dep.target) {
    this.deps.push(Dep.target)
  }
}
// 在原型上定义notify方法,用于通知watcher更新
Dep.prototype.notify = function() {
  this.deps.forEach(watcher => {
    watcher.update()
  })
}
// Vue中会有嵌套的逻辑,比如组件嵌套,所以利用栈来记录嵌套的watcher 
// 栈,先入后出 
const targetStack = [] 
function pushTarget(_target) { 
  if (Dep.target) targetStack.push(Dep.target) 
  Dep.target = _target 
} 
function popTarget() { 
  Dep.target = targetStack.pop() 
}

这里主要理解原型上的两个方法:depend 和 notify,一个用于添加依赖,一个用于通知更新。我们说收集“依赖”,那 this.deps 数组里到底存的是啥东西啊?Vue 设置了 Watcher 的概念用作依赖表示,即 this.deps 里收集的是一个个 Watcher。

3、数据变化时如何更新——实现 Watcher 类

Watcher,在Vue中有三种类型,分别用于页面渲染以及computed和watch这两个api,为了区分,将不同用处的 Watcher 分别称为 renderWatcher、computedWatcher 和 watchWatcher。

用 class 实现一下:


class Watcher {
  constructor(expOrFn) {
    // 这里传入参数不是函数时需要解析,parsePath略
    this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn)
    this.get()
  }
  // class中定义函数不需要写function
  get() {
    // 执行到这时,this是当前的watcher实例,也是Dep.target
    pushTarget(this)
    this.value = this.getter()
    popTarget()
  }
  update() {
    this.get()
  }
}

到这里,一个简单的响应式系统就成形了,总结来说:Object.defineProperty 让我们能够知道谁访问了数据以及什么时候数据发生变化,Dep 可以记录都有哪些 DOM 和某个数据有关,Watcher 可以在数据变化的时候通知 DOM 去更新。
Watcher 和 Dep 是一个非常经典的观察者设计模式的实现。

二、虚拟 DOM 和 diff

1、虚拟 DOM 是什么?

虚拟 DOM 是用 JS 中的对象来表示真实的DOM,如果有数据变动,先在虚拟 DOM 上改动,最后再去改动真实的DOM,Good idea!💡

关于虚拟 DOM 的优势,还是听尤大的:

在我看来 Virtual DOM 真正的价值从来都不是性能,而是它 1) 为函数式的 UI 编程方式打开了大门;2) 可以渲染到 DOM 以外的 backend。

举个例子:


<template>
  <div id="app" class="container">
    <h1>HELLO WORLD!</h1>
  </div>
</template>

// 对应的vnode 
{ 
  tag: 'div', 
  props: { id: 'app', class: 'container' }, 
  children: { tag: 'h1', children: 'HELLO WORLD!' } 
}

我们可以这样去定义:


function VNode(tag, data, childern, text, elm) { 
  this.tag = tag 
  this.data = data 
  this.childern = childern 
  this.text = text 
  this.elm = elm // 对真实节点的引用 
}

2、diff 算法——新旧节点对比

数据变化时,会触发渲染 watcher 的回调,更新视图。Vue 源码中在更新视图时用 patch 方法比较新旧节点的异同。

(1)判断新旧节点是不是相同节点


function sameVNode()
function sameVnode(a, b) { 
  return a.key === b.key && 
  ( a.tag === b.tag && 
    a.isComment === b.isComment && 
    isDef(a.data) === isDef(b.data) && 
    sameInputType(a, b) 
  ) 
 }

(2)若新旧节点不同

替换旧节点:创建新节点 -->  删除旧节点

(3)若新旧节点相同

  • 都没有子节点,好说
  • 一个有子节点一个没有,好说,要么删除个子节点要么新增个子节点
  • 都有子节点,这可就有点复杂了,执行updateChildren:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm
  // 以上是新旧Vnode的首尾指针、新旧Vnode的首尾节点

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 如果不满足这个while条件,表示新旧Vnode至少有一个已经遍历了一遍了,就退出循环
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 比较旧的开头和新的开头是否是相同节点
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 比较旧的结尾和新的结尾是否是相同节点
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 比较旧的开头和新的结尾是否是相同节点
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // 比较旧的结尾和新的开头是否是相同节点
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 设置key和不设置key的区别:
      // 不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      // 抽取出oldVnode序列的带有key的节点放在map中,然后再遍历新的vnode序列
      // 判断该vnode的key是否在map中,若在则找到该key对应的oldVnode,如果此oldVnode与遍历到的vnode是sameVnode的话,则复用dom并移动dom节点位置
      if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // same key but different element. treat as new element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  if (oldStartIdx > oldEndIdx) {
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

这里主要的逻辑是:新节点的头和尾与旧节点的头和尾分别比较,看是不是相同节点,如果是就直接patchVnode;否则的话,用一个 Map 存储旧节点的 key,然后遍历新节点的 key 看它们是不是在旧节点中存在,相同 key 那就复用;这里时间复杂度是O(n),空间复杂度也是O(n),用空间换时间~

diff 算法主要是为了减少更新量,找到最小差异部分 DOM ,只更新差异部分。

三、nextTick

所谓 nextTick,即下一个 tick,那 tick 是什么呢?

我们知道 JS 执行是单线程的,它处理异步逻辑是基于事件循环,主要分为以下几步:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack);
  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件;
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行;
  4. 主线程不断重复上面的第三步。

主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 Macro task 和 micro task,并且每个 macro task 结束后,都要清空所有的 micro task。


for (macroTask of macroTaskQueue) { 
  // 1. Handle current MACRO-TASK 
  handleMacroTask()
  // 2. Handle all MICRO-TASK 
  for (microTask of microTaskQueue) { 
    handleMicroTask(microTask)
  } 
}

在浏览器环境中,常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate、setInterval;常见的 micro task 有 MutationObsever 和 Promise.then。

我们知道数据的变化到 DOM 的重新渲染是一个异步过程,发生在下一个 tick。比如我们平时在开发的过程中,从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的 DOM 变化,我们就必须在 nextTick 后执行。比如下面的伪代码:


getData(res).then(() => { 
  this.xxx = res.data 
  this.$nextTick(() => { // 这里我们可以获取变化后的 DOM }) 
})

四、总结

到此这篇关于Vue源码学习之响应式是如何实现的文章就介绍到这了,更多相关Vue响应式实现内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!

--结束END--

本文标题: Vue源码学习之响应式是如何实现的

本文链接: https://lsjlt.com/news/154515.html(转载时请注明来源链接)

有问题或投稿请发送至: 邮箱/279061341@qq.com    QQ/279061341

猜你喜欢
  • Vue源码学习之响应式是如何实现的
    目录前言一、一个响应式系统的关键要素1、如何监听数据变化2、如何进行依赖收集——实现 Dep 类3、数据变化时如何更新——实现 Watcher 类二、虚拟 DOM 和 diff1、虚...
    99+
    2024-04-02
  • Vue源码学习defineProperty响应式数据原理实现
    目录准备工作第一步 对对象进行劫持第二步 修改取值方法第三步 深度属性劫持准备工作 接上文数据初始化完成之后,就可以对数据进行劫持。Vue2中对数据进行劫持采用了一个Api叫Obje...
    99+
    2024-04-02
  • Vue解读之响应式原理源码剖析
    目录初始化initState()initProps()initData()observe()ObserverdefineReactive()依赖收集DepWatcher依赖收集过程移...
    99+
    2024-04-02
  • Android源码学习之组合模式定义及应用
    组合模式定义: Compose objects into tree structures to represent part-whole hierarchies. Compos...
    99+
    2022-06-06
    学习 组合模式 Android
  • 怎么实现Vue的响应式
    这篇文章主要介绍了怎么实现Vue的响应式,具有一定借鉴价值,感兴趣的朋友可以参考下,希望大家阅读完这篇文章之后大有收获,下面让小编带着大家一起了解一下。简易版 以watch为切入点watch是平时开发中使用...
    99+
    2024-04-02
  • Android源码学习之单例模式应用及优点介绍
    单例模式定义: Ensure a class has only one instance, and provide a global point of access to it...
    99+
    2022-06-06
    学习 单例模式 Android
  • Spring源码学习之动态代理实现流程
    注:这里不阐述Spring和AOP的一些基本概念和用法,直接进入正题。 流程   Spring所管理的对象大体会经过确定实例化对象类型、推断构造方法创建对象...
    99+
    2024-04-02
  • Vue3响应式对象是如何实现的(2)
    目录前言分支切换的优化副作用函数嵌套产生的BUG自增/自减操作产生的BUG前言 在Vue3响应式对象是如何实现的(1)中,我们已经从功能上实现了一个响应式对象。如果仅仅满足于功能实现...
    99+
    2024-04-02
  • Vue3响应式对象是如何实现的(1)
    目录简单的响应式实现Proxy与响应式为什么需要Proxy?Proxy创建的代理对象与原始对象有何不同?多副作用函数的响应式实现简单的响应式实现 为了方便说明,先来看一个简单的例子。...
    99+
    2024-04-02
  • Vue 3中响应式的实现原理是什么
    本篇文章给大家分享的是有关Vue 3中响应式的实现原理是什么,小编觉得挺实用的,因此分享给大家学习,希望大家阅读完这篇文章后可以有所收获,话不多说,跟着小编一起来看看吧。1. 实现响应式响应基本类型变量首先...
    99+
    2024-04-02
  • vue如何使用媒体查询实现响应式
    目录使用媒体查询实现响应式的两种方式前提1.在每个组件中为其使用媒体查询2.写n套页面,在使用这些页面的组件中进行一次媒体查询vue中的媒体查询语法使用媒体查询实现响应式的两种方式 ...
    99+
    2024-04-02
  • Vue响应式原理与虚拟DOM如何实现
    本篇内容介绍了“Vue响应式原理与虚拟DOM如何实现”的有关知识,在实际案例的操作过程中,不少人都会遇到这样的困境,接下来就让小编带领大家学习一下如何处理这些情况吧!希望大家仔细阅读,能够学有所成!一、什么是响应式系统在Vue中,我们可以使...
    99+
    2023-07-05
  • VUE响应式原理的实现详解
    目录总结前言 相信vue学习者都会发现,vue使用起来上手非常方便,例如双向绑定机制,让我们实现视图、数据层的快速同步,但双向绑定机制实现的核心数据响应的原理是怎么样的呢,接下来让我...
    99+
    2024-04-02
  • 详解Vue响应式的部分实现
    目录什么是响应式Vue2与Vue3响应式之间的区别使用Object.defineProperty监听对象使用Object.defineProperty监听对象使用ES6的Proxy实...
    99+
    2022-12-08
    Vue响应式实现 Vue响应式
  • Python对象的底层实现源码学习
    目录1. PyObject:对象的基石2. PyVarObject:变长对象的基础2.1 浮点对象2.2 列表对象3. PyTypeObject:类型的基石4. PyType_Typ...
    99+
    2024-04-02
  • Android源码学习之工厂方法模式应用及优势介绍
    工厂方法模式定义: Define an interface for creating an object, but let subclasses decide which cl...
    99+
    2022-06-06
    工厂方法模式 方法 学习 Android
  • 如何在 Laravel 中学习 PHP 响应的最佳实践?
    随着互联网的快速发展,PHP作为一种简单易用的编程语言,被广泛应用于Web开发领域。而Laravel则是PHP中一款非常流行的Web框架。本文将介绍如何在Laravel中学习PHP响应的最佳实践。 什么是响应? 在Web开发中,响应是指服务...
    99+
    2023-08-09
    响应 学习笔记 laravel
  • Vue源码解析之数据响应系统的示例分析
    这篇文章主要介绍Vue源码解析之数据响应系统的示例分析,文中介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们一定要看完!数据双向绑定的思路1. 对象先来看元素是对象的情况。假设我们有一个对象和一个监测方...
    99+
    2024-04-02
  • Vue中响应式系统实现原理是什么
    本篇内容介绍了“Vue中响应式系统实现原理是什么”的有关知识,在实际案例的操作过程中,不少人都会遇到这样的困境,接下来就让小编带领大家学习一下如何处理这些情况吧!希望大家仔细阅读,能够学有所成!框架每个组件实例都会对应一个watcher实例...
    99+
    2023-07-05
  • Go语言学习之路:如何在Unix环境下编写高质量的响应式应用?
    Go语言是一种开源编程语言,由Google设计和开发,它的设计目标是提高程序员的生产力。Go语言拥有简洁的语法、高效的并发机制和优秀的性能,因此越来越受到开发者的青睐。本文将介绍如何在Unix环境下编写高质量的响应式应用。 安装Go环境...
    99+
    2023-09-26
    教程 响应 unix
软考高级职称资格查询
编程网,编程工程师的家园,是目前国内优秀的开源技术社区之一,形成了由开源软件库、代码分享、资讯、协作翻译、讨论区和博客等几大频道内容,为IT开发者提供了一个发现、使用、并交流开源技术的平台。
  • 官方手机版

  • 微信公众号

  • 商务合作