更新!更新!实现vue3虚拟DOM更新&diff算法优化🎉 🎉

5,055 阅读21分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

hey🖐! 我是小黄瓜😊😊。不定期更新,期待关注➕ 点赞,共同成长~

写在前面

本文的目标是实现一个基本的 vue3 的虚拟DOM的节点渲染与更新,包含最基础的情况的处理,本文是系列文章,本系列已全面使用vue3组合式语法,如果你对 vue3基础语法及响应式相关逻辑还不了解,那么请移步:

超详细整理vue3基础知识💥

狂肝半个月!1.3万字深度剖析vue3响应式(附脑图)🎉🎉

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

大结局!实现vue3模版编译功能🎉 🎉

本文只是整个vue3渲染器的下篇内容,包含组件及节点的更新,更新优化,diff算法的内容。

食用提醒!必看

看本篇之前必须要看上一篇首次渲染!!!

由于整个渲染过程中的函数实现以及流程过长,有很多函数的实现内容在相关的章节并不会全部展示,并且存在大量的伪代码,相关章节只会关注当前功能代码的显示和实现。

但是!为了便于理解,我在github上上传了每章节的具体实现。(请把贴心打在评论区 😂😂)把每一章节的实现都存放在了单独的文件夹:

截屏2022-09-10 08.18.15.png

只使用了单纯的htmljs来实现功能,只需要在index.html中替换相关章节的文件路径(替换以下三个文件),在浏览器中打开,就可以自行调试和查看代码。见下图:

image-20220909153126330.png

地址在这里,欢迎star!

👉 vue3-analysis(component)

mini-vue3的正式项目地址在这里!目前只实现了响应式和渲染器部分!

👉 k-vue

欢迎star!

本文你将学到

  • 更新element
  • 更新component
  • diff算法
  • 最长递增子序列应用
  • 实现nextTick

一. 更新element流程搭建

createApp-3137242.png

在实现更新之前,有必要先来想一下,到底怎样算是更新?怎样算是触发更新的操作?在上一篇文章中,我们只实现了初次渲染,也就是打开页面默认渲染出来的内容,不依靠动作去触发的部分。在这一部分,我们首先执行了setup函数,把它当作数据源来初始化render函数进行渲染,根据vnode的属性值来依次生成DOM节点,DOM属性,递归渲染子节点。最后将整个vnode渲染到页面上。也就是说目前实现的渲染只是纯静态的。

所谓更新就是在用户进行手动触发时,当数据发生改变时,DOM节点发生时对页面上的DOM元素进行修改或者增加或者删除的操作。使用原生的DOM的API来操作DOM时非常的简单,无论你怎么发生变化,我直接拼接DOM字符串,使用innerHTML怼上去就完事了。但是在vue这种成熟的框架处理这种问题时,需要考虑的问题比单纯的实现要多的多。比如最直观的问题,如果我们的DOM层级比较深,整个页面结构非常复杂,直接使用拼接渲染的方式并不高明,这意味着无论我做了多么小的改动,都会将页面重新渲染一遍,这样带来的性能损耗将是非常惊人的。那么怎样进行优化就将会是整个更新DOM过程中非常重要的事情。本文的后半部分的一大核心问题也就是处理更新的优化部分。

说了这么多,还有一个最核心的问题没有解决,怎么能知道数据更新了呢?关于更新时优化这都是后话了,首先我们得知道数据啥时候更新了才行,不然啥都白扯。更新的核心其实就是当数据发生改变时,重新执行render函数进行对比差异,然后渲染到页面上。等等,这句话怎么这么耳熟?这不就是响应式的实现逻辑吗!(对响应式不太熟悉的老铁们请先学习一下响应式部分)特别是我们的数据都是以ref或者reactive函数进行包裹的,那么只需要将重新执行render函数部分逻辑放在effect函数内就可以了。这样就可以达成数据与更新DOM的逻辑想绑定,数据更新,视图更新的功能了。

在此之前,还是先来实现一个例子:

 const App = {
   setup() {
     let value = reactive({
       count: 0
     })
     // 点击按钮count+1
     const changeCount = ()=>{
       value.count = value.count + 1
     }
 
     return {
       value,
       changeCount
     }
   },
   render() {
     return h('div', { id: 'root' }, [
       h('p', {}, 'count:' + this.value.count),
       h('button', { onClick: this.changeCount, }, 'change')
     ])
   }
 }

上面的例子中我们实现了一个按钮,点击按钮响应式数据value.count将会加一,相应的,页面中也会触发DOM更新的相关逻辑。

我们在初始化component实例的时候初始化属性isMounted,用于标识是否处于更新状态

 const createComponentInstance = function (vnode, parent) { // 修改
   const component = {
     vnode,
     type: vnode.type,
     props: {},
     setupState: {},
     provides: parent ? parent.provides : {},
     parent: parent ? parent : {},
     // 是否首次渲染?
     isMounted: false, // 新增
     subTree: {},
     slots: {},
     emit: () => {}
   }
 
   component.emit = emit.bind(null, component)
 
   return component
 }

接下来找到执行render函数的地方,绑定响应式数据的执行函数effect:

 const setupRenderEffect = function (instance, vnode, container) {
 
   effect(()=>{
     // 根据isMounted来判断目前处于更新/初始化?
     if(!instance.isMounted) {
       console.log("init");
       const { proxy } = instance;
       const subTree = (instance.subTree = instance.render.call(proxy));
 
       patch(null, subTree, container, instance);
 
       vnode.el = subTree.el;
 
       instance.isMounted = true;
     } else {
       console.log("update");
       
       const { proxy } = instance
       // render函数执行结果
       const subTree = instance.render.call(proxy)
       const prevSubTree = instance.subTree
       // 更新subTree
       instance.subTree = subTree;
       // 传入patch
       patch(prevSubTree, subTree, container, instance)
 
     }
   })
 }

在执行setupRenderEffect函数时将会执行那几件事情呢,首先使用component中的subTree来保存上次的执行结果,用于调用patch函数时,当作旧的vnode参与对比操作,最后需要将新的vnode来更新属性subTree

由于我们的patch函数参数又发生了变更,加入了上一次更新的vnode,所以需要再次对所有调用patch函数的地方进行参数修改:

render:

 const render = function (vnode, container) {
   // 首次渲染,第一个参数传递null
   patch(null, vnode, container, null) // 修改
 }

patch:

 // n2 代表当前处理的vnode
 const patch = function (n1, n2, container, parentComponent) { // 修改
 
   const { type, shapeFlag } = n2 // 修改
 
   switch(type) {
     case Fragment:
       processFragment(n1, n2, container, parentComponent); // 修改
       break
     case Text:
       processText(n1, n2, container); // 修改
       break
     default:
       if (shapeFlag & ShapeFlags.ELEMENT) {
         processElement(n1, n2, container, parentComponent) // 修改
       } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
         processComponent(n1, n2, container, parentComponent) // 修改
       }
       break
   }
 }

processFragment:

 const processFragment = function(n1, n2, container, parentComponent) { // 修改
   mountChildren(n2.children, container, parentComponent) // 修改
 }

processText:

 const processText = function(n1, n2, container) { // 修改
   const { children } = n2  // 修改
   const textVNode = (n2.el = document.createTextNode(children)) // 修改
   container.append(textVNode) 
 }

mountChildren:

 const mountChildren = function (children, container, parentComponent) {
   children.forEach(v => {
     // 首次渲染无需传递第一个参数
     patch(null, v, container, parentComponent) // 修改
   })
 }

由于在处理element的时候初始化的渲染和更新是不同的,所以在这个函数中需要进行分别处理,如果没有传递n1,则说明是首次渲染,反之则是更新。

const processElement = function (n1, n2, container, parentComponent) { // 修改
  if(!n1) { // 修改
    mountElement(n2, container, parentComponent)  // 修改
  } else { // 修改
    patchElement(n1, n2, container) // 修改
  }
}

patchElement中打印一下更新时的vnode:

const patchElement = function(n1, n2, container) {
  console.log(n1);
  console.log(n2);
}

image-20220914142908714.png

可以看到根据上面的例子,点击按钮后,输出了两个vnode,里面的children是不同的,这正是需要更新的内容。

二. 更新element 的props

createApp-3146993.png 更新props,也就是更新DOM属性,其实主要就是来过那种情况,旧的vnode与新的vnode不一致,直接设置新的props,如果新的props中没有旧的props,直接进行删除操作。

接下来还是国际惯例,首先实现一个案例,用需求来驱动功能实现:

 const App = {
   setup() {
     let props = reactive({
       foo: 'foo',
       bar: 'bar'
     })
 
     const changeProps1 = () => {
       props.foo = 'new-foo'
     }
 
     const changeProps2 = () => {
       props.foo = undefined
     }
 
     return {
       props,
       changeProps1,
       changeProps2,
     }
   },
   render() {
     return h('div', { id: 'root', ...this.props }, [
       h("button", { onClick: this.changeProps1 }, "修改"),
       h("button", { onClick: this.changeProps2 }, "删除"),
       h("button", { onClick: this.changeProps3 }, "增加"),
     ])
   }
 }

我们在div中设置了属性foo和属性bar,并且定义了两个按钮,分别对属性进行修改和删除操作。

对于props的处理,我们是在mountElement函数中进行的,因为处理属性的过程还需要在别的地方进行使用,所以需要将原本存在于mountElement函数中的逻辑抽离出来,用于处理更新props对象。

 const mountElement = function (vnode, container, parentComponent) {
   // 省略...
 
   // props
   for (const key in props) {
     const prop = props[key]
     // 首次渲染只需要传递更新后的属性值
     mountProps(el, key, null, prop) // 修改
   }
 
   container.append(el)
 }
 
 // 修改
 // mountProps第三个参数为更新前的props的key值,第四个参数nextVal为更新后
 const mountProps = function(el, key, prevVal, nextVal) {
   const isOn = key => /^on[A-Z]/.test(key)
   // 使用on进行绑定事件
   if(isOn(key)) {
     const event = key.slice(2).toLowerCase()
     el.addEventListener(event, nextVal)
   } else {
     // 增加判断,如果新的props为null或者undefined。那么根据key来删除DOM属性
     if(nextVal === undefined || nextVal === null) {
       el.removeAttribute(key)
     } else {
       el.setAttribute(key, nextVal)
     }
   }
 }

抽离出来mountProps函数,主要负责处理props对象生成DOM属性,增加判断当前属性是否存在,如果不存在直接根据key来删除DOM属性。

接下来在patchElement处理更新:

 const EMPTY_OBJ = {}
 const patchElement = function(n1, n2, container) {
   // 取出新旧vnode中的props
   const oldProps = n1.props || EMPTY_OBJ
   const newProps = n2.props || EMPTY_OBJ
   // 更新el
   const el = (n2.el = n1.el)
   // 调用patchProps找出差异
   patchProps(el, oldProps, newProps)
 }

关于为啥要创建一个空对象,然后作为新旧props的默认值,其实目的是让新旧的props在使用默认值对象的时候能够使用同一个引用,便于在下文中使用==来判断新旧对象的值。

 const patchProps = function(el, oldProps, newProps) {
   // 两个对象不相等时,才会进行对比
   if(oldProps !== newProps) {
     // 循环新的props
     for(let key in newProps) {
       // 根据新的props中的key分别在新旧props中取值
       const prevProp = oldProps[key]
       const nextProp = newProps[key]
       // 不想等则进行更新
       if(prevProp !== nextProp) {
         mountProps(el, key, prevProp, nextProp)
       }
     }
     // 判断是否存在需要删除的属性
     if(oldProps !== EMPTY_OBJ) {
       for(let key in oldProps) {
         if(!(key in newProps)) {
           mountProps(el, key, oldProps[key], null)
         }
       }
     }
   }
 }

在进行对比的时候,首先遍历新的props对象,取所有的key值在新旧的props中进行查找,如果不相等,直接调用mountProps函数进行更新。接下来处理旧的props中存在而心的props节点不存在的情况,遍历旧props判断是否存在于新props中,不存在直接调用mountProps函数进行删除。

首次渲染:

image-20220914172021682.png

点击修改,修改foo属性后:

image-20220914172102918.png

foo属性的值更新为new-foo

点击删除,将foo属性删除后:

image-20220914172147995.png

页面中foo属性已经被删除。

三. 更新 children

createApp-3152915.png

