【性能优化】告别白屏等待!用requestIdleCallback实现智能资源预加载,让你的应用快人一步
前言
在现代前端应用,特别是复杂的单页应用 (SPA) 中,代码分割 (Code Splitting) 是一项必备的优化手段。它能有效地减小首屏加载资源的体积,提升页面的初始加载速度。
然而,代码分割也带来了一个新的问题:当用户导航到新页面或触发某个功能时,需要动态加载对应的代码块 (Chunk),这个过程会产生网络请求。如果用户网络状况不佳,或者需要加载的模块较大,就会出现明显的延迟甚至短暂的白屏,严重影响用户体验。
那么,有没有一种方法,既能享受代码分割带来的好处,又能避免动态加载时的延迟呢?答案是肯定的——资源预加载 (Preloading) 。
本文将分享一个笔者在项目中实践的、基于 requestIdleCallback 的智能资源预加载工具,它能够在浏览器“摸鱼”的时候,悄悄地把用户可能需要的资源准备好,让应用的响应如同丝般顺滑。
优化的核心思路:时机与策略
预加载的核心在于“预测”和“时机”。
- 预测:我们需要预测用户接下来可能会使用哪些模块。这通常基于业务逻辑判断,比如,一个薪酬计算页面,用户很可能会使用数据预览、公式配置等功能。
- 时机:我们不能在页面主要内容加载时去预加载,这会阻塞关键资源的渲染,得不偿失。最佳时机是浏览器空闲时,即浏览器完成了渲染、布局等主要工作后,处于等待用户交互的阶段。
而 requestIdleCallback API 正是为这个“最佳时机”而生的。它允许我们将一个函数注册到队列中,浏览器会在主线程空闲时执行这个回调函数,并且会传入一个 deadline 对象,我们可以通过它判断剩余的空闲时间,避免长时间任务影响用户响应。
基于此,我们的优化策略是:
- 分级预加载:将需要预加载的资源分为不同优先级(高、中、低),在有限的空闲时间内优先加载最可能被用到的资源。
- 智能调度:使用
requestIdleCallback监听浏览器空闲,优雅地执行加载任务。 - 平稳退化:在不支持
requestIdleCallback的浏览器中,降级为setTimeout,保证功能的可用性。 - 手动触发:提供手动预加载接口,可以在特定交互(如
mouseover)时,更精确地预加载资源。
代码实现:AssetPreloader 工具类
话不多说,直接上代码。下面是我们封装的 AssetPreloader 工具。
JavaScript
/**
* 空闲时间资源预加载工具
* 在浏览器空闲时预加载可能需要的资源
*/
// 预加载配置
const PRELOAD_CONFIG = {
// 高优先级预加载 - 很可能会用到的包
highPriority: [
() => import('@/components/HotTable/index.vue'),
() => import('@/components/PreviewFile/index.vue'),
() => import('@/views/remuneration/checkComputation/index.vue')
],
// 低优先级预加载 - 可能会用到的包
lowPriority: [
() => import('@/views/remuneration/AggregateDetails/index.vue'),
() => import('@/views/remuneration/billTemplate/index.vue'),
() => import('@/components/FormulaConfig/index.vue')
],
// 延迟预加载 - 不常用但完整体验需要的包
deferred: [
() => import('@/views/remuneration/computationHistory/index.vue'),
() => import('@/components/ChooseCustomerModal/index.vue')
]
}
class AssetPreloader {
constructor() {
this.isPreloading = false
this.preloadedAssets = new Set()
this.init()
}
init() {
// 等待页面加载完成后开始预加载
if (document.readyState === 'complete') {
this.schedulePreload()
} else {
window.addEventListener('load', () => {
this.schedulePreload()
})
}
}
schedulePreload() {
// 使用 requestIdleCallback 在浏览器空闲时预加载
if ('requestIdleCallback' in window) {
requestIdleCallback((deadline) => {
this.preloadWithIdleTime(deadline)
}, { timeout: 5000 })
} else {
// 降级到 setTimeout
setTimeout(() => {
this.startPreload()
}, 2000)
}
}
async preloadWithIdleTime(deadline) {
if (this.isPreloading) return
this.isPreloading = true
try {
// 高优先级预加载
await this.preloadAssets(PRELOAD_CONFIG.highPriority, deadline, 'high')
// 检查是否还有空闲时间
if (deadline.timeRemaining() > 10) {
await this.preloadAssets(PRELOAD_CONFIG.lowPriority, deadline, 'low')
}
// 延迟预加载(不占用当前空闲时间)
setTimeout(() => {
this.preloadAssets(PRELOAD_CONFIG.deferred, null, 'deferred')
}, 10000)
} catch (error) {
console.warn('预加载资源时发生错误:', error)
} finally {
this.isPreloading = false
}
}
async preloadAssets(assets, deadline, priority) {
for (const asset of assets) {
// 检查空闲时间(高优先级和低优先级需要检查)
if (deadline && deadline.timeRemaining() < 5) {
console.log(`⏱️ 空闲时间不足,暂停 ${priority} 优先级预加载`)
break
}
try {
const assetKey = asset.toString()
if (!this.preloadedAssets.has(assetKey)) {
console.log(`🚀 开始预加载 ${priority} 优先级资源:`, assetKey.substring(0, 50) + '...')
await asset()
this.preloadedAssets.add(assetKey)
console.log(`✅ ${priority} 优先级资源预加载完成`)
// 给浏览器一点时间处理其他任务
if (priority !== 'deferred') {
await this.sleep(100)
}
}
} catch (error) {
console.warn(`❌ 预加载 ${priority} 优先级资源失败:`, error)
}
}
}
async startPreload() {
// 降级方案:直接开始预加载
await this.preloadAssets(PRELOAD_CONFIG.highPriority, null, 'high')
setTimeout(async () => {
await this.preloadAssets(PRELOAD_CONFIG.lowPriority, null, 'low')
}, 3000)
setTimeout(async () => {
await this.preloadAssets(PRELOAD_CONFIG.deferred, null, 'deferred')
}, 8000)
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 手动预加载特定资源
async preloadComponent(importFn, priority = 'manual') {
try {
const assetKey = importFn.toString()
if (this.preloadedAssets.has(assetKey)) return;
console.log(`🎯 手动预加载 ${priority} 资源`)
await importFn()
this.preloadedAssets.add(assetKey);
console.log(`✅ 手动预加载完成`)
} catch (error) {
console.warn(`❌ 手动预加载失败:`, error)
}
}
}
// 创建全局预加载实例
const preloader = new AssetPreloader()
// 导出预加载工具
export default preloader
// 导出预加载方法供组件使用
export const preloadTableComponents = () => {
return preloader.preloadComponent(() => import('@/components/HotTable/index.vue'), 'table')
}
export const preloadExcelComponents = () => {
return Promise.all([
preloader.preloadComponent(() => import('@/components/PreviewFile/index.vue'), 'excel'),
preloader.preloadComponent(() => import('@/views/remuneration/checkComputation/components/ExcelFloatingData/index.vue'), 'excel')
])
}
export const preloadFormulaComponents = () => {
return preloader.preloadComponent(() => import('@/components/FormulaConfig/index.vue'), 'formula')
}
代码解析
-
PRELOAD_CONFIG:这是预加载的配置文件。我们将需要预加载的模块(通过动态import()函数包裹)按优先级分为highPriority、lowPriority和deferred三类。这种配置方式清晰、易于维护。 -
init()&schedulePreload():init方法确保预加载任务在页面完全加载后 (window.load) 才开始调度,避免与首屏渲染竞争资源。schedulePreload是调度的核心,优先使用requestIdleCallback。如果浏览器不支持,则优雅降级到setTimeout,保证了兼容性。我们还设置了 5 秒的timeout,确保即使浏览器一直繁忙,预加载任务最终也能得到执行。
-
preloadWithIdleTime(deadline):- 这是
requestIdleCallback的执行体。它接收deadline对象。 - 按优先级加载:首先加载高优先级资源,然后检查
deadline.timeRemaining()是否还有富余时间(这里我们设置为大于 10ms),如果有,再继续加载低优先级资源。 deferred(延迟) 的资源通过一个独立的setTimeout在更晚的时候加载,完全不占用宝贵的初始空闲时间。
- 这是
-
preloadAssets(...):- 这是真正执行加载的函数。在循环加载每个资源前,它都会检查
deadline.timeRemaining(),如果空闲时间不足(小于 5ms),就break循环,暂停加载,将主线程控制权交还给浏览器。这体现了requestIdleCallback的合作式调度精神。 - 通过
preloadedAssets这个Set来记录已加载的资源,避免重复加载。 - 在每次加载成功后,通过
sleep(100)人为地制造一个短暂的间歇,让浏览器有机会处理其他可能出现的更高优先级的任务(如用户输入),使体验更流畅。
- 这是真正执行加载的函数。在循环加载每个资源前,它都会检查
-
手动预加载:
- 除了自动的空闲加载,我们还导出了
preloadComponent以及基于它封装的多个业务方法,如preloadFormulaComponents。 - 这使得我们可以在更精确的时机进行预加载。例如,当用户的鼠标悬停在一个“打开弹窗”的按钮上时,我们就可以调用相应的方法去预加载这个弹窗组件的资源。
- 除了自动的空闲加载,我们还导出了
如何使用?
-
全局自动加载: 在你的项目入口文件,如
main.js或main.ts中,只需引入这个文件即可。AssetPreloader实例会自动被创建并开始监听空闲时刻。JavaScript
// main.js import { createApp } from 'vue' import App from './App.vue' import './utils/preloader' // 引入即可,无需其他操作 const app = createApp(App) app.mount('#app') -
组件内手动加载: 在某个Vue组件中,你可以结合用户交互(如
mouseover)来手动触发预加载。代码段
<template> <button @click="openFormulaModal" @mouseover="handleMouseOver" > 配置公式 </button> </template> <script setup> import { preloadFormulaComponents } from '@/utils/preloader'; // 鼠标悬浮时,预加载公式配置组件 const handleMouseOver = () => { preloadFormulaComponents(); }; const openFormulaModal = async () => { // 当用户点击时,组件大概率已经被加载完毕 // 应用可以立即响应,无需等待网络请求 const FormulaConfigModal = (await import('@/components/FormulaConfig/index.vue')).default; // ...打开弹窗的逻辑 }; </script>这种方式将预加载时机与用户意图绑定,效果立竿见影。
优化效果比对
没有对比就没有伤害。我们来看看优化前后的差异。
优化前
- 行为:用户点击“配置公式”按钮。
- DevTools Network 面板:点击事件触发后,才开始请求
FormulaConfig组件对应的 JS Chunk。 - 用户感知:如果网络慢,会看到一个明显的加载指示器(菊花图),或者按钮点击后“没反应”,直到资源加载完成,弹窗才出现。
(示意图:优化前,用户交互后才发起资源请求)
优化后
-
行为:
- 页面加载完成后,浏览器进入空闲状态。
requestIdleCallback触发,AssetPreloader在后台开始静默加载lowPriority列表中的FormulaConfig组件。- 或者,用户鼠标移动到“配置公式”按钮上,触发
mouseover事件,手动预加载启动。
-
DevTools Network 面板:在用户有任何交互之前,
FormulaConfig组件的 JS Chunk 就已经被加载并缓存了。 -
用户感知:点击按钮,弹窗立即出现,几乎没有延迟,交互体验如原生应用般流畅。
(示意图:优化后,浏览器空闲时或用户悬停时就已完成加载)
通过这种方式,我们将必要的网络等待时间从用户的交互路径中“移走”,隐藏在了用户无感的浏览器空闲期,极大地提升了应用的感知性能 (Perceived Performance) 。
总结
资源预加载是前端性能优化中一项高回报的策略。本文介绍的 AssetPreloader 工具,通过结合 requestIdleCallback API、优先级队列和手动触发机制,提供了一套完整、健壮且对主线程友好的智能预加载方案。
它具备以下优点:
- 不阻塞渲染:利用浏览器空闲时间,对首屏加载零影响。
- 智能调度:在有限的空闲时间内优先处理重要资源。
- 体验流畅:有效消除代码分割带来的交互延迟,提升用户体验。
- 易于集成:配置化管理,引入即用,对现有代码侵入性小。
在你的下一个项目中,不妨也试试这种方法,让你的应用也“快人一步”吧!