Vue源码面试总结

new Vue 阶段做了什么

回答

首先执行 vue.prototype._init,这个方法在 initMixin 中被绑定到 vue 原型上。在 _init 方法中,vue 实例首先会进行配置合并,然后进行一系列的初始化,包括 initLifeCycle,initEvents,initRender,然后执行 beforeCreate 生命周期钩子,之后再进行数据的初始化,包括 initInjections,initState,initProvide,再调用 created 钩子,这也就是为什么无法在 beforeCreate 时还没有数据响应式的原因。之后就进入挂载阶段,如果有 el ,就自动调用 $mount 进入挂载阶段,否则需要手动挂载。(见 2-Vue初始化 )

挂载时,如果是运行时的 Vue 包,会通过打包器结合 vue-loader 进行预编译,将模板编译成 render 函数,所以它是可以直接去挂载的,因为它有 render 函数。而如果是全量包,并且组件配置项上没有 render, 那么就会先进入编译阶段,编译阶段只做一件事,就是编译得到 render 函数,并将其设置到 this.$options 上。核心函数叫 baseCompile,里面调用了三个函数,parse/optimize/generate,parse 将 html 模板解析成 ast,optimize 对 ast 进行静态标记,主要是用来优化 patch 或者说是优化 diff 算法的,因为静态的节点不会改变,没有必要走一遍对比新旧节点。generate 通过 ast 生成渲染函数。组件的配置对象上有 render 函数了,就可以真正进入挂载阶段了,挂载阶段会调用 mountComponent 函数,这个函数实例化了渲染 watcher,定义了一个 updateComponent 函数并传给了渲染 watcher,渲染 watcher 中执行 get 触发了 updateComponent 函数,而 updateComponent 这个方法它会调用 vm._update ,而且第一个参数就会执行 _render,_render 中会执行 createElement,生成 vnode,所以 vnode 就有了,而且作为参数传给了 _update,这个函数就是用来渲染页面的,它会调用 patch 函数,实现页面的首次渲染或者页面的更新。在 patch 函数中,注意如果有子组件的话,就需要再走一边 new Vue 的流程,生成子组件。而有子节点的话,就要递归的调用 createElm 和 createChildren,去创建节点。

1
2
3
4
const updateComponent = () => {
// 执行 vm._render() 函数,得到 VNode,并将 VNode 传递给 _update 方法,接下来就该到 patch 阶段了
vm._update(vm._render(), hydrating)
}

流程图

普通 DOM 元素渲染到页面

patch.png

组件相比普通 DOM 元素不一样的两步

  1. 执行 render,render 执行 createElement,根据传入的 tag 参数辨别,是普通 DOM 元素还是组件,调用不同的方法。
  • 普通 DOM 元素:tag是html的保留标签,如tag: 'div'
  • 组件:tag是以vue-component开头,如tag: 'vue-component-1-App'

组件VNode.png

2.如果是子组件的话,需要 new 子组件,再重新走一遍整个过程

patch组件.png

简单版流程图

组件化简化.png

响应式原理

回答

在 new Vue 时,vue 初始化的时候会调用 initState,在 initState 中会依次调用,initProps、initMethods、initData、initComputed、initWatch,这个顺序是固定的,因为存在优先级,一些属性是不能重复的,后面的函数会做判断,来避免重复的情况。

在具体的 init 函数中,我主要说一下 data 吧,首先 data 会做一个判重,避免和 props 以及 methods 的属性重复。然后会调用定义好的 proxy 函数将 data 中的数据代理到 vue 实例上,以便于通过 this.key 去访问。最后调用 observe 函数,传入data,给数据设置响应式。observe 函数就是数据响应式的入口,在 observe 函数里面会 new 一个 Observer,Observer 是一个观察者类,在构造函数中,会判断传进来的是数组还是对象。

如果是对象的话,会调用 walk 函数,遍历所有键,调用 defineReactive,在 defineReactive 中,首先会做一个递归处理,因为响应式是深层的嘛,不过不是递归调用自身 (defineReactive)而是调用 observe,传入值,再 new Observer,再调用 defineReactive,可以说是三个函数的循环递归。defineReactive 递归后,通过 object.defineProperty 设置 getter 和 setter,这也是真正响应式的核心,getter 中 会通过 dep.depend 做依赖收集,dep 添加到 watcher 中,watcher 也添加到 dep 中,dep 实际上就是 object.key。setter 中首先做一些处理,判断新值和老值是不是一样,判断属性是不是只读,处理完毕才会设置新值,然后对新值调用 observe,让新值也是响应式的,然后调用 dep.notify 通知依赖更新。

如果是数组的话,首先会改写数组原型上的方法,因为这些方法都可以改变数组。然后会调用 observeArray 函数,对数组的每一项调用 observe,总归是又回到了 observe,后面的流程其实和对象差不多。对于数组原型方法的改写其实也比较简单,再执行原生的方法之后,需要判断该方法,是不是 push/unshift/splice,如果是的话,就代表插入了新元素,需要调用 observeArray 对新元素做响应式处理,然后notify 通知更新。

依赖收集

1.三个核心对象 Observer、Dep、Watcher

依赖收集三大OBject.png

2.依赖收集准备阶段——Observer、Dep的实例化

依赖收集-新.png

依赖收集触发阶段——Wather实例化、访问数据、触发依赖收集

依赖收集触发阶段.png

