React各版本性能优化方案原理分析

前沿

开发动画复杂或是很重的项目 react真的会比原生js性能要好吗?
之前尝试用canvas开发动画时候发现 绘制动画时候需要调用ctx.clearRect清除上一帧动画 如果当前真动画计算量比较大 导致清楚上一帧画面到绘制当前帧画面会有很长时间 页面会白屏怎么办?
其实react很多设计思想完全可以用到我们平时的代码里面

一、前端框架对比 React vs Vue

vue属于编译时优化 而react属于重运行时优化

1.vue 编译时优化

什么是编译时优化?

vue是模版语法 我们使用模版的方式去编码 虽然没有jsx那样动态 但是v-if v-for是可枚举可控的 我们在编译时可以做更多的预判 使优化变得更方便 下面是vue3做的优化

传统虚拟dom会按照div标签一级一级进行遍历计算 因此性能就和模版大小呈正相关 但是每个模版能变化的节点属实有限 永远不会变化的节点我们没有必要浪费时间去检查 因此diff阶段跳过静态节点

vue3.0有一条性能优化 就是 complier 时的 transform 阶段解析 AST Element 打上的补丁标记。比如节点具有动态的不同属性 都会被不同的patchflag标签 然后 patchflag 再配合 block tree,就可以做到对不同节点的靶向更新。
主要分为两大类

  • 当 patchFlag 的值大于 0 时,代表所对应的元素在 patchVNode 时或 render 时是可以被优化生成或更新的
  • 当 patchFlag 的值小于 0 时,代表所对应的元素在 patchVNode 时,是需要被 full diff,即进行递归遍历 VNode tree 的比较更新过程。
能带patchFlag 的 Node 才被认为是动态的元素,会被追踪属性的修改
统计那个动态的部分最多只有三分之一,所以在编译的过程中发现那个比较小的动态部分,把它放到比较靠上的等级模块上,那么就可以称那个比较靠上的模块为block区块树,在这个动态变化的区块增加一个Array去追踪自身动态节点。那么意味着将来这个模块重新更新的时候,就不会再向下进行一层一层递归了,只做这个模块的动态部分的更新。

在3.0 里,渲染效率不再与模板大小成正相关,而是与模板中动态节点的数量成正相关。

2.React运行时优化

react的思路是js写法 很灵活 这就让编译时无法做过多的处理

const getName = (obj) => {
  return obj.a + obj.b
}
const obj = {a: 1, b: 2}

const element = (
  <div>{getname(obj)}</div>
)
ReactDOM.render(element, document.getElementById('root'))
因此React就要在运行时关注CPU性能的问题以及IO问题
  • CPU:主流浏览器的刷新频率一般是 60Hz,也就是每秒刷新 60 次,大概 16.6ms 浏览器刷新一次。由于 GUI 渲染线程和 JS 线程是互斥的,所以 JS 脚本执行和浏览器布局、绘制不能同时执行。
    在这 16.6ms 的时间里,浏览器需要完成 JS 的执行,也需要完成样式的重排和重绘,如果 JS 某个任务时间过长 超出了 16.6ms,这次刷新就没有时间执行样式布局绘制 在页面上就会表现为卡顿
  • IO:如果组件需要等待一些长时间网络请求,如果网络延的话就需要减少用户对网络延迟的感知

二、React框架的优化历程

1、react15

组成部分
  • Reconciler:负责调用 render 生成虚拟 Dom 进行 Diff,找出变化后的虚拟 Dom
  • Renderer: 负责接到 Reconciler 通知,将变化的组件渲染在当前宿主环境,比如浏览器,不同的宿主环境会有不同的 Renderer
优化点:半自动批处理

因为 setState 是同步的,当同时触发多次 setState 时浏览器会一直被JS线程阻塞,那么那么浏览器就会掉帧,导致页面卡顿,所以 React 才引入了批处理的机制,为了将同一上下文中触发的更新合并为一个更新

componentDidMount() {
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);   
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);   

    setTimeout(() => {
      this.setState({val: this.state.val + 1});
      console.log(this.state.val); 
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  
    }, 0);
  }
// 结果 0 0 2 3
 _processPendingState: function (props, context) {
  var inst = this._instance;
  var queue = this._pendingStateQueue;
  var replace = this._pendingReplaceState;
  this._pendingReplaceState = false;
  this._pendingStateQueue = null;

  if (!queue) {
   return inst.state;
  }
  ...

  var nextState = _assign({}, replace ? queue[0] : inst.state);
  for (var i = replace ? 1 : 0; i < queue.length; i++) {
   var partial = queue[i];

   _assign(nextState, typeof partial === 'function' ? partial.call(inst, nextState, props, context) : partial);
  }

  return nextState;
 },

