Skip to content

20 渲染: 如何优化布局和渲染,提升网页响应速度?

你好,我是三桥。

上节课,我们探讨了如何通过拆分长任务来优化INP指标,解决页面卡顿的问题。实际上,不仅是长任务会影响INP指标,页面布局、渲染和解析HTML会影响INP指标。

浏览器工作流程

我们先回顾一下浏览器是如何布局和渲染HTML的。下面是一张浏览器工作原理的流程图。

图片

从上图可以看出,浏览器的工作流程主要分为两部分:解析文档和渲染页面。

解析文档

首先,浏览器会从服务器获取HTML文档并解析其代码,包括识别HTML标签、属性和文本内容,然后生成一个DOM树。

其次,浏览器会根据CSS样式表确定每个元素的样式规则,如字体、颜色、大小和布局等。然后,将这些样式规则与DOM树结合,最终生成一个渲染树。

也就是说,整个过程里,浏览器会将原始DOM树解析成渲染树。虽然它们都是树,但实际上有所不同。DOM树包含可见和不可见两大类元素,而渲染树只包含需要显示的元素和样式信息,不包含不可见的元素。例如,如果元素的CSS样式为 display:none,那么该元素不会被包含在渲染树中。

浏览器在转换过程中是需要一定的计算时间,这个时间根据DOM树和样式表的复杂度而变化。需要特别注意的是,以下因素会影响转换耗时。

  • DOM树越深,遍历耗时越长。
  • 需要计算每个元素的高度、宽度、边框、边距等。
  • 元素的可视属性,如特殊字体、颜色渐变、动画等。

渲染页面

在渲染树构建完成后,浏览器渲染引擎会结合用户环境,通过布局和渲染,创建用户可见的页面内容。

首先,浏览器渲染引擎根据渲染树的元素和层级以及样式信息,确定每个元素在屏幕上的位置和大小。然后,根据这些布局信息,将页面内容绘制到屏幕上,展示给用户。

在渲染过程中,计算布局信息和绘制内容都需要时间,而这个时间长度取决于用户设备的性能。例如:

  • CPU渲染和GPU渲染的时间不同。
  • 在低配置移动设备上渲染页面的时间通常比高配置设备长。
  • 渲染树越复杂,渲染时间越长。

尤其是基于Vue或React的SPA单页应用,大部分JavaScript逻辑处理都在浏览器环境中完成,因此这种应用程序对JavaScript的性能和效率要求很高。

每当浏览器发现新DOM树后,就会触发重新布局或渲染。这些布局和渲染触发都在浏览器主线程上执行,是主线程任务的一部分。频繁的布局和渲染会使主线程忙碌,可能会导致页面卡顿或无响应。

总的来说,解析、布局和渲染以及JavaScript脚本的执行都在一个主线程上共享,如果这些操的耗时过长,就会降低INP指标。

布局优化

知道了浏览器的工作原理,我们应该如何提升网页响应速度呢?

先从布局优化入手,主要有三个优化方向:样式计算、重绘和重排和布局抖动。

样式计算

在前端技术领域,基于Vue或React技术栈的UI框架很多。由于这些UI框架默认带有样式风格,这些框架实现的前端项目很少会再自定义CSS样式布局或进行二次修改。

不过,具有独特风格的产品,其UI界面都需要我们自定义样式界面。有时这些界面的设计样式非常复杂,例如定制化的排行榜、动效多的抽奖活动页等。在某些特殊情况下,我们还需要谨慎使用CSS的计算样式属性,比如 calc()

现在,让我们来看一下通过Google开发者工具发现的一个样式问题。

图片

从上图中我们可以看到,前端页面执行了一个长任务,其中有一个名为“Recalculate Style”的紫色块。“Recalculate Style”意味着浏览器需要重新计算元素的样式信息,从而重新布局和重绘。

通过这个紫色块的详细信息(Summary),我们可以看到有两个明显影响性能的地方。

第一个是Total Time,它显示了这个紫色块总共耗时了55.2毫秒,即使只是重新计算样式信息就需要50多毫秒。

第二个是Elements Affected,它显示了有1520个元素被影响,这意味着重新计算样式后,有1520个元素等待被重新布局。

也就是说,前端应用肯定进行了一些改变CSS样式属性的操作,而且这个影响面很广,达到1520个元素。好了,现在我们要做的就是在前端代码中找出相应的代码并进行优化。

这里的优化建议是 尽量避免CSS样式表的样式规则计算。 具体三个方向。

  • 减少 calc() 函数的使用频率。
  • 避免频繁更新页面元素的宽度、高度、间距等改变元素大小的操作。
  • 除非必要,尽量不使用定位元素的伪类选择器。例如 nth-last-child nth-child() not() 等。

重绘和重排

