Hola a todos, soy Casson.
Cualquier 虚拟DOM
marco que dependa de él necesita un algoritmo para comparar los cambios de los nodos antes y después.Diff
Hay muchos artículos en Internet que explican Diff
la lógica de los algoritmos. Sin embargo, incluso si el lenguaje del autor es más refinado y las imágenes y los textos son ricos, creo que la mayoría de los estudiantes lo olvidarán después de leerlo durante mucho tiempo.
Hoy, cambiamos a un enfoque de aprendizaje de una vez por todas: el algoritmo React
central de la implementación.Diff
No es difícil, solo 40 líneas de código. ¿No creen? Mira abajo.
Bienvenido a unirse al grupo de investigación del marco front-end humano de alta calidad , con vuelo
Idea de diseño del algoritmo Diff
Imagínese, Diff
¿cuántas situaciones debe considerar el algoritmo? Hay aproximadamente tres tipos:
- Cambios en los atributos de los nodos, como:
// 更新前
<ul>
<li key="0" className="before">0</li>
<li key="1">1</li>
</ul>
// 更新后
<ul>
<li key="0" className="after">0</li>
<li key="1">1</li>
</ul>
复制代码
- Adiciones y eliminaciones de nodos, como:
// 更新前
<ul>
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
</ul>
// 更新后 情况1 —— 新增节点
<ul>
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
<li key="3">3</li>
</ul>
// 更新后 情况2 —— 删除节点
<ul>
<li key="0">0</li>
<li key="1">1</li>
</ul>
复制代码
- Movimiento de nodos, tales como:
// 更新前
<ul>
<li key="0">0</li>
<li key="1">1</li>
</ul>
// 更新后
<ul>
<li key="1">1</li>
<li key="0">0</li>
</ul>
复制代码
¿Cómo se debe diseñar el algoritmo Diff
? Teniendo en cuenta solo las tres situaciones anteriores, una idea de diseño común es:
- Primero determine a qué situación pertenece el nodo actual
- Si es una adición o una supresión, ejecutar la lógica de adición y supresión
- Si se trata de un cambio de atributo, ejecute la lógica de cambio de atributo
- Si es un movimiento, ejecuta la lógica de movimiento.
De acuerdo con este esquema, en realidad hay una premisa implícita: la prioridad de las diferentes operaciones es la misma . Sin embargo, en el desarrollo diario, el movimiento de nodos ocurre con menos frecuencia, por lo que el Diff
algoritmo priorizará otras situaciones.
Según este concepto, los Diff
algoritmos de los marcos principales (React, Vue) pasarán por múltiples rondas de recorrido, lidiando primero con situaciones comunes y luego con situaciones poco comunes .
Por lo tanto, esto requiere algoritmos que se ocupen de casos poco comunes para poder dominar varios límites case
.
换句话说,完全可以仅使用处理不常见情况的算法完成Diff
操作。主流框架之所以没这么做是为了性能考虑。
本文会砍掉处理常见情况的算法,保留处理不常见情况的算法。
这样,只需要40行代码就能实现Diff
的核心逻辑。
Demo介绍
首先,我们定义虚拟DOM
节点的数据结构:
type Flag = 'Placement' | 'Deletion';
interface Node {
key: string;
flag?: Flag;
index?: number;
}
复制代码
key
是node
的唯一标识,用于将节点在变化前、变化后关联上。
flag
代表node
经过Diff
后,需要对相应的真实DOM
执行的操作,其中:
-
Placement
对于新生成的node
,代表对应DOM
需要插入到页面中。对于已有的node
,代表对应DOM
需要在页面中移动 -
Deletion
代表node
对应DOM
需要从页面中删除
index
代表该node
在同级node
中的索引位置
注:本Demo
仅实现为node标记flag,没有实现根据flag执行DOM操作。
我们希望实现的diff
方法,接收更新前
与更新后
的NodeList
,为他们标记flag
:
type NodeList = Node[];
function diff(before: NodeList, after: NodeList): NodeList {
// ...代码
}
复制代码
比如对于:
// 更新前
const before = [
{key: 'a'}
]
// 更新后
const after = [
{key: 'd'}
]
// diff(before, after) 输出
[
{key: "d", flag: "Placement"},
{key: "a", flag: "Deletion"}
]
复制代码
{key: "d", flag: "Placement"}
代表d对应DOM
需要插入页面。
{key: "a", flag: "Deletion"}
代表a对应DOM
需要被删除。
执行后的结果就是:页面中的a变为d。
再比如:
// 更新前
const before = [
{key: 'a'},
{key: 'b'},
{key: 'c'},
]
// 更新后
const after = [
{key: 'c'},
{key: 'b'},
{key: 'a'}
]
// diff(before, after) 输出
[
{key: "b", flag: "Placement"},
{key: "a", flag: "Placement"}
]
复制代码
由于b
之前已经存在,{key: "b", flag: "Placement"}
代表b对应DOM
需要向后移动(对应parentNode.appendChild
方法)。abc
经过该操作后变为acb
。
由于a
之前已经存在,{key: "a", flag: "Placement"}
代表a对应DOM
需要向后移动。acb
经过该操作后变为cba
。
执行后的结果就是:页面中的abc变为cba。
Diff算法实现
核心逻辑包括三步:
-
遍历前的准备工作
-
遍历
after
-
遍历后的收尾工作
function diff(before: NodeList, after: NodeList): NodeList {
const result: NodeList = [];
// ...遍历前的准备工作
for (let i = 0; i < after.length; i++) {
// ...核心遍历逻辑
}
// ...遍历后的收尾工作
return result;
}
复制代码
遍历前的准备工作
我们将before
中每个node
保存在以node.key
为key
,node
为value
的Map
中。
这样,以O(1)
复杂度就能通过key
找到before
中对应node
:
// 保存结果
const result: NodeList = [];
// 将before保存在map中
const beforeMap = new Map<string, Node>();
before.forEach((node, i) => {
node.index = i;
beforeMap.set(node.key, node);
})
复制代码
遍历after
当遍历after
时,如果一个node
同时存在于before
与after
(key
相同),我们称这个node
可复用。
比如,对于如下例子,b是可复用的:
// 更新前
const before = [
{key: 'a'},
{key: 'b'}
]
// 更新后
const after = [
{key: 'b'}
]
复制代码
对于可复用的node
,本次更新一定属于以下两种情况之一:
-
不移动
-
移动
如何判断可复用的node
是否移动呢?
我们用lastPlacedIndex
变量保存遍历到的最后一个可复用node在before中的index:
// 遍历到的最后一个可复用node在before中的index
let lastPlacedIndex = 0;
复制代码
当遍历after
时,每轮遍历到的node
,一定是当前遍历到的所有node
中最靠右的那个。
Si esto node
es 可复用的node
, entonces hay dos relaciones nodeBefore
con lastPlacedIndex
:
Nota:
nodeBefore
representa la correspondencia correspondiente en可复用的node
elbefore
node
nodeBefore.index < lastPlacedIndex
Representa que debe node
estar a la lastPlacedIndex对应node
izquierda antes de actualizar.
Después de la actualización, no node
debería estar a la lastPlacedIndex对应node
izquierda (porque es el más a la derecha de todos los nodos actualmente atravesados ).
Esto significa que es node
hora de moverse hacia la derecha y necesita estar marcado Placement
.
nodeBefore.index >= lastPlacedIndex
Debe node
estar en su lugar, no es necesario moverlo.
// 遍历到的最后一个可复用node在before中的index
let lastPlacedIndex = 0;
for (let i = 0; i < after.length; i++) {
const afterNode = after[i];
afterNode.index = i;
const beforeNode = beforeMap.get(afterNode.key);
if (beforeNode) {
// 存在可复用node
// 从map中剔除该 可复用node
beforeMap.delete(beforeNode.key);
const oldIndex = beforeNode.index as number;
// 核心判断逻辑
if (oldIndex < lastPlacedIndex) {
// 移动
afterNode.flag = 'Placement';
result.push(afterNode);
continue;
} else {
// 不移动
lastPlacedIndex = oldIndex;
}
} else {
// 不存在可复用node,这是一个新节点
afterNode.flag = 'Placement';
result.push(afterNode);
}
复制代码
Terminando el trabajo después del recorrido.
Después del recorrido, si beforeMap
quedan algunos node
, significa que node
no se pueden reutilizar y deben marcarse para su eliminación.
Por ejemplo, en los siguientes casos, after
después , beforeMap
aún quedan {key: 'a'}
:
// 更新前
const before = [
{key: 'a'},
{key: 'b'}
]
// 更新后
const after = [
{key: 'b'}
]
复制代码
Esto significa que a
debe marcarse para su eliminación.
Entonces, finalmente, debe agregar la lógica de marcar la eliminación:
beforeMap.forEach(node => {
node.flag = 'Deletion';
result.push(node);
});
复制代码
Para obtener el código completo, consulte la dirección de demostración en línea
Resumir
Diff
La dificultad de todo el algoritmo radica en la lastPlacedIndex
lógica relevante.
Después de depurar Demo
varias veces, creo que puedes entender el principio.