你来说说computed的实现原理吧,lazy与计算属性computed——《Vue.js设计与实现》

系列文章的前置文章

《Vue.js设计与实现》------响应式系统的基本实现

《Vue.js设计与实现》------分支切换与 cleanup

《Vue.js设计与实现》------副作用函数实现可嵌套

《Vue.js设计与实现》------响应式系统的可调度性

依稀记得不久前在某音上刷到一个前端连麦面试的直播,一上来问的就是:说说computed的实现原理吧,连麦的同学并没有回答上来,回答的是使用场景,直播间的弹幕说一年经验的人不问这些,主播回答的意思是不问这个问什么,现在不是行情好的时候了,你能随便找到工作的话就可以不了解。

原来这就是强者的世界吗,当时就给还在边查文档边写Vue3(当然,现在也是边查边写)的我镇住了,两年打工经验的我也完全没听过这题目呀🧎‍♂️。记得21年的时候问Vue原理的时候基本都是你知道Vue的响应式原理吗,然后回答一个Object.defineProperty就可以了,再深一点是问key的作用问到diff算法(当然指的并不是大厂)。

拉回正题,上一节中我们初步学习了如何实现响应式系统的可调度性,这一节我们来学习与这个特性息息相关的也很常用的computed

懒执行的副作用函数

目前为止我们实现的effect函数是会立即执行的(前面的文章都贴在文章开头了,感兴趣的话可以先看一下前面的文章🙇‍♂️),如下面的代码:

javascript 复制代码
effect(() => {
    console.log(proxyObj.foo)
})

💡倘若我们想让这个字段和副作用函数关联的过程 不立刻执行该怎么办呢?我们可以在调用时传递options ,里面写上lazy: true,意思就是不要立刻执行,并把副作用函数返回回来,由我来控制什么时候执行:

javascript 复制代码
effect(() => {
    console.log(proxyObj.foo)
}, {
    // 新增
    lazy: true
})

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn) // 新增
        activeEffectFn = effectFn
        // 在调用副作用函数之前将当前副作用函数压入栈中
        effectStack.push(effectFn)
        // 接受fn函数的返回值
        fn()
        // 结束之后,弹出副作用函数
        effectStack.pop()
        // 把activeEffectFn改为栈顶的函数
        activeEffectFn = effectStack[effectStack.length - 1]
    }
    effectFn.deps = []
    // 将 options 挂载到 effectFn 上
    effectFn.options = options // 新增
    if (!options.lazy) {
        effectFn()
    }
    // 把副作用函数返回
    return effectFn
}

由此我们就可以手动来执行这个副作用函数了:

javascript 复制代码
const effectFn = effect(() => {
    console.log(proxyObj.foo)
}, {
    lazy: true
})

effectFn()

computed的初步实现

只是手动控制副作用函数执行用处并不大,但倘若我们传递给effect的函数会return一个值,如下:

javascript 复制代码
const effectFn = effect(() => {
    return proxy.foo + proxy.bar
}, {
    lazy: true
})

然后我们在effectFn调用这个函数时获取到返回值,并将他作为effectFn的返回值,那么我们就可以在调用时拿到计算后的值,有点绕,上代码:

diff 复制代码
function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn) // 新增
        activeEffectFn = effectFn
        // 在调用副作用函数之前将当前副作用函数压入栈中
        effectStack.push(effectFn)
-        fn()
+        // 接受fn函数的返回值
+        const res = fn()
        // 结束之后,弹出副作用函数
        effectStack.pop()
        // 把activeEffectFn改为栈顶的函数
        activeEffectFn = effectStack[effectStack.length - 1]
+        // 将返回值res作为effectFn的返回值
+        return res
    }
    effectFn.deps = []
    // 将 options 挂载到 effectFn 上
    effectFn.options = options // 新增
    if (!options.lazy) {
        effectFn()
    }
    // 把副作用函数返回
    return effectFn
}

这样就可以拿到计算结果了:

javascript 复制代码
const effectFn = effect(() => {
    return proxy.foo + proxy.bar
}, {
    lazy: true
})

const res = effectFn()

这基本上就是computed 的雏形。接下来我们尝试实现computed,我们知道computed是一个函数,在调用computed时,我们会得到一个对象,使用的时候我们会用.value来获取他的值,包装一下如下:

javascript 复制代码
function computed(getter) {
    const effectFn = effect(getter, {
        lazy: true
    })

    const obj = {
        get value() {
            // 在实际获取value值时才执行副作用函数
            return effectFn()
        }
    }
    
    return obj
}

具体运行一下:

javascript 复制代码
let obj = {
    // 添加两个变量
    param1: 1,
    param2: 1
}

const sum = computed(() => {
    console.log('set');
    return proxyObj.param1 + proxyObj.param2
})

console.log(sum.value)

