React Scheduler 分析

81 阅读7分钟

简单介绍

React 主要有三大块内容,分别是 Reconciler、Render、以及 Scheduler ,其实 React 16.8 之后比较能够体现React 的核心理念的就是 Scheduler ,虽然下面讲的是 React Scheduler ,但是其实在理解的时候不需要和 React 进行强绑定,理解为就是一个普通的任务调度器就好了

tips:下面有一些代码示例要跑代码的话,点击一个例子(代码/按钮),就关闭一个窗口吧,例子比较极端容易卡住

核心链路

Scheduler 的核心功能其实就是可以保证在面对很多需要执行的任务的时候不会阻止渲染,保证页面不会掉帧,影响用户体验,主要流程如下所示 image.png 其实是延时任务还是普通任务是使用者使用决定,对于 Scheduler 来说这里有两个核心问题需要进行解决

  • 如何对任务进行排序(根据时间进行优先级排序)
  • 如何判断是否该渲染了 这两个问题解决,其实 Scheduler 也就完成了

最小堆

其实在这个流程中并不需要任务队列中的人物每时每刻都是按顺序排好的,而是需要每次从任务队列拿走的都是最小的,那其实这里最适合的数据结构就是最小堆。 那什么是最小堆呢? 最小堆就是有以下特点的二叉树

  • 头节点始终是最小的
  • 父节点永远比子节点小
  • 是一颗完全二叉树 那最大顶堆自然就是相反的。 二叉树 每一个父节点只能有一个或两个字节点 image.png 完全二叉树 二叉树在排列时完全按照从上到下从左到右点顺序依次排列并且排满 image.png 最小堆 image.png 通过数组来实现最小堆的
indexvalue
01
12
24
36
43
55

寻找其父节点与左右子节点

父节点:Math.floor((index-1)/2)

左子节点:index*2 + 1

右子节点:index*2 + 2

实现起来也比较简单,主要就是上移和下移,包括也可以直接拿现成的数据结构,就自定义一下 compare 方法就 OK 了。

function push(heap, node) {
		var index = heap.length;
		heap.push(node);
		siftUp(heap, node, index);
}
// 返回头节点
function peek(heap) {
		var first = heap[0];
		return first === undefined ? null : first;
}
function pop(heap) {
		// 拿到头节点
		var first = heap[0];

		if (first !== undefined) {
				// 拿出最后一个节点,作为头节点
				var last = heap.pop();

				if (last !== first) {
						heap[0] = last;
						// 通过下移将头节点重新设置为 sortIndex 最小
						siftDown(heap, last, 0);
				}

				return first;
		} else {
				return null;
		}
}

function siftUp(heap, node, i) {
		var index = i;

		while (true) {
				// >>> 无符号右移
				var parentIndex = index - 1 >>> 1;
				// 拿到它的父亲节点
				var parent = heap[parentIndex];
				// parent 有值,而且比较下来新添加进来的 sortIndex 比父元素小
				if (parent !== undefined && compare(parent, node) > 0) {
						// 与父元素交换位置
						heap[parentIndex] = node;
						heap[index] = parent;
						index = parentIndex;
				} else {
						// 否则结束
						return;
				}
		}
}

function siftDown(heap, node, i) {
		var index = i;
		var length = heap.length;
		// 并且一直循环
		while (index < length) {
				var leftIndex = (index + 1) * 2 - 1;
				var left = heap[leftIndex];
				var rightIndex = leftIndex + 1;
				var right = heap[rightIndex]; // If the left or right node is smaller, swap with the smaller of those.
				// 比较 left 和 node
				if (left !== undefined && compare(left, node) < 0) {
						if (right !== undefined && compare(right, left) < 0) {
								heap[index] = right;
								heap[rightIndex] = node;
								index = rightIndex;
						} else {
								heap[index] = left;
								heap[leftIndex] = node;
								index = leftIndex;
						}
						// 比较 right 和 node 
				} else if (right !== undefined && compare(right, node) < 0) {
						heap[index] = right;
						heap[rightIndex] = node;
						index = rightIndex;
				} else {
						// Neither child is smaller. Exit.
						return;
				}
		}
}

