React源码中使用的数据结构

主要说一说React中使用特定数据结构实现的功能以及内部工作原理。代码示例基本都是简化后的源码,只保留了关键逻辑,同时会附带源代码链接。 React版本为17.0.2。

开始前需要了解

  • ReactElements:JSX返回的对象,每次render之后都会重新创建,后面叫它元素

  • Fiber Nodes:根据一个元素创建的对象,元素携带的信息会被合并到fiber中,不像元素,fiber每次render后不会重新创建,它是可变的,在react中承担virtualDOM的职责。 基于Fiber架构,React实现了如异步可中断的渲染、调度多个不同优先级的任务等看似很“魔幻”的特性,后面会讲到这两个特性实现的细节。

  • Update:在React中触发更新的方式只有两种,渲染root、调用setState。以setState举例,update是指调用setState创建的更新对象,记录待更新的值等信息,这个对象拥有一个lane属性,这个属性之后会被合并到fiber.lanes属性。

  • Reconciliation:对比新旧树的算法更新后需要对比新旧树的差异,来找出需要渲染的部分,值得一提的是源码中是使用旧fiber树和新元素树进行对比的。

  • Lanes:优先级模型,Fiber架构的一环,之前提到的调度多个不同优先级的任务就是基于它来实现的。lane是update对象的一个属性,是根据产生更新的“动作类型”来分配给update对象的优先级。 例如一个组件在componentDidMount和click事件中,各自调用了setState,这就会产生两个lane属性不同的update对象,click事件产生的lane优先级就会比componentDidmMount产生的更高,目的是尽快完成用户输入产生的更新。update.lane之后会被合并到fiber.lanes,代表某个fiber上所有未更新lane的集合(包含多个lanes的情况我们之后再说)。每次调用setState产生的update对象都有lane,代表本次更新的优先级。lane的类型非常多,有兴趣了解更多细节话可以看这个PR

  • Root:根fiber节点,包含所有fiber节点产生的lanes:比如一个root包含ABfiber,fiberA.lanes为011、fiberB.lanes为100,fiberRoot.pendingLanes就是111。

链表和Fiber树

像数组一样,链表也是一种线性结构,但不同于数组,链表中的元素在内存中并不是连续的,链表中的元素除了自身的值,还拥有一个指针属性,指向下一个元素。相对于传统的数组,链表的一个好处在于,添加或移除元素的时候不需要移动其他元素。

Linked List

fiber树就是一个链表,每个fiber对象都是一个链表节点,拥有return、sibling、child三个指针,分别指向父节点、兄弟节点和首个子节点。

为什么要将fiber设计成链表节点呢?React基于Fiber架构实现了异步可中断渲染:定期让出线程(暂停),从让步的进度继续完成,保证渲染不会阻塞线程太久。 举个例子,在正在渲染10000个元素,这个时候如果尝试在输入框输入内容或点击元素等,界面是完全没有反应的,因为这时线程一直在执行React源码(更新,创建fiber什么的)。 如果在渲染第100个元素时正好让出线程,这时候再有用户输入,由于线程是空闲的,所以可以响应输入,反馈输入后再从中断的第100个fiber继续剩下的更新。

显而易见的是,每次渲染都是从发起更新的节点开始处理一整条路径(对树进行深度遍历),很容易就可以想到递归,不过递归虽然可以中断,无法知道当前任务进度,更不可能实现从中断处恢复进度。Andrew Clark对Fiber架构的解释

所以使用某种数据结构来代替原本的递归调用栈,将处理每个节点的操作拆解为细小的单元,是实现可中断的第一步,之后才能进一步实现恢复进度,使用链表来表示这个树状的单元集合很明显比多维数组更加直观。

源码中的workLoopConcurrent会一直使用wip这个指针来遍历fiber树,同时beginWork和completeUnitOfWork会分别模拟递归入栈、出栈的行为。beginWork会返回子节点,complete会返回兄弟节点,或返回null并对父节点做完成操作,使用链表数据结构,可以随意的通过wip指针从中断处恢复未完成的工作。

