vue2.x源码解读纪录

124 阅读4分钟

杂项

  • array的相关操作都是触发的getter函数
    • obj.list[1]/obj.list[1]=1/obj.list.length/obj.list.length=0
  • with语句的作用是将代码的作用域设置到一个特定的对象中

程序执行步骤

  • 初始化流程
    • new Vue() -> this._init() -> initState(vm) -> observe(data) 响应式 -> vm.$mount() -> mountComponent()
      • mountComponent -> new Watcher(vm, updateComponent ) 重要一步,Watcher 初始化时执行 this.get() 会触发 updateComponent()
      • updateComponent() -> vm._update( vm._render() ) 之后每次更新都会触发这个函数
      • vm._render -> render: ( h ) => h( App ) -> h = createElement 用传入的参数创建 vnode 节点重要)并返回
      • vm._update -> vm.patch(vm.$el, vnode 上面返回的 ) = patch(oldVnode, vnode) 重要步骤
        • 里面 vm._vnode = vnode 这时保存就是之后更新用到的oldVnode重要步骤
      • patch -> createElm 创建真实节点并插入 DOM(首次渲染:先把子节点挨个插入到自己的父节点中,最后整体插入)
        • 重要:如果不是初始化渲染会执行sameVnode()->patchVnode(oldVnode, vnode)->updateChildren()执行 diff 算法
  • createElm 函数
    • 用于按照 vnode 类型创建不同的真实 DOM 并插入到对应的位置。可能是子节点插入到父节点中,或是父节点插入到 DOM 中
    • 初次渲染时 patch 执行的是 createElem 而不是 patchVnode
  • 依赖收集
    • new Watcher() -> pushTarget() = Dep.target = watcher -> this.getter() -> updateComponent() -> vm._render() 触发依赖收集 -> popTarget() = Dep.target = undefined
    • vm._render() = render(h) => h(App) 而内部会用到this.xxx这里读取某个属性,进而触发响应式的 getter -> dep.depend() 完成依赖收集。重要步骤
      • dep.depend() -> Dep.target.addDep() -> dep.addSub(watcher)
    • 重要说明:
      • 实际开发使用的<template>会进过编译器转为类似渲染函数内的h(应该可叫做虚拟节点树)的内容,这时已经包含了页面内的所有的代码内容(包括v-show/v-if/compontent等没展示的内容)。
      • 后续在执行render函数时,就会触发程序内写的this.xxx进而触发了getter完成了响应式的收集工作。所以响应式的收集是在render函数执行时,而不是实际插入到DOM时
      • 除开用户创建的computed/watche的Watcher函数外,实际全局用于渲染的Watcher函数就一个(mountComponent函数内的那个)
  • 视图更新
    • 响应式setter函数 -> dep.notify() -> wather.update() -> queueWatcher() -> nextTick(flushSchedulerQueue) 优化处理
      • nextTick(flushSchedulerQueue) 这里导致了为什么说 this.xxx 是异步而非同步的。主要为了多次更改 this.xxx 时让视图尽量少更新
    • flushSchedulerQueue -> watcher.run() -> watcher 内的 this.get() -> vm._update( vm._render() ) 又进行 render 和 update -> patch 视图更新
      • vm._render() 更新时都会执行,所以vnode也每次都会创建一个既newVnode
      • 然后vm._update时可用之前保存的oldVnodenewVnode进行比较。diff 算法的依据
    • vm._update() -> vm.__patch() 是走更新的那个 -> patch -> patchVnode
      • 所有的DOM视图更新动作,都是在比较判断时同步进行的。通过nodeOps对象下封装的一系列DOM操作函数
    • patch 每次都是从VDOM树的根开始按照同层比较以及子层diff算法进行
      • 同层走的 patchVnode 子层走的 updateChildren。然后 updateChildren 内又走 patchVnode 这么个逻辑
  • 生命周期触发
    • this._init() 内触发 callHook(vm, 'beforeCreate')/callHook(vm, 'created') 中间有个initState()所以在created内就可以使用this.xxx
    • mountComponent() 执行 callHook(vm, 'beforeMount')/callHook(vm, 'mounted')
    • flushSchedulerQueue() -> watcher.before() 内执行 callHook(vm, 'beforeUpdate') 同时执行 callUpdatedHooks() -> callHook(vm, 'updated')
    • Vue.prototype.$destroy 执行 callHook(vm, 'beforeDestroy')/callHook(vm, 'destroyed')

