深入理解 React 虚拟 DOM- 加速你的前端性能

393 阅读5分钟

Abstract

在 WEB 应用程序中,文档对象模型(DOM)是用于描绘 UI 的核心部分。然而,对真实 DOM 的频繁修改可能会对页面性能产生负面影响,导致页面卡顿。为解决这一问题,React 引入了虚拟 DOM。通过将状态更新到虚拟 DOM 中,React 可以通过协调(Reconciliation)更新真实 DOM,从而提高应用程序的性能。

通过阅读本文,你将了解到更新真实 DOM 为什么性能不好,React 虚拟 DOM 工作原理。

Introduce

真实 DOM

文档对象模型(Document Object Model)是描绘页面 UI 的核心部分,允许程序和脚本动态地访问、更新文档的内容、结构和样式。通过 HTML DOM,JavaScript 能够访问和改变 HTML 文档的所有元素。DOM树可视化

image.png

更新 DOM 并不慢,就像更新任何 JavaScript 对象一样。那么,到底是什么原因让真实 DOM 的更新变慢了呢?

我们来回顾下浏览器页面渲染流程

渲染引擎将 HTML 解析成 DOM 树,CSS 解析成 CSSOM,将两者结合创建渲染树。在渲染阶段,通过重绘和重排绘制页面元素。所以,当我们执行以下代码时:

document.getElementById('elementId').innerHTML = "New Value"

渲染引擎会执行以下操作

  1. 解析 HTML;
  2. 移除 elementId 的子元素;
  3. 使用 New Value 更新 DOM;
  4. 重新计算父子元素的 CSS 样式;
  5. 更新布局;
  6. 遍历渲染树,将其绘制在屏幕上。

重绘和重排非常影响性能。假设我们执行了 10 次更新 DOM 的操作,上述步骤将重复 10 次。这就是更新真实 DOM 慢的原因。

React 虚拟 DOM

在 React 中,对于每个 DOM 对象,都有一个对应的虚拟 DOM 对象。虚拟 DOM 对象具有与真实 DOM 对象相同的属性,但不会只能绘制到浏览器上。每当应用程序中的状态发生变化,React 就会去更新对应的虚拟 DOM 对象。React 操作 DOM 流程:

  • 首先,React 建立了一个真实 DOM 的副本,虚拟 DOM。
  • 每个节点代表一个元素。如果元素状态有更新,则会创建一个新的虚拟 DOM。
  • 通过 diff 算法识别更改的差异,并对变化的节点子树进行识别。
  • 最后,通过批量更新同步更改信息到真实的 DOM。

source: GreenRoots Blog

React 虚拟 DOM 有以下两个特征:

  1. 高效的 diff 算法
  2. 批量更新

下面,我们将结合源码分析。

Methodology

高效的 diff 算法

实际上,React 维护来两个虚拟 DOM,一个是真实 DOM 的副本,另一个是具有更新状态的虚拟 DOM。React 使用 diff 算法比较虚拟 DOM 以找到更新真实 DOM 的最少步骤。找到两个树之间的最小修改次数 O(n^3)。但 React 使用了一些启发式的方法,通过层级比较和 key 比较策略,将时间复杂度优化到 O(n) souce

diff 算法步骤:

  1. 广度优先搜索(BFS)。节点 B 和 H 的状态发生了变化,ReactJS 遍历到节点 B 时,它会默认重新渲染元素 H。Source: Github
  2. 如果父节点的状态发生变化,重新渲染所有的子节点。如果一个组件的状态改变了,React 会渲染所有子组件,即使子组件没有被修改。如图所示,React 只会对相同颜色的 DOM 节点进行比较。为了防止不必要的组件的重新渲染,我们可以使用shouldComponentUpdate

Source: Media

对上述两个步骤的 React 源码分析:

function reconcileChildFibers(
returnFiber: Fiber,
 currentFirstChild: Fiber | null,
 newChild: any,
 lanes: Lanes,
): Fiber | null {
  ...
  // Handle object types
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // diff single DOM 
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
  ...
    }
