Vue原理学习

个人笔记。进行源码分析的 Vue 版本为 v2.6.12

Vuejs 是什么?

参考:Vue 2.0——渐进式前端解决方案

Vue 是一套用于构建用户界面的渐进式(Progressive)框架。

我在做 Vue 的过程中也在不停地思考它的定位,现在,我觉得它与其他框架的区别就是渐进式的想法,也就是“Progressive”——这个词在英文中定义是渐进,一步一步,不是说你必须一竿子把所有的东西都用上。

在我理解,渐进式是指不一次提供含有所有功能的全家桶,而是提供最核心的声明式渲染和组件系统。然后根据你的需求去添加新的功能,如路由功能(Vue-Router)、状态管理功能(Vuex)。

Vue 响应式原理

参考:Vue 响应式原理白话版

抽象的上层的说法:

  • 每个组件实例都对应一个 watcher 实例
  • 对 data 的属性设置 getter 和 setter
  • 修改属性值,通知监听器,更新真实 DOM。

代码层面的分析

init 阶段(执行 defineReactive 方法),data 的属性就设置 setter 和 getter。defineReactive 方法里面创建了一个 Dep 实例对象。data 上每个属性 reactive 化时都会调用 defineReactive 函数,也就是说:

对于所有被 Vue reactive 化的属性来说都有一个 Dep 对象与之对应。

关于源码中对 Vue 的 Data 属性响应化,调用地非常深:

1
2
3
// src/core/instance/index.js
new Vue() -> this._init() -> initState() -> initData()
-> observe() -> new Observer() -> defineReactive()终于到了

defineReactive 函数所在的代码位置

init 之后会进入 mount 阶段(mountComponent 方法),然后会创建一个 Watcher 实例。这里有:

每个 Vue 实例都有一个 Watcher 实例与之对应。Watcher 在 mount 时被创建

通过 vm._watcher 可以得到 Vue 实例对应的 watcher。new Watcher 时,watcher 还拿到了一个依次调用了 vm._render 和 vm._update 方法的函数(这个方法会变成 watch.getter)。

  • vm._render 负责虚拟 DOM 的构建
  • vm._update 根据虚拟 DOM,更新真实 DOM。

当组件需要更新时,Watcher 的 run 方法会被调用,这个方法会调用 getter 方法,更新真实 DOM 树。

依赖收集

Watcher 会在构造函数内,调用 this.getter。这个我们前面提到的方法会调用 _render,这里就会触发 Data 的属性的 getter 函数。而这个函数里面执行了

1
dep.depend()

执行这个方法,会将 dep 添加到当前 Vue 实例对应的 watcher 的订阅者下,dep 也把 watcher 放到 dep 的订阅者(watcher 集合)下。换句话说,就是

watcher 初始化时,dep 和 watcher 相互关联

派发更新

响应化属性在 setter 下调用了

1
dep.notify()

接着会调用 watcher 们的 update 方法,这个方法会调用 run 方法(通常为异步调用它),更新 DOM 树。

Vue 组件中如何添加一个非响应式的 data 变量?

我们希望一个对象不被添加 setter 和 getter。场景是 Vue 组件要用到一个含有非常多属性的第三方编辑器实例对象,设置响应式没有必要且会降低性能。

我观察了将编辑器封装成 Vue 组件的项目,如 vue-quill-editor。发现我们可以利用 Vue 的一个特性来实现定义一个非响应式 Data 属性。

官方文档 说到:

值得注意的是只有当实例被创建时就已经存在于 data 中的 property 才是响应式的。

所以我们只要不要将属性放到 data 里就好了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export default {
  data() {
    return {
      val: 2, // 这些放响应式数据
    }
  },
  mounted() {
    this.editor = null // 可以去掉,不初始化了直接用
    this.initEditor()
  }
  initEditor() {
    // ...
    this.editor = new Editor()
    // ...
  }
}

为什么会这样呢?前面我就提到过了,Vue 是在 mount 阶段对 data 对象进行响应式化。如果我们是在其他地方(钩子函数)通过 this.xx 的方式来声明一个属性变量,这个变量也不会被放到 $data 对象里,即便在 mount 之前设置 this.xx 也是无法使其成为响应式的。

