性能优化之懒加载与延迟渲染

914 阅读14分钟

上一篇文章中,我们探讨了通过预加载和预渲染技术,尽可能地提前加载资源和渲染> UI,从而提升页面响应速度与用户体验。然而,当用户实际访问页面时,如何控制资源的加载顺序和渲染行为,避免过度的资源消耗和不必要的渲染显得尤为重要。此时懒加载(Lazy Loading)和延迟渲染(Deferred Rendering)技术成为优化页面性能的关键。

懒加载的核心思想是在用户需要资源时才进行加载,而不是在页面初始化时一次性加载全部资源。延迟渲染则是将不可见或非关键的内容延后加载,优先保障页面的关键内容和视口内的任务执行。这些技术不仅能显著降低初始加载时间,还可以减少用户的等待时间,优化带宽利用率,从而提升整体用户体验。

本文将详细探讨懒加载和延迟渲染在不同资源类型上的具体应用,包括:

  1. 图片懒加载:通过延迟图片加载,减少页面初期的加载时间与带宽消耗。
  2. JavaScript 脚本异步加载:介绍 deferasync 属性,优化脚本加载顺序和执行时机。
  3. Dynamic Import:动态引入模块,实现按需加载,让页面只加载当前需要的JavaScript代码。
  4. 字体文件按需加载:根据实际需要加载字体文件,避免不必要的字体文件加载导致的性能问题。
  5. content-visibility 延迟渲染:利用CSS的 content-visibility 属性,优化不可见元素的渲染性能。

通过这些懒加载和延迟渲染技术,我们可以显著提升网页的加载速度与响应能力,打造出用户体验更佳的现代化网站。接下来我们将逐一介绍这些技术的实现方法和应用场景。

图片懒加载与异步解码

对于类似商品列表、朋友圈 Feeds 流等带主图的列表页面而言,图片懒加载是最行之有效的性能优化手段,尽可能只渲染用户可视区内的图片

浏览器原生支持的 loading="lazy"

在早些年图片懒加载一般通过 JavaScript 实现,现在大部分主流浏览器通过loading="lazy"原生支持了图片懒加载,使用方法也非常简便

<img src="image-to-lazy-load.jpg" loading="lazy">

这个属性有三个可能的值:

  1. lazy:启用懒加载。浏览器会在图片即将进入视口时才开始加载。
  2. eager:禁用懒加载。图片会随着页面加载立即开始加载,无论图片位置如何。
  3. auto:浏览器自行决定何时加载图片,这是默认值。

当对图片设置了这个属性后,浏览器会根据自己的启发式算法决定图片的加载时机。这些算法会考虑多个因素,比如图片即将进入视口的距离,或者用户当前的网络条件等。通常启发式算法的工作方式如下:

  • 视口接近度:浏览器会监测页面滚动,检查懒加载图片距离视口的距离。当图片快要出现在视口内时,浏览器会开始加载图片。具体开始加载图片的距离阈值并没有统一的标准,不同的浏览器可能会有不同的实现。
  • 网络状况:一些浏览器可能会根据用户的网络状况(例如是否使用数据流量或者Wi-Fi)来决定是否提前加载图片。
  • CPU 和内存使用情况:如果用户设备的CPU或内存使用率很高,浏览器可能会延迟加载图片,直到资源使用减少。
  • 电池状态:对于移动设备,浏览器可能会在电池电量充足时更积极地加载资源。

虽然开发者无法精准控制图片加载的时机,但浏览器原生支持考虑的因素不仅仅是滚动位置,相对而言更加合理。顺便说一句,使用 JavaScript 懒加载本身也有性能开销,可能会影响到页面的 FPS

使用 IntersectionObserver 实现懒加载

如果希望使用 JavaScript 做更个性化的图片懒加载甚至 DOM 都不渲染的控制,在传统的懒加载实践中一般是通过监听滚动事件+函数节流+判断元素是否出现在 ViewPort 来实现懒加载