在处理完props的更新后,接下来开始着手处理children的更新,由于我们生成vnode时只支持字符串和数组的形式来创建子节点,字符串代表文本节点,数组代表子节点,所以在处理更新时也就只存在四种更新情况:

  • Array -> String
  • String -> String
  • String -> Array
  • Array -> Array

由于Array -> Array较为复杂,我们先来处理与文本节点相关的更新操作。

依旧是先来写一个🌰:

 // ex1 Array -> String
 const prevChild = [h("div", {}, "A"), h("div", {}, "B")];
 const nextChild = "newChildren";
 
 const App = {
   setup() {
     // 定义响应式数据
     const isChange = reactive({
       value: false
     })
     // 挂载到全局对象window上
     window.isChange = isChange;
 
     return {
       isChange
     }
   },
 
   render() {
     let self = this
     // isChange.value的值发生改变,更新children
     return self.isChange.value === true ? 
           h('div', {}, nextChild) :
           h('div', {}, prevChild)
   }
 }

创建响应式数据isChange.value,当该数据发生变化时,更改children,把isChange挂载到window上的原因是,可以在控制台通过打印直接使用window.isChange.value = true来更该数据,触发更新。

处理children更新的逻辑还是在patchElement函数中触发:

 const patchElement = function(n1, n2, container, parentComponent) {
 
   const oldProps = n1.props || EMPTY_OBJ
   const newProps = n2.props || EMPTY_OBJ
 
   const el = (n2.el = n1.el)
   // children update
   patchChildren(n1, n2, el, parentComponent) // 修改
   // props update
   patchProps(el, oldProps, newProps)
 }

那么当存在子节点时,更新为文本节点怎么来处理呢?答案是将之前的子节点全部删除,然后重新设置文本节点。

 const patchChildren = function(n1, n2, container, parentComponent) {
   // 取出新旧节点的shapeFlag
   const prevShapFlag = n1.shapeFlag
   const c1 = n1.children
   const c2 = n2.children
   const { shapeFlag } = n2
   // 当新的vnode的children为string类型时
   if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
     // 之前children为array
     if(prevShapFlag & ShapeFlags.ARRAY_CHILDREN) {
       unmountChildren(n1.children)
     }
     // 判断新旧children是否一致?
     if(c1 !== c2) {
       setElementText(container, c2)
     }
   }
 }

首先取出新旧vnodeshapeFlag用于子节点的类型判断,如果更新类型为Array->String,那么会删除原本的节点,然后设置新的文本节点。

删除子节点的操作长这样:

const unmountChildren = function(children) {
  // 循环children
  for(let i = 0; i < children.length; i++) {
    // 找到el
    let el = children[i].el
		// 根绝DOM节点找到其父节点
    let parent = el.parentNode
    // 删除
    if(parent) parent.removeChild(el)
  }
}

循环查找每个子节点的el属性,也就是节点的真实DOM节点,根据parentNode获取父节点,然后进行删除。

最后设置文本节点:

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

其实上面的代码也已经将String->String实现了,因为进入旧的childrenString这个逻辑判断后,如果新的children也为String并且不相等,那么就会触发setElementText函数,更新文本节点。

将上面例子中的新旧vnode替换成:

// ex3 Text -> Text
const prevChild = "oldChildren"
const nextChild = "newChildren"

页面也会成功更新。

最后Text -> Array的逻辑也很简单,使用setElementText函数清空文本节点,然后使用mountChildren来重新渲染children

const patchChildren = function(n1, n2, container, parentComponent) {
  
  const prevShapFlag = n1.shapeFlag
  const c1 = n1.children
  const c2 = n2.children
  const { shapeFlag } = n2

  if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    if(prevShapFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(n1.children)
    }

    if(c1 !== c2) {
      setElementText(container, c2)
    }
  } else {
    // 旧children为string
    if(prevShapFlag & ShapeFlags.TEXT_CHILDREN) {
      setElementText(container, "")
      mountChildren(c2, container, parentComponent)
    }
  }
}

重新设置新旧vnode

// ex2 Text -> Array
const prevChild = "oldChildren"
const nextChild = [h("div", {}, "A"), h("div", {}, "B")];

页面也已经成功进行更新。

四. 更新children - 两端对比diff算法

上文中我们已经处理了和childrenstring类型相关的更新操作。现在只剩下了重头戏,也就是节点更新时需要优化的重点。就是Array -> Array这种情况,因为在vnode的更新操作中,节点的层级通常是非常多的,不可能直接将所有的旧节点全部删除,然后重新将新的vnode渲染出来。所以diff算法就出现了,旨在最小化的变更DOM。

同样我们也会将节点的对比划分为几种情况。首先处理的是需要增加或者删除的情况。

增加指的是新旧的vnode的差异只存在于新的vnode在后面增加了几个子节点,除此之外其他的节点全部一致,比如我们以ABC当作节点为例:

未命名绘图.drawio (4).png

此次更新增加了DE节点,除此之外和旧的vnode一致。

未命名绘图.drawio (5).png

此次变更,新的vnode在前面增加了AB子节点,除此之外其他的节点与之前一致。

这种更新其实逻辑还是比较简单的,无非就是找到新增加的节点,然后创建DOM就完事了,但是如何去找这就又是一门学问了,前面已经说过了,为了追求性能上足够的好,我们不可能去每一个节点都拿来去新旧循环对比,这样的代价太大了,不如有很小的改动,就要循环整个vnode,未免有点太蠢了。那么怎么能够高效的找到增加节点开始的位置呢

其实最主要的目的就是要确定增加的节点范围,我们可以使用双端指针。

定义指针i,它的作用是从开始节点开始向后进行对比,如果新旧节点在i位置相同,那么i++,往前走一步,即new[i] === old[i],这里我们使用newold来表示新旧节点。

定义指针e1e2e1代表指向旧的vnode最后一个子节点,而e2代表指向新的vnode最后一个子节点,也就是分别指向新旧vnode的末端。然后依次进行对比,如果相同,则后退一步,即old[e1] === new[e2]

未命名绘图.drawio (6).png

如果i指针暂停,那么代表找到了不同节点的开始位置,而e1e2指针的暂停,则表示找到了新旧vnode不同节点的终点位置。

未命名绘图.drawio (7).png

根据上面的例子,我们找到了新旧节点差异的开端D和结尾E。

