一篇通俗易懂的Vue响应式原理解析

1,172 阅读8分钟

前言

俗话说知其人而要知其所以然,Vue3 已经出来很长一段时间,在熟练使用 Vue 框架搬砖的同时,我们必然要去了解其底层的实现原理,其中响应式是 Vue 的核心概念之一,今天总结一下 Vue 响应式的实现原理。

什么是响应式?

响应式是一种面向数据串流和变化传播的声明式编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。通俗点来说数据改变视图也跟着变化

可能平时大家或多或少知道 Vue 响应式实现原理是什么:

  • Vue2的响应式是基于Object.defineProperty实现的
  • Vue3的响应式是基于ES6的Proxy来实现的 正是由于实现响应式 api 的不同,导致vue3 和 vue2 上面有一些根本的差异,两个版本的好坏也体现在这上面。

Vue2

Vue2的响应式是基于Object.defineProperty的,下面是Object.defineProperty的简单示例:

const createReactive = (target, prop, value) => { 
  return Object.defineProperty(target, prop, {
    get() {
      console.log(`访问了${prop}属性`)
      return value
    },
    set(newValue) {
      console.log(`将${prop}由->${value}->设置成->${newValue}`)
      value = newValue
    }
  })
}

const data = createReactive({}, 'name', 'lisi')

console.log('data.name :>> ', data.name);
// 访问了name属性
// data.name :>>  lisi

data.name = 'zhangsan'
// 访问了name属性
// data.name :>>  lisi
// 将name由->lisi->设置成->zhangsan

上面的示例是Object.defineProperty的简单应用,而Object.defineProperty也有一些弊端使得尤大大在Vue3中抛弃了它,接着看下面示例:

// 接上面的代码

data.age = 18
console.log('data.age :>> ', data.age); // data.age :>>  18
data.age = 28
console.log('data.age :>> ', data.age); // data.age :>>  28

可以从上面的例子看出Object.defineProperty对于data新增age属性,进行访问和设值,都不会触发get和set,所以弊端就是:Object.defineProperty只对初始对象里的属性有监听作用,而对新增的属性无效。这也是为什么Vue2中对象新增属性的修改需要使用Vue.$set来设值的原因。

Vue3

ES6 的Proxy 方法天然支持代理对象新增属性,进行访问和设值,都会触发get/set方法。所以它很好的弥补了 Vue2 使用Object.defineProperty实现响应式的弊端,请看下面是示例:

const createReactive = (target) => { 
    return new Proxy(target, {
      get(target, prop) {
        console.log(`访问了${prop}属性`)
        return Reflect.get(target, prop)
      },
      set(target, prop, value) {
        console.log(`将${prop}由->${target[prop]}->设置成->${value}`)
        Reflect.set(target, prop, value)
      }
    })
}

const data = createReactive({
  name: 'lisi',
})

console.log('data.name :>> ', data.name);
// 访问了name属性
// data.name :>>  lisi

data.name = 'zhangsan' // 将name由->lisi->设置成->zhangsan

通过上面代码可以看出 Proxy 实现的效果跟Object.defineProperty是一样的,唯一的不同是对象新增属性上Proxy能触发触发get/set方法。

// 接上面代码

data.age = 18 // 将age由->undefined->设置成->18
console.log('data.age :>> ', data.age); 
// 访问了age属性
// data.age :>>  18
data.age = 28 // 将age由->18->设置成->28
console.log('data.age :>> ', data.age);
// 访问了age属性
// data.age :>>  28

Object.defineProperty虽然不能原生对新增属性进行响应式的监听,但是也提供了相应的Vue.$set方法的解决。而Proxy虽然方便强大,但是它最大的问题是对IE11的不兼容,如果项目还要考虑IE浏览器的话,只能使用 Vue2 版本。你可以使用caniuse查看兼容情况。

从一个简单函数说起

先看下面的代码:

let x;
let y;
let f = n => n * 100 + 100

x = 1
y = f(x)
console.log(y) // 200

x = 2
y = f(x)
console.log(y) // 300

x = 3
y = f(x)
console.log(y) // 400

上面代码中,要让y随着x变化,简单点我们只需要将代码重新执行一遍就行。

watchEffect

上面说了,每一次x改变就得再执行一次重复的代码,才能使y更新,其实这么写不优雅,咱们可以封装一个watchEffect 函数。

let x;
let y;
let f = n => n * 100 + 100

let watchEffect = () => {
  y = f(x)
  console.log(y)
}

x = 1 // 200

x = 2 // 300

x = 3 // 400

下面将先使用 vue2 Object.defineProperty实现,为了每次让x改变都会重新执行一遍watchEffect函数。而Object.defineProperty函数是对对象的操作,所以需要先实现一个ref函数将x变量从基础类型变成引用数据类型。

实现ref

说到上面,我们需要先定一个ref函数。

const ref = initValue => {
  let value = initValue
  return Object.defineProperty({}, 'value', {
    get() {
      return value
    },
    set(newValue) {
      value = newValue
      activeEffect()
    }
  })
}

