Code前端首页关于Code前端联系我们

Diff算法的设计思想 VS 40行代码实现React核心Diff算法

terry 2年前 (2023-09-27) 阅读数 68 #数据结构与算法
Diff算法的设计思路 VS 40行代码实现React核心Diff算法

来源:魔术师卡颂(ID:gh_52d0bec584f9)

任何依赖于DOM虚拟需求的框架❝》对比《节点变化前后》Diff算法。

网上解释❀❝算法逻辑的文章很多,无论作者的语言多么简洁,图片多么雄辩

今天我们改用一劳永逸的学习方法——实现Diff算法React.

不难,只有40行代码,你不觉得吗?往下看。由Juithm st设计,想象一下Diff算法要考虑多少种情况?大概有三种类型,即:

  1. 节点属性的变化,如:
// 更新前
<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. 如:节点的添加和删除,节点移动,如:
// 更新前
<ul>
  <li key="0">0</li>
  <li key="1">1</li>
</ul>

// 更新后
<ul>
  <li key="1">1</li>
  <li key="0">0</li>
</ul>

如何设计Diff算法?鉴于只有以上三种情况是常见的设计思路:

  1. 首先判断当前节点属于哪种情况
  2. 如果是增删则执行增删逻辑
  3. 如果是属性改变,执行属性改变逻辑
  4. 如果是移动,执行移动逻辑

根据这个方案,其实有一个隐含的假设——“不同操作的优先级是相同的” 。但在日常开发中,“结运动”发生的频率较低,因此Diff算法会优先考虑其他情况。

基于这个理念,主流框架(React、Vue)的Diff算法会经过几轮的pass,先处理后处理,后处理 “异常情况”。?

也就是说,只需要使用“tumm操作”,完全可以完成Diffmon操作”

。常规框架不这样做的原因是出于性能原因。

本文将砍掉“处理常见情况的算法”并保留❝❝❀❝动作.

这样只需要40行代码就可以实现Diff的核心逻辑。

演示介绍

首先我们定义虚拟DOM节点的数据结构:

type Flag = 'Placement' | 'Deletion';

interface Node {
  key: string;
  flag?: Flag;
  index?: number;
}

keydeis 。节点变化前后变化是相关的。

flag表示Diff❀❝遍历后,对对应的真实DOM进行操作Diff❀❝遍历Location对于新生成的节点 ,必须在页面上插入相应的DOM。对于现有的节点DOM对应的代表必须在页面上移动。

  • 删除代表节点
  • 对应DOM必须从删除页面下载※❝的索引位置

    节点

    中同级节点注:本Demo
    function diff(before: NodeList, after: NodeList): NodeList {
      const result: NodeList = [];
    
      // ...遍历前的准备工作
    
      for (let i = 0; i < after.length; i++) {
        // ...核心遍历逻辑
      }
    
      // ...遍历后的收尾工作
    
      return result;
    }
    

    仅实现了标志为“”,“根据标志执行DOM操作” 是未实现。

    我们希望实现diff方法来在更新

    之前接收和在更新ist之后接收,标记它们旗帜
    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: "Location"} 表示 d 对应DOMs,必须在第 页面提交。

    {key: "a", flag: "Delete"}表示DOM对应的a必须删除。

    执行后结果为:页面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: “Location”♝ 对应 b❝ DOM 必须向后移动(对应方法 parentNode.appendChild)。执行此操作后,abc 变为 acb

    由于 a 之前已经存在,{key: "a", flag: "Location"} 代表 和 DOM 需要向后移动。执行此操作后,acb 更改为 cba

    执行后的结果是:页面上的abc变为cba。

    不同算法的实现

    核心逻辑包括三个步骤:

    1. 遍历前的准备工作
    2. 遍历❀下班后❀下班后❀遍历前的修复工作

      我们拯救每一个node 位于 之前 中,位于 node.key,因为 ❝ node

    价值地图中。

    这样,以O(1)的复杂度,我们就可以在❝❝❝

    function diff(before: NodeList, after: NodeList): NodeList {
      const result: NodeList = [];
    
      // ...遍历前的准备工作
    
      for (let i = 0; i < after.length; i++) {
        // ...核心遍历逻辑
      }
    
      // ...遍历后的收尾工作
    
      return result;
    }
    

    中找到对应的节点:
    // 保存结果
    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中同时存在节点after(同) ,我们称之为 node 可重用。

    例如下面的例子中,b是可复用的:

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

    对于可复用的节点来说,本次更新必然属于以下两种情况之一: ❀未移动♝ 如何判断伙计,如果可重复使用的节点已被移动? ? 时间,每一轮穿过的节点必须是当前穿过的所有节点中最右边的一个。

    如果这个节点是一个可复用节点,那么之前是相同的lastPlacedIndex 有两种关系:

    注意:nodeBefore 表示对应的node

    • nodeBefore.index lastPlacedIndex对应的node

      更新后,节点不在lastPlacedIndex对应的节点左侧,不是所有“当前遍历到的”)。

      这意味着节点已移至右侧,必须标记为位置

      • nodeBefore.index >= lastPlacedIndex

      Node 已就位,不得移动。

      // 遍历到的最后一个可复用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);
      }
      

      遍历结束工作

      遍历结束后,如果Map❝之前的还剩下节点,则代表❝无disse❝不可复用d 并且必须标记为删除。

      例如下面的情况,遍历完after后,ap中的中仍然有{key: 'a'}

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

      这意味着a 必须标记为删除。

      所以,最后,添加标签删除逻辑:

      beforeMap.forEach(node => {
        node.flag = 'Deletion';
        result.push(node);
      });
      

      完整代码请参见在线演示地址 [1]

      总结 ♼❝ Diff

    算法为 last放置索引相关逻辑。

    按照Demo多调试几次。我想你能明白其中的原理。

    参考资料

    [1]在线演示地址:https://codesandbox.io/s/naughty-matan-6fq7n6?file=/src/diff.ts

    版权声明

    本文仅代表作者观点,不代表Code前端网立场。
    本文系作者Code前端网发表,如需转载,请注明页面地址。

    热门