12道vue高频原理面试题,你能答出几道?

来源:http://www.chinese-glasses.com 作者:Web前端 人气:91 发布时间:2020-03-14
摘要:时间: 2019-11-25阅读: 69标签: 面试前言 作为MVVM框架的一种,Vue最为人津津乐道的当是数据与视图的绑定,将直接操作DOM节点变为修改 data 数据,利用 Virtual Dom 来 Diff 对比新旧视图,从而

时间: 2019-11-25阅读: 69标签: 面试前言

作为MVVM框架的一种,Vue最为人津津乐道的当是数据与视图的绑定,将直接操作DOM节点变为修改 data 数据,利用 Virtual DomDiff 对比新旧视图,从而实现更新。不仅如此,还可以通过 Vue.prototype.$watch 来监听 data 的变化并执行回调函数,实现自定义的逻辑。虽然日常的编码运用已经驾轻就熟,但未曾去深究技术背后的实现原理。作为一个好学的程序员,知其然更要知其所以然,本文将从源码的角度来对Vue响应式数据中的观察者模式进行简析。

本文分享 12 道 vue 高频原理面试题,覆盖了 vue 核心实现原理,其实一个框架的实现原理一篇文章是不可能说完的,希望通过这 12 道问题,让读者对自己的 Vue 掌握程度有一定的认识(B 数),从而弥补自己的不足,更好的掌握 Vue

初始化 Vue 实例

  1. Vue 响应式原理核心实现类:

在阅读源码时,因为文件繁多,引用复杂往往使我们不容易抓住重点,这里我们需要找到一个入口文件,从 Vue 构造函数开始,抛开其他无关因素,一步步理解响应式数据的实现原理。首先我们找到 Vue 构造函数:

Observer : 它的作用是给对象的属性添加 getter 和 setter,用于依赖收集和派发更新

// src/core/instance/index.jsfunction Vue  { if (process.env.NODE_ENV !== 'production' && ! ) { warn('Vue is a constructor and should be called with the `new` keyword') } this._init}

// src/core/instance/init.jsVue.prototype._init = function  { ... // a flag to avoid this being observed vm._isVue = true // merge options // 初始化vm实例的$options if (options && options._isComponent) { initInternalComponent } else { vm.$options = mergeOptions( resolveConstructorOptions, options || {}, vm ) } ... initLifecycle // 梳理实例的parent、root、children和refs,并初始化一些与生命周期相关的实例属性 initEvents // 初始化实例的listeners initRender // 初始化插槽,绑定createElement函数的vm实例 callHook initInjections // resolve injections before data/props initState // resolve provide after data/props callHook if  { vm.$mount // 挂载组件到节点 }}

Dep : 用于收集当前响应式对象的依赖关系,每个响应式对象包括子对象都拥有一个 Dep 实例(里面 subs 是 Watcher 实例数组),当数据有变更时,会通过 dep.notify()通知各个 watcher。

为了方便阅读,我们去除了 flow 类型检查和部分无关代码。可以看到,在实例化Vue组件时,会调用 Vue.prototype._init ,而在方法内部,数据的初始化操作主要在 initState (这里的 initInjectionsinitProvideinitProps 类似,在理解了 initState 原理后自然明白),因此我们重点来关注 initState

Watcher : 观察者对象 , 实例分为渲染 watcher (render watcher),计算属性 watcher (computed watcher),侦听器 watcher(user watcher)三种

// src/core/instance/state.jsexport function initState  { vm._watchers = [] const opts = vm.$options if  initProps if  initMethods if  { initData } else { observe(vm._data = {}, true /* asRootData */) } if  initComputed if (opts.watch && opts.watch !== nativeWatch) { initWatch }}

Watcher 和 Dep 的关系

首先初始化了一个 _watchers 数组,用来存放 watcher ,之后根据实例的 vm.$options ,相继调用 initPropsinitMethodsinitDatainitComputedinitWatch 方法。

watcher 中实例化了 dep 并向 dep.subs 中添加了订阅者,dep 通过 notify 遍历了 dep.subs 通知每个 watcher 更新。

initProps