虚拟Dom

介绍说明

  • vnode本身是个js对象,它是对节点的描述。而VDOM是多个vnode组成的树形结构
  • 用于当某个状态发生改变时,只更新与这个状态相关联的DOM节点
  • 在 vue 中虚拟DOM描述了从vnode进行patch进而渲染到视图的过程
  • 将对真实Dom的操作转移到VDOM上,即js对象上这使的操作更快速和高效。同时因为并不是直接操作真实DOM所以也提供了跨平台的能力

VNode

  • 在vue中存在一个VNode类,使用它可以实例化不同类型的vnode实例。简单地说,vnode可以理解成节点描述对象
  • 可以对上次渲染视图所创建的vnode进行缓存。之后在需要更新视图时,将新创建的vnode和上次缓存的进行比较,找出不一样的地方并基于此去修改真实DOM
  • VNode的几种类型:注释节点、文本节点、元素节点、组件节点、函数式节点、克隆节点
  • 主要的vnode创建函数:new VNode()、createEmptyVNode()、createTextVNode()、cloneVNode()

patch

  • 虚拟DOM最核心的部分是patch,它可以将vnode渲染成真实的DOM。patch是及对两次vnode节点进行比较,找出不同点然后对现有DOM进行修改。所有的判断处理都是以新的vnode为准
  • 创建新增节点
    • 只有三种类型的节点会被创建并插入到DOM中:元素节点、注释节点和文本节点(纯文本)这个说法对应着createElm方法
  • 删除已经废弃的节点
    • 是将新创建的DOM节点插入到旧节点的前,然后再将旧节点删除,从而完成替换过程(为了知道添加的位置)
  • 修改需要更新的节点
    • 更新节点时,会判断新旧两个节点是否是静态节点(无状态的)。是的话就跳过不处理
    • 更新子节点分为4类操作(遍历方式):更新节点、新增节点、删除节点、移动节点位置
      • 新增:将新创建的节点插入到oldChildren中所有未处理节点的前面
      • 移动:在oldChildren中找到相同的但是位置不同。移动的位置是oldChildren所有未处理节点的前面
      • 更新:两个节点是同一个节点并且位置相同
      • 删除(遍历结束后):当newChildren中节点都循环一遍,而oldChildren中还有剩余就需要删除
      • 重要:因为oldChildren对应着真实DOM的结构,所以所有判断是以newOld为准,但操作以oldChildren为基础(如:新增、移动)。
  • 优化策略 diff 算法
    • 因为只做同层比较不深度遍历,所以时间复杂度为O(n)
    • 先按照4种查找方式进行比较,如果失败再依据newVnode对oldChildren进行循环便利
      • newStart-oldStart、newEnd-oldEnd、newEnd-oldStart、newStart-oldEnd
    • 循环时设置新旧头尾双指针,从两头向中间循环。从而获取未被处理的节点
    • 当newChildren先循环完,说明oldChildren需要删除反之需要新增
  • 重要:
    • diff 算法是先同层比较相同sameVnode->patchVnode->updateChildren,在进入下一层(子节点)然后按照4种方式进行优化比较。如果1种命中就再执行patchVnode->updateChildren进入下一层,没有就按照key进行遍历判断

模版编译原理

模版编译

  • 主要目标就是生成渲染函数,而渲染函数的作用就是每次执行它,就会使用当前最新的状态生成一份vnode,然后使用这个vnode进行渲染
  • 模版编译大体分为三个部分:将模版解析为AST、遍历AST标记静态节点、使用AST生成渲染函数
  • 三个部分分别对应:解析器、优化器、代码生成器
    • 静态节点由于无状态,除了初始化时需要渲染。之后的直接复用就好了

