Vue2核心源码解析

Flow

是什么

一种静态检查工具,工作中不会用到,类似于Typescript。Flow官网open in new window

使用flow的原因

  • Js是一个动态类型语言,灵活的背后带来的是难以寻找的bug;
  • 类型检查时当前动态类型语言发展的趋势,帮助开发者在编译时发现由于类型错误引起的bug;
  • 项目越复杂越需要通过工具的手段来保证项目的维护和代码的可读性。

INFO

可以看一下尤雨溪本人的解释:知乎open in new window:

  1. Vue 2.0 本身在初期的快速迭代阶段是用 ES2015 写的,整个构建工具链也沿用了 Vue 1.x 的基于 ES 生态的一套(Babel, ESLint, Webpack, Rollup...)
  2. Babel和ESlint都有对应的Flow插件以及支持的语法。

Flow 在 Vue.js 源码中的应用

在Vue2的更早的版本中,linkopen in new window,主目录下有.flowconfig文件,用于配置Flow.

Flow的libdef可以用于识别第三库或自定义类型。

源码学习思路

  1. 了解核心流程API,把库的API用熟(顺藤摸瓜)
  2. 整体执行流程,(lodash、underscore等工具库除外,无太多学习参考意义),如:
    • webpack/vite/gulp:从启动到打包输出的流程
    • Vue2:Vue2从编译 => 初始化 => 触发更新 => 卸载的流程
  3. 看package.json,看依赖和命令

Vue的目录结构设计

源码open in new windowsrc下:

文件夹内容
compiler编译相关
core核心代码
platforms解决跨平台
server服务端渲染
sfc.vue文件解析
shared共享代码

compiler

补充知识:编译可以在构建时(借助webpack、vue-loader等辅助工具插件),也可以在运行时使用包含构建功能的Vue.js。编译时一项耗性能的工作,所以推荐前者————离线编译。(本篇文章中搜索👺)

Vue.js所有编译部分的代码。包括将模板解析成ast语法树,ast语法树优化,代码生成功能:🐰

  • vue2中是通过正则方式实现,vue3更加严谨:通过状态机制来做tokens的解析,词法语法分析(也包括部分正则);
  • compiler/parser转换 => compiler/directive AST解析 => compiler/codergen 生成;
  • 完整的编译过程:将代码转成 => AST => 目标AST => 生成代码

core

核心代码,Vue.js的灵魂。
  • core/observer 响应式部分,dep.ts侦听依赖内容,scheduler.ts调度
  • core/instance 生命周期、proxy、初始化挂载等
  • core/vdom 虚拟dom等

platforms

Vue.js是跨平台的类MVVM框架,可以跑在 web 上,也可以配合 weex 跑在 native 客户端上。多端适配处理,非重点。

server

  • Vue2 支持了服务端渲染,这个目录下是所有服务端渲染的逻辑;
  • 这个文件夹下的代码 是跑在服务端的 Node.js,不是跑在浏览器端的vue.js;
  • 服务端渲染的主要工作是 将组件渲染为 服务端的HTML字符串,将他们直接发送到浏览器,最后静态标记混合为客户端上完全交互的应用程序。

sfc

.vue文件内容解析成一个 JS 对象。

shared

定义一些工具方法,被浏览器和服务端的Vue.js共享。如生命周期hooks等。

Vue源码构建

构建脚本和过程

Vue.js 源码是基于 Rollup 构建的,它的构建相关配置都在 scripts 目录下。

  • 当在命令行运行 npm run build 的时候,实际上就会执行 node scripts/build.js
//package.json
"build": "node scripts/build.js",
"build:ssr": "npm run build -- web-runtime-cjs,web-server-renderer",
"build:weex": "npm run build -- weex",
  • scripts/build.js:先从配置文件读取配置,再通过命令行参数对构建配置做过滤,这样就可以构建出不同用途的 Vue.js 了
let builds = require('./config').getAllBuilds()

// filter builds via command line arg
if (process.argv[2]) {
  const filters = process.argv[2].split(',')
  builds = builds.filter(b => {
    return filters.some(f => b.output.file.indexOf(f) > -1 || b._name.indexOf(f) > -1)
  })
} else {
  // filter out weex builds by default
  builds = builds.filter(b => {
    return b.output.file.indexOf('weex') === -1
  })
}

