聊聊时间切片?
一、前言
-
最近正在学习 react 原理,了解到 react 当中有运用到时间切片这个概念。因此通过本篇文章,我想谈谈自己的理解;并且通过一部分的
Demo
,来说明如何运用时间切片
这个思想; -
在
react15
包括之前的时候,从根节点往下调和的过程中,是采用递归的方式进行的,这种方式是不可中断的,由于是同步进行的,而 js 线程和 GUI 线程是互斥的,因此当项目越来越大的时候,就会造成用户能够明显觉察到这个过程会比较卡顿,操作的时候(例如用户输入时)没有效果。其实就是 js 同步任务过长,导致浏览器掉帧了;而 react 团队花了两年时间进行重构,启用了fiber架构
;才最终解决了这个问题; -
而这其中,就采用了时间切片这个思想;
-
问:什么是时间切片?
-
答:简单来讲就是将同步任务切成一个一个小片;在保证浏览器不掉帧的情况下去执行这一片一片的任务;最终达到既能够将这个大任务执行完毕,又能够让浏览器有时间进行绘制;在用户的感知下,就是比较流畅的;
而今天我试图根据自己的理解来讲述时间切片的概念;如何运用,并提供几个案例;供大家参考;
二、准备
准备一个可运行的react项目Demo;或者一个能够运行的html都可以
备注:我使用的是一个react项目,还请准备一个html的同学自行翻译一下代码哈!
复制代码
三、同步渲染
先模拟一个同步渲染,写一个必须执行 5 秒钟的同步任务;并且为了更好的展示 js 的阻塞效果,我们写一个动画,如果在同步渲染的过程中我们的动画出现了故障,那么就能够明显的感受到 js 线程和 GUI 线程是互斥的这一说法;
import React, { PureComponent } from "react";
import "./index.css";
class Home extends PureComponent {
state = {
taskNum: 0,
fiberNum: 0,
};
syncCalc = () => {
let now = performance.now(), /* 开始时间*/
index = 0;
while (performance.now() - now <= 5000) { // 必须执行5秒种,且是同步
index++;
}
this.setState({ taskNum: index });
};
render() {
return (
<div>
<h1>时间切片案例</h1>
<p>
<span>测试:</span>
<input />
</p>
<button onClick={this.syncCalc}>同步渲染{this.state.taskNum}</button>
<div className="box"></div>
</div>
);
}
}
export default Home;
复制代码
下面是 css 的部分
.box {
width: 100px;
height: 100px;
background: red;
animation: normal-animate 5s linear infinite;
margin: 50px 0;
position: absolute;
}
@keyframes normal-animate {
0% {
left: 0px;
}
50% {
left: 100px;
}
100% {
left: 0px;
}
}
复制代码
我们可以看一下效果:
通过上面的效果,我们可以看到非常明显的卡顿效果,并且和我们预期的一样,会卡顿5秒钟,为了更好的说明这个问题,我用chrome的性能分析工具来进行说明;
我任意在这个过程中,截了一帧的运行过程,可以看到,在一帧的全部时间范围内,全部被js所占用,因此浏览器没有时间,或者说被迫的进行了丢帧;并且这个丢帧的情况会持续大约5秒钟;
上面的 css 动画,大家可以看到我的命名是 normal-animate,是为了区分 css3 动画;大家可以把动画换成 css3 动画;
.box {
width: 100px;
height: 100px;
background: red;
animation: css3-animate 5s linear infinite;
margin: 50px 0;
position: absolute;
}
@keyframes css3-animate {
0% {
transform: translateX(0px);
}
50% {
transform: translateX(100px);
}
100% {
transform: translateX(0px);
}
}
复制代码
如果使用css3动画;我通过实验,很遗憾的告诉各位,阻塞效果没有了,不是说 GUI 渲染线程和 JS 引擎线程是相互排斥的么?
其实这个是因为 css3 开启了硬件加速;我们看的动画在跑是因为 css3 动画由 GPU 控制,支持硬件加速,并不需要软件方面的渲染;在这里也要倡导大家在平时的开发中尽量使用 css3,为我们的项目赋能;
下面的案例我们还是把动画切换为 normal-animate;因为虽说我们可以借助 css3 动画,解决一部分动画的问题,但是用户的操作,比如在输入框中输入(大家可以试一下!),依然是阻塞的,因此我们还是需要解决这一个问题;那怎么解决呢?
至少在 react 中借助了requestIdleCallback
这个 Api 的思想;api 文档在这里;
这个 api 简单来讲,可以在浏览器每一帧(16.6ms)的过程中,如果有空余的时间,会调用这个 api 并且会传入一个参数(这个参数反映了还剩多少的可用时间)
根据这个我们来看一下使用切片渲染的方式;
四、切片渲染
import React, { PureComponent } from "react";
import "./index.css";
class Home extends PureComponent {
state = {
taskNum: 0,
fiberNum: 0,
};
syncCalc = () => {
let now = performance.now(),
index = 0; // 当前时间;
while (performance.now() - now <= 5000) {
index++;
}
this.setState({ taskNum: index });
};
fiberCalc = () => {
let now = performance.now(),
index = 0;
if (window.requestIdleCallback) {
requestIdleCallback(func);
}
const _this = this;
function func(handle) {
// 这个边界条件特别重要;handle.timeRemaining() 的返回值反映了是否还可以继续执行任务;
while (performance.now() - now <= 5000 && handle.timeRemaining()) {
index++;
}
if (performance.now() - now <= 5000) {
requestIdleCallback(func);
_this.setState({ fiberNum: index });
}
}
};
render() {
return (
<div>
<h1>时间切片案例</h1>
<p>
<span>测试:</span>
<input />
</p>
<button onClick={this.syncCalc}>同步渲染{this.state.taskNum}</button>
--
<button onClick={this.fiberCalc}>切片渲染{this.state.fiberNum}</button>
<div className="box"></div>
</div>
);
}
}
export default Home;
复制代码
如果这两个案例一起比较,大家就可以看到非常明显的变化了;同步渲染阻塞动画,不能进行输入等操作;但切片渲染不阻塞动画,可以进行输入等操作;
还是一样,为了更好的说明问题,我再次使用性能分析工具,进行查看;
我依然截了一帧的过程,可以看到,在几乎每一帧中,渲染和绘制都是有在做的,因此才导致我们看到的相对流畅的效果,为什么可以这样呢?那是因为我们的循环条件中,只有当浏览器还有空余时间时,也就是handle.timeRemaining()
为true
时,我们才进行同步任务的执行,当为false
的时候,就达到了中断同步任务的效果。在这个空档时期浏览器就有时间进行绘制了,然后我们有向浏览器请求了一个新的任务处理请求,周而复始,直到将任务做完!
五、浏览器兼容性
我们似乎实现了切片渲染,但是查阅 MDN 文档,可以发现requestIdleCallback
这个 api 的兼容性并不好,因此考虑到这个问题,我们可能并不能堂而皇之就直接使用这个api
;事实上,react 团队也并没有直接使用这个 api 去解决问题,而是运用另外的方式进行了 polyfill;今天先不跟各位介绍具体的方式;想要了解的同学可以看这里;
仔细想想,如果我们用现有的知识是否可以实现时间切片呢?其实时间切片的本质就是中断函数的执行,如果在恰当的时候能够中断同步任务的执行,是否是个正确的方向呢? 在 js 当中其实 generater 函数就可以中断函数的执行呀?那在什么时候中断呢? 要想不阻塞渲染,我们可以使用宏任务,因为根据事件循环机制,每一次的新事件循环并不会阻塞 GUI 线程的绘制,因此我们可以利用第一个主线程去执行任务。可能只能处理一个任务,但是我们可以在下一次事件循环中去唤醒新的任务的执行。周而复始;
一起看一下代码吧?
import React, { PureComponent } from "react";
import "./index.css";
class Home extends PureComponent {
state = {
taskNum: 0,
fiberNum: 0,
};
syncCalc = () => {
let now = performance.now(),
index = 0; // 当前时间;
while (performance.now() - now <= 5000) {
index++;
}
this.setState({ taskNum: index });
};
fiberCalc = async () => {
function* fun() {
let now = performance.now(),
index = 0;
while (performance.now() - now <= 5000) {
console.log(index);
yield index++;
}
}
let itor = fun(); // 得到一个迭代器;
let slice = async (data) => {
const { done, value } = itor.next();
await new Promise((resolve) => setTimeout(resolve));
if (!done) {
return slice(value);
}
return data;
};
const res = await slice();
console.log(res, "res");
};
render() {
return (
<div>
<h1>时间切片案例</h1>
<p>
<span>测试:</span>
<input />
</p>
<button onClick={this.syncCalc}>同步渲染{this.state.taskNum}</button>
--
<button onClick={this.fiberCalc}>切片渲染{this.state.fiberNum}</button>
<div className="box"></div>
</div>
);
}
}
export default Home;
复制代码
这里的宏任务我使用的是 setTimeout 去唤醒的;因此每一个事件循环都执行一个任务;下一个任务再在下一个事件循环中去执行;看一下效果;
再次看一下帧情况;
还不错,在每一帧的过程中都进行了渲染和绘制;
六、切片优化渲染
但是似乎这种方式利用率不是很高;因为每个事件循环只执行一个任务;而每一帧的空闲事件就没有得到一个很好的利用;能不能像requestIdleCallback
那样完全利用浏览器的空余事件呢?
其实我们可以尽可能的提高每一帧过程中,同步代码的执行次数;来实现这个优化,但关键怎么控制呢?获取可以定一个标准的过期时间,每一个时间循环中,我们只允许执行 10ms 的同步任务;而不是只执行一个同步任务;
import React, { PureComponent } from "react";
import "./index.css";
class Home extends PureComponent {
state = {
taskNum: 0,
fiberNum: 0,
};
syncCalc = () => {
let now = performance.now(),
index = 0; // 当前时间;
while (performance.now() - now <= 5000) {
index++;
}
this.setState({ taskNum: index });
};
fiberCalc = async () => {
function* fun() {
let now = performance.now(),
index = 0;
while (performance.now() - now <= 5000) {
console.log(index);
yield index++;
}
}
let itor = fun(); // 得到一个迭代器;
let slice = async (data) => {
const { done, value } = itor.next();
await new Promise((resolve) => setTimeout(resolve));
if (!done) {
return slice(value);
}
return data;
};
const res = await slice();
console.log(res, "res");
};
betterCalc = async () => {
function* fun() {
let now = performance.now(),
index = 0;
while (performance.now() - now <= 5000) {
console.log(index);
yield index++;
}
}
let itor = fun(); // 得到一个迭代器;
let slice = (time = 10) => {
let start = performance.now(),
index = 0,
isFinish = false;
while (!isFinish && performance.now() - start <= time) {
index++;
const { done } = itor.next();
isFinish = done;
}
if (!isFinish) {
setTimeout(() => {
index = slice(time);
});
}
return index;
};
slice();
};
render() {
return (
<div>
<h1>时间切片案例</h1>
<p>
<span>测试:</span>
<input />
</p>
<button onClick={this.syncCalc}>同步渲染{this.state.taskNum}</button>
--
<button onClick={this.fiberCalc}>切片渲染{this.state.fiberNum}</button>
--
<button onClick={this.betterCalc}>切片优化渲染</button>
<div className="box"></div>
</div>
);
}
}
export default Home;
复制代码
看一看效果;
其实现在根据用户视觉已经不太看的出来了,我们可以借助浏览器的 perfermance 分析工具;
可以和打印出来的index的大小结合着进行分析;因为index的值,实际上峰值更大了,因此可以反应,这种方式实际上更好的利用了浏览器的效率;
可以看到经过优化之后的代码其实已经大大提升了浏览器的利用率;在这里我需要提醒的是;案例中举例的同步的任务,是一个无论怎样都需要花 5 秒钟的任务;也就是说其中 index 越大,说明浏览器实际处理的任务就越多;因为正常的任务,肯定是一个定量的;需要浏览器一个一个执行;消耗这个定量的任务;案例中的任务只是为了更好的模拟同步任务,并看到效果;