Vue 3.0 设计与实现

第一章 设计

第二章 响应式系统

不同于 Vue 2.x,Vue 3.x 放弃了 Object.defineProperty() 的方式,选择了 ES6 Proxy 来实现响应式系统,并且将响应式系统更多的暴露给用户。但是其中的设计思路并没有变化:对数据劫持,将其变为响应式数据,更具体一些就是,在 getter 中收集依赖,在 setter 中触发 trigger 来重新执行函数。

副作用函数

副作用函数,这是个与纯函数相对的概念。副作用函数执行会产生副作用(废话)。举个例子,Vue 模板中读取了一个数据(响应式数据),这里副作用函数就是 render 函数,render 函数读取响应式数据,并将 UI 渲染到页面上(其实只是生成了虚拟节点)。那么,当 render 函数读取过的数据变化时,render 函数应该重新执行,生成新的虚拟节点。我们抽取一下其中的思想:如果一个副作用函数读取了响应式数据,那么响应式数据变化时,这个函数应该重新执行。为什么一定是副作用函数呢?因为不产生我们需要的副作用的函数我们不 care 啊。简单的总结就是:副作用函数,就是我们需要他跟着响应式数据变化的函数

所以说,响应式系统设计的重点有两个:响应式数据 和 副作用函数。副作用函数读取了响应式数据,触发了响应式数据的 getter,就将自己记录到对应响应式数据的一种数据结构中;当响应式数据的 setter 被触发,说明副作用函数应该被执行,我们就可以找到对应的数据结构,将其中的函数全部执行一遍

  
const obj = {
  foo: 1,
}

effect(() => {
  console.log(obj.foo);
})

// 当我们修改了obj.foo时,我们希望重新打印obj.foo
obj.foo++;

那么我们来设计一下 effect 函数:

  
// 暂时存储被注册的副作用函数
let activeEffect;

function effect(fn) {
  // 把当前函数放入全局,方便 getter 能找到被注册的函数
  activeEffect = fn;
  // 执行 fn,这会触发响应式数据的 getter
  fn();
}

这时,我们还需要将 obj 变为响应式数据,主要思路就是之前说的 在 getter 收集依赖,在 setter 触发依赖,但是这时我们需要考虑将副作用函数收集在哪里,这里我就不卖关子了,收集进一个树型的数据结构:

  
// 最外层是一个 weakMap,key 为响应式对象,value 为另一个 map
{
  // 第二层的 map,保存了对象具体的 key,以及对应的副作用函数们(保存在 Set 中,这样就不会重复)
  [obj1]: {
    // 每一个 key 对应一个 set,保存所有副作用函数
    'foo': [fn, fn, ...],
    'bar': [fn, ...]
  },
  [obj2]: {
    // ...
  }
}

第一层使用 WeakMap 是因为不希望产生引用导致垃圾回收机制无法回收响应式数据。到了第二层,就没有必要使用弱引用了,因为如果源对象被垃圾回收,那么对应的值,也就是 map 也会被回收掉。

设计完数据结构,我们就可以正式开始写响应式数据了:

  
// 存储副作用函数
const bucket = new WeakMap();

const obj = new Proxy({ foo: 1 }, {
  // 对[[get]]操作进行拦截
  get(target, key) {
    // 如果找不到正在执行的副作用函数,直接返回
    if(!activeEffect) return;
    // 试图拿对应的map
    let depMap = bucket.get(target);
    // 如果没有就添加一个对应的map
    if (!depMap) {
      depMap = new Map();
      bucket.set(target, depMap);
    }
    // 试图拿对应的set,保存了所有副作用函数
    let depSet = depMap.get(key);
    if (!depSet) {
      depSet = new Set();
      depMap.set(key, depSet);
    }
    // 向set中添加这次读取数据的副作用函数,set本身是会去重的
    depSet.add(activeEffect);
    // 返回对应值
    return target[key];
  },
  // 对[[set]]操作进行拦截
  set(target, key, newVal) {
    // 设置属性值
      target[key] = newVal;
    // 取对应的map
    const depMap = bucket.get(target);
    if (!depMap) return;
    const depSet = depMap.get(key);
    // 执行所有的副作用函数
    depSet && depSet.forEach(fn => fn())
    }
})

当然,我们可以把上面追踪依赖的逻辑(getter)和出发依赖的逻辑(setter)单独封装为函数,即 tracktrigger

  
const bucket = new WeakMap();

const obj = new Proxy({ foo: 1 }, {
  get(target, key) {
    track(target, key);
    return target[key];
  },
  set(target, key, newVal) {
    trigger(target, key);
    target[key] = newVal;
  }
})

// 封装track逻辑
function track(target, key) {
    if (!activeEffect) return;
  let depMap = bucket.get(target);
  if (!depMap) {
    depMap = new Map();
    bucket.set(target, depMap);
  }
  let depSet = depMap.get(key);
  if (!depSet) {
    depSet = new Set();
    depMap.set(key, depSet);
  }
  depSet.add(activeEffect);
  return target[key];
}

// 封装trigger逻辑
function trigger(target, key) {
    const depMap = bucket.get(target);
  if (!depMap) return;
  const depSet = depMap.get(key);
  depSet && depSet.forEach(fn => fn());
}

cleanUp

现在我们只是大致实现了基本原理,但还是有很多细节没有考虑,比如当用户的副作用函数读取数据时,是通过分支语句读取的,那么当分支语句切换时,会产生遗留的副作用函数:

  
const data = {
  ok: true,
  text: 'Hello World!'
}
const obj = new Proxy(data, {/* ... */})

effect(() => {
  // 这里使用三元表达式产生分支。
  document.body.innerHTML = data.ok ? data.text : 'not';
})

如果 data.oktrue ,那么副作用会被收集到 data.ok 以及 data.text 对应的 set 中。那么如果收集完后,data.ok 变为 false,那么 data.text 收集的依赖就是不需要的了,因为这里 data.text 的变化不会再影响页面上的数据了。那么我们怎么解决这个遗留副作用函数的问题呢?我们只需要在每次执行副作用函数时,把该函数从所有收集了它的 Set 中移除就好了,这样再次执行副作用函数时,会产生新的、必须的依赖,无用的依赖就不会被收集到。

想法很简单,但是我们需要能够从副作用函数找到所有相关联的响应式数据,所以我们在 effect 函数中,给所有的副作用函数添加一个 dep 数组,能保存下来这个函数被哪些 Set 收集了:

  
function effect(fn) {
  // 再封装一层,这样就不会直接添加deps数组到用户的函数上了,而且每次执行副作用函数都会将它添加在全局的activeEffect中
  const effectFn = () => {
    activeEffect = effectFn;
    fn();
  }
  // 新添加的数组,记录Set
  effectFn.deps = [];
  // 执行函数
  effectFn();
}

那么 effect 这边的处理我们暂时就改好了,还有 track 我们需要处理。我们需要能够在一个函数被追踪时,将对应 Set 收集到函数中:

  
function track(target, key) {
  if (!activeEffect) return;
  let depMap = bucket.get(target);
  if (!depMap) {
    depMap = new Map();
    bucket.set(target, depMap);
  }
  let depSet = depMap.get(key);
  if (!depSet) {
    depSet = new Set();
    depMap.set(key, depSet);
  }
  // 收集set到副作用函数中
  activeEffect.deps.push(depSet);
}

我们已经能够从副作用函数找到对应的 Set 了,现在在回头看 effect,我们的目的是每次执行之前先把自己从 Set 中清理掉,所以我们再封装一个 cleanUp 函数:

  
function effect(fn) {
  const effectFn = () => {
    // 调用cleanUp,清理set
      cleanUp(effectFn);
    activeEffect = effectFn;
    fn();
  }
  effectFn.deps = [];
  effectFn();
}

// 实现cleanUp函数
function cleanUp(effectFn) {
  // 遍历deps,删除掉所有set中的副作用函数
  effectFn.deps.forEach((depSet) => {
    depSet.delete(effectFn);
  });
  // 清空deps数组
  effectFn.deps.length = 0;
}

目前我们的逻辑通畅,但是执行会产生死循环,因为我们的 trigger 会遍历 set,执行所有函数,但执行函数会调用 cleanUp,将函数本身从 set 移除,随后又执行 fn,导致函数又被添加到 set 中,这就相当于:

  
const set = new Set([1]);

set.forEach(item => {
  set.delete(1);
  set.add(1);
});

这会导致无限循环。解决方法就是,我们遍历一个 set 副本,而不是直接修改正在遍历的 set

  
function trigger(target, key) {
  const depMap = bucket.get(target);
  if (!depMap) return;
  const depSet = depMap.get(key);
  // 创建一个仅仅用来遍历的副本
  const depSetToRun = new Set(depSet);
  depSetToRun.forEach(effectFn => effectFn());
}

现在我们就完成了对遗留的副作用函数的清理。

响应式数据

引用类型

