# mini-vue3

# 真实的DOM渲染

# 虚拟DOM的优势

  1. 首先可以对真实的元素节点进行抽象,抽象成VNode(虚拟节点),这样方便后续对其进行各种操作:
    • 因为对于直接操作DOM来说是有很多的限制的,比如diff、clone等等,但是使用JavaScript编程语言来操作这些,就变得非常的简单;
    • 可以使用JavaScript来表达 非常多的逻辑 ,而对于DOM本身来说是非常不方便的;
  2. 其次是方便实现跨平台,包括你可以将VNode节点渲染成任意你想要的节点
    • 如渲染在canvas、WebGL、SSR、Native(iOS、Android)上;
    • 并且Vue允许开发属于自己的 渲染器(renderer),在其他的平台上渲染;

# 三大核心系统

事实上Vue的源码包含三大核心:

  • Compiler模块:编译模板系统;
  • Runtime模块:也可以称之为Renderer模块,真正渲染的模块;
  • Reactivity模块:响应式系统;
  1. 渲染器
  • mount函数的实现:
    • 第一步:根据tag,创建HTML元素,并且存储到vnode的el中;
    • 第二步:处理props属性 如果以on开头,那么监听事件;普通属性直接通过 setAttribute 添加即可;
    • 第三步:处理子节点 如果是字符串节点,那么直接设置textContent;如果是数组节点,那么遍历调用 mount 函数;
  • patch函数的实现,分为两种情况
    1. n1和n2是不同类型的节点:
      • 找到n1的el父节点,删除原来的n1节点的el;
      • 挂载n2节点到n1的el父节点上;
    2. n1和n2节点是相同的节点:
      • 处理props的情况
        • 先将新节点的props全部挂载到el上;
        • 判断旧节点的props是否不需要在新节点上,如果不需要,那么删除对应的属性;
      • 处理children的情况
        • 如果新节点是一个字符串类型,那么直接调用 el.textContent = newChildren;
        • 如果新节点不是字符串类型:
          1. 旧节点是一个字符串类型
            • 将el的textContent设置为空字符串;
            • 旧节点是一个字符串类型,那么直接遍历新节点,挂载到el上;
          2. 旧节点也是一个数组类型
            • 取出数组的最小长度;
            • 遍历所有的节点,新节点和旧节点进行path操作;
            • 如果新节点的length更长,那么剩余的新节点进行挂载操作;
            • 如果旧节点的length更长,那么剩余的旧节点进行卸载操作;
  1. renderer.js
const h = (tag, props, children) => {
  // vnode -> javascript对象 -> {}
  return {
    tag,
    props,
    children
  }
}

const mount = (vnode, container) => {
  // vnode -> element
  // 1.创建出真实的原生, 并且在vnode上保留el
  const el = vnode.el = document.createElement(vnode.tag);

  // 2.处理props
  if (vnode.props) {
    for (const key in vnode.props) {
      const value = vnode.props[key];

      if (key.startsWith("on")) { // 对事件监听的判断
        el.addEventListener(key.slice(2).toLowerCase(), value)
      } else {
        el.setAttribute(key, value);
      }
    }
  }

  // 3.处理children
  if (vnode.children) {
    if (typeof vnode.children === "string") {
      el.textContent = vnode.children;
    } else {
      vnode.children.forEach(item => {
        mount(item, el);
      })
    }
  }

  // 4.将el挂载到container上
  container.appendChild(el);
}

const patch = (n1, n2) => {
  if (n1.tag !== n2.tag) {
    const n1ElParent = n1.el.parentElement;
    n1ElParent.removeChild(n1.el);
    mount(n2, n1ElParent);
  } else {
    // 1.取出element对象, 并且在n2中进行保存
    const el = n2.el = n1.el;

    // 2.处理props
    const oldProps = n1.props || {};
    const newProps = n2.props || {};
    // 2.1.获取所有的newProps添加到el
    for (const key in newProps) {
      const oldValue = oldProps[key];
      const newValue = newProps[key];
      if (newValue !== oldValue) {
        if (key.startsWith("on")) { // 对事件监听的判断
          el.addEventListener(key.slice(2).toLowerCase(), newValue)
        } else {
          el.setAttribute(key, newValue);
        }
      }
    }

    // 2.2.删除旧的props
    for (const key in oldProps) {
      if (key.startsWith("on")) { // 对事件监听的判断
        const value = oldProps[key];
        el.removeEventListener(key.slice(2).toLowerCase(), value)
      } 
      if (!(key in newProps)) {
        el.removeAttribute(key);
      }
    }

    // 3.处理children
    const oldChildren = n1.children || [];
    const newChidlren = n2.children || [];

    if (typeof newChidlren === "string") { // 情况一: newChildren本身是一个string
      // 边界情况 (edge case)
      if (typeof oldChildren === "string") {
        if (newChidlren !== oldChildren) {
          el.textContent = newChidlren
        }
      } else {
        el.innerHTML = newChidlren;
      }
    } else { // 情况二: newChildren本身是一个数组
      if (typeof oldChildren === "string") {
        el.innerHTML = "";
        newChidlren.forEach(item => {
          mount(item, el);
        })
      } else {
        // oldChildren: [v1, v2, v3, v8, v9]
        // newChildren: [v1, v5, v6]
        // 1.前面有相同节点的原生进行patch操作
        const commonLength = Math.min(oldChildren.length, newChidlren.length);
        for (let i = 0; i < commonLength; i++) {
          patch(oldChildren[i], newChidlren[i]);
        }

        // 2.newChildren.length > oldChildren.length
        if (newChidlren.length > oldChildren.length) {
          newChidlren.slice(oldChildren.length).forEach(item => {
            mount(item, el);
          })
        }

        // 3.newChildren.length < oldChildren.length
        if (newChidlren.length < oldChildren.length) {
          oldChildren.slice(newChidlren.length).forEach(item => {
            el.removeChild(item.el);
          })
        }
      }
    }
  }
}
  1. reactive.js
 let temnum =1
	class Dep {
	  constructor() {
	    this.subscribers = new Set();
		this.usename='demo'+(temnum++)
	  }
	
	  depend() {
	    if (activeEffect) {
	      this.subscribers.add(activeEffect);
	    }
	  }
	
	  notify() {
	    this.subscribers.forEach(effect => {
	      effect();
		  console.log('how',this.usename)
	    })
	  }
	}
	
	let activeEffect = null;
	function watchEffect(effect) {
	  activeEffect = effect;
	  effect();
	  activeEffect = null;
	}
	
	// Map({key: value}): key是一个字符串
	// WeakMap({key(对象): value}): key是一个对象, 弱引用
	// 当依赖的key(对象)=null时,weakMap会随后将引用释放
	const targetMap = new WeakMap();
	function getDep(target, key) {
	  // 1.根据对象(target)取出对应的Map对象
	  let depsMap = targetMap.get(target);
	  if (!depsMap) {
	    depsMap = new Map();
	    targetMap.set(target, depsMap);
	  }
	  // 2.取出具体的dep对象
	  let dep = depsMap.get(key);
	  if (!dep) {
	    dep = new Dep();
	    depsMap.set(key, dep);
	  }
	  return dep;
	}
	
	// vue2对raw进行数据劫持
	function reactive(raw) {
	  Object.keys(raw).forEach(key => {
	    const dep = getDep(raw, key);
	    let value = raw[key];
	
	    Object.defineProperty(raw, key, {
	      get() {
	        dep.depend();
	        return value;
	      },
	      set(newValue) {
	        if (value !== newValue) {
	          value = newValue;
	          dep.notify();
	        }
	      }
	    })
	  })
	  return raw;
	}
	
	// // 测试代码
	// const info = reactive({counter: 100, name: "why"});
	// let foo = reactive({height: 1.88});
	
	// // watchEffect1
	// watchEffect(function () {
	//   console.log("effect1:", info.counter * 2, info.name);
	// })
	
	// watchEffect(function () {
	//   console.log("effect2:", info.counter * info.counter);
	// })
	
	// // watchEffect3
	// watchEffect(function () {
	//   console.log("effect3:", info.counter + 10, info.name);
	// })
	
	// watchEffect(function () {
	//   console.log("effect4:", foo.height);
	// })
	
	// info.counter++;
	// info.name = "why1";
	// foo.height = 2;

  1. index.js
function createApp(rootComponent) {
  return {
    mount(selector) {
      const container = document.querySelector(selector);
      let isMounted = false;
      let oldVNode = null;

      watchEffect(function() {
        if (!isMounted) {
          oldVNode = rootComponent.render();
          mount(oldVNode, container);
          isMounted = true;
        } else {
          const newVNode = rootComponent.render();
          patch(oldVNode, newVNode);
          oldVNode = newVNode;
        }
      })
    }
  }
}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  
  <div id="app"></div>
  <script src="./renderer.js"></script>
  <script src="./reactive.js"></script>
  <script src="./index.js"></script>

  <script>
    // 1.创建根组件
    const App = {
      data: reactive({
        counter: 0
      }),
      render() {
        return h("div", null, [
          h("h2", null, `当前计数: ${this.data.counter}`),
          h("button", {
            onClick: () => {
              this.data.counter++
              console.log(this.data.counter);
            }
          }, "+1")
        ])
      }
    }

    // 2.挂载根组件
    const app = createApp(App);
    app.mount("#app");
  </script>