可以看到打印出来了2:

不过目前我们只实现了计算属性的懒执行,也就是在实际获取value值时才执行副作用函数。

计算属性还有一个很重要的特性:可缓存性 ,在依赖的值没有改变的情况下,副作用函数无需重复执行,但是现在如果我们多次获取打印sum.value会发现每一次获取都去执行了一遍副作用函数:

javascript 复制代码
console.log(sum.value)
console.log(sum.value)

computed实现缓存

实现计算属性的可缓存性,我们需要考虑下面几个方面:

  1. 创建一个标志变量dirty,用于标识是否可以从缓存里取值;
  2. 创建一个存储值的变量value,在标志变量dirty意为不可从缓存里取值时重新运行副作用函数并更新值,同时把标志变量dirty置为可以从缓存里取值;
  3. 在计算属性依赖的值产生变化后将标志变量dirty置为不可从缓存里取值

实现的代码如下:

javascript 复制代码
function computed(getter) {
    let value // 用来缓存上一次的值
    let dirty = true // 用来表示是否要重新计算值,dirty表示"脏",需要重新计算值
    const effectFn = effect(getter, {
        lazy: true,
        // 添加调度器,在调度器中将dirty重置为true,新增
        scheduler() {
            dirty = true
        }
    })

    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                // 将dirty置为false,下一次获取值时直接返回缓存的value值, 新增
                dirty = false
            }
            return value
        }
    }

    return obj
}

其中在计算属性依赖的值产生变化后将标志变量置为不可从缓存里取值 这一步就用到了上一篇文章中我们讲到的调度器,同时也避免了修改依赖的值之后副作用函数运行。看下运行效果:

computed返回值的依赖绑定和触发

目前的实现里还有一个小问题,看下面的代码例子:

javascript 复制代码
effect(function effectFn2() {
    console.log(sum.value);
})

proxyObj.param1++

在这个例子中,effectFn2中会打印sum.value,我们期望在修改计算属性sum依赖的值后,能够触发执行effectFn2,但实际上只会触发一次打印,我们希望再打印一次3:

我们梳理一下这个effect的执行到底发生了什么,在执行到const res = fn()时,实际上就是执行effectFn2,需要打印sum.value,也就是getsum.value,由此触发了computed内部的get value(),将proxy.param1proxy.param2绑定到了他们对应的getter上,然后返回了计算后的值赋值到res上,然后就到下一步了。

然后是修改值proxyObj.param1++的这一步,执行到调度器时也只是把dirty的值修改成了true。

我们会发现,在整个过程中既没有绑定也没有触发effectFn2的过程:

  1. 没有将sum.valueeffectFn2关联起来,
  2. 在修改计算属性关联的值后也不会触发effectFn2

所以解决方法也很明显了,我们需要在获取value值时手动绑定,在修改计算属性依赖值的调度器时触发value值的副作用函数,如下:

diff 复制代码
function computed(getter) {
    let value // 用来缓存上一次的值
    let dirty = true // 用来表示是否要重新计算值,dirty表示"脏",需要重新计算值
    const effectFn = effect(getter, {
        lazy: true,
        // 添加调度器,在调度器中将dirty重置为true
        scheduler() {
            dirty = true
+            trigger(obj, 'value')
        }
    })

    const obj = {
        get value() {
            if (dirty) {
                value = effectFn()
                // 将dirty置为false,下一次获取值时直接返回缓存的value值
                dirty = false
            }
+            track(obj, 'value')
            return value
        }
    }

    return obj
}

再执行一下看看效果,打印了两次,后一次打印也是更新过后的值。

小结

不知道回答出来这些够不够跟面试官打的时候过两招了,下一节我们再看看watch的基本实现,这个应该也是可能会被问到的吧(昏厥)。有什么写的不对的地方或者什么疑问可以留言,希望文章有帮助到你~

参考书籍

《Vue.js设计与实现》------------霍春阳

相关推荐
天下无贼!几秒前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr几秒前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林4 分钟前
npm发布插件超级简单版
前端·npm·node.js
罔闻_spider37 分钟前
爬虫----webpack
前端·爬虫·webpack
吱吱鼠叔39 分钟前
MATLAB数据文件读写:1.格式化读写文件
前端·数据库·matlab
爱喝水的小鼠1 小时前
Vue3(一) Vite创建Vue3工程,选项式API与组合式API;setup的使用;Vue中的响应式ref,reactive
前端·javascript·vue.js
小晗同学1 小时前
Vue 实现高级穿梭框 Transfer 封装
javascript·vue.js·elementui
WeiShuai1 小时前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript
Wandra1 小时前
很全但是超级易懂的border-radius讲解,让你快速回忆和上手
前端
forwardMyLife1 小时前
element-plus的面包屑组件el-breadcrumb
javascript·vue.js·ecmascript