依赖收集initState 时,对 computed 属性初始化时,触发 computed watcher 依赖收集initState 时,对侦听属性初始化时,触发 user watcher 依赖收集render()的过程,触发 render watcher 依赖收集re-render 时,vm.render()再次执行,会移除所有 subs 中的 watcer 的订阅,重新赋值。派发更新组件中对响应的数据进行了修改,触发 setter 的逻辑调用 dep.notify()遍历所有的 subs(Watcher 实例),调用每一个 watcher 的 update 方法。原理

function initProps  { const propsData = vm.$options.propsData || {} const props = vm._props = {} // cache prop keys so that future props updates can iterate using Array // instead of dynamic object key enumeration. const keys = vm.$options._propKeys = [] const isRoot = !vm.$parent // root instance props should be converted if  { toggleObserving } for (const key in propsOptions) { keys.push const value = validateProp(key, propsOptions, propsData, vm) ... defineReactive if  { proxy } } toggleObserving}

当创建 Vue 实例时,vue 会遍历 data 选项的属性,利用 Object.defineProperty 为属性添加 getter 和 setter 对数据的读取进行劫持(getter 用来依赖收集,setter 用来派发更新),并且在内部追踪依赖,在属性被访问和修改时通知变化。

在这里, vm.$options.propsData 是通过父组件传给子组件实例的数据对象,如 ` 中的{item: false},然后初始化vm._propsvm.$options._propKeys分别用来保存实例的props数据和keys,因为子组件中使用的是通过proxy引用的_props里的数据,而不是父组件传递的propsData,所以这里缓存了_propKeys,用来updateChildComponent时能更新vm._props。接着根据isRoot是否是根组件来判断是否需要调用toggleObserving,这是一个全局的开关,来控制是否需要给对象添加ob属性。这个相信大家都不陌生,一般的组件的data等数据都包含这个属性,这里先不深究,等之后和defineReactive时一起讲解。因为props是通过父传给子的数据,在父元素initState时已经把ob添加上了,所以在不是实例化根组件时关闭了这个全局开关,待调用结束前在通过toggleObserving` 开启。

每个组件实例会有相应的 watcher 实例,会在组件渲染的过程中记录依赖的所有数据属性(进行依赖收集,还有 computed watcher,user watcher 实例),之后依赖项被改动时,setter 方法会通知依赖与此 data 的 watcher 实例重新计算(派发更新),从而使它关联的组件重新渲染。

之后是一个 for 循环,根据组件中定义的 propsOptions 对象来设置 vm._props ,这里的 propsOptions 就是我们常写的

一句话总结:

export default { ... props: { item: { type: Object, default:  } }}

const value = validateProp(key, propsOptions, propsData, vm)

vue.js 采用数据劫持结合发布-订阅模式,通过 Object.defineproperty 来劫持各个属性的 setter,getter,在数据变动时发布消息给订阅者,触发响应的监听回调

validateProp 方法主要是校验数据是否符合我们定义的 type ,以及在 propsData 里未找到 key 时,获取默认值并在对象上定义 __ob__ ,最后返回相应的值,在这里不做展开。

  1. computed 的实现原理

这里我们先跳过 defineReactive ,看最后

computed 本质是一个惰性求值的观察者。

if  { proxy}

computed 内部实现了一个惰性的 watcher,也就是 computed watcher,computed watcher 不会立刻求值,同时持有一个 dep 实例。

其中 proxy 方法:

其内部通过 this.dirty 属性标记计算属性是否需要重新求值。

function proxy (target, sourceKey, key) { sharedPropertyDefinition.get = function proxyGetter () { return this[sourceKey][key] } sharedPropertyDefinition.set = function proxySetter  { this[sourceKey][key] = val } Object.defineProperty(target, key, sharedPropertyDefinition)}

当 computed 的依赖状态发生改变时,就会通知这个惰性的 watcher,

vm 不存在 key 属性时,通过 Object.defineProperty 使得我们能通过 vm[key] 访问到 vm._props[key]

computed watcher 通过 this.dep.subs.length 判断有没有订阅者,

defineReactive

有的话,会重新计算,然后对比新旧值,如果变化了,会重新渲染。 (Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变化时才会触发渲染 watcher 重新渲染,本质上是一种优化。)

initProps 中,我们了解到其首先根据用户定义的 vm.$options.props 对象,通过对父组件设置的传值对象 vm.$options.propsData 进行数据校验,返回有效值并保存到 vm._props ,同时保存相应的 keyvm.$options._propKeys 以便进行子组件的 props 数据更新,最后利用 getter/setter 存取器属性,将 vm[key] 指向对 vm._props[key] 的操作。但其中跳过了最重要的 defineReactive ,现在我们将通过阅读 defineReactive 源码,了解响应式数据背后的实现原理。

没有的话,仅仅把 this.dirty = true。 (当计算属性依赖于其他数据时,属性并不会立即重新计算,只有之后其他地方需要读取属性的时候,它才会真正计算,即具备 lazy(懒计算)特性。)

// src/core/observer/index.jsexport function defineReactive ( obj, key, val, customSetter, shallow) { const dep = new Dep() const property = Object.getOwnPropertyDescriptor if (property && property.configurable === false) { return } // cater for pre-defined getter/setters const getter = property && property.get const setter = property && property.set if  && arguments.length === 2) { val = obj[key] } let childOb = !shallow && observe ...}
  1. computed 和 watch 有什么区别及运用场景?区别

首先 const dep = new Dep() 实例化了一个 dep ,在这里利用闭包来定义一个依赖项,用以与特定的 key 相对应。因为其通过 Object.defineProperty 重写 target[key]getter/setter 来实现数据的响应式,因此需要先判断对象 keyconfigurable 属性。接着

computed 计算属性 : 依赖其它属性值,并且 computed 的值有缓存,只有它依赖的属性值发生改变,下一次获取 computed 的值时才会重新计算 computed 的值。

if  && arguments.length === 2) { val = obj[key]}

