Vue2和Vue3的绑定原理

409 阅读7分钟

由于面试中多次被问到 Vue的响应式原理,双向绑定原理。这两个概念还是有一定区别的,但是面试过程中被面试官问到,还是会混为一谈来回答,故写一篇文章来总结一下这个问题,顺便学习一下vue的底层原理。

响应式原理

响应性是一种可以使我们声明式地处理变化的编程范式。举个例子:A=B+C,当C修改了,A也会随C的变化修改。

响应式原理是Vue的核心机制,当我们修改数据时,视图也会随数据变化更改。

Vue2

我们把一个对象传入Vue实例作为data选项,Vue会遍历所有对象的property(属性),并使用Object.defineProperty把这些property全部转化为getter/setter,在内部它们让Vue能够追踪依赖,在property被访问和修改时通知变更。

每个组件实例都对应一个watcher实例,它会在组件渲染过程中把“接触”过的数据property记录为依赖。之后当依赖项的setter触发时,会通知watcher,从而使它关联的组件重新渲染。

Object.defineProperty的劫持机制
// 数据劫持实现示例
function defineReactive(obj, key, val) {
  const dep = new Dep()
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.depend() // 依赖收集
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify() // 触发更新
    }
  })
}
依赖收集系统
class Dep {
  constructor() {
    this.subs = []
  }
  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}
简要代码
// Dep module
class Dep {
  static stack = []
  static target = null
  deps = null
  
  constructor() {
    this.deps = new Set()
  }

  depend() {
    if (Dep.target) {
      this.deps.add(Dep.target)
    }
  }

  notify() {
    this.deps.forEach(w => w.update())
  }

  static pushTarget(t) {
    if (this.target) {
      this.stack.push(this.target)
    }
    this.target = t
  }

  static popTarget() {
    this.target = this.stack.pop()
  }
}

// reactive
function reactive(o) {
  if (o && typeof o === 'object') {
    Object.keys(o).forEach(k => {
      defineReactive(o, k, o[k])
    })
  }
  return o
}

function defineReactive(obj, k, val) {
  let dep = new Dep()
  Object.defineProperty(obj, k, {
    get() {
      dep.depend()
      return val
    },
    set(newVal) {
      val = newVal
      dep.notify()
    }
  })
  if (val && typeof val === 'object') {
    reactive(val)
  }
}

// watcher
class Watcher {
  constructor(effect) {
    this.effect = effect
    this.update()
  }

  update() {
    Dep.pushTarget(this)
    this.value = this.effect()
    Dep.popTarget()
    return this.value
  }
}

// 测试代码
const data = reactive({
  msg: 'aaa'
})

new Watcher(() => {
  console.log('===> effect', data.msg);
})

setTimeout(() => {
  data.msg = 'hello'
}, 1000)

网上刷到一种关于vue响应式原理完美答案的说法:

在生命周期的initState方法中将data,methods,prop,computed,watch中的数据劫持,通过Observe方法将相关对象转换为Observer对象,然后在initRender方法中解析模板,通过Watcher对象,Dep对象与观察者模式将模板中指令与对象的数据建立依赖关系,使用全局对象Dep.target实现依赖效果,当数据变化时,setter被调用,触发Object.defineproperty方法中dep.notify方法,遍历该数据依赖列表,执行器update方法通知Watcher进行视图更新。

虽然我们在回答过程中可以不全部都背下来,但是也可以在描述过程的时候加入源码。

Vue2的监听缺陷

对象

Vue2无法检测property的添加和删除

var vm = new Vue({
   data:{
       obj:{ a:1 } 
   }
}) 
obj.a = 2 //响应式
obj.b=2 // 非响应式

// 解决方式
// 方法 1:使用 Vue.set
Vue.set(vm.obj, 'b', 2); 

// 方法 2:通过组件实例的 $set(推荐)
vm.$set(vm.obj, 'b', 2);

// 方法 3:Object.assign这种方法能触发多个属性修改,
// 这种方法其实是让vm.obj的引用地址改变了,所以可以监听到变化
vm.obj = Object.assign({}, vm.obj, { b: 2 });
数组
  1. Vue2无法检测数组索引
  2. 无法检测数组长度
var vm = new Vue({  
    data: {  
        items: ['a', 'b', 'c']  
    }  
})  
vm.items[1] = 'x' // 不是响应性的  
vm.items.length = 2 // 不是响应性的