// 节流函数
function throttle(fn, wait) {
  let lastTime = 0;
  return function() {
    const now = new Date().getTime();
    if (now - lastTime >= wait) {
      fn.apply(this, arguments);
      lastTime = now;
    }
  };
}

// 判断元素是否在视口内
function isElementInViewport(el) {
  const rect = el.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

// 图片懒加载函数
function lazyLoad() {
  const images = document.querySelectorAll('img[data-src]');

  images.forEach(img => {
    if (isElementInViewport(img)) {
      img.src = img.dataset.src;
      img.classList.remove('placeholder');
      img.removeAttribute('data-src'); // 防止重复处理
    }
  });
}

// 绑定滚动事件并使用节流函数
window.addEventListener('scroll', throttle(lazyLoad, 200));

// 确保初始页面加载时也进行一次检查
document.addEventListener('DOMContentLoaded', lazyLoad);

但这样的实现有几个弊端

  • 滚动事件在用户滚动页面时会频繁触发,即便使用了节流技术来限制调用频率,但节流窗口内的每次滚动仍然会触发检查
  • 多种边界情况需要手动处理,例如页面内容变化、窗口尺寸变化等情况,需要手动重新计算和检查

在现代浏览器中可以使用 IntersectionObserver API 判断某个元素是否在 ViewPort 中以及它在视口中的位置和状态,这在实现懒加载、无限滚动、广告曝光检测等场景中尤为有用,相对于上面的方案有几个优势

  • 浏览器原生实现,经过高度优化,能够高效地监测元素的可见性状态变化,仅在发生实际可视性变化时触发回调,减少不必要的计算
  • 可以选择在元素进入视口后停止观察,避免了手动管理事件监听器的繁琐
  • 能够提供详细的交叉检测属性(例如交叉比例),支持多种配置参数(如根元素、边距和阈值),能够灵活定义不同的触发条件和行为
document.addEventListener('DOMContentLoaded', () => {
  const images = document.querySelectorAll('img[data-src]');

  const lazyLoad = (entries, observer) => {
    entries.forEach(entry => {
      // 检查图片是否与 Viewport 交叉(是否在 Viewport 内)
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.classList.remove('placeholder');
        // 停止观察该图片,避免重复加载
        observer.unobserve(img);
      }
    });
  };

  // 创建 IntersectionObserver 实例,并配置其选项
  const observer = new IntersectionObserver(lazyLoad, {
    root: null, // 使用浏览器 Viewport 作为根元素
    rootMargin: '0px',
    threshold: 0.1, // 当图片进入视口 10% 时触发回调
  });

  images.forEach(img => observer.observe(img));
});

图片异步解码

页面大量使用图片不止会影响网络,图片在浏览器上的显示需要解码工作,解码图像和视频是计算密集型的操作,可能会占用大量的CPU资源,特别是对于高分辨率或者复杂编码格式的媒体文件,如果主线程被图像或视频的解码操作阻塞,用户在滚动页面或尝试交互时可能会感受到卡顿或延迟

对非首屏图片或视频添加 decoding="async" 可以允许浏览器在后台处理图片、视频解码,而不阻塞主线程,继续处理和渲染页面的其余部分,这样可以有助于改善页面的加载性能,减少用户感知到的延迟,并提供更加平滑的用户体验

<img src="image.jpg" decoding="async">

脚本异步加载

在HTML解析和渲染过程中,遇到 <script> 标签时会暂停所有解析工作,直到下载并执行完对应的JavaScript 脚本后才继续解析后续的 HTML。这种行为可能导致页面渲染的阻塞,从而影响性能。为了优化性能并减少这种阻塞,我们可以对那些非首屏渲染的脚本进行异步加载或延迟渲染

defer、async

在HTML中引入 JavaScript脚本时,有两种主要的属性可以用来实现异步加载:deferasync,这样脚本的下载与解析会在适当的时机插入,不会阻塞主文档的解析执行,虽然 defer 和 async 都允许浏览器异步加载脚本,但它们在脚本执行时机上的行为有所不同,适用于不同的应用场景