build(builds)
  • 接下来我们看一下配置文件,在 scripts/config.js 中:
const builds = {
  // Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
  'web-runtime-cjs': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.common.js'),
    format: 'cjs',
    banner
  },
  // Runtime+compiler CommonJS build (CommonJS)
  'web-full-cjs': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.common.js'),
    format: 'cjs',
    alias: { he: './entity-decoder' },
    banner
  },
  // Runtime only (ES Modules). Used by bundlers that support ES Modules,
  // e.g. Rollup & Webpack 2
  'web-runtime-esm': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.esm.js'),
    format: 'es',
    banner
  },
  // Runtime+compiler CommonJS build (ES Modules)
  'web-full-esm': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.esm.js'),
    format: 'es',
    alias: { he: './entity-decoder' },
    banner
  },
  // runtime-only build (Browser)
  'web-runtime-dev': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.js'),
    format: 'umd',
    env: 'development',
    banner
  },
  // runtime-only production build (Browser)
  'web-runtime-prod': {
    entry: resolve('web/entry-runtime.js'),
    dest: resolve('dist/vue.runtime.min.js'),
    format: 'umd',
    env: 'production',
    banner
  },
  // Runtime+compiler development build (Browser)
  'web-full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: { he: './entity-decoder' },
    banner
  },
  // Runtime+compiler production build  (Browser)
  'web-full-prod': {
    entry: resolve('web/entry-runtime-with-compiler.js'),
    dest: resolve('dist/vue.min.js'),
    format: 'umd',
    env: 'production',
    alias: { he: './entity-decoder' },
    banner
  },
}
/*这里列举了一些 Vue.js 构建的配置,对于单个配置,它是遵循 Rollup 的构建规则的。
其中 entry 属性表示构建的入口 JS 文件地址,dest 属性表示构建后的 JS 文件地址。
format 属性表示构建的格式,cjs 表示构建出来的文件遵循 CommonJS 规范,
es 表示构建出来的文件遵循 ES Module 规范。 umd 表示构建出来的文件遵循 UMD 规范。*/
  • 经过 Rollup 的构建打包后,最终会在 dist 目录下生成 dist/vue.runtime.common.dev.js。

Runtime Only VS Runtime + Compiler 👺

vue-cli 去初始化我们的 Vue.js 项目的时候会询问我们用 Runtime Only 版本的还是 Runtime + Compiler 版本。下面我们来对比这两个版本。

  • Runtime Only: 使用此版本的Vue.js,需借助 webpack 的 vue-loader 工具把 .vue文件编译成 JS, 因为是在编译阶段做的,所以代码体积更轻;

  • Runtime + Compiler:如果没有对代码做预编译,但又使用了 Vue 的 template 属性并传入一个字符串,则需要在客户端编译模板,如下所示:

    // 需要编译器的版本
    new Vue({
      template: '<div>{{ hi }}</div>'
    })
    
    // 这种情况不需要
    new Vue({
      render (h) {
        return h('div', this.hi)
      }
    })
    
  • Vue2.0中,最终渲染都是通过render函数,若写 template 属性,最终需编译成 render,那么这个编译过程发生运行时,需要带有编译器的版本。所以更推荐Runtime Only

根据执行过程分析源码

共九步。

1. 首先编写的是.vue文件

2. 用 sfc 将 .vue => JS

3. compiler用于编译处理

全文搜索🐰

4. 启动项目,初始化应用,new Vue()