</body>
</html>

# vue3源码解读

# setup与data优先级

vue-next-3.0.11\packages\runtime-core\src\componentPublicInstance.ts

     ... } else if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
        accessCache![key] = AccessTypes.SETUP//setup
        return setupState[key]
      } else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
        accessCache![key] = AccessTypes.DATA//data
        return data[key]
      } else if (
        // only cache other properties when instance has declared (thus stable)
        // props
        (normalizedProps = instance.propsOptions[0]) &&
        hasOwn(normalizedProps, key)
      ) {
        accessCache![key] = AccessTypes.PROPS//props
        return props![key]
      } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
        accessCache![key] = AccessTypes.CONTEXT//methods/computed
        return ctx[key]
      } else if (!__FEATURE_OPTIONS_API__ || shouldCacheAccess) {
        accessCache![key] = AccessTypes.OTHER
      }

# vue3与2的区别

  1. 源码体积优化
  • 移除一些冷门的 feature(比如 filter、inline-template 等)
  • 引入 tree-shaking 的技术,减少打包体积

tree-shaking 依赖 ES2015 模块语法的静态结构(即 import 和 export),通过编译阶段的静态分析,找到没有引入的模块并打上标记。

举个例子,一个 math 模块定义了 2 个方法 square(x) 和 cube(x) :

export function square(x) {
  return x * x
}
export function cube(x) {
  return x * x * x
}

在这个模块外面只引入了 cube 方法:

import { cube } from './math.js'
// do something with cube

最终 math 模块会被 webpack 打包生成如下代码

/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
  'use strict';
  /* unused harmony export square */
  /* harmony export (immutable) */ __webpack_exports__['a'] = cube;
  function square(x) {
    return x * x;
  }
  function cube(x) {
    return x * x * x;
  }
});

可以看到,未被引入的 square 模块被标记了, 然后压缩阶段会利用例如 uglify-jsterser 等压缩工具真正地删除这些没有用到的代码。也就是说,利用 tree-shaking 技术,如果项目中没有引入 Transition、KeepAlive 等组件,那么它们对应的代码就不会打包,这样也就间接达到了减少项目引入的 Vue.js 包体积的目的。

  1. 数据劫持优化 v2版本:Object.defineProperty,劫持数据的 getter 和 setter.但这个 API 有一些缺陷,它必须预先知道要拦截的 key 是什么,所以它并不能检测对象属性的添加和删除。 如果要劫持它内部深层次的对象变化,就需要递归遍历这个对象,执行 Object.defineProperty 把每一层对象数据都变成响应式的。毫无疑问,如果定义的响应式数据过于复杂,这就会有相当大的性能负担。

v3版本:proxy,Proxy API 并不能监听到内部深层次的对象变化,因此 Vue.js 3 的处理方式是在 getter 中去 递归响应式 ,这样的好处是真正访问到的内部对象才会变成响应式,而不是无脑递归,无疑也在很大程度上提升了性能

  1. 编译优化

Vue.js 2.x 从 new Vue 开始渲染成 DOM 的流程,上面说过的响应式过程就发生在图中的 init 阶段,另外 template compile to render function 的流程是可以借助 vue-loader 在 webpack 编译阶段离线完成,并非一定要在运行时完成。

所以想优化整个 Vue.js 的运行时,除了数据劫持部分的优化,可以在耗时相对较多的 patch 阶段想办法,Vue.js 3.0 也是这么做的,并且它通过在编译阶段优化编译的结果,来实现运行时 patch 过程的优化。

通过数据劫持和依赖收集,Vue.js 2.x 的数据更新并触发重新渲染的粒度是组件级的:

虽然 Vue 能保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vnode 树,举个例子,比如要更新这个组件:

<template>
  <div id="content">
    <p class="text">static text</p>
    <p class="text">static text</p>
    <p class="text">{{message}}</p>
    <p class="text">static text</p>
    <p class="text">static text</p>
  </div>
</template>

整个 diff 过程如图所示:

可以看到,因为这段代码中只有一个动态节点,所以这里有很多 diff 和遍历其实都是不需要的,这就会导致 vnode 的性能跟模版大小正相关,跟动态节点的数量无关,当一些组件的整个模版内只有少量动态节点时,这些遍历都是性能的浪费。

而对于上述例子,理想状态只需要 diff 这个绑定 message 动态节点的 p 标签即可。

Vue.js 3.0 做到了,它通过编译阶段对静态模板的分析,编译生成了 Block tree 。Block tree 是一个将模版基于动态节点指令切割的嵌套区块,每个区块内部的节点结构是固定的,而且每个区块只需要以一个 Array 来追踪自身包含的动态节点。借助 Block tree,Vue.js 将 vnode 更新性能由与模版整体大小相关提升为与动态内容的数量相关,这是一个非常大的性能突破。

除此之外,Vue.js 3.0 在编译阶段还包含了对 Slot 的编译优化、事件侦听函数的缓存优化,并且在运行时重写了 diff 算法

  1. 语法 API 优化:Composition API
    • Options API=>Composition API
    • 优化逻辑复用:mixins=>hook 函数[避免 命名冲突来源不清晰 等问题]

# vue3虚拟dom转真实dom

组件是一个抽象的概念,它是对一棵 DOM 树的抽象,在页面中写一个组件节点:

<hello-world></hello-world>

这段代码并不会在页面上渲染一个<hello-world>标签,而它具体渲染成什么,取决于怎么编写 HelloWorld 组件的模板。假如组件内部的模板定义是这样的:

<template>
  <div>
    <p>Hello World</p>
  </div>
</template>

可以看到,模板内部最终会在页面上渲染一个 div,内部包含一个 p 标签,用来显示 Hello World 文本。

所以,从表现上来看,组件的模板决定了组件生成的 DOM 标签 ,在 Vue内部,一个组件想要真正的渲染生成DOM,还需要经历 创建 vnode - 渲染 vnode - 生成 DOM 这几步:

# 应用程序初始化

一个组件可以通过“模板加对象描述”的方式创建,组件创建好以后是如何被调用并初始化的呢?因为整个组件树是由根组件开始渲染的,为了找到根组件的渲染入口,需要从应用程序的初始化过程开始分析。

// 在 Vue.js 2.x 中,初始化一个应用的方式如下
import Vue from 'vue'
import App from './App'
const app = new Vue({
  render: h => h(App)
})
app.$mount('#app')
// 在 Vue.js 3.0 中,初始化一个应用的方式如下
import { createApp } from 'vue'
import App from './app'
const app = createApp(App)
app.mount('#app')

Vue.js 3.0 初始化应用的方式和 Vue.js 2.x 差别并不大,本质上都是把 App 组件挂载到 id 为 app 的 DOM 节点上。

但是,在 Vue.js 3.0 中还导入了一个 createApp,其实这是个入口函数,它是 Vue.js 对外暴露的一个函数:

const createApp = ((...args) => {
  // 创建 app 对象
  const app = ensureRenderer().createApp(...args)
  const { mount } = app
  // 重写 mount 方法
  app.mount = (containerOrSelector) => {
    // ...
  }
  return app
})

createApp 主要做了两件事情:创建 app 对象和重写 app.mount 方法

  1. 创建 app 对象 ensureRenderer() 用来创建一个渲染器对象:
// 渲染相关的一些配置,比如更新属性的方法,操作 DOM 的方法
const rendererOptions = {
  patchProp,
  ...nodeOps
}

let renderer

// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions))
}

function createRenderer(options) {
  return baseCreateRenderer(options)
}

function baseCreateRenderer(options) {
  function render(vnode, container) {
    // 组件渲染的核心逻辑
  }
  return {
    render,
    createApp: createAppAPI(render)
  }
}

function createAppAPI(render) {
  // createApp createApp 方法接受的两个参数:根组件的对象和 prop
  return function createApp(rootComponent, rootProps = null) {
    const app = {
      _component: rootComponent,
      _props: rootProps,
      mount(rootContainer) {
        // 创建根组件的 vnode
        const vnode = createVNode(rootComponent, rootProps)
        // 利用渲染器渲染 vnode
        render(vnode, rootContainer)
        app._container = rootContainer
        return vnode.component.proxy
      }
    }
    return app
  }
}

