40 líneas de código para implementar el algoritmo React core Diff

Hola a todos, soy Casson.

Cualquier 虚拟DOMmarco 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 Diffla 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 Reactcentral 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:

  1. 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>
复制代码
  1. 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>
复制代码
  1. 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:

  1. Primero determine a qué situación pertenece el nodo actual
  2. Si es una adición o una supresión, ejecutar la lógica de adición y supresión
  3. Si se trata de un cambio de atributo, ejecute la lógica de cambio de atributo
  4. 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 Diffalgoritmo priorizará otras situaciones.

Según este concepto, los Diffalgoritmos 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;
}
复制代码

keynode的唯一标识,用于将节点在变化前、变化后关联上。

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算法实现

核心逻辑包括三步:

  1. 遍历前的准备工作

  2. 遍历after

  3. 遍历后的收尾工作

function diff(before: NodeList, after: NodeList): NodeList {
  const result: NodeList = [];

  // ...遍历前的准备工作

  for (let i = 0; i < after.length; i++) {
    // ...核心遍历逻辑
  }

  // ...遍历后的收尾工作

  return result;
}
复制代码

遍历前的准备工作

我们将before中每个node保存在以node.keykeynodevalueMap中。

这样,以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同时存在于beforeafterkey相同),我们称这个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 nodees 可复用的node, entonces hay dos relaciones nodeBeforecon lastPlacedIndex:

Nota: nodeBeforerepresenta la correspondencia correspondiente en 可复用的nodeelbeforenode

  • nodeBefore.index < lastPlacedIndex

Representa que debe nodeestar a la lastPlacedIndex对应nodeizquierda antes de actualizar.

Después de la actualización, no nodedebería estar a la lastPlacedIndex对应nodeizquierda (porque es el más a la derecha de todos los nodos actualmente atravesados ).

Esto significa que es nodehora de moverse hacia la derecha y necesita estar marcado Placement.

  • nodeBefore.index >= lastPlacedIndex

Debe nodeestar 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 beforeMapquedan algunos node, significa que nodeno se pueden reutilizar y deben marcarse para su eliminación.

Por ejemplo, en los siguientes casos, afterdespués , beforeMapaún quedan {key: 'a'}:

// 更新前
const before = [
  {key: 'a'},
  {key: 'b'}
]
// 更新后
const after = [
  {key: 'b'}
]
复制代码

Esto significa que adebe 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

DiffLa dificultad de todo el algoritmo radica en la lastPlacedIndexlógica relevante.

Después de depurar Demovarias veces, creo que puedes entender el principio.

Supongo que te gusta

Origin juejin.im/post/7086634898953338911
Recomendado
Clasificación