(下面需补充)

  1. 启动项目,初始化应用,new Vue()
    • core/index.ts => core/global-api:initGlobalAPI:
      • initUse(Vue) - 初始化插件机制 - Vue.use(xxx)
      • initMixin(Vue) - Vue.mixin
      • initExtend(Vue) - 继承机制 - Vue.extend
      • initAssetRegisters(Vue) - vue2中,src/shared/constens.ts将资源(ASSET_TYPES)规定为三类(component、directive、filter),这里初始化是针对这个
  2. 处理初始化逻辑,包含生命周期、Mixin等内容
    • core/instance/index.ts
  3. 创建对应的vdom,挂载
    • core/instance/render.ts 挂载 => creatElement => core/vdom/create-elemnent.ts - 初始化创建的逻辑
  4. 一旦发生更新,即data数据变更,通过监听触发
    • Vue2源码看老的版本,新版本大部分是Proxy重构;看这主要逻辑open in new window
    • patch逻辑open in new window - diff算法处理时:🚩
      //🚩面试常问:Vue中 :key 的作用:score/vdom/patch.ts的第36行,尤雨溪定义了一个sameVnode方法,
      //根节点初筛,sameVnode 对key,tag,type等进行简单的判断,通过了就进行具体差异对比, 不通过就新建dom。
      /*作用总结:Vue响应式系统中,大量源码做性能优化的事情,其中就包含:key,在diff过程中,始终以最以最小代价、
        最大程度去进行节点复用(patch),
        而key就作为我们判断节点是否可以复用的一大标准(除了判断key相同 还有其他判断如💛)。
      */
      function sameVnode(a, b) {
       return (
         a.key === b.key && //key全等 这也是不能用index作为key的原因🚩
         a.asyncFactory === b.asyncFactory &&
         ((a.tag === b.tag &&//💛
           a.isComment === b.isComment &&//💛
           isDef(a.data) === isDef(b.data) && //💛
           //data保存了元素上的属性 如attrs: { class: 'box' }, staticStyle: { color: 'red'}等
           //isDef作用: 判断值是否被定义
           sameInputType(a, b)) || //input type属性是否相同
           (isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
       )
      
  5. 收集依赖
  6. 处理更新diff

Vue2 Diff原理

INFO

diff目的:尽可能减少 dom 变更引起的 reflow 和 repaint,找到可以复用的节点。

双端diff:新旧列表的头与尾互相对比,在对比过程中指针会逐渐向内靠拢,直到某一个列表的节点全部遍历过,对比停止。(核心updateChildren⭐)

缺点:Vue2 Diff是全量Diff(当数据发生变化,就会产生 DOM 树,并和之前的 DOM 树比较找到不同节点更新)。若层级很高,非常消耗内存。

以下是具体分析:

1.patch

先判断是否首次渲染,首次直接 creatElm;若不是就判断新老两个节点的元素类型是否一样;如果两个节点都一样,就深入检查对应的子节点。若两个节点不一样则说明 Vnode 完全被改变了,直接替换 oldVnode 即可。

function patch(oldVnode, vnode, hydrating, removeOnly) {
  // 判断新的vnode是否为空
  if (isUndef(vnode)) {//新vnode为空
    // 如果老的vnode不为空 卸载所有的老vnode
    if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
    return
  }
  let isInitialPatch = false
  // 用来存储 insert钩子函数,在插入节点之前调用
  const insertedVnodeQueue = []
  // 如果老节点不存在,直接创建新节点
  if (isUndef(oldVnode)) {
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    // 是不是元素节点
    const isRealElement = isDef(oldVnode.nodeType)
    // 当老节点不是真实的DOM节点,并且新老节点的type和key相同,进行patchVnode更新工作
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // 如果不是同一元素节点的话
      // 当老节点是真实DOM节点的时候
      if (isRealElement) {
        // 如果是元素节点 并且在SSR环境的时候 修改SSR_ATTR属性
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          // 就是服务端渲染的,删掉这个属性
          oldVnode.removeAttribute(SSR_ATTR)
          hydrating = true
        }
        // 这个判断里是服务端渲染的处理逻辑
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true)
            return oldVnode
          }
        }
        // 如果不是服务端渲染的,或者混合失败,就创建一个空的注释节点替换 oldVnode
        oldVnode = emptyNodeAt(oldVnode)
      }

      // 拿到 oldVnode 的父节点
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)

      // 根据新的 vnode 创建一个 DOM 节点,挂载到父节点上
      createElm(
        vnode,
        insertedVnodeQueue,
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )
      // 如果新的 vnode 的根节点存在,就是说根节点被修改了,就需要遍历更新父节点
      // 递归 更新父占位符元素
      // 就是执行一遍 父节点的 destory 和 create 、insert 的 钩子函数
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent
        const patchable = isPatchable(vnode)
        // 更新父组件的占位元素
        while (ancestor) {
          // 卸载老根节点下的全部组件
          for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor)
          }
          // 替换现有元素
          ancestor.elm = vnode.elm
          if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
              cbs.create[i](emptyNode, ancestor)
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            const insert = ancestor.data.hook.insert
            if (insert.merged) {
              // start at index 1 to avoid re-invoking component mounted hook
              for (let i = 1; i < insert.fns.length; i++) {
                insert.fns[i]()
              }
            }
          } else {
            registerRef(ancestor)
          }
          // 更新父节点
          ancestor = ancestor.parent
        }
      }
      // 如果旧节点还存在,就删掉旧节点
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        // 否则直接卸载 oldVnode
        invokeDestroyHook(oldVnode)
      }
    }
  }
  // 执行 虚拟 dom 的 insert 钩子函数
  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  // 返回最新 vnode 的 elm ,也就是真实的 dom节点
  return vnode.elm
}

