1. 前言 网页回流与重绘有时可能会比 JS 的执行消耗更长的时间,比如插入十万条数据,这可能是一个伪需求,但是也是一个值得思考的命题。接下来我们的例子中的 html 都用如下的结构,点击页面按钮后,将 100000 个 li
插入到 ul
中去:
1 2 3 4 <body > <button onclick ="pushLi()" > 添加数据</button > <ul > </ul > </body >
我们先来看一下直接暴力插入 100000 条数据的写法:
1 2 3 4 5 6 7 8 9 10 11 function pushLi ( ) { const startTime = new Date ().valueOf () const count = 100000 const ul = document .querySelector ("ul" ) for (let i = 0 ; i < count; i++) { const li = document .createElement ('li' ) li.innerText = Math .random () * count ul.appendChild (li) } console .log (new Date ().valueOf () - startTime); }
点击按钮,页面大概消耗了 5s 的时间才把内容渲染出来,但是我们看控制台打印出的时间差只有 1s 左右。
这是因为浏览器本身存在优化机制,如果没进行一次 appendChild
操作就渲染一次页面,那么就要渲染十万次页面,这样是非常消耗性能的。所以浏览器就会将渲染任务缓存到队列中,在一定的范围内将页面的所有操作合并为一个渲染操作。但是十万条数据仍是一个庞大的数据,因此就会出现 js 已经计算完成,但是渲染仍为完成的情况,此时页面会卡住不动。
2. 初级优化 document.createDocumentFragment()
可以创建一个片段,这个片段并没有插入的 DOM 结构中,因此就省去了 DOM 查询的步骤,我们将创建的 li
没次都插入到 fragment
中,而不是每次都插入页面的 DOM 中,最后再将 fragment 插入到页面的 ul
中,这样的话就能一定程度提升 js 运算的性能,从而优化整体效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 function pushLi ( ) { const startTime = new Date ().valueOf () const count = 100000 const ul = document .querySelector ("ul" ) const fragment = document .createDocumentFragment () for (let i = 0 ; i < count; i++) { const li = document .createElement ('li' ) li.innerText = Math .random () * count fragment.appendChild (li) } ul.appendChild (fragment) console .log (new Date ().valueOf () - startTime); }
我们在控制台中查看打印的时间差,可以发现 JS 的执行速度提升了一倍:
那么渲染速度是否会因此提升呢?很遗憾并不,渲染仍在消耗相同的时间,因为我们最终插入页面的数据仍然是十万条,对于浏览器页面来说,仍是一次渲染十万条数据。
3. 节流插入 我们换一种思路,既然一次插入 100000 条数据会给浏览器造成巨大的压力,那么每次只要少渲染一点不久可以了吗。比如说我们在 100ms 渲染 100 条数据,十万条数据分 1000 次插入,也就是耗时 1000 * 100s,虽然耗时相比之下长了,但是页面不卡顿了,用户点击之后马上就可以看到数据,同时数据也在不断增长,直到十万条数据都出来,浏览器便可以停止渲染,这比让用户盯着屏幕卡顿 10 秒的效果好的多。
于是我们来优化一下写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 function pushLi ( ) { const ul = document .querySelector ("ul" ) const total = 100000 const once = 100 const loopCount = total / once let countOfRender = 0 function add ( ) { setTimeout (() => { const fragment = document .createDocumentFragment () for (let i = 0 ; i < once; i++) { const li = document .createElement ("li" ) li.innerText = Math .random () * total fragment.appendChild (li) } ul.appendChild (fragment) countOfRender += 1 if (countOfRender < loopCount) { add () } }, 100 ); } add () }
这样页面就不会卡顿了,但是这样的渲染并没有用到浏览器的性能极限。想想假如我们要想提高渲染效率,要从哪里下手呢?就是去将每次渲染的间隔时间尽量最小化,我们上面的例子使用了 100ms 这其实是不够极限的,只要时间段够短并且浏览器能渲染得过来,那就是合理的。那么最小到哪个阈值呢,我们都知道大部分屏幕的刷新率是 60hz 也就是每秒刷新 60 次,对于我们页面来说极限就是每秒渲染 60 次。我们用 100/60 得出每间隔 16ms 刷新一次是浏览器显示的极限,我们可以将 setTimeout
的时间间隔设置为 16 即可。
但是当我得意的将 setTimeout
的延时设置为 16ms 时突然发现事情并不对… …
发现改为了 16ms 之后,按理说,数据增长应该是平滑的,然而改了之后还是跟之前一样数据是一卡一卡的。emmmm,我知道 setTimeout 有最小值,查了一下最小值为 4ms,这也不对啊。上 MDN 查阅了一下发现了一篇文章 实际延时比设定值更久的原因:最小延迟时间 。
MDN 告诉我们,多次嵌套 setTimeout
可能会导致计时器的时间推迟。我们添加一个记录值,来记录上次渲染结束到本次渲染结束的时间间隔,也就是实际 setTimeout 延迟的时间,发现果然随着嵌套的深度,延迟执行的时间越来越长:
所以为了避免嵌套,那么我们就只能利用异步编程,来逐个循环执行定时器了,改写一下我们之前写好的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function pushLi ( ) { const ul = document .querySelector ("ul" ) const total = 100000 const once = 20 const loopCount = total / once let countOfRender = 0 function add ( ) { return new Promise (resolve => { setTimeout (() => { const fragment = document .createDocumentFragment () for (let i = 0 ; i < once; i++) { const li = document .createElement ("li" ) li.innerText = Math .random () * total fragment.appendChild (li) } ul.appendChild (fragment) countOfRender += 1 resolve () }, 16 ); }) } async function loop ( ) { await add () if (countOfRender < loopCount) { console .log (countOfRender); await loop () } } loop () }
这下就好多了,当渲染到第 287 次的时候,仍能保持时间间隔为 34ms。可以看出就算定时器没有嵌套,也会出现数据越多定时器的耗时间隔越长的情况。我分析可能会是这两个原因:要么是由于时间间隔过短,渲染没有跟上速度,导致了执行被阻塞,越来越往后推;要么是页面中还有定时器影响着新定时器的执行速度。
4. requestAnimationFrame 终于到了我们的主客 requestAnimationFrame
,其实原理跟我们上面讲的几乎是一摸一样,但是 requestAnimationFrame
提供了一种更优雅的方式,以及更好的优化性能,我们将上面的定时器改为 requestAnimationFrame
,方法就变成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function pushLi ( ) { const total = 100000 const once = 20 const loopCount = total / once let countOfRender = 0 let ul = document .querySelector ("ul" ) function add ( ) { const fragment = document .createDocumentFragment () for (let i = 0 ; i < once; i++) { const li = document .createElement ("li" ) li.innerText = Math .random () * total fragment.appendChild (li) } ul.appendChild (fragment) countOfRender += 1 loop () } function loop ( ) { if (countOfRender < loopCount) { window .requestAnimationFrame (add) } } loop () }
这感觉,丝滑般流畅:
但是,使用 requestAnimationFrame
也会出现后期时间间隔边长,也就是帧率变低的情况,也许就是因为页面数据过多造成性能的上不足导致的,但是相比与纯定时器,效果会更好一丢丢: