【Vue响应式原理】一文带你代码实现

170 阅读8分钟

前言

在Vue中,实现响应式原理大概会有以下几步:

  1. 数据劫持(设置、getter和setter,Vue3使用Proxy,Vue2使用Object.defineProperty)
  2. 依赖收集(Dep)
  3. 数据更新通知相关订阅者(watcher)执行对应的回调(例如更新虚拟DOM,diff算法比较新旧虚拟DOM,更新真实DOM...)

此文将手写实现Vue2/Vue3响应式原理。但仅到响应式更新这一层,并不会涉及到DOM层面;此外,会先实现Proxy版本,再此基础上稍加变化,实现Vue2的defineProperty版本...

Proxy代理 和 Reflect反射

ES6之后,新增了Proxy类,通过这个类,我们可以代理一整个对象,并通过捕捉器监听到对象的一些操作

都已经有了defineProperty,为什么还要使用Proxy?

  const obj = {
    age: 1,
  }

  const proxyObj = new Proxy(obj, {
    get(target, key) {
      console.log('get执行')
      return target[key]
    },
    set(target, key, newValue) {
      console.log('set执行')
      target[key] = newValue
    },
  })

  console.log(proxyObj.age)
  proxyObj.age = 100

image.png

但此时在get/set中,返回/设置值都是直接通过target[key]实现的,我们既然使用了代理,那么我们便不希望还是在原对象身上进行操作,此时可以使用ES6提供的Reflect反射

image.png

上述代码就可以变成:

  const obj = {
    age: 1,
  }

  const proxyObj = new Proxy(obj, {
    get(target, key) {
      return Reflect.get(target, key)
    },
    set(target, key, newValue) {
      Reflect.set(target, key, newValue)
    },
  })

  console.log(proxyObj.age)
  proxyObj.age = 100

Reflect 主要提供了很多操作 JS 对象的方法,有点像 Object 中操作对象的方法;比如Reflect.getPrototypeOf(target) 类似于 Object.getPrototypeOf()......那么既然 Object 上的方法都已经能实现相关操作了,为什么还需要这个东西?
这是因为在早期的 ECMA 规范中没有考虑到这种对对象本身的操作如何设计会更加规范,所以将这些 API 放到了Object 上面。导致 Object 既是构造函数,又是所有的对象的超类(基类),同时自身也有许多方法,造成 Object 太臃肿,产生了很多弊端。所以在 ES6 新增了 Reflect 对象, 让我们这些操作都集中到了 Reflect 对象上。同时,其方法与 Proxy 的大多数方法对应,在使用 Proxy 的时候使用 Reflect 对象,达到不操作原对象的效果

注意,此处既然使用了Proxy对这个对象进行了代理,那么后续的操作也要在这个代理的对象身上进行

响应式函数的封装:

在前面中,我们通过代理对象,当对象属性被访问/被修改的时候就会触发get/set,从而进行对应逻辑:比如getter的时候就返回对应的值,并进行依赖收集;setter的时候通知对应的watcher执行回调。那么,问题来了,如何知道和收集属性的watcher们?
响应式数据在哪里被使用到?在模板watcher、监听器watcher、计算属性watcher中会被使用,此处我们可以定义一个watchFn来模拟这种操作

let activeFn = null
function watchFn(fn){
    activeFn = fn
    fn()
    activeFn = null
}

watchFn(() => {
    console.log(proxyObj.age + '执行了')
})

watchFn中我们将回调函数存放在activeFn中,是为了后续方便存放到对应的Dep中(后续讲)。而还在watchFn中执行一遍传入的回调,就是为了触发对应属性的get

比如在这个例子中,调用watchFn传入的回调中使用了proxyObj身上的count属性,在watchFn中我们执行了一次fn(),此时proxyObj身上的count的get就会被触发,后续我们就能在get中将其收集到Dep中

Dep类进行依赖收集:

在前面我们知道,依赖需要在被访问(get)的时候进行收集,需要在被修改(set)的时候进行通知。问题是,一个.vue文件中,存在不止一个对象,而对象中又存在不止一个属性,那么怎么精准的确定这个Dep就是收集和obj1的key1相关的watcher,那个Dep就是收集和obj1的key2相关的watcher......

此处需要使用weakMapMap数据结构:

class Depend{
    constructor(){
        // 使用 Set 是为了使用去重,因为相同属性不需要重复监听
        this.watchFns = new Set()
    }
    
    addWatch(){
        activeFn && this.watchFns.add(activeFn)
    }
    
    notify(){
        this.watchFns.forEach(fn => fn())
    }
}

const weakMap = new WeakMap()
function getDep(target, key){
    let targetMap = weakMap.get(target)
    if(!targetMap){
        targetMap = new Map()
        weakMap.set(target, targetMap)
    }
    
    let depend = targetMap.get(key)
    if(!depend){
        depend = new Depend()
        targetMap.set(key, depend)
    }
    return depend
}

形成如下的一种结构:

depend.jpg

Dep进行依赖收集和执行的时机:

结论:在执行过程中,我们访问了某个响应式数据,触发了get,此时就应该进行依赖的收集;我们修改了某个响应式数据的值,触发了set,此时就应该通知订阅者执行对应的回调

const obj = {
    count: 1,
}

const proxyObj = new Proxy(obj, {
get(target, key) {
  const depend = new Depend()
  // 进行依赖的收集(收集watcher)
  depend.addWatch()
  return Reflect.get(target, key)
},
set(target, key, newValue) {
    Reflect.set(target, key, newValue)
    const depend = new Depend()
    // 通知watcher执行对应的回调
    depend.notify()
},
})

console.log(proxyObj.count)
proxyObj.count = 100

完整代码:

  let activeFn = null
  function watchFn(fn) {
    activeFn = fn
    fn()
    activeFn = null
  }

  class Depend {
    constructor() {
      this.watchFns = new Set()
    }

    addWatch() {
      activeFn && this.watchFns.add(activeFn)
    }

    notify() {
      for (const fn of this.watchFns) {
        fn()
      }
    }
  }

  /**
   * weakMap:
   * {
   *  target:Map
   *        {
   *          key:Depend
   *          key:Depend
   *        }
   * }
   * */
  let weakMap = new WeakMap()
  function getDep(target, key) {
    let targetMap = weakMap.get(target)
    if (!targetMap) {
      targetMap = new Map()
      weakMap.set(target, targetMap)
    }

    let depend = targetMap.get(key)
    if (!depend) {
      depend = new Depend()
      targetMap.set(key, depend)
    }
    return depend
  }

  const obj = {
    name: 'zs',
    age: 19,
  }

  const proxyObj = new Proxy(obj, {
    get(target, key) {
      const depend = getDep(target, key)
      depend.addWatch()
      return Reflect.get(target, key)
    },
    set(target, key, newValue) {
      Reflect.set(target, key, newValue)
      const depend = getDep(target, key)
      depend.notify()
    },
  })

  watchFn(() => {
    console.log(proxyObj.age + '执行了')
  })

  console.log('-----------------')

  proxyObj.age = 20

image.png
更新age,则通知对应的watcher执行回调 image.png
更新name,但并没有name的订阅者,所以不会执行回调

reactive的实现:

其实上述代码就是vue3 reactive的核心代码,我们只需要将其简单的封装即可实现:

  let activeFn = null
  function watchFn(fn) {
    activeFn = fn
    fn()
    activeFn = null
  }

  class Depend {
    constructor() {
      this.watchFns = new Set()
    }

    addWatch() {
      activeFn && this.watchFns.add(activeFn)
    }

    notify() {
      for (const fn of this.watchFns) {
        fn()
      }
    }
  }

  /**
   * weakMap:
   * {
   *  target:Map
   *        {
   *          key:Depend
   *          key:Depend
   *        }
   * }
   * */
  let weakMap = new WeakMap()
  function getDep(target, key) {
    let targetMap = weakMap.get(target)
    if (!targetMap) {
      targetMap = new Map()
      weakMap.set(target, targetMap)
    }

    let depend = targetMap.get(key)
    if (!depend) {
      depend = new Depend()
      targetMap.set(key, depend)
    }
    return depend
  }

function reactive(obj){
  const proxyObj = new Proxy(obj, {
    get(target, key) {
      const depend = getDep(target, key)
      depend.addWatch()
      return Reflect.get(target, key)
    },
    set(target, key, newValue) {
      Reflect.set(target, key, newValue)
      const depend = getDep(target, key)
      depend.notify()
    },
  })
  return proxyObj
}