之前我们都是硬编码的方式,对数据进行代理拦截,接下来我们要优化响应式数据的封装:

  
function reactive(obj) {
  return new Proxy(obj, {
    // 各种拦截器
  });
}

值得注意的是,reactive 是深层响应的,因为其中的 get 拦截器如果发现返回的属性是一个对象,会递归的调用 reactive 返回响应的对象。

但是有时我们可能不希望深响应,这就催生了 shallowReactive ,即浅响应。浅响应不会递归的返回响应对象,而是只有一层响应。

同样,Vue 3.0 在完成各种代理时也完成了只读属性和浅只读属性,只需要在拦截器中拦截掉所有修改即可。

原始类型

对于非原始值(即 JS 引用类型)的拦截,Proxy 是做不到的,因为他们并不按照引用的方式传递,当原始值赋值时,两个变量时完全没有关系的。所以 Vue 3.0 对于原始值的响应式处理是:包裹一层对象,让它变成引用类型

  
function ref(val) {
  return reactive({
    value: val
  });
}

所以从实现来看,ref(1)reactive({ value: 1 }) 并没有区别。但是我们需要知道它是一个 ref 包裹的原始值,还是真的就是一个响应的对象,因为这涉及到 Vue 自动脱 ref(Vue 为了避免用户反复通过 .value 语法来编码,会自动取 ref 对象的 value 属性)。所以 Vue 给 ref 对象添加了一个不可枚举的属性来标记:

  
function ref(val) {
  const wrapper = {
    value: val
  };
  // 添加一个不可枚举的属性标记
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  });
  return reactive(wrapper);
}

toRef 和 toRefs

我们可能会希望在 setup 返回的对象中直接展开一个包裹好的响应式对象,这样代码会看起来很简洁,而且不需要反复的用 . 来取属性。但是实际上,如果我们直接用展开运算符来展开一个响应式对象,那会导致丢失响应。

  
const obj = reactive({
  foo: 1,
  bar: 2
});

return {
  ...obj
};
// 这么做等价于
return {
  foo: 1,
  bar: 2
};

所以需求是:我们能不能得到一个普通对象,其中的每一个属性都映射到了响应式的对象对应的属性上,这样我们就可以对这个普通对象展开,所有的属性依旧指向响应式对象的属性?

答案是可以的,我们需要使用 gettersetter 来让普通对象的属性和响应式对象的属性之间建立联系:

  
const obj = reactive({ foo: 1, bar: 2 });

const newObj = {
  foo: {
    get value() {
      return obj.foo;
    },
    set value(newVal) {
      obj.foo = newVal;
    }
  },
  bar: {
    get value() {
            return obj.bar;
    },
    set value(newVal) {
      obj.bar = newVal;
    }
  }
}

观察上面的 newObj,我们其实是建立了一个新的普通对象,这个对象的每一个属性都类似于一个 ref 包裹的对象,引用着原始的响应对象。为了概念上的统一,我们将这些属性也视作 ref 对象。接下来将这个逻辑改为函数:

  
function toRef(obj, key) {
  const wrapper = {
    get value() {
      return obj[key];
    },
    set value(newVal) {
      obj[key] = newVal;
    }
  }
  // 视作真正的 ref 对象
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  })
  
  return wrapper;
}

const obj = reactive({ foo: 1, bar: 2 });
// 我们希望得到的,为展开运算符消费的普通对象
const newObj = {
  // 其中的属性保持了对响应式对象的链接,可以看做是真正的 ref 对象
  foo: toRef(obj, 'foo'),
  bar: toRef(obj, 'bar')
};

那么我们可以再封装一次,让我们不需要自己一次次调用 toRef 函数:

  
function toRefs(obj) {
  const res = {};
  for (const key in obj) {
    // 调用toRef来批量转换
    res[key] = toRef(obj, key);
  }
  return res;
}

这样,当我们读取 toRef 产生的对象时,其实是读取了对应响应式数据,设置其值时,也是对响应式数据进行设置。

自动脱 ref

为了不给用户增加更多的心智负担,我们希望在模板中能自动脱去 ref 的能力,即会自动读取 ref 对象的 value 属性。

  
<template>
    <!-- 我们希望这么使用,而不是foo.value -->
    <h2>{{foo}}</h2>
</template>

<script>
    export default {
    setup() {
      const obj = reactive({
        foo: 1,
        bar: 2
      });
      
      return {
        ...toRefs(obj)
      }
    }
  }
</script>

Vue 给出的解决方案是:再通过 Proxy 代理一次。

  
function proxyRefs(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver);
      // 如果是 ref 对象,就读取 value 属性
      return value.__v_isRef ? value.value : value;
    },
    set(target, key, newValue, receiver) {
      const value = target[key];
      if (value.__v_isRef) {
        value.value = newValue;
        return;
      }
      return Reflect.set(target, key, newValue, receiver);
    }
  })
}

setup 函数返回的对象,会被这个函数处理一次,这就是为什么我们可以直接在模板中使用 ref 值,而无需通过 value 属性。

第三章 渲染器

整体概述

渲染器是框架性能的核心。渲染器负责将虚拟 DOM 渲染到特定平台上(是跨平台的)。

  
function createRenderer() {
  function render(vnode, container) {
    // ...
  }
  
  function hydrate(vnode, container) {
    // ...
  }
  
  return {
    render,
    hydrate
  }
}

const renderer = createRenderer();
// 首次渲染
renderer.render(oldVnode, document.querySelector('#app'));
// 第二次渲染
renderer.render(newVnode, document.querySelector('#app'));
// 第三次渲染
renderer.render(null, document.querySelector('#app'));

这里我们的 createRenderer 函数创建了一个渲染器,渲染器(renderer)和渲染(render)是有区别的,渲染器中包含了更多的功能(比如 hydrate 函数),而 render 只负责将虚拟 DOM 渲染到容器中。

我们首次渲染时,是没有 oldVnode 的,只有我们传入的新的 Vnode,这时我们需要执行「挂载」操作,将传入的 Vnode 挂载进容器中。

当我们第二次调用 render 函数时,这时,容器中会保存下来上一次的 Vnode,与本次传入的新的 Vnode 进行对比,力求最小量对 DOM 进行更新,所以这时我们需要执行「更新」操作。

当我们第三次调用 render 函数,传入的新的 Vnode 是 null,表示我们本次要执行的是一个「卸载」操作,需要对容器中的 DOM 进行卸载。

当然,我们其实是可以将「挂载」和「更新」这两个操作看做是一个操作,「挂载」是特殊的「更新」。

  
function render(vnode, container) {
  // 传入了vnode,表示要进行更新操作
  if(vnode) {
    patch(container._vnode, vnode, container);
  }
  // 没传入vnode,表示需要卸载
  else {
    // 卸载逻辑
  }
  // 这次的vnode保存下来,下次再调用时使用
  container._vnode = vnode
}

挂载与更新

那么我们开始考虑 patch 函数:

  
function patch(oldVnode, newVnode, container) {
  // 没有oldVnode,说明要挂载
  if(!oldVnode) {
    mountElement(newVnode, container);
  }
  // 要更新
  else {
    // 更新逻辑
  }
}

这里我们将所有对平台具体的操作抽离出去,这样我们可以针对不同的平台修改这些函数。

挂载

我们可以考虑 mountElement 函数:

  
function createRenderer(options) {
  // 将跨平台实现的函数传进来
  const {
    createElement,
    insert,
    setElementText
  } = options;
  
  function mountElement(vnode, container) {
    // 将真实的DOM元素保存下来,这样方便其他的操作
    const el = vnode.el = createElement(vnode.type);
    
    // 处理children
    // children属性为字符串,设置内部文本
    if(typeof vnode.children === 'string') {
      setElementText(el, vnode.children);
    }
    // 有子节点
    else if(Array.isArray(vnode.children)) {
      // 遍历子节点,对每一个子节点进行挂载,所以第一个参数是null
      vnode.children.forEach(child => patch(null, child, el));
    }
    
    // 处理props
    for(const key in vnode.props) {
      // 设置属性,这里我们可以选择直接设置,也可以通过setAttribute,这要看具体的情况(不多展开细节)
      el[key] = vnode.props[key];
    }
    
    // 将定制好的节点插入容器
    insert(el, container);
  }
  
  function patch(oldVnode, newVnode, container) {
    // ...
  }
  
  function render(vnode, container) {
    // ...
  }
  
  return {
    render
  }
}

卸载

接着我们讨论「卸载」操作:

  
function render(vnode, container) {
  if(vnode) {
    patch(contain._vnode, vnode, container);
  }
  // 没传入vnode,表示需要卸载
  else {
      if(container._vnode) {
      // 封装进unmount函数
      unmount(container._vnode)
    }
  }
  container._vnode = vnode
}
  
function unmount(vnode) {
  // 我们调用原生的卸载方法
  const parent = vnode.el.parentNode;
  if(parent) parent.removeChild(vnode.el);
}

由于我们想要卸载的元素,可能是组件,或者包含自定义指令,这时我们需要在执行 unmount 函数时,调用这些钩子函数。

