Vue3核心源码解析

前置

友情链接

immerjs-拓展open in new window,与 Vue3 类似,使用Proxy实现妙用。

源码地址open in new window,参考 3.3.4 版本。

monorepo

INFO

比如公司有很多项目,他们的依赖是不同的内部包,monorepo 就是用单个 git仓库 储存多个项目。Vue3 采用 monorepo 项目管理 package。

分析流程

  1. .vue 文件编译 => compiler-sfc
  2. 应用初始化 => runtime-core
  3. 挂载 => runtime-core
  4. 数据侦听处理 => reactive
  5. 触发数据更新 => runtime-core
  6. 数据更新同步到视图 => runtime-core + runtime-dom
  7. 卸载

模拟 Vue3 中组件的状态

  • 先用比较笨的方法实现
<html>
<div id="app">{{count}}</div>
<button id="btn">click</button>
</html>
<body><script>
const data = { count:0 }
//将这个状态变成响应式,Vue2中是通过 Object.defineProperty,Vue3是Proxy
const reactiveData = new Proxy(data,{
  set(target,property,value){
    target[property] = value
    updateView()
    //updateView等价于 Vue 的 new Vue({data:{count:0}}).$mount('#app')
    //这是比较笨的方法,因为在属性中操作,Vue源码用收集依赖代替了这一步🚩
    return true
  },
  get(target,property){
    return target[property]
  }
})
//更新视图
const updateView = () => {
  document.querySelector("#app").innerHTML = reactiveData.count
}
//使用
const btnDom = document.querySelector("#btn")
btnDom.addEventListener("click",()=>{
  reactiveData.count ++
})
</script></body>
  • 源码中不可能在属性中写updateView这一步,用收集依赖代替。先收集,再找到对应时机触发,以此保证数据更新的优先级和频次。比如:页面中同一个按钮连续点击多次,需要收集此行为合并为一次操作。

Vue3 源码的完整周期(几大阶段)

  1. sfc编译(Vue3 的编译是全新的基于状态机(状态机算法题open in new window)的实现方式,Vue2是正则匹配)

  2. 响系统的初始化(runtime-core/src/component)

  3. 依赖收集(reactivity/src/collectionHandlers.ts)

    • 一旦响应式中某个属性被访问,就会调用 track.(track是标记收集依赖)
    • 具体流程:proxy 代理,在 get 方法中,决定追踪策略,然后通过定义的 track 进行属性追踪,在依赖 map 中追加对应的依赖追踪。
    • 面试:依赖中的数据结构是什么样的?
      // reactivity/src/dep.ts,对依赖进行去重处理
      type Dep = set<Reactive> & <TrackedMarkers>
      // reactivity/src/effec.ts:
      type KeyToDepMap = Map<any, Dep>
      const targetMap = new WeakMap<any, KeyToDepMap>() 
      /*🚩首先,每个target都有trackMap,通过WeakMap(弱引用map,一般在存储对象作为key时使用,
      与垃圾回收和新能处理关联大,问题weakMap什么时候被清除)的参数target和依赖map new出来的;
      KeyToDepMap(依赖map)
      */
    
  4. 响应式属性更新

  5. 重新渲染组件

  6. 更新完毕

Vue3 Diff原理

可对比《Vue2 Diff原理》学习。

Vue2 Diff算法有两个理念需关注。一是相同的前置和后置元素的预处理,二是最长递增子序列。

最长递增子序列

快速 diff 算法的核心,对应LeetCodeopen in new window

如现有数组[4,6,7],

递增子序列有:[4,6],[4,7],[4,6,7],[6,7],[7]

最长的递增子序列是[4,6,7]

1.前置与后置的预处理

🔴先看如下文字 :

hello ying
hey ying

🔴这两段文字是有一部分是相同的,这些文字是不需要修改也不需要移动的,真正需要进行修改中间的几个字母,所以diff就变成以下部分:

text1:llo
text2:y

🔴将以上换成vnode: d就是不同的地方,被绿框围住的节点就是不需要移动的部分,只需要补丁patch即可。

🔴此部分逻辑代码如下:

function vue3Diff(prevChildren, nextChildren, parent) {
  let j = 0,
    prevEnd = prevChildren.length - 1,
    nextEnd = nextChildren.length - 1,
    prevNode = prevChildren[j],
    nextNode = nextChildren[j];
  while (prevNode.key === nextNode.key) {
    patch(prevNode, nextNode, parent)
    j++
    prevNode = prevChildren[j]
    nextNode = nextChildren[j]
  }
  
  prevNode = prevChildren[prevEnd]
  nextNode = prevChildren[nextEnd]
  
  while (prevNode.key === nextNode.key) {
    patch(prevNode, nextNode, parent)
    prevEnd--
    nextEnd--
    prevNode = prevChildren[prevEnd]
    nextNode = prevChildren[nextEnd]
  }
}

🟠此时,需考虑边界情况,一种是 j>preEnd,一种是 j>nextEnd:

🟠在上图中,若 j>preEnd 且 j<=nextEnd,只需要把新列表中的 j~nextEnd 之间剩下的节点插入进去即可。

function vue3Diff(prevChildren, nextChildren, parent) {
  if (j > prevEnd && j <= nextEnd) {
    let nextpos = nextEnd + 1,
      refNode = nextpos >= nextChildren.length
                ? null
                : nextChildren[nextpos].el;
    while(j <= nextEnd) mount(nextChildren[j++], parent, refNode)
    
  } else if (j > nextEnd && j <= prevEnd) {
    while(j <= prevEnd) parent.removeChild(prevChildren[j++].el)
  }
}

🟡当while循环时,指针从两端向内逐渐靠拢,所以应该在循环中就去判断边界的情况。使用 label 语法,当触发边界情况时,退出全部的循环,直接进入判断:

function vue3Diff(prevChildren, nextChildren, parent) {
  let j = 0,
    prevEnd = prevChildren.length - 1,
    nextEnd = nextChildren.length - 1,
    prevNode = prevChildren[j],
    nextNode = nextChildren[j];
  // label语法
  outer: {
    while (prevNode.key === nextNode.key) {
      patch(prevNode, nextNode, parent)
      j++
      // 循环中如果触发边界情况,直接break,执行outer之后的判断
      if (j > prevEnd || j > nextEnd) break outer
      prevNode = prevChildren[j]
      nextNode = nextChildren[j]
    }
    prevNode = prevChildren[prevEnd]
    nextNode = prevChildren[nextEnd]
    while (prevNode.key === nextNode.key) {
      patch(prevNode, nextNode, parent)
      prevEnd--
      nextEnd--
      // 循环中如果触发边界情况,直接break,执行outer之后的判断
      if (j > prevEnd || j > nextEnd) break outer
      prevNode = prevChildren[prevEnd]
      nextNode = prevChildren[nextEnd]
    }
  }
  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    let nextpos = nextEnd + 1,
      refNode = nextpos >= nextChildren.length
                ? null
                : nextChildren[nextpos].el;
    while(j <= nextEnd) mount(nextChildren[j++], parent, refNode)
    
  } else if (j > nextEnd && j <= prevEnd) {
    while(j <= prevEnd) parent.removeChild(prevChildren[j++].el)
  }
}

2.判断是否需要移动

接下来,找到移动的点,让他移动到正确的位置。

当前后置预处理结束后,进入真正的 diff 环节

1️⃣首先,根据新列表剩余的节点数量,创建一个 source 数组,并将数组填满 -1

function vue3Diff(prevChildren, nextChildren, parent) {
  outer: {}
  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    // ...
  } else if (j > nextEnd && j <= prevEnd) {
    // ...
  } else {
    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1,     // 新列表中剩余的节点长度
      source = new Array(nextLeft).fill(-1);  // 创建数组,填满-1
  }
}

2️⃣source用于做新旧节点的对应关系。将新节点在就列表的位置储存在改数组中,根据source计算出 它的最长递增子序列用于 DOM 移动的节点。为此,先建立一个对象储存当前新列表中的节点与 index 的关系,再去就列表中找位置。

🦝tips:若旧节点在新列表中无,直接删除。此外,需要一个数量表示记录以及 patch 过的节点,若数量与新列表剩余的节点数量一致,剩下的旧节点直接删除。
function vue3Diff(prevChildren, nextChildren, parent) {
//...
  outer: {
  // ...
  }
  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    // ...
  } else if (j > nextEnd && j <= prevEnd) {
    // ...
  } else {
    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1,     // 新列表中剩余的节点长度
      source = new Array(nextLeft).fill(-1),  // 创建数组,填满-1
      nextIndexMap = {},                      // 新列表节点与index的映射
      patched = 0;                            // 已更新过的节点的数量
      
    // 保存映射关系  
    for (let i = nextStart; i <= nextEnd; i++) {
      let key = nextChildren[i].key
      nextIndexMap[key] = i
    } 
    
    // 去旧列表找位置
    for (let i = prevStart; i <= prevEnd; i++) {
      let prevNode = prevChildren[i],
              prevKey = prevNode.key,
        nextIndex = nextIndexMap[prevKey];
      // 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
      if (nextIndex === undefind || patched >= nextLeft) {
        parent.removeChild(prevNode.el)
        continue
      }
      // 找到对应的节点
      let nextNode = nextChildren[nextIndex];
      patch(prevNode, nextNode, parent);
      // 给source赋值
      source[nextIndex - nextStart] = i
      patched++
    }
  }
}

3️⃣ 找到位置后,观察重新赋值的source,若是全新的节点,初始值 -1 ,从而判断谁是全新的,谁是可复用的。

