2022 Vue2 & Vue3 高频笔记

361 阅读17分钟

生活值得你继续努力,加油!

Vue2基础知识

模板编译、数据绑定、依赖收集、派发更新

如果面试官有问到上述4个点,我一般会询问面试官,我有一个场景可以把上面4点知识点串起来解释下看可以不?

场景:页面有一个div,然后div绑定了data里面的变量a,值为1.两秒之后将a的值改为2.

通过这个场景从页面展示1到变成2将模板编译、数据绑定、依赖收集、派发更新这几个点串一下

首先Vue会遍历data里面的属性,内部通过defineReactive方法,使用Object.defineProperty将属性进行劫持(只会劫持已经存在的属性),数组则是通过重写数组方法来实现(原型继承)。 多层对象是通过递归来实现劫持。然后在劫持的时候,会让每个属性拥有自己的dep,存放他所依赖的watcher,当属性变化的时候会通知它对应的watcher去更新(dep.notify)。

然后开始模板编译过程,如果options有render方法就直接取render执行。

如果没有render且options中有templateel,那么template得优先级更高。如果不存在template,则取el得outHTML为template;

1、将template转成AST语法树

2、静态优化,优化不会改变得内容,做diff算法得时候可以跳过被标记成静态节点的元素

3、将AST语法树转化成render函数(_c、_s、_v这些),

4、执行render函数(_render)生成虚拟节点

5、然后将虚拟节点转化成真实节点

这里需要注意一点就是执行render函数的时候是通过watcher执行,也就是我们说的渲染watcher。

然后执行render的时候会对a进行取值操作,触发了a的getter,a的dep属性就收集了当前watcher(渲染watcher)

然后就结束拉,页面展示为1。

两秒之后修改a的值,走到了a的setter,拿到a的dep收集的全部watcher执行,也就是执行render方法,页面重新渲染(DIFF),这个时候页面就变为2拉。

批量更新和nextTick原理

Vue里面如果对一个属性进行重复赋值,到最后会发现只更新了一次,这个取决于Vue内部实现了批量更新

this.name = "1"; 
this.name = "2"; 
this.name = "3";

拿这个举个例子 name属性的dep收集了页面更新的watcher,如果没有批量更新操作,那就会执行三次render函数。所以Vue内部维护了一个队列,对相同的watcher进行过滤(通过watcher的id),上述代码实际上队列只有一个watcher,然后调用nexttick对这个队列进行循环执行。

而nexttick的原理是

1、首先会将把nexttick的回调放进一个数组callbacks

2、然后根据当前的环境按promiseMutationObserversetImmediatesettimeout的顺序降级的处理flushCallbacks方法(遍历callbacks执行),赋值给timerFunc

3、然后执行timerFunc方法

那为什么nexttick可以获取到最新DOM?

按上面所说数据在更新的时候会调用nexttick(flushSchedulerQueue), 其中flushSchedulerQueue就是把属性所有相关watcher执行的方法,按照上面nexttick的源码解析会将刷新视图的方法当作一个异步任务来处理,因为调用顺序的原因,flushSchedulerQueue先执行,在执行nexttick的回调,所以可以拿到最新的DOM

computed和watch的区别 & 原理

watcher有三种 渲染watcher 用户watcher 和computed watcher

watch的原理是遍历watch的属性的到表达式,然后为每一个属性生成一个watcher(用户watcher),然后将表达式转化为一个函数执行,这个时候函数内部获取了vm[表达式]的值,触发了表达式的get,开始收集当前生成的watcher。然后数据发生变化的时候,回调函数就会执行。

计算属性原理稍微复杂一点

假设页面代码如下

<div>{{name}}</div> 
<div>{{newName}}</div> 
data() { return { name: 'chenying' } }, 
computed: { newName() { return this.name + 1 } }

1、 Vue的双向数据绑定依赖于Dep.target的巧妙设计,其中维护了一个全局的数组targetStack,然后通过popTarget和pushTarget的方法来控制Dep.target

2、每个watcher有一个deps属性,存放所以调用了这个watcher的dep,每个dep有一个subs数组,存放所有依赖的watcher,他们是相互关联的