事件的处理

关于事件,我们需要考虑:如何在虚拟节点上描述事件、如何将事件添加在 DOM 上 以及 如何更新事件。

对于描述事件,我们可以把事件看做是特殊的,以 on 开头的属性。

将事件添加在 DOM 上,我们只需要通过 addEventListener 来绑定即可。

但是对于事件更新,如果我们需要更换事件,则需要 removeEventListener,再绑定新的事件。所以 Vue 采用了比较取巧的方式:绑定一个假的事件处理函数,在这个函数中调用真实的处理函数,这样我们就可以直接换掉真实的函数了。

  
function patchProps(el, key, prevValue, nextValue) {
  if(/^on/.test(key)) {
    // 这个invoker就是我们的假的处理函数,保存在el._vei中
    const invokers = el._vei || (el._vei = {});
    let invoker = invokers[key];
    const name = key.slice(2).toLowerCase();
    if(nextValue) {
      // 之前没有绑定过这个事件
      if(!invoker) {
        invoker = el._vei[key] = (e) => {
          // 调用真正的处理函数
          invoker.value(e)
        }
        invoker.value = nextValue;
        el.addEventListener(name, invoker);
      } else {
        // 之前绑定过,直接换值就行
        invoker.value = newxtValue;
      }
    } else if (invoker) {
      // nextValue 没有值,但是invoker有值,说明需要移除事件
      el.removeEventListener(name, invoker)
    }
  }
  else if (key === 'class') {
    // ...
  }
  else {
    // ...
  }
}

更新子节点

更新子节点时,我们首先区分子节点是不是只有文本:

  • 如果 vnode.children 是字符串,那么说明元素有文本子节点。
  • 如果 vnode.children 是数组,那么说明元素有多个子节点。

之所以要区分文本节点和子节点,是因为这样我们可以让更新子节点的逻辑更清晰。

那么一个节点的子节点有可能是:

  • 没有子节点
  • 文本子节点
  • 一组子节点

由于有新旧节点之分,那么就是 3 * 3 = 9 种情况。

  
function patchElement(n1, n2) {
  const el = n2.el = n1.el;
  const oldProps = n1.props;
  const newProps = n2.props;
  // 更新props
  for (const key in newProps) {
    // 更新属性
    if (newProps[key] !== oldProps[key]) {
      patchProps(el, key, oldProps[key], newProps[key]);
    }
  }
  for (const key in oldProps) {
    // 删除属性
    if (!(key in newProps)) {
      patchProps(el, key, oldProps[key], null);
    }
  }
  // 更新子节点的函数
  patchChildren(n1, n2, el);
}
  
function patchChild(n1, n2, container) {
  // 新子节点是字符串
    if (typeof n2.children === 'string') {
    // 如果旧子节点是一组子节点,依次卸载
    if (Array.isArray(n1.children)) {
      n1.children.forEach(c => unmount(c));
    }
    // 旧子节点是其他两种情况,直接换成新的文本即可
    setElementText(container, n2.children);
  }
  // 新子节点是一组子节点
  else if (Array.isArray(n2.children)) {
    // 旧子节点也是一组子节点
    if (Array.isArray(n1)) {
      // 这里就是核心的 diff 算法
      
    } else {
      // 旧子节点不是一组子节点,我们只需要清空之前的内容,依次挂载新的子节点
      setElementText(container, '');
      n1.children.forEach(c => patch(null, c, container));
    }
  }
  // 新子节点不存在
  else {
    if (Array.isArray(n1.children)) {
      n1.children.forEach(c => unmount(c));
    }
    else if (typeof n1.children === 'string') {
      setElementText(container, '');
    }
  }
}

Diff 算法

接下来我们就来看看渲染器中最核心的 diff 算法。

当新旧子节点都是一组子节点时,为了最小的性能开销完成更新,需要通过 diff 比较出两组节点的区别,然后最小量更新 DOM。

简单 Diff 算法

我们可以对两组节点依次对比(不考虑节点仅顺序改变),如果有标签可以复用,我们就可以少操作一次 DOM。

  
function patchChildren(n1, n2, container) {
  // ...
  else if (Array.isArray(n2)) {
    if (Array.isArray(n1)) {
      const oldChildren = n1.children;
      const newChildren = n2.children;
      const oldLen = oldChildren.length;
      const newLen = newChildren.length;
      // 找到较短的长度,作为公共长度
      const commonLen = Math.min(oldLen, newLen);
      for (let i = 0; i < commonLen; i++) {
        patch(oldChildren[i], newChildren[i]);
      }
      if (newLen > oldLen) {
        // 如果新节点更长,那么把新的节点挂载上去
        for (let i = commonLen; i < newLen; i++) {
          patch(null, newChildren[i], container);
        }
      } else if (oldLen > newLen) {
        for (let i = commonLen; i < oldLen; i++) {
          // 如果旧节点更长,就把长的部分卸载掉
          unmount(oldChildren[i]);
        }
      }
    }
  }
  // ...
}

那么我们可以很轻易地发现这种方法还有很大的优化空间,首先就是我们并没有考虑到顺序改变后 DOM 的复用。

有些时候,我们很可能只是改变了几个 DOM 节点的排列顺序,这时我们只需要移动这些 DOM 节点就可以了。这时我们需要能鉴别出,哪些 DOM 节点是相同的,这只靠 vnode.type 并不可靠:

  
// oldChildren
{
  { type: 'p', clildren: '1' },
  { type: 'p', clildren: '2' },
  { type: 'p', clildren: '3' },
}
// newChildren
{
  { type: 'p', clildren: '2' },
  { type: 'p', clildren: '3' },
  { type: 'p', clildren: '1' },
}

这种情况我们只需要移动节点顺序,但是我们无法判断哪些节点是相同的节点,这就是我们为什么需要 key 属性。我们可以通过 key 的值来对节点进行鉴别。

要注意的是,即使是 key 值相同,也不意味着不需要进行 patch,因为新旧节点的值可能会改变,只是说这个 DOM 可以复用。

更新节点
  
function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        // ...
  } else if (Array.isArray(n2.children)) {
    const oldChildren = n1.children;
    const newChildren = n2.children;
    
    // 遍历新节点,寻找相同节点(先遍历新节点是为了避免有节点被移除)
    for (const newVNode of newChildren) {
      for (const oldVNode of oldChildren) {
        if (oldVNode !== newVNode) {
          // 找到相同节点,进行patch
                    patch(oldVNode, newVNode, container);
          break; // 找到了,不需要再找了
        }
      }
    };
  } else {
        // ...
  }
};

这样我们就可以保证,所有的新旧节点都已经更新了,但是还没有移动到正确的顺序。因为我们是使用新节点依次对比旧节点,所以我们已经解决了挂载和卸载的问题。

移动元素

我们可以开始考虑移动元素。首先我们需要找到要移动的元素。我们可以先思考什么时候不需要移动元素?答案是在节点顺序没有发生变化时,我们不需要移动元素。

  
// oldChildren
{
  { key: 1 },
  { key: 2 },
  { key: 3 },
}

// newChildren1 和之前一样,不需要移动
{
  { key: 1 },
  { key: 2 },
  { key: 3 },
}

// newChildren2 少了一个,但是顺序没变,也不需要移动
{
  { key: 1 },
  { key: 3 },
}

// newChildren3 打乱顺序,需要移动
{
  { key: 3 },
  { key: 1 },
  { key: 2 },
}

那么我们就可以发现,重点在于 「相对顺序」,而不是简单的比较。

我们使用新节点(newChildren1)依次对比旧节点,可以发现新的元素在旧节点中的位置为:0, 1, 2。是一个递增序列,说明 「相对顺序」 没有改变,所以不需要移动。

我们还可以以同样的方法来看 newChildren2,序列为:0, 2。所以也不需要移动。

那么我们来看需要移动的 newChildren3

  • 第一个新节点对应旧节点中的位置是 2;

  • 第二个新节点是 0,说明他与前一个节点的顺序不对,这个节点需要被移动;

  • 第三个节点是 1,说明与第一个节点的顺序不对,也需要被移动;

我们再简单的总结一下这个算法:找到一个**「相对位置」**最大的索引值(也可以是暂时最大的,因为即使后面有更大的也不影响,说明前面的都比那个更大的小),后面如果有更大的,就更新这个最大索引值,如果更小,则说明这个节点需要移动。

  
function patchChildren(n1, n2, container) {
    // ...
  else if (Array.isArray(n2)) {
    const oldChildren = n1.children;
    const newChildren = n2.children;
    
    // 最大索引
    let lastIndex = 0;
    for (let i = 0; i < newChilren.length; i++) {
      const newVNode = newChildren[i];
      for (let j = 0; j < oldChlidren.length; j++) {
        const oldVNode = oldChildren[j];
        if (newVNode.key === oldVNode.key) {
                    patch(oldVNode, newVNode, container);
          if (j < lastIndex) {
            // 当前找到的索引值小于最大索引值,需要移动
          } else {
            // 找到的索引值不小于最大索引值,需要更新
            lastIndex = j
          }
          break;
        }
      };
    }
  } else {
    // ...
  }
};