这中间还有一个关键的问题,怎么判断两个节点是相同的?

答案是:目前的做法是使用属性keytype。看见没,这就是日常开发中在for遍历循环的时候写key的重要性,真的是实打实的提升性能啊。

接下来就到了实现代码的环节了,在此之前,还是先写一个小例子:

// 定义新旧children
const prevChild = [
  h("p", { key: "A" }, "A"),
  h("p", { key: "B" }, "B"),
  h("p", { key: "C" }, "C"),
];
const nextChild = [
  h("p", { key: "A" }, "A"),
  h("p", { key: "B" }, "B"),
  h("p", { key: "C" }, "C"),
  h("p", { key: "D" }, "D"),
  h("p", { key: "E" }, "E"),
];

const App = {
  setup() {
    // 设置响应式数据
    const isChange = reactive({
      value: false
    })
    window.isChange = isChange;

    return {
      isChange
    }
  },

  render() {
    let self = this
		// 响应式数据改变时,更新children
    return self.isChange.value === true ? 
          h('div', {}, nextChild) :
          h('div', {}, prevChild)
  }
}

更新的逻辑还是在patchChildren中来实现,上文中我们已经实现了和文本节点相关的逻辑:

 const patchChildren = function(n1, n2, container, parentComponent, anchor) { // 修改
   
   // 省略...
   if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
     // 省略...
   } else {
     // 当前children为array
     if(prevShapFlag & ShapeFlags.TEXT_CHILDREN) {
       setElementText(container, "")
       mountChildren(c2, container, parentComponent, anchor) // 修改
     } else {
       // Array -> Array
       // diff children
       patchKeyedChildren(c1, c2, container, parentComponent, anchor) // 修改
     }
   }
 }
 

patchKeyedChildren函数就是我们处理更新的战场。

第一步,首先根据指针确定开端/结束位置:

 function patchKeyedChildren(
   c1,
   c2,
   container,
   parentComponent
 ) {
   const l2 = c2.length;
   // 定义前指针i
   let i = 0;
   // 定义后指针e1,e2
   // 分别指向新旧节点的尾部
   let e1 = c1.length - 1;
   let e2 = l2 - 1;
   // 判断是否为相同节点
   function isSomeVNodeType(n1, n2) {
     return n1.type === n2.type && n1.key === n2.key;
   }
   // 移动i
   while (i <= e1 && i <= e2) {
     const n1 = c1[i];
     const n2 = c2[i];
     // 如果为相同节点,则递归调用patch,因为此子节点不一定为最终的文本节点
     if (isSomeVNodeType(n1, n2)) {
       patch(n1, n2, container, parentComponent, parentAnchor);
     } else {
       break;
     }
 
     i++;
   }
   // e1, e2前移
   while (i <= e1 && i <= e2) {
     const n1 = c1[e1];
     const n2 = c2[e2];
 
     if (isSomeVNodeType(n1, n2)) {
       patch(n1, n2, container, parentComponent, parentAnchor);
     } else {
       break;
     }
 
     e1--;
     e2--;
   }
 }

当i和e1,e2都移动完毕后,此时三个指针的位置分别为:

 i === 3
 e1 === 2
 e2 === 4

这三个指针的位置可以说明很多问题,可以反映新旧vnode的差异到底是什么样的情况:

  • 当i指针大于e1指针,小于等于e2指针时,代表需要创建新节点,此时从e1指针后面的位置开始创建新节点即可,结束位置位于e2。
  • 当i指针小于e1指针并且大于e2指针时,代表需要删除多余的旧节点。
  • 剩余的情况代表差异位置不在开始或者结束位置,而在中间位置(此情况下文实现)。

拿我上面的🌰来看:

 // 旧vnode
 const prevChild = [
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
   h("p", { key: "C" }, "C"),
 ];
 // 新vnode
 const nextChild = [
   h("p", { key: "D" }, "D"),
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
   h("p", { key: "C" }, "C"),
 ];
 
 // 指针位置分别为
 i === 0
 e1 === -1
 e2 === 0
 // 旧vnode
 const prevChild = [
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
   h("p", { key: "C" }, "C"),
 ];
 // 新vnode
 const nextChild = [
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
   h("p", { key: "C" }, "C"),
   h("p", { key: "D" }, "D"),
   h("p", { key: "E" }, "E"),
 ];
 
 // 指针位置分别为
 i === 3
 e1 === 2
 e2 === 4

全都符合我们第一种情况。

实现前两种情况:

 function patchKeyedChildren(
   c1,
   c2,
   container,
   parentComponent,
   parentAnchor
 ) {
   const l2 = c2.length;
   let i = 0;
   let e1 = c1.length - 1;
   let e2 = l2 - 1;
 
   function isSomeVNodeType(n1, n2) {
     return n1.type === n2.type && n1.key === n2.key;
   }
 
   while (i <= e1 && i <= e2) {
     const n1 = c1[i];
     const n2 = c2[i];
 
     if (isSomeVNodeType(n1, n2)) {
       patch(n1, n2, container, parentComponent, parentAnchor);
     } else {
       break;
     }
 
     i++;
   }
 
   while (i <= e1 && i <= e2) {
     const n1 = c1[e1];
     const n2 = c2[e2];
 
     if (isSomeVNodeType(n1, n2)) {
       patch(n1, n2, container, parentComponent, parentAnchor);
     } else {
       break;
     }
 
     e1--;
     e2--;
   }
 
   if (i > e1) {
     if (i <= e2) {
       // 获取插入DOM节点的位置
       const nextPos = e2 + 1;
       // 此插入位置的计算主要针对位于vnode前端增加节点的情况
       // 在vnode后端增加节点直接传入null,效果等同于append
       const anchor = nextPos < l2 ? c2[nextPos].el : null;
       while (i <= e2) {
         patch(null, c2[i], container, parentComponent, anchor);
         i++;
       }
     }
   } else if (i > e2) {
     while (i <= e1) {
       remove(c1[i].el);
       i++;
     }
   } else {
     // 中间对比
   }
 }
 // 根据父节点删除
 const remove = function(child) {
   const parent = child.parentNode
 
   if(parent) parent.removeChild(child)
 }

还记得之前我们在mountElement函数中挂载DOM时,是直接使用的append,现在来看是不符合现在的要求的,试想,如果我们在vnode的前端增加节点,你还能都给我加到末尾吗?明显是不合理的。

