Vue3核心源码解析
前置
友情链接
immerjs-拓展,与 Vue3 类似,使用Proxy实现妙用。
源码地址,参考 3.3.4 版本。
monorepo
INFO
比如公司有很多项目,他们的依赖是不同的内部包,monorepo 就是用单个 git仓库 储存多个项目。Vue3 采用 monorepo 项目管理 package。
分析流程
- .vue 文件编译 => compiler-sfc
- 应用初始化 => runtime-core
- 挂载 => runtime-core
- 数据侦听处理 => reactive
- 触发数据更新 => runtime-core
- 数据更新同步到视图 => runtime-core + runtime-dom
- 卸载
模拟 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 源码的完整周期(几大阶段)
sfc编译(Vue3 的编译是全新的基于状态机(状态机算法题)的实现方式,Vue2是正则匹配)
响系统的初始化(runtime-core/src/component)
依赖收集(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) */
响应式属性更新
重新渲染组件
更新完毕
Vue3 Diff原理
可对比《Vue2 Diff原理》学习。
Vue2 Diff算法有两个理念需关注。一是相同的前置和后置元素的预处理,二是最长递增子序列。
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
![](https://cetacea-1304984885.cos.ap-shanghai.myqcloud.com/pieces/Snipaste_2024-04-17_17-49-37.jpg)
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 ,从而判断谁是全新的,谁是可复用的。
![](https://cetacea-1304984885.cos.ap-shanghai.myqcloud.com/pieces/Snipaste_2024-04-17_18-00-28.jpg)
其次,想判断是否可移动,若找到的 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中的位置索引:
![](https://cetacea-1304984885.cos.ap-shanghai.myqcloud.com/pieces/Snipaste_2024-04-17_18-46-00.jpg)
2️⃣ 我们根据source,对新列表进行重新编号。从后向前遍历 source 的每一项,会出现三种情况:
- 当前的值为-1,这说明该节点是全新的节点,又由于我们是从后向前遍历,我们直接创建好DOM节点插入到队尾就可以了;
- 当前的索引为最长递增子序列中的值,也就是i === seq[j],这说说明该节点不需要移动;
- 当前的索引不是最长递增子序列中的值,那么说明该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)
}
}
}
}
编译器
状态机的方式处理,对编写编译器有帮助。