在我们找到要移动的元素后,我们就可以思考怎么移动元素了。想要移动元素,我们需要拿到元素的引用,这在 patch 时,被放进了新节点的 el 属性上。

  
function patchElement(n1, n2) {
    const el = n2.el = n1.el;
  // ...
};

随后我们考虑如何移动节点,依旧是 newChildren3

  
// oldChildren
{
  { key: 1 },
  { key: 2 },
  { key: 3 },

// newChildren3 打乱顺序,需要移动
{
  { key: 3 },
  { key: 1 },
  { key: 2 },
}
  1. 取新节点中第一个 key 为 3 的虚拟节点,去找到在旧节点中的索引为 2,更新 lastIndex 为 2。
  2. 取下一个节点 key 为 1,找到所在索引为 0,需要移动。将它移动到 key = 3 节点的后面。
  3. 再取下一个节点 key 为 2,找到所在索引为 1,需要移动。将它移动到 key = 1 节点的后面。

更新完成。我们可以发现,新节点们的顺序,就是我们希望移动的顺序。所以对于每个需要移动的节点来说,只需要把它放在他在新节点中的前一个节点的后面就行了(因为新节点顺序没错,所以在前一个节点的后一个节点,看似是废话)。

那么我们看下根据这个思路实现的代码:

  
function patchChildren(n1, n2, container) {
  // ...
  else if (Array.isArray(n2)) {
    const oldChildren = n1.children;
    const newChildren = n2.children;
    
    // 最大索引
    let lastIndex = 0;
    for (let i = 0; i < newChilren.length; i++) {
      const newVNode = newChildren[i];
      for (let j = 0; j < oldChlidren.length; j++) {
        const oldVNode = oldChildren[j];
        if (newVNode.key === oldVNode.key) {
                    patch(oldVNode, newVNode, container);
          if (j < lastIndex) {
            // 当前找到的索引值小于最大索引值,需要移动
            // 拿到 newVNode 的前一个节点,他对应 DOM 节点后面就是正确的位置
            const prevVNode = newVNode[i - 1];
            // 如果 prevVNode 不存在,说明是第一个,不需要移动
            if (prevVNode) {
              // 由于我们需要移动真实 DOM 节点,所以需要找到它的下一个兄弟节点,放在他下一个兄弟的前面
              const anchor = prevVNode.el.nextSibling;
              // 移动节点,这个函数跨平台实现,在浏览器是由 insertBefore 实现
              insert(newVNode.el, container, anchor);
            }
          } else {
            lastIndex = j;
          }
          break;
        }
      };
    } 
  } else {
    // ...
  }
};
新增元素

如果 newChildren 中有新添加的节点,在 oldChildren 中找不到索引。这时我们需要挂载新的节点,并将它放在合适的位置。

  
function patchChildren(n1, n2, container) {
  // ...
  else if (Array.isArray(n2)) {
    const oldChildren = n1.children;
    const newChildren = n2.children;
    
    let lastIndex = 0;
    for (let i = 0; i < newChilren.length; i++) {
      const newVNode = newChildren[i];
      // 设定一个变量指示有没有找到匹配的索引
      let find = false;
      for (let j = 0; j < oldChlidren.length; j++) {
        const oldVNode = oldChildren[j];
        if (newVNode.key === oldVNode.key) {
          find = true;
                    patch(oldVNode, newVNode, container);
          if (j < lastIndex) {
            // 当前找到的索引值小于最大索引值,需要移动
            // 拿到 newVNode 的前一个节点,他对应 DOM 节点后面就是正确的位置
            const prevVNode = newVNode[i - 1];
            // 如果 prevVNode 不存在,说明是第一个,不需要移动
            if (prevVNode) {
              // 由于我们需要移动真实 DOM 节点,所以需要找到它的下一个兄弟节点,放在他下一个兄弟的前面
              const anchor = prevVNode.el.nextSibling;
              // 移动节点,这个函数跨平台实现,在浏览器是由 insertBefore 实现
              insert(newVNode.el, container, anchor);
            }
          } else {
            lastIndex = j;
          }
          break;
        }
      };
      // 找完一个新节点,来看看这个节点是不是新节点。find 为 false 表示这个节点需要挂载
      if (!find) {
        // 找到前一个节点的下一个兄弟节点作为锚点
                const prevVNode = newChildren[i - 1];
        let anchor = null;
        // 如果有前置节点,说明不是第一个
        if (prevVNode) {
          anchor = prevVNode.el.nextSibling;
        }
        // 说明是第一个节点,使用第一个元素作为锚点
        else {
          anchor = container.firstChild;
        }
        // 挂载节点,这里需要指定挂载位置
        patch(null, newVNode, container, anchor);
      }
    } 
  } else {
    // ...
  }
};

由于我们需要新的 patch 函数,所以我们再修改一下

  
// patch 函数接受第四个参数,即锚点元素
function patch() {
  // ...
  if (typeof type === 'string') {
    if (!n1) {
      // 挂载,将锚点元素传过去
      mountElement(n2, container, anchor);
    } else {
      patchElement(n1, n2);
    }
  }
  // ...
}

function mountElement(vnode, container, anchor) {
    // ...
  insert(el, container, anchor);
}
移除元素

如果新的节点被删除了,那么旧节点会遗留下来。我们只需要在新节点都移动处理完后,再遍历一遍旧节点,删除其中的不存在的节点即可。

  
function patchChildren(n1, n2, container) {
  // ...
  else if (Array.isArray(n2)) {
    const oldChildren = n1.children;
    const newChildren = n2.children;
    
    let lastIndex = 0;
    for (let i = 0; i < newChilren.length; i++) {
      // ...
    } 
    
    // 处理完,再处理删除的节点
    for (let i = 0; i < oldChildren.length; i++) {
      const oldVNode = oldChildren[i];
            const has = newChildren.find(vnode => vnode.key === oldVNode);
      if (!has) {
        // 如果在newChildren中找不到,那么删除该节点
        unmount(oldVNode);
      }
    }
  } else {
    // ...
  }
};

双端 Diff 算法

简单 Diff 算法已经解决了问题,但是仍然有一些缺陷,简单 Diff 算法对 DOM 的移动操作并不是最优的。比如:

新节点					旧节点
p--3          p--1
p--1          p--2
p--2          p--3

在这种情况下,简单 Diff 算法会让 p--1 节点和 p--2 节点移动到 p--3 节点的后面,但其实我们只需要将 p--3 节点移动到最前就可以了。

原理

双端 Diff 算法同时对新旧两组节点的两个端点同时进行比较,这里会提供四个指针,分别指向四个端点。

新旧节点及真实 DOM 状态.jpg

每一轮都会进行四次比较(中间的灰色箭头),如果有某次发现节点可以复用(第四次比较,发现 p-4 节点可以复用),那么移动对应的节点(第四次比较将 oldEndIdx 对应的节点,移动到 oldStartIdx 节点之前),随后指针接着移动。

第一次移动后状态.jpg

代码如下:

  
function patchKeyChild(n1, n2, container) {
  const oldChildren = n1.children;
  const newChildren = n2.children;
  // 四个索引值
  let oldStartIdx = 0;
  let oldEndIdx = oldChildren.length - 1;
  let newStartIdx = 0;
  let newEndIdx = oldChildren.length - 1;
  // 四个索引值指向的 vnode 节点
  let oldStartVNode = oldChildren[oldStartIdx];
  let oldEndVNode = oldChildren[oldEndIdx];
  let newStartVNode = newChildren[newStartIdx];
  let newEndVNode = newChildren[newEndIdx];
  
  // 循环进行双端比较,直到有一组结束
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVNode.key === newStartVNode.key) {
      // 第一次比较,新旧节点起始互相比较
    } else if (oldEndVNode.key === newEndVNode.key) {
      // 第二次比较,新旧节点末尾互相比较
    } else if (oldStartVNode.key === newEndVNode.key) {
      // 第三次比较,新节点末尾与旧节点起始互相比较
    } else if (oldEndVNode.key === newStartVNode.key) {
      // 第四次比较,旧节点末尾与新节点起始互相比较
      
      // 假设第四次找到了可复用的节点,需要将 oldEnd,移动到 oldStart 前面
      // 调用 patch 进行打补丁
      patch(oldEndVNode, newStartVNode, container);
      // 移动 DOM
      insert(oldEndVNode.el, container, oldStartVNode.el);
      // 更新索引
      oldEndVNode = oldChildren[--oldEndIdx];
      newStartVNode = newChildren[++newStartIdx];
    }
  }
}

剩余三种情况的操作也和第四种情况类似,只是移动的元素不同。

直到至少有一方的索引汇合,双端比较结束:

双端比较结束后状态.jpg

非理想情况

在理想情况下,比如:

新节点					旧节点
p--3          p--1
p--1          p--2
p--2          p--3

我们会发现,双端 Diff 只需要移动一次,而简单 Diff 需要移动两次。

但是这是在能找到可复用节点的情况,如果是这种情况:

新节点					旧节点
p--2          p--1
p--4          p--2
p--1          p--3
p--3					p--4

我们就会发现,第一轮比较中,一个可复用的节点都没有,这时我们就需要另外的处理方式:我们会拿新一组节点的头部节点去旧一组中寻找:

  
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (oldStartVNode.key === newStartVNode.key) {
    // 第一次比较,新旧节点起始互相比较
  } else if (oldEndVNode.key === newEndVNode.key) {
    // 第二次比较,新旧节点末尾互相比较
  } else if (oldStartVNode.key === newEndVNode.key) {
    // 第三次比较,新节点末尾与旧节点起始互相比较
  } else if (oldEndVNode.key === newStartVNode.key) {
    // 第四次比较,旧节点末尾与新节点起始互相比较
  } else {
    // 四次比较都没有发现有可复用节点
    // 使用新节点头部去找对应的索引
    const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key);
  }
}

我们找到了头部节点 p--2 对应的索引 1,这说明,原来索引 1 的节点,现在应该排在头部,我们可以将它移动到头部。

  
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (oldStartVNode.key === newStartVNode.key) {
    // ...
  } else if (oldEndVNode.key === newEndVNode.key) {
    // ...
  } else if (oldStartVNode.key === newEndVNode.key) {
    // ...
  } else if (oldEndVNode.key === newStartVNode.key) {
    // ...
  } else {
    // 使用新节点头部去找对应的索引
    const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key);
    if (idxInOld >= 0) {
      // 要移动的节点
      const vnodeToMove = oldChildren[idxInOld];
      // 打补丁
      patch(vnodeToMove, newStartVNode, container);
      // 移动到最前面,即旧节点的最前面
      insert(vnodeToMove, container, oldStartVNode.el);
      // 因为这个节点我们已经处理过了,真实 DOM 已经移到了正确位置,设置为 undefined
      oldChildren[idxInOld] = undefined;
      // 更新 newStart
      newStartVNode = newChildren[++newStartIdx];
    }
  }
}

接着双端 Diff 继续比较,直到遇见我们之前设置过的 undefined,说明这个节点我们处理过了。我们还需要添加两个处理分支来判断一下这种情况:

  
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (!oldStartIdx) { // 如果头部节点被处理过,跳过
    oldStartVNode = oldChildren[++oldStartIdx];
  } else if (!oldEndIdx) { // 如果尾部节点被处理过,跳过
    oldEndVNode = oldChildren[--oldEndIdx];
  } else if (oldStartVNode.key === newStartVNode.key) {
    // ...
  } else if (oldEndVNode.key === newEndVNode.key) {
    // ...
  } else if (oldStartVNode.key === newEndVNode.key) {
    // ...
  } else if (oldEndVNode.key === newStartVNode.key) {
    // ...
  } else {
    const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key);
    if (idxInOld >= 0) {
      const vnodeToMove = oldChildren[idxInOld];
      patch(vnodeToMove, newStartVNode, container);
      insert(vnodeToMove, container, oldStartVNode.el);
      oldChildren[idxInOld] = undefined;
      newStartVNode = newChildren[++newStartIdx];
    }
  }
}

这样,非理想情况下的问题也解决了。

添加新元素

如果新节点中有在旧节点中找不到的,那么就是新节点,需要挂载到正确的位置去。

新增节点的情况.jpg

在一轮比较后,我们发现没有可复用的节点,所以试图找到 newStart 对应的旧索引,然后我们发现找不到,说明这是一个新增的节点。

  
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (!oldStartIdx) {
    oldStartVNode = oldChildren[++oldStartIdx];
  } else if (!oldEndIdx) {
    oldEndVNode = oldChildren[--oldEndIdx];
  } else if (oldStartVNode.key === newStartVNode.key) {
    // ...
  } else if (oldEndVNode.key === newEndVNode.key) {
    // ...
  } else if (oldStartVNode.key === newEndVNode.key) {
    // ...
  } else if (oldEndVNode.key === newStartVNode.key) {
    // ...
  } else {
    const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key);
    if (idxInOld >= 0) {
      const vnodeToMove = oldChildren[idxInOld];
      patch(vnodeToMove, newStartVNode, container);
      insert(vnodeToMove, container, oldStartVNode.el);
      oldChildren[idxInOld] = undefined;
    } else { // 说明节点是新增的节点
      // 挂载到头部
      patch (null, newStartVNode, container, oldStartVNode.el);
    }
    newStartVNode = newChildren[++newStartIdx];
  }
}

但是这样做其实还有遗漏,如果我们改变一下新节点顺序,让新节点能找到可复用节点:

新增节点的情况-遗漏.jpg

最终比较结果会变成,旧节点已经结束,但是新节点的 p--4 被遗漏下来了。所以我们还需要再进行修补:

  
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  // 进行比较
}
// 在比较结束后,检查一下剩余的节点
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
  // 如果旧节点已经结束,但是新节点还有剩余,说明需要挂载新节点
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    // 将剩余的新增节点按顺序挂载到头部
    patch(null, newChildren[i], container, oldStartVNode.el);
  }
}
移除不存在的元素

解决了新增节点的问题,我们再看移除节点的问题:

移除节点.jpg

当新节点结束时,旧节点还剩下一个 p--2 节点:

移除节点-结束.jpg

所以我们还需要新增一段逻辑来移除旧节点:

  
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  // 进行比较
}

if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
  // 添加新节点
} else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
  // 移除旧节点
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    unmount(oldChildren[i]);
  }
}

双端 Diff 算法,就已经完整了。

快速 Diff 算法

Vue 2 采用的是双端 Diff 算法,而 Vue 3 则换成了快速 Diff 算法。快速 Diff 算法顾名思义,他比双端 Diff 算法更快。

原理

快速 diff 首先依次匹配新旧节点的前置节点和后置节点,这些节点相对位置不变,只需要打补丁。

相同的前置节点和后置节点.jpg

  
function patchKeyedChildren(n1, n2, container) {
  const newChildren = n2.children
  const oldChildren = n1.children
  // 处理相同的前置节点
  let j = 0
  let oldVNode = oldChildren[j]
  let newVNode = newChildren[j]
  // 寻找到所有相同节点
  while(oldVNode.key === newVNode.key) {
    patch(oldVNode, newVNode, container)
    j++
    oldVNode = oldChildren[j]
      newVNode = newChildren[j]
  }
  // 处理相同的后置节点
  let oldEnd = oldChildren.length - 1
  let newEnd = newChildren.length - 1
  oldVNode = oldChildren[oldEnd]
  newVNode = newChildren[newEnd]
  while(oldVNode.key === newVNode.key) {
    patch(oldVNode, newVNode, container)
    oldEnd--
    newEnd--
    oldVNode = oldChildren[oldEnd]
      newVNode = newChildren[newEnd]
  }
}

这样处理完,相同的前置节点和后置节点就已经被更新了,剩下就是处理中间的不同部分。

如果 oldEnd 指针小于 j 指针,说明旧节点都被处理完了。如果 newEnd 指针大于 j 指针,说明新节点还没处理完,这时,剩下的元素就是新增的元素,需要挂载到 newEnd 指针前面。

新增节点的情况-快速diff.jpg

  
function patchKeyedChildren(n1, n2, container) {
  const newChildren = n2.children
  const oldChildren = n1.children
  // 处理相同的前置节点
  // ...
  
  // 处理相同的后置节点
  // ...
  
  // 挂载新节点
  if (oldEnd < j && newEnd >= j) {
    // 挂载索引
    const anchorIndex = newEnd + 1
    // 如果是最后一个节点,说明是尾部,不需要锚点元素
    const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
    // 挂载
    while (j <= newEnd) {
            patch(null, newChildren[j++], container, anchor)
    }
  }
}

同理,如果 oldEnd 没有超过 j,但是 newEnd 超过了 j,说明旧节点中间的部分在新节点中没有,需要删除。

  
function patchKeyedChildren(n1, n2, container) {
  const newChildren = n2.children
  const oldChildren = n1.children
  // 处理相同的前置节点
  // ...
  
  // 处理相同的后置节点
  // ...
  
  // 挂载新节点
  if (oldEnd < j && newEnd >= j) {
    const anchorIndex = newEnd + 1
    const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
    while (j <= newEnd) {
            patch(null, newChildren[j++], container, anchor)
    }
  }
  // 卸载旧节点
  else if (oldEnd >= j && newEnd < j) {
    // 将 j 到 oldEnd 之间的所有元素卸载
        while (j <= oldEnd) {
      unmount(oldChildren[j++])
    }
  }
}

以上处理的都是较为理想和简单的情况,接下来讨论中间不同部分复杂的情况。

复杂情况下两组子节点.jpg

我们依旧处理相同的前置节点和后置节点:

复杂情况下处理完前置后置节点状态.jpg

可以看到,这次我们还需要接着处理。接下来,处理过程类似简单 diff 算法,我们要找到需要移动的节点,以及新增或删除的接待。

首先,我们构造一个 source 数组,这个数组长度等于未处理节点个数,初始值都是 -1

构造 source 数组.jpg

  
function patchKeyedChildren(n1, n2, container) {
  const newChildren = n2.children
  const oldChildren = n1.children
  // 处理相同的前置节点
  // ...
  
  // 处理相同的后置节点
  // ...
  
  if (oldEnd < j && newEnd >= j) {
    // 挂载新节点
  } else if (oldEnd >= j && newEnd < j) {
    // 卸载旧节点
  }
  // 复杂情况
  else {
    // 构造 source 数组
    const count = newEnd - j + 1
    const source = new Array(count)
    source.fill(-1)
  }
}

source 数组用来存储新的一组子节点中的节点在旧的一组中的位置索引,后面将会使用它计算出一个最长递增子序列,来找出相对位置不需要变化的子串

填充 source 数组.jpg

p--3 节点对应的旧索引为 2,所以填充对应的数组值为 2,以此类推。

  
// ...
else {
  const count = newEnd - j + 1
  const source = new Array(count)
  source.fill(-1)
  
  // 分别设置起始指针
  const oldStart = j
  const newStart = j
  // 用旧节点去新节点中找
  for (let i = oldStart; i <= oldEnd; i++) {
    const oldVNode = oldChildren[i]
    for (let k = newStart; k <= newEnd; k++) {
      newVNode = newChildren[k]
      if (newVNode.key === oldVNode.key) {
        patch(oldVNode, newVNode, container)
        // 填充 source 数组
        source[k - newStart] = i
      }
    }
  }
}

但是这个填充操作的时间复杂度是 O(n^2),处于性能考虑,我们希望能降低一些。所以我们为新的一组子节点构建一张 索引表,用来存储 节点 key 值新节点的位置 的映射(使用哈希表来减少查找的过程):

构建索引表.jpg

  
// ...
else {
  const count = newEnd - j + 1
  const source = new Array(count)
  source.fill(-1)
  
  // 分别设置起始指针
  const oldStart = j
  const newStart = j
  
  // 构建索引表
  const keyIndex = {}
  for (let i = newStart; i <= newEnd; i++) {
    keyIndex[newChildren[i]] = i
  }
  
  // 用旧节点去新节点中找
  for (let i = oldStart; i <= oldEnd; i++) {
    const oldVNode = oldChildren[i]
    // 直接通过索引表得到索引,避免循环
    const k = keyIndex[oldVNode.key]
    
    if (typeof k !== 'undefined') {
            newVNode = newChildren[k]
      patch(oldVNode, newVNode, container)
      // 填充 source 数组
      source[k - newStart] = i
    } else {
      // 没找到,卸载节点
      unmount(oldVNode)
    }
  }
}

这样一来,通过索引表,我们将时间复杂度降低到了 O(n) 的层次。接下来,我们需要考虑节点的移动,这和简单 diff 类似。

第四章 组件化

组件的实现原理

上一章讲的是渲染器,渲染器负责将虚拟 DOM 渲染为真实 DOM。当我们编写 Vue 时,实际上就是在编写组件化的虚拟 DOM。这些组件构成了整个 Vue 应用。

渲染组件

从用户的角度看,一个组件是一个对象:

  
const MyComponent = {
    name: 'MyComponent',
  data() {
        return {
      foo: 1,
    }
  },
}

但是从渲染器的角度看,一个组件是一个虚拟 DOM 树。

  
const vnode = {
  // 使用 type 存储组件的选项对象,即用户编写的对象
  type: MyComponent,
  // ...
}

但是这时,我们的 patch 函数还没有办法接受组件类型,我们需要增加一个分支判断:

  
function patch(n1, n2, container, anchor) {
    if (n1 && n1.type !== n2.type) {
    unmount(n1);
    n1 = null;
  }
  const { type } = n2;
  if (typeof type === 'string') {
        // 作为普通元素处理
  } else if (type === Text) {
    // 作为文本节点处理
  } else if (type === Fragment) {
        // 作为片段处理
  } else if (typeof type === 'object') {
    // 新增,作为组件处理
    if (!n1) {
      // 挂载
      mountComponent(n2, container, anchor);
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor);
    }
  }
}

当渲染器有能力处理组件后,我们要设计组件应该被如何编写。组件需要描述一段页面结构,所以组件必须有一个渲染函数,即 render 函数。这个 render 函数还应该返回虚拟 DOM,让渲染器可以将虚拟 DOM 渲染为真实 DOM。

  
const MyComponent = {
  name: 'MyComponent',
  render() {
    // 返回虚拟节点
    return {
      type: 'div',
      children: '文本内容',
    }
  },
}

这样,我们就可以将组件传递给渲染器,让渲染器完成渲染:

  
// 应用这个组件时,用来描述该组件的虚拟节点
const CompVNode = {
  type: MyComponent
}

renderer.render(CompVNode, document.querySelector('#app'))

renderer.render 函数会调用 mountComponent 来真正的完成渲染:

  
function mountComponent(vnode, container, anchor) {
  // 拿到用户的组件配置项
  const componentOptions = vnode.type
  // 拿到其中的渲染函数
  const { render } = componentOptions
  // 调用渲染函数得到虚拟 DOM 树
  const subTree = render()
  // 渲染
  patch(null, subTree, container, anchor)
}

这样,我们就得到了基本的组件化方案。

组件状态与自更新

实际上在 render 中,引用 data 中的数据是很常见的,我们必须让组件能够具备读取自身状态,而且能自动更新。

  
const MyComponent = {
  data() {
    return {
      foo: 'hello world'
    }
  },
  render() {
    return {
      type: 'div',
      children: `foo 的值为: ${this.foo}`, // 在渲染函数中使用组件状态
    }
  },
}

这里的渲染函数,试图获取 data 中的数据,我们需要能让他通过 this 访问到:

  
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  const { render, data } = componentOptions
  // 使用 reactive 函数初始化 data,将它变成响应式数据
  const state = reactive(data())
  // 通过 state 调用 render
  const subTree = render.call(state, state)
  patch(null, subTree, container, anchor)
}

我们再来分析组件自更新。我们需要在 state 变化时,重新调用 render 函数和 patch 函数。我们可以将这两个 副作用函数 用 effect 包裹执行。

  
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  const { render, data } = componentOptions
  const state = reactive(data())
  // 通过副作用函数执行,将这两个函数添加到依赖中去
  effect(() => {
    const subTree = render.call(state, state)
      patch(null, subTree, container, anchor)
  })
}

为了避免多次修改状态,导致组件不断更新,我们可以将组件更新放进异步队列里,这样可以对多次更新任务去重,避免反复更新组件。

  
// 任务缓存队列,用一个 set 来表示,用来去重
const queue = new Set()
// 标志正在刷新任务队列
let isFlushing = false
const p = Promise.resolve()

// 调度器,让每一次事件循环都只有一次更新
function queueJob(job) {
    queue.add(job)
  // 如果还没有开始刷新队列,刷新它。如果已经开始刷新,则不需要再添加微任务
  if (!isFlushing) {
    isFlushing = true
    p.then(() => {
      try {
        queue.forEach(job => job())
      } finally {
        // 执行完全部的任务后,可以接受新的任务了
        isFlushing = false
        queue.length = 0
      }
    })
  }
}

然后我们可以在 effect 函数中使用调度器

  
function mountComponent(vnode, container, anchor) {
  const componentOptions = vnode.type
  const { render, data } = componentOptions
  
  const state = reactive(data())
  
  effect(() => {
    const subTree = render.call(state, state)
      patch(null, subTree, container, anchor)
  }, {
    // 指定调度器
    scheduler: queueJob
  })
}

但是,上面这段代码还有缺陷,就是每次 patch 时,传入的都是 null ,每次都是进行挂载,而不是打补丁。为了实现打补丁,我们需要实现组件实例,用它来维护组件的整个生命周期的状态。

组件实例与生命周期

组件实例本质上就是一个对象,维护着组价运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态(data),等等。

  
function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
  const { render, data } = componentOptions
  
  const state = reactive(data())
  
  // 定义组件实例,一个组件实例就是一个对象,包含组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 组件是否被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null,
  }
  
  // 将组件实例放在 vnode 上,用于以后更新
  vnode.component = instance
  
  effect(() => {
    // 调用组件的渲染函数,获得子树
    const sbTree = render.call(state, state)
    // 检查组件是否已经被挂载
    if (!instance.isMounted) {
      // 初次挂载
      patch(null, subTree, container, anchor)
      // 将组件设置为已挂载,这样以后就会进行更新,而不是挂载
      instance.isMounted = true
    } else {
      // 更新组件
      patch(instance.subTree, subTree, container. anchor)
    }
    // 更新组件实例的子树
    instance.subTree = subTree
  }, { scheduler: queueJob })
}