2.patchNode

  • VnodeoldVnode指向同一个对象,直接return;
  • 将旧节点的真实 DOM 赋值到新节点(真实dom连线到新子节点)称为 elm,然后遍历调用 update 更新oldVnode上的所有属性,如class、style、attrs、domProps、events...
  • 若新老节点都有文本节点,且文本不相同,就用vnode.text更新文本内容;
  • 若 oldVnode 有子节点而 Vnode没有,直接删除老节点;
  • 若 oldVnode 没有子节点而 Vnode有,将 Vnode 的子节点真实化后添加到 DOM 中即可;
  • 若两者都有子节点,执行updateChildren比较子节点。
function patchVnode(
  oldVnode, // 老的虚拟 DOM 节点
  vnode, // 新节点
  insertedVnodeQueue, // 插入节点队列
  ownerArray, // 节点数组
  index, // 当前节点的下标
  removeOnly
) {
  // 新老节点对比地址一样,直接跳过
  if (oldVnode === vnode) {
    return
  }
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // clone reused vnode
    vnode = ownerArray[index] = cloneVNode(vnode)
  }
  const elm = vnode.elm = oldVnode.elm
  // 如果当前节点是注释或 v-if 的,或者是异步函数,就跳过检查异步组件
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }
  // 当前节点是静态节点的时候,key 也一样,或者有 v-once 的时候,就直接赋值返回
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance
    return
  }
  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }
  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isDef(data) && isPatchable(vnode)) {
    // 遍历调用 update 更新 oldVnode 所有属性,比如 class,style,attrs,domProps,events...
    // 这里的 update 钩子函数是 vnode 本身的钩子函数
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    // 这里的 update 钩子函数是我们传过来的函数
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }
  // 如果新节点不是文本节点,也就是说有子节点
  if (isUndef(vnode.text)) {
    // 如果新老节点都有子节点
    if (isDef(oldCh) && isDef(ch)) {
      // 如果新老节点的子节点不一样,就执行 updateChildren 函数,对比子节点
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      // 如果新节点有子节点的话,就是说老节点没有子节点

      // 如果老节点是文本节点,就是说没有子节点,就清空
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      // 添加新节点
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      // 如果新节点没有子节点,老节点有子节点,就删除
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 如果老节点是文本节点,就清空
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 如果老节点的文本和新节点的文本不同,就更新文本
    nodeOps.setTextContent(elm, vnode.text)
  }
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

3.updateChildren

注意,这是针对新旧虚拟节点都有子节点时,比较子节点差异的函数。以下是手动实现 Vue2 中的 updateChildren 过程。

3.1 实现思路

1️⃣先用 4 个指针指向两个列表的头尾:

function vue2Diff(prevChildren, nextChildren, parent) {
  let oldStartIndex = 0,
    oldEndIndex = prevChildren.length - 1
    newStartIndex = 0,
    newEndIndex = nextChildren.length - 1;
  let oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldEndIndex],
    newStartNode = nextChildren[nextStartIndex],
    newEndNode = nextChildren[nextEndIndex];
}

2️⃣根据 4 个指针找到 4 个节点,然后进行对比,以下是对比的步骤:

🦝比较顺序口诀:首首比较、尾尾比较、尾首比较、首尾比较
  1. 使用旧列表的头一个节点oldStartNode与新列表的头一个节点newStartNode对比
  2. 使用旧列表的最后一个节点oldEndtNode与新列表的最后一个节点newEndNode对比
  3. 使用新列表的最后一个节点newEndNode与旧列表的头一个节点oldStartNode对比
  4. 使用新列表的头一个节点newStartNode与旧列表的最后一个节点oldEndtNode对比

