助力P7 offer:分享10道 Vue 高频面试题

1,004 阅读8分钟

1. 请说说vue的双向绑定

表象上,双向绑定指的是JS 变量到DOM内容之间的数据映射,也就是变量值更新后会自动反映到DOM节点;而用户在DOM上做的变更操作也会即时反馈到对应变量。

实现上,基于响应式系统实现了从变量到DOM树的绑定,在Vue 2.x 版本是基于 Object.defineProperty 接口实现了对变量属性值get、set过程的拦截,每当 render 函数所依赖的属性被修改时触发 set 逻辑, set 底层的观察者(Watch 对象)进一步触发组件更新回调 —— updateComponent 函数(定义在 src/core/instance/lifeCycle.js),进而重新执行组件 render 、 __patch__ 以及 diff 过程,最终将数值更新到DOM树上。

从DOM到变量的绑定则主要通过 v-model 指令实现,内在逻辑简单很多,可以近似地理解为 v-bind:value="x" 以及 @change="x=value" 逻辑的复合,比如对于 input[type=text] ,绑定 input 的值为组件变量 x ,同时监听 change 事件,在事件回调中修改对应的绑定变量。当然了,考虑到浏览器兼容性、复杂类型(checkbox、radio、select) 取值等问题,源码会比上述过程复杂一些。

2. 计算属性如何收集依赖

表象上,计算属性具备 缓存 功能,仅当它所依赖的其他属性数值变更时才会重新执行计算。

依赖收集的实现集中在 Watcher 与 Dep 两个类中。首先要理解,在Vue中有两种形态的跟踪机制,一是data、props这种简单对象,特点是它们本身就存储了原子值,不会因为其它变量的变化而变化;第二种类型包括computed、render这类复合对象则复杂很多,特点是它们自身并不维护数值,而是执行过程中通过组件其他 data、props、computed、render 以及运行上下文中的其他数值推算而得。

props 比较特殊,虽然在父组件中可能是通过computed计算得出,但在子组件的 initProps 函数中只是调用了 observe 进行观察,处理上与 data 相似。

实现上,对于 简单对象 ,Vue 2.x 会遍历其属性,通过 Object.defineProperty 接口包装get、set行为,同时为该属性绑定一个 Dep 对象;对于 computed、render 这类复合求值的属性,则会绑定一个 Watcher 对象。那么重点来了,计算属性在求值的时候 ( Watcher.run 函数) 会调用 pushTarget 将其绑定的 Watcher 对象设置为全局变量(源码),此后,函数体内一旦调用经过封装的响应式属性,就将触发属性对应的 Dep.prototype.depend 函数(源码)将依赖对象记录到 Watcher 对象的 newDeps 数组,后续这些响应式属性被修改时(源码)再遍历通知依赖于它的 Watcher 对象。

为了更好地帮助理解,建议简单地阅读一下相关实现:

  1. src/core/observe/dep.js
  2. src/core/observe/watcher.js
  3. src/core/observe/index.js

另外,这个问题不要乱猜,我就遇到过有候选人答复是通过正则匹配实现。正则解决这种场景的问题首先非常复杂,其次本质上只是一种词法程度的静态分析手段,要扩展推断语义某种程度上相当于实现一个简化的编译器,性能、复杂度都不可控。 我还遇到过有人答复说是通过AST实现,在执行阶段做AST分析显然对性能会造成不小的负担,也并不是好的方案。

扩展开来,Vue3中依赖收集过程的设计与Vue2相似,细节上有如下区别:

  1. 核心接口变成了 Composition 风格的 track 与 trigger 函数。
  2. 内部维护了一个 WeakMap 对象,用于记录属性到 effect 对象的依赖关系

3. patch 过程