Vue 不允许动态添加 根级别 的响应式 property。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式 property。

或者对一个对象进行冻结:Object.freeze(obj)。这样我们就无法对其设置 setter 和 getter。但我们之后的生命周期内也无法操作它。

可以通过 vm.xx 能访问到 vm.$data.xx 的原因

Vue 做了代理。Vue 使用了自定义的 proxy 的方法,在 vm 实例上设置了和 $data 同名的 key,并通过 setter 和 getter 设置了代理:

1
2
3
4
5
6
7
8
9
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

除了代理 $data,还代理了 $props。如果 $data 和 $props 的属性同名,Vue 会在初始化时发出警告,且最终 vm.xx 访问到的是 vm.$data.xx

修改数组元素为什么不会触发更新?

数组的响应化和对象不同,走的是另一个方向。

对于数组,考虑数组可能会很大,可能会有性能问题,Vue 没有对一个个属性设置 getter 和 setter。而是 ”代理“ 了数组的方法。

代码

1
2
3
4
5
6
7
8
if (Array.isArray(value)) {
  if (hasProto) {
    protoAugment(value, arrayMethods)
  } else {
    copyAugment(value, arrayMethods, arrayKeys)
  }
  this.observeArray(value)
}

arrayMethods 是重写了数组方法的原型为 Array.prototype 的对象来实现 “代理”。重写的方法为:‘push’, ‘pop’,‘shift’, ‘unshift’, ‘splice’, ‘sort’, ‘reverse’,且设置为不可枚举。

如果浏览器支持 __proto__,直接将 Data 的数组属性的 __proto__ 设置为 arrayMethods。形成 arr -> arrayMethods -> Array.prototype 的原型链。如果不支持 __proto__,直接在数组本身上添加这些方法,且设置为不可枚举。

调用这些方法时,会调用 Array.prototype 对应的方法,然后对引入的新数组元素进行响应化,然后调用 ob.dep.notify() 触发 watcher 收集依赖更新 DOM。

因为并没有对数组属性做响应化,所以修改 arr.n 和 arr.length 都不会触发更新。官方文档对此的建议是:

  • Vue.set(vm.items, indexOfItem, newValue)
  • vm.items.splice(indexOfItem, 1, newValue)

因为这两个方法都会触发 dep.notify 方法。

Vue 的虚拟 DOM diff 算法

参考:

算法的一些特点:

  1. Vue 的 DOM Diff 算法只会在 同级 之间进行比较,并不会跨层进行比较。
  2. 在 diff 比较的过程中,循环从两边向中间收拢。

根据 oldVnode 和 vnode 的不同,进行不同的方案进行 diff 和 patch。最复杂的情况是二者都有子节点,涉及到的函数有:

1
patch(oldVnode, node) -> patchVnode(oldVnode, node) -> updateChildren(elm, oldVnode,children, vnode.children)

下面尝试总览一下 patch 内部的逻辑,会砍掉很多细枝末节,因为里面有非常多的条件分支。

patch

patch 函数负责比较传入的两个虚拟 Node(vnode),找出不同,用合适的方法将旧 vnode 替换为新 vnode。

patch 函数做的工作(只说主要流程,实际上里面有一大堆其他的条件分支):

  1. vnode 不存在,但 oldVnode 存在,销毁掉 oldVnode,结束。
  2. vnode 存在,但 oldVnode 不存在,根据 vnode 调用 createElm(vnode),替换旧的
  3. vnode 和 oldVnode 都存在,且它们 “相同”(sameVnode(oldVnode, vnode)),调用 patchVnode 方法执行更具体的更新策略。
  4. 它们不同,执行 createElm(vnode),替换旧的

createElm(vnode) 负责将 vnode 真实化,更新到真实 DOM 上(个人理解,可能还有其他的功能,去哦也不确定这个是不是这个函数的主要功能)

patchVnode

  • oldVnode 和 vnode 指向同一对象,不做处理,结束函数。(说明节点没有发生变化)
  • 调用一些钩子函数(如 vnode.data.hook.prepatch,需要满足一定条件)
  • 两者都有文本节点(vnode.text)且不相等,替换 elm.textContent。
  • oldVnode 有子节点,而 vnode 没有,删除子节点(removeVnodes)。
  • oldVnode 没有子节点,而 vnode 有,添加子节点(addVnodes)。
  • 两者都有子节点,则执行 updateChildren(elm, oldCh, ch)