整体流程

依赖收集宏观.png

派发更新

1.对象属性修改触发set,派发更新。this.msg = 'new val'

obj派发更新.png

2.数组调用方法。this.arr.push(4)

数组派发更新.png

computed 和 watcher

https://juejin.cn/post/7074422512318152718#heading-8

nextTick 的原理

首先讲一下流程:

1.nextTick 会将传入的函数做一个包装然后放到 callbacks 数组里,这个包装主要是针对没传入回调以及回调错误捕获。

2.如果当前没有其它 nextTick 在执行的话,就会调用 timerFunc 函数,会根据环境,做一个兼容性判断,选择某一种异步执行的方法调用 flushCallbacks 函数。优先级是 promise.then > object.observe > setImmediate > setTimeout。(因为 timerFunc 是异步的,同时只能执行一个)

3.flushCallbacks 遍历 callbacks 执行其中每一个回调。这里注意,源码里对 callbacks 做了一个拷贝,因为执行回调的过程中,可能会触发新的 nextTick,会将新的回调 push 入 callbacks,这样可能就会一直循环下去。因此nextTick 回调中的 nextTick 应该放在下一轮执行。

然后注意,并不是只有手动调用 vm.$nextTick 时才会触发 nextTick。实际上 vue 采用的是异步更新策略,也就是说当监听到数据变化的时候不会立即更新 DOM。更新后会调用 setter 里的 dep.notify(),dep.notify() 会遍历 dep 中的 watcher,执行 watcher.update()。在 update 中,会判断 watcher.sync,如果是 true,就调用 run 函数,直接同步更新,否则走异步更新,调用 queueWatcher,将 watcher 入队,然后判断队内是否有刷新函数,如果没有的话,就是用 nextTick 将 watcher 的更新函数放入 callbacks 数组里。然后就是 nextTick 的流程了。

流程图

nextTick

tick——pending.png

Vue 异步更新

改变一个属性值会发生什么

obj派发更新.png

异步更新流程

异步更新.png

patch / diff 算法

diff 算法只是一种寻找差异的算法,不同框架有不同实现。

Vue2 使用的是修改版的 snabbdom 算法,

首先,patch 是在 _update 方法中被调用的,它有三个作用,首次渲染、页面更新、销毁组件。会将 新老 VNode 传给 patch,进行一些判断,如果新 VNode 不存在,老 VNode 存在,那么就销毁老节点。如果新 VNode 存在 而 老 VNode 不存在,那就是首次渲染,创建 DOM 树。如果老 VNode 存在,新 VNode 也存在,那么就进入更新阶段,执行 patchVNode,首先全量更新所有的属性,然后会进行一些判断,新 VNode 有没有 text,新 VNode 有没有孩子,老 VNode 有没有孩子,不同情况处理方式不一样,比较核心的是新老节点都有孩子的情况,要执行 updateChildren,在这个函数中基于前端一般不会完全打乱节点顺序的情况做了一些小优化,会有首尾双指针做双端比较,比较首首、尾尾、首尾、尾首是不是相同节点,通过 sameVNode 函数,在其中会进行判断,包括但不仅限于 key,还有 tag 、包括 input 标签 的 type 属性是不是一样等等。如果命中的话,就可以节省一次遍历的开销,如果没有命中,就建一个哈希表,将老孩子首指针和尾指针之间的元素的 key 和 index 存到这个哈希表里 ,然后就可以通过这个哈希表来找相同节点,找到相同节点再递归 patchVNode。

patch 或者说 diff 算法的流程就是这样,其中核心就在两点,一个就是 sameVNode 用于节点复用,一个就是 updateChildren,因为一棵组件树有大量的子元素,所以 updateChildren 这个方法是会被反复调用的,那这个双端比较的优化其实是非常关键的。此外,key 也很关键,它是判断节点是否能复用的核心标识。

Vue3 使用 inferno 算法代替了 snabbdom,我没有具体去读过源码,但是知道它是基于最长上升子序列的思想,所以它的复杂度是 O(nlogn) 的,低于 snabbdom 的 O(n),但是它可以节省不少 DOM 移动操作,因为在算法中会去考虑怎么移动才能使操作次数最少。

inferno 的例子

abc
cab
其实我们发现新的vdom的index数组是[2,0,1],
我们求出递增子序列,发现我们只需要移动2这个index就行。

patchVNode

img

updateChildren

img

sameVNode

sameVnode 函数中包含了两个节点属于相同类型的条件,这是节点能够复用的门槛。Vue2 将 key 作为判断的标识之一,这也是为什么我们说 v-for 一定也写上 key。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Vue2 中 sameVnode 源码
function sameVnode (a, b) {
return (
a.key === b.key && // key 值的判断
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag && // 标签名的判断
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // input 标签 type 的判断
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}

参考

vue 响应式原理

https://juejin.cn/post/7074422512318152718

nextTick

https://juejin.cn/post/7077181211029798942

patch/diff

https://juejin.cn/post/7143172056211783688

Inferno

https://www.proyy.com/6960514069065531405.html

不同 diff 算法

https://www.wangt.cc/2021/03/%E5%87%A0%E7%A7%8Ddiff%E7%AE%97%E6%B3%95/


Vue源码面试总结
http://example.com/2022/10/27/Vue源码面试总结/
Author
John Doe
Posted on
October 27, 2022
Licensed under