2、首先源码会遍历computed然后给每一项添加一个computed watcher,其中computed watcher和普通watcher的区别是传入了参数lazy:true,并且不会立即执行传入的表达式

3、然后通过Object.defineProperty方法为每一项计算属性添加getter 和setter

4、然后全部renderwatcher执行,页面获取到newName的值,触发getter方法

5、其中getter方法里面会调用watcher.evaluate() - 将dirty改为false,然后将当前Dep.tagert改为computed watcher,调用get方法,get方法里面会对name取值所以调用name的getter方法,也就是给name的subs添加newName的watcher

5、然后Dep.target中的computed watcher出栈,此时Dep.target为页面watcher

6、再然后手动调用计算属性watcher.depend()方法,遍历这个watcher的deps,找到所有有关系的dep,然后往dep.subs里面添加当前的Dep.target,也就是页面watcher

7、这个时候name的subs里面是这样的 [compouted watcher, render watcher],所以name更新会触发这2个watcher

而我们常说的计算属性可以缓存,这个缓存指的是如果依赖的值没有变化,获取计算属性的值的时候不会在调用这个计算属性watcher的get方法,而是直接从watcher.value里面取

VUE2 DIFF算法原理

  1. 会先比较标签是否一致,如果标签不一致用新的标签替换掉老的标签

  2. 如果标签一致,有可能都是文本节点,那就比较文本的内容即可

  3. 当标签相同时,复用老的标签元素,并且进行属性的比对。(如果老的有新的没有,直接删除。如果老的有,新的也有,以新的为准。如果老的没有,新的有,直接用新的)

  4. 然后判断有没有孩子节点

4-1. 如果老的有孩子,新的没有孩子,就删除老的孩子元素

4-2. 如果老的没孩子,新的有孩子,则将新的孩子添加到老的上面

4-3. 如果老的有孩子,新的也有孩子,就进行比较(双指针)

4-3-1. 首先优化4个场景

  • 头头比较: 如果新老头节点一致,则复用老节点 - abcd->abcde 实际上abcd都是复用的,新增e

  • 尾尾比较: 如果新老尾节点一致,则复用老节点 - abcd->eabcd 实际上abcd都是复用的,新增e

  • 新头旧尾比较: 如果新头和旧尾一致,则复用旧尾,移动到头部位置 - abcd -> dabc

  • 旧头新尾比较: 如果旧头和新尾一致,则复用老旧头,移动到尾部位置 - abcd -> bcda

4-3-2. 如果优化策略没有命中,就开始暴力对比,用老节点造一个映射表(key:索引的结构),去搜索看是否存在,如果存在则复用,不存在则新增节点,最后删除多余的老节点

abcd->dcba的流程

首先命中优化策略 旧头新尾 ,复用a,将a移动到最尾部。然后命中 旧头新尾 ,复用b,移动到a前面。然后命中优化策略 旧头新尾 ,复用c,移动到b前面。最后命中优化策略 头头 ,直接复用d

在 v-for 时写了 key ,将第二个元素和第三个元素交换了顺序,实际的 diff 算法怎样的

触发头头,复用第一个节点。后续触发尾尾,复用第四个节点至最后一个节点的全部节点。然后触发旧头新尾,复用老的第二个节点,移动位置。最后触发头头,复用老的第三个节点

组件渲染流程

Vue.extend实际上是创建一个子类Sub,将子类的原型指向Vue.prototype(Sub.prototype = Vue.prototype),并且将自己的component属性和全局的options.conponents属性进行合并,这也是为啥优先取自己定义的同名组件

Vue.component实际上就是内部调用Vue.extend生成一个构造器,并存Vue.options.components中

组件的渲染流程如下:

调用mount方法,解析模板,生成render函数,执行render函数,这个时候通过标签tag判断他是一个组件,会特殊创建组件的虚拟节点,虚拟节点里面有组件构造器和一些hooks(init),然后通过虚拟节点挂载真实节点,挂载时候调用init方法,也就是通过构造器生成组件实例,并且调用组件实例的mount方法进行挂载。

