Vue3
Vue2与Vue3有什么不同
响应式实现方法不同
API风格不同
在vue2使用的是选项式api,在vue3中既可以使用组合式api,又可以使用选项式api。
其他
组件注册方式不同:Vue3导入组件后,也不需要在components里注册
模板语法不同:Vue2中模板里只能有一个根标签,而在Vue3里可以有多个,因为这些根标签都会被
fragment
标签包裹
Vue3.0里为什么要用 Proxy API 替代 defineProperty API ?
defineProperty
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改对象的现有属性,并返回此对象。
1 | //传入一个对象,将这个对象转变成响应式对象 |
1 | function defineReactive(obj, key, val) { |
1 | const arrData = [1,2,3,4,5]; |
缺点小结
Object.defineProperty
无法监听到数组方法对数组元素的修改- 需要遍历对象每个属性
逐个添加监听
,而且无法监听到对象属性
的添加
与删除
,如果属性值是嵌套对象,还深层监听,造成性能问题。
Proxy
Proxy
的监听是整个对象
,那么对这个对象的所有操作会进入监听操作,这就完全可以代理所有属性了
定义一个响应式方法reactive
,这个reactive
方法,就是vue3中的reactive
方法的简化版
1 | function reactive(obj) { |
测试一下简单数据的操作,发现都能劫持
1 | const state = reactive({ |
再测试嵌套对象情况,这时候发现就不那么 OK 了
1 | const state = reactive({ |
如果要解决,需要在get
之上再进行一层代理
1 | const observed = new Proxy(obj, { |
修改后输出的结果:
1 | 获取bar:[object Object] |
由此可以看出,Proxy是按需添加响应式的,只有当我们取出的值是一个对象的时候,Proxy才会递归给这个对象添加响应式。而在Object.defineProperty
中是直接递归添加响应式的。
总结
Object.defineProperty
这个方法存在许多缺点,比如必须遍历对象
的所有属性逐个添加监听
,而且无法监听对象属性的增加与删除,如果属性的值是引用类型
还需要深度监听
,造成性能问题
。
对于数组,Object.defineProperty
方法无法监听到数组方法,对数组元素的修改,需要重写数组方法。
而Proxy能监听整个对象的变化,也能监听到数组方法对数组元素的修改。
说说Vue 3.0中Treeshaking特性?
是什么
Tree shaking
是一种通过清除多余js代码方式,来优化项目打包体积
的技术。Vue3源码实现了高度的ESM模块化,更好的支持Tree-shaking。
如何做
Tree shaking
是基于ES6
模块语法(import
与exports
),主要是借助ES6
模块的静态编译
思想,在编译时
就能确定模块的依赖关系,以及输入和输出的变量。
Tree shaking
无非就是做了两件事:
- 编译阶段利用
ES6 Module
判断哪些模块已经加载 - 判断那些函数和变量未被使用或者引用,进而删除对应代码
那么为什么使用 CommonJs、AMD 等模块化方案无法支持 Tree Shaking 呢?
因为在 CommonJs、AMD、CMD 等旧版本的 js 模块化方案中,导入导出行为是高度动态,难以预测的,只能在代码运行的时候
确定所有模块的依赖关系,例如:
1 | if(process.env.NODE_ENV === 'development'){ |
而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句,只能出现在模块顶层,可以理解为全局作用域;且导入导出的模块名必须为字符串常量,这意味着下述代码在 ESM 方案下是非法的:
1 | if(process.env.NODE_ENV === 'development'){ |
所以,ESM 模块之间的依赖关系是高度确定
的,与运行状态无关,编译工具只需要对 ESM 模块做静态语法分析
,就可以从代码字面量中,推断出哪些模块值未曾被其它模块使用,这是实现 Tree Shaking 技术的必要条件。
关于tree-shaking
更多内容参考:前端面试—webpack | 三叶的博客
关于cjs和esm
的更多内容参考:nodejs | 三叶的博客
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 data = { |
在上述代码中,当执行effect函数,会将传入的fn标记为activeEffect
(激活状态的副作用函数),然后调用fn,访问响应式数据,触发依赖收集,调用track
方法,将activeEffect
收集到对应key的deps集合中去。后续当修改obj.name
,触发trigger
,调用name属性的deps集合中的所有副作用函数。
更新依赖
但是上述代码还有缺陷,当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 | activeEffect = 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) { |
然后我们就可以开始实现计算属性了。
基本实现
1 | function computed(getter) { |
首先我们定义一个computed
函数,这个函数接收一个getter
函数作为参数,我们把getter
函数作为副作用函数,用它创建一个lazy
的effect
。computed
函数的执行会返回一个对象,它的value
属性是一个访问器属性,只有读取value的值的时候,才会执行effectFn
并将其返回值返回。
1 | const data = {foo:1, bar:2} |
添加缓存
我们的代码现在还不算完美,因为计算属性的依赖被修改了,计算属性的effectFn
也会重新执行,这算不上懒计算,而且我们多次访问sumRes.value
的值,会导致effectFn
进行多次计算,即使obj.foo
和 obj.bar
本身没有发生变化。为了解决这个问题,我们需要添加值缓存的功能。
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
又被切换,然后传入的副作用函数(箭头函数)执行完毕,然后发现它没有被任何数据收集。所以即便修改了obj.foo
,注册的副作用函数也不会重新执行。
1 | function computed(getter) { |
vue3中的watch是如何实现的?
定义getter
所谓watch就是观测一个响应式的数据,当数据发生变化时通知并执行相应的回调函数。实际上,watch的实现本质上就是利用了watch和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(()=>{ |
在getter函数内部,用户可以指定watch依赖(监听)哪些响应式数据,只有当这些数据变化才会触发回调函数的执行:
1 | function watch(source, cb, options) { |
拿到新值与旧值
现在实现的watch还缺少一个非常重要的功能,我们调用wath的回调cb中,通常能获取到新值和旧值。首先我们需要在watch中定义新值与旧值,然后需要获取到这个值,这就必须拿到调用effect返回的包装后的副作用函数effectFn。
1 | function watch(source, 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类型的数据默认是深度监听(递归访问子属性)。
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) { |
自动脱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属性也参与依赖的收集。
你了解过watchEffect吗
watch
的套路是:既要指明监视的属性,也要指明事件的回调。watchEffect
的套路是:不用指明监视哪个属性,监视的回调中用到哪个属性,那就监视哪个属性。
1 | // watchEffect 所指定的回调中用到的数据只要发生变化,则直接重新执行回调。 |
简要源码:
1 | function watchEffect(fn, options = {}) { |
1 | watchEffect((onInvalidate) => { |
也就是说,传入watchEffect的副作用函数,还支持配置形参onInvalidate
,传入的副作用函数,在watchEffect内部被调用的时候,会传入onInvalidate
函数,这个函数的作用其实就设置cleanup方法,简单的来说,onInvalidate允许用户自定义cleanup函数。在传入watchEffect的副作用函数每次重新执行前,都会先进行副作用清理。
watchEffect还会返回一个stop函数,支持清理包装后的副作用函数,原理就是从deps中删除对应的副作用函数(清理依赖)
总结
- watchEffect是基于effect实现的,基于effect包装而来的
- 支持清理过期的副作用
- 会返回一个stop函数,该函数的作用是从所有依赖集合中移除传入的副作用函数。