可以看到不管执行几次setstate 只会拿到最后一次变更去更新

其实这里也存在一个bug 就是在setTimeout里面的setstate没有批处理 这个在React18做了修复 等下面会讲到

缺陷:React15的性能瓶颈:

为什么 react 早在几年前就提出了 fiber 方案:主要是因为 react 15 在 mount 或 diff 过程中,并没有考虑到组件树过于庞大的问题,由于这两个过程是从父到子的递归渲染,且浏览器的单线程限制 ui渲染线程和js线程互斥,在运行 JS 的时候,无法渲染 DOM,所以导致在组件树太庞大的情况下,会造成用户卡顿,也无法对页面进行任何操作。


当层级很深时,递归更新时间超过了 16ms

2、react16 Fiber

在 CPU 上,我们的主要问题是,在 JS 执行超过 16.6 ms 时,页面就会产生卡顿,那么 React 的解决思路,就是在浏览器每一帧的时间中预留一些时间给 JS 线程,React 利用这部分时间更新组件。当预留的时间不够用时,React 将线程控制权交还给浏览器让他有时间渲染UI,React 则等待下一帧再继续被中断的工作。Fiber就做到了可中断

浏览器帧的概念

  • 某任务执行时间过长 超过16ms 渲染就会推迟 造成页面卡顿
  • 浏览器大概一帧有10毫秒的空闲时间 我们可以在这个空闲做一些事情

    Fiber就利用了这一思想

Fiber大体思路

  • fiber是一个执行单元 每执行完一个单元 浏览器就会检查剩余多少时间 没有时间就将控制权让出
  • 通过fiber架构 让协调过程变成可中断的 适时让出cpu执行权 提高响应速度
fiber工作单元

举例说明

<div id='A1'>
  <div id='B1'>
    <div id='C1'></div>
    <div id='C2'></div>
  </div>
  <div id='B2'></div>
</div>

五个元素 先处理A1 比如一共10ms A1执行5ms 还有时间看是否存在下一单元 看下一个是B1 执行B1 检查是否有剩余时间 没时间或者没有下一个单元了 执行权交还浏览器 那一什么样的方式去停止他们 再回来执行C1呢 那就需要一个明确的顺序 因此需要一个fiber数据结构

requestIdleCallback

  • requestIdleCallback使开发者在主事件循环上执行后台和低优先级工作 不会影响延迟关键事件:动画和输入响应
  • 正常帧执行完任务后没超过16ms 说明有时间空余 就会执行requestIdleCallback里面的注册任务
  • requestAnimationFrame回调会在每一帧确定执行 属于高优先级任务 requestIdleCallback不一定 它属于低优先级任务


    申请时间切片

举个🌰

requestIdleCallback(myWork)
function sleep(duration) {
  let start = Date.now();
  while(start + duration> Date.now()) {

  }
}

// 一个任务队列
let tasks = [

  function t1() {
    console.log('执行任务1')
    sleep(20);
    console.log('结束任务1')
  },
  function t2() {
    console.log('执行任务2')
    sleep(20);
    console.log('结束任务2')
  },
  function t3() {
    console.log('执行任务3')
    sleep(20);
    console.log('结束任务3')
  },
]

// deadline是requestIdleCallback返回的一个对象
function myWork(deadline) {
  console.log(`当前帧剩余时间: ${deadline.timeRemaining()}`)
  // 查看当前帧的剩余时间是否大于0 && 是否还有剩余任务
  if (deadline.timeRemaining() > 0 && tasks.length) {
    // 在这里做一些事情
    const task = tasks.shift()
    task()
  }
  // 如果还有任务没有被执行,那就放到下一帧调度中去继续执行,类似递归
  if (tasks.length) {
  console.log(`当前帧剩剩余: ${deadline.timeRemaining()} 执行下次真`)

    requestIdleCallback(myWork)
  }
}
requestIdleCallback将任务分割成一个个小任务

由于兼容性和刷新帧率的问题,React 并没有直接使用 requestIdleCallback , 而是使用了 MessageChannel 模拟实现 下面会详细讲解原理😎

核心原理

架构

Reconciler 的工作就是使用 Diff 算法对比生成 workInProgress Fiber ,这个阶段是可中断的
Renderer 的工作是把 workInProgress Fiber 转换成真正的 DOM 节点

一、 调度器 - Scheduler

调度任务的优先级,高优任务优先进入 Reconciler 利用时间切片,就可以根据当前的宿主环境性能,为每个工作单元分配一个可运行时间,从而实现异步可中断的更新
Scheduler 就可以帮我们完成这件事情,我们可以看到,我们一次耗时很长的更新任务被拆分成一小段一小段的。这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性