updateChildren

updateChildren 函数所在位置

我们会使用两对指针,初始化时,每对指针指向子节点数组的两端。为了方便描述,我们起个短一点的变量名:oldS, oldE, S, E。

指针会从两边往中间移动,直到 oldS > oldE 或 S > E 结束,也就是左指针跑到右指针右边为止。

  • oldS 或 oldE 不存在,相关的指针向中间移动,进入下一轮循环(到了后面讨论 key 相等的情况,你就知道为什么会有不存在的情况,且要跳过它)
  • oldS 和 S “相同” 或 oldE 和 E “相同”,则 patchVnode(oldS, S),patchVnode 可能会调用 updateChildren,所以这里产生了递归。最后对应指针向中间移动
  • oldS 和 E “相同”,同样执行 patchVnode(),然后 将 oldS 对应的真正元素,移动到 oldE 元素右边(为什么?大概是一种巧妙的实现,要尝试去写才能理解?),最后依旧是指针向中间移动。
  • oldE 和 S “相同”,执行 patchVnode(),然后 将 oldE 对应的真实元素,移动到 oldS 元素左边。(为什么?),最后指针往中间移动。
  • 都不同。
    • 对 oldCh 位于 [oldStartIdx, oldEndIdx] 区间生成哈希表 oldKeyToIdx,键为 vnode.key,值为数组索引值。
    • 如果 S.key 存在,从 oldKeyToIdx 哈希表取对应的 vnode,若不存在,则取其中 “相同” 的 vnode。
    • 若都没找到,直接创建一个新的真实节点元素(createElm);如果找到了,打补丁:patchVnode(vnodeToMove, S),然后对应的 oldCh[idexInOld] 设置为 undefined,接着是将这个元素(vnodeToMove.elm)放到 oldStart 的左边。指针向中间移动。

结束遍历后,也有两种情况。因为我们的结束条件是当其中一个左指针跑到对应的右指针右侧,那么另一边的指针还没跑完,我们需要做一些收尾工作。

  1. S 和 E 没遍历完:将 S 到 E 的所有 vnode 都真实化(addVnodes()),覆盖掉
  2. oldS 和 E 没遍历完:说明有多余的节点,将它们销毁掉(removeVnodes())

upateChildren 例子

请根据给出的题目条件和算法自行推算,然后对答案。看我这个答案是无法理解中间状态变化的细节的。

 真实Dom:  a, b, d
   oldCh:  a, b, d 
   newCh:  a, c, d, b 

真实 Dom 的变化情况:

  1. oldS = S,a 更新,此时真实DOM为 a, b, d
  2. oldS = E,b 更新,b 真实DOM元素移动到 d(oldE 指向的对象)的后面。此时真实Dom为 a, d, b
  3. oldE = E,d 更新,此时真实Dom为 a, d, b,遍历结束
  4. 发现是 newCh 没遍历完,将剩余的虚拟节点([c])真实化(放到 newE + 1 指针的前面),此时真实Dom为 a, c, d, b

为什么使用列表渲染时最好要提供 key?

前面我们分析了 Vue 如何对真实 DOM 进行 patch。

patch 过程中哪里会用到 key?

比较两个 vnode 是否 “相同”,调用 sameValue 函数时,会比较 key。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

对于列表渲染,如果不用 key,它们任意两个都是 “相同” 的,因为它们的 key 都是 undefined。这样会发生 就地服用

不设置 key 的话可能会出现什么意料之外的效果?

参考:API: key - Vue.js

需要明确的一点是,key 不是一定要用在 v-for 上的,只是 v-for 下必须使用罢了(不适用会出现警告)。

  • props(传入的值)发生了变化,但 data(内部变量)没有变化。因为两个实际上应该不同的 vnode 被认为是相同的,导致…(待我组织语言)
  • 可能不会触发组件销毁。比如 [5, 6, 7] 替换为 [1, 2, 3],Vuejs 只会替换 props,直接设置 textContent。这在只是要大量渲染 UI 的情况下是有利的,因为不会销毁组件。
  • 不会发生 DOM 的移动,只是替换内容。这意味着 MutationObserver 检测子元素的移动会有问题,Vuejs 做出了虚假的移动。
  • 无法强行触发过渡(替换)。

