Vue3
Vue2与Vue3有什么不同
响应式实现方法不同
Object.defineProperty
在vue2中的响应式基于Object.defineProperty,能监听属性的getter和setter,然后在getter中触发依赖收集,在setter中触发依赖更新。
dep和watcher
在vue2中,每个响应式属性都有一个dep实例负责管理它的依赖:触发getter时调用dep.depend方法收集依赖,触发setter时调用dep.notify通知依赖更新。然后在vue2中,依赖被抽象为一个Watcher实例,通常情况下new Watcher实例就会开启依赖收集。
proxy + Object.defineProperty
在vue3中的响应式基于Proxy,解决了vue2中修改数组下标无法被监听到的问题,解决了vue2中无法监听到对象属性的新增和删除的问题(解决了什么问题)。同时vue3中也不是完全抛弃了Object.defineProperty,vue3中的ref就使用了Object.defineProperty来接听value属性的getter和setter。
副作用函数effect
在vue3中,不存在watcher和dep,取而代之的是副作用函数effect,使用effect函数注册副作用函数,就相当于在vue2中创建watcher实例,副作用函数的依赖改变,副作用函数就会重新执行。
API风格不同
在vue2使用的是选项式api,在vue3中推荐使用组合式api。组合式api的好处是,可以将逻辑紧密相关的代码写在一起,而且在组合式api的写法下,源码改为了函数式编程(vue2是类式编程),更方便按需引入,因为tree-shaking必须配合按需引入的写法来实现,所以vue3中的tree-shaking的效果更好,打包后的文件更小。
生命周期钩子函数不同
在vue3中
- setup函数替代了
beforeCreate和created这2个生命周期函数 beforeDestory和destoryed钩子被替换为beforeUnmount和unmounted- 并且所有生命周期函数在原来的基础上添加了前缀on。
组件混入mixin
在vue3中不推荐使用组件混入mixin来复用代码,因为组件混入mixin只适合选项式api的写法,不适合用在组合式api中,而且mixin也容易和组件本身的配置项产生冲突。在vue3中推荐将代码封装到hooks中,需要的时候再引入。
fragment
在vue2中的组件模板内,只能使用一个根标签,而在vue3中,组件模板内可以使用多个根标签,会自动被fragment标签包裹。
v-model
在vue2,vue3中,v-model在自定义组件上的用法不同。在vue2中,在自定义组件上使用v-model相当于动态绑定了value属性,然后监听组件的input事件,在vue3中,在自定义组件上使用v-model,相当于动态绑定了modelValue属性,然后监听的是组件的update:modelValue事件。
v-if和v-for的优先级
在vue3中v-if的优先级高于v-for,而在vue2中则反之。这样当将v-if和v-for写在一起的时候,通常会就会报错,从而避免开发者将这两个指令写在一起。
新增组件
在vue3中新增了Suspense和Teleport组件。
keep-alive
vue2,vue3中keep-alive的写法不同。在vue2中,只需使用keep-alive标签包裹router-view,就能实现组件的缓存;然后在keep-alive标签上添加一些属性比如include,max,就能实现更为精细的缓存控制。
在vue3中,由于组件不需要命名,也不需要注册,所以只能通过路由对象并结合v-if来控制路由组件的缓存。在模板中的写法也有所不同,结构更为复杂。
自定义指令的钩子也不同
在vue2中的自定义指令钩子包括bind(指令首次绑定到元素上时触发),inserted(元素插入到父节点中时触发),update(组件更新时触发),unbind(指令解绑时触发)。
而在vue3中,自定义指令的钩子则和vue3中的生命周期函数类似,比如mounted(元素插入到父元素时触发),updated(组件更新完成后触发),unmounted(指令解绑,元素卸载的时候触发)。
Vue3.0里为什么要用 Proxy API 替代 defineProperty API ?
使用proxy来替代Obejct.defineProperty ,是因为基于Obejct.defineProperty 实现的响应式存在诸多问题:
Object.defineProperty只能监听一个属性的getter和setter,如果想要给一个对象添加响应式,需要遍历它的所有属性并依次调用Object.defineProperty方法,性能较差Object.defineProperty无法监听到数组方法对数组元素的修改,无法监听到通过下标对数组的修改, 无法监听到对象属性的增加和删除。
而Proxy监听的是整个对象,完全没有Object.defineProperty在实现响应式上存在的问题,而且不需要遍历对象每个属性逐个添加响应式,性能更好。
Composition Api 与 Options Api 有什么不同?
代码组织方式
选项式api按照代码的类型来组织代码;而组合式api按照代码的逻辑来组织代码,逻辑紧密关联的代码会被放到一起。
代码复用方式
在选项式api这,我们使用mixin来实现代码复用,使用单个mixin似乎问题不大,但是当我们一个组件混入大量不同的 mixins 的时候,就存在两个非常明显的问题:命名冲突和数据来源不清晰。
而在组合式api中,我们可以将一些可复用的代码抽离出来作为一个函数并导出,在需要在使用的地方导入后直接调用即可。这个种模块化的方式,既解决了命名冲突的问题
vue3中的响应式是如何实现的?
基本实现
vue3中的响应式是通过proxy+副作用函数实现的。
副作用函数,指的是会产生副作用的函数,也就是说函数的执行会影响其他函数的执行。在vue3中,函数内部访问了响应式数据的函数,就可以被视为副作用函数,也叫做effect。
1 | const obj = { text: "hello world" } |
我们希望当obj.text被修改的时候,上述副作用函数effect会重新执行,但是目前来看,这个功能无法实现,因为obj不是响应式数据,它被修改无法被检测到。因此我们需要把这个数据变成响应式的,然后当触发这个数据的getter的时候,收集这个副作用函数,当触发这个数据的setter的时候,执行这个函数。在vue3中,我们使用proxy来劫持对象的getter和setter。
1 | const bucket = new WeakMap() //存储了所有原始数据的depsMap |
在上述代码中:
- 使用proxy获取代理对象,给对象添加响应式
- 定义了effect函数,用来注册副作用函数,被注册的副作用函数会被设置为
activeEffect,这是一个全局变量,然后被调用,访问响应式数据。 - 获取对象的属性,会触发getter,然后调用
track方法进行副作用收集,每个属性都有自己的deps集合 - 修改对象的属性,会触发setter,然后调用
trigger方法,调用这个属性收集到的所有副作用函数
更新依赖
但是上述代码还有缺陷,当effect函数是下面这种情况时:
1 | effect(()=>{ |
初次执行时,由于obj.bool为true,所以注册副作用函数的时候,只有obj.bool的deps集合和obj.name的deps集合会收集这个副作用函数(箭头函数)为依赖。当我们修改obj.bool为false的时候,三元表达式的值就和obj.name没有任何关系了,但是当我们修改obj.name的时候,副作用函数还是会被执行。因为obj.name的deps集合中还存在这个副作用函数。
解决办法就是,后续每次触发副作用函数的时候,都先从存储有这个副作用函数的deps集合中,删除这个副作用函数,然后再执行一次依赖收集。因此我们还需要知道这个副作用函数,被哪些deps集合收集了。
修改effect函数如下:
1 | function effect(fn) { |
effectFn是包装后的副作用函数,不仅会执行传入的副作用函数,还会进行依赖清除。
修改的track函数如下:
1 | function track(target, key) { |
无限循环问题
上述修改后的代码仍然存在问题,当我们修改数据触发trigger的时候,会陷入无限循环。问题就处在trigger函数中。
1 | function trigger(target, key) { |
当我们修改一个响应式数据的属性,会先找到这个属性的deps集合,然后遍历执行其中的副作用函数,对于每个副作用函数,执行时都会先将自己从这个dep集合中移除,然后再次触发依赖收集:
1 | const effectFn = () => { |
于是这个副作用函数,又被放入了这个正在遍历的deps集合中。语言规范中有对此明确说明,在调用forEach遍历set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,这个值会被重新访问。因此上面的代码会无限执行。
解决办法也很简单,只需要在遍历执行副作用函数的时候,将deps集合拷贝一次然后遍历这个集合就行。
1 | const copy_deps = new Set(deps) //正确的做法是先把这个集合拷贝 |
嵌套的effect
1 | effect(function fn1(){ |
在这种情况下,我们希望当修改obj.foo时,会触发fn1的执行,由于fn2嵌套在fn1中,所以也会触发fn2的执行。而当修改obj.bar的时候,只会触发fn2的执行。但实际情况并非如此,当我能修改obj.foo的时候,反而输出”fn2执行了”。我们来分析一下:
当执行第一个effect函数的时候,包装后的fn1会被立即执行,activeEffect会被设置为包装后的fn1,然后执行fn1:输出”fn1执行了”,然后执行第二个effect函数,同理,包装后的fn2会被立即执行,activeEffect会被设置为包装后的fn2,然后执行fn2:输出”fn2执行了”,然后访问obj.bar,于是包装后的fn2就会被obj.bar的deps集合收集,然后继续执行fn1,执行temp1 = obj.foo访问obj.foo,但此时activeEffect还是包装后的fn2,于是包装后的fn2就会被obj.foo的deps集合收集。
所以说是我们的activeEffect设计的有问题。 我们需要再加一个副作用函数栈effectStack:
1 | let activeEffect |
这样我们解决了嵌套effect情况下的依赖收集错误的问题了。
无限递归问题
再看下面这种情况:
1 | effect(()=>{ |
再这个传入的副作用函数中,不但访问了obj.foo还修改了obj.foo,当注册这个副作用函数的时候,会立即执行包装后的副作用函数:
- 先清除依赖
- 设置activeEffect为这个包装了的副作用函数
- 执行
obj.foo++,这个操作可以分为2部分,先读取,再修改obj.foo的值。 - 在读取
obj.foo的值的时候,这个被包装了的副作用函数,就会被收集到obj.foo的deps集合中,然后又修改了obj.foo,导致这个被包装了的副作用函数又被执行,可以先前这个包装了的副作用函数并没有执行完毕,所以是函数内部调用了函数,所以导致了无限递归问题
解决这个问题的方法也很简单,只需修改trigger中的代码即可,如果trigger触发的副作用函数等于activeEffect,也就是正在执行的副作用函数,则直接返回。
1 | copy_deps.forEach(fn => { |
添加调度
目前为止,只要我们修改了响应式数据,就会触发对应的副作用函数立即执行。我们能不能控制副作用函数的执行时机和次数呢?也就是说能不能添加调度呢?
我们可以为effect函数设计一个选项参数options,它允许用户指定调度器。
1 | effect(()=>{console.log(obj.foo)},{ |
同时我们将options挂载在包装后的副作用函数上,修改effect函数的代码
1 | effectFn.deps = [] //记录了在哪些deps集合中存储了这个副作用函数 |
然后在trigger函数中,执行回调函数的时候,如果发现回调函数中存在调度器,则执行调度器。
1 | const copy_deps = new Set(deps) //正确的做法是先把这个集合拷贝 |
到此为止我们还只能修改副作用函数执行的时机:我们可以将副作用函数放入宏任务队列,让其在下一个宏任务阶段执行,还不能控制回调函数的执行次数,修改了几次响应式数据,就会触发几次副作用函数的执行。有时候我们只关系结果而不关心中间的过程,所以多次执行副作用函数是没必要的。我们可以借鉴vue2中的nextTick来实现控制副作用函数的执行次数:
1 | const jobQueue = new Set() //任务队列,具有去重机制 |
然后我们就可以在调度器中这么写:
1 | effect(() => { console.log(obj.age) }, { |
vue3中的计算属性是如何实现的
懒执行的effect
在深入讲解计算属性之前,我们需要先来聊聊如何实现懒执行的effect,在我们上面的中,使用effect注册的副作用函数会立即执行,比如:
1 | effect(()=>{ |
但在有些场景下,我们不希望它立即执行,而是在需要它的时候再执行,比如计算属性,这时我们可以通过在options中添加lazy属性来达到目的。
1 | function effect(fn, options) { |
这样我们调用 effect就能拿到返回的effectFn函数然后手动调用。但是只是实现了手动调用effectFn函数其实意义不大,最好还能返回传入的副作用函数的返回值:
1 | function effect(fn, options) { |
然后我们就可以开始实现计算属性了。对应到vue2中,fn就是getter,effectFn就是get方法。
基本实现
1 | function computed(getter) { |
首先我们定义一个computed函数,这个函数接收一个getter函数作为参数,我们把getter函数作为副作用函数,用它创建一个lazy的effect。computed函数的执行会返回一个对象,它的value属性是一个访问器属性,只有读取value的值的时候,才会执行effectFn并将其返回值返回。
1 | const data = {foo:1, bar:2} |
添加缓存
我们的代码现在还不算完美,因为计算属性的依赖(getter中访问的响应式数据)被修改了,计算属性的effectFn也会重新执行,这算不上懒计算,而且我们多次访问sumRes.value的值,会导致effectFn进行多次计算,即使obj.foo 和 obj.bar本身没有发生变化。为了解决这个问题,我们需要添加值缓存的功能,具体的来说,需要添加调度器scheduler,需要修改返回的对象的get value()
1 | function computed(getter) { |
添加依赖管理
现在我们设计的计算属性已经趋于完美了,但是它还有一个缺陷,体现在我们在一个effect中读取计算属性的值的时候:
1 | const sumRes = computed( () => obj.foo + obj.bar ) |
我们期望修改了obj.foo,副作用函数会重新执行,就像在模板中使用了计算属性,然后计算属性的依赖被修改了,模板会更新一样,但是事实并非如此。其实这是一个典型的effect嵌套问题:执行effect函数,会立即执行包装后的副作用函数effectFn,将activeEffect修改为effectFn,然后访问sumRes的value属性,触发计算属性的getter,然后执行计算属性的effectFn,然后计算属性的effectFn被 obj.foo和obj.bar的deps集合收集,然后activeEffect又被切换,然后传入的副作用函数(箭头函数)执行完毕,然后发现它没有被任何数据收集,计算属性的依赖只会收集计算属性的副作用函数
解决办法就是,当访问计算属性的value属性的时,调用track方法收集副作用,然后计算属性的依赖被修改后,调用trigger通知副作用函数执行。
1 | function computed(getter) { |
vue3中的watch是如何实现的?
定义getter
所谓watch就是观测一个响应式的数据,当数据发生变化时通知并执行相应的回调函数。实际上,watch的实现本质上就是利用了effect和options.scheduler。下面我们实现一个最简单的watch函数:
1 | //source是响应式数据(proxy对象),cb是回调函数 |
上述代码中,当source.foo被修改,就会执行回调函数,而不是执行注册的副作用函数。
从这里也可以看出,effect主要负责包装好的副作用函数设置到activeEffect上,而track也是从activeEffect上收集副作用函数,存储在bucket中,二者配合工作才实现了vue3的响应式系统。
在上述代码中,我们硬编码了对foo的读取操作,为了让watch函数更具有通用行,我们需要封装一个通用的读取操作:
1 | function traverse(obj) { |
递归的读取一个响应式对象上的所有子属性。当任意一个属性发生变化,都能够触发回调函数的执行。
watch函数除了可以观测响应式数据(proxy对象),还可以接收一个getter函数:
1 | watch(()=>{ obj.foo },()=>{ |
在getter函数内部,用户可以指定watch依赖(监听)哪些响应式数据,只有当这些数据变化才会触发回调函数的执行:
1 | function watch(source, cb, options) { |
拿到新值与旧值
现在实现的watch还缺少一个非常重要的功能,我们调用wath的回调cb中,通常能获取到新值和旧值。首先我们需要在watch中定义新值与旧值,然后需要获取到这个值,这就必须拿到调用effect返回的包装后的副作用函数effectFn。
1 | function watch(source, cb) { |
对应到vue2中,此处的scheduler像极了watcher的run方法,不但负责调用get(对应effectFn),还负责调用cb。
立即执行的watch
默认情况下,一个watch中的回调,只会在监听的响应式数据发生变化后才变化,在vue中,可以通过指定immediate:true让回调函数立即执行。仔细思考后可以发现,立即执行回调函数和后续数据变化执行回调函数的逻辑相同,所以我们可以把调度函数scheduler封装为一个通用的函数。修改后的代码如下:
1 | function watch(source, cb, options) { |
回调函数的执行时机
除了指定回调函数为立即执行外,还可以通过其他选项参数,来指定回调函数的执行时机。例如在vue3中通过flush选项来指定,当flush的值post,代表调度函数需要将副作用函数放到微任务队列中,等待dom更新后再执行。
1 | const effectFn = effect(() => getter(), { |
过期的副作用
竞态问题通常在多进程或者多线程中被提及,前端工程师可能很少讨论它,但是日常工作中,你可能早就遇到过与竞态问题相似的场景。
1 | let finalData |
假设我们第一次修改obj对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求A,随着时间的推移,在请求A的结果返回前,我们对obj对象的某个字段做了第二次修改,这会导致发送第二次请求B,此时请求A和请求B都在进行中,那么哪个请求会先到达?我们不确定,如果请求B先于请求A到达,这就会导致最终finalData存储的是A请求返回的结果,但由于B请求是后发送的,因此我们认为请求B返回的数据才是最新的,所以我们希望变量finalData存储的是B请求返回的结果,而非A请求返回的结果。
实际上,我们可以将这个问题做一个总结,请求A是副作用函数第一次执行时所产生的副作用,请求B是副作用函数第二次执行时所产生的副作用。由于请求B后发生,所以请求A的结果应该被视为最新的,而请求A已经过期了,其产生的结果应该被视为无效。
归根结底,我们需要一个让副作用过期的手段。我们来看看vue是怎么做的。
1 | let finalData |
1 | function watch(source, cb, options) { |
注册一次监听watch函数只会执行一次,而回调函数可以执行多次,每次执行的回调函数都占用了不同的内存空间。
监听ref
目前我们实现的watch函数只能监听reactive包装的数据,也旧是proxy对象,或者proxy对象上的属性,如何监听ref包裹的对象,也就是refImpl对象的变化呢?
当传入watch的是一个refImpl对象,我们watch内部会将其识别出来,然后将() => source.value作为getter。
1 | if (isRef(source)) { |
访问ref对象的value属性,同样能触发副作用收集,也是从activeEffect中获取副作用函数,不过不是存储在bucket中,而是ref对象自己的dep属性中(值是一个集合)。
watch能正常监听refImpl对象的改变,比如ref(0);但是监听不到ref({a:1})对象的改变。因为默认情况,watch只会监听refImpl对象的value属性的变化,如果value属性未变,就不会触发回调函数,即便value是个对象,且它的属性改变了,但是只要它的引用没变,回调函数也不会执行。
这个问题可以通过开启深度监听解决(在第三个参数传入{ deep: true }),或者监听ref.value(返回一个reactive包装的对象),因为watch监听reactive类型的数据默认是深度监听(递归访问子属性)。
你了解过watchEffect吗
watch 的套路是:既要指明监视的属性,也要指明事件的回调。watchEffect 的套路是:不用指明监视哪个属性,回调中用到哪个属性,那就监视哪个属性。
1 | // watchEffect 所指定的回调中用到的数据只要发生变化,则直接重新执行回调。 |
简要源码:
1 | function watchEffect(fn, options = {}) { |
1 | watchEffect((onInvalidate) => { |
也就是说,传入watchEffect的副作用函数,还支持配置形参onInvalidate。onInvalidate允许用户自定义cleanup函数,在本次回调中指明如何清理本次回调的副作用,在传入watchEffect的副作用函数每次重新执行前,都会先进行副作用清理,清理上次回调的副作用。
watchEffect还会返回一个stop函数,支持清理包装后的副作用函数,原理就是从deps中删除对应的副作用函数(清理依赖)
总结
watchEffect可以看成简化版的watch,省略了cb,或者说将cb和副作用函数合并到一起了。传入WatchEffect的函数会立即执行一次, 触发依赖收集(收集副作用函数),这个函数中使用了哪些响应式数据,就监听哪些响应式数据,当这些响应式数据改变,就重新执行回调函数。
ref和reactive对象依赖收集的方式不同吗?
reactive对象,指的其实是reactive包装的对象,本质是proxy对象。proxy对象触发getter的时候,通过track从activeEffect上收集依赖,存储到bucket中(具体存储在某个属性的deps集合中),然后触发setter的时候,通过trigger触发依赖更新
查看ref函数源码,可以观察到ref包装的对象,也就是refImpl对象,触发getter的时候,执行的是trackRefValue,然后触发setter的时候,执行的是triggerRefValue 。观察refImpl对象,可以发现依赖它的依赖,或者说收集的副作用函数存储在refImpl.dep上。
观察trackRefValue的源码可知,trackRefValue也是从activeEffect上收集依赖(激活的副作用函数),也就是说它们收集的东西都是一样的,都是副作用函数,而这个副作用函数由effect函数来提供,只不过依赖存储的位置不同。
1 | export function trackRefValue(ref) { |
toRef()和toRefs()
响应式丢失
1 | const obj = reactive({ foo:1, bar:2 }) |
在上述例子中,我们打印newObject,输出一个普通的对象{ foo:1, bar:2 },这个对象并不具备响应式,当然也可以自己测试一下。
使用扩展运算符进行对象的浅拷贝,其实也只是将一个个属性拷贝然后读取对应的值。
toRef()
如何来解决这个问题呢?我们可以借鉴ref给基本数据类型添加响应式的方案。
1 | const obj = reactive({ foo:1, bar:2 }) |
可以看到toRef返回了一个新的对象newObj,这个对象有一个访问器属性value,当访问newObj的value属性其实是在访问obj的foo属性,由于obj是响应式数据,所以newObj.value会触发依赖收集(副作用收集)。当修改newObj.value其实是在修改obj的foo属性,所以能触发依赖更新。
toRefs()
使用toRefs可以将一个对象上的第一层属性都转化为ref类型
1 | const obj = reactive({ foo:1, bar:2 }) |
自动脱ref
toRefs函数的确解决了响应式丢失的问题,但是同时也带来了新的问题。由于toRefs会把响应式数据的第一层属性值转换为ref,因此必须通过value属性访问值。
1 | const obj = reactive({foo:1, bar:2}) |
这其实增加了用户的心智负担,因为用户通常在模板中使用数据,我们也不希望编写这样的代码:
1 | <p>{{ foo.value / bar.value}}</p> |
因此我们需要自动脱ref的能力,所谓自动脱ref,指的是属性的访问行为,即如果读取的属性的值是一个ref,则直接将该ref对应的value属性值返回。例如:
1 | newObj.foo //1 |
要实现此功能,需要使用Proxy为newObj创建一个代理对象,通过代理来实现最终的目标。
1 | function proxyRefs(newObj) { |
可以观察到创建的这个代理对象,触发get的时候,并没有进行依赖收集,只是单纯的返回值。
实际上我们在编写 Vue.js 组件时,组件中的 setup 函数返回的数据,会自动传递给 proxyRefs 函数进行处理,这也是为什么我们可以在模板中直接访问一个 ref 的值而无需通过.value属性来访问。
既然读取属性的值有自动脱ref的能力,那么设置属性的值也应该有自动为ref设置值的能力,实现功能很简单,只需要添加对应的set拦截函数即可。
1 | function proxyRefs(newObj) { |
实际上,自动脱ref不仅存在于上述场景中,reactive函数也有自动脱ref的功能。
1 | const count = ref(0) |
ref类型的数据如何收集模板为依赖
计算属性和ref类型的数据,在模板中使用的话,并没有访问value属性,那是如何触发依赖收集的?怎么会把更新模板的副作用函数收集为依赖?其实这得益于vue中自动脱ref的机制。
1 | function proxyRefs(newObj) { |
可以观察到,创建的这个代理对象,触发get的时候,并没有触发依赖收集,只是单纯的返回值。当访问ref对象的value属性时,就触发了trackRefValue(count),相当于是在get中又触发了一个get。
我们来看一个完整例子:
1 | <script setup> |
执行流程:
setup 执行,返回 { count }
1 | setup() → { count: RefImpl } |
Vue 调用 proxyRefs 包装返回值
1 | ctx = proxyRefs({ count }) |
模板编译器生成渲染函数
1 | function render() { |
渲染时执行 render()
1 | ctx.count // 触发 proxyRefs 的 get |
isRef(count) → 返回 count.value,触发 trackRefValue(count),依赖收集完成
同样的,computed 本质是一个 ComputedRefImpl,它也有 .value。然后依赖收集也是通过调用trackRefValue(refImpl),将activeEffect收集到自己的dep集合中。
上述分析的意义在于,当进行依赖分析的时候,在getter函数中如果访问了值为ref类型的属性,可以认为同时也访问了value属性,value属性也参与依赖的收集。