任务拆分

时间切片的本质,也就是模拟实现 requestIdleCallback 这个函数 看下源码

  • 调度任务
// 接收 MessageChannel 消息
const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime(); // 1. 获取当前时间
    deadline = currentTime + yieldInterval; // 2. 设置deadline
    const hasTimeRemaining = true;
    try {
      // 3. 执行回调, 返回是否有还有剩余任务
      const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      if (!hasMoreWork) {
        // 没有剩余任务, 退出
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      } else {
        port.postMessage(null); // 有剩余任务, 发起新的调度
      }
    } catch (error) {
      port.postMessage(null); // 如有异常, 重新发起调度
      throw error;
    }
  } else {
    isMessageLoopRunning = false;
  }
  needsPaint = false; // 重置开关
};

const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;

// 请求回调
requestHostCallback = function(callback) {
  // 1. 保存callback
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    // 2. 通过 MessageChannel 发送消息
    port.postMessage(null);
  }
};
// 取消回调
cancelHostCallback = function() {
  scheduledHostCallback = null;
};

时间切片(time slicing)相关: 执行时间分割, 让出主线程 把控制权归还浏览器, 浏览器可以处理用户输入, UI 绘制等紧急任务
在 React 的 render 阶段,开启 Concurrent Mode 时,每次遍历前,也会通过 Scheduler 提供的 shouldYield 方法判断是否需要中断遍历,使浏览器有时间渲染
Fiber 架构配合 Scheduler 实现了 Concurrent Mode 的底层 — “异步可中断的更新

const localPerformance = performance;
// 获取当前时间
getCurrentTime = () => localPerformance.now();

// 时间切片周期, 默认是5ms(如果一个task运行超过该周期, 下一个task执行之前, 会把控制权归还浏览器)
let yieldInterval = 5;

let deadline = 0;
const maxYieldInterval = 300;
let needsPaint = false;
const scheduling = navigator.scheduling;
// 是否让出主线程
shouldYieldToHost = function() {
  const currentTime = getCurrentTime();
  if (currentTime >= deadline) {
    if (needsPaint || scheduling.isInputPending()) {
      // There is either a pending paint or a pending input.
      return true;
    }
    // There's no pending input. Only yield if we've reached the max
    // yield interval.
    return currentTime >= maxYieldInterval; // 在持续运行的react应用中, currentTime肯定大于300ms, 这个判断只在初始化过程中才有可能返回false
  } else {
    // There's still time left in the frame.
    return false;
  }
};

// 请求绘制
requestPaint = function() {
  needsPaint = true;
};

// 设置时间切片的周期
forceFrameRate = function(fps) {
  if (fps < 0 || fps > 125) {
    // Using console['error'] to evade Babel and ESLint
    console['error'](
      'forceFrameRate takes a positive int between 0 and 125, ' +
        'forcing frame rates higher than 125 fps is not supported',
    );
    return;
  }
  if (fps > 0) {
    yieldInterval = Math.floor(1000 / fps);
  } else {
    // reset the framerate
    yieldInterval = 5;
  }
};
  • 任务队列管理

过上文的分析, 我们已经知道请求和取消调度的实现原理. 调度的目的是为了消费任务, 接下来就具体分析任务队列是如何管理与实现的.
Scheduler.js维护了一个taskQueue任务队列管理就是围绕这个taskQueue展开
unstable_scheduleCallback函数中(源码链接):

1.创建任务
// 省略部分无关代码
function unstable_scheduleCallback(priorityLevel, callback, options) {
  // 1. 获取当前时间
  var currentTime = getCurrentTime();
  var startTime;
  if (typeof options === 'object' && options !== null) {
    // 从函数调用关系来看, 在v17.0.2中,所有调用 unstable_scheduleCallback 都未传入options
    // 所以省略延时任务相关的代码
  } else {
    startTime = currentTime;
  }
  // 2. 根据传入的优先级, 设置任务的过期时间 expirationTime
  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;
  // 3. 创建新任务
  var newTask = {
    id: taskIdCounter++,
    callback, // callback: 传入的回调函数
    priorityLevel, // priorityLevel: 优先级等级
    startTime,
    expirationTime, // expirationTime: task的过期时间, 优先级越高 expirationTime = startTime + timeout 越小
    sortIndex: -1,
  };
  if (startTime > currentTime) {
    // 省略无关代码 v17.0.2中不会使用
  } else {
    newTask.sortIndex = expirationTime;
    // 4. 加入任务队列
    push(taskQueue, newTask);
    // 5. 请求调度
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }
  return newTask;
}

