手撸 Vue 3 的响应式 “丐版”

我们都知道了在 Vue2 中的响应式是通过 Object.defineProperty 重写 get set,进行数据劫持来实现的。那么 Vue3 中是如何实现的呢?

要了解响应式,我们先认识一下副作用函数。 本文参考自霍春阳大大的《Vue.js 设计与实现》

副作用函数

副作用函数指的就是会产生副作用的函数(听君一席话,胜读一席话 🤣),代码如下

let a = 1

function effect(x) {a++return a + x
}
effect(1) // 2
console.log(a) // 2 

effect 函数中执行自身逻辑中,修改了全局变量 a,对其他使用的产生了影响,那么这就是一个副作用,这种会影响除了自身局部变量以外的变量的函数,我们称之为 “副作用函数”

响应式数据

理解了副作用函数,那么我看来看一下什么是响应式。假设在一个副作用函数读取一个变量的属性并赋值,代码如下

let obj = {text: 'hello effect'
}

function effect() {document.body.innerHTML = obj.text
}
effect() 

effect 函数执行后,页面会显示 hello effect,同时当我们再次修改 obj.text = "hello world" ,effect 再次执行,页面同步刷新成为 hello world,那么我们就实现了数据的响应式。 接下来让我们来尝试实现以下最基本的响应式吧。

no bb,show me the code! 🤐

响应式数据的基本实现

由上面可知,只要我们再 obj.text 每次变化的时候,重新执行 effect 不就好了么?是的就这么简单。 核心就是代理对象 “Proxy” 来实现。代码如下

// 原始数据
let data = {text: 'hello effect'
}
// 用于存储副作用函数
let bucket = new Set()
// 原始数据代理
let obj = new Proxy(data, {// 拦截读取get(target, key) {// 将副作用函数存入 bucketbucket.add(effect)return target[key]},// 拦截设置值set(target, key, value) {target[key] = value// 执行副作用函数bucket.forEach((fn) => fn())return true}
})
const effect = () => {document.body.innerHTML = obj.textconsole.log('effect run')
}
effect()
setTimeout(() => {obj.text = 'hello world'
}, 1000) 

页面展示 hello effect ,1s 之后页面展示 hello world ,这样我们就实现了最简单的响应式。

但是是有问题的,我们执行 obj.desc = 'aaa' 也会触发副作用函数,原因在于这句 activeEffect = effectFn ,可以通过栈来解决。

并且当先副作用函数执行之前没有清理之前收集的依赖。下面我们进行优化

let obj = {flag: true,text: 'hello world'
}
let activeEffect
let effectStack = []
let bucket = new WeakMap()
let data = new Proxy(obj, {get(target, key) {track(target, key)return target[key]},set(target, key, value) {target[key] = valuetrigger(target, key)}
})

function effect(fn) {let effectFn = () => {// 当前副作用函数执行之前,清理其他属性对它依赖,因为副作用函数执行的时候会重新进行依赖收集cleanup(effectFn)activeEffect = effectFn// 调用之前副作用函数入栈effectStack.push(effectFn)fn()// 还原 activeEffecteffectStack.pop()activeEffect = effectStack[effectStack.length - 1]}// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合effectFn.deps = []effectFn()
}

function cleanup(effectFn) {// 将当前 effect,在所有副作用set中删除effectFn.deps.forEach((deps) => {deps.delete(effectFn)})effectFn.deps.length = 0
}

function track(target, key) {if (!activeEffect) returnlet depsMap = bucket.get(target)if (!depsMap) bucket.set(target, (depsMap = new Map()))let deps = depsMap.get(key)if (!deps) depsMap.set(key, (deps = new Set()))deps.add(activeEffect)activeEffect.deps.push(deps)
}

function trigger(target, key) {let deps = bucket.get(target)?.get(key)if (deps) {// 执行的时候会 cleanup 会触发 delete,而副作用函数会触发依赖收集导致for死循环// 需要使用一个新的 set 进行处理let effectRun = new Set()deps.forEach((fn) => {// 防止副作用函数在执行的过程中再次触发 trigger 而死循环fn !== activeEffect && effectRun.add(fn)})effectRun.forEach((fn) => fn())} else {return}
}
effect(() => {console.log(data.flag ? data.text : 'none')
}) // "hello world"
data.text = 'aaa' // "aaa"
data.flag = false // "none"
data.text = 'bbb' // 不会再次触发副作用函数
// 嵌套的 effect
effect(() => {data.count++console.log('嵌套的 effect count:', data.count)
})

data.count++ 

这样我们一个基本的数据响应式就完成了。

调度执行

有时候我们控制副作用函数的执行时机,那我们就需要对副作用注册函数进行扩展,增加一个参数,代码如下

// 省略部分重复代码
function effect(fn, options = {}) {let effectFn = () => {// ...}// 新增代码挂载 optionseffectFn.options = options
}

function trigger(target, key) {// ...effectRun.forEach((fn) => {// 有调度器,不执行函数交给 schedulerif (fn?.options?.scheduler) {fn.options.scheduler(fn)} else {fn()}})
}

// 案例
effect(() => {console.log('count:', data.count)
}, {scheduler(fn) {setTimeout(fn, 1000)}
}) 

计算属性 computed

结合上面的副作用函数,和 options 参数,我们就可以来实现一个有趣的功能–计算属性

现在我们看到 effect 函数注册之后时立即执行的,我们需要改造一下,用于后面的使用,其实就是另一个参数 lazy

// 省略部分代码
function effect(fn, options = {}) {const effectFn = () => {// ...const res = fn();// ...return res}// 没有 lazy 就直接执行,有的话就不执行if (!options.lazy) {effectFn()}return effectFn
}

let effectFn = effect(() => data.count + 2, {lazy: true,
})
effectFn() // 2
data.count++
effectFn() // 3 

现在就可以来实现一个计算属性,首先定义一个 computed 函数,接受一个 getter 函数作为参数,我们将这个 getter 作为副作用函数来返回。

function computed(getter) {let effectFn = effect(getter)const obj = {get value() {return effectFn()}}return obj
}
let count = computed(() => data.count * 2)

count.value // 2
data.count = 2
count.value // 4 

上面已经实现了基本的 computed,我们都知道,计算属性是有缓存的,而我们上面的实现,是每次调用都会去进行计算,现在我们增加一个 dirty 检测。

function computed(getter) {let value// 标记是否需要重新计算let dirty = truelet effectFn = effect(getter, {// 副作用函数执行的时候,说明依赖的数据发生了变化scheduler() {dirty = true}})const obj = {get value() {if (dirty) {value = effectFn()dirty = false}return value}}return obj
} 

先写这么多后面,后面继续完善 computed 和 watch 🤐

最后

整理了一套《前端大厂面试宝典》,包含了HTML、CSS、JavaScript、HTTP、TCP协议、浏览器、VUE、React、数据结构和算法,一共201道面试题,并对每个问题作出了回答和解析。

有需要的小伙伴,可以点击文末卡片领取这份文档,无偿分享

部分文档展示:



文章篇幅有限,后面的内容就不一一展示了

有需要的小伙伴,可以点下方卡片免费领取

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值