在 Vue.js内部通过 createRenderer 创建一个渲染器,这个渲染器内部会有一个 createApp 方法,它是执行 createAppAPI 方法返回的函数,接受了 rootComponentrootProps 两个参数,在应用层面执行 createApp(App) 方法时,会把 App 组件对象作为根组件传递给 rootComponent。这样,createApp 内部就创建了一个 app 对象,它会提供 mount 方法,这个方法是用来挂载组件的。

在整个 app 对象创建过程中,Vue.js 利用闭包函数柯里化的技巧,很好地实现了参数保留。比如,在执行 app.mount 的时候,并不需要传入渲染器 render,这是因为在执行 createAppAPI 的时候渲染器 render 参数已经被保留下来了。

  1. 重写 app.mount 方法 为什么要重写这个方法?Vue.js 不仅仅是为 Web 平台服务,它的目标是支持跨平台渲染,而 createApp 函数内部的 app.mount 方法是一个标准的可跨平台的组件渲染流程:
mount(rootContainer) {
  // 创建根组件的 vnode
  const vnode = createVNode(rootComponent, rootProps)
  // 利用渲染器渲染 vnode
  render(vnode, rootContainer)
  app._container = rootContainer
  return vnode.component.proxy
}

再来看 app.mount 重写都做了哪些事情

app.mount = (containerOrSelector) => {
  // 标准化容器
  const container = normalizeContainer(containerOrSelector)
  if (!container)
    return
  const component = app._component
   // 如组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
  if (!isFunction(component) && !component.render && !component.template) {
    component.template = container.innerHTML
  }
  // 挂载前清空容器内容
  container.innerHTML = ''
  // 真正的挂载
  return mount(container)
}

首先是通过 normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象),然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容;接着在挂载前清空容器内容,最终再调用 app.mount 的方法走标准的组件渲染流程。

从 app.mount 开始,才算真正进入组件渲染流程,那么接下来就重点看一下核心渲染流程做的两件事情:创建 vnode 和渲染 vnode

# 核心渲染流程:创建 vnode 和渲染 vnode

  1. 创建 vnode:vnode 本质上是用来描述 DOM 的 JavaScript 对象,它在 Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。
    • 普通元素节点
    • 组件节点
<button class="btn" style="width:100px;height:50px">click me</button>

用vnode表示

const vnode = {
  type: 'button',
  props: { 
    'class': 'btn',
    style: {
      width: '100px',
      height: '50px'
    }
  },
  children: 'click me'
}

type 属性表示 DOM 的标签类型,props 属性表示 DOM 的一些附加信息,比如 style 、class 等,children 属性表示 DOM 的子节点,它也可以是一个 vnode 数组,只不过 vnode 可以用字符串表示简单的文本

<custom-component msg="test"></custom-component>
const CustomComponent = {
  // 在这里定义组件对象
}
const vnode = {
  type: CustomComponent,
  props: { 
    msg: 'test'
  }
}

组件 vnode 其实是对抽象事物的描述,这是因为并不会在页面上真正渲染一个 <custom-component>标签,而是渲染组件内部定义的 HTML 标签。

# 虚拟dom优势

  • 抽象,引入 vnode,可以把渲染过程抽象化,从而使得组件的抽象能力也得到提升。
  • 其次是跨平台,因为 patch vnode 的过程不同平台可以有自己的实现
  • 虽然 diff 算法在减少 DOM 操作方面足够优秀,但最终还是免不了操作 DOM,所以说性能并不是 vnode 的优势
    • 比如一个 1000 * 10 的 Table 组件,render to vnode 的过程会遍历 1000 * 10 次去创建内部 cell vnode,整个耗时就会变得比较长,加上 patch vnode 的过程也会有一定的耗时,当去更新组件的时候,用户会感觉到明显的卡顿

回顾 app.mount 函数的实现,内部是通过 createVNode 函数创建了根组件的 vnode :

 const vnode = createVNode(rootComponent, rootProps)

createVNode 函数的大致实现:

function createVNode(type, props = null
,children = null) {
  if (props) {
    // 处理 props 相关逻辑,标准化 class 和 style
  }
  // 对 vnode 类型信息编码
  const shapeFlag = isString(type)
    ? 1 /* ELEMENT */
    : isSuspense(type)
      ? 128 /* SUSPENSE */
      : isTeleport(type)
        ? 64 /* TELEPORT */
        : isObject(type)
          ? 4 /* STATEFUL_COMPONENT */
          : isFunction(type)
            ? 2 /* FUNCTIONAL_COMPONENT */
            : 0
  const vnode = {
    type,
    props,
    shapeFlag,
    // 一些其他属性
  }
  // 标准化子节点,把不同数据类型的 children 转成数组或者文本类型
  normalizeChildren(vnode, children)
  return vnode
}

通过上述代码可以看到,其实 createVNode 做的事情很简单,就是:对 props 做标准化处理、对 vnode 的类型信息编码、创建 vnode 对象,标准化子节点 children 。

# 渲染vnode

render(vnode, rootContainer)
const render = (vnode, container) => {
  if (vnode == null) {
    // 销毁组件
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    // 创建或者更新组件
    patch(container._vnode || null, vnode, container)
  }
  // 缓存 vnode 节点,表示已经渲染
  container._vnode = vnode
}

这个渲染函数 render 的实现很简单,如果它的第一个参数 vnode 为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑。接着看一下上面渲染 vnode 的代码中涉及的 patch 函数的实现

const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
  // 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
  const { type, shapeFlag } = n2
  switch (type) {
    case Text:
      // 处理文本节点
      break
    case Comment:
      // 处理注释节点
      break
    case Static:
      // 处理静态节点
      break
    case Fragment:
      // 处理 Fragment 元素
      break
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // 处理普通 DOM 元素
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 6 /* COMPONENT */) {
        // 处理组件
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 64 /* TELEPORT */) {
        // 处理 TELEPORT
      }
      else if (shapeFlag & 128 /* SUSPENSE */) {
        // 处理 SUSPENSE
      }
  }
}

patch 本意是打补丁的意思,这个函数有两个功能,一个是根据 vnode 挂载 DOM,一个是根据新旧 vnode 更新 DOM.

在创建的过程中,patch 函数接受多个参数,这里目前只重点关注前三个:

  • 第一个参数 n1 表示旧的 vnode,当 n1 为 null 的时候,表示是一次挂载的过程;
  • 第二个参数 n2 表示新的 vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑;
  • 第三个参数 container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。

对于渲染的节点,这里重点关注两种类型节点的渲染逻辑:对组件的处理和对普通 DOM 元素的处理。

先来看对组件的处理。由于初始化渲染的是 App 组件,它是一个组件 vnode,来看一下组件的处理逻辑是怎样的。首先是用来处理组件的 processComponent 函数的实现

const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  if (n1 == null) {
   // 挂载组件
   mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
  else {
    // 更新组件
    updateComponent(n1, n2, parentComponent, optimized)
  }
}

如果 n1 为 null,则执行挂载组件的逻辑,否则执行更新组件的逻辑,接着来看挂载组件的 mountComponent 函数的实现

const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  // 创建组件实例
  const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense))
  // 设置组件实例
  setupComponent(instance)
  // 设置并运行带副作用的渲染函数
  setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)
}

可以看到,挂载组件函数 mountComponent 主要做三件事情:创建组件实例、设置组件实例、设置并运行带副作用的渲染函数。

首先是创建组件实例,Vue.js 3.0 虽然不像 Vue.js 2.x 那样通过类的方式去实例化组件,但内部也通过对象的方式去创建了当前渲染的组件实例。

其次设置组件实例,instance 保留了很多组件相关的数据,维护了组件的上下文,包括对 props、插槽,以及其他实例的属性的初始化处理。

最后是运行带副作用的渲染函数 setupRenderEffect,重点来看一下这个函数的实现:

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
  // 创建响应式的副作用渲染函数
  instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
      // 渲染组件生成子树 vnode
      const subTree = (instance.subTree = renderComponentRoot(instance))
      // 把子树 vnode 挂载到 container 中
      patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
      // 保留渲染生成的子树根 DOM 节点
      initialVNode.el = subTree.el
      instance.isMounted = true
    }
    else {
      // 更新组件
    }
  }, prodEffectOptions)
}

该函数利用响应式库的 effect 函数创建了一个副作用渲染函数 componentEffect 。副作用,这里可以简单地理解为,当组件的数据发生变化时,effect 函数包裹的内部渲染函数 componentEffect 会重新执行一遍,从而达到重新渲染组件的目的。

渲染函数内部也会判断这是一次初始渲染还是组件更新。

初始渲染主要做两件事情:渲染组件生成 subTree、把 subTree 挂载到 container 中。

首先,是渲染组件生成 subTree,它也是一个 vnode 对象。这里要注意别把 subTree 和 initialVNode 弄混了(其实在 Vue.js 3.0 中,根据命名已经能很好地区分它们了,而在 Vue.js 2.x 中它们分别命名为 _vnode 和 $vnode)。举个例子说明,在父组件 App 中里引入了 Hello 组件:

<template>
  <div class="app">
    <p>This is an app.</p>
    <hello></hello>
  </div>
</template>

在 Hello 组件中是 <div>标签包裹着一个 <p> 标签:

<template>
  <div class="hello">
    <p>Hello, Vue 3.0!</p>
  </div>
</template>

在 App 组件中, <hello>节点渲染生成的 vnode ,对应的就是 Hello 组件的 initialVNode ,为了好记,你也可以把它称作“组件 vnode”。而 Hello 组件内部整个 DOM 节点对应的 vnode 就是执行 renderComponentRoot 渲染生成对应的 subTree,可以把它称作“子树 vnode”。

每个组件都会有对应的 render 函数,即使写 template,也会编译成 render 函数,而 renderComponentRoot 函数就是去执行 render 函数创建整个组件树内部的 vnode,把这个 vnode 再经过内部一层标准化,就得到了该函数的返回结果:子树 vnode。

渲染生成子树 vnode 后,接下来就是继续调用 patch 函数把子树 vnode 挂载到 container 中了。

那么又再次回到了 patch 函数,会继续对这个子树 vnode 类型进行判断,对于上述例子,App 组件的根节点是 <div> 标签,那么对应的子树 vnode 也是一个普通元素 vnode,接下来看对普通 DOM 元素的处理流程。

来看一下处理普通 DOM元素的 processElement 函数的实现:

const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  isSVG = isSVG || n2.type === 'svg'
  if (n1 == null) {
    //挂载元素节点
    mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
  else {
    //更新元素节点
    patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
  }
}

如果 n1 为 null,走挂载元素节点的逻辑,否则走更新元素节点逻辑。接着来看挂载元素的 mountElement 函数的实现

const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let el
  const { type, props, shapeFlag } = vnode
  // 创建 DOM 元素节点
  el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
  if (props) {
    // 处理 props,比如 class、style、event 等属性
    for (const key in props) {
      if (!isReservedProp(key)) {
        hostPatchProp(el, key, null, props[key], isSVG)
      }
    }
  }
  if (shapeFlag & 8 /* TEXT_CHILDREN */) {
    // 处理子节点是纯文本的情况
    hostSetElementText(el, vnode.children)
  }
  else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
    // 处理子节点是数组的情况
    mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
  }
  // 把创建的 DOM 元素节点挂载到 container 上
  hostInsert(el, container, anchor)
}

可以看到,挂载元素函数主要做四件事:创建 DOM 元素节点、处理 props、处理 children、挂载 DOM 元素到 container 上。

首先是创建 DOM 元素节点,通过 hostCreateElement 方法创建,这是一个平台相关的方法,来看一下它在 Web 环境下的定义:

function createElement(tag, isSVG, is) {
  isSVG ? document.createElementNS(svgNS, tag)
    : document.createElement(tag, is ? { is } : undefined)
}

接下来是对子节点的处理,知道 DOM 是一棵树,vnode 同样也是一棵树,并且它和 DOM 结构是一一映射的。

如果子节点是纯文本,则执行 hostSetElementText 方法,它在 Web 环境下通过设置 DOM 元素的 textContent 属性设置文本:

function setElementText(el, text) {
  el.textContent = text
}

如果子节点是数组,则执行 mountChildren 方法:

const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, optimized, start = 0) => {
  for (let i = start; i < children.length; i++) {
    // 预处理 child
    const child = (children[i] = optimized
      ? cloneIfMounted(children[i])
      : normalizeVNode(children[i]))
    // 递归 patch 挂载 child
    patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  }
}

子节点的挂载逻辑同样很简单,遍历 children 获取到每一个 child,然后递归执行 patch 方法挂载每一个 child 。注意,这里有对 child 做预处理的情况(后面编译优化的章节会详细分析)。

可以看到,mountChildren 函数的第二个参数是 container,而调用 mountChildren 方法传入的第二个参数是在 mountElement 时创建的 DOM 节点,这就很好地建立了父子关系。

另外,通过递归 patch 这种深度优先遍历树的方式,就可以构造完整的 DOM 树,完成组件的渲染。

处理完所有子节点后,最后通过 hostInsert 方法把创建的 DOM 元素节点挂载到 container 上,它在 Web 环境下这样定义:

function insert(child, parent, anchor) {
  if (anchor) {
    parent.insertBefore(child, anchor)
  }
  else {
    parent.appendChild(child)
  }
}

这里会做一个 if 判断,如果有参考元素 anchor,就执行 parent.insertBefore ,否则执行 parent.appendChild 来把 child 添加到 parent 下,完成节点的挂载。

因为 insert 的执行是在处理子节点后,所以挂载的顺序是先子节点,后父节点,最终挂载到最外层的容器上。

# 组件更新:完整的 DOM diff 流程

组件渲染的过程,本质上就是把各种类型的 vnode 渲染成真实 DOM。组件是由模板、组件描述对象和数据构成的,数据的变化会影响组件的变化。组件的渲染过程中创建了一个带 副作用的渲染函数 ,当数据变化的时候就会执行这个渲染函数来触发组件的更新。

# 副作用渲染函数更新组件的过程

回顾带副作用渲染函数 setupRenderEffect 的实现,这次重点关注更新组件部分的逻辑

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
  // 创建响应式的副作用渲染函数
  instance.update = effect(function componentEffect() {
    if (!instance.isMounted) {
      // 渲染组件
    }
    else {
      // 更新组件
      let { next, vnode } = instance
      // next 表示新的组件 vnode
      if (next) {
        // 更新组件 vnode 节点信息
        updateComponentPreRender(instance, next, optimized)
      }
      else {
        next = vnode
      }
      // 渲染新的子树 vnode
      const nextTree = renderComponentRoot(instance)
      // 缓存旧的子树 vnode
      const prevTree = instance.subTree
      // 更新子树 vnode
      instance.subTree = nextTree
      // 组件更新核心逻辑,根据新旧子树 vnode 做 patch
      patch(prevTree, nextTree,
        // 如果在 teleport 组件中父节点可能已经改变,所以容器直接找旧树 DOM 元素的父节点
        hostParentNode(prevTree.el),
        // 参考节点在 fragment 的情况可能改变,所以直接找旧树 DOM 元素的下一个节点
        getNextHostNode(prevTree),
        instance,
        parentSuspense,
        isSVG)
      // 缓存更新后的 DOM 节点
      next.el = nextTree.el
    }
  }, prodEffectOptions)
}

更新组件主要做三件事情:更新组件 vnode 节点、渲染新的子树 vnode、根据新旧子树 vnode 执行 patch 逻辑。

首先是更新组件 vnode 节点,这里会有一个条件判断,判断组件实例中是否有新的组件 vnode(用 next 表示),有则更新组件 vnode,没有 next 指向之前的组件 vnode。

接着是渲染新的子树 vnode,因为数据发生了变化,模板又和数据相关,所以渲染生成的子树 vnode 也会发生相应的变化。

最后就是核心的 patch 逻辑,用来找出新旧子树 vnode 的不同,并找到一种合适的方式更新 DOM。

const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
  // 如果存在新旧节点, 且新旧节点类型不同,则销毁旧节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    // n1 设置为 null 保证后续都走 mount 逻辑
    n1 = null
  }
  const { type, shapeFlag } = n2
  switch (type) {
    case Text:
      // 处理文本节点
      break
    case Comment:
      // 处理注释节点
      break
    case Static:
      // 处理静态节点
      break
    case Fragment:
      // 处理 Fragment 元素
      break
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // 处理普通 DOM 元素
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else if (shapeFlag & 6 /* COMPONENT */) {
        // 处理组件
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
	  ......
  }
}
function isSameVNodeType (n1, n2) {
  // n1 和 n2 节点的 type 和 key 都相同,才是相同节点
  return n1.type === n2.type && n1.key === n2.key
}

在这个过程中,首先判断新旧节点是否是相同的 vnode 类型,如果不同,比如一个 div 更新成一个 ul,那么最简单的操作就是删除旧的 div 节点,再去挂载新的 ul 节点。

如果是相同的 vnode 类型,就需要走 diff 更新流程了,接着会根据不同的 vnode 类型执行不同的处理逻辑,这里只分析普通元素类型和组件类型的处理过程。

  1. 处理组件

如何处理组件的呢?举个例子,在父组件 App 中里引入了 Hello 组件:

<template>
  <div class="app">
    <p>This is an app.</p>
    <hello :msg="msg"></hello>
    <button @click="toggle">Toggle msg</button>
  </div>
</template>
<script>
  export default {
    data() {
      return {
        msg: 'Vue'
      }
    },
    methods: {
      toggle() {
        this.msg = this.msg ==== 'Vue'? 'World': 'Vue'
      }
    }
  }
