Vue 3.0 响应式原理浅析

458 阅读9分钟

前言

Vue 3.0 的源码已经公布出来 链接,大家感叹学不动了,但是还是要趁热学起来。当然在了解源码之前,还是要熟悉下新的API说明 首先科普下调试方法,先安装好依赖,然后

  1. 修改 rollup.config.js
const externals = Object.keys(aliasOptions).filter(p => p !== '@vue/shared')
    output.sourcemap = true  // 新加这行
  return {
    input: resolve(`src/index.ts`),
  1. tsconfig.json 文件设置 "sourceMap": true
  2. 新建自己的测试页面
<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <meta http-equiv="X-UA-Compatible" content="ie=edge">
 <title>Document</title>
 <script src="../packages/vue/dist/vue.global.js"></script>
</head>
<body>
 <div id="app"></div>
 <script>
  const { reactive, computed, effect, watch, createApp, h } = Vue
  const App = {
   setup() {
    const state = reactive({
     count: 0
    })
    function increment(e) {
     state.count++
    }
    effect(() => {
     console.log('count改变', state.count);
    })
    return (props, slots, attrs, vnode) => (
      h('button', {
        onClick: increment
      }, state.count)
    )
   }
  }
  createApp().mount(App, '#app')
 </script>
</body>
</html>
  1. yarn run dev 就可以愉快的断点调试了

然而,本文并不是要一行一行的分析 Vue 3.0 的源码,而是想要以自己的语言来讲清楚 Vue 3.0 的响应式原理。

Reactive 的实现

let a = reactive({ name: 'hello' })
console.log(b.name) // 'hello'

在 Vue 3.0 中使用 reactive 方法来创建响应式变量,熟悉 2.x 的肯定知道,在 2.x 里面是在 data 里返回一个对象,初始化的时候会遍历这个对象,使用 Object.defineProperty 来重写 get 和 set,对于嵌套的对象,会递归的调用,使值也变成响应式的。但是,使用 Object.defineProperty 存在问题,一个是新增的对象无法直接变为响应式的,需要调用 Vue.set,二个是无法检测数组的变化,vue 劫持了原生数组,来实现对数组的变化检测。但是都不是很好的方法。Vue 3.0 使用了 Proxy 来拦截数据的变化,代码变得简洁了很多。

function reactive(obj) {
  return createReactiveObject(obj)
}
function createReactiveObject(obj) {
  // 不是对象则直接返回
  if (!isObject(obj)) {
    return obj
  }
  const handlers = {
    get(obj, key) {
      console.log('----------get---------')
      console.log(key)
      let result = Reflect.get(obj, key)
      // 如果值为对象则继续调用 reactive 方法使其变为响应式
      return isObject(result) ? reactive(result) : result 
    },
    set(obj, key, value) {
      console.log('----------set----------')
      console.log(key, value)
      let result = Reflect.set(obj, key, value)
      return result
    },
    deleteProperty(obj, key) {
      return Reflect.deleteProperty(obj, key)
    }
  }
  let proxy = new Proxy(obj, handlers)
  return proxy
}
let a = reactive({ name: 'hello' })
console.log(a.name)
a.name = 'world'
----------get---------
name
hello
----------set----------
name world

这里使用 Proxy 实现了一个简单的 reactive 函数,当访问 a.name 时,会触发get,当使用 a.name = 'world' 时触发 set,里面使用了 ES6 的 Reflect.set 和 Reflect.get 来赋值和取值,这个比单纯的使用对象操作符要好些,它与 Proxy的方法是相对应的,并且有返回。

let obj = { name: 'hello' }
let a = reactive(obj)
let b = reactive(obj)
let c = reactive(a)

这样存在几个问题,首先,多次调用 reactive(obj) 时,还是会重复执行,其次,将代理过的对象继续代理。这时就需要对于已经代理过的对象就不进行处理。

const toProxy = new WeakMap()
const toRaw = new WeakMap()
function createReactiveObject(obj) {
  // 不是对象则直接返回
  if (!isObject(obj)) {
    return obj
  }
  let observed = toProxy.get(obj)
  // 说明当前对象已经被代理过
  if (observed) return observed
  // 说明当前就是代理对象
  if (toRaw.has(obj)) return obj
  const handlers = {
    get(obj, key) {
      console.log('----------get---------')
      console.log(key)
      let result = Reflect.get(obj, key)
      return isObject(result) ? reactive(result) : result
    },
    set(obj, key, value) {
      console.log('----------set----------')
      console.log(key, value)
      let result = Reflect.set(obj, key, value)
      return result
    },
    deleteProperty(obj, key) {
      return Reflect.deleteProperty(obj, key)
    }
  }
  let proxy = new Proxy(obj, handlers)
  toProxy.set(obj, proxy)
  toRaw.set(proxy, obj)
  return proxy
}

这里使用了 WeakMap 数据结构,它类似于对象,但是 key 只能为对象,且其值为弱引用,不计入垃圾回收机制。 toProxy.set(obj, proxy); toRaw.set(proxy, obj)在代理成功后,会将对象及代理后的对象存在两个变量中,在执行代理对象之前,会在这两个 weakmap 里查找,是否已经存在,如果 toProxy 里存在,则说明改对象已经被代理过,直接返回对应的值即可,如果 toRaw 里存在,则说明改对象已经是个被代理后的对象,直接返回该对象。

effect的实现

let obj = { name: 'hello' }
let a = reactive(obj)
effect(() => {
  console.log(a.name)
})
a.name = 'world'
// hello
// world

effect 意思是副作用,初始会执行一次,然后当 name值变化时,会执行一次,这就是响应式原理的另一块,依赖搜集和依赖执行。在 vue 2.0 中,已 computed 举例,首先会执行一下 getter 方法,此时会访问响应式变量的值,触发 get,在 get 里搜集依赖,然后当变量改变时,会触发 set,在 set 里执行依赖,从而使值可以随着另一个值的变化而变化。其实在 3.0 里也类似。

const toProxy = new WeakMap()
const toRaw = new WeakMap()
const targetMap = new WeakMap()
function createReactiveObject(obj) {
  // 不是对象则直接返回
  if (!isObject(obj)) {
    return obj
  }
  let observed = toProxy.get(obj)
  // 说明当前对象已经被代理过
  if (observed) return observed
  // 说明当前就是代理对象
  if (toRaw.has(obj)) return obj
  const handlers = {
    get(obj, key) {
      console.log('----------get---------')
      console.log(key)
      let result = Reflect.get(obj, key)
      track(obj, key) // 搜集依赖
      return isObject(result) ? reactive(result) : result
    },
    set(obj, key, value) {
      console.log('----------set----------')
      console.log(key, value)
      let result = Reflect.set(obj, key, value)
      trigger(obj, key) // 执行依赖
      return result
    },
    deleteProperty(obj, key) {
      return Reflect.deleteProperty(obj, key)
    }
  }
  let proxy = new Proxy(obj, handlers)
  toProxy.set(obj, proxy)
  toRaw.set(proxy, obj)
  return proxy
}
let activeEffectStacks = []
function track(obj, key) {
  let effect = activeEffectStacks[activeEffectStacks.length - 1]
  if (effect) {
    let depsMap = targetMap.get(obj)
    // 如果不存在,则为该对象新建一个 map
    if (!depsMap) {
      targetMap.set(obj, depsMap = new Map())
    }
    let dep = depsMap.get(key)
    // 如果该属性不存在则为该属性新建一个 set
    if (!dep) {
      depsMap.set(key, dep = new Set())
    }
    if (!dep.has(effect)) {
      dep.add(effect)
    }
  }
}
function trigger(obj, type, key) {
  let depsMap = targetMap.get(obj)
  if (!depsMap) return
  let deps = depsMap.get(key)
  if (deps) {
    deps.forEach(effect => {
       effect()
    })
  }
}
function effect(fn, options) {
  let effect = createReactiveEffect(fn, options)
  effect()
  return effect
}
function createReactiveEffect(fn, options) {
  let effect = function() {
    return run(effect, fn)
  }
  return effect
}
function run(effect, fn) {
  try {
    activeEffectStacks.push(effect)
    fn()
  } finally {
    activeEffectStacks.pop()
  }
}

effect函数包装了一下fn,因为之后 effect 上还有一些其他的参数需要定义,所以使用createReactiveEffect来包装一下,第66行进行初次执行,这里使用activeEffectStacks数组来保存 effect 自身,执行函数体后,会访问 a.name,此时就触发了依赖搜集(17 行),这里使用了 targetMap 来保存对象以及其属性的依赖,结构类似于

{
    obj1: {
    a: [fn1, fn2],
    b: [fn2, fn4]
  },
  obj2: {
    c: [fn5]
  }
}

这里分别使用了 WeakMap、Map、Set,如 39~51 行,当函数执行完毕后会将其从 activeEffectStacks 弹出,这样就可以保证依赖正确。其实这里和 vue 2.0 里类似,Vue 2.0 使用了 Dep.target 来保存这个依赖,搜集完毕后会将其赋为 null,trigger 函数就比较好理解了,触发 set 时从 targetMap 取出对象和属性对应的 set 集合,执行里面的每一个函数。 这里存在着一个问题,对于数组来说,set 会触发两次,一次是新增 index = 3 的值,一次是将 length 设为 4,这样就会导致依赖被执行两次。

let c = reactive([1, 2, 3])
c.push(4)
----------get---------
push
----------get---------
length
----------set----------
3 4
----------set----------
length 4

这里稍作修改,将这两种分为两类,一种是新增(第一个set),一种是修改(第二个set)。如何区分这两者呢?其实只需要判断该对象上是否存在该值即可,修改 set 函数如下

set(obj, key, value) {
  console.log('----------set----------')
  console.log(key, value)
  let oldValue = obj[key]
  let hasKey = obj.hasOwnProperty(obj, key)
  let result = Reflect.set(obj, key, value)
  if (!hasKey) {
    trigger(obj, 'add', key)
  } else if (oldValue !== value) {
    trigger(obj, 'set', key)
  }
  return result
}
function trigger(obj, type, key) {
  let depsMap = targetMap.get(obj)
  if (!depsMap) return
  let deps = depsMap.get(key)
  if (deps) {
    deps.forEach(effect => {
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        effect()
      }
    })
  }
  if (type === 'add') {
    let deps = depsMap.get('length')
    if (deps) {
      deps.forEach(effect => effect())
    }
  }
}