const obj = reactive({
   name: 'zs',
   age: 19,
})

如果是Vue2,就是将Proxy/Reflect换成Object.defineProperty

function reactive(obj) {
Object.keys(obj).forEach((key) => {
  let val = obj[key]
  Object.defineProperty(obj, key, {
    get() {
      const depend = getDep(obj, key)
      depend.addWatch()
      return val
    },
    set(newValue) {
      val = newValue
      const depend = getDep(obj, key)
      depend.notify()
    },
  })
})
return obj
}

存在的问题:

Proxy本身只能代理对象,但无法监听到深层次对象的变化,也就是说,对于一个对象嵌套对象的响应式数据,上述方案无法监听到嵌套对象的属性的变化:

const obj = {
    name: 'zs',
    age: 19,
    self: {
      msg: 'this is a message',
    },
 }
 
const proxyObj = new Proxy(obj, {...})

watchFn(() => {
console.log(proxyObj.self.msg + '执行了')
})

console.log('---以下是触发更新---')

proxyObj.self.msg = 'new msg'

image.png
可以看到,但深层次对象属性发生变化时,就算我们已经收集了watcher,但是也通知不到他执行,本质就是因为Proxy只能代理一层。解决方法:在get中去递归响应式,这样的好处是真正访问到内部对象的时候才会变成响应式,而不是无脑递归,很大程度上提升了性能

const proxyObj = new Proxy(obj, {
  get(target, key) {
    const value = Reflect.get(target, key)
    const depend = getDepend(target, key)
    depend.addDepend()
    // 递归处理响应式,如果需要的话
    if (typeof value === 'object' && value !== null) {
      return reactive(value)
    }
    return value
  },
  set(target, key, newValue) {
    Reflect.set(target, key, newValue)
    const depend = getDepend(target, key)
    depend.notify()
  },
})

ref的实现:

在Vue3中,ref可以用于声明基本数据类型和引用型数据,而reactive只能声明引用型数据。但是你是否想过Vue3的响应式是基于Proxy实现的,但是Proxy只能代理对象(引用型数据),那么ref声明的基本数据类型的响应式是怎么做到的?你是否又想过,为什么ref声明的就必须得通过.value访问(在<script>中)
一切的一切,就是因为:ref本质是基于reactive实现的,ref声明的属性会被包装到一个新对象value属性中,然后再通过reactive对这个新对象进行处理实现响应式

function ref(value){
    const obj = {
        value: value
    }
    return reactive(obj)
}

const count = ref(0)
watchFn(() => {
    console.log('count:', count.value)
})
console.log('---触发更新---')
count.value = 2

image.png


这里其实顺带引出来另外一个问题:当使用ref去声明引用型数据(对象)后,想要使用watch时,就得开启deep深度监听,因为就类似变成了:

const o = ref({
    key1: val1,
    key2: val2
})

const newObj = {
    value: {
        key1: val1,
        key2: val2
    }
}

ref声明的这个对象被放到另一个对象的value属性上,此时就是对象嵌套对象,所以就得深度监听

结语

在整个的实现方案中,我们需要手动调用watchFn响应式函数进行watcher的回调的收集,但是在Vue开发过程中我们似乎并未使用过watcher这么一个函数。正如“前言”所说,此文只到响应式更新这一层面。在Vue中,其实是在render函数中使用的watchFn(),他会在模板编译的知道模板中使用了哪些响应式数据,并在后续响应式数据更新的时候通知watcher,从而实现视图的更新。随后又牵扯出新的一系列问题:

  1. 每次数据更新,都直接更新DOM会造成新能的损耗(尤其是在DOM结构复杂的时候),所以引出了虚拟DOM;
  2. 虚拟DOM又需要h()函数来创建(虚拟DOM就是一个JS对象,包含tag/type、props、children三个属性)
  3. 第一次渲染的时候,所拿到的虚拟DOM就会被作为真实DOM渲染到页面上,所以又需要一个虚拟DOM->真实DOM的mountElement()
  4. 在此后的更新中,还需要通过diff算法比较新旧虚拟DOM的差别,从而更新真实DOM...

欲知后事如何,有空其他文章再讲!!!

若文章内容有误,欢迎指出交流指正