其实维护的队列就是一个小顶堆数组O(1)的复杂度就能找到优先级更高的任务

  • 生命周期方法:是最高优先级、同步执行的。
  • 受控用户输入:比如输入框内输入文字,同步执行。
  • 交互事件:比如动画requestAnimationFrame,高优先级执行。
  • 其他数据请求、使用了suspensetransition 这样的更新,是低优先级执行的。
注意⚠️:这个优先级的机制有别于Fiber链表构建中的优先级机制

实际上是 React 的 Scheduler 的优先级机制,在 React 内部,Scheduler 是一个独立包,它只负责任务的调度,甚至不关心这个任务具体是干什么,所以 Scheduler 内部的优先级机制也是独立于 React 的
React 内部也有一套自己的优先级机制,在Fiber链表中 哪些 Fiber 以及 哪些 Update 对象,是高优先级的。
在 React16 中,Fiber 和 Update 的优先级和 任务的优先级 是类似。React 会根据不同的操作优先级给每个 Fiber 节点的 Update 增加一个 expirationTime 由于某些问题,React 已经在 Fiber 中不再使用 expirationTime 去表示优先级 下面会介绍

2.消费任务:

创建任务之后, 最后请求调度requestHostCallback(flushWork)(创建任务源码中的第 5 步), flushWork函数作为参数被传入调度中心内核等待回调. requestHostCallback函数在上文调度内核中已经介绍过了, 在调度中心中, 只需下一个事件循环就会执行回调, 最终执行flushWork

// 省略无关代码
function flushWork(hasTimeRemaining, initialTime) {
  // 1. 做好全局标记, 表示现在已经进入调度阶段
  isHostCallbackScheduled = false;
  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    // 2. 循环消费队列
    return workLoop(hasTimeRemaining, initialTime);
  } finally {
    // 3. 还原全局标记
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
  }
}

flushWork中调用了workLoop. 队列消费的主要逻辑是在workLoop函数中, 这就是React 工作循环一文中提到的任务调度循环.

// 省略部分无关代码
function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime; // 保存当前时间, 用于判断任务是否过期
  currentTask = peek(taskQueue); // 获取队列中的第一个任务
  while (currentTask !== null) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // 虽然currentTask没有过期, 但是执行时间超过了限制(毕竟只有5ms, shouldYieldToHost()返回true). 停止继续执行, 让出主线程
      break;
    }
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      // 执行回调
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      // 回调完成, 判断是否还有连续(派生)回调
      if (typeof continuationCallback === 'function') {
        // 产生了连续回调(如fiber树太大, 出现了中断渲染), 保留currentTask
        currentTask.callback = continuationCallback;
      } else {
        // 把currentTask移出队列
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
    } else {
      // 如果任务被取消(这时currentTask.callback = null), 将其移出队列
      pop(taskQueue);
    }
    // 更新currentTask
    currentTask = peek(taskQueue);
  }
  if (currentTask !== null) {
    return true; // 如果task队列没有清空, 返回ture. 等待调度中心下一次回调
  } else {
    return false; // task队列已经清空, 返回false.
  }
}

workLoop就是一个大循环, 虽然代码也不多, 但是非常精髓, 在此处实现了时间切片(time slicing)和fiber树的可中断渲染. 这 2 大特性的实现, 都集中于这个while循环.

可中断渲染原理

每一次while循环的退出就是一个时间切片, 深入分析while循环的退出条件:
1.队列被完全清空: 这种情况就是很正常的情况, 没有遇到任何阻碍.
2.执行超时: 在消费taskQueue时, 在执行task.callback之前, 都会检测是否超时。如果某个task.callback执行时间太长(如: fiber树很大, 或逻辑很重)也会造成超时

在时间切片的基础之上, 如果单个task.callback执行时间就很长(假设 200ms). 就需要task.callback自己能够检测是否超时, 所以在 fiber 树构造过程中, 每构造完成一个单元, 都会检测一次超时(源码链接), 如遇超时就退出fiber树构造循环, 并返回一个新的回调函数(就是此处的continuationCallback)并等待下一次回调继续未完成的fiber树构造.

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}
二、 双缓存

canvas开发动画时候发现 绘制动画时候需要调用ctx.clearRect清除上一帧动画 如果当前帧动画计算量比较大 导致清除上一帧画面到绘制当前帧画面会有很长时间 页面会白屏
什么是双缓存:这种在内存中构建并直接替换的技术
React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。
在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。

function App() {
  const [num, add] = useState(0);
  return (
    <p onClick={() => add(num + 1)}>{num}</p>
  )
}