function workLoopConcurrent() {
  // 循环fiber树,workInProgress代表处理中的fiber
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork: Fiber): void {
  // performUnitOfWork根据元素类型的不同,执行的逻辑也不同,比如调用函数组件,实例化类组件,更新context的值等。
  const current = unitOfWork.alternate;
  let next;
  // 相当于递归入栈,主要是检查是否需要更新,执行对应类型元素的初始化,reconciliation等。
  next = beginWork(current, unitOfWork, subtreeRenderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // 这条路径处理完了(没有子节点了),开始执行出栈的逻辑。
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}
复制代码

栈和Context

一种遵循后进先出原则(LIFO)的线性结构。

栈在react源码中的使用很多,这里挑一个我认为比较有代表性的:在contextAPI中的应用。

首先我们来看看React文档介绍context的一个特性:“一个Provider可以和多个消费组件有对应关系。多个Provider也可以嵌套使用,里层的会覆盖外层的数据。”

const MyContext = React.createContext(null);

const Content = () => (
  <MyContext.Consumer>
    {value => `The ${value} layer.`}
  </MyContext.Consumer>
);

const Demo = () => (
  <>
    <MyContext.Provider value="outer">
      <Content />
      {/* 外层第一个Content为outer */}
      <MyContext.Provider value="inner">
        <Content />
        {/* 里层的会覆盖外层的数据的特性,Content为inner */}
      </MyContext.Provider>
      <Content />
      {/* 外层第二个Content为outer */}
    </MyContext.Provider>
  </>
);
复制代码

了解这个特性之后,再看看问题,之前说过更新组件树的顺序是深度遍历:

外层Provider -> 外层第一个Content -> 内层Provier -> 内层Content -> 外层第二个Content
复制代码

问题在于需要在处理完内层Provider一条路径后,再将value回溯到外层Provider,value为outer的状态。 很容易想到的解决方案是让Consumer和Provider实例关联,让其可以直接获取到正确的值,不过这样做可能需要很大的代价让Provider去查找对应的Consumer。

React源码是这样实现的: 声明全局变量valueStack、valueCursor、index,分别代表储存contextValue的栈,当前contextValue的值、当前context在栈中的索引。

所有的context实例,都使用这三个全局变量。

在遍历fiber树时,遇到Provider类型fiber就会执行pushProvider函数:增加索引,改变指针,将上层context(如果这个Provider上层还有Provider的话)的value存到stack中(下层如果有Provider,保证其能获取到本次的值,回溯的关键),更新cursor指向本次的value,最后更新context实例的value。

当处理完这条路径的所有节点,也就是出栈时执行popProvider函数,以这个例子来说,处理完innerContext这条路径之后就要回溯处理外层第二个Context,popProvider做了一些恢复操作,保证随后执行的第三次pushProvider获取到正确的contextValue。

export function pushProvider(context) {
  index++;

  valueStack[index] = valueCursor.current;

  valueCursor.current = value;

  context._currentValue = nextValue;
}

export function popProvider(context) {
  // cursor指向的是上次的值,根据是否嵌套使用Provider指向上层contextValue或已经赋值为null
  const currentValue = valueCursor.current;
  valueCursor.current = valueStack[index];
  // 删除栈中这个context的值
  valueStack[index] = null;
  index--;
  // 更新context实例的值
  context._currentValue = currentValue;
}
复制代码

其实使用栈来实现记录context变化和之前的使用链表实现fiber树很像,一样在使用数据结构来替代原本的递归调用栈,以便可以手动控制栈中的值。

二叉堆

二叉堆是满足以下两个特性的二叉树。

  1. 一棵完全二叉树,树的每一层都有左右子节点(除了最后一层的叶节点),并且最后一层的叶节点尽可能都是左侧子节点。二叉堆的这个特性使它很适合使用数组表示。
  2. 二叉堆不是最小堆就是最大堆。最小堆中,堆顶的节点就是最小的。所有的节点都小于等于每个它的子节点。最大堆和最小堆相反。

下面两个堆都是合法的:

            10                      10
         /      \               /       \  
       20        100          15         30  
      /                      /  \        /  \
    30                     40    50    100   40
复制代码

二叉堆可以用数组来表示:

Binary Heap

  • 堆顶元素在索引0的位置
  • 以i表示为自身索引,(i - 1) / 2指向父节点
  • (2 * i) + 1指向左子节点
  • (2 * i) + 2指向右子节点

堆的操作

siftUp():上移操作,将这个节点和它的父节点进行交换,直到父节点小于这个值,时间复杂度为 O(Logn),如果父节点不小于它,就不需要操作,因为剩余部分是满足堆特性的。

function siftUp(heap, node, i) {
  let index = i;
  while (index > 0) {
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    if (compare(parent, node) > 0) {
      // The parent is larger. Swap positions.
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // The parent is smaller. Exit.
      return;
    }
  }
}
复制代码

siftDown():下移操作,将这个节点与它的左右子节点交换,直到子节点大于它,时间复杂度为 O(Logn)。

function siftDown(heap, node, i) {
  let index = i;
  const length = heap.length;
  const halfLength = length >>> 1;
  while (index < halfLength) {
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    // If the left or right node is smaller, swap with the smaller of those.
    if (compare(left, node) < 0) {
      if (rightIndex < length && compare(right, left) < 0) {
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    } else if (rightIndex < length && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      // Neither child is smaller. Exit.
      return;
    }
  }
}
复制代码

push():向堆中插入值,时间复杂度为 O(Logn),因为插入元素后要维持二叉堆的特性,需要对新插入的元素执行siftUp操作。

export function push(heap: Heap, node: Node): void {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}
复制代码

pop():移除堆顶元素,再将末尾元素放到堆顶,时间复杂度为 O(Logn),因为移除元素后要维持二叉堆的特性,需要对堆顶元素执行siftDown操作。

export function pop(heap: Heap): Node | null {
  if (heap.length === 0) {
    return null;
  }
  const first = heap[0];
  const last = heap.pop();
  if (last !== first) {
    heap[0] = last;
    siftDown(heap, last, 0);
  }
  return first;
}
复制代码

Scheduler

众所周知setState有批量更新的行为,就实现来说,setState产生update对象之后并不是立即去更新,而是将准备执行的任务存放到一个任务队列中,所以setState并没有执行任何更新,只是记录一些待处理的信息,再将一次更新要执行的任务放到一个任务队列中。

真正执行(调度)任务的是Schduler模块,一个职责单一的调度器,作为代码库设计,不与React耦合。Scheduler的主要功能包含:排序任务队列中的任务,挑选优先级最高的任务以异步的方式执行、在线程空闲时唤起未完成的任务(在React中的体现就是workLoop的中断)。

React会去调用Scheduler的scheduleCallback函数,将performConcurrentWorkOnRoot函数注册到scheduler的taskQueue,之后这个任务什么时候该被执行就交给Scheduler了,React不再负责。

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = getCurrentTime();

  var startTime = currentTime;

  var timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }
  // 过期时间,如果过期了会把这个任务放到队首,各优先级的过期时间也不同。
  var expirationTime = startTime + timeout;
  var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  // sortIndex是队列给任务排序的参照,可看出是基于过期时间设置的,越小就越优先(距当前时间最近)。
  newTask.sortIndex = expirationTime;
  // 操作二叉堆
  push(taskQueue, newTask);
  // 这里调用栈很深,没做什么关键的事,主要就是一直调用`workLoop`函数(Scheduler和React中有同名函数)尝试清空任务队列。
  requestHostCallback(flushWork);

  return newTask;
}
复制代码
function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // 如果没有过期,这里也会调用shouldYieldToHost让出线程
      break;
    }
    const callback = currentTask.callback;
    currentTask.callback = null;
    currentPriorityLevel = currentTask.priorityLevel;
    const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
    // React的`performConcurrentWorkOnRoot`函数会接受Scheduler传过来的是否过期标识,如果已经过期,React会发起不可中断的更新

    // `performConcurrentWorkOnRoot`函数还会根据React是否已经处理完全部fiber来给Scheduler返回自身或者null
    // 中断的情况需要通知Scheduler不要将自己从任务队列中清除,等待机会继续执行这个任务
    const continuationCallback = callback(didUserCallbackTimeout);
    if (typeof continuationCallback === 'function') {
      // 将这个task对象的callback设置为返回的函数
      currentTask.callback = continuationCallback;
    } else {
      // 任务结束,移出队列
      if (currentTask === peek(taskQueue)) {
        pop(taskQueue);
      }
    }
    currentTask = peek(taskQueue);
  }
  if (currentTask !== null) {
    // 返回队列中还是否有任务的标识
    return true;
  } else {
    return false;
  }
}
复制代码

