什么是响应式?
响应式,就是一个变量依赖了其他变量,当被依赖的其他变量更新后该变量也要响应式的更新。
实现响应式思路
响应式的思路一般都是这样一个模型
- 定义某个数据为响应式数据,它会拥有收集访问它的函数的能力
- 定义观察函数,在这个函数内部去访问响应式数据,访问到响应式数据的某个key的时候,会建立一个依赖关系key -> reaction观察函数
- 检测到响应式数据的key的值更新的时候,会去重新执行一遍它所收集的所有reaction观察函数
我们看个例子:
// 响应式数据
const state = reactive({
count: 0,
age: 18
})
// 观察函数
const effect = effect(() => {
console.log('effect: ' + state.count)
})
- 用reactive包裹的数据叫做响应式数据
- 在effect内部执行的函数叫观察函数
定义
reactive({ count: 0, age: 18 }),会让{ count: 0, age: 18 }这个普通的对象变成一个proxy,而后续对于这个proxy所有的get、set等操作都会被我们内部拦截下来。
访问
effect函数会先开启一个开始观察的开关,然后去执行
console.log('effect: ’ + state.count),执行到state.count的时候,proxy的get拦截到了对于state.count的访问,这时候就可以知道访问者是const effect = effect(() => { console.log('effect: ’ + state.count) })这个函数,那么就把这个函数作为count这个key值的观察函数收集在一个地方。
修改
下次对于state.count修改时,会去找count这个key下所有的观察函数,轮流执行一遍。
这样就实现了响应式模型。
Vue2和Vue3的区别
我们通过一个例子看下:
Object.defineProperty
<template>
{{ obj.b }}
</template>
<script>
export default {
data: {
obj: { a: 0 },
},
mounted() {
this.obj.b = 5
}
}
</script>
Object.defineProperty必须对于确定的key值进行响应式的定义。这就导致了如果data在初始化的时候没有b属性,那么后续对于b属性的赋值都不会触发Object.defineProperty中对于set的劫持。只能用一个额外的api Vue.set来解决。
Proxy
const raw = {}
const data = new Proxy(raw, {
get(target, key) { },
set(target, key, value) { }
})
从例子中可以看出来:Proxy在定义的时候并不用关心key值,只要定义了get方法,那么后续对于data上任何属性的访问(哪怕是不存在的),都会触发get的劫持,set也是同理。这样Vue3中,对于需要定义响应式的值,初始化时的要求就没那么高了,只要保证它是个可以被Proxy接受的对象或者数组类型即可。
前置知识
Proxy
具体可以参考MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
Reflect
具体可以参考MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect
WeakMap
具体可以参考MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
dep
是dependence的缩写,也就是依赖
effect
指因某种原因导致产生结果,着重持续稳定的影响
track
指追踪、踪迹
trigger
指触发
我们先来看一张经典的Vue3响应式原理图,该图清晰的描述了vue3响应式原理的具体实现过程。
下面将围绕这个图仔细讲解
初始化阶段
初始化做了哪些事情呢?
我们先看来个例子:
首先clone Vue3源码,在 packages/reactivity 模块下调试,在项目根目录运行yarn dev reactivity,然后进入packages/reactivity目录找到产出的 dist/reactivity.global.js 文件,创建index.html文件,内容如下:
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>响应式demo测试</title>
</head>
<body>
<script src="./dist/reactivity.global.js"></script>
<script>
const { reactive, effect } = VueReactivity
const origin = {
count: 0
}
const state = reactive(origin)
const fn = () => {
const count = state.count
console.log(`set count to ${count}`)
}
effect(fn)
</script>
</body>
</html>
通过浏览器打开,在控制台我们输入state.count++,会输出set count to 1,即实现了响应式。
从上述代码中可以总结下初始化做了什么:
- 把origin对象转化成响应式的Proxy对象state
- 把函数fn()作为一个响应式的effect函数
如何把origin对象转化成响应式的Proxy对象state
Vue 3.0使用Proxy来代替之前的Object.defineProperty(),改写了对象的 getter/setter,完成依赖收集和响应触发。reactive() 函数主要实现逻辑如下:
import { handler } from './handlers.js'
export function reactive(target) {
// handler包括get和set
const observed = new Proxy(target, handler)
return observed
}
proxy的handler
handler中主要包括get和set。
get收集依赖
- 依赖收集在get操作的时候要调用。
- 依赖收集中有依赖收集栈
get实现代码如下:
/** 劫持get访问 收集依赖 */
/**
*
* @param {*} target 原始对象
* @param {*} key 当前赋值属性
* @param {*} receiver 可以简单理解为响应式proxy本身
* @returns
*/
function createGetter (target, key, receiver) {
const res = Reflect.get(target, key, receiver) // 求值
track(target, 'get', key)
// 深层数据的劫持
// 即在深层访问的时候,若访问的数据是个对象,就把这个对象也用reactive包装成proxy再返回,这样在最里面属性进行赋值操作的时候,也可以是响应式的
return isObject(res)
? reactive(res)
: res
}
set触发更新
set赋值操作的时候,本质上就是去检查这个key收集到了哪些reaction观察函数,然后依次触发。
set实现代码如下:
/** 劫持set访问 触发收集到的观察函数 */
function createSetter (target, key, value, receiver) {
const hadKey = hasOwn(target, key) // 先检查一下这个key是不是新增的
const oldValue = target[key] // 拿到旧值
const result = Reflect.set(target, key, value, receiver) // 设置新值
if (!hadKey) {
// 新增key值时触发观察函数
trigger(target, 'add', key)
} else if (value !== oldValue) {
// 已存在的key的值发生变化时触发观察函数
trigger(target, 'set', key)
}
return result
}
set核心逻辑总结
- 当响应式数据进行赋值操作时,会收集对应的执行函数作为当前赋值属性的依赖
- 当进行赋值操作后state.count = 1的操作,会触发对于state的set劫持,此时就会从key值的依赖收集里面找到观察函数,再重新执行一遍
把函数fn()作为一个响应式的effect函数
- 普通的函数fn() 被effect()包裹之后,就会变成一个响应式的effect函数,而fn()也会被立即执行一次
- fn() 里面有引用到Proxy对象的属性,所以这一步会触发对象的 getter,从而启动依赖收集
- effect函数也会被压入一个名为"activeReactiveEffectStack"(此处为 effectStack)的栈中,供后续依赖收集的时候使用
effect.js实现如下:
// 接受用户传入的函数,在这个函数内访问响应式数据才会去收集观察函数作为自己的依赖
/**
* 观察函数
* 在传入的函数里去访问响应式的proxy 会收集传入的函数作为依赖
* 下次访问的key发生变化的时候 就会重新运行这个函数
* @param {*} fn
* @returns
*/
export function effect (fn) {
// 构造一个effect
// effect是包装了原始函数之后的观察函数
// 在run的上下文中执行原始函数 可以收集到依赖
const effect = function effect(...args) {
return run(effect, fn, args)
}
// 立即执行一次
effect()
// 返回出去 让外部也可以手动调用
return effect
}
// 收集响应依赖的的函数,在get操作的时候要调用
/** 把函数包裹为观察函数 */
export function run(effect, fn, args) {
if (effectStack.indexOf(effect) === -1) {
try {
// 往栈里放入当前effect
// 把当前的观察函数推入栈内 开始观察响应式proxy
effectStack.push(effect)
// 立即执行一遍fn()
// fn()执行过程会完成依赖收集,会用到effect
// 运行用户传入的函数 这个函数里访问proxy就会收集effect函数作为依赖了
return fn(...args)
} finally {
// 完成依赖收集后从栈中扔掉这个effect
// 运行完了永远要出栈
effectStack.pop()
}
}
}
到这里,整个初始化过程就结束了。
依赖收集阶段
先看下依赖收集阶段做了什么?
触发时机
就是在effect被立即执行,其内部的fn() 触发了Proxy对象的getter的时候。简单说,只要执行到类似state.count时,就会触发state的getter。
依赖收集的目的
建立一份”依赖收集表“,即图中的"targetMap"。targetMap是一个 WeakMap,其key值是当前的Proxy对象state代理前的对象origin,而 value则是该对象所对应的depsMap。
depsMap是一个Map,key值为触发getter时的属性值(这里是 count),而value则是触发过该属性值所对应的各个 effect函数。
通过一段伪代码看下:
const state = reactive({
count: 0,
age: 18
})
const effect1 = effect(() => {
console.log('effect1: ' + state.count)
})
const effect2 = effect(() => {
console.log('effect2: ' + state.age)
})
const effect3 = effect(() => {
console.log('effect3: ' + state.count, state.age)
})
在这段代码中targetMap表示如下:
即,{ target -> key -> dep } 的对应关系就建立起来了,依赖收集也就完成了。
targetMap、depsMap、dep 之间的关系可以通过一张经典的图来看下:
具体代码实现如下:
export const targetMap = new WeakMap()
export const effectStack = [] // 栈结构,依赖收集栈,供后续依赖收集的时候使用
export function track (target, operationType, key) {
/** 从栈的末尾取到正在运行的observe包裹的函数(即观察函数) */
const effect = effectStack[effectStack.length - 1]
if (effect) {
let depsMap = targetMap.get(target)
if (depsMap === void 0) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (dep === void 0) {
// 如果这个key之前没有收集过观察函数 就新建一个, 然后set到整个value的存储里去
depsMap.set(key, (dep = new Set()))
}
if (!dep.has(effect)) {
// 把当前key对应的观察函数收集起来
dep.add(effect)
}
}
}
总结
- 把effect推入effectStack后开始执行用户传入的函数
- 在函数内访问响应式proxy的属性,又会触发get的拦截
- 这时候get去effectStack找当前正在运行的effect,就可以成功的收集到依赖了。
effect观察函数
该函数接受一个用户传入的函数,在这个函数内访问响应式数据才会去收集观察函数作为自己的依赖。
代码实现如下:
// 接受用户传入的函数,在这个函数内访问响应式数据才会去收集观察函数作为自己的依赖
/**
* 观察函数
* 在传入的函数里去访问响应式的proxy 会收集传入的函数作为依赖
* 下次访问的key发生变化的时候 就会重新运行这个函数
* @param {*} fn
* @returns
*/
export function effect (fn) {
// 构造一个effect
// effect是包装了原始函数之后的观察函数
// 在run的上下文中执行原始函数 可以收集到依赖
const effect = function effect(...args) {
return run(effect, fn, args)
}
// 立即执行一次
effect()
// 返回出去 让外部也可以手动调用
return effect
}
核心逻辑在run函数中:
// 收集响应依赖的的函数,在get操作的时候要调用
/** 把函数包裹为观察函数 */
export function run(effect, fn, args) {
if (effectStack.indexOf(effect) === -1) {
try {
// 往栈里放入当前effect
// 把当前的观察函数推入栈内 开始观察响应式proxy
effectStack.push(effect)
// 立即执行一遍fn()
// fn()执行过程会完成依赖收集,会用到effect
// 运行用户传入的函数 这个函数里访问proxy就会收集effect函数作为依赖了
return fn(...args)
} finally {
// 完成依赖收集后从栈中扔掉这个effect
// 运行完了永远要出栈
effectStack.pop()
}
}
}
核心思路总结
- 把effect推入effectStack后开始执行用户传入的函数
- 在函数内访问响应式proxy的属性,又会触发get的拦截
- 这时get去effectStack找当前正在运行的effect,就可以成功的收集到依赖了
通过例子我们看下
const { reactive, effect } = VueReactivity
const origin = {
count: 0
}
const state = reactive(origin)
const fn = () => {
const count = state.count
console.log(`set count to ${count}`)
}
effect(fn)
上述代码中,effect内部对于state的key值count的访问,会收集fn作为count的依赖。state.count++的操作,会触发对于state的set劫持,此时就会从key值的依赖收集里面找到fn,再重新执行一遍。
响应阶段
先通过一张图看下响应阶段做了什么?
说明
- 当修改对象的某个属性值的时候,会触发对应的setter
- setter里面的trigger() 函数会从依赖收集表里找到当前属性对应的各个 dep,然后把它们推入到effects和computedEffects(计算属性)队列中,最后通过scheduleRun()挨个执行里面的effect
代码实现
export function trigger (target, operationType, key) {
// 取得对应的depsMap
const depsMap = targetMap.get(target)
if (depsMap === void 0) {
return
}
// 取得对应的各个 dep
const effects = new Set()
if (key !== void 0) {
const dep = depsMap.get(key)
dep && dep.forEach(effect => {
effects.add(effect)
})
}
// 触发某些由循环触发的观察函数收集
if (operationType === 'add' || operationType === 'set') {
const iterationKey = Array.isArray(target) ? 'length' : Symbol('iterate')
const dep = depsMap.get(iterationKey)
dep && dep.forEach(effect => {
effects.add(effect)
})
}
// 简化版scheduleRun,挨个执行effect, 值更新时触发观察函数
effects.forEach(effect => {
effect()
})
}
总结
- 初始化reactive:new一个Proxy,参数是target和handler,target是原始对象,handler包括set和get
- 依赖收集:get函数中执行track进行依赖收集,建立依赖收集表,即收集观察函数推入到栈中。
- 响应阶段:set函数触发观察函数,遍历栈中的函数,执行effect,effect中执行run函数
参考网址:
https://juejin.cn/post/6938702983014121485#heading-7
https://juejin.cn/post/6844903959660855309#heading-2(精简非常详细)
https://juejin.cn/post/6844904050014552072#heading-14
https://juejin.cn/post/6844904050912133133