<script src="script1.js" defer></script>
<script src="script2.js" defer></script>

使用 defer 属性时,脚本会在解析 HTML 完成之后但在 DOMContentLoaded 事件之前执行,多个带有 defer 属性的脚本会按它们在文档中出现的顺序执行,其适用于以下场景:

  • 如果脚本需要访问完整的DOM结构,比如表单验证脚本、操作DOM节点的脚本
  • 当需要严格按照顺序加载和执行多个脚本时,使用 defer 可以保证脚本按顺序执行
<script src="script1.js" async></script>
<script src="script2.js" async></script>

使用 async 属性时,脚本会立即异步下载,并尽可能在下载完成后立即执行,不考虑 HTML 解析的状态,多个带有 async 属性的脚本其执行顺序无法保证。如果脚本不依赖其他脚本或DOM结构,可以独立执行,使用 async 能大幅提升页面加载性能

  • 用户行为追踪脚本、实时数据获取脚本等
  • 社交媒体分享按钮、广告脚本等

dynamic import 实现组件

ES6 的动态导入(Dynamic Import)使得在代码运行时可以按需加载模块。传统的模块导入是静态的,所有依赖项在编译时就确定,而动态导入则在运行时决定加载哪些模块

动态导入使用 import() 函数,可以在任意地方使用,该函数返回一个 Promise,当模块成功加载时,Promise 解析为该模块的导出内容

import('./MyComponent.js').then(module => {
  const MyComponent = module.default;
  // 使用组件
});

假设有一个模块 math.js,定义了一些数学函数

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

在另一个文件中,我们可以默认不加载 math.js,实现只有在 Button 点击的时候才按需加载

// main.js
document.getElementById('btnCal').addEventListener('click', () => {
  import('./math.js')
    .then(mathModule => {
      console.log('Module loaded:', mathModule);
      console.log('3 + 4 =', mathModule.add(3, 4));
    })
    .catch(error => {
      console.error('Error loading module:', error);
    });
});

通过动态导入 import() 结合 Webpack,可以轻松实现代码拆分,按需加载,提高应用性能,很多 SPA 页面的路由懒加载正是通过 Dynamic Import 技术实现

const path = require('path');

module.exports = {
  entry: './src/index.js', // 入口文件
  output: {
    filename: '[name].bundle.js', // 使用[name]占位符以支持多入口分割
    path: path.resolve(__dirname, 'dist'),
    chunkFilename: '[name].chunk.js' // 动态导入的模块保存为独立的chunk文件
  },
  module: {
    rules: [
      {
        test: /.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      }
    ]
  },
  optimization: {
    splitChunks: {
      chunks: 'all', // 对异步和同步模块都进行代码分割
    }
  }
};

React 组件懒加载

利用 Dynamic import,React.lazy 允许定义懒加载的组件。当组件被渲染时,React 会在后台加载其对应的 JavaScript 文件

const LazyComponent = React.lazy(() => import('./LazyComponent'));

lazy 的组件需要配合 Suspense 使用,Suspense ****提供了一个“fallback”界面,在懒加载的组件还未完全加载完成时显示,当组件完成加载后重新渲染组件内容

<Suspense fallback={<div>Loading...</div>}>
  <LazyComponent />
</Suspense>

创建目录结构

src
├── components
│   ├── AsyncComponent.tsx
│   └── SyncComponent.tsx
└── App.tsx

编写组件

// components/AsyncComponent.tsx
export default function AsyncComponent() {
  return <div>This is a lazily loaded component</div>
}

// components/SyncComponent.tsx
export default function SyncComponent() {
  return <div>This is normal component</div>
}

// App.tsx
import { lazy, Suspense } from 'react';
import SyncComponent from './components/SyncComponent';

const AsyncComponent = lazy(() =>
  new Promise(resolve => {
    // 放大异步加载过程,方便观测效果
    setTimeout(() => {
       resolve(import('./components/AsyncComponent'));
    }, 2000);
  })
);

