最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 0年前端的Vue响应式原理学习总结:基本原理

    正文概述 掘金(争霸爱好者)   2021-02-24   388

    同学们,你是否想学习Vue的数据响应式原理而无从下手呢?是否有过被复杂的源码教程劝退的经历呢?如果你和我一样,做过一个项目之后想深入原理的话,恭喜你,你来对地方了。这个系列文章将从纯粹的Vue响应式原理出发,没有其他因素的干扰,带领大家实现一个自己的响应式系统。

    项目地址:gitee

    0.前言

    使用Vue时,我们只需要修改数据(state),视图就能够获得相应的更新,这就是响应式系统。要实现一个自己的响应式系统,我们首先要明白要做什么事情:

    1. 数据劫持:当数据变化时,我们可以做一些特定的事情
    2. 依赖收集:我们要知道那些视图层的内容(DOM)依赖了哪些数据(state)
    3. 派发更新:数据变化后,如何通知依赖这些数据的DOM

    接下来,我们将一步步地实现一个自己的玩具响应式系统

    1. 数据劫持

    几乎所有的文章和教程,在讲解Vue响应式系统时都会先讲:Vue使用Object.defineProperty来进行数据劫持。那么,我们也从数据劫持讲起,大家可能会对劫持这个概念有些迷茫,没有关系,看完下面的内容,你一定会明白。

    Object.defineProperty的用法在此不多做介绍,不明白的同学可在MDN上查阅。下面,我们为obj定义一个a属性

    const obj = {}
    
    let val = 1
    Object.defineProperty(obj, a, {
      get() { // 下文中该方法统称为getter
        console.log('get property a')
        return val
      },
      set(newVal) { // 下文中该方法统称为setter
        if (val === newVal) return
        console.log(`set property a -> ${newVal}`)
        val = newVal
      }
    })
    

    这样,当我们访问obj.a时,打印get property a并返回1,obj.a = 2设置新的值时,打印set property a -> 2。这相当于我们自定义了obj.a取值和赋值的行为,使用自定义的gettersetter来重写了原有的行为,这也就是数据劫持的含义。

    但是上面的代码有一个问题:我们需要一个全局的变量来保存这个属性的值,因此,我们可以用下面的写法

    // value使用了参数默认值
    function defineReactive(data, key, value = data[key]) {
      Object.defineProperty(data, key, {
        get: function reactiveGetter() {
          return value
        },
        set: function reactiveSetter(newValue) {
          if (newValue === value) return
          value = newValue
        }
      })
    }
    
    defineReactive(obj, a, 1)
    

    如果obj有多个属性呢?我们可以新建一个类Observer来遍历该对象

    class Observer {
      constructor(value) {
        this.value = value
        this.walk()
      }
      walk() {
        Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
      }
    }
    
    const obj = { a: 1, b: 2 }
    new Observer(obj)
    

    如果obj内有嵌套的属性呢?我们可以使用递归来完成嵌套属性的数据劫持

    // 入口函数
    function observe(data) {
      if (typeof data !== 'object') return
      // 调用Observer
      new Observer(data)
    }
    
    class Observer {
      constructor(value) {
        this.value = value
        this.walk()
      }
      walk() {
        // 遍历该对象,并进行数据劫持
        Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
      }
    }
    
    function defineReactive(data, key, value = data[key]) {
      // 如果value是对象,递归调用observe来监测该对象
      // 如果value不是对象,observe函数会直接返回
      observe(value)
      Object.defineProperty(data, key, {
        get: function reactiveGetter() {
          return value
        },
        set: function reactiveSetter(newValue) {
          if (newValue === value) return
          value = newValue
        }
      })
    }
    
    const obj = {
      a: 1,
      b: {
        c: 2
      }
    }
    
    observe(obj)
    

    对于这一部分,大家可能有点晕,接下来梳理一下:

    执行observe(obj)
    ├── new Observer(obj),并执行this.walk()遍历obj的属性,执行defineReactive()
        ├── defineReactive(obj, a)
            ├── 执行observe(obj.a) 发现obj.a不是对象,直接返回
            ├── 执行defineReactive(obj, a) 的剩余代码
        ├── defineReactive(obj, b) 
    	    ├── 执行observe(obj.b) 发现obj.b是对象
    	        ├── 执行 new Observer(obj.b),遍历obj.b的属性,执行defineReactive()
                        ├── 执行defineReactive(obj.b, c)
                            ├── 执行observe(obj.b.c) 发现obj.b.c不是对象,直接返回
                            ├── 执行defineReactive(obj.b, c)的剩余代码
                ├── 执行defineReactive(obj, b)的剩余代码
    代码执行结束
    

    可以看出,上面三个函数的调用关系如下:

    0年前端的Vue响应式原理学习总结:基本原理

    三个函数相互调用从而形成了递归,与普通的递归有所不同。 有些同学可能会想,只要在setter中调用一下渲染函数来重新渲染页面,不就能完成在数据变化时更新页面了吗?确实可以,但是这样做的代价就是:任何一个数据的变化,都会导致这个页面的重新渲染,代价未免太大了吧。我们想做的效果是:数据变化时,只更新与这个数据有关的DOM结构,那就涉及到下文的内容了:依赖

    2. 收集依赖与派发更新

    依赖

    在正式讲解依赖收集之前,我们先看看什么是依赖。举一个生活中的例子:淘宝购物。现在淘宝某店铺上有一块显卡(空气)处于预售阶段,如果我们想买的话,我们可以点击预售提醒,当显卡开始卖的时候,淘宝为我们推送一条消息,我们看到消息后,可以开始购买。

    将这个例子抽象一下就是发布-订阅模式:买家点击预售提醒,就相当于在淘宝上登记了自己的信息(订阅),淘宝则会将买家的信息保存在一个数据结构中(比如数组)。显卡正式开放购买时,淘宝会通知所有的买家:显卡开卖了(发布),买家会根据这个消息进行一些动作(比如买回来挖矿)。

    Vue响应式系统中,显卡对应数据,那么例子中的买家对应什么呢?就是一个抽象的类: Watcher。大家不必纠结这个名字的含义,只需要知道它做什么事情:每个Watcher实例订阅一个或者多个数据,这些数据也被称为wacther的依赖(商品就是买家的依赖);当依赖发生变化,Watcher实例会接收到数据发生变化这条消息,之后会执行一个回调函数来实现某些功能,比如更新页面(买家进行一些动作)。

    0年前端的Vue响应式原理学习总结:基本原理

    因此Watcher类可以如下实现

    class Watcher {
      constructor(data, expression, cb) {
        // data: 数据对象,如obj
        // expression:表达式,如b.c,根据data和expression就可以获取watcher依赖的数据
        // cb:依赖变化时触发的回调
        this.data = data
        this.expression = expression
        this.cb = cb
        // 初始化watcher实例时订阅数据
        this.value = this.get()
      }
      
      get() {
        const value = parsePath(this.data, this.expression)
        return value
      }
      
      // 当收到数据变化的消息时执行该方法,从而调用cb
      update() {
        this.value = parsePath(this.data, this.expression) // 对存储的数据进行更新
        cb()
      }
    }
    
    function parsePath(obj, expression) {
      const segments = expression.split('.')
      for (let key of segments) {
        if (!obj) return
        obj = obj[key]
      }
      return obj
    }
    

    其实前文例子中还有一个点我们尚未提到:显卡例子中说到,淘宝会将买家信息保存在一个数组中,那么我们的响应式系统中也应该有一个数组来保存买家信息,也就是watcher

    总结一下我们需要实现的功能:

    1. 有一个数组来存储watcher
    2. watcher实例需要订阅(依赖)数据,也就是获取依赖或者收集依赖
    3. watcher的依赖发生变化时触发watcher的回调函数,也就是派发更新。

    每个数据都应该维护一个属于自己的数组,该数组来存放依赖自己的watcher,我们可以在defineReactive中定义一个数组dep,这样通过闭包,每个属性就能拥有一个属于自己的dep

    function defineReactive(data, key, value = data[key]) {
      const dep = [] // 增加
      observe(value)
      Object.defineProperty(data, key, {
        get: function reactiveGetter() {
          return value
        },
        set: function reactiveSetter(newValue) {
          if (newValue === value) return
          value = newValue
          dep.notify()
        }
      })
    }
    

    到这里,我们实现了第一个功能,接下来实现收集依赖的过程。

    依赖收集

    现在我们把目光集中到页面的初次渲染过程中(暂时忽略渲染函数和虚拟DOM等部分):渲染引擎会解析模板,比如引擎遇到了一个插值表达式,如果我们此时实例化一个watcher,会发生什么事情呢?从Watcher的代码中可以看到,实例化时会执行get方法,get方法的作用就是获取自己依赖的数据,而我们重写了数据的访问行为,为每个数据定义了getter,因此getter函数就会执行,如果我们在getter中把当前的watcher添加到dep数组中(淘宝低登记买家信息),不就能够完成依赖收集了吗!!

    通过上面的分析,我们只需要对getter进行一些修改:

    get: function reactiveGetter() {
      dep.push(watcher) // 新增
      return value
    }
    

    问题又来了,watcher这个变量从哪里来呢?我们是在模板编译函数中的实例化watcher的,getter中取不到这个实例啊。解决方法也很简单,将watcher实例放到全局不就行了吗,比如放到window.target上。因此,Watcherget方法做如下修改

    get() {
      window.target = this // 新增
      const value = parsePath(this.data, this.expression)
      return value
    }
    

    这样,将get方法中的dep.push(watcher)修改为dep.push(window.target)即可。

    派发更新

    实现依赖收集后,我们最后要实现的功能是派发更新,也就是依赖变化时触发watcher的回调。从依赖收集部分我们知道,获取哪个数据,也就是说触发哪个数据的getter,就说明watcher依赖哪个数据,那数据变化的时候如何通知watcher呢?相信很多同学都已经猜到了:在setter中派发更新。

    set: function reactiveSetter(newValue) {
      if (newValue === value) return
      dep.forEach(d => d.update()) // 新增 update方法见Watcher类
    }
    

    3. 优化代码

    1. Dep类

    我们可以将dep数组抽象为一个类:

    class Dep {
      constructor() {
        this.subs = []
      }
    
      depend() {
        this.addSub(Dep.target)
      }
    
      notify() {
        const subs = [...this.subs]
        subs.forEach((s) => s.update())
      }
    
      addSub(sub) {
        this.subs.push(sub)
      }
    }
    

    defineReactive函数只需做相应的修改

    function defineReactive(data, key, value = data[key]) {
      const dep = new Dep() // 修改
      observe(value)
      Object.defineProperty(data, key, {
        get: function reactiveGetter() {
          dep.depend() // 修改
          return value
        },
        set: function reactiveSetter(newValue) {
          if (newValue === value) return
          value = newValue
          dep.notify() // 修改
        }
      })
    }
    

    2. window.target

    watcherget方法中

    get() {
      window.target = this // 设置了window.target
      const value = parsePath(this.data, this.expression)
      return value
    }
    

    大家可能注意到了,我们没有重置window.target。有些同学可能认为这没什么问题,但是考虑如下场景:有一个对象obj: { a: 1, b: 2 }我们先实例化了一个watcher1watcher1依赖obj.a,那么window.target就是watcher1。之后我们访问了obj.b,会发生什么呢?访问obj.b会触发obj.bgettergetter会调用dep.depend(),那么obj.bdep就会收集window.target, 也就是watcher1,这就导致watcher1依赖了obj.b,但事实并非如此。为解决这个问题,我们做如下修改:

    // Watcher的get方法
    get() {
      window.target = this
      const value = parsePath(this.data, this.expression)
      window.target = null // 新增,求值完毕后重置window.target
      return value
    }
    
    // Dep的depend方法
    depend() {
      if (Dep.target) { // 新增
        this.addSub(Dep.target)
      }
    }
    

    通过上面的分析能够看出,window.target的含义就是当前执行上下文中的watcher实例。由于js单线程的特性,同一时刻只有一个watcher的代码在执行,因此window.target就是当前正在处于实例化过程中的watcher

    3. update方法

    我们之前实现的update方法如下:

    update() {
      this.value = parsePath(this.data, this.expression)
      this.cb()
    }
    

    大家回顾一下vm.$watch方法,我们可以在定义的回调中访问this,并且该回调可以接收到监听数据的新值和旧值,因此做如下修改

    update() {
      const oldValue = this.value
      this.value = parsePath(this.data, this.expression)
      this.cb.call(this.data, this.value, oldValue)
    }
    

    4. 学习一下Vue源码

    在Vue源码--56行中,我们会看到这样一个变量:targetStack,看起来好像和我们的window.target有点关系,没错,确实有关系。设想一个这样的场景:我们有两个嵌套的父子组件,渲染父组件时会新建一个父组件的watcher,渲染过程中发现还有子组件,就会开始渲染子组件,也会新建一个子组件的watcher。在我们的实现中,新建父组件watcher时,window.target会指向父组件watcher,之后新建子组件watcherwindow.target将被子组件watcher覆盖,子组件渲染完毕,回到父组件watcher时,window.target变成了null,这就会出现问题,因此,我们用一个栈结构来保存watcher

    const targetStack = []
    
    function pushTarget(_target) {
      targetStack.push(window.target)
      window.target = _target
    }
    
    function popTarget() {
      window.target = targetStack.pop()
    }
    

    Watcherget方法做如下修改

    get() {
      pushTarget(this) // 修改
      const value = parsePath(this.data, this.expression)
      popTarget() // 修改
      return value
    }
    

    此外,Vue中使用Dep.target而不是window.target来保存当前的watcher,这一点影响不大,只要能保证有一个全局唯一的变量来保存当前的watcher即可

    5.总结代码

    现将代码总结如下:

    // 调用该方法来检测数据
    function observe(data) {
      if (typeof data !== 'object') return
      new Observer(data)
    }
    
    class Observer {
      constructor(value) {
        this.value = value
        this.walk()
      }
      walk() {
        Object.keys(this.value).forEach((key) => defineReactive(this.value, key))
      }
    }
    
    // 数据拦截
    function defineReactive(data, key, value = data[key]) {
      const dep = new Dep()
      observe(value)
      Object.defineProperty(data, key, {
        get: function reactiveGetter() {
          dep.depend()
          return value
        },
        set: function reactiveSetter(newValue) {
          if (newValue === value) return
          value = newValue
          dep.notify()
        }
      })
    }
    
    // 依赖
    class Dep {
      constructor() {
        this.subs = []
      }
    
      depend() {
        if (Dep.target) {
          this.addSub(Dep.target)
        }
      }
    
      notify() {
        const subs = [...this.subs]
        subs.forEach((s) => s.update())
      }
    
      addSub(sub) {
        this.subs.push(sub)
      }
    }
    
    Dep.target = null
    
    const TargetStack = []
    
    function pushTarget(_target) {
      TargetStack.push(Dep.target)
      Dep.target = _target
    }
    
    function popTarget() {
      Dep.target = TargetStack.pop()
    }
    
    // watcher
    class Watcher {
      constructor(data, expression, cb) {
        this.data = data
        this.expression = expression
        this.cb = cb
        this.value = this.get()
      }
    
      get() {
        pushTarget(this)
        const value = parsePath(this.data, this.expression)
        popTarget()
        return value
      }
    
      update() {
        const oldValue = this.value
        this.value = parsePath(this.data, this.expression)
        this.cb.call(this.data, this.value, oldValue)
      }
    }
    
    // 工具函数
    function parsePath(obj, expression) {
      const segments = expression.split('.')
      for (let key of segments) {
        if (!obj) return
        obj = obj[key]
      }
      return obj
    }
    
    // for test
    let obj = {
      a: 1,
      b: {
        m: {
          n: 4
        }
      }
    }
    
    observe(obj)
    
    let w1 = new Watcher(obj, 'a', (val, oldVal) => {
      console.log(`obj.a 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
    })
    
    

    4. 注意事项

    1. 闭包

    Vue能够实现如此强大的功能,离不开闭包的功劳:在defineReactive中就形成了闭包,这样每个对象的每个属性就能保存自己的值value和依赖对象dep

    2. 只要触发getter就会收集依赖吗

    答案是否定的。在Depdepend方法中,我们看到,只有Dep.target为真时才会添加依赖。比如在派发更新时会触发watcherupdate方法,该方法也会触发parsePath来取值,但是此时的Dep.targetnull,不会添加依赖。仔细观察可以发现,只有watcherget方法中会调用pushTarget(this)来对Dep.target赋值,其他时候Dep.target都是null,而get方法只会在实例化watcher的时候调用,因此,在我们的实现中,一个watcher的依赖在其实例化时就已经确定了,之后任何读取值的操作均不会增加依赖。

    3. 依赖嵌套的对象属性

    我们结合上面的代码来思考下面这个问题:

    let w2 = new Watcher(obj, 'b.m.n', (val, oldVal) => {
      console.log(`obj.b.m.n 从 ${oldVal}(oldVal) 变成了 ${val}(newVal)`)
    })
    

    我们知道,w2会依赖obj.b.m.n, 但是w2会依赖obj.b, obj.b.m吗?或者说,obj.b,和obj.b.m,它们闭包中保存的dep中会有w2吗?答案是会。我们先不从代码角度分析,设想一下,如果我们让obj.b = null,那么很显然w2的回调函数应该被触发,这就说明w2会依赖中间层级的对象属性。

    接下来我们从代码层面分析一下:new Watcher()时,会调用watcher的get方法,将Dep.target设置为w2get方法会调用parsePath来取值,我们来看一下取值的具体过程:

    function parsePath(obj, expression) {
      const segments = expression.split('.') // 先将表达式分割,segments:['b', 'm', 'n']
      // 循环取值
      for (let key of segments) {
        if (!obj) return
        obj = obj[key]
      }
      return obj
    }
    

    以上代码流程如下:

    1. 局部变量obj为对象obj,读取obj.b的值,触发getter,触发dep.depend()(该depobj.b的闭包中的dep),Dep.target存在,添加依赖
    2. 局部变量objobj.b,读取obj.b.m的值,触发getter,触发dep.depend()(该depobj.b.m的闭包中的dep),Dep.target存在,添加依赖
    3. 局部变量obj为对象obj.b.m,读取obj.b.m.n的值,触发getter,触发dep.depend()(该depobj.b.m.n的闭包中的dep),Dep.target存在,添加依赖

    从上面的代码可以看出,w2会依赖与目标属性相关的每一项,这也是符合逻辑的。

    5. 总结

    总结一下:

    1. 调用observe(obj),将obj设置为响应式对象,observe函数,Observe, defineReactive函数三者互相调用,从而递归地将obj设置为响应式对象
    2. 渲染页面时实例化watcher,这个过程会读取依赖数据的值,从而完成在getter中获取依赖
    3. 依赖变化时触发setter,从而派发更新,执行回调,完成在setter中派发更新

    占个坑

    从严格意义来说,我们现在完成的响应式系统还不能用于渲染页面,因为真正用于渲染页面的watcher是不需要设置回调函数的,我们称之为渲染watcher。此外,渲染watcher可以接收一个渲染函数而不是表达式作为参数,当依赖变化时自动重新渲染,而这样又会带来重复依赖的问题。此外,另一个重要的内容我们还没有涉及到,就是数组的处理。

    现在看不懂前面提到的问题,没有关系,这个系列之后的文章会一步步来解决这些问题,希望大家能够继续关注。


    起源地下载网 » 0年前端的Vue响应式原理学习总结:基本原理

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元