</script>

Hello 组件中是 <div> 包裹着一个 <p> 标签, 如下所示:

<template>
  <div class="hello">
    <p>Hello, {{msg}}</p>
  </div>
</template>
<script>
  export default {
    props: {
      msg: String
    }
  }
</script>

点击 App 组件中的按钮执行 toggle 函数,就会修改 data 中的 msg,并且会触发App 组件的重新渲染。

结合前面对渲染函数的流程分析,这里 App 组件的根节点是 div 标签,重新渲染的子树 vnode 节点是一个普通元素的 vnode,应该先走 processElement 逻辑。组件的更新最终还是要转换成内部真实 DOM 的更新,而实际上普通元素的处理流程才是真正做 DOM 的更新,先跳过这里,继续往下看。

和渲染过程类似,更新过程也是一个树的深度优先遍历过程,更新完当前节点后,就会遍历更新它的子节点,因此在遍历的过程中会遇到 hello 这个组件 vnode 节点,就会执行到 processComponent 处理逻辑中,再来看一下它的实现,重点关注一下组件更新的相关逻辑:

const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  if (n1 == null) {
    // 挂载组件
  }
  else {
    // 更新子组件
    updateComponent(n1, n2, parentComponent, optimized)
  }
}
const updateComponent = (n1, n2, parentComponent, optimized) => {
  const instance = (n2.component = n1.component)
  // 根据新旧子组件 vnode 判断是否需要更新子组件
  if (shouldUpdateComponent(n1, n2, parentComponent, optimized)) {
    // 新的子组件 vnode 赋值给 instance.next
    instance.next = n2
    // 子组件也可能因为数据变化被添加到更新队列里了,移除它们防止对一个子组件重复更新
    invalidateJob(instance.update)
    // 执行子组件的副作用渲染函数
    instance.update()
  }
  else {
    // 不需要更新,只复制属性
    n2.component = n1.component
    n2.el = n1.el
  }
}

可以看到,processComponent 主要通过执行 updateComponent 函数来更新子组件,updateComponent 函数在更新子组件的时候,会先执行 shouldUpdateComponent 函数,根据新旧子组件 vnode 来判断是否需要更新子组件。在 shouldUpdateComponent 函数的内部,主要是通过检测和对比组件 vnode 中的 props、chidren、dirs、transiton 等属性,来决定子组件是否需要更新。

在一个组件的子组件是否需要更新,主要依据子组件 vnode 是否存在一些会影响组件更新的属性变化进行判断,如果存在就会更新子组件。

虽然 Vue.js 的更新粒度是组件级别的,组件的数据变化只会影响当前组件的更新,但是 在组件更新的过程中,也会对子组件做一定的检查,判断子组件是否也要更新,并通过某种机制避免子组件重复更新。

接着看 updateComponent 函数,如果 shouldUpdateComponent 返回 true ,那么在它的最后,先执行 invalidateJob(instance.update)避免子组件由于自身数据变化导致的重复更新,然后又执行了子组件的副作用渲染函数 instance.update 来主动触发子组件的更新。

DOM结构是一棵树,从上面的流程中可以知道,在更新一个节点时不光会更新节点本身,还会更新节点的子节点,所以,vue会在这里进行检查,看是否当前组件的副作用函数已经在队列中了,如果在,直接移除掉,反正再往下也会主动触发更新。这样就避免了二次重复渲染。

再回到副作用渲染函数中,有了前面的讲解,再看组件更新的这部分代码,就能很好地理解它的逻辑了:

// 更新组件
let { next, vnode } = instance
// next 表示新的组件 vnode
if (next) {
  // 更新组件 vnode 节点信息
  updateComponentPreRender(instance, next, optimized)
}
else {
  next = vnode
}
const updateComponentPreRender = (instance, nextVNode, optimized) => {
  // 新组件 vnode 的 component 属性指向组件实例
  nextVNode.component = instance
  // 旧组件 vnode 的 props 属性
  const prevProps = instance.vnode.props
  // 组件实例的 vnode 属性指向新的组件 vnode
  instance.vnode = nextVNode
  // 清空 next 属性,为了下一次重新渲染准备
  instance.next = null
  // 更新 props
  updateProps(instance, nextVNode.props, prevProps, optimized)
  // 更新 插槽
  updateSlots(instance, nextVNode.children)
}

结合上面的代码,在更新组件的 DOM 前,需要先更新组件 vnode 节点信息,包括更改组件实例的 vnode 指针、更新 props 和更新插槽等一系列操作,因为组件在稍后执行 renderComponentRoot 时会重新渲染新的子树 vnode ,它依赖了更新后的组件 vnode 中的 props 和 slots 等数据。

一个组件重新渲染可能会有两种场景,一种是组件本身的数据变化,这种情况下 next 是 null;另一种是父组件在更新的过程中,遇到子组件节点,先判断子组件是否需要更新,如果需要则主动执行子组件的重新渲染方法,这种情况下 next 就是新的子组件 vnode

这个子组件对应的新的组件 vnode 是什么时候创建的呢?答案很简单,它是在父组件重新渲染的过程中,通过 renderComponentRoot 渲染子树 vnode 的时候生成,因为子树 vnode 是个树形结构,通过遍历它的子节点就可以访问到其对应的组件 vnode。再拿前面举的例子说,当 App 组件重新渲染的时候,在执行 renderComponentRoot 生成子树 vnode 的过程中,也生成了 hello 组件对应的新的组件 vnode。

所以 processComponent 处理组件 vnode,本质上就是去判断子组件是否需要更新,如果需要则递归执行子组件的副作用渲染函数来更新,否则仅仅更新一些 vnode 的属性,并让子组件实例保留对组件 vnode 的引用,用于子组件自身数据变化引起组件重新渲染的时候,在渲染函数内部可以拿到新的组件 vnode。

前面也说过,组件是抽象的,组件的更新最终还是会落到对普通 DOM 元素的更新。

  1. 处理普通元素

再来看如何处理普通元素,我把之前的示例稍加修改,将其中的 Hello 组件删掉,如下所示:

<template>
  <div class="app">
    <p>This is {{msg}}.</p>
    <button @click="toggle">Toggle msg</button>
  </div>
</template>
<script>
  export default {
    data() {
      return {
        msg: 'Vue'
      }
    },
    methods: {
      toggle() {
        this.msg = 'Vue'? 'World': 'Vue'
      }
    }
  }
</script>

当点击 App 组件中的按钮会执行 toggle 函数,然后修改 data 中的 msg,这就触发了 App 组件的重新渲染。

App 组件的根节点是 div 标签,重新渲染的子树 vnode 节点是一个普通元素的 vnode,所以应该先走 processElement 逻辑:

const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  isSVG = isSVG || n2.type === 'svg'
  if (n1 == null) {
    // 挂载元素
  }
  else {
    // 更新元素
    patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
  }
}
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, optimized) => {
  const el = (n2.el = n1.el)
  const oldProps = (n1 && n1.props) || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  // 更新 props
  patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
  const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
  // 更新子节点
  patchChildren(n1, n2, el, null, parentComponent, parentSuspense, areChildrenSVG)
}

可以看到,更新元素的过程主要做两件事情:更新 props 和更新子节点。其实这是很好理解的,因为一个 DOM 节点元素就是由它自身的一些属性和子节点构成的。

首先是更新 props,这里的 patchProps 函数就是在更新 DOM 节点的 class、style、event 以及其它的一些 DOM 属性。

其次是更新子节点,来看一下这里的 patchChildren 函数的实现:

const patchChildren = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized = false) => {
  const c1 = n1 && n1.children
  const prevShapeFlag = n1 ? n1.shapeFlag : 0
  const c2 = n2.children
  const { shapeFlag } = n2
  // 子节点有 3 种可能情况:文本、数组、空
  if (shapeFlag & 8 /* TEXT_CHILDREN */) {
    if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
      // 数组 -> 文本,则删除之前的子节点
      unmountChildren(c1, parentComponent, parentSuspense)
    }
    if (c2 !== c1) {
      // 文本对比不同,则替换为新文本
      hostSetElementText(container, c2)
    }
  }
  else {
    if (prevShapeFlag & 16 /* ARRAY_CHILDREN */) {
      // 之前的子节点是数组
      if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
        // 新的子节点仍然是数组,则做完整地 diff
        patchKeyedChildren(c1, c2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
      else {
        // 数组 -> 空,则仅仅删除之前的子节点
        unmountChildren(c1, parentComponent, parentSuspense, true)
      }
    }
    else {
      // 之前的子节点是文本节点或者为空
      // 新的子节点是数组或者为空
      if (prevShapeFlag & 8 /* TEXT_CHILDREN */) {
        // 如果之前子节点是文本,则把它清空
        hostSetElementText(container, '')
      }
      if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
        // 如果新的子节点是数组,则挂载新子节点
        mountChildren(c2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
      }
    }
  }
}

