前沿
开发动画复杂或是很重的项目 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 的比较更新过程。
统计那个动态的部分最多只有三分之一,所以在编译的过程中发现那个比较小的动态部分,把它放到比较靠上的等级模块上,那么就可以称那个比较靠上的模块为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,所以导致在组件树太庞大的情况下,会造成用户卡顿,也无法对页面进行任何操作。
2、react16 Fiber
在 CPU 上,我们的主要问题是,在 JS 执行超过 16.6 ms 时,页面就会产生卡顿,那么 React 的解决思路,就是在浏览器每一帧的时间中预留一些时间给 JS 线程,React 利用这部分时间更新组件。当预留的时间不够用时,React 将线程控制权交还给浏览器让他有时间渲染UI,React 则等待下一帧再继续被中断的工作。Fiber就做到了可中断
浏览器帧的概念
- 某任务执行时间过长 超过16ms 渲染就会推迟 造成页面卡顿
- 浏览器大概一帧有10毫秒的空闲时间 我们可以在这个空闲做一些事情
Fiber就利用了这一思想
Fiber大体思路
- fiber是一个执行单元 每执行完一个单元 浏览器就会检查剩余多少时间 没有时间就将控制权让出
- 通过fiber架构 让协调过程变成可中断的 适时让出cpu执行权 提高响应速度
举例说明
<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)
}
}
由于兼容性和刷新帧率的问题,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,高优先级执行。
- 其他数据请求、使用了
suspense
、transition
这样的更新,是低优先级执行的。
注意⚠️:这个优先级的机制有别于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)
渲染完成之后,workInProgress树会赋值给current树
-
update时
点击p节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树
更新复用currentFiber的alternate属性(这个决定是否复用的过程就是Diff算法,后面章节会详细讲解
)也就是第一次渲染成功的fiber,如果一直没用变化他将一直复用currentFiber的alternate
可以得出双缓存的结论:
currtentFiber.alternate===workInProgress
workInProgress.alternate===currtentFiber
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;
}
四、 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数据结构如下:
在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 中其他的场景几乎都可以使用防抖和节流去提高响应性能。