3️⃣使用以上四步对比,去寻找 key 相同的可复用节点,当在某一步找到了就停止后面的寻找,如下图:

对比顺序代码如下:
function vue2Diff(prevChildren, nextChildren, parent) {
  let oldStartIndex = 0,
    oldEndIndex = prevChildren.length - 1
    newStartIndex = 0,
    newEndIndex = nextChildren.length - 1;
  let oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldEndIndex],
    newStartNode = nextChildren[newStartIndex],
    newEndNode = nextChildren[newEndIndex];
  
  if (oldStartNode.key === newStartNode.key) {
  } else if (oldEndNode.key === newEndNode.key) {
  } else if (oldStartNode.key === newEndNode.key) {
  } else if (oldEndNode.key === newStartNode.key) {
  }
}

4️⃣当对比时找到了可复用的节点,我们还是先patch给元素打补丁,然后将指针进行前/后移一位指针。根据对比节点的不同,我们移动的指针和方向也不同,具体规则如下:

  1. 当旧列表的头一个节点oldStartNode与新列表的头一个节点newStartNode对比时key相同。那么旧列表的头指针oldStartIndex与新列表的头指针newStartIndex同时向后移动一位;
  2. 当旧列表的最后一个节点oldEndNode与新列表的最后一个节点newEndNode对比时key相同。那么旧列表的尾指针oldEndIndex与新列表的尾指针newEndIndex同时向前移动一位;
  3. 当旧列表的头一个节点oldStartNode与新列表的最后一个节点newEndNode对比时key相同。那么旧列表的头指针oldStartIndex向后移动一位;新列表的尾指针newEndIndex向前移动一位;
  4. 当旧列表的最后一个节点oldEndNode与新列表的头一个节点newStartNode对比时key相同。那么旧列表的尾指针oldEndIndex向前移动一位;新列表的头指针newStartIndex向后移动一位;
🦝tips:指针逐渐向内靠拢,在数学中属于函数的发散与收敛的收敛性。

5️⃣上面提到,要让指针向内靠拢,所以我们需要循环。循环停止的条件是当其中一个列表的节点全部遍历完成,代码如下:

function vue2Diff(prevChildren, nextChildren, parent) {
  let oldStartIndex = 0,
    oldEndIndex = prevChildren.length - 1,
    newStartIndex = 0,
    newEndIndex = nextChildren.length - 1;
  let oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldEndIndex],
    newStartNode = nextChildren[newStartIndex],
    newEndNode = nextChildren[newEndIndex];
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (oldStartNode.key === newStartNode.key) {
      patch(oldStartNode, newStartNode, parent)

      oldStartIndex++
      newStartIndex++
      oldStartNode = prevChildren[oldStartIndex]
      newStartNode = nextChildren[newStartIndex]
    } else if (oldEndNode.key === newEndNode.key) {
      patch(oldEndNode, newEndNode, parent)

      oldEndIndex--
      newndIndex--
      oldEndNode = prevChildren[oldEndIndex]
      newEndNode = nextChildren[newEndIndex]
    } else if (oldStartNode.key === newEndNode.key) {
      patch(oldvStartNode, newEndNode, parent)

      oldStartIndex++
      newEndIndex--
      oldStartNode = prevChildren[oldStartIndex]
      newEndNode = nextChildren[newEndIndex]
    } else if (oldEndNode.key === newStartNode.key) {
      patch(oldEndNode, newStartNode, parent)

      oldEndIndex--
      newStartIndex++
      oldEndNode = prevChildren[oldEndIndex]
      newStartNode = nextChildren[newStartIndex]
    }
  }   
}

6️⃣至此整体的循环我们就全部完成了,下面我们需要考虑这样两个问题:

  • 什么情况下DOM节点需要移动;
  • DOM节点如何移动(见目录3.3和3.4)

🔴首先看第一个问题:

🔴第一个循环结束后:

在第四步发现旧列表的尾节点oldEndNode与新列表的头节点newStartNode的key相同(d节点),是可复用的DOM节点。通过观察,原本在旧列表末尾的节点,是新列表开头的节点,没有节点比他更靠前。因为他是第一个,所以只需把当前的节点移动到旧列表的第一个节点之前,让 DOM-D 成为第一个节点即可。