最后说一说调度多个优先级不同的任务

之前我们提到过一个fiber.lanes属性可能包含多种不同优先级的lane,这种情况是最常见的:workLoop让步时(存在未完成的任务),某组件产生了一次新的更新,这种情况下fiber.lanes就包含两种lane,它已有的和新产生的。

如果一个fiber有多个lanes,React就需要挑选出优先级最高的部分更新来执行。 现在假设已经存在的任务是将state设为A,新任务是将state设为B,会有已存在的更优先和新的更优先两种情况:

已存在任务优先级更高的情况,继续执行将设为state为A的任务,state为B的更新部分会被跳过,内部实际上是这样工作的:每次执行更新时都对比当前已经存在的lanes和新的未处理lanes(root.pendingLanes),这些低优先级的lanes都会被跳过(比如state的更新),等待下一次调度。 所以在已存在任务优先级更高的情况下再次以低优先级的动作调用setState,React不会把这两次setState合并到同一批次处理,而是先执行高优先级任务。

// 跳过优先级低于基本优先级更新的源码,这部分在`processUpdateQueue`函数
if (!isSubsetOfLanes(renderLanes, updateLane)) {
  // Priority is insufficient. Skip this update. If this is the first
  // skipped update, the previous update/state is the new base
  // update/state.
  const clone: Update<State> = {
    eventTime: updateEventTime,
    lane: updateLane,

    tag: update.tag,
    payload: update.payload,
    callback: update.callback,

    next: null,
  };
  if (newLastBaseUpdate === null) {
    // 被跳过的update对象都会被收集到`baseUpdate`队列,等待下一次处理
    newFirstBaseUpdate = newLastBaseUpdate = clone;
    newBaseState = newState;
  } else {
    newLastBaseUpdate = newLastBaseUpdate.next = clone;
  }
  // Update the remaining priority in the queue.
  newLanes = mergeLanes(newLanes, updateLane);
}
复制代码

新任务优先级更高的情况,需要取消已存在的任务,以更高优先级在Scheduler上注册一个新的performConcurrentWorkOnRoot,这种情况可以理解为高优先级任务的插队。

// 取消低优先级任务的源码,这部分在`ensureRootIsScheduled`函数
const nextLanes = getNextLanes(
  root,
  root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
// We use the highest priority lane to represent the priority of the callback.
const newCallbackPriority = getHighestPriorityLane(nextLanes);

// Check if there's an existing task. We may be able to reuse it.
// 获取已存在任务优先级
const existingCallbackPriority = root.callbackPriority;
if (existingCallbackPriority === newCallbackPriority) {
  // The priority hasn't changed. We can reuse the existing task. Exit.
  // 新旧任务优先级相同,直接复用已存在任务就可以顺便把新的更新处理
  return;
}
// `getNextLanes`会筛选出已存在和未处理优先级中较高的,所以这里已存在的lane优先级只可能更低或者是上面的相等
if (existingCallbackNode != null) {
  // Cancel the existing callback. We'll schedule a new one below.
  // 取消已有的任务,等待下次调度再完成它
  cancelCallback(existingCallbackNode);
}
复制代码

Guess you like

Origin juejin.im/post/7063015348403961864