这样,我们就可以在合适的时机调用组件的声明周期钩子:

  
function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
  // 拿到对应的钩子
  const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions
  
  beforeCreate && beforeCreate()
  const state = reactive(data())
  
  const instance = {
    state,
    isMounted: false,
    subTree: null,
  }
  
  vnode.component = instance
  
  // 调用时允许 this 访问组件状态
  created && created.call(state)
  
  effect(() => {
    const sbTree = render.call(state, state)
    if (!instance.isMounted) {
      beforeMount && beforeMount.call(state)
      patch(null, subTree, container, anchor)
      instance.isMounted = true
      mounted && mounted()
    } else {
      beforeUpdate && beforeUpdate.call(state)
      patch(instance.subTree, subTree, container. anchor)
      updated && updated.call(state)
    }
    instance.subTree = subTree
  }, { scheduler: queueJob })
}

大概的原理就是这样。

props 与组件的被动更新

在 Vue 3 中,被子组件指定接受的 prop,会被传递到 props 中,而没指定,但是父组件传递进来的,会被传递到 attrs 中。我们在构建组件时,还需要考虑这两部分的数据。

  
function mountComponent(vnode, container, anchor) {
    const componentOptions = vnode.type
  // 取出用户配置的 props
  const { render, data, props: propsOption, /* 省略其他 */ } = componentOptions
  
  beforeCreate && beforeCreate()
  const state = reactive(data())
  // 解析 props 和 attrs
  const [props, attrs] = resolveProps(propsOption, vnode.props)
  
  const instance = {
    state,
    isMounted: false,
    subTree: null,
  }
  
  vnode.component = instance
  // ...
}

// 解析组件的 props
function resolveProps(options, propsData) {
  const props = []
  const attrs = []
  for (const key in propsData) {
    // 遍历传入的组件属性,如果被组件定义,放入 props
    if (key in options) {
      props[key] = propsData[key]
    } else {
      // 没被组件定义,放到 attrs
      attrs[key] = propsData[key]
    }
  }
  return [props, attrs]
}

具体实现上还有一些细节,比如默认值,类型校验...

处理完 props,还要考虑当 props 改变时,父组件会先重新渲染,随后触发 patchComponent 来更新子组件,我们将这种更新叫做子组件的被动更新。当子组件被动更新时,我们需要:

  • 检测子组件是不是真的要更新,即 props 是不是真的改变了
  • 如果要更新,则更新子组件的 propsslot 等内容

patchComponent 实现如下:

  
function patchComponent(n1, n2, anchor) {
  // 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例
  const instance = (n2.component = n1.component)
  // 获取当前的 props 数据
  const { props } = instance
  if (hasPropsChanged(n1.props, n2.props)) {
    // 如果 props 改变了,拿到新的 props
    const [nextProps] = resolveProps(n2.type.props, n2.props)
    // 更新 props
    for (const k in nextProps) {
      props[k] = nextProps[k]
    }
    // 删除不存在的 props
    for (const k in props) {
      if (!(k in nextProps)) delete props[k]
    }
  }
}

function hasPropsChanged(prevProps, nextProps) {
    const nextKeys = Object.keys(nextProps)
  // 如果数量变了,说明有变化
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    // 有不相等的 props,说明有变化
    if (nextProps[key] !== prevProps[key]) return true
  }
  return false
}

这里的 props 对象是浅响应(shallowReactive)的,因此只需要设置 instance.props 下的属性即可触发子组件重新渲染。

setup 的作用与实现

setup 是 Vue 3 新增的组件选项,用来配合组合式 API。他可以有两种返回值:

  • 返回一个函数,该函数将作为该组件的 render 函数:

      
    const Comp = {
      setup() {
            return () => {
          return { type: 'div', children: 'hello' }
        }
      }
    }

    一般用来描述不好用模板实现的内容,比如多层嵌套的重复内容

  • 返回一个对象,这个对象中的数据将暴露给模板使用:

      
    const Comp = {
      setup() {
        const count = ref(0)
        return {
          count,
        }
      },
      render() {
        return { type: 'div', children: `count is ${this.count}` }
      }
    }

另外,setup 接受两个参数:props、context。

  
const Comp = {
  props: {
    foo: String,
  },
  setup(props, context) {
        props.foo // 访问 props 数据
    // context 保存了与组件接口相关的数据和方法
    const { slot, emit, attrs, expose } = context
  }
}

context 对象包括:

  • slots:组件接受的插槽。
  • emit:用来发射事件。
  • attrs:没被定义,但是被传入的属性。
  • expose:一个函数,用来显示的暴露组件数据。

在 Vue 3 中,更支持使用 组合式 API。

组件事件与 emit 的实现

在 Vue 中,我们可以使用 emit 函数来发射组件自定义的事件。

  
const MyComponent = {
  name: 'MyComponent',
  setup(props, { emit }) {
    // 发出自定义 change 事件,并传递两个参数
        emit('change', 1, 2)
    
    return () => {
            return // ...
    }
  },
}

// 使用
<MyComponent @change="handler"></MyComponent>
// 上面的模板被编译生成的 vnode 为:
const CompVNode = {
    type: MyComponent,
  props: {
        onChange: handler,
  }
}

Vue 3 将事件编译为 以 on 开头的属性,存储在 props 中。具体实现则是根据事件名称去寻找对应的事件处理函数并执行。

  
function mountComponent(vnode, container, anchor) {
  //...
  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
  }
  // 定义 emit 函数
  function emit(event, ...payload) {
    // 将事件转换为 on 开头 change -> onChange
        const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
    // 去寻找事件处理函数
    const handler = instance.props[eventName]
    if (handler) {
      handler(...payload)
    } else {
            console.log('事件不存在')
    }
  }
  // 将 emit 函数添加到 context 中,提供给 setup
  const setupContext = { attrs, emit }
  // ...
}

但是现在由于事件没有在 props 中声明过,会被 resolveProps 放入 attrs 中,导致我们找不到事件属性,所以我们还需要接着优化一下:

  
function resolveProps(options, propsData) {
  const props = {}
  const attrs = {}
  for (const key of propsData) {
    // 以字符串 on 开头的 props,无论是否显示地声明,都将它添加到 props
    if (key in options || key.startsWith('on')) {
      props[key] = propsData[key]
    } else {
      attrs[key] = propsData[key]
    }
  }
  return [props, attrs]
}

插槽的工作原理与实现

插槽 是组件预留的一个槽位,具体渲染的内容由调用者传入。

  
<template>
    <header><slot name="header" /></header>
    <div><slot name="body" /></div>
    <footer><slot name="footer" /></footer>
</template>

父组件可以传入对应的内容:

  
<template>
    <MyComponent>
      <template #header>
            <h1>我是标题</h1>
        </template>
        <template #body>
            <section>我是内容</section>
        </template>
        <template #footer>
            <p>我是注脚</p>
        </template>
  </MyComponent>
</template>

这段父组件模板会被编译成如下的渲染函数:

  
function render() {
  return {
    type: MyComponent,
    // 组件的 children 会被编译成一个对象
    children: {
            header() {
                return { type: 'h1', children: '我是标题' }
      },
      body() {
        return { type: 'section', children: '我是内容' }
      },
      footer() {
                return { type: 'p', children: '我是注脚' }
      }
    }
  }
}

而有插槽的子组件则会被编译为:

  
// MyComponent 组件的编译结果
function render() {
  // 没有根元素,返回的是一个数组
  return [
    {
      type: 'header',
      children: [this.$slot.header()]
    },
    {
      type: 'body',
      children: [this.$slot.body()]
    },
    {
      type: 'footer',
      children: [this.$slot.footer()]
    }
  ]
}

我们可以发现,插槽的工作原理和 render 函数十分类似,就是调用插槽函数获得渲染内容。在运行时的实现上,插槽则依赖于 setupContext 中的 slots 对象,如下代码所示:

  
function mountComponent(vnode, container, anchor) {
  // 省略部分代码
  
  // 直接使用编译好的 vnode.children 对象作为 slots 对象即可
  const slots = vnode.children || {}
  
  const setupContext = { attrs, emit, slots }
}

最简单的 slot 实现非常简单,就是将编译器编译好的 children 属性作为 slot 的值。

注册生命周期

在 Vue 中,我们还可以自己注册生命周期钩子。

  
import { onMounted } from 'vue'

const MyComponent = {
  setup() {
    onMounted(() => {
      console.log('mounted 1')
    })
    // 可以注册多个
    onMounted(() => {
      console.log('mounted 2')
    })
    // ...
  }
}

为了能让 onMounted 函数能够注册到对应组件上,我们需要维护一个全局变量 currentInstance,来保存当前的组件实例。每当初始化组件时,我们先将它设置为当前组价实例,再执行 setup 函数,这样我们就可以通过 currentInstance 来获取当前正在初始化的组件实例。

  
let currentInstance = null

function setCurrentInstance(instance) {
    currentInstance = instance
}