function vue2Diff(prevChildren, nextChildren, parent) {
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (oldStartNode.key === newStartNode.key) {
      // ...
    } else if (oldEndNode.key === newEndNode.key) {
      // ...
    } else if (oldStartNode.key === newEndNode.key) {
      // ...
    } else if (oldEndNode.key === newStartNode.key) {
      patch(oldEndNode, newStartNode, parent)
      // 移动到旧列表头节点之前
      parent.insertBefore(oldEndNode.el, oldStartNode.el)
      
      oldEndIndex--
      newStartIndex++
      oldEndNode = prevChildren[oldEndIndex]
      newStartNode = nextChildren[newStartIndex]
    }
  }
}

🔴进入第二次循环: 在第二步发现,oldEndNodenewEndNode是复用节点(如上图c节点)。原本在旧列表中就是尾部,在新列表中也是尾部,说明C节点不需要移动。 oldStartNodenewStartNode同理。

🔴进入第三次循环: 在第三步发现,a为复用节点,将 DOM-A 移动到 DOM-B 即可。

🔴进入最后一次循环: 在第一步旧列表头节点oldStartNode与新列表头节点newStartNode位置相同,所以啥也不用做。然后结束循环。

3.2 非理想情况

1️⃣上文存在四次对比都没有找到可复用节点的特殊情况。这时需拿新列表的第一个节点去和旧列表比对,找出相同 key。

2️⃣接第1️⃣步,有两种结果,找到key和没找到key。找到了,们只需要将找到的节点的DOM元素,移动到开头;DOM移动后,由我们将旧列表中的节点改为undefined,这是至关重要的一步,因为我们已经做了节点的移动了所以我们不需要进行再次的对比了。最后我们将头指针newStartIndex向后移一位。

3️⃣没有找到,直接创建一个新的节点放到最前面就可以了,然后后移头指针newStartIndex

4️⃣当旧列表遍历到undefined就跳过当前节点。

function vue2Diff(prevChildren, nextChildren, parent) {
  //4.当旧列表遍历到undefined就跳过当前节点
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (oldStartNode.key === newStartNode.key) {...}
    else if (oldEndNode.key === newEndNode.key) {...}
    else if (oldStartNode.key === newEndNode.key) {...}
    else if (oldEndNode.key === newStartNode.key) {...}
    else {
      // 1.在旧列表中找到 和新列表头节点key 相同的节点
      let newtKey = newStartNode.key,
        oldIndex = prevChildren.findIndex(child => child.key === newKey);
      if (oldIndex > -1) {
        //2.找到的情况
        let oldNode = prevChildren[oldIndex];
        patch(oldNode, newStartNode, parent)
        parent.insertBefore(oldNode.el, oldStartNode.el)
        prevChildren[oldIndex] = undefined
      } else {
        //3.没有找到的情况
        mount(newStartNode, parent, oldStartNode.el)
      }
      newStartNode = nextChildren[++newStartIndex]
    }
  }
}

3.3 添加节点

oldEndIndex小于oldStartIndex,但是新列表中还有剩余的节点,我们只需要将剩余的节点依次插入到oldStartNode的DOM之前就可以了。为什么是插入oldStartNode之前呢?原因是剩余的节点在新列表的位置是位于oldStartNode之前的,如果剩余节点是在oldStartNode之后,oldStartNode就会先行对比。

function vue2Diff(prevChildren, nextChildren, parent) {
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
  }
  if (oldEndIndex < oldStartIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      mount(nextChildren[i], parent, prevStartNode.el)
  }}}

3.4 移除节点

当新列表的newEndIndex小于newStartIndex时,我们将旧列表剩余的节点删除即可。这里我们需要注意,旧列表的undefind。当头尾节点都不相同时,我们会去旧列表中找新列表的第一个节点,移动完DOM节点后,将旧列表的那个节点改为undefind。所以我们在最后的删除时,需要注意这些undefind,遇到的话跳过当前循环即可。

function vue2Diff(prevChildren, nextChildren, parent) {
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
  }
  if (oldEndIndex < oldStartIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      mount(nextChildren[i], parent, prevStartNode.el)
    }
  } else if (newEndIndex < newStartIndex) {
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      if (prevChildren[i]) {
        partent.removeChild(prevChildren[i].el)
  }}}}