对于一个元素的子节点 vnode 可能会有三种情况:纯文本、vnode 数组和空。那么根据排列组合对于新旧子节点来说就有九种情况,可以通过三张图来表示。

首先来看一下旧子节点是纯文本的情况:

  • 如果新子节点也是纯文本,那么做简单地文本替换即可;
  • 如果新子节点是空,那么删除旧子节点即可;
  • 如果新子节点是 vnode 数组,那么先把旧子节点的文本清空,再去旧子节点的父容器下添加多个新子节点。

接下来看一下旧子节点是空的情况:

  • 如果新子节点是纯文本,那么在旧子节点的父容器下添加新文本节点即可;
  • 如果新子节点也是空,那么什么都不需要做;
  • 如果新子节点是 vnode 数组,那么直接去旧子节点的父容器下添加多个新子节点即可。

最后来看一下旧子节点是 vnode 数组的情况:

  • 如果新子节点是纯文本,那么先删除旧子节点,再去旧子节点的父容器下添加新文本节点;
  • 如果新子节点是空,那么删除旧子节点即可;
  • 如果新子节点也是 vnode 数组,那么就需要做完整的 diff 新旧子节点了,这是最复杂的情况,内部运用了核心 diff 算法。

# diff算法

新子节点数组相对于旧子节点数组的变化,无非是通过更新、删除、添加和移动节点来完成,而核心 diff 算法,就是在已知旧子节点的 DOM 结构、vnode 和新子节点的 vnode 情况下,以较低的成本完成子节点的更新为目的,求解生成新子节点 DOM 的系列操作。假设有这样一个列表:

<ul>
  <li key="a">a</li>
  <li key="b">b</li>
  <li key="c">c</li>
  <li key="d">d</li>
</ul>

然后在中间插入一行,得到一个新列表:

<ul>
  <li key="a">a</li>
  <li key="b">b</li>
  <li key="e">e</li>
  <li key="c">c</li>
  <li key="d">d</li>
</ul>

在插入操作的前后,它们对应渲染生成的 vnode 可以用一张图表示:

从图中可以直观地感受到,差异主要在新子节点中的 b 节点后面多了一个 e 节点。再把这个例子稍微修改一下,多添加一个 e 节点:

<ul>
  <li key="a">a</li>
  <li key="b">b</li>
  <li key="c">c</li>
  <li key="d">d</li>
  <li key="e">e</li>
</ul>

然后删除中间一项,得到一个新列表:

<ul>
  <li key="a">a</li>
  <li key="b">b</li>
  <li key="d">d</li>
  <li key="e">e</li>
</ul>

在删除操作的前后,它们对应渲染生成的 vnode 可以用一张图表示:

可以看到,这时差异主要在新子节点中的 b 节点后面少了一个 c 节点。

综合这两个例子,很容易发现新旧 children 拥有相同的头尾节点。对于相同的节点,只需要做对比更新即可,所以 diff 算法的第一步从头部开始同步。

  1. 同步头部节点
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0
  const l2 = c2.length
  // 旧子节点的尾部索引
  let e1 = c1.length - 1
  // 新子节点的尾部索引
  let e2 = l2 - 1
  // 1. 从头部开始同步
  // i = 0, e1 = 3, e2 = 4
  // (a b) c d
  // (a b) e c d
  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = c2[i]
    if (isSameVNodeType(n1, n2)) {
      // 相同的节点,递归执行 patch 更新节点
      patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
    }
    else {
      break
    }
    i++
  }
}

在整个 diff 的过程,需要维护几个变量:头部的索引 i、旧子节点的尾部索引 e1和新子节点的尾部索引 e2。

同步头部节点就是从头部开始,依次对比新节点和旧节点,如果它们相同的则执行 patch 更新节点;如果不同或者索引 i 大于索引 e1 或者 e2,则同步过程结束。拿第一个例子来说,通过下图看一下同步头部节点后的结果:

可以看到,完成头部节点同步后:i 是 2,e1 是 3,e2 是 4。

  1. 同步尾部节点

接着从尾部开始同步尾部节点,实现代码如下

const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0
  const l2 = c2.length
  // 旧子节点的尾部索引
  let e1 = c1.length - 1
  // 新子节点的尾部索引
  let e2 = l2 - 1
  // 1. 从头部开始同步
  // i = 0, e1 = 3, e2 = 4
  // (a b) c d
  // (a b) e c d
  // 2. 从尾部开始同步
  // i = 2, e1 = 3, e2 = 4
  // (a b) (c d)
  // (a b) e (c d)
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1]
    const n2 = c2[e2]
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
    }
    else {
      break
    }
    e1--
    e2--
  }
}

同步尾部节点就是从尾部开始,依次对比新节点和旧节点,如果相同的则执行 patch 更新节点;如果不同或者索引 i 大于索引 e1 或者 e2,则同步过程结束。

可以看到,完成尾部节点同步后:i是2,e1是1,e2是2。

接下来只有 3 种情况要处理:

  • 新子节点有剩余要添加的新节点;
  • 旧子节点有剩余要删除的多余节点;
  • 未知子序列。
  1. 添加新的节点
  • 首先要判断新子节点是否有剩余的情况,如果满足则添加新子节点
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0
  const l2 = c2.length
  // 旧子节点的尾部索引
  let e1 = c1.length - 1
  // 新子节点的尾部索引
  let e2 = l2 - 1
  // 1. 从头部开始同步
  // i = 0, e1 = 3, e2 = 4
  // (a b) c d
  // (a b) e c d
  // ...
  // 2. 从尾部开始同步
  // i = 2, e1 = 3, e2 = 4
  // (a b) (c d)
  // (a b) e (c d)
  // 3. 挂载剩余的新节点
  // i = 2, e1 = 1, e2 = 2
  if (i > e1) {
    if (i <= e2) {
      const nextPos = e2 + 1
      const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor
      while (i <= e2) {
        // 挂载新节点
        patch(null, c2[i], container, anchor, parentComponent, parentSuspense, isSVG)
        i++
      }
    }
  }
}

如果索引 i 大于尾部索引 e1 且 i 小于 e2,那么从索引 i 开始到索引 e2 之间,直接挂载新子树这部分的节点。

对的例子而言,同步完尾部节点后 i 是 2,e1 是 1,e2 是 2,此时满足条件需要添加新的节点,来通过下图看一下添加后的结果:

添加完 e 节点后,旧子节点的 DOM 和新子节点对应的 vnode 映射一致,也就完成了更新。

  • 删除多余节点

如果不满足添加新节点的情况,我就要接着判断旧子节点是否有剩余,如果满足则删除旧子节点,实现代码如下:

const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0
  const l2 = c2.length
  // 旧子节点的尾部索引
  let e1 = c1.length - 1
  // 新子节点的尾部索引
  let e2 = l2 - 1
  // 1. 从头部开始同步
  // i = 0, e1 = 4, e2 = 3
  // (a b) c d e
  // (a b) d e
  // ...
  // 2. 从尾部开始同步
  // i = 2, e1 = 4, e2 = 3
  // (a b) c (d e)
  // (a b) (d e)
  // 3. 普通序列挂载剩余的新节点
  // i = 2, e1 = 2, e2 = 1
  // 不满足
  if (i > e1) {
  }
  // 4. 普通序列删除多余的旧节点
  // i = 2, e1 = 2, e2 = 1
  else if (i > e2) {
    while (i <= e1) {
      // 删除节点
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
    }
  }
}

如果索引 i 大于尾部索引 e2,那么从索引 i 开始到索引 e1 之间,直接删除旧子树这部分的节点。

第二个例子是就删除节点的情况,可以从同步头部节点开始,用图的方式演示这一过程。首先从头部同步节点:

此时的结果:i 是 2,e1 是 4,e2 是 3。接着从尾部同步节点:

此时的结果:i 是 2,e1 是 2,e2 是 1,满足删除条件,因此删除子节点中的多余节点:

删除完 c 节点后,旧子节点的 DOM 和新子节点对应的 vnode 映射一致,也就完成了更新。

  • 处理未知子序列 单纯的添加和删除节点都是比较理想的情况,操作起来也很容易,但是有些时候并非这么幸运,遇到比较复杂的未知子序列,这时候 diff 算法会怎么做呢?再通过例子来演示存在未知子序列的情况,假设一个按照字母表排列的列表:
<ul>
  <li key="a">a</li>
  <li key="b">b</li>
  <li key="c">c</li>
  <li key="d">d</li>
  <li key="e">e</li>
  <li key="f">f</li>
  <li key="g">g</li>
  <li key="h">h</li>
</ul>

然后打乱之前的顺序得到一个新列表:

<ul>
  <li key="a">a</li>
  <li key="b">b</li>
  <li key="e">e</li>
  <li key="d">c</li>
  <li key="c">d</li>
  <li key="i">i</li>
  <li key="g">g</li>
  <li key="h">h</li>