组件更新流程

组件更新会触发组件的prepatch方法,会复用这个组件,并且比较组件的插槽-属性-事件

因为父组件传递给子组件的props是响应式的,在渲染子组件模板时候如果使用了则会收集当前子组件的渲染watcher,然后父组件数据变化自动触发子组件更新

异步组件原理

先渲染一个占位符组件,如果组件异步加载回来,则调组件的forceUpdate强制刷新组件,类似于图片懒加载

v-model的原理

v-model对应普通表单元素和组件是不一样的。

对于不同的表单元素v-model会有不同的结果,如input和checked。对于input来说会被编译成(input + value + 指令),并且value和input实现的双向数据绑定阻止了中文的触发,取之的是指令内部手动的触发双向绑定。

对于组件来说就很简单了,就是默认编译为:value和@input,但是也可以通过model: {props: xx, event:xx}来修改默认绑定

keep-alive原理

keepalive原理是默认缓存加载过的组件对应的实例,内部采用LRU算法。大致逻辑是内部维护一个cache对象,为每个加载的组件生成一个唯一key来做cache的键,将vnode中的data.keepAlive设置为true(vnode中有data.keepAlive 并且有componentInstance说明这个虚拟节点是缓存过的),下次再加载相同的组件,则从cache缓存中取$el,判断标识就不走mount这些逻辑了,并且触发actived和deactived钩子.

而include和exclude是根据组件的name或者tag来判断的

Vue.use原理

mixin原理和合并策略

其实都是合并到Vue.options属性里面去,然后Vue.$options是option和Vue.options进行合并的。

其中生命周期用策略模式合并成数组,也就是每个相同的生命周期都会执行。

对于components的合并是子的比全局的优先,如果子没有则取全局的

对于props、methods、inject、computed,则是创建第三方对象ret,像将父的上述属性赋值给ret,再将子的上述属性赋值给ret,所以子的后替换父的

对于data,data内部进行递归合并,在和组件的数据发生冲突时以组件数据优先

生命周期和原理

beforeCreate 在实例初始化之后,数据观测(data observer) 和 event/watcher 事件配置之前被调用。

created 实例已经创建完成之后被调用。在这一步,实例已完成以下的配置:数据观测(data observer),属性和方法的运算, watch/event 事件回调。这里没有$el

beforeMount 在挂载开始之前被调用:相关的 render 函数首次被调用。

mounted el 被新创建的 vm.$el 替换,并挂载到实例上去之后调用该钩子。

beforeUpdate 数据更新时调用,发生在虚拟 DOM 重新渲染和打补丁之前。

updated 由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。

beforeDestroy 实例销毁之前调用。在这一步,实例仍然完全可用。

destroyed Vue 实例销毁后调用。调用后,Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。 该钩子在服务器端渲染期间不被调用。

原理

Vue的生命周期钩子就是回调函数(callhook)而已,当创建组件实例的过程中会调用对应的钩子方法

Vue在内部会把用户的选项进行合并处理, mergeOptions针对不同的字段进行合并。其中就包含生命周期hook合并,内部是把合并的同名钩子合并成一个数组,最后在初始化的时候把数组里面每个钩子依次执行

v-if和v-show的区别

v-if控制的是元素是否渲染,会被编译成三元表达式。

而v-show控制的是样式,会被编译成指令,内部首先先把元素原始的display保存下来,然后隐藏的话赋值为display为none,显示的时候赋值为原来的值

为什么v-for 和 v-if不能连用

v-for的优先级比较高,会被编译成_l,而v-if会被编译成三元表达式。因为v-for的优先级比较高,如果联用的话相当于每条数据都进行一次三元表达式运算,浪费性能

组件的data为啥要返回一个函数

每个组件内部实际上都是通过Vue.extend来创建一个子类,而extend方法会把组件的options放倒Sub类上,如果说用对象的话,那相当于全部组件都公用一个Sub类上的data,引用类型就会互相影响

function Vue() {} 
function Sub() { 
    // 会将data存起来 this.data = this.constructor.options.data; 
} 
Vue.extend = function(options) { Sub.options = options; return Sub; } 

