Vue的响应式原理

858 阅读8分钟

最近一直在看响应式,在掘金上类似的文章已经多的不能再多了,如果要问响应式我相信大家都能说上来几句,在前端社区中vue的响应式也是一个火热的话题。笔者此次炒冷饭的主要原因是之前看代码的时候,虽然能够大致理解是个什么道理,但仍然存在很多疑惑,所以希望这一次可以把自己的疑惑的地方尽可能弄清楚。

说到响应式,如果我们抛开Vue的中的应用不谈,单就实现这种设计模式的思路应该是什么这本身就是一个很值得思考的过程。我们先看一下下面的代码:

    function defineReactivity(source, key) {
    
        const obj = {}
    
        Object.defineProperty(obj, key, {
            configurable: true,
            enumerable: true,
            
            get() {
                
                depend()
                
                return source[key]
            },
            
            set(newVal) {
                
                notify()
                
                source[key] = newVal
            }
        
        })
        
        return obj
    }

不需要几行代码,就可以实现出一个简单的响应式,如果把 Object.defineProperty 换成 Proxy 我们甚至还可以省略声明额外对象的这个步骤。那么再基于上面的代码抛出我在思考过程中的几个问题:

  1. 如何进行收集,收集的东西是什么
  2. 如何区别绑定每一个数据和它的依赖对象
  3. 当数据变化如何通知依赖的对象们

首先,我们需要明确的是程序并不知道依赖对象想要在数据更新之后做什么事情。由于在订阅了这个数据之后一定需要做一些事情,那么我们就需要去收集一个行为或者可以触发这个行为的对象。单拿Vue绑定视图这部分来说,那收集的行为就一定是 render view

第二个问题,我们收集了很多行为,但是同时对象上有很多属性,我们需要明确区别每一个属性上的依赖。这一步乍一看好像并不复杂,但是如果涉及了多层的数据结构就非常复杂了。例如 { a1: { a2: { a3: 'deep' }, b: 11 } } 这个数据结构有一个非常有意思的地方是,如果我依赖了 a2 这个对象,当 a3 变话了需不需要被通知到。因此在 Vue 的 Observer 上有两个 dep 一个是处理自身被订阅的对象,还有一个处理当自身作为一个子元素时依赖了父级的对象。

第三个问题,当数据变化的时候如何进行通知。因为我们已经收集很多行为或者具有触发行为的对象,因此我们只需要全部触发行为即可。这个部分可能是我的思考不够或者代码阅读不够细致,并没有找到太多复杂或者边缘的场景。

经过了简单分析之后我们来实现一套完整的响应式:

    const dep = new Map
    let watchHandlers = []

    function defineReactivity(source, key) {
    
        const obj = {}
        
        let val = source[key]
    
        Object.defineProperty(obj, key, {
            configurable: true,
            enumerable: true,
            
            get() {
                
                depend(obj, key, val)
                
                return val
            },
            
            set(newVal) {
            
                if (newVal === val || (newVal !== newVal && val !== val))
                    return

                val = newVal
                
                notify(obj, key)
                

            }
        
        })
        
        return obj
    }
    
   function running(handler) {
     watchHandlers = []
     
     handler()
     let i = watchHandlers.length - 1, 
         watchedHanler = null
     
     while(watchedHanler = watchHandlers[i--]) {
         const [obj, key] = watchedHanler
         
         if (!dep.has(obj))
             dep.set(obj, new Map)
             
         if (!dep.get(obj).has(key))
             dep.get(obj).set(key, [])
             
         dep.get(obj).get(key).push(handler)
     }
     
   }
   
   
   function depend(obj, key, value) {
       if (dep.has(obj))
           if (dep.get(obj).has(key))
               return value
           
        watchHandlers.push([obj, key])
   }
   
   function notify(obj, key) {

       if (dep.has(obj))
          if (dep.get(obj).has(key))
            for (const watchedHanler of dep.get(obj).get(key))
                 watchedHanler()
              
   }
   
 const dv = document.createElement('div')
 dv.classList.add('root')
 document.body.appendChild(dv)

 const $ = document.querySelector.bind(document)
 
 const p = defineReactivity({ name: 'this is p' }, 'name')
 
 running(() => $('.root').innerText = p.name)
    