function mountComponent(vnode, container, anchor) {
  // ...
  
  const instance = {
    state,
    props: shallowReactive(props),
    isMounted: false,
    subTree: null,
    slots,
    // 在组件实例中添加 mounted 数组,用来储存 onMounted 函数注册的生命周期钩子函数
    mounted: []
  }
  
  // ...
  
  const setupContext = { attrs, emit, slots }
  
  // 执行 setup 之前,设置当前组件实例
  setCurrentInstance(instance)
  // 执行 setup
  const setupResult = setup(shallowReadonly(instance.props), setupContext)
  // 重置当前组件实例
  setCurrentInstance(null)
}

以 mounted 钩子为例,我们只需要在注册时,将函数推入数组中,就可以实现多次注册了。

  
function onMounted(fn) {
  if (currentInstance) {
    currentInstance.mounted.push(fn)
  } else {
    console.log('onMounted 函数只能在 setup 中调用')
  }
}

最后,我们需要在合适的时机来调用这些钩子:

  
function mountComponent(vnode, container, anchor) {
  // ...
  effect(() => {
    const subTree = render.call(render(renderContext, renderContext))
    if (!instance.isMounted) {
      // ...
      
      // 遍历执行钩子函数
      instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
    } else {
      // ...
    }
    instance.subTree = subTree
  }, {
    sheduler: queueJob
  })
}

其他钩子函数原理是一样的。

异步组件与函数式组件

接下来,我们将讨论异步组件与函数式组件。

异步组件的特点是:使用异步的方式加载并渲染一个组件,这在代码分割(异步组件会被单独打包),服务端下发组件等场景下尤为重要。

函数式组件的特点是:无状态,编写简单且直观。在 Vue 2 中,函数式组件没有状态,有明显的性能优势。但是在 Vue 3 中,函数式组件与有状态组件的性能差距不大,都非常好。在 Vue 3 中使用函数式组件,主要的原因是它简单,而不是它性能好。

异步组件要解决的问题

异步组件的实现用户可以自行实现,不需要框架的支持:

  
const loader = () => import ('App.vue')
loader.then(App => {
  createApp(App).mount('#app')
})

异步组件使用 import 函数,来在运行时加载组件。函数返回一个 Promise 实例。组件加载成功后,调用 createApp 函数完成挂载。但是这样我们实现的是全部页面的挂载。如果我们希望实现部分页面的挂载:

  
<template>
    <CompA />
    <component :is="asyncComp" ></component>
</template>

<script>
import { shallowRef } from 'vue'
import CompA from 'CompA.vue'
  
export default {
  components: {
    CompA,
  },
  setup() {
    const asyncComp = shallowRef(null)
    // 异步加载组件
    import('CompB.vue').then(CompB => asyncComp.value = CompB)
    
    return {
      asyncCompB,
    }
  }
}
</script>

这样虽然实现了异步组件的加载和渲染,但是真实场景下并没有这么简单,我们还需要考虑:

  • 如果组件加载超时,我们是否需要渲染 Error 组件?
  • 组件在加载时,是否要展示占位的内容,比如 Loading 组件?
  • 组件加载的速度不一定,可能会很快,这可能会导致 Loading 组件闪烁。是否要延迟 200ms 展示 Loading 组件?
  • 组件加载失败后,是否要重试?

这就需要我们的框架能够:

  • 允许用户指定加载出错时要渲染的组件。
  • 允许用户指定 Loading 组件,以及展示该组件的延迟时间。
  • 允许指定加载组件失败的时长。
  • 组件加载失败时,可以重试。

这些问题,就是 Vue 3 异步组件解决的问题。

异步组件的实现原理

defineAsyncComponent

为了提供给用户注册高阶组件的能力,需要封装一个函数来加载异步组件:

  
<template>
    <AsyncComp></AsyncComp>
</template>

<script>
export default {
  components: {
    // 使用这个函数来加载异步组件,让用户可以像同步组件一样使用异步组件
    AsyncComp: defineAsyncComponent(() => import('CompA')),
  }
}
</script>

defineAsyncComponent 就是一个高阶组件:

  
function defineAsyncComponent(loader) {
    // 存储加载好的异步组件
  let InnerComp = null;
  // 返回一个包装好的组件
  return {
    name: 'AsyncComponentWrapper',
    setup() {
      // 异步组件是否加载成功
      const loaded = ref(false)
      loader().then(c => {
        InnerComp = c;
        loaded.value = true;
      })
      // 返回一个 render 函数,即这个高阶组件
      return () => {
        // 如果异步组件加载成功,渲染该组件,否则渲染一个占位组件
        return loaded.value ? { type: InnerComp } : { type: Text, children: '' }
      }
    }
  }
}
超时与 Error 组件

异步组件通过网络来获取组件资源,那么我们势必要考虑组件加载超时的情况,如果组件超时,会触发超时错误,这时如果用户配置了 Error 组件,就要去渲染这个组件:

  
// 用户接口
const AsyncComp = defineAsyncComponent({
  loader: () => import('CompA.vue'),
  timeout: 2000, // 超市时长
  errorComponent: ErrorComp, // 错误组件
})

// 实现
function defineAsyncComponent(options) {
    // 这次传入的可以是一个配置项
  if (typeof options === 'function') {
    // 如果用户传入的是loader,转为options格式
    options = {
      loader: options
    }
  }
  
  const { loader } = options;
  let InnerComp = null;
  
  return {
    name: 'AsyncComponentWrapper',
    setup() {
      const loaded = ref(false)
      // 是否加载超时
      const timeout = ref(false)
      
      loader().then(c => {
        InnerComp = c;
        loaded.value = true;
      })
      
      let timer = null;
      if (options.timeout) {
        // 如果制定了超时时长
        timer = setTimeout(() => {
          // 超时后,将超时标志设置为true
          timeout.value = true
        }, options.timeout)
      }
      
      // 组件如果被销毁了,取消定时器
      onMounted(() => clearTimeout(timer));
      
      const placeholder = { type: Text, children: '' }
      
      return () => {
        if (loaded.value) {
          // 如果异步组件加载成功,渲染该组件
          return { type: InnerComp }
        } else if (timeout.value) {
          // 超时,如果指定了错误组件,渲染该组件,否则渲染占位组件
          return options.errorComponent ? options.errorComponent : placeholder;
        }
        // 没加载成功,也没超时,渲染占位组件
        return placeholder;
      }
    }
  }
}

除此之外,我们还可以将加载失败的错误传递给用户自定义的 Error 组件,来让该组件可以进行精细化处理。

延迟与 Loading 组件

之前我们讨论过,我们希望提供加载时 Loading 组件和 Loading 组件延迟展示。

  
// 用户接口设计
defineAsyncComponent({
  loader: () => new Promise(r => { /* ... */ }),
  delay: 200,
  loadingComponent: {
    setup() {
      return () => {
        return { type: 'h2', childern: 'Loading...' }
      }
    }
  }
})
  
function defineAsyncComponent(options) {
  if (typeof options === 'function') {
    options = {
      loader: options
    }
  }
  const { loader } = options;
  let InnerComp = null;
  return {
    name: 'AsyncComponentWrapper',
    setup() {
      const loaded = ref(false)
      const error = shallowRef(null)
      // 正在加载标志
      const loading = ref(false)
      
      let loadingTimer = null
      if (options.delay) {
        // 延迟后将标志设置为 true
        loadingTimer = setTimeout(() => {
          loading.value = true
        }, options.delay)
      } else {
        // 没有设置延迟,直接设置为true
        loading.value = true
      }
      loader()
          .then(c => {
          InnerComp = c
          loaded.value = true
          })
          .catch(err => {
                    error.value = err
        })
          .finally(() => {
                    loading.value = false
            clearTimeout(loadingTimer)
        })
      let timer = null
      if (options.timeout) {
        timer = setTimeout(() => {
          const err = new Error(`Async component timed out after ${options.timeout}ms.`)
        }. options.timeout)
      }
      const placeholder = { type: Text, children: '' }
      
      return () => {
        if (loaded.value) {
          return { type: InnerComp }
        } else if (error.value && options.errorComponent) {
          // 如果加载失败,且用户指定了错误组件
          return { type: options.errorComponent }
        } else if (loading.value && options.loadingComponent) {
          // 如果正在加载,且指定了加载组件
        } else {
          return placeholder
        }
      }
    }
  }
}

函数式组件

在用户接口层面,函数式组件就是一个返回虚拟 DOM 的函数:

  
function MyFuncComp(props) {
  return { type: 'h1', children: props.title }
}

函数式组件没有自身状态,但是他仍可以接受父组件传过来的 props。如果想指定函数式组件接受的 props,则需要给函数添加静态属性:

  
function MyFuncComp(props) {
  return { type: 'h1', children: props.title }
}

// 使用静态属性指定 props
MyFuncComp.props = {
  title: String
}

具体的实现就是让 patch 函数支持 type 为函数的情况,对函数式组件进行挂载和 diff。