Effective前端6:避免页面卡顿

TKZLorenzo 7年前
   <p>什么是页面卡顿?如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/27383c4ce6a218f15e9dfed91fc7b7cb.gif"></p>    <p>当拖动页面或者滚动的时候页面一卡一卡的,看起来不连贯,我们就说页面卡了,这是一种非常不友好的体验,怎么衡量页面卡顿的情况呢?</p>    <h2>1. 失帧和帧率FPS</h2>    <p>如果你家里买了电视盒的话,在设置里面应该会有一个输出设置:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/0b0b7438c829f0f33b3bb29f9de99e22.jpg"></p>    <p>上面选中的60Hz就是帧率(frame per second),即一秒钟60帧,换句话说,一秒钟的动画是由60幅静态图片连在一起形成的。60fps是动画播放比较理想、比较基础的要求。当然如果你的显卡要是连这个都支持不了的话那就没办法了。windows系统有个刷新频率也是这个意思。</p>    <p>所以卡了,就是失帧了,或者掉帧了,1秒钟没有60个画面,看起来不连贯了。这可能是因为在渲染某些帧所花的时间比较长,导致停留在这些帧的时间较长,所以画面停顿了。</p>    <h2>2. 渲染流程</h2>    <p>60fps就要求1帧的时间为1s / 60 = 16.67ms。浏览器显示页面的时候,要处理js逻辑,还要做渲染,每个执行片段不能超过16.67ms。实际上,浏览器内核自身支撑体系运行也需要消耗一些时间,所以留给我们的时间差不多只有10ms。这10ms里面需要做一些什么事情?在Chrome的开发者文档 <a href="/misc/goto?guid=4959736527892738708" rel="nofollow,noindex">Rendering Performance</a> 里面提到这个流程:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/4f15a80868ab81e5c9123e7a9864d5b2.jpg"></p>    <p>首先你用js做了些逻辑,还触发了样式变化,style把应用的样式规则计算好之后,把影响到的页面元素进行重新布局,叫做layout,再把它画到内存的一个画布里面,paint成了像素,最后把这个画布刷到屏幕上去,叫做composite,形成一帧。</p>    <p>这几项的任何一项如果执行时间太长了,就会导致渲染这一帧的时间太长,平均帧率就会掉。假设这一帧花了50ms,那么此时的帧率就为1s / 50ms = 20fps.</p>    <p>当然上面的过程并不一定每一步都会执行,例如:</p>    <ol>     <li>你的js只是做一些运算,并没有增删DOM或改变CSS,那么后续几步就不会执行</li>     <li>style只改了颜色等不需要重新layout的属性就不用执行layout这一步</li>     <li>style改了transform属性,在blink和edge浏览器里面不需要layout和paint,如下面 <a href="/misc/goto?guid=4959736528003945251" rel="nofollow,noindex">css trigger</a> 的说明: <img src="https://simg.open-open.com/show/f0fcb5d2a770427601253e7bc05ad059.png"></li>    </ol>    <p>发生掉帧的时候,我们可以使用的Chrome的devtools的timeline来观察这个过程。以最开始的例子做说明。</p>    <h2>3. 掉帧分析</h2>    <p>打开timeline的标签,勾上js profile和paint这两个选项,然后点击左边的记录按钮:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e5744a662e97ec56241dbca1dd8027e7.png"></p>    <p>在页面拖动地图,出现卡顿的情况后,点击关闭记录按钮,就会生成这次操作的详细过程,先看最上面的overview图:</p>    <p><img src="https://simg.open-open.com/show/f1a4da598875de845672e9853fb7fb85.png"></p>    <p>最上面一栏是帧率,顶点表示60fps,红色方格表示渲染时间比较长的帧,Chrome把这种情况叫做jank。可以看到上面有3个比较大的低谷,这并不是异常的失帧,这是Chrome检测到页面没有动了,idle空闲了,自动降低帧率。第二栏是CPU,黄色的为script,紫色的是CSS,蓝色是html,可以看到往往script占了比较高的CPU。</p>    <p>我们注意到在6s和8s中间CPU占用有一个比较大的峰值,并且失帧得比较厉害:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/ff56f4b2cf92ab490c6c305c15d70b91.png"></p>    <p>选中这段区域,进行放大查看:</p>    <p><img src="https://simg.open-open.com/show/e67d99c86e33015e661ac339810133fb.png"></p>    <p>可以看到有好几帧都超过了16.67ms,其中有一帧甚至达到了81.8ms,所以难怪卡得那么厉害。我们重点看一下这一帧里面发生了什么。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/dacbd78bb0ee4349940a3b803cc630b2.png"></p>    <p>这一帧的FPS只有1s / 81.8ms = 12fps,点击第二个tab展开:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/11c76a1104a67f53b6c696dcd2ffe5cb.png"></p>    <p>其中js的处理用掉了46.8ms(js里面还要更新dom),排第二的rendering花掉了22.9ms,这个rendering包括上面说的css计算和layout:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/b2830e130181a679545eb3fb0718c75b.png"></p>    <p>最后的Painting,时间还是比较少的,只花了2.5ms:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/270b6ef5729b09a339f7e969ee6b66f1.png"></p>    <p>所以最长的开销是js脚本,并且很可能js里面做了很多dom操作或者改了很多css,导致Rendering的时间也很长。</p>    <p>由于在开始记录之前勾选了js profile的选项,所以可观察这些js执行的具体开销,包括调用的函数栈及每个函数的执行时间:</p>    <p><img src="https://simg.open-open.com/show/cab910ea54fb5102f52fe852a048bffe.png"></p>    <p>最上面那个函数是XHR Ready State Change触发的,也就是说这一整段代码都是在一个ajax的success回调函数里面执行的。再往下可以看到回调函数里面调用的最耗时的两个函数:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c368b8925bce9eb6d56b39364265b1bd.png"></p>    <p>其一的showMapResut就花费了22.65ms,它又调了removeOldHouses和addNewHouses,这两个各自的时间约为11ms。</p>    <p>而另一个showResult的时间更多:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/3e2b45caac7ed424ea0302d05172ffd8.png"></p>    <p>快40ms,它下面的doShowResut和resizeContainer最为耗时。</p>    <p>所以我们找到4个最为耗时的函数。那接下来怎么呢?</p>    <p>上面已经提到,每一帧留给我们的时间只有10ms。所以可以考虑把上面那4个函数拆了,分别在4个连续的帧里面执行。这样应该会改善很多。</p>    <h2>4. 使用requestAnimationFrame拆分代码段</h2>    <p>我们把代码拆成一个个单元,每个单元就是一个task任务,每一帧执行之前就去取一个task执行。并且控制每个task的执行时间都在10ms以内。这样就可以解决问题。js在渲染每一帧之前会去调 requestAnimationFrame( 传一个函数的参数给它去执行)。所以用这一个api,并把task传给它。我们建立一个任务队列,为此封装一个Task类:</p>    <pre>  <code class="language-javascript">function Task(){      this.tasks = [];  }  //添加一个任务  Task.prototype.addTask = function(task){      this.tasks.push(task);  };  //每次重绘前取一个task执行  Task.prototype.draw = function(){      var that = this;      window.requestAnimationFrame(function(){          var tasks = that.tasks;          if(tasks.length){              var task = tasks.shift();              task();          }          window.requestAnimationFrame(function(){that.draw.call(that)});      });  };  </code></pre>    <p>使用的时候先new一个Task,然后调draw函数初始化。有任务的时候调addTask插到队尾,执行任务的时候调shift取出队头元素。</p>    <p>上面的实现其实有一点问题,因为requestAnimationFrame是全局的,每次new一个Task,进行draw的时候,会把上一个传给它的task给覆盖掉。但是这个是可以从代码层面上解决的,这里不展开讨论。</p>    <p>然后再封装一个mapTask的单例,存放map页面的task:</p>    <pre>  <code class="language-javascript">var aTask = null;     var mapTask = {      get: function(){          if(!aTask){              aTask = new Task();              aTask.draw();          }          return aTask;      },      add: function(task){          mapTask.get().addTask(task);      }  };  </code></pre>    <p>需要插入一个任务的时候就调一下mapTask.add,把上面4个十分耗时的函数分别当作一个任务插进去,下面是原本的执行逻辑:</p>    <pre>  <code class="language-javascript">updateHouses: function(houses){      var remainMultipleMarkers = null;      var housesFilter = null;          housesFilter = filterData.filterHouse(houses);      remainMultipleMarkers = filterData.removeOldHouses(housesFilter.remainsHouses);      housesFilter.newHouses = housesFilter.newHouses.concat(remainMultipleMarkers);      filterData.addNewHouses(housesFilter.newHouses);  },  </code></pre>    <p>现在把它改成两个task,并加到任务队列里面:</p>    <pre>  <code class="language-javascript">mapTask.add(function(){      housesFilter = filterData.filterHouse(houses);      remainMultipleMarkers = filterData.removeOldHouses(housesFilter.remainsHouses);  });  mapTask.add(function(){      housesFilter.newHouses = housesFilter.newHouses.concat(remainMultipleMarkers);      filterData.addNewHouses(housesFilter.newHouses);  });  </code></pre>    <p>同样地,把另外两个也这样改一下。</p>    <p>然后再拖动地图,查看效果,会发现页面瞬间爽滑了好多:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e567dd2113f359d90c164006c824cbff.gif"></p>    <p>当把页面拖快的时候还是会有一点卡顿,但是比之前已经好很多。这里还有优化的空间,例如后面两个函数的执行时间还是比较长,可以把这两个函数再继续拆分task。</p>    <p>看一下timeline:</p>    <p><img src="https://simg.open-open.com/show/95ec2c1a4bc401c3f316b35cb4142c0f.png"></p>    <p>可以看到4个task分别在4帧执行,并且Task3还有很大的优化空间。</p>    <p>除了拆分代码段的方法外,还有其它一些地方要注意:</p>    <h2>5. 其它的优化方法</h2>    <h3>(1)尽量减少layout</h3>    <p>获取scrollTop、clentWidth等维度属性时都会触发layout以获取实时的值,所以在for循环里面应该把这些值缓存一下。以下代码:</p>    <pre>  <code class="language-javascript">for(var i = 0; i < childs.length){    childs.style.width = node.offsetWidth + "px";  }  </code></pre>    <p>应该改成:</p>    <pre>  <code class="language-javascript">var width = node.offsetWidth;  for(var i = 0; i < childs.length){    childs.style.width = width + "px";  }  </code></pre>    <p>当循环次数很多的时候,优化版的代码会明显提高性能。</p>    <p>获取一个元素的样式(getComputedStyle)时,也会触发layout</p>    <p>另外,能够使用transform满足要求的就别使用position/width/height做动画。</p>    <h3>(2)简化DOM结构</h3>    <p>当DOM结构越复杂时,需要重绘的元素也就越多。所以dom应该保持简单,特别是那些要做动画的,或者要监听scroll/mousemove事件的。</p>    <h3>参考:</h3>    <ol>     <li><a href="/misc/goto?guid=4959736528108736354" rel="nofollow,noindex">淘宝首页性能优化实践</a></li>     <li><a href="/misc/goto?guid=4959736528197311487" rel="nofollow,noindex">如何评价页面的性能</a></li>    </ol>    <p> </p>    <p> </p>    <p>来自:http://www.renfed.com/2017/02/09/avoid-jank/</p>    <p> </p>