</ul>

在操作前,它们对应渲染生成的 vnode 可以用一张图表示:

还是从同步头部节点开始,用图的方式演示这一过程。首先从头部同步节点:

同步头部节点后的结果:i 是 2,e1 是 7,e2 是 7。接着从尾部同步节点:

同步尾部节点后的结果:i 是 2,e1 是 5,e2 是 5。可以看到它既不满足添加新节点的条件,也不满足删除旧节点的条件。那么对于这种情况,应该怎么处理呢?

结合上图可以知道,要把旧子节点的 c、d、e、f 转变成新子节点的 e、c、d、i。从直观上看,把 e 节点移动到 c 节点前面,删除 f 节点,然后在 d 节点后面添加 i 节点即可。

其实无论多复杂的情况,最终无非都是通过更新、删除、添加、移动这些动作来操作节点,而要做的就是找到相对优的解。

当两个节点类型相同时,执行更新操作;当新子节点中没有旧子节点中的某些节点时,执行删除操作;当新子节点中多了旧子节点中没有的节点时,执行添加操作,这些操作在前面已经阐述清楚了。相对来说这些操作中最麻烦的就是移动,既要判断哪些节点需要移动也要清楚如何移动。

  • 移动子节点

那么什么时候需要移动呢,就是当子节点排列顺序发生变化的时候,举个简单的例子具体看一下:

var prev = [1, 2, 3, 4, 5, 6]
var next = [1, 3, 2, 6, 4, 5]

可以看到,从 prev 变成 next,数组里的一些元素的顺序发生了变化,可以把子节点类比为元素,现在问题就简化为如何用最少的移动使元素顺序从 prev 变化为 next 。

一种思路是在 next 中找到一个 递增子序列 ,比如 [1, 3, 6] 、[1, 2, 4, 5]。之后对 next 数组进行倒序遍历,移动所有不在递增序列中的元素即可。

如果选择了 [1, 3, 6] 作为递增子序列,那么在倒序遍历的过程中,遇到 6、3、1 不动,遇到 5、4、2 移动即可,如下图所示:

如果选择了 [1, 2, 4, 5] 作为递增子序列,那么在倒序遍历的过程中,遇到 5、4、2、1 不动,遇到 6、3 移动即可,如下图所示:

可以看到第一种移动了三次,而第二种只移动了两次,递增子序列越长,所需要移动元素的次数越少,所以如何移动的问题就回到了求解最长递增子序列的问题。

现在要做的是在新旧子节点序列中找出相同节点并更新,找出多余的节点删除,找出新的节点添加,找出是否有需要移动的节点,如果有该如何移动。

在查找过程中需要对比新旧子序列,那么就要遍历某个序列,如果在遍历旧子序列的过程中需要判断某个节点是否在新子序列中存在,这就需要双重循环,而双重循环的复杂度是 O(n2) ,为了优化这个复杂度,可以用一种 空间换时间 的思路,建立索引图,把时间复杂度降低到 O(n)。

  • 建立索引图

所以处理未知子序列的第一步,就是建立索引图。

通常在开发过程中, 会给 v-for 生成的列表中的每一项分配唯一 key 作为项的唯一 ID,这个 key 在 diff 过程中起到很关键的作用。对于新旧子序列中的节点,认为 key 相同的就是同一个节点,直接执行 patch 更新即可。

根据 key 建立新子序列的索引图,实现如下:

const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0
  const l2 = c2.length
  // 旧子节点的尾部索引
  let e1 = c1.length - 1
  // 新子节点的尾部索引
  let e2 = l2 - 1
  // 1. 从头部开始同步
  // i = 0, e1 = 7, e2 = 7
  // (a b) c d e f g h
  // (a b) e c d i g h
  // 2. 从尾部开始同步
  // i = 2, e1 = 7, e2 = 7
  // (a b) c d e f (g h)
  // (a b) e c d i (g h)
  // 3. 普通序列挂载剩余的新节点, 不满足
  // 4. 普通序列删除多余的旧节点,不满足
  // i = 2, e1 = 4, e2 = 5
  // 旧子序列开始索引,从 i 开始记录
  const s1 = i
  // 新子序列开始索引,从 i 开始记录
  const s2 = i //
  // 5.1 根据 key 建立新子序列的索引图
  const keyToNewIndexMap = new Map()
  for (i = s2; i <= e2; i++) {
    const nextChild = c2[i]
    keyToNewIndexMap.set(nextChild.key, i)
  }
}

新旧子序列是从 i 开始的,所以先用 s1、s2 分别作为新旧子序列的开始索引,接着建立一个 keyToNewIndexMap 的 Map<key, index> 结构,遍历新子序列,把节点的 key 和 index 添加到这个 Map 中,注意这里假设所有节点都是有 key 标识的。

keyToNewIndexMap 存储的就是新子序列中每个节点在新子序列中的索引,如下图所示:

得到了一个值为 {e:2,c:3,d:4,i:5} 的 新子序列索引图

  • 更新和移除旧节点

接下来,就需要遍历旧子序列,有相同的节点就通过 patch 更新,并且移除那些不在新子序列中的节点,同时找出是否有需要移动的节点:

const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0
  const l2 = c2.length
  // 旧子节点的尾部索引
  let e1 = c1.length - 1
  // 新子节点的尾部索引
  let e2 = l2 - 1
  // 1. 从头部开始同步
  // i = 0, e1 = 7, e2 = 7
  // (a b) c d e f g h
  // (a b) e c d i g h
  // 2. 从尾部开始同步
  // i = 2, e1 = 7, e2 = 7
  // (a b) c d e f (g h)
  // (a b) e c d i (g h)
  // 3. 普通序列挂载剩余的新节点,不满足
  // 4. 普通序列删除多余的旧节点,不满足
  // i = 2, e1 = 4, e2 = 5
  // 旧子序列开始索引,从 i 开始记录
  const s1 = i
  // 新子序列开始索引,从 i 开始记录
  const s2 = i
  // 5.1 根据 key 建立新子序列的索引图
  // 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
  // 新子序列已更新节点的数量
  let patched = 0
  // 新子序列待更新节点的数量,等于新子序列的长度
  const toBePatched = e2 - s2 + 1
  // 是否存在要移动的节点
  let moved = false
  // 用于跟踪判断是否有节点移动
  let maxNewIndexSoFar = 0
  // 这个数组存储新子序列中的元素在旧子序列节点的索引,用于确定最长递增子序列
  const newIndexToOldIndexMap = new Array(toBePatched)
  // 初始化数组,每个元素的值都是 0
  // 0 是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明这个新节点没有对应的旧节点
  for (i = 0; i < toBePatched; i++)
    newIndexToOldIndexMap[i] = 0
  // 正序遍历旧子序列
  for (i = s1; i <= e1; i++) {
    // 拿到每一个旧子序列节点
    const prevChild = c1[i]
    if (patched >= toBePatched) {
      // 所有新的子序列节点都已经更新,剩余的节点删除
      unmount(prevChild, parentComponent, parentSuspense, true)
      continue
    }
    // 查找旧子序列中的节点在新子序列中的索引
    let newIndex = keyToNewIndexMap.get(prevChild.key)
    if (newIndex === undefined) {
      // 找不到说明旧子序列已经不存在于新子序列中,则删除该节点
      unmount(prevChild, parentComponent, parentSuspense, true)
    }
    else {
      // 更新新子序列中的元素在旧子序列中的索引,这里加 1 偏移,是为了避免 i 为 0 的特殊情况,影响对后续最长递增子序列的求解
      newIndexToOldIndexMap[newIndex - s2] = i + 1
      // maxNewIndexSoFar 始终存储的是上次求值的 newIndex,如果不是一直递增,则说明有移动
      if (newIndex >= maxNewIndexSoFar) {
        maxNewIndexSoFar = newIndex
      }
      else {
        moved = true
      }
      // 更新新旧子序列中匹配的节点
      patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, optimized)
      patched++
    }
  }
}

建立了一个 newIndexToOldIndexMap 的数组,来存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,用于确定最长递增子序列,这个数组的长度为新子序列的长度,每个元素的初始值设为 0, 它是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明遍历旧子序列的过程中没有处理过这个节点,这个节点是新添加的。

下面说说具体的操作过程:正序遍历旧子序列,根据前面建立的 keyToNewIndexMap 查找旧子序列中的节点在新子序列中的索引,如果找不到就说明新子序列中没有该节点,就删除它;如果找得到则将它在旧子序列中的索引更新到 newIndexToOldIndexMap 中。

注意这里索引加了长度为 1 的偏移,是为了应对 i 为 0 的特殊情况,如果不这样处理就会影响后续求解最长递增子序列。

遍历过程中,用变量 maxNewIndexSoFar 跟踪判断节点是否移动,maxNewIndexSoFar 始终存储的是上次求值的 newIndex,一旦本次求值的 newIndex 小于 maxNewIndexSoFar,这说明顺序遍历旧子序列的节点在新子序列中的索引并不是一直递增的,也就说明存在移动的情况。

除此之外,这个过程中也会更新新旧子序列中匹配的节点,另外如果所有新的子序列节点都已经更新,而对旧子序列遍历还未结束,说明剩余的节点就是多余的,删除即可。

至此,完成了新旧子序列节点的更新、多余旧节点的删除,并且建立了一个 newIndexToOldIndexMap 存储新子序列节点的索引和旧子序列节点的索引之间的映射关系,并确定是否有移动。

可以看到, c、d、e 节点被更新,f 节点被删除,newIndexToOldIndexMap 的值为 [5, 3, 4 ,0],此时 moved 也为 true,也就是存在节点移动的情况。

  • 移动和挂载新节点

接下来,就到了处理未知子序列的最后一个流程,移动和挂载新节点:

const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0
  const l2 = c2.length
  // 旧子节点的尾部索引
  let e1 = c1.length - 1
  // 新子节点的尾部索引
  let e2 = l2 - 1
  // 1. 从头部开始同步
  // i = 0, e1 = 6, e2 = 7
  // (a b) c d e f g
  // (a b) e c d h f g
  // 2. 从尾部开始同步
  // i = 2, e1 = 6, e2 = 7
  // (a b) c (d e)
  // (a b) (d e)
  // 3. 普通序列挂载剩余的新节点, 不满足
  // 4. 普通序列删除多余的节点,不满足
  // i = 2, e1 = 4, e2 = 5
  // 旧子节点开始索引,从 i 开始记录
  const s1 = i
  // 新子节点开始索引,从 i 开始记录
  const s2 = i //
  // 5.1 根据 key 建立新子序列的索引图
  // 5.2 正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
  // 5.3 移动和挂载新节点
  // 仅当节点移动时生成最长递增子序列
  const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : EMPTY_ARR
  let j = increasingNewIndexSequence.length - 1
  // 倒序遍历以便可以使用最后更新的节点作为锚点
  for (i = toBePatched - 1; i >= 0; i--) {
    const nextIndex = s2 + i
    const nextChild = c2[nextIndex]
    // 锚点指向上一个更新的节点,如果 nextIndex 超过新子节点的长度,则指向 parentAnchor
    const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
    if (newIndexToOldIndexMap[i] === 0) {
      // 挂载新的子节点
      patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG)
    }
    else if (moved) {
      // 没有最长递增子序列(reverse 的场景)或者当前的节点索引不在最长递增子序列中,需要移动
      if (j < 0 || i !== increasingNewIndexSequence[j]) {
        move(nextChild, container, anchor, 2)
      }
      else {
        // 倒序递增子序列
        j--
      }
    }
  }
}

前面已经判断了是否移动,如果 moved 为 true 就通过 getSequence(newIndexToOldIndexMap) 计算最长递增子序列。

接着采用倒序的方式遍历新子序列,因为倒序遍历可以方便使用最后更新的节点作为锚点。在倒序的过程中,锚点指向上一个更新的节点,然后判断 newIndexToOldIndexMap[i] 是否为 0,如果是则表示这是新节点,就需要挂载它;接着判断是否存在节点移动的情况,如果存在的话则看节点的索引是不是在最长递增子序列中,如果在则倒序最长递增子序列,否则把它移动到锚点的前面。

为了便于更直观地理解,用前面的例子展示一下这个过程,此时 toBePatched 的值为 4,j 的值为 1,最长递增子序列 increasingNewIndexSequence 的值是 [1, 2]。在倒序新子序列的过程中,首先遇到节点 i,发现它在 newIndexToOldIndexMap 中的值是 0,则说明它是新节点,需要挂载它;然后继续遍历遇到节点 d,因为 moved 为 true,且 d 的索引存在于最长递增子序列中,则执行 j-- 倒序最长递增子序列,j 此时为 0;接着继续遍历遇到节点 c,它和 d 一样,索引也存在于最长递增子序列中,则执行 j--,j 此时为 -1;接着继续遍历遇到节点 e,此时 j 是 -1 并且 e 的索引也不在最长递增子序列中,所以做一次移动操作,把 e 节点移到上一个更新的节点,也就是 c 节点的前面。

新子序列倒序完成,即完成了新节点的插入和旧节点的移动操作,也就完成了整个核心 diff 算法对节点的更新。

来看一下示例处理后的结果,如下图所示:

可以看到新子序列中的新节点 i 被挂载,旧子序列中的节点 e 移动到了 c 节点前面,至此,就在已知旧子节点 DOM 结构和 vnode、新子节点 vnode 的情况下,求解出生成新子节点的 DOM 的更新、移动、删除、新增等系列操作,并且以一种较小成本的方式完成 DOM 更新。

知道了子节点更新调用的是 patch 方法, Vue.js 正是通过这种递归的方式完成了整个组件树的更新。

核心 diff 算法中最复杂就是求解最长递增子序列,下面再来详细学习一下这个算法。

  • 最长递增子序列

求解最长递增子序列是一道经典的算法题,多数解法是使用动态规划的思想,算法的时间复杂度是 O(n2),而 Vue.js 内部使用的是维基百科提供的一套“贪心 + 二分查找”的算法,贪心算法的时间复杂度是 O(n),二分查找的时间复杂度是 O(logn),所以它的总时间复杂度是 O(nlogn)。

单纯地看代码并不好理解,用示例来看一下这个子序列的求解过程。

假设有这个样一个数组 arr:[2, 1, 5, 3, 6, 4, 8, 9, 7],求解它最长递增子序列的步骤如下:

最终求得最长递增子序列的值就是 [1, 3, 4, 8, 9]。

通过演示可以得到这个算法的主要思路:对数组遍历,依次求解长度为 i 时的最长递增子序列,当 i 元素大于 i - 1 的元素时,添加 i 元素并更新最长子序列;否则往前查找直到找到一个比 i 小的元素,然后插在该元素后面并更新对应的最长递增子序列。

这种做法的主要目的是让递增序列的差尽可能的小,从而可以获得更长的递增子序列,这便是一种贪心算法的思想。

了解了算法的大致思想后,接下来看一下源码实现:

function getSequence (arr) {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) {
        // 存储在 result 更新前的最后一个索引的值
        p[i] = j
        result.push(i)
        continue
      }
      u = 0
      v = result.length - 1
      // 二分搜索,查找比 arrI 小的节点,更新 result 的值
      while (u < v) {
        c = ((u + v) / 2) | 0
        if (arr[result[c]] < arrI) {
          u = c + 1
        }
        else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }
  u = result.length
  v = result[u - 1]

  // 回溯数组 p,找到最终的索引
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

其中 result 存储的是长度为 i 的递增子序列最小末尾值的索引。比如上述例子的第九步,在对数组 p 回溯之前, result 值就是 [1, 3, 4, 7, 9] ,这不是最长递增子序列,它只是存储的对应长度递增子序列的最小末尾。因此在整个遍历过程中会额外用一个数组 p,来存储在每次更新 result 前最后一个索引的值,并且它的 key 是这次要更新的 result 值:

j = result[result.length - 1]
p[i] = j
result.push(i)

可以看到,result 添加的新值 i 是作为 p 存储 result 最后一个值 j 的 key。上述例子遍历后 p 的结果如图所示:

从 result 最后一个元素 9 对应的索引 7 开始回溯,可以看到 p[7] = 6,p[6] = 5,p[5] = 3,p[3] = 1,所以通过对 p 的回溯,得到最终的 result 值是 [1, 3 ,5 ,6 ,7],也就找到最长递增子序列的最终索引了。这里要注意,求解的是最长子序列索引值,它的每个元素其实对应的是数组的下标。对于的例子而言,[2, 1, 5, 3, 6, 4, 8, 9, 7] 的最长子序列是 [1, 3, 4, 8, 9],而求解的 [1, 3 ,5 ,6 ,7] 就是最长子序列中元素在原数组中的下标所构成的新数组。

# 总结

Vue.js 的更新粒度是组件级别的,并且 Vue.js 在 patch 某个组件的时候,如果遇到组件这类抽象节点,在某些条件下也会触发子组件的更新。

对于普通元素节点的更新,主要是更新一些属性,以及它的子节点。子节点的更新又分为多种情况,其中最复杂的情况为数组到数组的更新,内部又根据不同情况分成几个流程去 diff,遇到需要移动的情况还要去求解子节点的最长递增子序列。

整个更新过程还是利用了树的深度遍历,递归执行patch方法,最终完成了整个组件树的更新。

下面,通过一张图来更加直观感受组件的更新流程:

最后更新: 9/1/2022, 2:26:35 PM