watch 侦听器 : 更多的是「观察」的作用,无缓存性,类似于某些数据的监听回调,每当监听的数据变化时都会执行回调进行后续操作。

arguments.length === 2 意味着调用 defineReactive 时未传递 val 值,此时 valundefined ,而 !getter || setter 判断条件则表示如果在 property 存在 getter 且不存在 setter 的情况下,不会获取 key 的数据对象,此时 valundefined ,之后调用 observe 时将不对其进行深度观察。正如之后的 setter 访问器中的:

运用场景

if  return

运用场景:

此时数据将是只读状态,既然是只读状态,则不存在数据修改问题,继而无须深度观察数据以便在数据变化时调用观察者注册的方法。

当我们需要进行数值计算,并且依赖于其它数据时,应该使用 computed,因为可以利用 computed 的缓存特性,避免每次获取值时,都要重新计算。

Observe

当我们需要在数据变化时执行异步或开销较大的操作时,应该使用 watch,使用 watch 选项允许我们执行异步操作 ( 访问一个 API ),限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态。这些都是计算属性无法做到的。

defineReactive 里,我们先获取了 target[key]descriptor ,并缓存了对应的 gettersetter ,之后根据判断选择是否获取 target[key] 对应的 val ,接着是

  1. 为什么在 Vue3.0 采用了 Proxy,抛弃了 Object.defineProperty?Object.defineProperty 本身有一定的监控到数组下标变化的能力,但是在 Vue 中,从性能/体验的性价比考虑,尤大大就弃用了这个特性(Vue 为什么不能检测数组变动)。为了解决这个问题,经过 vue 内部处理后可以使用以下几种方法来监听数组

    push();pop();shift();unshift();splice();sort();reverse();

let childOb = !shallow && observe

由于只针对了以上 7 种方法进行了 hack 处理,所以其他数组的属性也是检测不到的,还是具有一定的局限性。

根据 shallow 标志来确定是否调用 observe ,我们来看下 observe 函数:

Object.defineProperty 只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。Vue 2.x 里,是通过 递归 + 遍历 data 对象来实现对数据的监控的,如果属性值也是对象那么需要深度遍历,显然如果能劫持一个完整的对象是才是更好的选择。

// src/core/observer/index.jsexport function observe  { if  || value instanceof VNode) { return } let ob if (hasOwn && value.__ob__ instanceof Observer) { ob = value.__ob__ } else if ( shouldObserve && !isServerRendering() &&  || isPlainObject && Object.isExtensible && !value._isVue ) { ob = new Observer } if  { ob.vmCount++ } return ob}

Proxy 可以劫持整个对象,并返回一个新的对象。Proxy 不仅可以代理对象,还可以代理数组。还可以代理动态增加的属性。

