个人笔记。进行源码分析的 Vue 版本为 v2.6.12
Vuejs 是什么?
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 属性响应化,调用地非常深:
|
|
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 函数。而这个函数里面执行了
|
|
执行这个方法,会将 dep 添加到当前 Vue 实例对应的 watcher 的订阅者下,dep 也把 watcher 放到 dep 的订阅者(watcher 集合)下。换句话说,就是
watcher 初始化时,dep 和 watcher 相互关联。
派发更新
响应化属性在 setter 下调用了
|
|
接着会调用 watcher 们的 update 方法,这个方法会调用 run 方法(通常为异步调用它),更新 DOM 树。
Vue 组件中如何添加一个非响应式的 data 变量?
我们希望一个对象不被添加 setter 和 getter。场景是 Vue 组件要用到一个含有非常多属性的第三方编辑器实例对象,设置响应式没有必要且会降低性能。
我观察了将编辑器封装成 Vue 组件的项目,如 vue-quill-editor。发现我们可以利用 Vue 的一个特性来实现定义一个非响应式 Data 属性。
官方文档 说到:
值得注意的是只有当实例被创建时就已经存在于 data 中的 property 才是响应式的。
所以我们只要不要将属性放到 data 里就好了:
|
|
为什么会这样呢?前面我就提到过了,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 设置了代理:
|
|
除了代理 $data,还代理了 $props。如果 $data 和 $props 的属性同名,Vue 会在初始化时发出警告,且最终 vm.xx 访问到的是 vm.$data.xx
修改数组元素为什么不会触发更新?
数组的响应化和对象不同,走的是另一个方向。
对于数组,考虑数组可能会很大,可能会有性能问题,Vue 没有对一个个属性设置 getter 和 setter。而是 ”代理“ 了数组的方法。
代码:
|
|
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 算法
参考:
- https://juejin.cn/post/6844903607913938951
- https://www.infoq.cn/article/udlcpkh4iqb0cr5wgy7f
- https://zhuanlan.zhihu.com/p/81752104
算法的一些特点:
- Vue 的 DOM Diff 算法只会在 同级 之间进行比较,并不会跨层进行比较。
- 在 diff 比较的过程中,循环从两边向中间收拢。
根据 oldVnode 和 vnode 的不同,进行不同的方案进行 diff 和 patch。最复杂的情况是二者都有子节点,涉及到的函数有:
|
|
下面尝试总览一下 patch 内部的逻辑,会砍掉很多细枝末节,因为里面有非常多的条件分支。
patch
patch 函数负责比较传入的两个虚拟 Node(vnode),找出不同,用合适的方法将旧 vnode 替换为新 vnode。
patch 函数做的工作(只说主要流程,实际上里面有一大堆其他的条件分支):
- vnode 不存在,但 oldVnode 存在,销毁掉 oldVnode,结束。
- vnode 存在,但 oldVnode 不存在,根据 vnode 调用 createElm(vnode),替换旧的
- vnode 和 oldVnode 都存在,且它们 “相同”(
sameVnode(oldVnode, vnode)),调用patchVnode方法执行更具体的更新策略。 - 它们不同,执行 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
我们会使用两对指针,初始化时,每对指针指向子节点数组的两端。为了方便描述,我们起个短一点的变量名: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 的左边。指针向中间移动。
结束遍历后,也有两种情况。因为我们的结束条件是当其中一个左指针跑到对应的右指针右侧,那么另一边的指针还没跑完,我们需要做一些收尾工作。
- S 和 E 没遍历完:将 S 到 E 的所有 vnode 都真实化(addVnodes()),覆盖掉
- oldS 和 E 没遍历完:说明有多余的节点,将它们销毁掉(removeVnodes())
upateChildren 例子
请根据给出的题目条件和算法自行推算,然后对答案。看我这个答案是无法理解中间状态变化的细节的。
真实Dom: a, b, d
oldCh: a, b, d
newCh: a, c, d, b
真实 Dom 的变化情况:
- oldS = S,a 更新,此时真实DOM为
a, b, d - oldS = E,b 更新,b 真实DOM元素移动到 d(oldE 指向的对象)的后面。此时真实Dom为
a, d, b - oldE = E,d 更新,此时真实Dom为
a, d, b,遍历结束 - 发现是 newCh 没遍历完,将剩余的虚拟节点([c])真实化(放到 newE + 1 指针的前面),此时真实Dom为
a, c, d, b
为什么使用列表渲染时最好要提供 key?
前面我们分析了 Vue 如何对真实 DOM 进行 patch。
patch 过程中哪里会用到 key?
比较两个 vnode 是否 “相同”,调用 sameValue 函数时,会比较 key。
|
|
对于列表渲染,如果不用 key,它们任意两个都是 “相同” 的,因为它们的 key 都是 undefined。这样会发生 就地服用
不设置 key 的话可能会出现什么意料之外的效果?
需要明确的一点是,key 不是一定要用在 v-for 上的,只是 v-for 下必须使用罢了(不适用会出现警告)。
- props(传入的值)发生了变化,但 data(内部变量)没有变化。因为两个实际上应该不同的 vnode 被认为是相同的,导致…(待我组织语言)
- 可能不会触发组件销毁。比如 [5, 6, 7] 替换为 [1, 2, 3],Vuejs 只会替换 props,直接设置 textContent。这在只是要大量渲染 UI 的情况下是有利的,因为不会销毁组件。
- 不会发生 DOM 的移动,只是替换内容。这意味着 MutationObserver 检测子元素的移动会有问题,Vuejs 做出了虚假的移动。
- 无法强行触发过渡(替换)。
key 设置为 index 和不设置 key 与区别吗?
|
|
没有区别,只是解除了警告(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 的区别
- https://juejin.cn/post/6844903657264136200
- https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model
- https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
微任务有:
- 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 改为优先使用 macro,2.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() 求值。
|
|
第一次访问计算属性时,先执行 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 时再计算。
|
|
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。
|
|
以上不会触发 watch a 对应的函数。因为我们是对 a.b 进行设置的。除非使用 vm.a = { b: 2 },或者对 watch 设置 deep 配置项为 true。