let Child = Vue.extend({ data: { name: 'chen' } }); // 两个组件就是两个实例, 希望数据互不干扰 
let child1 = new Child(); 
let child2 = new Child(); 

console.log(child1.data.name); 
child1.data.name = 'ying'; 
console.log(child2.data.name);

Vue.$set原理

针对对象采用的是defineReactive,将对象新增的属性变成响应式的。因为有可能对象本身有dep属性,所以需要触发对象本身存储的watcher来实现重新渲染的逻辑。

针对数组 直接调用splice方法即可实现数据变化更新视图

Vue-router原理和核心实现API

Vue-route里面我们最常用的就是hash模式和history模式,还有一个abstract这个我认识就比较少了

hash模式的原理是利用location.hash 来实现的,location.hash 可以获取到url后面 #/xxx的值,我们可以通过location.hash.slice(1)获取#后面的路径,然后赋值给window.location.hash就可以跳转啦,但是我们

还需要监听hashchange的方式来监听hash的变化,来实时改变window.location.hash的值

history模式的原理是通过HTML5提供的history api,我们可以使用window.location.pathname获取当前路由的路径,然后通过history.pushState({}, null, path)的方式可以进行跳转,但是history api是不支持浏览器的前进和后退的,所以我们需要监听popstate,来实现浏览器的前进后退

其中Vue.use(router)的时候大致做了这些,将传进来的routes规则扁平化成路由映射表,然后通过Vue.mixin在beforeCreate将router的实例共享给全部组件,并且代理成routerrouter和route,然后注册两个组件link和view,最后开启路由监听啥的。而我们经常用的 添加动态路由的方法addRoute和addRoutes实际上就是往上面说的路由映射表添加数据

Vue中路由懒加载原理

和webpack懒加载是一样的,import代码切割 + JSONP引入

Vue为什么需要虚拟DOM

虚拟DOM就是为了diff算法做准备的,因为没有虚拟DOM的话,实际上进行的是两个字符串模板的比对,就很麻烦。

再者直接操作DOM是比较浪费性能的,因为DOM上的属性很多,而操作虚拟DOM相当于对象间的比对,相比来说所消耗内存的性能就少很多了

而且虚拟DOM具有跨平台的能力

Vue中key的作用和原理,谈谈你对它的理解

Vue在patch过程中通过key可以判断两个虚拟节点是否是相同节点。 (可以复用老节点)

无key会导致更新的时候出问题

尽量不要采用索引作为key,会出现问题,如数组最前面插入一项,会出现意想不到的问题

Q:对MVVM的理解

MVVM分别是model、viewModel、view 目的是为了实现分层,借助了后端MVC的思想,但是发现后端把全部视图更新,数据处理逻辑都丢在constructor层,逻辑就臃肿。

所以MVVM是隐藏constructor这层,直接将数据映射到视图上,并且自动监控视图的变化,视图变化之后更新数据

VUE3基础知识

VUE3代理那块为啥性能比VUE2高

首先Vue3采用proxy,Vue2采用defineProperty进行代理。而proxy天生支持代理数组等,而defineProperty需要重写数组的方法实现代码,还不可以支持索引和length变化的劫持。

再者Vue2里面是一上来就递归data的全部属性通过defineProperty进行代理。而Vue3只是通过reactive进行手动代理,就是说哪些数据需要响应式的能力由业务方决定,节省了性能。并且Vue3-proxy是懒递归如:

const proxy = reactive({
    address: {
        num: 1
    }
})

proxy只是代理到address这一层,后续如果代码有对num的取值如proxy.address.num,这个时候会再代理一次

Vue3依赖收集和派发更新原理

假设现在有这样一个代码,然后我们逐渐分析下代码是怎么依赖收集和派发更新的。

import { reactive, effect } from 'vue'
const obj = { name: 'cy' }
const proxy = reactive(obj)

effect(() => {
   console.log(proxy.name)
})

settimeout(() => {
    proxy.name = "666"
}, 1000)

我们会发现一开始输出cy,然后1s之后输出666,那具体代码怎么走的呢?

