前言
俗话说知其人而要知其所以然,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
}