ReactDOM.render(<App/>, document.getElementById('root'));
  • mount时

在首次渲染的时候,会创建fiberRoot和rootFiber,fiberRoot是整个应用的根节点,rootFiber是组件的根节点。

接下来进入render阶段,根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树。(下图中右侧为内存中构建的树,左侧为页面显示的树)
在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber(即rootFiber.alternate)

目前current只有一个根 因此虚拟树复用了根 既用alternate连接

渲染完成之后,workInProgress树会赋值给current树

  • update时

点击p节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树
更新复用currentFiber的alternate属性(这个决定是否复用的过程就是Diff算法,后面章节会详细讲解)也就是第一次渲染成功的fiber,如果一直没用变化他将一直复用currentFiber的alternate

可以得出双缓存的结论:

currtentFiber.alternate===workInProgress

workInProgress.alternate===currtentFiber
没变化的复用alternate

Reconciler 的工作就是使用 Diff 算法对比 current Fiber 和 React Element ,生成 workInProgress Fiber ,这个阶段是可中断的,Renderer 的工作是把 workInProgress Fiber 转换成真正的 DOM 节点。

三、 生命周期的改变

在新的 React 架构中,一个组件的渲染被分为两个阶段:

  • 第一个阶段也就是fiber构建的阶段是可以被 React 打断的,一旦被打断,这阶段所做的所有事情都被废弃,当 React 处理完紧急的事情回来会重新渲染这个组件,这时候第一阶段的工作会重做一遍
  • 第二个阶段叫做 commit 阶段,一旦开始就不能中断,也就是说第二个阶段的工作会直接做到这个组件的渲染结束

两个阶段的分界点,就是 render 函数。render 函数之前的所有生命周期函数(包括 render)都属于第一阶段,之后的都属于第二阶段。开启 Concurrent Mode 之后, render 之前的所有生命周期都有可能会被打断,或者重复调用 比如如下生命周期:componentWillMount componentWillReceiveProps componentWillUpdate

如果我们在这些生命中期中引入了副作用,被重复执行,就可能会给我们的程序带来不可预知的问题,所以到了 React v16.3,React 干脆引入了一个新的生命周期函数 getDerivedStateFromProps,这个生命周期是一个 静态方法,在里面根本不能通过 this 访问到当前组件,输入只能通过参数,对组件渲染的影响只能通过返回值。

    static getDerivedStateFromProps(nextProps, prevState) {
        if (nextProps.info!== prevState.info) {
            return ({
                info: nextProps.info
            })
        }
        return null;
    }

image.png
四、 React16缺陷

在 expirationTime 最开始被设计的时候,React 体系中还没有 Suspense 异步渲染 的概念。假如现在有这样的场景: 有 3 个任务, 其优先级 A > B > C,正常来讲只需要按照优先级顺序执行就可以。
但是现在有这样的情况:A 和 C 任务是 CPU 密集型,而 B 是IO密集型 (Suspense 会调用远程 api, 算是 IO 任务), 即 A(cpu) > B(IO) > C(cpu),在这种情况下呢,高优先级 IO 任务会中断低优先级 CPU 任务,这显然,是不合理的 看下17版本如何解决

3、React17

1.多版本共存

17版本之前的react 如果存在多版本嵌套 如果页面上有多个 React 版本,他们都将在顶层注册事件处理器。这会破坏 e.stopPropagation():如果嵌套树结构中阻止了事件冒泡,但外部树依然能接收到它。这会使不同版本 React 嵌套变得困难重重

  • 回顾react事件的实现方式

react实现了自己的一套事件机制并绑定到document上 也就是自己的合成事件 当我们触发一个事件的时候 这样可以抹平各个浏览器的兼容性问题

这样就会存在一个问题 再写react事件的时候需要绑定this 不然this会丢失 其实原因并不怪react 而是js函数和this工作原理导致的 事件处理的那个 props——onClick 接收到了函数,却不知道执行上下文

const obj = {
  name: 'obj',
  func() {
    console.log(this)
  }
}

obj.func()  // {name: "obj", func: ƒ}

如果把func提取出来执行,那么func在模块化严格模式中打印的就是undefined

const func = obj.func;

func();   // undefined
  render() {
    return (
      <button onClick={this.handleClick}>按钮</button>
    );
  }

this.handleClick 中的 handleClick 被提取出来,放在一个事件池中 之后被触发,这个时候,this 早已经不是指向那个组件了

  • React 17 React 会把事件 attach 到 React 渲染树的根 DOM 容器中
const rootNode = document.getElementById('root'); 
ReactDOM.render(<App />, rootNode);

绑定在根DOM容器上 这样就有效将嵌套的React版本隔离开来 事件不会相互影响

2.新的优先级算法lanes

但是现在有这样的情况:A 和 C 任务是 CPU 密集型,而 B 是IO密集型 (Suspense 会调用远程 api, 算是 IO 任务), 即 A(cpu) > B(IO) > C(cpu),在这种情况下呢,高优先级 IO 任务会中断低优先级 CPU 任务

那么使用 expirationTime ,它是以某一优先级作为整棵树的优先级更新标准,而并不是某一个具体的组件,这时我们的需求是需要把 任务B 从 一批任务 中分离出来,先处理 cpu 任务 A 和 C ,如果通过 expirationTime 很难表示批的概念,也很难从一批任务里抽离单个任务,这时呢,我们就需要一种更细粒度的优先级

Lane类型被定义为二进制变量, 利用了位掩码的特性, 在频繁运算的时候占用内存少, 计算速度快.

越低优先级的 lanes 占用的位越多。比如 InputDiscreteLanes占了2个位,TransitionLanes 占了9个位。原因在于:越低优先级的更新越容易被打断(如果当前优先级的所有赛道都被占有了,则把当前优先级下降一个优先级),导致积压下来,所以需要更多的位。相反,最高优的同步更新的 SyncLane 不需要多余的 lanes。

4、React18 Concurrent Rendering 并发渲染机制

在 React 18 版本中,ReactDOM.createRoot() 替代了通常作为程序入口的 ReactDOM.render() 方法。

这个方法主要是防止 React 18 的不兼容更新导致你的应用程序崩溃

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
const container = document.getElementById('root');
// Create a root.
const root = ReactDOM.createRoot(container);
// Render the top component to the root.
root.render(<App />);

批处理的优化

  • 半自动批处理:

在v18之前,只有事件回调、生命周期回调中的更新会批处理,比如上例中的onClick。
而在promise、setTimeout等异步回调中不会批处理

export function batchedUpdates<A, R>(fn: A => R, a: A): R { 
  const prevExecutionContext = executionContext; 
  executionContext |= BatchedContext; 
  try { 
    return fn(a); 
  } finally { 
    executionContext = prevExecutionContext; 
    // If there were legacy sync updates, flush them at the end of the outer 
    // most batchedUpdates-like method. 
    if (executionContext === NoContext) { 
      resetRenderTimer(); 
      flushSyncCallbacksOnlyInLegacyMode(); 
    } 
  } 
}

可以看到,传入一个回调函数fn,此时会通过「位运算」为代表当前执行上下文状态的变量executionContext增加BatchedContext状态 拥有这个状态位代表当前执行上下文需要批处理。
在fn执行过程中,其获取到的全局变量executionContext都会包含BatchedContext 最终fn执行完后,进入try...finally逻辑,将executionContext恢复为之前的上下文。
说白了 batchedUpdates是同步调用的 等到执行异步的部分 批处理早已执行完,executionContext中已经不包含BatchedContext。此时触发的更新不会走批处理逻辑。

为了弥补「半自动批处理」的不灵活,ReactDOM中导出了unstable_batchedUpdates方法供开发者手动调用。

onClick() { 
  setTimeout(() => { 
    ReactDOM.unstable_batchedUpdates(() => { 
      this.setState({a: 3}); 
      this.setState({a: 4}); 
    }) 
  }) 
} 

  • 全自动批处理:

v18实现「自动批处理」的关键在于两点:
1.增加调度的流程
2.不以全局变量executionContext为批处理依据,而是以更新的优先级为依据

举个🌰

  componentDidMount() {
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);   
    this.setState({val: this.state.val + 1});
    console.log(this.state.val);   
    setTimeout(() => {
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);    
      this.setState({val: this.state.val + 1});
      console.log(this.state.val);  
    }, 0);
  }
看下调度流程部分源码
//调度流程
function ensureRootIsScheduled(root, currentTime) { 
 
  // 获取当前所有优先级中最高的优先级 
  var nextLanes = getNextLanes(root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes); 
  // 本次要调度的优先级 
  var newCallbackPriority = getHighestPriorityLane(nextLanes);  
  // 已经存在的调度的优先级 
  var existingCallbackPriority = root.callbackPriority; 
  if (existingCallbackPriority === newCallbackPriority) { 
    return; 
  } 
  // 调度更新流程 
  newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root)); 
 
  root.callbackPriority = newCallbackPriority; 
  root.callbackNode = newCallbackNode; 
} 