const App = () => {
  return (
    <div className="content">
      <Suspense fallback={<div>loading...</div>}>
        <AsyncComponent />
      </Suspense>
      <SyncComponent />
    </div>
  );
}

字体文件按需加载

为了保证品牌一致性和跨平台一致性,很多网站使用了 web font。web font 有多种格式,在以往是个复杂的选择问题,自从 WOFF2 格式出现后就不用纠结了,WOFF2 使用了 Brotli,因此其压缩效果比 WOFF 高出 30%,从而可以减少下载数据量,从而提升性能,同时 WOFF2 浏览器支持也特别好,可以使用 @font-face 在页面引入 web font

@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 100;
  src: url(https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1MmgVxFIzIXKMnyrYk.woff2) format('woff2');
}

但 web font 需要用户下载后才能使用,尤其是支持多语言的页面 web font 的字体通常会很大。但我们可以利用浏览器对字体处理的两个特性来做到 web font 的按需加载

  • 浏览器解析到 @font-face 声明代码时候并不会下载对应的字体,只有解析到使用了该 font-face 中定义字体的页面元素时,才会下载对应的字体
  • 把一个完整的 web font 字符集按照 unicode-range 拆分成多个文件——字体子集化

这样浏览器只会解析并下载当前页面用的到的字体文件,从而加速页面渲染。

简单介绍一下字体子集化,字体子集化是一种减小字体文件大小的技术,它通过移除字体文件中不需要的字符来实现

由于许多字体文件包含了成千上万的字符,这些字符包括各种语言的字母、符号、标点、数字、空格、换行符以及装饰性和特殊字符。然而一个特定的网站或应用可能不需要这么多字符,在 @font-face 内部可以使用 unicode-range 定义字体应用到的 Unicode 字符范围。这样可以将字体文件拆分成多个,当字符在定义的范围内时,浏览器才会下载并使用对应的字体文件

例如网站主要使用英文,但偶尔包含一些拉丁字符,可以用 unicode-range 来指定只加载这些字符的字体:

@font-face {
  font-family: 'MyFont';
  src: url('myfont.woff2') format('woff2');
  unicode-range: U+00-FF; /* 基本拉丁字母及扩展 */
}

通过将字体文件限制为仅包含网站或应用所需的字符,可以减少文件的大小,得益于上面提到的字体当被使用到才会下载的机制,从而减少下载时间和带宽使用,提高网站的加载速度和性能

Google Roboto 字体的定义 fonts.googleapis.com/css2?family…

/* cyrillic-ext */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2) format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
  font-family: 'Roboto';
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu7mxKKTU1Kvnz.woff2) format('woff2');
  unicode-range: U+1F00-1FFF;
}
...

延迟渲染可视区外的元素

content-visibility 是 CSS 属性,允许浏览器跳过不在屏幕上的元素的渲染工作,直到用户滚动到它们的位置。通过跳过不可见内容的渲染,content-visibility 可以显著减少页面的初始加载时间,并降低内存的使用,从而改善用户体验。配合 contain-intrinsic-size 属性可以对容器进行渲染前的占位

<style>
  .image-gallery {
    content-visibility: auto;
    contain-intrinsic-size: 1000px 500px; /* 设置一个合适的占位大小 */
  }
</style>

<div class="image-gallery">
  <img src="image1.jpg" alt="描述1">
  <img src="image2.jpg" alt="描述2">
  <!-- 更多图片 -->
</div>

使用 content-visibility 后可以非常低成本的为页面带来几个显著的改观

  1. 减少渲染工作:允许浏览器仅在元素进入视口时才进行渲染和布局,从而减少不必要的渲染工作
  2. 资源节约:降低CPU和内存使用,特别是对于长列表或无限滚动的场景,极大减少因全部渲染而导致的资源消耗
  3. 智能优化:浏览器智能管理确保在需要时加载和渲染内容,无需手动操作大量复杂的懒加载逻辑
  4. 渐进式增强:虽然 content-visibility 在 Safari 的浏览器兼容性还需要一定时间,但最大的好处是 content-visibility 没有对页面结构做破坏性的变化,可以放心使用