不过,有时我们无法避免上面说的三种场景,当样式计算影响性能时,我们应该怎么办呢?

首先,我们有必要了解两个常见的概念:重排和重绘。

重排指的是当页面布局发生变化时,浏览器重新计算元素的几何属性,然后更新布局的过程。重绘是指在布局计算完成后,浏览器根据元素的样式信息重新绘制页面内容的过程。

它们的工作原理和布局和绘制几乎相同,只是重排在布局变化时触发,重绘在布局计算完成后触发。

我们知道,元素的位置和大小的变化,会触发重排,其重新布局的操作资源是比较昂贵的,是影响页面性能的重要因素之一。

既然知道重排会影响性能,现在让我们看一个例子。

// 缓存样式属性
var bodyStyle = document.body.style;

// 设置padding,触发重排和重绘
bodyStyle.padding = "18px";
// 设置border,触发重排和重绘
bodyStyle.border = "4px solid red";

// 修改颜色,只触发重绘
bodyStyle.color = "red";
// 修改背景色,只出发重绘
bodyStyle.backgroundColor = "#fad";

// 在body元素下插入一个新元素,触发重排和重绘
document.body.appendChild(document.createTextNode('dude!'));

我相信在不少前端历史项目中,这类代码经常出现。代码通过持续改变body元素的style属性来满足业务场景。然而,这样的代码逻辑随时可能会引起频繁地重排和重绘,这并不是最佳的方法。

最好的做法应该有两种,一种是设置样式类名,另一种是修改cssText属性值。例如下面的代码。

// body.css
.new_class {
    padding: 18px;
    border: "4px solid red";
    color: red;
    background-color: #fad;
}

// 通过类名方法,减少多次更改样式属性
document.body.className += "new_class"

如果某些样式是动态生成的,我们可以采用第二种方法。例如下面的代码。

const paddingVal = '18px';
const borderVal = '4px solid red';
const colorVal = 'red';
const backgroundColorVal = '#fad'
document.body.cssText += '; padding: ' + paddingVal
    '; border: ' + borderVal
    '; color: ' + colorVal
    '; backgroundColor: ' + backgroundColorVal

布局抖动

需要特别注意的一种情况是循环体内执行的更新样式属性,如下面的代码所示。

const len = document.getElementsByName('.list').length;
const el = document.getElementById('relayout');
for (const i = 0; i < len; i++ ) {
    el.style.left = el.offsetLeft + 10 + 'px';
    el.style.top = el.offsetTop + 10 + 'px';
}

这段代码在循环体中不断更新el元素的 lefttop 的偏移位置,同时又读取最新 offsetLeftoffsetTop 偏移值,导致浏览器必须进行重新布局,我们称之为布局抖动。

更好的做法是,提前读取 offsetLeftoffsetTop 偏移值并缓存到 lefttop 变量,不再在循环体中读取实时元素的偏移值。例如参考以下代码。

const len = document.getElementsByName('.list').length;
const el = document.getElementById('relayout');
let left = el.offsetLeft;
let top = el.offsetTop;
const elStyle = el.style;
for (const i = 0; i < len; i++ ) {
    left += 10;
    top += 10;
    elStyle.left = left + 'px';
    elStyle.top = top + 'px';
}

总的来说,我第二个建议的优化方案是降低重排和重绘的频率。包括几个方面

  • 减少在JavaScript代码中更改元素的位置、大小、间距、边距。
  • 避免在循环体内触发浏览器重排。
  • 尽可能采用样式名称,而不是直接使用JavaScript修改样式。
  • 对于延迟加载的块,可以考虑使用骨架屏作为块的占位符,以避免重新布局。

然而,现在大多数新的前端项目都使用Vue或React技术栈来实现Web应用程序。它们依赖于虚拟DOM技术,可以防止上述样式规则的频繁更新引起的重绘和重排。

这里再说一个大家都容易忽略的优化方向:DOM元素数量对浏览器的布局和渲染速度的影响。

绘制优化

接下来,我们将进一步探讨如何优化页面渲染。

如何优化DOM大小

在我们学习如何优化DOM大小之前,首先需要理清两个概念:DOM大小和DOM深度。

通常,我们可以将第一种树(DOM树)视为DOM大小,表示网页的结构。这是因为浏览器需要知道DOM树的变化,以便更新渲染树,并最终重新渲染页面。

我们知道树形结构有一个属性叫做深度,这表示树的复杂程度。所以,DOM深度就是从根节点到最深层节点的最长路径的长度,同时也显示了节点在DOM树中的层次关系。

因此,我们可以得出一个结论:DOM大小越大,DOM深度越深,意味着页面越复杂,响应速度就越慢。

怎么判断页面DOM大小是否过大呢?

在谷歌的Lighthouse报告中,如果页面DOM超过800个节点,它会提供警告信息。当节点数量超过1400时,就认为DOM过大了。