所以我们需要记录一个坐标,用于DOM的insert插入节点,根据上面我们指针的移动规律,这个坐标记录e2的下一位即可,因为我们要在e2的位置添加节点,而insertBefore是添加到指定节点之前,所以我们要记录e2指针的下一个位置。

先修改mountElement函数的挂载方法:

 const mountElement = function (vnode, container, parentComponent, anchor) { // 修改
   // 省略...
 
   // props
   for (const key in props) {
     const prop = props[key]
 
     mountProps(el, key, null, prop)
   }
 
   // container.append(el)
   insert(container, el, anchor)  // 修改
 }
 
 // 插入
 const insert = function(parent, child, anchor) {
   parent.insertBefore(child, anchor || null)
 }

因为我们有增肌了参数anchor,而patch函数和mountElement函数并不直接有调用关系,所以...又是一轮大规模的参数传递...😭😭

render函数:

 const render = function (vnode, container) {
   // 初始化渲染不需要传递坐标
   patch(null, vnode, container, null, null)  // 修改
 }

patch函数:

 const patch = function (n1, n2, container, parentComponent, anchor) { // 修改
 
   const { type, shapeFlag } = n2 
 
   switch(type) {
     case Fragment:
       processFragment(n1, n2, container, parentComponent, anchor); // 修改
       break
     case Text:
       processText(n1, n2, container);
       break
     default:
       if (shapeFlag & ShapeFlags.ELEMENT) {
         processElement(n1, n2, container, parentComponent, anchor)  // 修改
       } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
         processComponent(n1, n2, container, parentComponent, anchor) // 修改
       }
       break
   }
 }

processFragment函数:

const processFragment = function(n1, n2, container, parentComponent, anchor) {  // 修改
  mountChildren(n2.children, container, parentComponent, anchor)  // 修改
}

processElement函数:

const processElement = function (n1, n2, container, parentComponent, anchor) {  // 修改
  if(!n1) {
    mountElement(n2, container, parentComponent, anchor)  // 修改
  } else { 
    patchElement(n1, n2, container, parentComponent, anchor)  // 修改
  }
}

patchElement函数:

 const patchElement = function(n1, n2, container, parentComponent, anchor) { // 修改
   // 省略...
   // children update
   patchChildren(n1, n2, el, parentComponent, anchor) // 修改
   // props update
   patchProps(el, oldProps, newProps)
 }

patchChildren函数:

 const patchChildren = function(n1, n2, container, parentComponent, anchor) { // 修改
   
   // 省略...
 
   if(shapeFlag & ShapeFlags.TEXT_CHILDREN) {
     // 省略...
   } else {
     // 当前children为array
     if(prevShapFlag & ShapeFlags.TEXT_CHILDREN) {
       setElementText(container, "")
       mountChildren(c2, container, parentComponent, anchor) // 修改
     } else {
       // Array -> Array
       // diff children
       patchKeyedChildren(c1, c2, container, parentComponent, anchor) // 修改
     }
   }
 }

mountElement函数:

 const mountElement = function (vnode, container, parentComponent, anchor) { // 修改
   // 省略...
 
   if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
     el.textContent = children
   } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
     mountChildren(children, el, parentComponent, anchor) // 修改
   }
 
   // props
   for (const key in props) {
     const prop = props[key]
 
     mountProps(el, key, null, prop)
   }
 
   // container.append(el)
   insert(container, el, anchor)  // 修改
 }

mountChildren函数:

 const mountChildren = function (children, container, parentComponent,anchor) { // 修改
   children.forEach(v => {
     patch(null, v, container, parentComponent, anchor) // 修改
   })
 }

processComponent函数:

 const processComponent = function (n1, n2, container, parentComponent, anchor) {  // 修改
   mountComponent(n2, container, parentComponent, anchor)  // 修改
 }

mountComponent函数:

 const mountComponent = function (vnode, container, parentComponent, anchor) {  // 修改
   // 创建组件实例
   const instance = createComponentInstance(vnode, parentComponent)
 
   setupComponent(instance)
   setupRenderEffect(instance, vnode, container, anchor)  // 修改
 }

setupRenderEffect函数:

 const setupRenderEffect = function (instance, vnode, container, anchor) {  // 修改
 
   effect(()=>{
     if(!instance.isMounted) {
       // 省略...
 
       patch(null, subTree, container, instance, anchor);  // 修改
       vnode.el = subTree.el;
       instance.isMounted = true;
     } else {
       // 省略...
 
       instance.subTree = subTree;
       patch(prevSubTree, subTree, container, instance, anchor)  // 修改
 
     }
   })
 }

完整代码见:github.com/konvyi/vue3…

五. 更新children - 中间对比(修改/删除)

处理完两端的节点,再看看一下中间节点,这种情况是指差异的部分位于中间。

处理中间节点的差异也有三种情况:

  • 删除
  • 新建
  • 修改
  • 复用

我们先来看一下删除节点和修该节点的情况。

删除节点是指节点存在于旧的vnode中,而不存在新的vnode中,修改是指节点是相同的(具有相同的key,而修改节点的props或者children)。

例如:

 //旧
 const prevChild = [
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
 
   h("p", { key: "Z" }, "Z"),
   h("p", { key: "C", id: "c-prev" }, "C"),
   h("p", { key: "D" }, "D"),
 
   h("p", { key: "F" }, "F"),
   h("p", { key: "G" }, "G"),
 ];
 // 新
 const nextChild = [
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
 
   h("p", { key: "C", id:"c-next" }, "C"),
 
   h("p", { key: "F" }, "F"),
   h("p", { key: "G" }, "G"),
 ];

在上面这个例子中,我们删除了Z和D节点,修改了C节点的id属性。两端的节点是相同的。