解析器

  • 主要实现将模版解析为AST,主要有HTML解析器、文本解析器、过滤器解析器
    • 文本/过滤器解析器都在HTML解析器的钩子函数中执行
  • HTML解析器会在解析到不同内容时触发响应配置的钩子函数。如:start/end/chars/comment 等钩子函数
    • start钩子:创建AST、处理AST(指令)、管理AST(维护父子结构)
    • end钩子:管理AST(维护父子结构)
  • AST层级结构:我们只需要维护一个栈(stack)即可,用栈来记录层级关系,这个层级关系也可以理解为DOM的深度
    • 在每次触发钩子函数start时,把当前构建的节点推入栈中;每当触发钩子函数end时,就从栈中弹出一个节点
    • 这样就可以保证每当触发钩子函数start时,栈的最后一个节点就是当前正在构建的节点的父节点
  • 解析过程是循环HTML模版字符串内容,每次循环从中截取一小段字符串,然后重复以上步骤直到模版内容截取额结束(本质是通过正则语法对模版字符串进行匹配的过程)
    • 开始标签分为三部分进行匹配处理,为标签名(startTagOpen)、属性(attribute)、结尾(startTagClose)
  • 注意: 解析器生成的结果是一个AST树结构的

image.png

优化器

  • 作用是在AST中找出静态子树并打上标记。主要以下两点好处
    • 每次重新渲染时,不需要为静态子树创建新节点
    • 在虚拟DOM中打补丁(patching)的过程可以跳过
  • 主要分类两个步骤。主要是通过节点类型type判断,分为1、2、3几种类型
    • 在AST中找出所有静态节点并打上标记(marktatic -> isStatic)
    • 在AST中找出所有**静态根节点(必须有子节点)**并打上标记(markStaticRoots)
      • 查找逻辑:找到的第一个有子节点的静态节点,并且它不只有一个静态文本子节点的既是静态根节点
    • 注意:是两次处理逻辑
  • type类型说明(是在 parseHTML 的回调函数内赋值的)
    • 1 - 元素节点
    • 2 - 带变量的动态文本节点
    • 3 - 不带变量的纯文本节点

代码生成器

  • 作用是将AST转换成渲染函数中的内容,这个内容可以称为代码字符串。生成过程是一个递归的过程,从AST顶依次向下处理每一个节点。
    • 对应插入DOM的3种节点类型:_c(标签)、_v(文本)、_e(注释)
  • 生成元素节点,其实就是生成一个_c的函数调用字符串。主要包含:genElement、genData、genChildren
    • genElement按照节点类型调用不同的生成函数
    • genStatic会把所有静态节点都保存到staticRenderFns数组内

程序执行步骤

  • Vue.prototype.$mount -> const { render, staticRenderFns } = compileToFunctions(template) = createCompiler().compileToFunctions = createCompileToFunctionFn()
    • createCompiler() -> const compiled = baseCompile(template) -> baseCompile() 重要步骤:执行上面的三个逻辑
    • createCompileToFunctionFn() -> createFunction()重要步骤:让生成的render代码字符串变为可执行函数
  • 注意:这里的柯里化处理逻辑

patchVnode/updateChildren内的diff比较

  • 这里说的是当根节点oldVnode/newVnode.tag相同时。然后同层走的 patchVnode 子层走的 updateChildren
  • 所有的比较都是依据newVnode进行的,而操作都是通过oldVnode进行的。因为只有oldVnode.elm是存在的,并且优化了节点的使用。VDOM使用的核心思想

sameVnode

  • 判断key/tag/isComment是否相同,判断data是否都定义了

