聊聊时间切片?

聊聊时间切片?

一、前言

  • 最近正在学习 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;
  }
}
复制代码

我们可以看一下效果:

切片.jpg

通过上面的效果,我们可以看到非常明显的卡顿效果,并且和我们预期的一样,会卡顿5秒钟,为了更好的说明这个问题,我用chrome的性能分析工具来进行说明;

截屏2022-03-06 上午10.58.34.png

我任意在这个过程中,截了一帧的运行过程,可以看到,在一帧的全部时间范围内,全部被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;
复制代码

切片.jpg

如果这两个案例一起比较,大家就可以看到非常明显的变化了;同步渲染阻塞动画,不能进行输入等操作;但切片渲染不阻塞动画,可以进行输入等操作;

还是一样,为了更好的说明问题,我再次使用性能分析工具,进行查看;

截屏2022-03-06 上午11.03.11.png

我依然截了一帧的过程,可以看到,在几乎每一帧中,渲染和绘制都是有在做的,因此才导致我们看到的相对流畅的效果,为什么可以这样呢?那是因为我们的循环条件中,只有当浏览器还有空余时间时,也就是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 去唤醒的;因此每一个事件循环都执行一个任务;下一个任务再在下一个事件循环中去执行;看一下效果;

优化.jpg

再次看一下帧情况;

截屏2022-03-06 上午11.07.38.png

还不错,在每一帧的过程中都进行了渲染和绘制;

六、切片优化渲染

但是似乎这种方式利用率不是很高;因为每个事件循环只执行一个任务;而每一帧的空闲事件就没有得到一个很好的利用;能不能像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;
复制代码

看一看效果;

优化.jpg

其实现在根据用户视觉已经不太看的出来了,我们可以借助浏览器的 perfermance 分析工具;

截屏2022-03-06 上午11.08.48.png

可以和打印出来的index的大小结合着进行分析;因为index的值,实际上峰值更大了,因此可以反应,这种方式实际上更好的利用了浏览器的效率;

可以看到经过优化之后的代码其实已经大大提升了浏览器的利用率;在这里我需要提醒的是;案例中举例的同步的任务,是一个无论怎样都需要花 5 秒钟的任务;也就是说其中 index 越大,说明浏览器实际处理的任务就越多;因为正常的任务,肯定是一个定量的;需要浏览器一个一个执行;消耗这个定量的任务;案例中的任务只是为了更好的模拟同步任务,并看到效果;

七、参考文档

时间分片技术(解决 js 长任务导致的页面卡顿)

CSS动画之硬件加速

react如何polyfill requestIdleCallback

React技术揭秘

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享