patchKeyedChildren这个函数中,我们已经处理完了双端对比:

 function patchKeyedChildren(
   c1,
   c2,
   container,
   parentComponent,
   parentAnchor
 ) {
   
   // 省略...
   if (i > e1) {
     // 省略...
   } else if (i > e2) {
     // 省略...
   } else {
     // 既不大于e1,也不大于e2,说明差异的开始位置位于中间
     // 中间对比
     let s1 = i
     let s2 = i
     // 计算需要处理的长度
     const toBePatched = e2 - s2 + 1
     // 当前处理的位置
     let patched = 0
     // map映射,用于保存新vnode中的节点位置
     const keyToNewIndexMap = new Map()
     // 保存
     for(let i = s2; i <= e2; i++) {
       let nextChild = c2[i]
       keyToNewIndexMap.set(nextChild.key, i)
     }
     // 遍历旧vnode
     for(let i = s1; i <= e1; i++) {
       const prevChild = c1[i]
       // 优化,如果patched的值大于需要处理的长度
       // 代表旧的剩余的需要删除
       if(patched >= toBePatched) {
         remove(prevChild.el)
         continue
       }
 
       let newIndex
       // 首先判断是否在map映射中查找到key值,
       // 如果没有查找到则只能遍历所有新vnode查找
       if(prevChild.key != null) {
         newIndex = keyToNewIndexMap.get(prevChild.key)
       } else {
         for(let j = s2; j <= e2; j++) {
           if(isSomeVNodeType(prevChild, c2[j])) {
             newIndex = j
             break
           }
         }
       }
       // 没有查找到下标,直接删除旧节点
       if(newIndex === undefined) {
         remove(prevChild.el)
       } else {
         // 查找到则进一步递归对比,patched++
         patch(prevChild, c2[newIndex], container, parentComponent, null)
         patched++
       }
     }
   }
 }

删除的思路只要是验证旧的vnode节点是否位于新的vnode之中,两种方式来验证,一是使用key与新节点的下标进行映射,当便利旧节点时,使用旧节点的key进行查找,如果旧节点中没有key,则是能遍历所有新节点进行寻找。(再一次印证了key的重要性)至于节点中propschildren的更新,直接走patch函数中的更新流程的即可。

接下来就水到渠成了,可以查找到就继续调用patch进行深层对比,没有查找到则说明在新的vnode中该节点已经不存在,直接删除。

六. 更新children - 中间对比(增加/移动)

可能有很多人难以理解标题的内容,增加节点可以理解,但是移动是怎么一回事?

要回答这个问题,还是先看一下这个🌰:

 const prevChild = [
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
 
   h("p", { key: "C" }, "C"),
   h("p", { key: "D" }, "D"),
   h("p", { key: "E" }, "E"),
   h("p", { key: "Z" }, "Z"),
 
   h("p", { key: "F" }, "F"),
   h("p", { key: "G" }, "G"),
 ];
 
 const nextChild = [
   h("p", { key: "A" }, "A"),
   h("p", { key: "B" }, "B"),
 
   h("p", { key: "D" }, "D"),
   h("p", { key: "C" }, "C"),
   h("p", { key: "Y" }, "Y"),
   h("p", { key: "E" }, "E"),
 
   h("p", { key: "F" }, "F"),
   h("p", { key: "G" }, "G"),
 ];
 
 const App = {
   setup() {
     const isChange = reactive({
       value: false
     })
     window.isChange = isChange;
 
     return {
       isChange
     }
   },
 
   render() {
     let self = this
 
     return self.isChange.value === true ?
       h('div', {}, nextChild) :
       h('div', {}, prevChild)
   }
 }

这个例子其实和之前的更新思路是一致的(开始和结束位置的节点未发生变化),只不过发生变化的节点位置发生了变化:

 // 旧的vnode
 h("p", { key: "C" }, "C"),
 h("p", { key: "D" }, "D"),
 h("p", { key: "E" }, "E"),
 h("p", { key: "Z" }, "Z"),
 
 // 新的vnode
 h("p", { key: "D" }, "D"),
 h("p", { key: "C" }, "C"),
 h("p", { key: "Y" }, "Y"),
 h("p", { key: "E" }, "E"),
 

观察上面的节点可知,Z节点为需要删除的节点,Y节点是需要创建的节点,而C,D,E节点都是未发生变化的,如果我们只是单纯的将所有开始与结束节点不同的一段vnode全部删除重建,未免有点太浪费了,如何高效的对所有已经创建的节点进行利用呢?其实这就回答了上面的问题,移动。

未命名绘图.drawio (8).png

如图所示,其实刨开Z的删除和Y的新增,只需要将D移动至开始就可以了,最小化的修改了我们的DOM。

 function patchKeyedChildren(
   c1,
   c2,
   container,
   parentComponent,
   parentAnchor
 ) {
     // 省略...
     if (i > e1) {
       // 省略...
     } else if (i > e2) {
       // 省略...
     } else {
       // 中间对比
     let s1 = i
     let s2 = i
 
     const toBePatched = e2 - s2 + 1
     let patched = 0
     const keyToNewIndexMap = new Map()
     // 用于保存需要处理的节点下标
     const newIndexToOldIndexMap = new Array(toBePatched) // 新增
     // 数组补0
     for (let i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // 新增
 
 
     for (let i = s2; i <= e2; i++) {
       let nextChild = c2[i]
       keyToNewIndexMap.set(nextChild.key, i)
     }
 
     for (let i = s1; i <= e1; i++) {
       const prevChild = c1[i]
 
       if (patched >= toBePatched) {
         remove(prevChild.el)
         continue
       }
 
       let newIndex
       if (prevChild.key != null) {
         newIndex = keyToNewIndexMap.get(prevChild.key)
       } else {
         for (let j = s2; j <= e2; j++) {
           if (isSomeVNodeType(prevChild, c2[j])) {
             newIndex = j
             break
           }
         }
       }
 
       if (newIndex === undefined) {
         remove(prevChild.el)
       } else {
         // 根据新vnode中的位置来保存旧vnode节点的下标
         newIndexToOldIndexMap[newIndex - s2] = i + 1 // 新增
 
         patch(prevChild, c2[newIndex], container, parentComponent, null)
         patched++
       }
     }
     // 获取最长递增子序列
     const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap) // 新增
 
     let j = increasingNewIndexSequence.length - 1
     for (let i = toBePatched - 1; i >= 0; i--) {
       const nextIndex = i + s2
       const nextChild = c2[nextIndex]
       // 插入节点,记录锚点
       const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null
       // 如果在newIndexToOldIndexMap为0,则说明未找到newIndex,为新增节点
       if (newIndexToOldIndexMap[i] === 0) {
         patch(null, nextChild, container, parentComponent, anchor)
       } else {
         if (j < 0 || increasingNewIndexSequence[j] !== i) {
           insert(container, nextChild.el, anchor)
         } else {
           j--
         }
       }
     }
     }
   }