对于前端同学来说,我们可以使用 querySelectorAll 方法查询所有DOM元素,参考代码如下。

document.querySelectorAll('*').length;

虽然我们可以用脚本查询DOM元素数量,但全链路数据结构并没有要求记录这个值。这是因为DOM的大小只是一个参考值,DOM多并不意味着有性能问题。

如果有此需求,我们可以在每次上报数据时,使用querySelectorAll来查询当前的DOM数量,并把它作为一个性能检测的指标加入到链路数据中。

通常,常规的Web应用很少会有大量的DOM,除非在一些大型Web应用程序中,它们才会由很多页面构成。

另外,DOM深度的问题实际上与Web应用程序的复杂性有关系,DOM大小也会影响着其深度。因此,优化DOM的大小也是在优化DOM的深度。

对于DOM大小的优化,我有两个建议。

建议一:减少无用层级元素

首先,我们要明确什么是无用层级元素。

通常,这些元素在页面结构中无实际作用或者不必要的元素。这些元素可能会增加页面的复杂性,降低页面性能,或者使代码难以理解和维护。例如以下情况。

  • 空容器元素,没有实际内容,如 <div></div>
  • 冗余的元素。这些元素在页面中没有实际作用,只是为实现样式而存在。
  • 过度嵌套元素。在某些情况下,前端同学可能无意中使用嵌套元素来满足排版需要,导致页面结构复杂化。过度嵌套会增加DOM树深度,可能还会影响性能。
  • 无意义标记元素。例如,用 div 而不是 button 作为按钮根元素。
  • 无效的注释。

明确了这些标准,优化的方向就很清晰了。现在,我们用React的jsx语法为例,来看看这段代码。

// app.js
class App extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div className="center">
        <header className="header">
        </header>
        <div className="content">
          <section><!-- section content --></setcion>
          <section><!-- section content --></setcion>
          <section><!-- section content --></setcion>
        </div>
        <footer className="footer">
          <div>
            <ul>
              <li><!-- footer list --></li>
              <li><!-- footer list --></li>
            </ul>
          </div>
        </footer>
        </div>
    )
  }

};

ReactDOM.render(
  <App />,
  document.getElementById('app')
);

从上述代码中,我发现有两个不必要的元素。

首先,第12行的 div 元素是不必要的。通常,前端同学都会自然而然地使用 div 来包裹三个 section,认为它们都属于正文内容。实际上,它只是为了排版和布局而已。

其次,第18行的 div 元素也是多余的。 divul 都是块元素,我们可以直接用 ul 来替代。

虽然这只是一个代码示例,真实的项目代码可能会更复杂,但优化的思路是一致的,那就是找出并去掉那些在网页中没有作用的元素,特别是 div 元素。

还有一种容易忽略的情况,让我们先来看看这段代码。

export default function Header() {
  return (
    <div>
      <ul>
        <li>
            <a href="https://time.geekbang.org/">极客时间</a>
        </li>
        <li>
            <a href="https://time.geekbang.org/">极客时间</a>
        </li>
      </ul>
      <div>
        <a href="https://time.geekbang.org/">用户协议</a>
        <a href="https://time.geekbang.org/">隐私政策</a>
      </div>
    </div>
  );
}

从代码分析来看,逻辑上并没有任何问题。但是,如果从优化DOM层级的角度考虑,最顶层的 div 元素是可以省略的。

由于React的jsx语法要求组件必须只能有一个根元素,前端同学在实现组件的时候也会很自然地就直接使用 div 元素。Vue同理。

那如果不用 div 元素作为根元素,最佳的方案应该是下面这样。

export default function Header() {
  return (
    <>
      <ul>
        <li>
            <a href="https://time.geekbang.org/">极客时间</a>
        </li>
        <li>
            <a href="https://time.geekbang.org/">极客时间</a>
        </li>
      </ul>
      <div>
        <a href="https://time.geekbang.org/">用户协议</a>
        <a href="https://time.geekbang.org/">隐私政策</a>
      </div>
    </>
  );
}

因此,第三个优化建议是,减少无用的DOM元素。

  1. 删除不必要的空容器和冗余的元素。
  2. 避免元素过度嵌套,尽量使页面结构简洁。
  3. 如果React或Vue项目,尽可能使用 <> 作为组件的根元素。
  4. 使用语义化的HTML元素,避免使用无意义标签。

这样做可以减少无用的DOM元素,简化页面结构,提高页面性能,并使代码更易于理解和维护。

建议二:只渲染可视区内容。

有些前端同学可能认为优化DOM数量的效果并不明显,对于一些复杂Web应用程序来说,可能更是如此。那还有什么优化建议吗?

