复杂应用的 CSS 性能分析和优化建议

by Z.J.T on November 22, 2012

译自:Profiling CSS for fun and profit. Optimization notes.

我最近正在为一个所谓的单页应用做性能优化,这是一个高度异步化、富交互并且使用了很 CSS3 效果的 web app,不单单是用点圆角和渐变之类的,还有阴影、旋转变换、过渡效果、半透明,当然还会用点 CSS 伪类技巧和一些尚在实验阶段的特性。

暂时抛开 Javascript/DOM 性能上的瓶颈,我准备先来研究一下旧版的 CSS,然后搞清楚为什么 UI 这么好性能却很差,虽然旧版的 js 逻辑并没有很大的改动但是它还是让我很火大,只要稍微玩一玩就知道它根本不够流畅。

这是不是样式问题造成的呢?

很巧的是,Opera 最近几天刚好发布了一个样式性能调试器(紧随 WebKit 刚刚的一个关于样式性能分析的 bug 修复),用来展示 CSS 选择器的性能,文档 reflow,repaint 甚至 document 、css 的解析时间。

 

我以前并不喜欢只在一个环境下调试,当然也不喜欢只针对一个渲染引擎进行分析(特别是只用在一种浏览器里的),不过这次决定试试,毕竟不同的样式规则在不同引擎里是相似的,而且很多是唯一的规则。

唯一一个跟这个工具比较像的是 WebKit 开发者工具中的 Timeline tab,但是没有那么好用,它不显示 reflow/repaint/selector 的时间,唯一的办法是把这些数据倒出来,然后人工分析。

下面就是一些我用 Webkit 和 Opera 的工具分析出来的观点。

开始之前,我得提醒一下大家,我的大部分结论最试用于大而且负责的应用,特别是那些有上千个 DOM 节点、极度富交互的的应用。在我自己的项目中,我减少了网面加载时间大约 650ms (500ms 来自的样式重新计算,100ms 来自 repait,50ms 来自 reflow 的时间减少) ,整个应用变得更快了,特别是在比 IE7 还老的破浏览下。

对于相对简单的页面来说,你先应该去看看其他的一些优化建议。

优化建议

 

1. 其实并不存在最快的规则,我们通常做法是把样式模块合并到一个文件中试用,这样会导致其中的一部分样式并没有被特定的页面用到。其实把没用的样式规则拿掉是优化 CSS 的最好的方法之一,因为这样的话就可以省去多余的样式匹配,当然合并多个文件到一个大文件还是有好处的,比如说可以减少请求数,但是我们应该只把跟当前页面有关的样式打包到一起。

其实这也不算什么新发现了,Page Speed 早就有过这条建议。不过,我还被它的效果吓到了,去掉多余样式让我节省了大约 200-300ms 的选择器匹配时间(根据Opera 调试工具的结果)。

2. 减少 reflow,这是另外一条总所周知的规则,起了非常大的作用。性能消耗多的样式规则在只有少量 reflow/repaint 的时候并不没有产生那么的性能消耗,但是一条很简单的规则却有可能让这个网页慢起来,所以减少 reflow 必须和减少样式复杂度一起做起来。

3. 性能消耗最大选择器应该是 * 和多 class 选择器(比如 .foo.bar, .foo.bar.baz qux),我们都最大这个,不过最好还是通过分析确定一下

4. 要注意那些本来不需要用的全选符 *,我发现过一个选择器是这样的:button > *,但是我找遍了整个网站,发现所有按钮只有一个 <span> 在里面,所以把 * 换成 span 就可以换来很多的提升,因为浏览器已经不需要去匹配所有的元素了(因为从右向左匹配原则),只需要查找所有的 span,而 span 的数量远比所有元素少,然后再查找父元素是 button 的 span 即可,所以应该把 * 替换成其他标签,不过通常比较麻烦。

这种优化的问题是损失了一些可扩展性,因为修改 HTML 之后也需要修改 CSS,你也不能日后再去修改按钮的样式,这样会产生一些无用的规则,所以这条规则我还不怎么确定,还是针对自己的实际情况来做优化吧,除非渲染引擎修改选取规则否则这种优化可以先忽略(译者注:其实通过加 class 而不用 tag 就可以解决这个问题)。

5. 我用这种方法快速找出可以替换成 tag 的 * 选择符。

$$(selector).pluck('tagName').uniq(); // ["SPAN"]

这个方法依赖于 Prototype.js 里的  Array#pluckArray#uniq . 未来有 ES5 和新的选择器或许可以这样:

Object.keys([].slice.call( document.querySelectorAll('button > *')) .reduce(function(memo, el){ 
memo[el.tagName] = 1; return memo; 
}, {})); 

 

6. 在 Opera 和 WebKit 中 [type=”…”] 比 input[type=”…”] 更加耗时,可能是浏览器认为属性检测对于特定的标签来说才是比较通用方法。

