Vue源码剖析

Vue 响应式数据

什么是响应式数据:数据变了,视图能更新,反之视图更新,数据要不要更新,不归响应式数据管。
Vue 在内部实现了一个最核心的defineReactive方法,借助了Object.defineProperty,核心就是劫持属性(只会劫持已经存在的属性),把所有的属性,重新的添加了 getter 和 setter,因此在用户取值和设置值的时候,可以进行一些操作。

  • 对象:多层对象需要通过递归来实现劫持。
  • 数组:考虑性能原因没有用 defineProperty 对数组的每一项进行劫持,而是选择重写数组的(push,shift,pop,unshift,sort,splice,reverse)方法,数组中如果是对象数据类型也会进行递归劫持,数组的索引和长度变化是无法监控到的。

Vue 中如何进行依赖收集

  • 每个属性都拥有自己的 dep 属性,存放他所依赖的 watcher,当属性变化后会通知自己对应的 watcher 去更新
  • 默认在初始化时会调用 render 函数,此时会触发属性依赖收集 dep.depend()
  • 当属性发生修改时会触发 watcher 更新 dep.notify()

Vue 在初始化的时候会进行挂载$mount操作,会进行编译操作,最终会走到render function,当组件进行渲染时会去取值,取值getter时,调用dep.depend()收集这个 watcher,存放在Dep中,当我们去更改值setter,调用dep.notify()去通知这个 watcher 去更新,实际上 watcher 中存放的就是组件的update函数.更新的时候,就会走到虚拟 dom 相关的方法。

Vue 中模板编译原理

模板编译原理实际上就是 将 template 转换成 render 函数,大致可分为以下三步:

  1. 将 template 模板转换成 ast 语法树 - parserHTML
  • 定义一个 stack 栈,存放标签的父子关系
  • 通过正则匹配模板字符串,不停的解析,不停的删除,直至字符串解析完成,
  • 得到 ast 树,(存放标签名,子节点,及属性列表)
  1. 对静态语法做静态标记 static,会递归遍历子节点进行标记,组件和插槽不属于静态语法 - markUp
  • 只有在第一次编译时,会进行静态标记,不是每次渲染都标记
  • 静态标记主要是用来做 diff 优化的,静态节点跳过 diff 操作
  • 子节点有一个变化,父节点都不是静态的
  1. 生成代码,核心就是拼接字符串(_c,_v,_s),最终加上with语法 - codeGen

Vue 生命周期钩子

  • Vue 的生命周期钩子就是回调函数而已,当创建组件实例的过程中会调用对应的钩子方法。
  • 内部会对钩子函数进行处理,将钩子函数维护成数组的形式
  • 首先会采用策略模式,对 hook 进行合并 mergeHook(),合并成队列,然后依次调用
