バックグラウンド
Vue3 の差分アルゴリズムについて学びましょう~
論理的な概要
このアルゴリズムのプロセスはそれほど複雑ではありません。
- 古いリストと新しいリストの先頭から同時にトラバースし、同じノードを見つけてパッチを適用します。それ以外の場合はトラバースを停止します。
- 同時に、古いリストと新しいリストの末尾から走査して同じノードを見つけてパッチを適用します。それ以外の場合は走査を停止します。
- 古いリスト内のすべてのノードが走査され、新しいリスト内にまだ走査されていないノードが存在する場合、それは新しいリストにノードが追加されていることを意味し、これらのノードはすべて追加されます。
- この時点でまだトラバースされていないノードが古いリストにあり、新しいリストがトラバースされている場合、古いリストの一部のノードが新しいリストで削除され、古いリストでまだトラバースされていないノードが削除されることを意味します。
- 新旧のリストにまだトラバースされていないノードがある場合は、これらのノードを操作する必要があります。ここでの操作とは、追加、移動、および削除を指します。このステップは、二分探索と最長の昇順サブシーケンスを含む最も複雑なステップです。
差分処理
関数パラメータ
/**
* Vue3 Diff简易版
* @param {*} oldList 旧列表
* @param {*} newList 新列表
* @param {*} param2 4个函数分别代表 挂载、更新、移除、移动节点操作
*/
function diffArray(oldList, newList, {
mountElement, patch, unmount, move }) {
// 节点是否可以复用,这里只是简单的判断key值,事实上判断元素是否可以复用,还要判断tag等值
function isSameVnodeType(node1, node2) {
return node1.key === node2.key;
}
// ...
}
古いリストと新しいリストを最初からたどって同じノードを見つけます
このグラフでは、両方のリストの先頭から同時にトラバースし、同じノードに AB があり、ループを停止します。
function diffArray(oldList, newList, {
mountElement, patch, unmount, move }) {
// 节点是否可以复用
function isSameVnodeType(node1, node2) {
return node1.key === node2.key;
}
// 头部遍历的起始变量
let i = 0;
// 旧列表的长度
const oldLen = oldList.lenght;
// 新列表的长度
const newLen = newList.lenght;
// 1. 从头遍历新旧列表,找出相同的节点
while(i < oldLen && i < newLen) {
const oldNode = oldList[i];
const newNode = newList[i];
if(isSameVnodeType(oldNode, newNode)) {
patch(oldNode.key);
i++;
}else {
break;
}
}
}
古いリストと新しいリストを端からたどって同じノードを見つけます
古いリストと新しいリストの長さは必ずしも同じではなく、2 つのトラバーサルの末尾座標も異なるため、別々に宣言する必要があります。同じノードが見つかった場合は引き続き前進し、そうでない場合はループを終了します。
// 旧列表尾巴下标
const oldEndIndex = oldLen - 1;
// 新列表尾巴下标
const newEndIndex = newLen - 1;
// 2.从尾遍历新旧列表,找出相同的节点
while(i <= oldEndIndex && i <= newEndIndex){
const oldNode = oldList[oldEndIndex];
const newNode = newList[newEndIndex];
if(isSameVnodeType(oldNode, newNode)) {
patch(oldNode.key);
oldEndIndex--;
newEndIndex--
}else {
break;
}
}
新しいノード
前の i が最終サイクルにある場合、最終値は であり2
、新しいリストには古いリストより 1 つ多いノード、つまり新しいノードがあることを示します。oldEndIndex = 1
newEndIndex = 2
// 3.新增节点
if( i > oldEndIndex && i <= newEndIndex ){
while(i <= newEndIndex) {
const newNode = newList[i];
mountElement(newNode.key);
i++;
}
}
ノードの削除
前述したように、この時点では旧リストにはまだ巡回されていないノードが存在し、新リストは巡回されているため、新リストでは旧リストの一部のノードが削除され、旧リストでまだ巡回されていないノードが削除されることを意味し、図から新リストではノードCが削除されていることが分かる。
// 4. 删除节点
else if(i <= oldEndIndex && i > newEndIndex) {
while(i <= oldEndIndex) {
const delNode = oldList[i];
unmount(delNode.key);
i++;
}
}
ここまでのコード全体は次のとおりです。
/**
* Vue3 Diff简易版
* @param {*} oldList 旧列表
* @param {*} newList 新列表
* @param {*} param2 4个函数分别代表 挂载、更新、移除、移动节点操作
*/
function diffArray(oldList, newList, {
mountElement, patch, unmount, move }) {
// 节点是否可以复用
function isSameVnodeType(node1, node2) {
return node1.key === node2.key;
}
let i = 0;
// 旧列表的长度
const oldLen = oldList.lenght;
// 新列表的长度
const newLen = newList.lenght;
// 旧列表尾巴下标
const oldEndIndex = oldLen - 1;
// 新列表尾巴下标
const newEndIndex = newLen - 1;
// 1. 从头遍历新旧列表,找出相同的节点
while(i < oldLen && i < newLen) {
const oldNode = oldList[i];
const newNode = newList[i];
if(isSameVnodeType(oldNode, newNode)) {
patch(oldNode.key);
i++;
}else {
break;
}
}
// 2.从尾遍历新旧列表,找出相同的节点
while(i <= oldEndIndex && i <= newEndIndex){
const oldNode = oldList[oldEndIndex];
const newNode = newList[newEndIndex];
if(isSameVnodeType(oldNode, newNode)) {
patch(oldNode.key);
oldEndIndex--;
newEndIndex--
}else {
break;
}
}
// 3.新增节点
if( i > oldEndIndex && i <= newEndIndex ){
while(i <= newEndIndex) {
const newNode = newList[i];
mountElement(newNode.key);
i++;
}
}
// 4.删除节点
else if(i <= oldEndIndex && i > newEndIndex) {
while(i <= oldEndIndex) {
const delNode = oldList[i];
unmount(delNode.key);
i++;
}
}
else {
// ...
}
}
最終処理
さて、最も頭を使う部分に移りましょう。最初に例を挙げましょう。
ここでは実際に新しいリストの [M, H,.C, D, E, J] と古いリストの [C, E, H] を比較して演算を行いたいと考えています。ソース コードは決してそれほど愚かなものではなく、最初に新しいリストのノードを走査し、次に古いリストのノードを埋め込みループと比較することで、配列の特性に応じてデータ構造を変換し、時間のかかるループを削減しています。次にソース コードに従って学習します。
// 新旧节点不同的起点坐标
const oldStartIndex = i;
const newStartIndex = i;
// 5.1 根据数组的特点,将新列表中的key和index做成映射关系
const keyToNewIndexMap = new Map();
for(let i = newStartIndex; i < newEndIndex; i++){
const node = newList[i];
keyToNewIndexMap.set(node.key, i);
}
得られた結果は次のとおりです。
まずここで相対座標について触れてください。!!
便宜上、Vue のソース コードでは、ループのスコープを操作したい新しいリストのノードに直接変更しています (つまり、以下では相対座標を使用します)。たとえば、操作対象のノード リストに対する M の相対座標は、元のリストの 2 ではなく 0 です。
次に、後で使用するいくつかの変数を宣言しましょう。
// 新列表中需要操作的节点数量(上述例子就是6个节点)
const toBePatched = newEndIndex - newStartIndex + 1;
// 已经操作过的节点数量
let patched = 0;
// 先将这个参数简单理解为,新列表的节点与旧列表节点的映射,如果存在就非0,如果不存在就是0
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
次に、最初に古いリストを反復処理します。
for(let i = oldStartIndex; i < oldEndIndex; i++){
const node = oldList[i];
// 如果需要操作的节点数量已经小于操作过的节点,说明旧列表中这些节点是木有用的,需要卸载
if(patched >= toBePatched){
unmount(node.key);
continue;
}
// 接下来根据oldNode中的key,来看看keyToNewIndexMap是否有对应的下标,如果有,那说明旧节点在新列表中被复用啦,否则就得删除
// Vue3源码中有一段是处理节点没有key的情况,这里就不写啦,这里默认我们的节点都是有key的
const newIndex = keyToNewIndexMap.get(node.key);
if(newIndex === undefined){
// 旧节点在新列表中没有被复用,直接卸载
unmount(node.key);
}else {
// 如果在旧列表中找到可以复用的节点,那么更新newIndexToOldIndexMap[相对坐标] = 旧列表的坐标 + 1
// 这里加1的原因 后面再解释!
newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;
// 复用节点
patch(node.key);
// 操作过的节点数量 + 1
patched++;
}
}
ここでnewIndexToOldIndexMap
最初に値を取得します。
newIndexToOldIndexMap[相对坐标] = 旧列表的坐标 + 1
ここで 1 を追加する理由は、この配列の最初の要素が古いリストと新しいリストの両方に存在する場合、その座標は 0 であるため、存在するかどうかを区別するために +1 が必要であり、それがゼロ以外の数値と呼ばれることを保証する必要があると理解しています。初期の技術書を見ると-1に初期化されている場合もあるので+1する必要はありません。
これで、配列座標が得られました。次の本質は、この配列座標に従ってノードを操作することです。操作の前に、最長の昇順部分列を取得する必要があります。まあ、このアルゴリズムの実装プロセスはスキップしましょう (アルゴリズムの説明は私には適していません)。まず、なぜそれが最長の昇順部分列になるのかについて話しましょう。ノードを操作するとき、より多くのノードがその位置を変更しないようにするか、できるだけ少ないノードを変更するようにします。
取得する配列は [0, 5, 3, 0, 4, 0] で、0 (多重化されたノードではない) は無視されます。ここで、最も長い連続サブシーケンスはノード C と E に対応する [3, 4] であることが簡単にわかります。ノード D を移動するだけで済みます。
ただし、ここでの最長サブシーケンスの結果は [3, 4] の座標を返す必要があるため、結果は [2, 4] になるはずです。
// 先假设获得最长升上子序列
function getSequence(arr){
// 这里应该返回的是序列对应的下标,[3, 4] 转为下标变成[2, 4]
return [2, 4]
}
次に、ロジックを続けます。
for(let i = oldStartIndex; i < oldEndIndex; i++){
// ...
}
// 最长升上子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
// 最长升上子序列的最后一个坐标
let lastIndex = increasingNewIndexSequence.length - 1;
/**
* 由于移动元素可能会用到inertBefore方法
* 该方法需要知道后一个元素,才能插入前面一个元素
* 所以这次遍历需要从后面开始
*/
for (i = toBePatched - 1; i >= 0; i--){
if(newIndexToOldIndexMap[i] === 0) {
// 说明是新增节点
mountElement(node.key);
}else {
// 将相对坐标转为绝对(列表)坐标
const index = newStartIndex + i;
// 获得对应新列表中的节点
const node = newList[index];
// 如果没有最长上升子序列 或者 当前节点不在该子序列中,则需要移动
if(lastIndex < 0 || i !== increasingNewIndexSequence[lastIndex]) {
move(node.key)
}else {
lastIndex--;
}
}
}
newIndexToOldIndexMap
後ろから前にトラバースする:
- 0 は新しいノードを追加して先に進むことを表します
- 現在の i は 4、増加NewSequence[lastIndex] = 4、等しい、前に進む、lastIndex–
- 0 は新しいノードを追加して先に進むことを表します
- 現在の i は 2、増加NewSequence[lastIndex] = 2、等しい、前に進む、lastIndex–
- lastIndex が 0 未満であるため、ノード 5 を移動します
- 0 は、新しいノードを追加し、前に進み、ループを終了することを表します。
ここには最適化ポイントがあり、最長の昇順サブシーケンスを取得する関数は実際には非常に時間がかかりますが、場合によってはそれを使用する必要がありません。
// 是否需要获得最长升上子序列(是否需要移动节点)
let move = false;
// 子序列中最大的值
let maxNewIndexSoFar = 0;
for(let i = oldStartIndex; i < oldEndIndex; i++){
// ...
if(newIndex === undefined) {
// ...
}else {
// 判断是否有人插队(每个节点都按需递增的话)
if(newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
}else {
// 说明被插队了,得去求最长递增子序列
move = true;
}
// ...
}
}
// 获取最长子序列(可能很耗时,所以要进行优化,判断是否确定要move)
const increasingNewIndexSequence = move ? getSequence(newIndexToOldIndexMap) : [];
最終的なコードは次のようになります。
/**
* Vue3 Diff简易版
* @param {*} oldList 旧列表
* @param {*} newList 新列表
* @param {*} param2 4个函数分别代表 挂载、更新、移除、移动节点操作
*/
function diffArray(oldList, newList, {
mountElement, patch, unmount, move }) {
// 节点是否可以复用
function isSameVnodeType(node1, node2) {
return node1.key === node2.key;
}
let i = 0;
// 旧列表的长度
const oldLen = oldList.lenght;
// 新列表的长度
const newLen = newList.lenght;
// 旧列表尾巴下标
const oldEndIndex = oldLen - 1;
// 新列表尾巴下标
const newEndIndex = newLen - 1;
// 1. 从头遍历新旧列表,找出相同的节点
while(i < oldLen && i < newLen) {
const oldNode = oldList[i];
const newNode = newList[i];
if(isSameVnodeType(oldNode, newNode)) {
patch(oldNode.key);
i++;
}else {
break;
}
}
// 2.从尾遍历新旧列表,找出相同的节点
while(i <= oldEndIndex && i <= newEndIndex){
const oldNode = oldList[oldEndIndex];
const newNode = newList[newEndIndex];
if(isSameVnodeType(oldNode, newNode)) {
patch(oldNode.key);
oldEndIndex--;
newEndIndex--
}else {
break;
}
}
// 3.新增节点
if( i > oldEndIndex && i <= newEndIndex ){
while(i <= newEndIndex) {
const newNode = newList[i];
mountElement(newNode.key);
i++;
}
}
// 4.删除节点
else if(i <= oldEndIndex && i > newEndIndex) {
while(i <= oldEndIndex) {
const delNode = oldList[i];
unmount(delNode.key);
i++;
}
}
else {
// 新旧节点不同的起点坐标
const oldStartIndex = i;
const newStartIndex = i;
// 5.1 根据数组的特点,将新列表中的key和index做成映射关系
const keyToNewIndexMap = new Map();
for(let i = newStartIndex; i < newEndIndex; i++) {
const node = newList[i];
keyToNewIndexMap.set(node.key, i);
}
// 新列表中需要操作的节点数量(上述例子就是6个节点)
const toBePatched = newEndIndex - newStartIndex + 1;
// 已经操作过的节点数量
let patched = 0;
// 先将这个参数简单理解为,新列表的节点与旧列表节点的映射,如果存在就非0,如果不存在就是0
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
// 是否需要获得最长升上子序列(是否需要移动节点)
let moved = false;
// 子序列中最大的值
let maxNewIndexSoFar = 0;
for(let i = oldStartIndex; i < oldEndIndex; i++) {
const node = oldList[i];
// 如果需要操作的节点数量已经小于操作过的节点,说明旧列表中这些节点是木有用的,需要卸载
if(patched >= toBePatched) {
unmount(node.key);
continue;
}
// 接下来根据oldNode中的key,来看看keyToNewIndexMap是否有对应的下标,如果有,那说明旧节点在新列表中被复用啦,否则就得删除
// Vue3源码中有一段是处理节点没有key的情况,这里就不写啦,这里默认我们的节点都是有key的
const newIndex = keyToNewIndexMap.get(node.key);
if(newIndex === undefined) {
// 旧节点在新列表中没有被复用,直接卸载
unmount(node.key);
}else {
// 判断是否有人插队(每个节点都按需递增的话)
if(newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
}else {
// 说明被插队了,得去求最长递增子序列
moved = true;
}
// 如果在旧列表中找到可以复用的节点,那么更新newIndexToOldIndexMap[相对坐标] = 旧列表的坐标 + 1
newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;
// 复用节点
patch(node.key);
// 操作过的节点数量 + 1
patched++;
}
}
// 获取最长子序列(可能很耗时,所以要进行优化,判断是否确定要move)
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
let lastIndex = increasingNewIndexSequence.length - 1;
/**
* 由于移动元素可能会用到inertBefore方法
* 该方法需要知道后一个元素,才能插入前面一个元素
* 所以这次遍历需要从后面开始
*/
for(let i = toBePatched - 1; i > 0; i--) {
if(newIndexToOldIndexMap[i] === 0) {
// 判断节点是不是新增的,不能被复用,即新增节点
mountElement(node.key);
}else {
// 将相对坐标转为绝对(列表)坐标
const index = newStartIndex + i;
// 获得对应新列表中的节点
const node = newList[index];
// 如果没有最长上升子序列 或者 当前节点不在该子序列中,则需要移动
if(lastIndex < 0 || i !== increasingNewIndexSequence[lastIndex]) {
move(node.key)
}else {
lastIndex--;
}
}
}
}
}