通常,大量DOM元素的出现主要有几种情况。

  • 页面有一个无限加载的列表,每个列表项都有复杂的层级结构。
  • 在单页面应用(SPA)中,存在多个可切换的列表,每个列表都有不同数量的层级结构。
  • 基于swiper实现的长列表,或弹窗式的swiper列表。

你可能已经注意到,长列表是导致大量DOM的主要原因,因为长列表通常是产品的核心功能。

我们以极客时间官网为例,看看一个列表卡片具体有多少个DOM。

图片

从图中的红色框可以看出,一个列表卡片包含很多元素和数据。再根据蓝色框的脚本统计,这个卡片有20个DOM元素。

虽然,DOM结构设计看起来并没有什么问题。不过,有一种特殊的需求,它是这样的。

首先,需求中的列表总共有100条,产品希望能够一次性显示出来。

其次,点击列表卡片可以弹窗显示卡片详情。关闭弹窗后,可以立即看到原来的列表和当前位置。

最后,弹窗需要支持左右滑动翻页功能,例如显示上一条和下一条数据。

假设每个卡片有大约10个DOM元素,那么100条数据就是1000个DOM元素。这还不包括弹窗的翻页功能。如果加上弹窗还要显示100条数据,又是1000个DOM元素。

还有,弹窗还要支持swiper左右切换功能,这又涉及CSS切换动画。

结果,一个简单的列表功能就有了2000个DOM元素。页面性能肯定很差,特别是弹窗后的左右切换效果,会明显卡顿。

遇到这种需求场景如何解决?我认为解决这个需求场景并不难。优化的核心思想是用最少DOM数量来确保浏览器可视区域的信息显示,并且不降低用户体验。

有两种方案可以解决这个问题。

第一个方案就是虚拟化列表。

虚拟化列表是指在列表中仅渲染用户可见的卡片内容。

实际上,用户浏览器可视区域内不可能展示所有的100条数据。我们可以采取一定的策略,只渲染可视区域内的数据,例如5条,其余的95条会根据用户滚动时间,只加载可视区域内的列表项。

这种方案可以保证列表的DOM数量长期稳定,而且还能改善渲染和滚动性能。

如果你的项目是React,可以考虑使用 react-window 库来实现列表虚拟化。如果是Vue项目,也有很多列表虚拟化组件库可以使用,比如 vue-virtual-scroll-list

第二个方案是预加载。

关于前面提到的弹窗后的卡片切换功能,如果只是显示一个卡片,其实并不会有什么性能问题。

但问题就在于,需求里需要左右翻页切换不同卡片,这时候如果一下子就把100条数据全部渲染到swiper列表上,那DOM树自然就会变得超级大。而且还有左右切换卡片的动画,自然容易出现卡顿现象。

要解决这个问题,我们就需要用到预加载的策略了。

swiper列表的预加载其实就是在用户滑动列表的时候,提前加载一部分内容,这样当用户滚动到新内容时就能无缝地看到数据。

具体到我们这个例子,每次打开卡片窗口时,除了加载当前卡片数据,还要预先加载上一条和下一条卡片的数据。然后再监听左右滑动的事件,当触发该事件时,更新当前卡片数据,同时立即更新上一条和下一条数据。这样就能够保证以最少的DOM数量实现流畅的左右滑动体验。

你可以通过下面的滑动操作流程图来更好地理解具体的实现逻辑。

图片

在前端技术社区中,已经有一套成熟的swiper列表框架,这些框架基本都有预加载功能。前端同学在使用时,记得开启这个功能。

同理,React和Vue社区也有许多类似的框架,使用时注意列表数量。具体哪些swiper框值得使用,这里就不再详述了。

这就是我们这节课最后的优化建议,通过虚拟化列表或预加载的策略来优化长列表。

总结

总结一下,这节课我们继续学习了INP指标的优化。在浏览器主线程中,除了执行JavaScript任务,还包括浏览器的布局和渲染,它们每次的重排和重绘都会对性能产生负面影响。

在这节课中,我提出了个布局和渲染的优化方案:降低样式计算的使用率、减少重排重绘的频率、减少DOM数量和优化长列表。

事实上,大型前端项目通常非常复杂,很难使用标准化的优化方案解决性能问题。特别是布局和渲染这两种场景中,需要通过开发者工具分析,找出问题根源并提出解决方案。

不过,无论前端用的是哪种框架,只要有性能问题或交互卡顿,一定是前端业务逻辑导致,绝不会是底层问题。

一句话总结,性能优化就是换一种实现方案解决业务问题。

下节课,我们继续学习前端全链路优化的另一个指标,TTFB。

思考题

现在,给你布置一道思考题。

以你当前维护的前端项目作为实践对象,尝试查找并解决前端页面是否存在布局和渲染问题,同时尝试努力优化你的项目。

欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!