updateChildren

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // 循环遍历
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 上面两种 undef() 的情况,是下面最后一种处理造成的
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 前-前
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 后-后
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // 前-后
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // oldStartVnode 移到原本 最后 位置
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        // 后-前
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // oldEndVnode 移到原本 最前 位置
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 上面都不好使
        // 按照 oldVnode 创建 key 的 map。如:{ key: index }
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        
        // newVode.key 存在就直接从 oldCh[index] 取对应值
        // 不存在就通过 findIdxInOld 去遍历看🈶️符合 sameVnode() 的不
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

        if (isUndef(idxInOld)) { // New element
          // 没有肯定是新的啊
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 再确认判断一下,是否可重复使用
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            // 将 oldCh[idxInOld] 置空,防止下次循环时处理到同一个节点。对上最上面两种
            oldCh[idxInOld] = undefined
            // !注意:这里看似是往 oldCh 最前面 插入,其实应该说是所有 未处理节点 的最前面
            // 因为按照 指针 的移动,这个 oldStartVnode 是当前所有 未处理 节点的第一个
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 也不一样,就创建新的
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        // 移动指针
        newStartVnode = newCh[++newStartIdx]
      }
    }

    // 遍历都结束了,在进行一次剩余项的操作
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

批量异步更新 nextTick 原理

  • 数据触发视图更新流程setter -> Dep.notify -> Watcher -> patch -> 视图
    • 让数据更新和视图更新脱离。实际数据更新是同步的,而视图更新是个异步的过程
    • 按照上面的流程实际更新视图步骤在watcher.update() -> watcher.run()中,并且由于 vue 对视图更新有批量优化处理。让视图进去异步队列在下一次 tick 时调用
    • 由于同一个数据的setter可能多次触发,(因为优化处理)对应的watcher回被重复放入队列。为了防止无效触发在 Watcher 创建时定义了本身的id,并在进入队列时判断全局map[id]方式防止重复,然后在队列执行完后释放
    • 注意: 下面有两个队列各自有自己的锁flag机制,确保了view/this.$nextTick()在本轮事件循环内都只用一个延时机制
  • nextTick 原理
    • 按照优化逻辑需要在下次 tick 时执行,为了让data数据先更新完。那就需要实现一个延迟机制js内延迟的方式就是让任务在下次事件循环时在执行
let callbacks = [];
let pending = false;

// 也是 this.$nextTick()
function nextTick (cb) {
  // 把 cb 放在 callbacks 中,在下次事件循环时在执行
  callbacks.push(cb);
  // pending 是锁定 callbacks 的,只让出现一个
  if (!pending) {
    pending = true;
    // 实际是 timerFunc 函数,会有 Promise/setImmediate/setTimeout 逻辑判断
    setTimeout(flushCallbacks, 0);
  }
}

function flushCallbacks () {
  pending = false;
  const copies = callbacks.slice(0);
  // 可设置源数组为 0; callbacks = [] 是给赋值个新数组
  callbacks.length = 0;
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}
  • 批量异步更新 Watcher 处理
    • 由于通过优化处理让数据/视图更新进行了脱离,但实际每次setter还是多次会触发同个Watcher的进入队列这是没必要的。所有就要有个记录确保同一个Watcher在本次循环时只进入一次,那就给每个Watcher设定唯一id用于区分
    • flushSchedulerQueue 源码内有个从小到大的排序处理
      • 组件的更新由父到子。因为父组件创建早于子组件,所以对应的Watcher也同样是这样的顺序
      • 用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的
      • 如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行
let uid = 0;

class Watcher {
  constructor() {
    this.id = ++id;
  }
  
  update() {
    queueWatcher(this)
  }
  
  run() {
    // 实际更新视图
  }
}

// 防止 Watcher 重复,验证用的 map
let has = {};
// 存放 Watcher 的队列
let queue = [];
let waiting = false;

// watcher.update() -> queueWatcher(this)
function queueWatcher(watcher) {
  const id = watcher.id;
  // 防止重复进入 queue
  if (has[id] == null) {
    has[id] = true;
    queue.push(watcher);
    // 这个 waiting 用于锁定 queue 队列的,让本次视图更新都在下一次 tick 执行
    if (!waiting) {
      waiting = true;
      nextTick(flushSchedulerQueue);
    }
  }
}

// 实际执行 watcherQueue 的,会在下次事件循环执行
function flushSchedulerQueue () {
  let watcher, id;

  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    id = watcher.id;
    // 重置标识位
    has[id] = null;
    watcher.run();
  }

  waiting  = false;
}