修改 trigger 函数,如果 type 是新增,则上半部分的key肯定早不到对应的 deps,下半部分取 length,执行依赖。

ref的实现

ref 可以将原始类型转换为响应式数据

let r = ref(1)
console.log(r.value)
effect(()=> {
  console.log(r.value)
})
r.value = 2
1
1
2

原理也很简单,使用一个对象包装一层,访问对象的 value 值即为真实的值

function ref(raw) {
  raw = isObject(raw) ? reactive(raw) : raw
  const v = {
    _isRef: true, // 标识为 ref 类型
    get value() {
      track(v, 'get', '')
      return raw
    },
    set value(newValue) {
      raw = newValue
      trigger(v, 'set', '')
    }
  }
  return v
}

这时存在一个问题, 假如对象为

let r = ref(1)
let c = reactive({ b: r })

此时每次访问 r 还得使用 c.b.value 这时只需要改写 get,在返回值的时候判断如果是 ref 类型,直接返回其 value值

if (result._isRef) {
  return result.value
}

computed的实现

在 Vue 2.0 里,computed 简直不要太好用,在 3.0 里 用法稍许不同

// computed 是懒执行,不改变值不会执行
 let a = reactive({ foo: 0 })
 let c = computed(()=>{
  console.log('执行')
  return a.foo + 1
})
// 不取不执行,取n次只执行一次
console.log(c.value)
console.log(c.value)