其次,想判断是否可移动,若找到的 index 一直递增,说明不需要移动任何节点;通过设置一个变量来保存是否需要移动的状态:

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  outer: {
  // ...
  }
  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    // ...
  } else if (j > nextEnd && j <= prevEnd) {
    // ...
  } else {
    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1,     // 新列表中剩余的节点长度
      source = new Array(nextLeft).fill(-1),  // 创建数组,填满-1
      nextIndexMap = {},                      // 新列表节点与index的映射
      patched = 0,
      move = false,                           // 是否移动
      lastIndex = 0;                          // 记录上一次的位置
    // 保存映射关系  
    for (let i = nextStart; i <= nextEnd; i++) {
      let key = nextChildren[i].key
      nextIndexMap[key] = i
    } 
    // 去旧列表找位置
    for (let i = prevStart; i <= prevEnd; i++) {
      let prevNode = prevChildren[i],
              prevKey = prevNode.key,
        nextIndex = nextIndexMap[prevKey];
      // 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
      if (nextIndex === undefind || patched >= nextLeft) {
        parent.removeChild(prevNode.el)
        continue
      }
      // 找到对应的节点
      let nextNode = nextChildren[nextIndex];
      patch(prevNode, nextNode, parent);
      // 给source赋值
      source[nextIndex - nextStart] = i
      patched++
      // 递增方法,判断是否需要移动
      if (nextIndex < lastIndex) {
        move = true
      } else {
        lastIndex = nextIndex
      }
    }
    if (move) {
    // 需要移动
    } else {
    //不需要移动
    }
  }
}

3.DOM如何移动

判断完是否需要移动后,需考虑如何移动。

1️⃣ 一旦需要进行DOM移动,我们首先要做的就是找到source的最长递增子序列

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  if (move) {
    const seq = lis(source); // [0, 1]
  // 需要移动
  } else {
  //不需要移动
  }
}

source 为[2,3,1,-1],此时最长递增子序列为[2,3]

调用 lis 函数求出 source 的最长递增子序列为[0,1]。这是最长递增子序列中的各个元素在 source中的位置索引:

2️⃣ 我们根据source,对新列表进行重新编号。从后向前遍历 source 的每一项,会出现三种情况:

  1. 当前的值为-1,这说明该节点是全新的节点,又由于我们是从后向前遍历,我们直接创建好DOM节点插入到队尾就可以了;
  2. 当前的索引为最长递增子序列中的值,也就是i === seq[j],这说说明该节点不需要移动;
  3. 当前的索引不是最长递增子序列中的值,那么说明该DOM节点需要移动,这里也很好理解,我们也是直接将DOM节点插入到队尾就可以了,因为队尾是排好序的;
function vue3Diff(prevChildren, nextChildren, parent) {
  if(move){//需要移动
    const seq = lis(source); // [0, 1]
    let j = seq.length - 1;  // 最长子序列的指针
    // 从后向前遍历
      let pos = nextStart + i, // 对应新列表的index
      nextNode = nextChildren[pos],// 找到vnode
      nextPos = pos + 1// 下一个节点的位置,用于移动DOM
      refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM节点
      cur = source[i];  // 当前source的值,用来判断节点是否需要移动
       if (cur === -1) {
        // 情况1,该节点是全新节点
        mount(nextNode, parent, refNode)
      } else if (cur === seq[j]) {
        // 情况2,是递增子序列,该节点不需要移动
        // 让j指向下一个
        j--
      } else {
        // 情况3,不是递增子序列,该节点需要移动
        parent.insetBefore(nextNode.el, refNode)
      }
  }else{//不需要移动}
}

3️⃣ 说完了需要移动的情况,再说说不需要移动的情况。如果不需要移动的话,我们只需要判断是否有全新的节点给他添加进去就可以了:

function vue3Diff(prevChildren, nextChildren, parent) {
  if (move) {
    const seq = lis(source); // [0, 1]
    let j = seq.length - 1;  // 最长子序列的指针
    // 从后向前遍历
    for (let i = nextLeft - 1; i >= 0; i--) {
      let pos = nextStart + i, // 对应新列表的index
        nextNode = nextChildren[pos],        // 找到vnode
        nextPos = pos + 1// 下一个节点的位置,用于移动DOM
        refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM节点
        cur = source[i];  // 当前source的值,用来判断节点是否需要移动
      if (cur === -1) {
        // 情况1,该节点是全新节点
        mount(nextNode, parent, refNode)
      } else if (cur === seq[j]) {
        // 情况2,是递增子序列,该节点不需要移动
        // 让j指向下一个
        j--
      } else {
        // 情况3,不是递增子序列,该节点需要移动
        parent.insetBefore(nextNode.el, refNode)
      }
    }
  } else {
    //不需要移动
    for (let i = nextLeft - 1; i >= 0; i--) {
      let cur = source[i];  // 当前source的值,用来判断节点是否需要移动
      if (cur === -1) {
      let pos = nextStart + i, // 对应新列表的index
          nextNode = nextChildren[pos],        // 找到vnode
          nextPos = pos + 1// 下一个节点的位置,用于移动DOM
          refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM节点
          mount(nextNode, parent, refNode)
      }
    }
  }
}

编译器

状态机的方式处理,对编写编译器有帮助。