首先定义一个数组用来存储需要更新的节点下标,初始化时使用0进行填充,如果可以获取到newIndex的话,说明该节点在新旧的vnode中同时存在,我们会对这种节点的下标进行填充,而如果是新增节点的话,我们并不能处理到数组中为0的值,也就是该位置不会被填充,依然为0,这也是下文中我们判断新增节点与移动节点的依据。

接下来就到了移动节点的操作,这需要我们筛选出到底不需要移动的是哪些?这需要一个重要的辅助函数-最长递增子序列。

 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) {
         p[i] = j;
         result.push(i);
         continue;
       }
       u = 0;
       v = result.length - 1;
       while (u < v) {
         c = (u + v) >> 1;
         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];
   while (u-- > 0) {
     result[u] = v;
     v = p[v];
   }
   return result;
 }

关于这个函数是如何实现的,因为本篇篇幅有点太长了,我准备在vue3系列完结之后单独写一篇文章,这里其实对理解整个更新过程影响不是特别大,只需要知道这个函数可以将记录数组中整个递增的数字的下标,就拿我们上文中的例子来说:

 // 记录完下标后
 newIndexToOldIndexMap = [4, 3, 0, 5]
 
 // 这也就对应了
 [D, C, Y, E]
 [4, 3, 0, 5]
 // Y为0,代表新增
 // 其他分别代表在旧的vnode中的下标位置

经过处理之后:

 const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
 increasingNewIndexSequence = [1, 3]

代表节点C,E是无需变动的节点。

最后倒序移动需要移动的节点D就可以了。

七. 实现组件更新

createApp-3665613.png

以上的更新都是属于element的更新,那么componet(组件)怎样进行更新呢?

比如有以下🌰:

 const App = {
   setup() {
     // 定义响应式数据
     let count = reactive({
       value: 'pino'
     })
     // 修改count.value
     let changeCount = function() {
       count.value = 'new-pino'
     }
 
     return {
       count,
       changeCount
     }
   },
   
   render() {
     return h('div', {}, [
       h('p', {}, 'App'),
       // 子组件,设置props
       h(Child, { count: this.count.value }),
       h('button', { onClick: this.changeCount }, 'change')
     ])
   }
 }
 
 const Child = {
   setup() {},
   render() {
     // 使用this.$props使用props数据
     return h('div', {}, `Child->props:${this.$props.count}`)
   }
 }

在组件App中,我们设置了响应式数据count并将其作为props传递给子组件Child,当点击按钮change时候。改变count的值,那么在子组件Child中的值也应该发生变化。这就是我们实现的效果。

根据上面的分析不难看出,其实主要更新的就是组件的props属性。

首先为获取数据时增加props拦截:(此为上文中的内容,参见上一篇首次渲染的文章)

 const publicPropertiesMap = {
   $el: i => i.vnode.el,
   $slots: i => i.slots,
   // 增加$props拦截,通过$props来访问props数据
   $props: i => i.props // 增加
 }

component实例中增加next属性,用于保存最新更新的vnode

 const component = {
   vnode,
   type: vnode.type,
   next: null, // 新增
   props: {},
   setupState: {},
   provides: parent ? parent.provides : {},
   parent: parent ? parent : {},
   isMounted: false,
   subTree: {},
   slots: {},
   emit: () => {}
 }

在创建vnode时增加component属性用于保存component属性:

 const vnode = {
   type,
   props,
   children,
   component: null, // 增加
   key: props && props.key,
   shapeFlag: getShapeFlag(type),
   el: null,
 }

processComponent函数中判断是否存在n1,如果存在的话则说明为更新操作,需要处理更新的逻辑,如果没有n1则说明是首次渲染:

 const processComponent = function (n1, n2, container, parentComponent, anchor) {
   if(!n1) { // 修改
     mountComponent(n2, container, parentComponent, anchor) // 修改
   } else { // 修改
     // 处理更新
     updateComponent(n1, n2) // 修改
   } // 修改
 }

在首次渲染时保存component实例:

 const mountComponent = function (vnode, container, parentComponent, anchor) {
   // 创建组件实例
   const instance = (vnode.component = createComponentInstance(vnode, parentComponent)) // 修改
 
   setupComponent(instance)
   setupRenderEffect(instance, vnode, container, anchor)
 }

updateComponent函数处理组件更新逻辑:

 const updateComponent = function(n1, n2) {
   // 获取component实例
   const instance = (n2.component = n1.component)
   // 判断是否需要更新?
   if(shouldUpdateComponent(n1, n2)) {
     // 保存最新vnode
     instance.next = n2;
     // 更新DOM
     instance.update();
   } else {
     // 不需要更新则复用el
     // 将n2设置为实例的vnode属性(更新vnode)
     n2.el = n1.el;
     instance.vnode = n2;
   }
 }

此时的n1n2(新旧vnode):

WX20220920-171502@2x.png

此时props中的属性count已经发生了变化。

整个更新函数还有两个关键点,如何判断是需要更新?如何进行更新?

判断更新就是将新的props对象进行遍历,再与旧的props进行对比,如果不相同则需要更新。

 const shouldUpdateComponent = function(prevVNode, nextVNode) {
   const { props: prevProps } = prevVNode;
   const { props: nextProps } = nextVNode;
   // 遍历新的props
   for (const key in nextProps) {
     // 如果有不同的属性,返回true(需要更新)
     if (nextProps[key] !== prevProps[key]) {
       return true;
     }
   }
 
   return false;
 }

判断是实现了,那么怎么更新呢,因为更新props不可能只是把实例中的属性更改了就完事了,还需要再对页面中的效果进行更新,因为最终是要显示在页面上的。

可以想一下我们在element的更新中是怎样更新页面的呢?是用effect进行绑定函数的方式,监听到数据的变化,再次执行渲染函数执行render函数的。那么我们的component的更新是不是也可以借用响应式呢?

