Vue3响应式系统实现原理
一、响应式数据与副作用函数
副作用函数:指的是会产生副作用的函数 例如:
let name = ''
function effect() {
name = 'hello Vue3'
}
effect()
当 effect 函数执行时,它会改变全局变量name的值,但除了 effect 函数之外的任何函数都可以读取或设置 name 的值。也就是说,effect函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用,所以effect就是一个副作用函数。
响应式数据: 假设在一个副作用函数中读取了某个对象的属性:
const obj = { name: 'name 初始值' }
function effect() {
document.body.innerText = obj.name // 读取obj.name
}
effect()
//修改 obj.name
obj.name = 'hello Vue3' // 此时我们修改了 obj.name的值 我们希望副作用函数effect能够重新执行
如上面的代码所示,副作用函数 effect 会设置 body 元素的 innerText属性,其值为obj.name的值,obj.name的值发生变化时,我们希望副作用函数 effect 会重新执行 如果能够实现当obj的值改变了, 副作用函数effect能够自动重新执行, 那么obj就是一个响应式数据
那应该怎么让一个数据变成响应式数据呢?
仔细观察上面的代码,我们会发现两点核心操作
- effect副作用函数执行时, 会读取obj.name的值并赋值给body.innerText , 也就是会触发obj.name的读取操作
- 当修改obj.name的值是 , 会触发obj.named的 设置操作
这时候我们只要能劫持obj对象的读取和设置操作,是不是就能够实现让obj变成响应式obj了呢 推而广之,只要我们能劫持数据的读取和设置操作,就能实现让数据变成响应式数据
那如何劫持数据呢? 在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是Vue2所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue3所采用的方式。 我们现在研究是Vue3,因此我们就用Proxy 来实现
二、响应式系统实现
首先需要提供一个注册副作用函数的函数
// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
/**
* effect用来注册副作用的函数
* @param fn 副作用函数
*/
function effect(fn) {
// 把注册的副作用函数赋值给activeEffect
activeEffect = fn
fn() // 执行副作用函数
}
然后需要劫持数据的获取和设置操作 , 把数据变成响应式数据
/**
* 把数据变成响应式数据的函数
* @param data 数据
*/
const reactive = (data) =>{
return new Proxy(data ,{
//劫持数据的获取操作
get(target , key){
// TODO 做点什么
}
//劫持数据的设置操作
set(target , key , newVal){
// TODO 做点什么
}
})
}
现在我们已经有了 副作用函数 和 响应式数据了, 下面就需要把副作用函数和响应式数据关联起来, 实现数据变化了 副作用函数自动重新执行
那么怎样实现数据和副作用函数的关联呢?
首先我们思考数据和副作用函数的关系
/**
* 定义一个数据, 它有name、age、 sex三个属性
*/
const data = {
name: '张三',
age: 18,
sex: '男'
}
//注册一个 effect1 副作用函数 读取data的name和age属性
effect(function effect1(){
document.body.innerText = `我叫${data.name},我今年${data.age}岁了`
})
//注册一个 effect2 副作用函数 读取data的name和sex属性
effect(function effect2(){
document.body.innerText = `我叫${data.name},我是一个${data.sex}孩子`
})
观察上面的代码, 我们可以发现 数据和副作用函数存在如下关系
/**
* data的name 属性关联了 effect1 和 effect2
* data的age 属性关联了 effect1
* data的sex 属性关联了 effect2
*
*/
data
|- name
|- effect1
|- effect2
|- age
|- effect1
|- sex
|- effect2
那怎么用代码来表达这个关系呢? 聪明的你肯定想到了, 想不到也没关系,下面直接告诉你Vue3怎么表达的 哈哈
//首先定义一个 weakMap 用来存储 对象和 key的关系
const reactiveMap = new WeakMap()
/**
* reactive是基础的响应式api函数 把数据变成响应式数据的函数
* @param data 数据
* return proxy 代理对象
*/
const reactive = (data) =>{
// 因为Proxy 只能劫持对象 所以不是对象 直接返回
if(!(typeof data === 'object' && typeof data !== null)) {
new Error('reactive只能代理对象')
}
const proxy = new Proxy(data ,{
//劫持数据的获取操作
get(target , key , receiver){
const result = Reflect.get(target, key, receiver)
//如果没有副作用函数关联 直接返回数据
if(!activeEffect) return result
// 从reactiveMap中取出 存储的target 它是一个Map类型 里面存储 key 和 effect 的关联
let depsMap = reactiveMap.get(target)
// 如果不存在depsMap 那就新建一个 Map 和target 关联
if(!depsMap) {
reactiveMap.set(target, (depsMap = new Map()))
}
//再根据 key 从depsMap 中取出 key关联的 effect 集合 它是一个Set类型 key ---> Set
let deps = depsMap.get(key)
//如果 deps存在 就新建一个Set 和key 关联 里面存储 effect
if(!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将注册的 副作用函数存入 Set
deps.add(activeEffect)
// 返回属性值
return result
},
//劫持数据的设置操作
set(target , key , newVal, receiver){
const result = Reflect.set(target, key, newVal, receiver)
//通过target 取出 key 组成的 Map
let depsMap = reactiveMap.get(target)
if(!depsMap) return result
// 通过key 取出key 关联的 存储effect函数 的Set集合
let deps = depsMap.get(key)
//取出所有的key 关联的 effect执行
deps && deps.forEach(fn => fn())
return result
}
})
return proxy
}
通过上面的代码, 我们可以发现Vue3是通过
WeakMap 存储 target(数据对象) ---> Map 建立数据和属性的依赖关系
Map 存储 key(数据对象属性) ---> Set 建立属性和effect函数的依赖关系
记住并理解这个数据结构, 这是 Vue3实现响应式的核心
通过这个过程就收集了 数据 ---> 属性 ---> effect函数 之间的依赖关系
有了这个依赖关系 就可以在修改数据的时候 重新执行数据关联的 effect函数
我们测试一下
const data = {
name: '张三',
age: 18,
sex: '男'
}
// 把data 变成响应式数据
const obj = reactive(data)
effect(()=>{
document.body.innerText = `我叫${obj.name},我是一个${obj.sex}孩,我今年${obj.age}岁了`
})
//页面会展示 我叫张三,我是一个男孩,我今年18岁了
//修改name
obj.name = '李四'
//页面会展示 我叫李四,我是一个男孩,我今年18岁了
//修改age
obj.age = 50
//页面会展示 我叫李四,我是一个男孩,我今年50岁了
//修改age
obj.sex = '女'
//页面会展示 我叫李四,我是一个女孩,我今年50岁了
这样就实现了 修改数据 视图会自动更新 到这里,我们完成了基本的响应式 ,但是还不完善 , 比如 分支切换和 effect嵌套 还有问题
三、分支切换
如果代码里面有分支切换, 开始依赖了obj.name 分支切换之后又不依赖obj.name了, 这时还是会收集 obj.name的依赖 比如下面这段代码
const obj = reactive({
name: 'Vue3',
age: 2,
isShowName: true
})
effect(()=>{
document.body.innerText = obj.isShowName ? `大家好,我是${obj.name}` : `我今年${obj.age}岁了`
})
obj.isShowName = false
obj.age = 3
第一次执行effect的时候 obj.isShowName 为true 这时候会走第一个分支 会把effect收集为obj.name依赖 然后把 obj.isShowName修改为false 这时候会走第二个分支 这时候没有依赖obj.name了 按理说不应该把effect收集obj.name 的依赖
我们执行一下代码 看看结果
我们会发现, effect还是被obj.name收集了 , 这样就出现了不必要的收集 解决方案就是每次添加依赖前清空下上次的 依赖
我们需要记录effect 依赖被谁依赖了
我们改造下现有的 effect 函数:
// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
/**
* effect用来注册副作用的函数
* @param fn 副作用函数
*/
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn
fn() // 执行副作用函数
}
// 记录下这个 effect 函数被放到了哪些 deps 集合里
effectFn.deps = []
effectFn()
}
对之前的 fn 用effectFn包一层,在effectFn函数上添加个 deps 数组来记录被添加到哪些依赖集合里
get 收集依赖的时候,也记录一份到这里:
//其他代码省略 只看get操作
get(target , key , receiver){
const result = Reflect.get(target, key, receiver)
//如果没有副作用函数关联 直接返回数据
if(!activeEffect) return result
// 从reactiveMap中取出 存储的target 它是一个Map类型 里面存储 key 和 effect 的关联
let depsMap = reactiveMap.get(target)
// 如果不存在depsMap 那就新建一个 Map 和target 关联
if(!depsMap) {
reactiveMap.set(target, (depsMap = new Map()))
}
//再根据 key 从depsMap 中取出 key关联的 effect 集合 它是一个Set类型 key ---> Set
let deps = depsMap.get(key)
//如果 deps存在 就新建一个Set 和key 关联 里面存储 effect
if(!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将注册的 副作用函数存入 Set
deps.add(activeEffect)
// 记录副作用函数被哪些 deps集合 依赖了
activeEffect.deps.push(deps)
// 返回属性值
return result
},
这样下次再执行这个 effect 函数的时候,就可以把这个 effect 函数从上次添加到的依赖集合里删掉
// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
/**
* effect用来注册副作用的函数
* @param fn 副作用函数
*/
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
fn() // 执行副作用函数
}
// 记录下这个 effect 函数被放到了哪些 deps 集合里
effectFn.deps = []
effectFn()
}
function cleanup(effectFn){
for(let i = 0, len = effectFn.deps.length; i < len; i++){
// 从依赖effectFn 的deps集合中删掉自己
effectFn.deps[i].delete(effectFn)
}
effectFn.deps.length = 0
}
最后在set的时候 新创建一个Set 集合来执行依赖 避免无限循环
//劫持数据的设置操作
set(target , key , newVal, receiver){
const result = Reflect.set(target, key, newVal, receiver)
//通过target 取出 key 组成的 Map
let depsMap = reactiveMap.get(target)
if(!depsMap) return result
// 通过key 取出key 关联的 存储effect函数 的Set集合
let deps = depsMap.get(key)
//取出所有的key 关联的 effect执行
// deps && deps.forEach(fn => fn()) //注释掉
//新增这个逻辑 避免无限循环
const effectRun = new Set(deps)
effectRun && effectRun.forEach(fn => fn())
return result
}
现在我们看见依赖已经被清除了
四、effect 嵌套
effect 嵌套就是effect函数里面 嵌套effect函数
比如下面的代码
effect(() => {
console.log('外层effect', obj.name);
effect(() => {
console.log('内层effect', obj.age);
});
});
先解释一下我们为什么要处理嵌套的effect的依赖收集问题,因为我们的组件是会嵌套的,也就是父组件更新 子组件也要更新
现在我们还不能正确的收集嵌套的effect, 因为我们现在定一个effect栈来收集effect
执行 effect 函数前把当前 effectFn 入栈,执行完以后出栈,修改 activeEffect 为栈顶的 effectFn。
这样就保证了收集到的依赖是正确的。
// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
//定义一个effect栈来收集effect
const effectStack = []
/**
* effect用来注册副作用的函数
* @param fn 副作用函数
*/
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn) //先让effectFn 入栈
fn() // 执行副作用函数
effectStack.pop() // 副作用函数执行完就出栈
activeEffect = effectStack[effectStack.length - 1] // 最后让activeEffect函数指向栈顶元素
}
// 记录下这个 effect 函数被放到了哪些 deps 集合里
effectFn.deps = []
effectFn()
}
至此,我们的响应式系统就算比较完善了。
全部代码如下:
// 变量activeEffect 用来保存注册的副作用函数
let activeEffect
//定义一个effect栈来收集effect
const effectStack = []
/**
* effect用来注册副作用的函数
* @param fn 副作用函数
*/
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn) //先让effectFn 入栈
fn() // 执行副作用函数
effectStack.pop() // 副作用函数执行完就出栈
activeEffect = effectStack[effectStack.length - 1] // 最后让activeEffect函数指向栈顶元素
}
// 记录下这个 effect 函数被放到了哪些 deps 集合里
effectFn.deps = []
effectFn()
}
//从依赖集合中删掉自己
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
//首先定义一个 weakMap 用来存储 对象和 key的关系
const reactiveMap = new WeakMap()
/**
* reactive是基础的响应式api函数 把数据变成响应式数据的函数
* @param data 数据
* return proxy 代理对象
*/
const reactive = (data) =>{
// 因为Proxy 只能劫持对象 所以不是对象 直接返回
if(!(typeof data === 'object' && typeof data !== null)) {
return new Error('reactive只能代理对象')
}
const proxy = new Proxy(data ,{
//劫持数据的获取操作
get(target , key , receiver){
const result = Reflect.get(target, key, receiver)
//如果没有副作用函数关联 直接返回数据
if(!activeEffect) return result
// 从reactiveMap中取出 存储的target 它是一个Map类型 里面存储 key 和 effect 的关联
let depsMap = reactiveMap.get(target)
// 如果不存在depsMap 那就新建一个 Map 和target 关联
if(!depsMap) {
reactiveMap.set(target, (depsMap = new Map()))
}
//再根据 key 从depsMap 中取出 key关联的 effect 集合 它是一个Set类型 key ---> Set
let deps = depsMap.get(key)
//如果 deps存在 就新建一个Set 和key 关联 里面存储 effect
if(!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将注册的 副作用函数存入 Set
deps.add(activeEffect)
// 记录副作用函数被哪些 deps集合 依赖了
activeEffect.deps.push(deps)
// 返回属性值
return result
},
//劫持数据的设置操作
set(target , key , newVal, receiver){
const result = Reflect.set(target, key, newVal, receiver)
//通过target 取出 key 组成的 Map
let depsMap = reactiveMap.get(target)
if(!depsMap) return result
// 通过key 取出key 关联的 存储effect函数 的Set集合
let deps = depsMap.get(key)
//取出所有的key 关联的 effect执行
// deps && deps.forEach(fn => fn())
//新创建一个Set集合来执行副作用函数 避免add、delete无限循环
const effectRun = new Set(deps)
effectRun && effectRun.forEach(fn => fn())
return result
}
})
return proxy
}
五、总结
3响应式最核心的原理就是 劫持数据的读取和设置操作
而为了达到数据改变自动更页面,我们设计了一套数据结构来让effect副作用函数和数据关联起来
数据结构的最外层是 WeakMap,key 为对象,value 为响应式的 Map。这样当对象销毁时,Map 也会销毁。
Map 里保存了每个 key 的依赖集合,用 Set 组织。
我们通过 Proxy 监听对象的get和set操作来达到自动的依赖收集和派发更新,也就是添加 effect 到对应 key 的 deps 的集合里。set 的时候触发所有的 effect 函数执行。
这就是基本的响应式系统。
但是还不够完善,每次执行 effect 前要从上次添加到的 deps 集合中删掉它,然后重新收集依赖。这样可以避免因为分支切换产生的无效依赖。
并且执行 deps 中的 effect 前要创建一个新的 Set 来执行,避免 add、delete 造成的无限循环。
此外,为了支持嵌套 effect,需要在执行 effect 之前把它推到栈里,然后执行完出栈 ,最后让activeEffect始终指向栈顶元素,确保activeEffect指向最新的副作用函数 。
解决了这几个问题之后,就是一个完善的 Vue3 响应式系统了。
当然,现在虽然功能是完善的,但是没有实现 computed、watch 等功能。