// 解决方法
// 第一类问题
Vue.set(vm.items, indexOfItem, newValue)
vm.items.splice(indexOfItem, 1, newValue)
// 第二类问题
vm.items.splice(newLength)

Vue3

副作用(作用):如果一个函数引用了外部的数据,这个函数会受到外部数据改变的影响,我们就说这个函数存在副作用

let A2

function update() {
  A2 = A0 + A1
}

这里的update是作用,A0和A1是作用的依赖,因为他们的值被用来执行这个作用。作用也称为依赖的订阅者

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)//收集依赖
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)//触发更新
    }
  })
}

function ref(value) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(refObject, 'value')
    }
  }
  return refObject
}
// 这会在一个副作用就要运行之前被设置
// 我们会在后面处理它
let activeEffect

function track(target, key) {
  if (activeEffect) {
  //副作用订阅将被存储在一个全局的WeakMap<target, Map<key, Set<effect>>>数据结构中。
  //如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建。
    const effects = getSubscribersForProperty(target, key)
    effects.add(activeEffect)
  }
}

//执行所有该属性的副作用函数
function trigger(target, key) {
  const effects = getSubscribersForProperty(target, key)
  effects.forEach((effect) => effect())
}

whenDepsChange是魔法函数,能在依赖变化时调用副作用

function whenDepsChange(update) {
  const effect = () => {
    activeEffect = effect
    update()
    activeEffect = null
  }
  effect()
}

它将原本的 update 函数包装在了一个副作用函数中。在运行实际的更新之前,这个外部函数会将自己设为当前活跃的副作用。这使得在更新期间的 track() 调用都能定位到这个当前活跃的副作用。

简要代码
let activeEffect;
 
function reactive(target) {
  return new Proxy(target, {
    get(target, key) {
      if (activeEffect) {
        track(target, key);
      }
      return Reflect.get(target, key);
    },
    set(target, key, value) {
      const result = Reflect.set(target, key, value);
      trigger(target, key);
      return result;
    }
  });
}
 
const targetMap = new WeakMap();
 
function track(target, key) {
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }
  dep.add(activeEffect);
}
 
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}
 
function effect(fn) {
  activeEffect = fn;
  fn(); // 执行函数并触发依赖收集
  activeEffect = null;
}

// 测试代码
const obj = reactive({ count: 1, double: 0 })
const updateDouble = () => {
    obj.double = obj.count * 2
    console.log(obj)
}
effect(updateDouble) // 关键步骤!

vue3中使用Reflect操作的原因:

  1. 确保this指向代理对象
  2. 保证原型链上的属性也能被响应式系统追踪
  3. 提供可靠的操作结果,明确的布尔返回值比异常更易处理
  4. Reflect 方法与 Proxy trap(Proxy陷阱是指在代理对象上可以被拦截的操作) 对应,简化代理实现。例如Reflect.get() 是专门为代理机制设计的,它能够正确触发代理的拦截器,并且在拦截器中正确处理属性访问。直接访问属性可能会绕过代理的拦截器,导致响应式系统失效。
  5. 支持元编程,为高级功能提供基础

双向绑定

vue2和vue3双向绑定在功能上的区别

  • vue2对数组和对象重新赋值需要用特殊的方法,vue3可以直接赋值。
  • vue2初始化时递归遍历所有属性,数据变化时全量检查,vue3只有属性被访问时才递归代理深层对象,数据改变时,仅更新实际依赖数据的部分。实现按需劫持和细粒度依赖追踪,缩短初始化时间,减少组件重新渲染范围。
  • vue2一个组件只能绑定一个v-model,vue3可以绑定多个。
<!-- Vue2 只能绑定单个 v-model -->
<input v-model="message" />

<!-- Vue3 的多个 v-model -->
<UserForm v-model:name="name" v-model:age="age" />

回归问题,既然我们知道了双向绑定原理和响应式原理的区别,那么面试官问vue2和vue3的绑定原理的区别时我们究竟如何回答?其实,我觉得如果在面试中被提问到vue的双向绑定,可以从上述的双向绑定回答,但是面试官问到vue2和vue3的区别时,从面试的角度上来说,面试官更想考察的是我们对vue底层的了解,而响应式就是双向绑定的核心机制,所以在面试中回答响应式原理的区别应该更合适,当然,如果感觉不放心,还是可以从两方面入手:功能(数据绑定)和原理(响应式系统)

写这篇文章时其实也查阅了很多相关资料,由于许多文章在一些表述上也会有争议的地方,笔者还是按自己的理解做了取舍,如有不对之处,欢迎大家评论区指正。