function compare(a, b) {
		var diff = a.sortIndex - b.sortIndex;
		return diff !== 0 ? diff : a.id - b.id;
}

requestIdleCallback

然后就是如何判断浏览器是否需要去渲染了,这里其实有一个标准的 API 叫做 reauestIdleCallback,但是 React 并没有直接使用这一套 API ,而是自己实现了一套类似机制的 API ,主要原因是因为 safari 目前都还不支持这套 API 使用方式 image.png

requestIdleCallback((time)=>time.timeRemaining())

setTimeout

实现思路如下 当然我下面的肯定和 Schduler 实现的不一样,因为它中间还夹杂了其它逻辑,我把这个逻辑给简化了

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <style>
      div {
        margin-top: 20px;
      }
    </style>
    <div>
      <input type="text" />
    </div>
    <div>
      <button id="btn">点击开始</button>
    </div>
    <script>
      const requestIdleCallback = (function () {
        const yieldInterval = 16;
        function now() {
          return Date.now();
        }
        return function (handler) {
          let startTime = now();
          return setTimeout(function () {
            handler({
              didTimeout: false,
              timeRemaining: function () {
                return Math.max(0, yieldInterval - (now() - startTime));
              },
            });
          }, 1);
        };
      })();
      btn.onclick = () => {
        function callback({ timeRemaining }) {
          while (timeRemaining() > 0) {
            console.log("hello");
          }
          requestIdleCallback(callback);
        }
        requestIdleCallback(callback);
      };
    </script>
  </body>
</html>

但是定时器会有一个嵌套五层,最少4ms 的一个问题,主要的话是为了防止某些情况下对计时器对频繁调用而对浏览器的性能造成严重的影响

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="run">运行</button>
    <table>
      <thead>
        <tr>
          <th>之前</th>
          <th>现在</th>
          <th>实际延时</th>
        </tr>
      </thead>
      <tbody id="log"></tbody>
    </table>
    <script>
      let last = 0;
      let iterations = 10;

      function timeout() {
        // 记录调用时间
        logline(new Date().getMilliseconds());
        // 如果还没结束,计划下次调用
        if (iterations-- > 0) {
          setTimeout(timeout, 0);
        }
      }
      function run() {
        // 清除日志
        const log = document.querySelector("#log");
        while (log.lastElementChild) {
          log.removeChild(log.lastElementChild);
        }

        // 初始化迭代次数和开始时间戳
        iterations = 10;
        last = new Date().getMilliseconds();
        // 开启计时器
        setTimeout(timeout, 0);
      }

      function logline(now) {
        // 输出上一个时间戳、新的时间戳及差值
        const tableBody = document.getElementById("log");
        const logRow = tableBody.insertRow();
        logRow.insertCell().textContent = last;
        logRow.insertCell().textContent = now;
        logRow.insertCell().textContent = now - last;
        last = now;
      }

      document.querySelector("#run").addEventListener("click", run);
    </script>
  </body>
</html>

所以为了提升这个 4ms 的性能,其实 Scheduler 还实现了一套基于 MessageChannel 的实现

MessageChannel

所以说 4ms 的问题是因为 setTimeout,仔细看这段实现代码会发现,其实之所以会使用 setTimeout 就是希望在浏览器渲染之前可以在宏队列当中加一个任务而已,让浏览器渲染完成之后继续进行任务的执行,那可以在宏队列里面加任务的又不止有 setTimeout,当然可以采取替代方式,于是 Scheduler 又使用 MessageChannel 实现了另外一套并且优先使用这一套,使用 MessageChannel 模拟 requestIdleCallback 代码如下。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <style>
      div {
        margin-top: 20px;
      }
    </style>
    <div>
      <input type="text" />
    </div>
    <div>
      <button id="btn">点击开始</button>
    </div>
    <script>
      const requestIdleCallback = (function () {
        const yieldInterval = 16;
        const channel = new MessageChannel();
        const port = channel.port2;
        var currentHandler;
        channel.port1.onmessage = function () {
          let startTime = now();
          currentHandler({
            didTimeout: false,
            timeRemaining: function () {
              return Math.max(0, yieldInterval - (now() - startTime));
            },
          });
        };
        function now() {
          return Date.now();
        }
        return function (handler) {
          currentHandler = handler;
          port.postMessage(null);
        };
      })();
      btn.onclick = () => {
        function callback({ timeRemaining }) {
          while (timeRemaining() > 0) {
            console.log("hello");
          }
          requestIdleCallback(callback);
        }
        requestIdleCallback(callback);
      };
    </script>
  </body>