function mergeHook(parentVal, childVal) {
  const res = childVal // 儿子有
    ? parentVal
      ? parentVal.concat(childVal) // 父亲也有,就是合并
      : Array.isArray(childVal) // 儿子是数组
      ? childVal
      : [childVal] // 不是数组包装成数组
    : parentVal;
  return res ? dedupeHooks(res) : res;
}
  1. beforeCreate 在实例初始化 init 之后,数据初始化(data observer)之前调用,拿不到响应式的状态,可以拿到$on、$events 以及一些父子关系。在当前阶段 data、methods、computed 以及 watch 上的数据和方法都不能被访问。
  2. created 数据初始化完毕后调用,实例已经创建完成。完成数据观测(data observer),属性和方法的运算,可以直接用响应式数据。但是没有$el,不能进行 dom 操作。
  3. beforeMount 在挂载开始之前被调用(在 mountComponent 方法中被调用):之后相关的 render 函数首次被调用。
  4. mounted el 被新创建的真实的 vm.$el 替换,并挂载到实例上后调用该钩子。此阶段可以获取渲染后的节点。
  5. beforeUpdate 数据更新前调用,在创建 Watcher 时会传一个 before 方法,它里面会调用 beforeUpdate 钩子,每次页面更新都会去调用当前的渲染 watcher,会判断有没有 before 方法,有的话就会调用 beforeUpdate, 发生在虚拟 DOM 重新渲染和打补丁 patch 之前。然后再去执行 watcer.run()真实的更新方法。
  6. updated 执行完 watcer.run()之后,调用 updated 钩子,表示 dom 已完成更新。 (执行数据更改导致的虚拟 DOM 重新渲染和打补丁)。注意避免在此期间更新数据,因为可能会导致为无限循环的更新。
  7. beforeDestroy 实例销毁之前调用。仅作为实例即将的信号,实例仍然完全可用。之后会进行一系列的卸载操作。执行真正的卸载(从父节点中移除、清空自己的 watcher、卸载所有的属性、标记当前组件销毁状态、把虚拟节点也销毁掉、然后调 destroyed)。可以在这时进行一些收尾工作如清除定时器等。
  8. destroyed 实例销毁后调用。移除所有的事件监听器(否则会导致内存泄漏),销毁所有子实例。设置当前虚拟节点的父节点为 null。该钩子在服务器端渲染期间不被调用。

Vue 组件 data 为什么必须是个函数?

组件复用,需要每个组件中都有自己的 data,这样组件之间才不会相互干扰,组件中的 data 如果写成对象形式,就使多个组件实例会共享一份 data,一个数据变化后,会影响其他实例中的数据。
因此每次使用组件时都会对组件进行实例化操作后,调用 data 函数返回一个对象作为组件的数据源。这样可以保证多个组件间数据互不影响。

而根实例(new Vue())采用单例模式,且不需要任何的合并操作,所以根实例的 data 属性可以是函数,也可以是对象,实际上源码中根本的判断条件为 vm 属性,只有根才有 vm 属性,组件和 mixin 都没有 vm 属性,因此可以作为判断条件,区分 data 是否为函数。并给出相关报错信息。

nextTick 原理

当用户修改了数据后并不会马上更新视图,更新 DOM 时是异步执行的,只要侦听到数据变化,Vue 将开启一个任务队列,并缓冲同一时间循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。而 $nextTick 中的方法会被放到更新队列的后面,在下次 DOM 更新循环结束之后执⾏延迟回调,视图需要等队列中所有任务完成之后,再统一进行更新。在修改数据之后使⽤ $nextTick,则可以在回调中获取更新后的 DOM。

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

set 方法实现原理

  1. 如果目标不存在或者是原始类型,直接报错,cannot set reactive property on undefined,null,or primitive value
  2. 如果是数组,Vue.set(arr,1,100),调用重写的target.splice(key,1,val)方法,可以更新视图
  3. 如果是对象,看这个对象本身有没有这个值,如果有就直接更新就好,因为他本身就是响应式的,
  4. 如果是根实例,或者根数据 data 时,会报错提示 应该在初始化时声明该数据
  5. 如果不是响应式数据,也不需要将其定义成响应式属性 Vue.set({},'age',18),相当于这个对象本身就不是响应式的,就直接赋值,也不需要更新视图
  6. 最后就把调用的属性定义成响应式的即可。调用defineReactive(ob.value,key,val)
  7. 通知视图更新 ob.dep.notify()

因此 Vue.set 实际上就是两个方法的集合,target.splice(key,1,val)defineReactive(ob.value,key,val),

虚拟 dom 的作用

是什么:Virtual DOM 就是用 js 对象来描述真实 DOM 结构,是对真实 DOM 的抽象。
为什么:由于直接操作 DOM 性能低,但是 js 层的操作效率高,可以将 DOM 操作转化成对象操作,最终通过 diff 算法比对新旧 vdom 的差异进行更新 DOM(减少了对真实 DOM 的操作)。
边操作 dom 边获取视图,每次操作 dom 都可能会引起 dom 的回流和重绘,导致性能不高,有了 vdom 就可以把所有的操作都放在 vdom 上,最终把更新和一系列的逻辑批量的同步到真实 dom 上,
好处:虚拟 DOM 不依赖真实平台环境从而也可以实现跨平台。比如 nodejs 就没有 Dom,想要实现 SSR 就需要借助 Vdom

