如果你还不了解它们,它们其实就是页面的视觉结构随滚动变化的站点。正常情况下,页面上的元素按比例缩放、旋转或者移动到滚动的位置。
我们视差效果的演示页面
你喜不喜欢视差网站是一回事,但是我们能确定的是这绝对是一个性能黑洞。原因是当你滚动时,浏览器会试图对新内容出现的地方(根据滚动的方向)进行性能优化,总的来讲,在滚动中视觉上越少更新浏览器性能越好。对于视差网站来说这很少见,因为在整个页面上大的视觉元素会多次发生改变,从而导致浏览器必须对整个页面进行重绘(为什么是性能黑洞,可以参考我的这篇文章《Web滚动性能优化实战》)。
把视差网站归纳为下面的特性是合理的:
1、 当你向上或者向下滚动页面时,背景元素改变位置、旋转或者缩放。
2、 页面内容,例如文本或者小图片,以特别的从上到下的方式滚动。
我们之前介绍过滚动性能及其优化方式,你可以以此来改进应用的响应能力。本文将建立在此基础上,所以你需要先读一下上面这篇文章。
所以现在的问题是,如果你正在构建一个视差滚动网站,是必须要进行代价昂贵的重绘,还是有其它方法可以采用来最大限度的提高性能?让我们来看看可供选择的方法。
方法1:使用DOM元素和绝对定位
这可能是大多数人选择的方式。页面里有许多元素,当滚动事件触发时,许多视觉上的更新会发生在这些元素上。这里我展示了一个演示页面。
如果你开启了开发者工具时间轴的frame模式,并且上下滚动,你会注意到有代价昂贵的全屏绘制操作。如果你滚动多次,你也许可以在一个单独的帧里看到多个滚动事件,每一个都会触发布局工作。
开发者工具展示了一帧里有大量的绘制操作和多个由事件触发的布局
重要的是要牢记,为了达到60fps(与典型的显示器刷新率60Hz相匹配),我们必须要在差不多16ms内完成所有事情。在这第一个版本中,我们每当得到一个滚动事件,我们就要执行一次视觉更新,但是正如我们在前面的文章-《用requestAnimationFrame实现更简单动画》和《Web滚动性能优化实战》里讨论到的一样,这与浏览器的更新节奏并不一致。所以我们要么错过帧,要么在一帧里完成太多的工作。这会让你的站点很容易看起来不舒服和不自然,导致用户感觉失望。
让我们把视觉更新的代码从滚动事件中移到requestAnimationFrame回调里,并且在滚动事件的回调里简单的获取滚动的值。我们在第二个演示中展示了这个变化。
如果你重复滚动测试,你可能会注意到有轻微的改善,虽然并不多。原因是由滚动触发的布局操作代价昂贵,而现在我们只在每帧中执行一次布局操作。
开发者工具展示了一帧里有大量的绘制操作和多个由事件触发的布局
我们现在在每帧里可以处理一个或者上百个滚动事件,但最重要的是,我们仅仅存储最近的一个滚动值,供requestAnimationFrame回调触发时使用,并执行视觉上的更新。关键是我们已经从每次接收到滚动事件时进行视觉更新优化为在浏览器给我们的合适时机进行处理。你是不是觉得这相当给力?
这个方法的主要问题是,无论使用requestAnimationFrame与否,我们基本上都会生成整个页面的层,在移动这些视觉元素时需要大量和代价昂贵的重绘。通常重绘会是一个阻塞操作(虽然这点将会优化),这意味着浏览器不能同时进行其它工作,而我们经常有可能超过浏览器16ms的帧的处理时限,这代表会出现性能上卡顿的情况。
方法2:使用DOM元素和3D转换
除了绝对定位之外,另外一种我们可以采用的方法就是3D转换(transform)。在这种情况下我们可以看到每个用3D转换处理的元素都会产生新的层。相比之下,在方法1中,如果有任何变化时,我们必须要重绘页面上一大部分的层。
这意味着使用此方法情况会大为不同:我们可能对应用了3D转换的任何元素都会有一个层。如果通过更多元素的转换做到这一点,我们不需要重绘任何层,GPU能够处理移动元素和合成整个页面。也许你想知道为什么用3D转换替代3D,原因是2D转换不能保证得到一个新的层,而3D转换可以。
这是另一个使用了3D转换的演示。滚动时你可以看到性能已经大有改观。
很多时候人们使用-webkit-transform:translateZ(0)这个技巧,能够看到有奇妙的性能改善(宇捷注:关于这种方式,其实就是利用3D转换来开启浏览器硬件加速,属于一种Hack。国内很少有资料提及,而国外有很多移动App开发性能优化的文章提到。国内可以看看《改善HTML5网页性能》,国外可以看看《IncreasingPerformance of HTML and JavaScript on Mobile Devices》)。这种方式现在可以正常工作,但是会带来一些问题:
1、 它并不是浏览器兼容的;
2、 它强迫浏览器为每一个转换的元素创建新的层。大量的层会带来其它性能瓶颈,所以需要有节制的使用。
3、 它在某些Webkit版本的移植上被禁用。
所以,你如果采用这种方法需要非常谨慎,这对解决问题来说是一个临时方案。在完美的情况下我们甚至都不会考虑它,而且浏览器每天都在改进中,谁知道也许哪天我们就不需要它了。
方法3:使用固定定位(Fixed Position)的Canvas或者WebGL
我们最后要考虑的方法就是在页面上采用固定定位的Canvas,而把转换的图像绘制在上面。乍看之下,这可能不是最高效的解决方案,但是它有几个好处:
- 我们不再需要大量合成工作,因为页面只有一个元素 - Canvas;
- 我们可以高效的通过硬件加速处理一个单独的bitmap;
- Canvas2D API非常适合我们要执行的转换类型,这意味着开发和维护更容易管理。
使用Canvas元素为我们提供了一个新的层,但是它只有一层,而在方法2中我们为每一个应用3D转换的元素都创建了一个新层,所以有额外的工作量来把这些层合成在一起。
如果你看看这种方法的演示,并且在开发者工具中观察,你会发现它的性能更加优异。在这个方法里,我们只需在Canvas上调用drawImage API、设置背景图像,以及每一个要在屏幕上正确位置绘制的色块。
- /**
- * Updates and draws in the underlying visual elements to the canvas.
- */
- function updateElements () {
- var relativeY = lastScrollY / h;
- // Fill the canvas up
- context.fillStyle = "#1e2124";
- context.fillRect(0, 0, canvas.width, canvas.height);
- // Draw the background
- context.drawImage(bg, 0, pos(0, -3600, relativeY, 0));
- // Draw each of the blobs in turn
- context.drawImage(blob1, 484, pos(254, -4400, relativeY, 0));
- context.drawImage(blob2, 84, pos(954, -5400, relativeY, 0));
- context.drawImage(blob3, 584, pos(1054, -3900, relativeY, 0));
- context.drawImage(blob4, 44, pos(1400, -6900, relativeY, 0));
- context.drawImage(blob5, -40, pos(1730, -5900, relativeY, 0));
- context.drawImage(blob6, 325, pos(2860, -7900, relativeY, 0));
- context.drawImage(blob7, 725, pos(2550, -4900, relativeY, 0));
- context.drawImage(blob8, 570, pos(2300, -3700, relativeY, 0));
- context.drawImage(blob9, 640, pos(3700, -9000, relativeY, 0));
- // Allow another rAF call to be scheduled
- ticking = false;
- }
- /**
- * Calculates a relative disposition given the page’s scroll
- * range normalized from 0 to 1
- * @param {number} base The starting value.
- * @param {number} range The amount of pixels it can move.
- * @param {number} relY The normalized scroll value.
- * @param {number} offset A base normalized value from which to start the scroll behavior.
- * @returns {number} The updated position value.
- */
- function pos(base, range, relY, offset) {
- return base + limit(0, 1, relY - offset) * range;
- }
- /**
- * Clamps a number to a range.
- * @param {number} min The minimum value.
- * @param {number} max The maximum value.
- * @param {number} value The value to limit.
- * @returns {number} The clamped value.
- */
- function limit(min, max, value) {
- return Math.max(min, Math.min(max, value));
- }
/** * Updates and draws in the underlying visual elements to the canvas. */ function updateElements () { var relativeY = lastScrollY / h; // Fill the canvas up context.fillStyle = "#1e2124"; context.fillRect(0, 0, canvas.width, canvas.height); // Draw the background context.drawImage(bg, 0, pos(0, -3600, relativeY, 0)); // Draw each of the blobs in turn context.drawImage(blob1, 484, pos(254, -4400, relativeY, 0)); context.drawImage(blob2, 84, pos(954, -5400, relativeY, 0)); context.drawImage(blob3, 584, pos(1054, -3900, relativeY, 0)); context.drawImage(blob4, 44, pos(1400, -6900, relativeY, 0)); context.drawImage(blob5, -40, pos(1730, -5900, relativeY, 0)); context.drawImage(blob6, 325, pos(2860, -7900, relativeY, 0)); context.drawImage(blob7, 725, pos(2550, -4900, relativeY, 0)); context.drawImage(blob8, 570, pos(2300, -3700, relativeY, 0)); context.drawImage(blob9, 640, pos(3700, -9000, relativeY, 0)); // Allow another rAF call to be scheduled ticking = false; } /** * Calculates a relative disposition given the page’s scroll * range normalized from 0 to 1 * @param {number} base The starting value. * @param {number} range The amount of pixels it can move. * @param {number} relY The normalized scroll value. * @param {number} offset A base normalized value from which to start the scroll behavior. * @returns {number} The updated position value. */ function pos(base, range, relY, offset) { return base + limit(0, 1, relY - offset) * range; } /** * Clamps a number to a range. * @param {number} min The minimum value. * @param {number} max The maximum value. * @param {number} value The value to limit. * @returns {number} The clamped value. */ function limit(min, max, value) { return Math.max(min, Math.min(max, value)); }
这种做法真的在处理大图片(或者其它很容易写到一个Canvas上的元素)或者大块的文本时肯定根据挑战性。但是在你的网站上,它可能被证明会是最合适的解决方案。如果你不得不在Canvas上处理文本,你也许要使用fillText API,但是它有访问成本(你刚刚才把文本转换为bitmap!)而且你需要处理文本换行以及其它问题。你需要尽量避免这么做。
讨论了这么多,我们没有理由假设视差的工作就一定要用Canvas元素。如果浏览器支持,我们可以使用WebGL。这里面的关键是WebGL是所有API到显卡最直接的方式,并且在你的站点效果很复杂的情况下性能是最有可能达到60fps的。
你最直接的反应可能是觉得采用WebGL矫枉过正,或者它并没有获得广泛支持,但是如果你如果使用了类似于Three.js的库,你可以随时回退为使用Canvas元素,同时你的代码能以一种一致和友好的方式进行抽象。我们需要做的只是用Modernizr来检测相应API的支持:
- // check for WebGL support, otherwise switch to canvas
- if (Modernizr.webgl) {
- renderer = new THREE.WebGLRenderer();
- } else if (Modernizr.canvas) {
- renderer = new THREE.CanvasRenderer();
- }
// check for WebGL support, otherwise switch to canvas if (Modernizr.webgl) { renderer = new THREE.WebGLRenderer(); } else if (Modernizr.canvas) { renderer = new THREE.CanvasRenderer(); }
然后使用Three.js的API,而不是自己处理上下文。这里有一个支持两种渲染方式的演示。
这种方法的最后一个问题是,如果你并不特别爱好在页面上添加额外的元素,你可以总是在Firefox和Webkit浏览器里使用canvas作为背景元素。很明显,这并不是普遍适用的,所以你应该对此持谨慎态度。
逐步退化
开发者默认采用绝对定位元素而不是其它方法的主要原因可能仅仅简单是浏览器支持的问题。这种方式在一定程度上是错误的,因为对于老旧的浏览器来说,只能提供非常贫乏的渲染体验。即便在现代浏览器中,使用绝对定位也不一定能带来好的性能。
更好的方案是在老旧的浏览器上避免尝试视差效果,仅在最好的浏览器上确保能够用正确的API呈现站点效果。当然,如果你使用了Three.js,你应该能够很容易根据所需要的支持在渲染器之间进行切换。
结论
我们评估了几种方式来处理大量重绘的区域,从绝对定位的元素到使用固定定位的Canvas。当然你要采用的实现方式,取决于你要达到的目标和具体设计,但是知道有多种选择是一件好事情。在本文的例子中,我们设法从相对卡顿、低于30fps优化到了平滑、60fps的效果。
与往常一样,无论尝试哪种方法,不要凭借猜测,而应该亲自动手去尝试。