节选后的调度流程大体是:
1.获取当前所有优先级中最高的优先级
2.将步骤1的优先级作为本次调度的优先级
3.看是否已经存在一个调度
4.如果已经存在调度,且和当前要调度的优先级一致,则return
5.不一致的话就进入调度流程
可以看到,调度的最终目的是在一定时间后执行performConcurrentWorkOnRoot,正式进入更新流程。

第一次执行完this.setState创造的update数据结构如下:
lane为1

在v18,不同场景下触发的更新拥有不同优先级,如上例子中事件回调中的this.setState会产生同步优先级的更新,这是最高的优先级(lane为1)

第二次执行this.setState创造的update数据结构如下:
lane为16,代表Normal(即一般优先级) 注意⚠️ 此update对象挂载在fiber节点上 enqueueUpdate(fiber, update);

第一次调用this.setState,进入「调度流程」后,不存在existingCallbackPriority 所以会执行调度:

第二次调用this.setState,进入「调度流程」后,已经存在existingCallbackPriority,即第一次调用产生的。

此时比较两者优先级:由于两个更新都是在onClick中触发,拥有同样优先级,所以return。
按这个逻辑,即使多次调用this.setState,如:

onClick() { 
  this.setState({a: 1}); 
  this.setState({a: 2}); 
  this.setState({a: 3}); 
} 

只有第一次调用会执行调度,后面几次执行由于优先级和第一次一致会return。
当一定时间过后,第一次调度的回调函数performConcurrentWorkOnRoot会执行,进入更新流程。
由于每次执行this.setState都会创建update并挂载在fiber上 所以即使只执行一次更新流程,还是能将状态更新到最新

react的setstate和hooks的setstate对比 不知道大家平时开发的时候有没有对两者的原理区别产生过好奇
 const updateQueue = fiber.updateQueue;
 if (updateQueue === null) {
   // Only occurs if the fiber has been unmounted.
   return;
 }

 const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;
 const pending = sharedQueue.pending;
 if (pending === null) {
   // This is the first update. Create a circular list.
   update.next = update;
 } else {
   update.next = pending.next;
   pending.next = update;
 }
 sharedQueue.pending = update;
}

hooks的setstate(usestate的第二个方法参数)

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  // 1. 创建update对象
  const eventTime = requestEventTime();
  const lane = requestUpdateLane(fiber); // 确定当前update对象的优先级
  const update: Update<S, A> = {
    lane,
    action,
    eagerReducer: null,
    eagerState: null,
    next: (null: any),
  };
  // 2. 将update对象添加到当前Hook对象的updateQueue队列当中
  const pending = queue.pending;
  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
  // 3. 请求调度, 进入reconcier运作流程中的`输入`环节.
  scheduleUpdateOnFiber(fiber, lane, eventTime); // 传入的lane是update优先级
}

可以发现两者的思想确实是一样的 都是将所有更新挂在一个链表上 只是挂载的地方不一样

startTransition属性:

什么是过度任务?

  • 第一类紧急更新任务。比如一些用户交互行为,按键,点击,输入等。
  • 第二类就是过渡更新任务。比如 UI 从一个视图过渡到另外一个视图。
1.startTransition 依赖于 concurrent Mode 渲染并发模式

传统 legacy 模式

import ReactDOM from 'react-dom'
/* 通过 ReactDOM.render  */
ReactDOM.render(
    <App />,
    document.getElementById('app')
)

v18 concurrent Mode并发模式

import ReactDOM from 'react-dom'
/* 通过 createRoot 创建 root */
const root =  ReactDOM.createRoot(document.getElementById('app'))
/* 调用 root 的 render 方法 */
root.render(<App/>)

这种模式诞生的场景是数据量大,DOM 元素节点多的场景,一次更新带来的变化可能是巨大的,所以频繁的更新,执行 js 事务频繁调用,浏览器要执行大量的渲染工作,所以给用户感觉就是卡顿。
举一个很常见的场景:就是有一个 input 表单。并且有一个大量数据的列表,通过表单输入内容,对列表数据进行搜索,过滤。那么在这种情况下,就存在了多个并发的更新任务

2.startTransition的使用🌰

我们希望输入框状态改变更新优先级要大于列表的更新的优先级。 这个时候我们的主角就登场了。用 startTransition 把两种更新区别开。