7.跟居 Oprea 的分析工具, ::selection 和 :active 是最为耗时的选择器。可理解的是 :active 为什么还是,但是 ::selection 就不知道为什么了,可能是分析工具的bug,或者浏览器本身的渲染方式决定。
8. 在 Opera 和 WebKit 中, border-radius 是最耗时的属性之一,甚至比 shadow 和 gradient 和耗时,要注意的是它不影响布局生成时间只影响 repaint 时间。
下面的测试看到,我创建了有 400个按钮的页面:

Buttons
我开始查看这么多的样式是怎么影响渲染性能的(分析工具的 repaint time),最简页面的按钮只有这样样式

background: #F6F6F6; 
border: 1px solid rgba(0, 0, 0, 0.3); 
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 
font-size: 14px; height: 32px; vertical-align: middle; 
padding: 7px 10px;
 float: left; 
margin: 5px; 

Btn Before

这些仅仅花了 6ms 就渲染完成(Opera),然后我开始加一些样式,最终的样式如下:( 足足花了 177ms 用于 repaint,增加了 30 倍)

text-shadow: rgba(255, 255, 255, 0.796875) 0px 1px 0px; 
box-shadow: rgb(255, 255, 255) 0px 1px 1px 0px inset, rgba(0, 0, 0, 0.0976563) 0px 2px 3px 0px; border-radius: 13px; 
background: -o-linear-gradient(bottom, #E0E0E0 50%, #FAFAFA 100%); 
opacity: 0.9; color: rgba(0,0,0,0.5); 

Btn After

每个属性拿出来分析的结果如下:

text-shadow 和 linear-gradient 是最不耗时的. Opacity 和透明 rgba() 样色相对好一些。box-shadow 带这inset one (0 1px 1px 0) 稍微比(0 2px 3px 0) 好一点. border-radius 更高一些。

  • 我也试了transform: ratete(1deg),得到的数据也非常高,稍微滚定一下 400 个按钮的页面还是显得非常不流畅,我觉得不应该轻易使用 transform,然后这只是一个缺少优化的例子?我挺好奇的,所以做了不同角度的测试:可以发现尽管只有 0.01deg 的转动也是想当耗时,随着度数的增加性能不断下降,但是并不是线性的增长,到了 45deg 有一个回落直到 90deg。

 

这里还有很多测试的空间,我很想看到transform 的其他属性在图同浏览器中表现如何。

9. 在 Opera 中,网页缩放比例影响布局性能,缩小比例反而越耗时,这很好理解,毕竟同一个地方现在要渲染更多的东西,不过为了让实验能保持一直,要确保所有的测试是在一个缩放比例中进行的,我发现我得重新在不同的缩放比例中做上面的这些式样。

说到缩放,测试一下缩小字体会不会影响整体的性能可能会有用。
10. 在Opera 中,缩放窗口大小不会影响渲染,看起来布局,渲染和样式的计算和窗口大小没上面关系。

11. 在 Chrome 中却相反。

12. 在 Opera 里,刷新页面会导致性能下降,并且呈持续下降趋势,从下面的图可以看出在刷新 40 个页面之后渲染时间不断地变慢(底部的红色方块对应每次页面加载时间,其中有一小段的刷新时间间隔),到最后 Paint 时间几乎是第一次的 3 倍,看起来像是每个页面都泄漏了些东西出来,处于谨慎,我总是去前 5 次平均来计算一个正常的数值。
Profiler Page Reload

用来测试刷新页面的脚本:

window.onload = function() { 
 setTimeout(function() { 
    var match = location.href.match(/?(d+)$/); var index = match ? parseInt(match[1]) : 0; 
    var numReloads = 10; index++; if (index < numReloads) { 
    location.href = location.href.replace(/?d+$/, '') + '?' + index; 
    } 
  }, 5000); 
}; 

我还没在 WebKit/Chrome 中测试上面的情况。

13. 我遇到过一个想当恶心的情况是这样,在 SASS 语法中:

a.remove > * { 
/* some styles */ 
.ie7 & { margin-right: 0.25em; } 
} 

它会编译成这样:

a.remove > * { 
/* some styles */ } 
.ie7 a.remove > * { 
margin-right: 0.25em 
} 

注意那个 IE7 选择器,为什么会带上通配符,我们知道通配符是很慢的,所以在除了 IE7 之后的所有浏览器(其实就是在 <body> 上加了 .ie7 class 的)中这将会导致额外的性能消耗,这个很明显是用得最差的 IE7 专用选择器

这还有一个类似的:

.steps { 
  li { /* some styles */ 
    .ie7 & { zoom: 1; } 
  } 
} 

会编译成:

 
.steps li { 
  /* some styles */ 
} 
.ie7 .steps li { 
zoom: 1 
} 

这里例子里,渲染引擎还是需要去找每个 <li>(在 .steps 下的),直到它发现在 dom 树网上并没有任何 class 是 ie7 的元素。

我这里的情况是,我在最终编译完的 css 中用了非常多的跟 ie7 ie8 这样的选择器,很多还是通配符。有个很简单的办法,就是把所有 IE 相关的样式移到一个单独的文件,然后有条件注释加载,然后就不会有那么多的选择器需要去解析,匹配,渲染。

不过,这种优化是有代价的,我发现把 IE 相关的样式归到单独文件维护起来确实是一个问题,到了要修改、增加、删除一些样式的时候,都要去那个文件里修改,或许将来 SASS 这样的工具能够为这样的情况做一些优化,把这些单独做到一个用条件注释加载的文件中。

14. 在 Chrome 中(包括 WebKit 内核的其他浏览器),你可以用开发者工具中 Timeline 栏看到 repaint/reflow 和样式计算性能,并且支持导出 JSON 格式的数据,我第一次看到这样功能是在今年的 Performance Calendar 上,由Marcel Duran 完成的,他用 node.js 和一个简本来解析和提取数据。

不过,他的脚本包含了我不想要的”重新计算样式“的时间,我也不想要页面刷新的数据,所以我抽取了一个简单版本,他会分析所有数据,过滤出跟 repaint,布局和样式重算的数据,然后把这些数据加起来。

var LOGS = './logs/', fs = require('fs'), 
files = fs.readdirSync(LOGS); 
files.forEach(function (file, index) { 
var content = fs.readFileSync(LOGS + file), log, 
times = { Layout: 0, RecalculateStyles: 0, Paint: 0 }; 
try { log = JSON.parse(content); } 
catch(err) { 
console.log('Error parsing', file, ' ', 
err.message); } 
if (!log || !log.length) return; 
log.forEach(function (item) { 
  if (item.type in times) { 
    times[item.type] += item.endTime - item.startTime; 
  } 
}); 
  console.log('nStats for', file); 
  console.log('n Layouttt', times.Layout.toFixed(2), 'ms');
  console.log(' Recalculate Stylest', times.RecalculateStyles.toFixed(2), 'ms'); 
  console.log(' Paintttt', times.Paint.toFixed(2), 'msn'); 
  console.log(' Totalttt', (times.Layout + times.RecalculateStyles + times.Paint).toFixed(2), 'msn'); 
}); 

 

在拿到 timeline 数据并且跑了过滤脚本之后,你可以拿到数据:

 Layout 6.64 ms Recalculate Styles 0.00 ms Paint 114.69 ms Total 121.33 ms 

用 Chrome 的 Timeline 和这个脚本,我试了上面的那个在 Opera 中做的按钮测试:

跟 Opera 差不多, border-radius 似乎是性能最差的,不过 linear-gradient 比在 Opera 中性能消耗多得很多, box-shadow 的性能消耗也比 text-shadow 多很多。

值得一提的是,Timeline 只提供了 Layout 的信息,然而 Opera 的还有 Reflow,我不确定reflow的数据是包含在 layout 数据里还是被忽略,为了更准确的测试,以后会去搞搞清楚。

15. 在我快做完这些测试的时候,WebKit 已经加了一个跟Opera 类似的选择器性能分析工具。

我没办法做那么多测试,不过有件事得说一下,在 WebKit 中选择器的匹配有少部分比 在Opera 中的快,同样的一个 HTML(就是那个优化之前的单页应用)在 Opera 中花了 1144ms 在选择器上,但是在 WebKit 中只有 18ms,差了 65倍,要嘛是有的东西没有被算到最终数据中,要嘛是 WebKit 真的在选择其匹配上快非常非常多。Chrome 的 timeline 显示,样式计算花了大约 37ms,repaint 用了大约 52ms(opera 中花了 225ms,虽然不一样,但是挺接近的),在 WebKit 中保存不了 Timeline,所以 reflow 和 repaint 的数据也就看不到了。

总结

  • 减少选择器的数量 (包括这样的跟浏览器有关的: .ie7 .foo .bar
  • 不要用通配符(包括想这样不规范的类型选择符[type="url"]
  • 页面的缩放比例是会影响 CSS 性能的(比如: Opera)
  • 窗口大小也会影响 CSS 性能 (比如: Chrome)
  • 页面反复重新加载会降低 CSS 性能 (比如 Opera)
  • “border-radius” 和 “transform” 是性能最差的 CSS 属性(至少在WebKit & Opera 中是)
  • 基于 WebKit 的浏览器中,Timeline 栏可以算出总的样式 计算/reflow/repaint 时间
  • WebKit 中的选择器匹配快得多很多

疑问

在最后,我还有几个关于 CSS 性能的疑问:

  • 带引号属性和不带的差别(比如 [type=search][type="search"]). 会怎么影响性能呢?
  • 使用多个 box-shadows/text-shadows/backgrounds 时的 CSS 性能有什么特征吗?比如 1 个 text-shadow 对比 3 个、5个的时候。
  • 伪元素选择器的性能如何?(:before, :after)。
  •  border-radius 不同的值性能如何?是不是越大值越消耗性能?是不是线性增长的?
  • !important 会不会影响性能?怎么影响?
  • 硬件加速会不会影响性能?怎么影响?
  • 不同的样式组合是否性能差不多?(比如  text-shadow 和 linear-gradient 比对 text-shadow 和一个单色背景)