diff 算法的实现原理

Vue 的 diff 算法是平级比较,不考虑跨级比较的情况。内部采用深度递归的方式 + 双指针的方式进行比较(双指针分别指向新旧的结尾)。

  1. 先比较是否是相同节点,判断属性 key + tag
  2. 相同节点比较属性,并复用老节点
  3. 比较儿子节点,考虑老节点和新节点儿子的情况
  4. 优化比较:头头、尾尾、头尾、尾头
  5. 比对查找进行复用

diff 的复杂度 是 O(n),当一方子元素的头尾相等时,结束循环,(因为同层比较,内部只有一层循环).子元素嵌套时,递归同层比较
如果不能匹配到的话,就会根据当前的老的索引 key 创建一个映射表,拿新的去里面找,如果能找到就复用,找不到就创建新的,最终把老的多余的删掉,

Vue 中 key 的作用和原理

  1. Vue 在 patch 过程中通过 key 可以判断两个虚拟节点是否是相同节点。 (可以复用老节点)
  2. 无 key 会导致更新的时候出问题,比如 unshift 变成 push 效果,并更新所有节点,有 key 时,就可以节点复用,仅做节点的移动即可。
  3. 尽量不要采用索引作为 key,而是使用数据的唯一标识

vue 初渲染流程

  • vue 初始化流程 _init:
  1. 默认会调用 vue._init 方法将用户的参数挂在到$options 选项上,vm.$options。(vue 调用的方法使用原型扩展的形式)
  2. vue 会根据用户的参数进行数据的初始化,data props computed watch 等 ,在外界是无法访问的,可以通过 vm._data 访问到用户的数据。
  3. 对数据进行观测,对象(递归使用 Object.defineProperty),数组(方法重写,切片编程),劫持到用户的操作,观测的目的是用户修改数据时 -> 更新视图
  4. 将数据代理到 vm 对象上,vm.xxx => vm._data.xxx
  • vue 挂载流程 $mount:
  1. 判断用户是否传入了 el 属性, 内部会调用$mount 方法,用户也可以自行调用该方法
  2. 处理模板优先级 render / template / outerHTML
  3. 将模板编译成函数, 步骤: parseHTML 解析模板 -> ast 语法树, generate 解析语法树生成 code -> new Function 生成 render 函数
  4. 通过 render 方法,生成虚拟 dom + 真实的数据 => 真实的 dom
  5. 根据虚拟节点渲染真实的节点

vue 更新流程 依赖收集实现过程

  1. vue 中使用了观察者模式,默认组件渲染的时候,会创建一个 watcher,并且会渲染视图
  2. 当渲染视图的时候,会取 data 中的数据,会走每个属性的 get 方法,就让这个属性的 dep 记录 watcher
  3. 同时让 watcher 也记住 dep,dep 和 watcher 是多对多的关系,因为一个属性可以对应多个视图,一个视图对应多个数据
  4. 如果数据发生变化,会通知对应属性的 dep,一次通知存放的 watcher 去更新

一个属性对应一个 dep, 一个 dep 对应多个 watcher(数据多页面共享)
一个组件对应一个 watcher,一个 watcher 可以对应多个 dep(多个属性)
观察者模式: dep 收集 watcher,变化时一次通知,watcher 是观察者,dep 是被观察者
dep 用来收集渲染逻辑(watcher),watcher 中存放的是组件的 update 函数。数据变化通知 dep 中的 watcher 去执行对应的 update 方法
页面重新渲染逻辑:只有当页面模板中用到的数据(就是写在 render 中的数据) 发生改变时,才会调用 update 方法

