加载动画为什么不会被 JS 阻塞?实现大数据计算前的流畅过渡动画

前言

之前在写抽屉组件时,通常在数据请求回来的时候会加一个 loadingstate 去设置加载动画,这是一个比较常见的操作,但是有一天开发的时候我突然发现一个场景,初始化完成数据后,后面的动态请求不会再有加载动画,当我继续请求时并响应时,我发现抽屉内的交互被阻塞了,大概在响应的 1-3s 后才能够交互,这个问题的原因很容易发现,因为响应的数据太大,图表组件的计算占用了主线程,导致无法进入到处理 DOM 的部分

发现原因后,我又发现,打开抽屉的初始化的时候没有这个问题,初始化的数据也很大,为什么加载动画结束后不会出现大数据计算占用主线程导致无法交互的问题,而是结束动画后马上就可以交互了,通过这个上下文,我们可以得到抽屉加载数据动画的两个问题

  1. 为什么加载动画结束后就可以交互了?初始化也存在大量占用主线程的计算,为什么没有阻塞交互?
  2. 假设初始化计算是和加载动画一起结束的,为什么计算占用主线程这段时间加载动画不会被阻塞?

实现加载前的动画

该章节主要复现前言里的加载动画部分,你可以在这里找到文章里所有的代码

jsx 复制代码
const SearchBtn = () => {
  const [isClick, setIsClcik] = useState(false);

  return (
    <div>
      <span>search btn: </span>
      <button
        onClick={() => {
          console.log("clicked!");
          setIsClcik(true);
        }}
      >
        {isClick ? "clciked!" : "click"}
      </button>
    </div>
  );
};

const ListItem = ({ children }) => {
  let t = 0;
  for (let i = 0; i < 10000; i++) {
    for (let i = 0; i < 2000; i++) {
      t++;
    }
  }

  return <div className="list-item">{children}</div>;
};

const List = ({ data }) => {
  return (
    <div>
      <SearchBtn />
      {data.map((key) => (
        <ListItem key={key}>{key}</ListItem>
      ))}
    </div>
  );
};

export default function App() {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState([]);

  return (
    <div className="drawer">
      <button
        className="drawer-op-btn"
        onClick={() => {
          setLoading(true);
          setTimeout(() => {
            setData(Array.from({ length: 20 }).map((_, index) => index));
            setLoading(false);
          }, 1000);
        }}
      >
        reset to loading
      </button>
      <button
        className="drawer-op-btn"
        onClick={() => {
          setTimeout(() => {
            setData(Array.from({ length: 21 }).map((_, index) => index + 100));
          }, 1000);
        }}
      >
        update data
      </button>
      <div className={classNames("drawer-body", loading && "loading")}>
        {loading && <div className="loader"></div>}
        {!loading && data.length !== 0 && <List data={data} />}
      </div>
    </div>
  );
}

下面是初始化的效果

可以看到,当加载动画结束以后,<SearchBtn /> 马上响应了交互,并打印信息

如果说没有加载动画,效果是怎么样呢?比如点击 update data

jsx 复制代码
<button
  className="drawer-op-btn"
  onClick={() => {
    setTimeout(() => {
	  setData(Array.from({ length: 60 }).map((_, index) => index + 100));
    }, 0);
  }}
>
  update data
</button>

点击 update data 后,响应了几次点击,但是过了一会儿就不响应了,GIF 里我一直在点击,但是点击事件一直累计了一段时间才随着 button 的响应点击交互后才执行

让我们简单分析一下为什么会这样,当点击 udpate data 以后,开始发请求,请求使用的 setTimeout 模拟,它的时间为 1000ms,而 4 次的 clicked 打印就是在 1000ms 触发的,当数据请求回来以后,组件 <ListItem /> 的初始化计算开始,而且数量很多,导致主线程被长时间的占用,渲染被阻塞了

jsx 复制代码
const ListItem = ({ children }) => {
  let t = 0;
  for (let i = 0; i < 10000; i++) {
    for (let i = 0; i < 2000; i++) {
      t++;
    }
  }
  ...
};

下面是一张主线程被阻塞的可视化图

组件内的初始化计算让主线程停留在了黄色区域,无法进行到绿色区域,也就是 paint 所在的区域

PS: S: Style 样式计算, L: Layout 布局计算, P: paint 绘制也可以理解为最终的渲染

同时点击事件的回调也是在主线程执行的,也就是说初始化计算时间太长也会导致回调执行阻塞,这就是为什么 <SearchBtn /> 在等待一段时间响应交互后突然打印一堆 clicked

好,现在我们知道主线程长时间被占用会导致渲染/点击事件回调执行的阻塞,那就来到了我们第一个问题