首先判断需要观察的数据是否为对象以便通过 Object.defineProperty 定义 __ob__ 属性,同时需要 value 不属于 VNode 的实例( VNode 实例通过 Diff 补丁算法来实现实例对比并更新)。接着判断 value 是否已有 __ob__ ,如果没有则进行后续判断:

  1. Vue 中的 key 到底有什么用?

shouldObserve:全局开关标志,通过toggleObserving来修改。 !isServerRendering():判断是否服务端渲染。 || isPlainObject:数组和纯对象时才允许添加__ob__进行观察。 Object.isExtensible:判断value是否可扩展。 !value._isVue:避免Vue实例被观察

key 是给每一个 vnode 的唯一 id,依靠 key,我们的 diff 操作可以更准确、更快速 (对于简单列表页渲染来说 diff 节点也更快,但会产生一些隐藏的副作用,比如可能不会产生过渡效果,或者在某些节点有绑定数据(表单)状态,会出现状态错位。)

满足以上五个条件时,才会调用 ob = new Observer ,接下来我们要看下 Observer 类里做了哪些工作

diff 算法的过程中,先会进行新旧节点的首尾交叉对比,当无法匹配的时候会用新节点的 key 与旧节点进行比对,从而找到相应旧节点.

// src/core/observer/index.jsexport class Observer { constructor  { this.value = value this.dep = new Dep() this.vmCount = 0 def(value, '__ob__', this) if  { if  { protoAugment } else { copyAugment(value, arrayMethods, arrayKeys) } this.observeArray } else { this.walk } } /** * Walk through all properties and convert them into * getter/setters. This method should only be called when * value type is Object. */ walk  { const keys = Object.keys for (let i = 0; i < keys.length; i++) { defineReactive } } /** * Observe a list of Array items. */ observeArray  { for (let i = 0, l = items.length; i < l; i++) { observe } }}

更准确 : 因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确,如果不加 key,会导致之前节点的状态被保留下来,会产生一系列的 bug。

构造函数里初始化了 value 、 dep 和 vmCount 三个属性,为 this.value 添加 __ob__ 对象并指向自己,即 value.__ob__.value === value ,这样就可以通过 value 或 __ob__ 对象取到 dep 和 value 。 vmCount 的作用主要是用来区分是否为 Vue 实例的根 data , dep 的作用这里先不介绍,待与 getter/setter 里的 dep 一起解释。

更快速 : key 的唯一性可以被 Map 数据结构充分利用,相比于遍历查找的时间复杂度 O(n),Map 的时间复杂度仅仅为 O(1),源码如下:

接着根据 value 是数组还是纯对象来分别调用相应的方法,对 value 进行递归操作。当 value 为纯对象时,调用 walk 方法,递归调用 defineReactive 。当 value 是数组类型时,首先判断是否有 __proto__ ,有就使用 __proto__ 实现原型链继承,否则用 Object.defineProperty 实现拷贝继承。其中继承的基类 arrayMethods 来自 src/core/observer/array.js :

function createKeyToOldIdx(children, beginIdx, endIdx) { let i, key; const map = {}; for (i = beginIdx; i = endIdx; ++i) { key = children[i].key; if (isDef(key)) map[key] = i; } return map;}
// src/core/observer/array.jsconst arrayProto = Array.prototypeexport const arrayMethods = Object.createconst methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']methodsToPatch.forEach { // cache original method const original = arrayProto[method] def(arrayMethods, method, function mutator  { const result = original.apply const ob = this.__ob__ let inserted switch  { case 'push': case 'unshift': inserted = args break case 'splice': inserted = args.slice break } if  ob.observeArray // notify change ob.dep.notify
  1. 谈一谈 nextTick 的原理JS 运行机制

这里为什么要对数组的实例方法进行重写呢?代码里的 methodsToPatch 这些方法并不会返回新的数组,导致无法触发 setter ,因而不会调用观察者的方法。所以重写了这些变异方法,使得在调用的时候,利用 observeArray 对新插入的数组元素添加 __ob__ ,并能够通过 ob.dep.notify 手动通知对应的被观察者执行注册的方法,实现数组元素的响应式。

JS 执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤:

if  { ob.vmCount++}