vue 异步更新的实现流程

开启一个异步队列并将更新的 watcher 去重,将用户的$nextTick 和内部的更新逻辑, 合并为一个 Promise.then,依次执行(多个 nextTick 是一个 promise.then)
nextTick 用一个异步任务,将多个方法维持一个队列里,执行时机遵循 js 的 eventloop 机制,具体的执行时机 ,要看底层用的是那个方法,因为 vue 考虑了浏览器的兼容性,vue 中对 nextTick 做了很多兼容性处理,promise 微任务 > MutationObserver(h5 的 api 微任务) > setImmediate > setTimeout

组件的初始化流程

  1. 第一步:创造组件的虚拟节点,创建虚拟节点的时候,内部会去调用 Vue.extend 方法,产生组件的构造函数 Ctor
  2. 第二步:给组件添加钩子函数,data.hook = {init},合并 mergeOptions (自己的组件.proto = 全局的组件),最终返回了一个虚拟节点
  3. 第三步:页面开始渲染,渲染的时候,会去调用 patch 方法,并且根据当前的虚拟节点,转换成真实节点,这时会去调用 createElm,创造真实节点。
  4. 第四步:创造真实节点的时候发现,如果这个节点是组件,就会调用组件的 createCompontent => 调用 hook.init 方法,
  5. 第五步: 此时 init 方法,会 new Ctor(),之后会进行子组件的初始化操作 this._init
  6. 第六步:最终再去调用组件的挂载操作$mount,产生一个$el 真实节点,对应组件模板渲染后的结果。
  7. 第七步:将组件的 vnode.componentInstance.$el 插入到父标签中

keep-alive 实现原理

keep-alive 组件是一个抽象组件, 也是一个虚拟组件, 不会被记录到父子组件关系当中,一般用在路由组件的外层, 主要为了缓存组件, 为频繁挂载销毁,提供缓存功能节约性能,

  • 包含 include 属性,添加白名单,表示那些组件需要缓存,切换过后才会进行缓存,并不是将白名单中的 name 直接全部缓存。
  • 包含 exclude 属性,添加黑名单,表示那些组件不用缓存
  • max = x 最多缓存几个组件, 如果超过最大限制 需要删除第一个, 在增加最新的 LRU
  1. created 钩子:创造一个对象 cache 来缓存组件,key[],表示缓存的是谁
  2. render():渲染
  3. mounted():挂载,通过 watch Api 监控 include 和 exclude 做缓存处理,pruneCache

render

获取 keep-alive 中的所有子组件,获取插槽中的第一个,根据组件的名称, 判断 include 和 exclude, 拿到后把组件的实例缓存起来
拿到组件的 key 用来做缓存,如果有缓存 获取缓存的实例,ABA,=>shift 以后再 push
缓存组件 会缓存子组件,缓存的是父节点的 el, 其中包含着所有子组件渲染后完整的结果。
第一次渲染完毕后,会把虚拟节点进行标记直接返回一个组件,keep-alive 最终渲染的结果就是第一个子组件

mounted

缓存中存放了 {组件的 key : 组件的实例},复用的时候,直接使用缓存中,组件的实例
如果超过最大限制 需要删除第一个,在增加最新的,遵循 LRU 原则(Least Recently Used 即最近最久未使用的)

组件更新

每次切换组件,都会进行组件的初始化流程 init 方法,第一次组件渲染时,会在组件虚拟节点上挂载 componentIntance 属性和 keepalive 标记
更新时会再次调用 init 方法,此时会判断虚拟节点的属性和 keepalive 标记,进行 prepatch 方法,对会组件插槽中的内容进行比较。
会判断组件是否需要进行强制更新,会比较新老节点,去执行当前实例的强制更新方法,vm.$forceUpdate ,实际走的就是 keep-alive 的 render()

posted @ 2022-09-26 19:16  Echoyya、  阅读(169)  评论(0编辑  收藏  举报