在上一篇文章中,我们探讨了通过预加载和预渲染技术,尽可能地提前加载资源和渲染> UI,从而提升页面响应速度与用户体验。然而,当用户实际访问页面时,如何控制资源的加载顺序和渲染行为,避免过度的资源消耗和不必要的渲染显得尤为重要。此时懒加载(Lazy Loading)和延迟渲染(Deferred Rendering)技术成为优化页面性能的关键。
懒加载的核心思想是在用户需要资源时才进行加载,而不是在页面初始化时一次性加载全部资源。延迟渲染则是将不可见或非关键的内容延后加载,优先保障页面的关键内容和视口内的任务执行。这些技术不仅能显著降低初始加载时间,还可以减少用户的等待时间,优化带宽利用率,从而提升整体用户体验。
本文将详细探讨懒加载和延迟渲染在不同资源类型上的具体应用,包括:
- 图片懒加载:通过延迟图片加载,减少页面初期的加载时间与带宽消耗。
- JavaScript 脚本异步加载:介绍
defer与async属性,优化脚本加载顺序和执行时机。 - Dynamic Import:动态引入模块,实现按需加载,让页面只加载当前需要的JavaScript代码。
- 字体文件按需加载:根据实际需要加载字体文件,避免不必要的字体文件加载导致的性能问题。
- content-visibility 延迟渲染:利用CSS的
content-visibility属性,优化不可见元素的渲染性能。
通过这些懒加载和延迟渲染技术,我们可以显著提升网页的加载速度与响应能力,打造出用户体验更佳的现代化网站。接下来我们将逐一介绍这些技术的实现方法和应用场景。
图片懒加载与异步解码
对于类似商品列表、朋友圈 Feeds 流等带主图的列表页面而言,图片懒加载是最行之有效的性能优化手段,尽可能只渲染用户可视区内的图片
浏览器原生支持的 loading="lazy"
在早些年图片懒加载一般通过 JavaScript 实现,现在大部分主流浏览器通过loading="lazy"原生支持了图片懒加载,使用方法也非常简便
<img src="image-to-lazy-load.jpg" loading="lazy">
这个属性有三个可能的值:
- lazy:启用懒加载。浏览器会在图片即将进入视口时才开始加载。
- eager:禁用懒加载。图片会随着页面加载立即开始加载,无论图片位置如何。
- 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脚本时,有两种主要的属性可以用来实现异步加载:defer 和 async,这样脚本的下载与解析会在适当的时机插入,不会阻塞主文档的解析执行,虽然 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 后可以非常低成本的为页面带来几个显著的改观
- 减少渲染工作:允许浏览器仅在元素进入视口时才进行渲染和布局,从而减少不必要的渲染工作
- 资源节约:降低CPU和内存使用,特别是对于长列表或无限滚动的场景,极大减少因全部渲染而导致的资源消耗
- 智能优化:浏览器智能管理确保在需要时加载和渲染内容,无需手动操作大量复杂的懒加载逻辑
- 渐进式增强:虽然 content-visibility 在 Safari 的浏览器兼容性还需要一定时间,但最大的好处是 content-visibility 没有对页面结构做破坏性的变化,可以放心使用