export default function App(){
    const [ value ,setInputValue ] = React.useState('')
    const [ isTransition , setTransion ] = React.useState(false)
    const [ query ,setSearchQuery  ] = React.useState('')
    const handleChange = (e) => {
        /* 高优先级任务 —— 改变搜索条件 */
        setInputValue(e.target.value)
        if(isTransition){ /* transition 模式 */
            React.startTransition(()=>{
                /* 低优先级任务 —— 改变搜索过滤后列表状态  */
                setSearchQuery(e.target.value)
            })
        }else{ /* 不加优化,传统模式 */
            setSearchQuery(e.target.value)
        }
    }
    return <div>
        <button onClick={()=>setTransion(!isTransition)} >{isTransition ? 'transition' : 'normal'} </button>
        <input onChange={handleChange}
            placeholder="输入搜索内容"
            value={value}
        />
       <NewList  query={query} />
    </div>
}

可以看到input里面更新的无比慢 严重卡顿

看下使用startTranstion后的效果

使用startTranstion后将渲染列表放置低优先级 这样就优先渲染input里面的更新了

3.为什么不用setTimeout
  • startTransition 的处理逻辑和 setTimeout 有一个很重要的区别,setTimeout 是异步延时执行,而 startTransition 的回调函数是同步执行的。在 startTransition 之中任何更新,都会标记上 transition,React 将在更新的时候,判断这个标记来决定是否完成此次更新 所以 Transition 可以理解成比 setTimeout 更早的更新
  • 对于渲染并发的场景下,setTimeout 仍然会使页面卡顿。因为超时后,还会执行 setTimeout 的任务,它们与用户交互同样属于宏任务,所以仍然会阻止页面的交互。那么 transition 就不同了,在 conCurrent mode 下,startTransition 是可以中断渲染的 ,所以它不会让页面卡顿,React 让这些任务,在浏览器空闲时间执行,所以上述输入 input 内容时,startTransition 会优先处理 input 值的更新,之后才是列表的渲染
4.实现原理

同步可中断的 setTimeout是宏任务 晚于startTransition执行 这样的方法执行是脱离了事务的,react 管控不到,所以就没法 batch 了。

// 源码实现
import ReactCurrentBatchConfig from './ReactCurrentBatchConfig';

export function startTransition(scope: () => void) {
 const prevTransition = ReactCurrentBatchConfig.transition;
 ReactCurrentBatchConfig.transition = 1;
 try {
   scope();
 } finally {
   ReactCurrentBatchConfig.transition = prevTransition;
 }
}

和batchedUpdates源码很类似 设置开关的方式 开关就是transition = 1 代表这是一个低优先级的过渡更新

useTranstion过度属性

上面介绍了 startTransition ,又讲到了过渡任务,本质上过渡任务有一个过渡期,在这个期间当前任务本质上是被中断的,那么在过渡期间,应该如何处理呢,或者说告诉用户什么时候过渡任务处于 pending 状态,什么时候 pending 状态完毕。

import  {  useTransition  }  from  'react' ; 
const  [ isPending ,  startTransition ]  =  useTransition ( ) ;

第一个是,当处于过渡状态的标志——isPending。
第二个是一个方法,可以理解为上述的 startTransition。可以把里面的更新任务变成过渡任务。
源码其实也就是实现了一个自定义hook

使用🌰


可以看到能够准确捕获到过渡期间的状态

实现原理
function mountTransition(): [(() => void) => void, boolean] {
  const [isPending, setPending] = mountState(false);

  const start = startTransition.bind(null, setPending);
  mountRef(start);
  return [start, isPending];
}
function startTransition(setPending, callback) {
  const priorityLevel = getCurrentPriorityLevel();
  if (decoupleUpdatePriorityFromScheduler) {
    const previousLanePriority = getCurrentUpdateLanePriority();
    ...
    runWithPriority(
      priorityLevel < UserBlockingPriority
        ? UserBlockingPriority
        : priorityLevel,
      () => {
        setPending(true);
      },
    );


    setCurrentUpdateLanePriority(DefaultLanePriority);

    runWithPriority(
      priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
      () => {
        const prevTransition = ReactCurrentBatchConfig.transition;
        ReactCurrentBatchConfig.transition = 1;
        try {
          setPending(false);
          callback();
        } finally {
          if (decoupleUpdatePriorityFromScheduler) {
            setCurrentUpdateLanePriority(previousLanePriority);
          }
          ReactCurrentBatchConfig.transition = prevTransition;
        }
      },
    );
  } 
...
}

从上面可以看到,useTranstion 本质上就是 useState + startTransition 。
通过 useState 来改变 pending 状态。在 mountTransition 执行过程中,会触发两次 setPending ,一次在 transition = 1 之前,一次在之后。一次会正常更新 setPending(true) ,一次会作为 transition 过渡任务更新 setPending(false); ,所以能够精准捕获到过渡时间。

Vue 的整体 diff 策略与 React 对齐,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差,因为在 Vue 3 初期引入过,后期因为收益不高移除掉了。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容