答案当然是可以的,我们在首次渲染的时候将effect函数进行保存,在需要更新的时候调用就可以了。还记得在响应式那一节中effect函数的返回值是啥吗,调用effect函数的返回值还可以执行依赖函数,这也就实现了更新页面的功能。(不熟悉响应式可以参见响应式那一部分的文章)

 const setupRenderEffect = function (instance, vnode, container, anchor) {
     // 保存effect函数
     instance.update = effect(() => { // 修改
       if (!instance.isMounted) {
         console.log("init");
         const { proxy } = instance;
         const subTree = (instance.subTree = instance.render.call(proxy));
   
         patch(null, subTree, container, instance, anchor);
         vnode.el = subTree.el;
         instance.isMounted = true;
       } else {
         console.log("update");
   
         const { next, vnode } = instance // 增加
         if(next) { // 增加
           next.el = vnode.el // 增加
           // 在执行更新页面之前首先要先更新component实例中的各个属性
           updateComponentPreRender(instance, next) // 增加
         } // 增加
   
         const { proxy } = instance
         const subTree = instance.render.call(proxy)
         const prevSubTree = instance.subTree
   
         instance.subTree = subTree;
         patch(prevSubTree, subTree, container, instance, anchor)
   
       }
   })
 }
 
 const updateComponentPreRender = function(instance, nextVNode) {
   instance.vnode =  nextVNode
   instance.next = null
   
   instance.props = nextVNode.props
 } 

八. 实现nextTick功能

虽然更新功能已经实现的差不多了,但是其实在执行的时机上还是有很大的问题,比如我们有以下的🌰:

 const App = {
   setup() {
     // 定义响应式数据count
     let count = reactive({
       value: 0
     })
     // 
     let changeCount = function() {
       for(let i = 0; i < 10; i++) {
         count.value = count.value + 1
       }
     }
 
     return {
       count,
       changeCount
     }
   },
   render() {
     return h('div', {}, [
       h('p', {}, `count: ${this.count.value}`),
       h('button', { onClick: this.changeCount }, 'update')
     ])
   }
 }

在这个例子中,我们通过点击按钮update来触发changeCount函数,changeCount函数中循环为count增加10次。

image-20220921153030774.png

我们在更新逻辑时打印"update",发现更新逻辑竟然执行了10次,也就是出发了10次更新DOM的操作,哪怕发生的变化仅仅只是把响应式数据加一!

这显然是非常离谱的,那么怎么能够减少更新呢,以上面的例子为例,其实我们只在循环完毕后执行一次更新DOM的操作就可以了。那么其实解决的方案也很简单,把更新操作放到微任务队列就可以了嘛。微任务是等到所有的同步任务全部执行完毕后才会执行,那么等到微任务里面的更新操作开始执行时,我们的循环当然已经执行完毕了,所以更新操作只会执行一次。

但是如果故事到这里就结束其实也挺美好的,但是事情往往都不会一帆风顺,如果像我们上面的想法实现的话,那么又会出现一个新的问题,看下边的🌰:

 const App = {
   setup() {
     let count = reactive({
       value: 0
     })
     
     let changeCount = function() {
       for(let i = 0; i < 10; i++) {
         count.value = count.value + 1
       }
       // 获取当前component实例
       const instance = getCurrentInstance()
       console.log(instance, 'instance');
     }
 
     return {
       count,
       changeCount
     }
   },
   render() {
     return h('div', {}, [
       h('p', {}, `count: ${this.count.value}`),
       h('button', { onClick: this.changeCount }, 'update')
     ])
   }
 }

如果我们在for循环后面直接获取最新的component实例,由于我们的DOM更新操作放到了微任务里面,那么获取的当前实例自然不会是最新的,但是我们在响应式数据变化之后获取实例本意肯定是想获取最新的component实例,但是由于我们更新DOM操作的延后,导致正常的功能会受到影响。

那么怎么解决呢?

还记得nextTick吗?

  this.$nextTick(() => {
    console.log(getCurrentInstance())
  })

nextTick里面可以获取当最新的DOM。

nextTick的实现也很直接,既然你把更新DOM的操作放到了微任务里,那么我也把nextTick里面的执行逻辑也放在微任务里面不就可以了。

我们在每次执行instance.update函数时,使用queueJobs函数进行处理:

 const setupRenderEffect = function (instance, vnode, container, anchor) {
     instance.update = effect(() => { 
      // 省略...
   }, {
     // 使用scheduler函数进行包裹
     // 执行时会执行此函数
     scheduler() {
       queueJobs(instance.update);
     }
   })
 }

这里使用effect函数的第二个参数进行处理,不熟悉的话需要熟悉一下响应式的部分。

接下来实现queueJobs函数:

 // 初始化执行栈
 const queue = []
 // 定义一个成功的promise状态
 const p = Promise.resolve()
 // 定义是否添加任务的状态
 let isFlushPending = false
 
 const queueJobs = job => {
   // 如果任务未被添加到队列中
   if(!queue.includes(job)) {
     queue.push(job)
   }
 
   queueFlush()
 }

queueFlush函数主要用于判断是否添加至微任务:

 const queueFlush = () => {
   // 如果当前处于true,则直接返回
   if(isFlushPending) return
   isFlushPending = true
   // 调用nextTick函数将flushJobs添加至微任务队列
   nextTick(flushJobs)
 }
 // 取出所有队列中的任务执行
 const flushJobs = () => {
   isFlushPending = false
   let job
   while((job = queue.shift())) {
     job && job()
   }
 }
 
 const nextTick = fn => {
   // 使用Promise.then
   return fn ? p.then(fn) : p
 }

其实中心思想就是在创建完一次任务添加至微任务队列之后,后续的执行都只是将函数添加到queue队列中。当执行微任务后isFlushPending开关变为false,之后可以再次添加任务到微任务中。

而直接执行nextTick函数则会自定创建一个任务到微任务中:

 const App = {
   setup() {
     let count = reactive({
       value: 0
     })
     
     let changeCount = function() {
       for(let i = 0; i < 10; i++) {
         count.value = count.value + 1
       }
       // 在nextTick函数中调用
       nextTick(()=>{
         const instance = getCurrentInstance()
       })
     }
 
     return {
       count,
       changeCount
     }
   },
   render() {
     return h('div', {}, [
       h('p', {}, `count: ${this.count.value}`),
       h('button', { onClick: this.changeCount }, 'update')
     ])
   }
 }

写在最后 ⛳

未来可能会更新实现mini-vue3javascript基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