此时在控制台敲一个 p.name = 123 就会发现页面的内容改变了,这就是一个比较完整的响应式功能,其中并没有对依赖的数据是不是对象进一步进行递归处理,因为太复杂了有点麻烦...。但是,不管怎么说,如果能够理解上面的代码应该会对Vue中更加复杂的响应式有一些帮助。

Tips: newVal !== newVal && val !== val 为什么要把源码这部分内容摘抄出来,主要是我觉得这个位置的判断非常细节,判断前后两个 value 是不是相同还考虑到了 NaN 的情况

我对于面向对象编程的理解是,对象的属性是用来表现对象的特性,对象的方法用来表示对象的行为,而行为的目的是要改变对象的特性的。换句话说就是:对象的方法不仅仅可以改变对象的属性,但是对象的属性只能通过对象的方法来改变。而Vue作为一个面向对象编程的集大成的一个框架,在内部处理对象属性和方法之间的关系也是这样。在Vue中构成响应式有三个非常重要的组成部分,分别是 ObserverDepWacther,都是通过自身的方法来修改自身的属性,这就是为什么在阅读的时候会发现有点绕。

先说这三个部分是做了什么事情。Observer 监听的对象的属性,并在属性改变之后通知依赖这个属性的其余对象,这个监听的动作已经是一个深度的监听,但是即使这样也无法处理新增属性,这也是为什么会提供 set 方法来处理新增对象的属性或者新增数组元素。Dep 会挂载在每一个 Observer 上收集这些依赖某一个属性的对象们。其实在上面的事例代码也参照这三个部分的作用进行了简化,例子用使用了 Map 来作为 Dep 类型也是为了区别不同对象的不同属性的依赖项。最后 Wacther 的行为就是在一切发生了变化之后进行通知回调。这里需要说清一件事情,Wacther 本质上并不会真正的储存 dep ,我们看到所有的储存行为只是一个临时的动作,仅仅记录了在本次行为中使用的依赖,而在下一个行为的时候就会被重新替换。这就是为什么整个渲染过程全程只需要一个 WacthermountComponent 的时候被 new 出来,因为每一次只需要替换这个唯一的 Wacther 上的 dep 就可以了。

下面就是这三部分中相关的代码

 function observe(val) {
    if (typeof val !== 'obj' || val === null)
        return
       
    if ('__ob__' in val)
        return val.__ob__
        
     return new Oberver(val)
 }
 
 class Observer {
   constructor(val) {
       this.value = val
       this.value.['__ob__'] = this
       this.dep = new Dep
       if (Array.isArray(val))
           for (const item of val)
               observe(item)
       else
         this.walk(val)
   }
   
   walk(obj) {
       const keys = Object.keys(obj)

        for (let i = 0; i < keys.length; i++) {
          defineReactive(obj, keys[i])
        }
   }
 }
 
 function defineReactive(obj, keys[i], val) {
     const dep = new Dep
     const property = Object.getOwnPropertyDescriptor(Obj, key) || {}
     const getter = property.get
     const setter = property.set
     if ((!getter || setter) && arguments.length === 2) {
       val = obj[key]
     }
     
     let childOb = observe(val)
     
     Object.defineProperty(obj, key, {
        configurable: true,
        enumerable: true,

        get: function reactiveGetter(){
          const value = getter ? getter.call(obj) : val

          if (Dep.target) {
            dep.depend()

            if (childOb) {
              childOb.dep.depend()

              if (Array.isArray(value)) {}
            }

          }

          return value
        },

        set: function reactiveSetter(newVal) {
          const value = getter ? getter.call(obj) : val

          if (val === newVal || (newVal !== newVal || val !== val))
            return

          if (setter) {
            setter.call(obj, newVal)
          } else {
            val = newVal
          }

          childOb = observe(newVal)

          dep.notify()
        }
      })
 }