</html>

使用这种方式就解决使用 setTimeout 会有 4ms 延迟的问题(但是你会发现在这个极端的例子当中,使用 setTimeout 比使用 MessageChannel 要更加流畅,这也提现了 setTimeout 4ms 延迟的作用),之所以没有完全采用 MessageChannel 替代 setTimeout,还是因为兼容性 image.png 这个兼容性可以说是非常不错了,但是对于 Scheduler 来说还是不能接受,从这里也能看出来做一个通用的框架需要考虑的问题有多少。

事件循环

其实看了上面会带有一个疑问,那就是为什么只能够使用 宏队列 添加任务?为什么使用 微队列 或者使用requestAnimationFrame ******添加任务不行呢,其实是因为浏览器对他们的处理不一样 用一个动态的图来表示一下,添加多个宏任务、多个微任务以及 requestAnimationFrame任务且在任务当中一直添加任务,浏览器的执行效果不同

  • 宏任务:并不会全部执行完
  • requestAnimationFrame:将第一次添加的任务全部执行
  • 微任务:必须将所有任务执行完毕 可以看看如下的例子(可以比较直接的看到效果)
  • 宏任务:不会有延迟
  • requestAnimationFrame:有明显的延迟
  • 微任务:界面不会渲染
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <button id="btn1">宏任务</button>
      <button id="btn2">微任务</button>
      <button id="btn3">RAF任务</button>
    </div>
    <script>
      function appendChild(text) {
        const div = document.createElement("div");
        div.innerText = text;
        document.body.appendChild(div);
      }
      // 宏任务
      btn1.onclick = () => {
        appendChild("宏任务");
        function c() {
          console.log("setTimeout");
          setTimeout(c, 0);
        }
        for (let i = 0; i < 100000; i++) {
          setTimeout(c, 0);
        }
      };
      // 微任务
      btn2.onclick = () => {
        appendChild("微任务");
        for (let i = 0; i < 10000; i++) {
          Promise.resolve().then(b);
        }
        function b() {
          console.log("Promise");
          Promise.resolve().then(b);
        }
      };
      // RAF
      btn3.onclick = () => {
        appendChild("RAF任务");

        for (let i = 0; i < 100000; i++) {
          requestAnimationFrame(a);
        }
        function a() {
          console.log("requestAnimationFrame");
          requestAnimationFrame(a);
        }
        requestAnimationFrame(a);
      };
    </script>
  </body>
</html>

而且他们执行的顺序也是不一致的,微任务和 requestAnimationFrame 是在渲染前进行执行,而宏队列任务则是在渲染后进行执行,也就是说只有使用宏任务才可以在渲染后继续执行任务

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <button id="btn1">宏任务</button>
      <button id="btn2">微任务</button>
      <button id="btn3">RAF任务</button>
    </div>
    <script>
      function appendChild(text) {
        const div = document.createElement("div");
        div.innerText = text;
        document.body.appendChild(div);
      }
      btn1.onclick = () => {
        appendChild("宏任务");
        setTimeout(() => {
          while (1) {
            console.log("宏任务");
          }
        }, 0);
        const now = Date.now();
      };
      btn2.onclick = () => {
        appendChild("微任务");
        Promise.resolve().then(() => {
          while (1) {
            console.log("微任务");
          }
        });
      };
      btn3.onclick = () => {
        appendChild("RAF");
        requestAnimationFrame(() => {
          while (1) {
            appendChild("RAF");
          }
        });
      };
    </script>
  </body>
</html>

在上面这个比较极端的例子中

  • 宏任务能正常渲染界面
  • 微任务和 RAF 不能正常渲染界面 所以综上来看,其实宏任务是最合适的