首先reactive会把传入的对象通过proxy进行代码,赋予了getter和setter的能力。

然后effect(fn)会将fn包装成一个effect,然后执行effect,这个时候会触发proxy.name的getter,然后proxy.name的get回调里面会调用track方法,在全局里面有一个targetMap,通过全局的targetMap存储proxy.name对应的effect.如下图:

vue344.png

这个时候依赖收集就完成了,其实vue3依赖收集收集的是effect(fn)内部把fn封装成的fn。

然后1s之后触发proxy.name的get回调,会调用trigger方法,从全局的targetMap里面找到proxy.name对应得effect遍历执行,也就是console.log(proxy.name)会再次执行,就是派发更新得逻辑。

effect里面嵌套调用effect,内部保证属性收集正确得effect?

effect(() => {
    proxy.name = 111
    effect(() => {
        proxy.name = 222
    })
    proxy.name = 333
})

其实effect内部是实现一个栈结构,嵌套调用会把effect进栈,执行完出栈,然后收集的是当前栈顶得effect

ref原理

我们知道reactive是对传入的对象进行代理,赋予响应式的能力。而ref(x)是让x具有响应式的能力,无论x是普通类型还是引用类型。

let isShow = ref(true)
effect(() => {
    console.log(isShow.value)
})
isShow.value = false

上述会输出true和false,也就是让一个布尔类型的值具有响应式的能力。

ref内部原理是生成一个类RefImpl

RefImpl内部会先判断传递进来的是原始类型还是对象类型,如果是对象类型则调用reactive进行代理。

如果是原始类型则手动调用track和trriger实现响应式,具体步骤如下:

将val赋值给this._value

RefImpl类内部有对value的get和set

class RefImpl {
    get value() {
        // 调用track进行依赖收集
        track()
        return this._value
    }
    set value(newVal) {
        // 调用trriger进行派发更新
        trriger()
        this._value = newVal
    }
}

get value的时候调用track收集依赖

set value的时候调用trriger派发更新

Vue3 toRef、torefs的原理

内部其实就是一个类,有get value方法,实际上调用get value方法是做了层代理,取得是state.age,然后触发age的getter从而触发track收集依赖啥的

Vue3computed原理

const proxy = reactive({
  age: 25
})
let newAge = computed(() => {
  return proxy.age * 2
})
effect(() => {
  console.log(newAge.value)
})
proxy.age = 30

首先computed执行会创建一个computed effect,和其他effect不一样的是它传入参数有lazy和scheduler,并且不会立即执行

const runner = effect(getter, {lazy:true, scheduler: () => {dirty: true, trigger()}})

然后会返回一个计算属性类,类上有get value 和 set value

{
    __v_isRef: true,
    get value() {
        if (dirty) {
            value = runner(); // 取值时运行effect
            dirty = false;
        }
        track(computed, "get", 'value');
        return value;
    },
    set value(newValue) {
        setter(newValue)
    }
}

然后后续如果调用返回类的get value,则执行effect的getter,触发了proxy.age的getter(track),这个时候age收集了这个计算属性effect,然后计算属性类又收集了外层的effect。

也就是age收集computed effect, 而计算属性类又收集了外层的effect

然后修改age的值的时候,调用trriger方法,computed effect会执行,但是因为传入了scheduler,所以执行的是scheduler函数而不是getter,然后将dirty置为true,表示值发生改变了(下次在调用计算属性类get value的时候会重新执行computed effect的getter,重新获取最新值),然后触发计算属性类自身收集的effect,就是外层的effect会执行

至于vue3如何实现计算属性的缓存,上述有提到一丢丢,这里总结一下,内部通过dirty变量来控制,一开始dirty为true。第一次获取计算属性的值的时候,因为dirty为true,所以调用computed effect的getter获取最新值,然后将dirty置为false。后续如果dirty一直为false不管调用多少次get value,都一直返回上一次的值。当age发生改变的时候触发trigger,也就是执行到computed的scheduler,这个时候会将dirty置为true,表示依赖的值发生改变,计算属性需要重新计算,这个时候再调用计算属性get value的时候,就重新执行effect getter获取最新值返回