key 设置为 index 和不设置 key 与区别吗?

1
<div v-for="(item, index) in items" :key="index"></div>

没有区别,只是解除了警告(warnning)罢了。index 是父组件提供的,index 永远是跟随数组的索引,而不是跟随它原本指向的子组件。

假设原来 oldCh 的第一个 vnode 为 a,它的 key 是 0。后来数组变了,此时 newCh 的第一个 vnode 变成了 b,它的 key 是什么?还是 0。因为 index 是父组件的数组提供的,和位置有关,和对应的子组件无关。

DOM Diff 算法会认为同样是 key 为 0 的 a 和 b 相等,这和设置 key 为 undefind 并没有什么区别,因为是同样的效果。

那么如何合理地设置 key 呢?

  • 如果数组元素有可以作为唯一的 id 数据(可以是单个或多个属性的组合),用它作为 key。
  • 数组元素没有 id,但是个对象,可以考虑手动给它加个 id,然后作为 key
  • 【不推荐】使用随机数作为 key。这样会导致两个相同的 vnode 被认为是不同的,原本只是简单地 “就地复用”,现在不得不销毁然后重建,花销很大。
  • 如果你的数组不会再变了,也没找到合适的 id,那就可以不加了(或者加 index 解除警告)

Vue 的 nextTick 原理

这里分析的是 v2.6.12 的 nextTick,如果你发现我的讲解和网上一些文章的逻辑不同是正常的,因为 nextTick 曾发生过重大的改写,具体后面会说。

Vue 非常核心的特性是 异步。在数据修改时,不会立即进行操作,而在 合适的时机 一次性执行,更新 DOM。这个时机如何确定呢,答案是 异步

首先检查浏览器对一些异步方法的支持,尝试各种异步方案(优先使用微任务异步),来定义 timeFunc 函数。

首先会尝试使用 microTask(微任务)异步:

  • Promise。具体做法是 Promise.resolve(),然后 then
  • MutationObserver。具体做法是监听一个创建出来的文件节点,文本内容不停地 0 到 1 再到 0 的变化来产生异步任务

然后是尝试 macroTack(宏任务)

  • setImmediate。
  • setTimeout。所有浏览器都支持,作为兜底方案。

涉及到的函数:

  • timeFunc 函数负责是创建异步任务,执行 flushCallbacks 函数。
  • flushCallbacks 函数将 callbacks 数组里的回调函数按顺序全部执行,并清空数组。此外 pending 也设置为 false,这样下次发生 nextTick 时,就又能够执行 timeFunc 函数了。
  • nextTick
    • 函数会往 callbacks 回调函数数组添加新的回调函数,且如果 pending 为 false,执行 timerFunc 函数。
    • nextTick 会返回 Promise,如果浏览器支持 Promise 且没有使用回调函数。否则返回 undefined。

micro 和 macro 的区别

微任务有:

  • Promise。规范没有要求,但通常实现为微任务
  • MutationObserver

宏任务有:setTimeout,

  • setTimeout/setInterval。
  • I/O。如 fetch, ajax, fs.readFile
  • setImmediate()。仅 nodejs 下有,一次 Event Loop 执行完毕后调用
  • requestAnimationFrame
  • process.nextTick。nodejs 特有

UI 渲染什么任务?大概是微任务

执行完所有微任务后,才会执行宏任务。

Vue.nextTick 实现变更历史

Vue 的 nextTick 曾经发生过几次的调整:2.4 及之前优先使用 micro,到了 2.5 改为优先使用 macro2.6 开始又换回 micro

为什么要调整呢?

我先看看这个(有空再看):

不清楚,大概是