所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。主线程不断重复上面的第三步。

最后添加这个 if 判断,在 Vue 实例的根 data 对象上,执行 ob.vmCount++ ,这里主要为了后面根据 ob.vmCount 来区分是否为根数据,从而在其上执行 Vue.set 和 Vue.delete 。

主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro task 和 micro task,并且每个 macro task 结束后,都要清空所有的 micro task。

getter/setter

for (macroTask of macroTaskQueue) { // 1. Handle current MACRO-TASK handleMacroTask(); // 2. Handle all MICRO-TASK for (microTask of microTaskQueue) { handleMicroTask(microTask); }}

在对 val 进行递归操作后,将 obj[key] 的数据对象封装成了一个被观察者,使得能够被观察者观察,并在需要的时候调用观察者的方法。这里通过 Object.defineProperty 重写了 obj[key] 的访问器属性,对 getter/setter 操作做了拦截处理, defineReactive 剩余的代码具体如下:

在浏览器环境中 :

...Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { const value = getter ? getter.call : val if  { dep.depend { childOb.dep.depend() if  { dependArray } } } return value }, set: function reactiveSetter  { ... childOb = !shallow && observe }})

常见的 macro task 有setTimeout、MessageChannel、postMessage、setImmediate

首先在 getter 调用时,判断 Dep.target 是否存在,若存在则调用 dep.depend 。我们先不深究 Dep.target ,只当它是一个观察者,比如我们常用的某个计算属性,调用 dep.depend 会将 dep 当做计算属性的依赖项存入其依赖列表,并把这个计算属性注册到这个 dep 。这里为什么需要互相引用呢?这是因为一个 target[key] 可以充当多个观察者的依赖项,同时一个观察者可以有多个依赖项,他们之间属于多对多的关系。这样当某个依赖项改变时,我们可以根据 dep 里维护的观察者,调用他们的注册方法。现在我们回过头来看 Dep :

常见的 micro task 有10bet,MutationObsever 和 Promise.then

// src/core/observer/dep.jsexport default class Dep { static target: ?Watcher; id: number; subs: Array; constructor () { this.id = uid++ this.subs = [] } addSub  { this.subs.push } removeSub  { remove } depend  { Dep.target.addDep { // stabilize the subscriber list first const subs = this.subs.slice() ... for (let i = 0, l = subs.length; i < l; i++) { subs[i].update() } }}

异步更新队列

构造函数里,首先添加一个自增的 uid 用以做 dep 实例的唯一性标志,接着初始化一个观察者列表 subs ,并定义了添加观察者方法 addSub 和移除观察者方法 removeSub 。可以看到其在 getter 中调用的 depend 会将当前这个 dep 实例添加到观察者的依赖项,在 setter 里调用的 notify 会执行各个观察者注册的 update 方法, Dep.target.addDep 这个方法将在之后的 Watcher 里进行解释。简单来说就是会在 key 的 getter 触发时进行 dep 依赖收集到 watcher 并将 Dep.target 添加到当前 dep 的观察者列表,这样在 key 的 setter 触发时,能够通过观察者列表,执行观察者的 update 方法。

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。

当然,在 getter 中还有如下几行代码:

如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。

if  { childOb.dep.depend() if  { dependArray }}

然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。

这里可能会有疑惑,既然已经调用了 dep.depend ,为什么还要调用 childOb.dep.depend ?两个 dep 之间又有什么关系呢?

Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

其实这两个 dep 的分工是不同的。对于数据的增、删,利用 childOb.dep.notify 来调用观察者方法,而对于数据的修改,则使用的 dep.notify ,这是因为 setter 访问器无法监听到对象数据的添加和删除。举个例子:

在 vue2.5 的源码中,macrotask 降级的方案依次是:setImmediate、MessageChannel、setTimeout

const data = { arr: [{ value: 1 }],}data.a = 1; // 无法触发setterdata.arr[1] = {value: 2}; // 无法触发setterdata.arr.push; // 无法触发setterdata.arr = [{value: 4}]; // 可以触发setter

本文由10bet发布于Web前端,转载请注明出处:12道vue高频原理面试题,你能答出几道?

关键词:

上一篇:用js屏蔽被http劫持的浮动广告实现方法

下一篇:没有了

最火资讯