上面代码中我们可以封装一个createReactive函数,可以为其他api复用。

const createReactive = (target, prop, value) => { 
  return Object.defineProperty(target, prop, {
    get() {
      return value
    },
    set(newValue) {
      value = newValue
    }
  })
}

const ref = (initValue) => createReactive({}, 'value', initValue)

为了实现y随着x的变化,我们还需一个全局activeEffect变量保存当前的watchEffect函数。

let x;
let y;
let f = n => n * 100 + 100
let activeEffect;
let watchEffect = cb => {
  activeEffect = cb
  activeEffect()
}
const createReactive = (target, prop, value) => { 
  return Object.defineProperty(target, prop, {
    get() {
      return value
    },
    set(newValue) {
      value = newValue
      activeEffect()
    }
  })
}

const ref = (initValue) => createReactive({}, 'value', initValue)

x = ref(1) // 200

watchEffect(() => {
  y = f(x.value)
  console.log(y)
})

x.value = 2 // 300

x.value = 3 // 400

上面实现针对单个响应式变量同时也实现了简易watchEffect函数。在项目里往往会有很多响应式变量,且存在多个watchEffect函数,这时一个activeEffect变量就不够用,这时就需要依赖收集类。

Dep

上面说了,面对多个watchEffect函数,需要一个类对它进行管理。Dep定义如下:

// 接上面代码
class Dep {
  deps = new Set()
  depend() {
    if (activeEffect) {
      this.deps.add(activeEffect)
    }
  }
  notify() {
    this.deps.forEach(dep => dep())
  }
}

上面dep使用Set进行收集最要因为Set可以自动去重。依赖的添加发生属性被访问的时,而依赖收集函数的执行发生在属性值改变的时候。代码如下:

// 接上面代码
let watchEffect = cb => {
  activeEffect = cb
  activeEffect()
  activeEffect = null
}

const createReactive = (target, prop, value) => {
  let dep = new Dep()
  return Object.defineProperty(target, prop, {
    get() {
      dep.depend()
      return value
    },
    set(newValue) {
      value = newValue
      dep.notify()
    }
  })
}

上面代码实现了多个变量的依赖收集和依赖通知,但是上面还存在一个缺陷,细心的童鞋应该不难发生,在对x变量的连续的赋值操作,watchEffect函数每次都会执行。如果有100次赋值操作,则会执行100次函数运算。所以这边需要异步队列进行代码的优化。

实现nextTick

上面说到,我们需要一个队列来对我们的代码进行优化。代码如下:

// 接上面代码

let nextTick = (cb) => Promise.resolve().then(cb)

let queue = []
let queueJob = job => {
  if (!queue.includes(job)) {
    queue.push(job)
    nextTick(flushJobs)
  }
}
let flushJobs = () => {
  let job;
  while((job = queue.shift()) !== undefined) {
    job()
  }
}

class Dep {
  deps = new Set()
  depend() {
    if (activeEffect) {
      this.deps.add(activeEffect)
    }
  }
  notify() {
    this.deps.forEach(dep => queueJob(dep))
  }
}
...
x.value = 2
x.value = 3 // 400

Vue.nextTick的作用是将回调推迟到下一个 DOM 更新周期之后执行。在更改了一些数据以等待 DOM 更新后立即使用它。上面通过Promise构造的简易的异步队列,从而可以实现性能优化。如果不是很了解异步机制的话,可以查看Js事件循环(Event Loop)机制

实现reactive

根据上面的代码,我们简单的实现reactive函数,代码如下:

const reactive = (obj) => {
  Object.keys(obj).forEach(key => createReactive(obj, key, obj[key]))
  return obj
}

来看一下结果:

...

const data = reactive({
  count: 0
})

watchEffect(() => {
  console.log(data.count)
})

data.count = 2 // 2

实现computed

我们先实现一个简易的 computed 函数:

let computed = (fn) => {
  let value;
  return {
    get value() {
      value = fn()
      return value
    }
  }
}

简单看一下结果:

...
let computed = (fn) => {
  let value;
  return {
    get value() {
      value = fn()
      return value
    }
  }
}
let count = ref(0)
watchEffect(() => {
  console.log(computedValue.value)
})
const computedValue = computed(() => count.value + 3)
count.value++ // 4

上面代码已经基本实现了computed函数的功能,但是大家都知道computed函数具有缓存的特点,要实现缓存特性我们可以定义一个dirty变量来控制。而dirty变量重置回原来的值的时机:在上面 fn 函数中响应变量发生变化的时候。而响应式变量改变会触发watchEffect函数,所以我们需要对watchEffect函数进一步封装和处理,这边我们可以将watchEffect函数的内容提取出来定义一个effect函数,这边为什么要单独对effect封装呢?主要是不想在原先的fn添加options属性,污染了原函数。

let effect  = (fn, options = {}) => {
  let effect = (...args) => {
    try {
      activeEffect = effect
      return fn(...args)
    } finally {
      activeEffect = null
    }
  }
  effect.options = options
  return effect
}