patch 模块继承自 snabbdom 框架,在vue中负责从vnode到DOM节点的转换。首次创建节点逻辑:

  1. 调用 createComponent 函数根据vnode创建组件 (patch.js#L144,这个步骤递归处理子组件)
    1. createComponent 检测 vnode 是否具有 init 钩子(patch.js#L214),没有则跳过下面步骤,返回undefined
    2. 调用 init 钩子(定义于 create-component.js#L37),创建子组件实例
    3. 调用 initComponent ,触发 create 钩子
    4. 将子组件生成的vnode挂载到父节点 (patch.js#L222)
    5. 返回true
  2. createComponent 返回结果为true,则跳过下面步骤,否则:
    1. vnode.tag 不为空时,调用 createChildren ,并将结果插入当前节点
    2. vnode.tag 为空,且 vnode.isComment === true 时,调用 nodeOps.createComment 创建 comment 节点
    3. 上述判断均为false时,调用 nodeOps.createTextNode 创建文本节点

数据变更触发重新渲染时,会在上述逻辑之前执行diff比对,确定那些节点需要重新渲染。

4. key 属性的作用

这个问题出现的频率跟 响应式原理 有的一拼,简单来说是 Vue 做 diff 比对时核心是根据vnode的tag、key两个属性判断两个节点是否相同 (isSameNode 函数),适当的key配置能够有效降低DOM节点重建频率,提高运行性能。

更深入的答案可以扩展开来聊聊diff过程:每当组件 render 函数的依赖项发生变化时,会重新执行 render 生成一颗新的 vnode 树,新树被传入 __patch__ 函数进行diff更新。diff 是一个广度优先的比对过程(patch.js#L404),针对新旧两颗vnode树的同一层级,Vue 首先进行首尾比对,若比对不通过则调用 createKeyToOldIdx 函数收集旧vnode树节点的key值,若新树节点的key值在旧树key列表中则复用旧DOM节点;否则创建新的DOM节点。若旧vnode树节点未正确提供key,则上述过程只能到 首尾比对 时结束。

5. Vue 对数组做了那些特殊处理

大部分特殊处理的代码集中在 src/core/observer/array.js 文件,主要是对数组的突变方法例如 pushsplicesort 进行响应式封装,当变更发生时调用 dep.notify 触发响应式流程。

这是面上的答案,其实底子里还有不少针对数组的特殊处理,比如说 src/core/observer/index.js 文件中, Observer 构造函数遇到数组时需要展开每个数组项调用 observe 函数:

export class Observer {
  constructor (value: any) {
    this.value = value
    ... 
    if (Array.isArray(value)) {
      ...
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * 使用于对象场景
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * 适用于数组场景
   */
  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

响应式属性的 get 过程遇到数组时,也需要展开数组项逐个定义依赖:

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    const value = getter ? getter.call(obj) : val
    if (Dep.target) {
      dep.depend()
      if (childOb) {
        childOb.dep.depend()
        if (Array.isArray(value)) {
          dependArray(value)
        }
      }
    }
    return value
  },
  set: function reactiveSetter (newVal) {
    ...
  }
})

function dependArray (value: Array<any>) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i]
    e && e.__ob__ && e.__ob__.dep.depend()
    if (Array.isArray(e)) {
      dependArray(e)
    }
  }
}

6. Vue 组件性能优化手段

常用的性能优化手段有(仅适用于 2.x版本):

  1. 使用 key 提高组件复用率
  2. 正确处理组件 render 函数复杂度,因为 render 依赖的响应属性越多,越容易造成重新渲染,必要时可拆分子组件将复杂度分发到更细粒度上进行管理
  3. 通常地,可以在 created 回调中执行异步数据处理,以尽快启动异步任务
  4. 必要时可以使用 inject/provide 替代 props 实现父子间传值,因为 props 会对属性再进行一次响应式封装,而 inject/provide 接口只是简单地传值
  5. 对于大数据场景,可以使用 Object.freeze 接口禁止属性变更,避免过度频繁的渲染
  6. 使用 keep-alive 缓存组件实例
  7. 使用 functional 降低单个组件生命流程与状态管理的复杂度

7. 与Vue2.x相比,vue3响应式实现的优缺点

优点:

  1. 普适性更强,不需要针对数组做特殊处理;也能应用于值类型变量
  2. 启动速度更快,因为启动时不需要再遍历对象的所有属性,而是在运行过程中增量执行依赖管理
  3. 实现上重构 Watcher-Dep 模式,改用单个变量(reactivity/src/effect.ts#L10) 记录依赖关系,架构关系更简单,性能也稍有增强
  4. 响应式能力通过 Composition API 开放,不再依赖于 Vue 实例,更容易复用

缺点:

  1. 响应式底层依赖的 Proxy 相比于 Object.defineProperty 在许多场景中性能是相对较差的,这种差距放在SSR场景可能会造成性能问题

8. 各发布版本的区别

Vue 2.x 的发布策略相信比较耳熟能详了,内容上包含两种形态,一个是 Full 包,包含 runtime 与 ** compiler** 两种类型的源码;一个是 runtime 包,只包含运行时需要的最小依赖,不具备编译功能。在 Full / runtime 基础上再根据 UMD/CMD/ESM ,普通版本、DEV版本、prod 版本组合分发,那么结果就会生成一堆类似这样的发布文件:

  1. vue.js / vue.min.js : UMD 模块,适用于通过 script 直接引入
  2. vue.common.js / vue.common.dev.js / vue.common.prod.js : commonJS 模块,适用于node、browserify、webpack 1 等环境
  3. vue.esm.js / vue.esm.browser.js / vue.esm.browser.min.js : ESM 模块,ESM的特点是可以配合 wepack、rollup实现 treeshake,但Vue2.x实际上只 export default Vue
  4. vue.runtime.js / vue.runtime.min.js : UMD 模块,只包含 runtime
  5. vue.runtime.common.js / vue.runtime.common.dev.js / vue.runtime.common.prod.js :commonJS模块
  6. vue.runtime.esm.js :ESM 模块

9. 组件通讯方式

** to be continue **

10. Vue 中如何实现HOC

** to be continue **

本文使用 mdnice 排版