computed是惰性的,也就是说只有在取 c.value 时,函数体才会执行,如果其依赖的 a.foo 的值不变,则不管访问多少次 c.value,computed内部的函数都只会执行一次,computed使用上面的 effect函数来实现

function computed(getter) {
  let dirty = true
  const runner = effect(getter, {
    lazy: true,
    scheduler: () => {
      dirty = true
    }
  })
  let value
  return {
    _isRef: true,
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
      }
      return value
    }
  }
}

修改 effect 方法 和 trigger 方法

function effect(fn, options) {
  let effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}
function createReactiveEffect(fn, options) {
  let effect = function() {
    return run(effect, fn)
  }
  effect.scheduler = options.scheduler
  return effect
}
deps.forEach(effect => {
  if (effect.scheduler) {
    effect.scheduler()
  } else {
    effect()
  }
})

这里使用了 effect 来包装 getter,使其变成响应式的,和 ref 类似,返回了一个包装对象,当读取 value 的值的时候,才会执行,计算最新的值。由于 computed 是惰性求值,所以在使用 effect 包装的时候,加了个参数 lazy: true 此时在 effect 函数里如果 lazy 为 true 则并不会执行。再一点,如果依赖的值不发生变化,则computed 不会重新求值。源码设计的非常巧妙,使用了一个标识 dirty,初始 dirty 为 true,则第一次读取 computed 值的时候会计算,并将 dirty 设为 false,在使用 effect 包装的时候,会在 effect 上添加 scheduler 方法,第一次取值的时候,会搜集对应变量的依赖,即该 effect,只有当这个变量发生改变的时候,才会触发 trigger 执行依赖,此时如果有scheduler 则会执行 scheduler 将 dirty 设为 true,这样下次读取 computed 的值的时候才会重新执行getter,获取最新的值。否则 dirty 会一直为 false,返回的值仍然为之前计算的值。

以上就是 vue 3.0 的响应式原理解析,把主要原理部分抽取出来了,便于理清原理。实际的源码还有很多细节的地方处理,这部分代码主要在 packages/reactvity 里,各个函数分的很清晰,推荐自己按照上面的方法自己写些demo,调试一下,里面的单元测试也可以对照着看,以便更好的了解用法。

完整代码