let watchEffect = (cb) => {
    let runner = effect(cb)
    runner()
}

在Vue3 源码里面定义了一个Schedular的钩子函数,作为依赖响应式发生以后触发的方法,通知依赖notify方法触发时候执行Schedular的钩子函数,则我们可以在依赖改变时候将dirty变量置成true

let computed = (fn) => {
  let value;
  let dirty = true
  let runner = effect(fn, {
    schedular: () => {
      if (!dirty) {
        dirty = true
      }
    }
  })
  return {
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
      }

      return value
    }
  }
}

class Dep {
  deps = new Set()
  depend() {
    if (activeEffect) {
      this.deps.add(activeEffect)
    }
  }
  notify() {
    this.deps.forEach(dep => queueJob(dep))
    this.deps.forEach(dep => {
     dep.options && dep.options.schedular && dep.options.schedular()
    })
  }
}

完整的computed就实现了。

实现watch

watch函数需要接收三个参数,监听对象,回调函数,可选参数配置,简单代码实现如下:

let watch = (source, cb, options = {}) => {
  let getter = () => {
    return source() 
  }
  const { immediate } = options
  let oldValue;
  let runner = effect(getter, {
    schedular: () => applyCb()
  })

  const applyCb = () => {
    let newValue = runner()
    if (newValue !== oldValue) {
      cb(newValue, oldValue)
      oldValue = newValue
    }
  }
  if (immediate) {
    applyCb()
  } else {
    oldValue = runner()
  }
}

源码中对source是箭值函数和数组进行判断,这边只对箭头函数进行处理。可选参数这边只实现了immediate。来看一下简单的结果:

let watch = (source, cb, options = {}) => {
  let getter = () => {
    return source() 
  }
  const { immediate } = options
  let oldValue;
  let runner = effect(getter, {
    schedular: () => applyCb()
  })

  const applyCb = () => {
    let newValue = runner()
    if (newValue !== oldValue) {
      cb(newValue, oldValue)
      oldValue = newValue
    }
  }
  if (immediate) {
    applyCb()
  } else {
    oldValue = runner()
  }
}
let x = ref(0)
watch(() => x.value, (newValue, oldValue) => {
  console.log('newValue, oldValue :>> ', newValue, oldValue);
}, {immediate: true})

实现watchEffect

watchEffect函数会返回一个清除函数,现在我们简单实现一下。首先我们需要在watchEffect函数中定义clearUpEffect清楚函数。代码如下:

let watchEffect = cb => {
  let runner = effect(cb)
  runner()
  return () => {
    clearUpEffect(runner)
  }
}

let clearUpEffect = (effect) => {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
  }
}

上面代码我们需要在effect函数中获取到依赖,所以我们需要在依赖收集的时候,给effect添加依赖对象。

let effect  = (fn, options = {}) => {
  let effect = (...args) => {
    try {
      activeEffect = effect
      return fn(...args)
    } finally {
      activeEffect = null
    }
  }
  effect.options = options
  effect.deps = []
  return effect
}

class Dep {
  deps = new Set()
  depend() {
    if (activeEffect) {
      this.deps.add(activeEffect)
      activeEffect.deps.push(this.deps)
    }
  }
  notify() {
    this.deps.forEach(dep => queueJob(dep))
    this.deps.forEach(dep => {
     dep.options && dep.options.schedular && dep.options.schedular()
    })
  }
}

上面当依赖收集effect函数的同时,依赖收集的时候也将依赖对象添加到effetc定义deps属性中。

实现数组方法

Object.defineProperty 中对数组操作的方法不会触发响应式,vue2 则重写数组的方法使它们可以触发视图变化。实现代码如下:

let push = Array.prototype.push

Array.prototype.push = function(...args) {
  push.apply(this, [...args])
  this._dep && this._dep.notify()
}

const createReactive = (target, prop, value) => {
  target._dep = new Dep()
  return Object.defineProperty(target, prop, {
    get() {
      target._dep.depend()
      return value
    },
    set(newValue) {
      value = newValue
      target._dep.notify()
    }
  })
}

上面的代码只实现了push方法,比较简单的将依赖挂载在对象上面,在对象调用数组方法的时候,通知依赖更新。

实现set

set函数实现比较简单,直接看代码:

const set = (target, prop, initValue) => createReactive(target, prop, initValue)

Vue3

上面我们都是通过vue2 中Object.defineProperty实现的,Vue3 则是通过Proxy实现的,我们只需要将createReactive函数的Object.defineProperty替换成Proxy就行,其他的基本不需要改变。

const createReactive = (target) => {
  let deps = new Dep()
   return new Proxy(target, {
    get(target, prop) {
      deps.depend()
      return Reflect.get(target, prop)
    },
    set(target, prop, value) {
      Reflect.set(target, prop, value)
      deps.notify()
    }
  } )
}

const ref = initValue => createReactive({value: initValue})

const reactive = (obj) => {
  Object.keys(obj).forEach(key => createReactive(obj))
  return obj
}

demo源码

参考文章