我们在有之前的知识背景,再理解 nextTick 的实现就不难了,这里有一段很关键的注释:在 Vue 2.4 之前的版本,nextTick 几乎都是基于 micro task 实现的,但由于 micro task 的执行优先级非常高,在某些场景下它甚至要比事件冒泡还要快,就会导致一些诡异的问题,如 issue #4521、#6690、#6566;但是如果全部都改成 macro task,对一些有重绘和动画的场景也会有性能影响,如 issue #6813。所以最终 nextTick 采取的策略是默认走 micro task,对于一些 DOM 交互事件,如 v-on 绑定的事件回调函数的处理,会强制走 macro task。

微任务优先级太高(比宏任务高)。

computed 计算属性

initState –> if(opts.computed) initComputed(vm, opts.computed)initComputed 函数会 遍历 opts.computed 对象,各自创建对应的 Watcher 实例。

initComputed

vm 下会创建一个 _computedWatchers 的空对象作为哈希表,用于存储期间创建的所有 watcher。

这里的 Watcher 实例和 渲染 Watcher 不同,是 computed watcher,它在初始化时额外提供了一个 { lazy: true } 的配置项。这样,在 Watcher 初始化时,不会立即调用 Watcher.prototype.get 方法求值保存到 watcher.value 上。

检查 key 是否已经在 props 或 data 定义过了,如果是,生产环境静默失败,非生产环境进行警告;如果不是,对 computed 下的 key 执行 defineComputed(vm, key, computed[key])

defineComputed

defineComputed 会对 vm.key 设置 setter 和 getter。因为 computed 大多数情况只提供一个函数作为 getter,一般不提供 setter(需要为对象形式)。如果没有提供 setter,会使用一个空函数(也就是 Vue 项目中到处都用到的 noop)。

getter 为通过 createComputedGetter 函数通过闭包方式绑定了 key 后返回的一个函数。watcher.dirty 如果为真值(说明目前的计算属性 watcher.value 可能不对,是脏的,下次渲染时不使用缓存),调用 watcher.evaluate() 求值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) { 
        watcher.evaluate() // dirty 才求值,否则使用缓存值
      }
      if (Dep.target) {
        watcher.depend() // 当前的计算属性,可能也是另一个计算属性的依赖,这里也要作为依赖被收集
      }
      return watcher.value
    }
  }
}

第一次访问计算属性时,先执行 watcher.evaluate(求值),该方法下又执行 watcher.get,会先设置 Dep.target 为当前这个渲染 Dep.target。执行 watcher.getter(用户定义的计算函数)。这样依赖的属性就会触发 getter,具体过程本文最前面有详细描述,最终结果是当前的渲染 computedWatcher 将涉及到的 dep 全都订阅了,即 Dep.target.addDep(this)。至此,这个 computed watcher 的依赖收集完毕。

当依赖的属性被修改,就会通知(dep.notify -> watcher.update)computed watcher,但此时不会立即计算,而是将 watcher.dirty 设置为 true。(可能是考虑到有多个依赖同时改变的情况,希望依赖都改变完后才对计算属性求值,computed watcher 本质是 lazy watcher)。这样等到下一次渲染触发 setter 时再计算。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Watcher {
  // ...
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true // computed watcher 的 lazy 永远是 true,所以到这里就结束了。
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

watch 又做了什么?

参考:https://juejin.cn/post/6844903926819454983

执行顺序:

  • initState
  • initWatch(值可能时数组,会依次对它们执行 createWather)
  • createWatcher(主要做格式的处理)
  • vm.$watch:生成 watcher

watch 的用法:https://cn.vuejs.org/v2/api/#watch

watch 底层调用的 vm.$watch 方法,只是在调用该方法前,做了一些格式处理工作。

生成的 watcher 会绑定对应的 key (存到 watcher.expOrFn watcher.cb)。当 key 被修改时,watcher.update 会执行(watcher.cb)。(话说,没找到将 watcher 添加到 vm.key 对应的 dep 的代码逻辑)

watch 可以设置为 deep。这样对于嵌套的下对象,也会触发 getter。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
new Vue({
  data() {
    a: {
      b: 1
    }
  },
  watch: {
    a() {
      console.log('a change')
    }
  }
})
vm.a.b = 2

以上不会触发 watch a 对应的函数。因为我们是对 a.b 进行设置的。除非使用 vm.a = { b: 2 },或者对 watch 设置 deep 配置项为 true。