为什么加载动画结束后就可以交互了?初始化也存在大量占用主线程的计算,为什么没有阻塞交互?

在我们初始化 GIF 结束后,我们点击 <SearchBtn /> 是不会经历像 update data 点击后的情况的,马上就会响应交互,难道这个时候 <ListItem /> 没有进行计算吗?

这个问题好解释,因为 react 是批量更新状态的,初始化计算在 dataloading 状态更新到浏览器前完成

jsx 复制代码
export default function App() {
  ...
  return (
    <div className="drawer">
      <button
        className="drawer-op-btn"
        onClick={() => {
          ...
          setTimeout(() => {
            setData(Array.from({ length: 20 }).map((_, index) => index));
            setLoading(false);
          }, 1000);
        }}
      >
        reset to loading
      </button>
      ...
  );

但是仔细一想,又不太对劲,初始化计算是在 loading 状态更新前完成的,也就是说加载动画执行的时候,主线程是被占用的,那么就和我们之前给的可视化有冲突了,加载动画也算渲染呀,为啥没被阻塞呢?

所以就来到我们的第二个问题

假设初始化计算是和加载动画一起结束的,为什么计算占用主线程这段时间加载动画不会被阻塞?

我们来看下我们的加载动画的实现

css 复制代码
.loader {
  width: 30px;
  height: 30px;
  border: 4px solid #3498db;
  border-top: 4px solid transparent;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

注意我们的实现圆环旋转用的是 transform,关键就在于 transform,答案是

整个 transform 动画被浏览器丢给 GPU 执行(GPU 加速),不会被阻塞

如果补充一点小知识会让答案更容易理解,JS 线程和 GUI 渲染线程同属渲染进程,它们是互斥的,互斥可以回顾之前的主线程被阻塞的可视化图,但是 transform 的动画不需要经过绿色的 paint 阶段,也就是说它是独立与主线程的,是给 GPU 执行的

但是有些动画是需要经过 paint 的,比如下面这个打点的加载动画

css 复制代码
/* JSX */
/* {loading && <div className="loader"></div>} */
@keyframes dot-animation {
  0% {
    content: ".";
  }
  33% {
    content: "..";
  }
  66% {
    content: "...";
  }
  100% {
    content: ".";
  }
}

.dot::after {
  content: ".";
  animation: dot-animation 1s steps(4) infinite;
}

如果用它来作为我们的加载动画,就会被阻塞了,效果如下

这个动画只运行了 setTimeout 模拟的 1000ms,当 <ListItem /> 计算开始时就被阻塞了,就符合我们之前的可视化了

什么?你问我什么 css property 应用到动画上不会被 js 阻塞?

这是两个问题

  1. 什么 css property 应用到动画 ,能应用到动画上的 css property 实在是太多了,但是不能应用到动画上的 css property 也太多了,比如 visibility,所以写动画的时候先跑起来,再研究是不是因为 js 阻塞导致的动画停滞
css 复制代码
/* PS: opacity 可以生效 */
@keyframes change-visibility {
  0% {
    visibility: hidden;
  }
  25% {
    visibility: visible;
  }
  100% {
    visibility: hidden;
  }
}
  1. 会不会被 js 阻塞 ,试试就知道了 O(∩_∩)O(不想试就直接上 transform 吧!)

参考资料

  1. [译]渲染性能优化之渲染的5个阶段(JS->Style->Layout->Paint->Composite)
  2. 【事件循环】【前端】事件原理讲解,超级硬核,忍不住转载 - free-coder - bilibili
  3. CSS 动画会不会被 JS 阻塞? - 知乎
相关推荐
CSR-kkk10 分钟前
前端工程化速通——①ES6
前端·es6·速通
yt9483215 分钟前
C#实现CAN通讯接口
java·linux·前端
前端小巷子15 分钟前
Cookie与Session:Web开发中的身份验证与数据存储
前端·javascript·面试
小磊哥er27 分钟前
【前端工程化】前端开发中如何做一套规范的项目模版
前端
Wetoria38 分钟前
管理 git 分支时,用 merge 还是 rebase?
前端·git
前端开发与ui设计的老司机1 小时前
UI前端与数字孪生融合新领域:智慧环保的污染源监测与治理
前端·ui
一只小风华~1 小时前
Web前端开发: :has功能性伪类选择器
前端·html·html5·web
成遇1 小时前
Eslint基础使用
javascript·typescript·es6
Mr_Mao5 小时前
Naive Ultra:中后台 Naive UI 增强组件库
前端
前端小趴菜057 小时前
React-React.memo-props比较机制
前端·javascript·react.js