当然了这并不是Vue中实现的源码,而是一个精简版,我把核心的逻辑都拿了出来并保留了我觉得非常好的代码。这里面就像我们在上一趴介绍的那样,在 Obersver 分别把 value 中的属性类型根据不同的数据类型分别进行了深度处理,基本类型的数据会直接进行监听,不是基本类型数据就递归监听,递归的出口是一个基础类型数据或者一个被绑定的对象。在 defineReactive 方法中也是不仅仅绑定了自身的依赖,也针对对象属性进行了深度依赖。

    var uid = 0

    class Dep {

      static target = null

      constructor() {
        this.subs = []
        this.id = uid++
      }

      addSub(sub) {
        this.subs.push(sub)
      }

      removeSub(sub) {
        remove(sub)
      }

      depend() {
        if (Dep.target)
          Dep.target.addDep(this)
      }

      notify() {
        const subs = this.subs.slice()

        let i = subs.length

        sort()

        while(i--) {
          subs[i].update()
        }

      }

    }

    var targetStack = []

    function pushTarget(target) {
      targetStack.push(target)
      Dep.target = target
    }

    function popTarget() {
      targetStack.pop()
      Dep.target = targetStack[targetStack.length - 1]
    }

Dep 的数据结构非常的简单,主要功能就是订阅和通知。然后就是 Dep.target 这个对象是干嘛。顾名思义这个对象指向了想要依赖某一数据的目标对象,而这个对象是一个 Wacther 的实例。这部分本来想要等到后面的部分介绍,但是怕看到这部分产生疑惑,所以提到这部分来说。前面也是提到了我们需要收集的内容是什么,一个行为或者一个执行某一个行为的对象,在这里收集的就是具有行为能力的一个对象。这个 Wacther 对象的行为就是我们在数据变化之后需要触发的行为,所以我们不需要知道 Watcher 做了什么,只需要触发这个行为就像可以。而使用 tagretStack 这个数据结构是因为在该行为执行完成之前还可能加入其余的行为,这就是 $wacth 或者 computed 的动作了。

class Watcher {
  constructor(vm, expOrFn, cb, options, render) {
    this.vm = vm
    this.getter = expOrFn
    this.sync = true
    this.active = true

    this.deps = []
    this.newDeps = []

    this.depIds = new Set
    this.newDepIds = new Set

    this.value = this.get()
  }

  get() {
    let value

    pushTarget()

    value = this.getter()

    this.cleanupDeps()

    popTarget()

    return value
  }

  addDep(dep) {
    const id = dep.id

    if (!this.newDepIds.has(id)) {
      this.newDeps.push(dep)
      this.newDepIds.add(id)

      if (!this.depIds.has(id))
        dep.addSub(this)
    }
  }

  cleanupDeps() {
    let i = this.deps.length
    let dep

    while(dep = this.deps[i--])
      if (!this.newDepIds.has(dep.id))
        dep.removeSub(this)

    let tmp = this.depIds
    this.depIds = this.newDepIds
    this.newDepIds = tmp
    this.newDepIds.clear()

    tmp = this.deps
    this.deps = this.newDepIds
    this.newDepIds = tmp
    this.newDepIds.length = 0
  }

  update() {
    if (this.sync) {
      this.run()
    } else {
      // queueWatcher(this)
    }
  }

  run() {
    if (this.active) {
      const value = this.get()

      if (value !== this.value || isObject(value) || this.deep) {
        const oldValue = this.value
        this.value = value

        this.cb.call(this.vm, value, oldValue)
      }
    }
  }

  teardown () {
    if (this.active) {
      if (!this.vm._isBeingDestroyed) {
        remove(this.vm._watchers, this)
      }
      let i = this.deps.length
      while (i--) {
        this.deps[i].removeSub(this)
      }
      this.active = false
    }
  }
}

这部分让我困惑了很久的地方是为什么使用了两个 depList 储存前后两次渲染需要的依赖。使用两list的原因是一个是储存上一次的依赖,一个是本次的依赖。本次的依赖记录变化,上一次的依赖在渲染结束之前解绑。

其实到这里响应式的内容并没有完全结束,出了上述的内容还有处理异步队列的部分,但是那部分内容我还没有来得及看,等阅读完之后也会作为一个学习记录的内容发出来。最后想说的是,纵观整个响应式的设计其实都 自身改变自身 原则,每个部分各司其职,然后再联合协作共同完成了整个响应式。所以在学习一个源码的如何实现的同时,更重要的是它的设计和思考