​
    // diff DOM Array
    if (isArray(newChild)) {
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }
​
  ...
}

reconcileChildFibers

  • 根据 newChild 的类型进行处理
  • 返回是当前节点的第一个子节点
function reconcileSingleElement(
 returnFiber: Fiber,
 currentFirstChild: Fiber | null,
 element: ReactElement,
 lanes: Lanes,
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  while (child !== null) {
    // 判断 key
    if (child.key === key) {
      const elementType = element.type;
      // 比较 type
      if (elementType === REACT_FRAGMENT_TYPE) {
        if (child.tag === Fragment) {
          // 因为diff的是单个的Element,所以要删除兄弟节点
          deleteRemainingChildren(returnFiber, child.sibling);
          // 基于child 复制一个fiber
          const existing = useFiber(child, element.props.children);
          existing.return = returnFiber;
          ...
          return existing;
        }
      } else {
          // type不同,删除其兄弟节点
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          ...
          return existing;
        }
      }
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 删除当前节点
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
  ...
}

reconcileSingleElement

  • 传入子节点不为 null,遍历child的链表,找到 key 相同的节点,复用这个节点,删掉的兄弟节点
  • 如果 key 不同,则直接删除
function reconcileChildrenArray(
returnFiber: Fiber,
 currentFirstChild: Fiber | null,
 newChildren: Array<any>,
 lanes: Lanes,
): Fiber | null { 
  ...
  // 存储链表的第一个指针
  let resultingFirstChild: Fiber | null = null;
  let previousNewFiber: Fiber | null = null;
​
  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0;
  let newIdx = 0;
  let nextOldFiber = null;
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // 复用 fiber 节点
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes,
    );
    // 复用失败,跳出循环
    if (newFiber === null) {
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
    // 如果是新创建的 DOM,直接删除老的 DOM
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        deleteChild(returnFiber, oldFiber);
      }
    }
    ...
    // 链接服用的链表
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }
​
  // 新 DOM 遍历完毕,删除旧链表剩余节点
  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    if (getIsHydrating()) {
      const numberOfForks = newIdx;
      pushTreeFork(returnFiber, numberOfForks);
    }
    return resultingFirstChild;
  }
​
  // 旧 DOM 遍历完成,但新 DOM 还存在,新增
  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        // TODO: Move out of the loop. This only happens for the first run.
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    if (getIsHydrating()) {
      const numberOfForks = newIdx;
      pushTreeFork(returnFiber, numberOfForks);
    }
    return resultingFirstChild;
  }
​
  // 保存没有匹配到的节点
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
​
  // 从 map 中取出和 newChild 的 key 值相同的fiber,然后创建 fiber,更新属性,
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }
  ...
}

newChidren 为 Array 的情况:

  • 遍历生成
function MyComponent() {
  return list.map(i => <span key={i}>{i}</span>);
}
  • child 的父元素为 Fragment
function MyComponent() {
  return (
    <>
      <span>1</span>
      <span>2</span>
      <span>3</span>
    </>
  );
}

reconcileChildrenArray

  • 对比新旧两个 DOM 数组,通过 index 顺序和 key 判断能否复用;
  • 找到不相同的元素,跳出循环;
  • 如果新 DOM 遍历完毕,就删除旧 DOM 的剩余节点;
  • 如果旧 DOM 遍历完毕,就插入新 DOM 的剩余节点;
  • 如果都不满足,就创建一个map,保存所有没有匹配到的节点,然后根据 key 从 map 里面查找,如果找到相同的则复用,没有则新建。

批量更新

  1. React 结合 requestAnimationFramerequestIdleCallback,提出 unstable_scheduleCallback 来进行任务调度。
  2. React 制定各个任务的优先级和对应的时间,通过任务调度决定函数执行时机。
// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY = maxSigned31BitInt;
  1. 在更新虚拟 DOM 时,通过 diff 算法找到需要更新的元素,在事件循环中执行更新的步骤,批量将所有元素更新到真实 DOM 上。

对上述步骤的 React 源码分析:

function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: {delay: number},
): Task {
  var currentTime = getCurrentTime();
​
  var startTime;
  // 计算过期时间
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }
  ...
  // 生成一个 task
  var newTask: Task = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  if (enableProfiling) {
    newTask.isQueued = false;
  }
​
  // 执行延迟任务
  if (startTime > currentTime) {
    newTask.sortIndex = startTime;
    push(timerQueue, newTask);
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      if (isHostTimeoutScheduled) {
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      // Schedule a timeout.
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    newTask.sortIndex = expirationTime;
    // 插入任务
    push(taskQueue, newTask);
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }
​
  return newTask;
}

unstable_scheduleCallback

  • 根据任务优先级进行排序;
  • 计算 timeout 时间;
  • 生成一个task ,插入新任务;
  • requestHostCallback 和 requestHostTimeout 执行任务。
function requestHostCallback(
  callback: (hasTimeRemaining: boolean, initialTime: number) => boolean,
) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

requestHostCallback

  • 将传入的 flushwork 赋值给 scheduledHostCallback
  • scheduledHostCallback 根据是否有剩余时间、当前时间、任务链表第一个任务的过期时间来决定当前帧是否执行任务

注:scheduledHostCallback 的执行是通过消息 MessageChannel 来执行,本文不做展开

function flushWork(hasTimeRemaining: boolean, initialTime: number) {
  ...
  const previousPriorityLevel = currentPriorityLevel;
  try {
    if (enableProfiling) {
      try {
        // 执行
        return workLoop(hasTimeRemaining, initialTime);
      } catch (error) {
        if (currentTask !== null) {
          const currentTime = getCurrentTime();
          // $FlowFixMe[incompatible-call] found when upgrading Flow
          markTaskErrored(currentTask, currentTime);
          // $FlowFixMe[incompatible-use] found when upgrading Flow
          currentTask.isQueued = false;
        }
        throw error;
      }
    } else {
      // 执行
      return workLoop(hasTimeRemaining, initialTime);
    }
  } finally {
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
    if (enableProfiling) {
      const currentTime = getCurrentTime();
      markSchedulerSuspended(currentTime);
    }
  }
}
function workLoop(hasTimeRemaining: boolean, initialTime: number) {
  let currentTime = initialTime;
  // 检查不再延迟的任务,加入任务队列
  advanceTimers(currentTime);
  // 取任务
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    // 当前任务过期时间大于当前时间
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      break;
    }
    // 执行任务
    ...
    currentTask = peek(taskQueue);
  }
  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
    // 当前帧还有空闲时间,继续执行
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

flushWorkwookLoop

  • 根据任务队列第一个任务的过期时间来决定当前帧是否执行任务;
  • 任务过期的话就会把任务链表内过期的任务都执行一遍直到没有过期任务或者没有任务;
  • 任务没过期的话,则会在当前帧过期之前尽可能多的执行任务,如果还有任务(hasMoreWork)就继续调度;

Conclusion

React 通过引入虚拟 DOM,解决了因频繁操作真实 DOM 造成的性能问题。React 维护了一颗真实 DOM 的副本和更新的虚拟 DOM,通过优化 diff 算法从 O(n^3) 优化到 O(n) 并结合批量更新机制来提高前端性能,避免频繁操作 DOM 造成的页面卡顿问题。

Referrence

[1]  www.w3school.com.cn/js/js_htmld…

[2]  taligarsiel.com/Projects/ho…

[3]  developer.mozilla.org/zh-CN/docs/…

[4]  medium.com/@happymishr…

[5]  www.codecademy.com/article/rea…

[6]  blog.greenroots.info/reactjs-vir…

[7]  reactjs.org/docs/optimi…

[8]  medium.com/@happymishr…

[9]  github.com/facebook/re…

[10]  github.com/facebook/re…

[11]  github.com/facebook/re…

[12]  developer.mozilla.org/en-US/docs/…

[13]  developer.mozilla.org/en-US/docs/…

[14]  github.com/facebook/re…

[15]  github.com/facebook/re…

[16]  juejin.cn/post/695380…

[17]  github.com/facebook/re…

[18]  github.com/facebook/re…

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情