【性能优化】告别白屏等待!用requestIdleCallback实现智能资源预加载,让你的应用快人一步

392 阅读8分钟

【性能优化】告别白屏等待!用requestIdleCallback实现智能资源预加载,让你的应用快人一步

前言

在现代前端应用,特别是复杂的单页应用 (SPA) 中,代码分割 (Code Splitting) 是一项必备的优化手段。它能有效地减小首屏加载资源的体积,提升页面的初始加载速度。

然而,代码分割也带来了一个新的问题:当用户导航到新页面或触发某个功能时,需要动态加载对应的代码块 (Chunk),这个过程会产生网络请求。如果用户网络状况不佳,或者需要加载的模块较大,就会出现明显的延迟甚至短暂的白屏,严重影响用户体验。

那么,有没有一种方法,既能享受代码分割带来的好处,又能避免动态加载时的延迟呢?答案是肯定的——资源预加载 (Preloading)

本文将分享一个笔者在项目中实践的、基于 requestIdleCallback 的智能资源预加载工具,它能够在浏览器“摸鱼”的时候,悄悄地把用户可能需要的资源准备好,让应用的响应如同丝般顺滑。

优化的核心思路:时机与策略

预加载的核心在于“预测”和“时机”。

  1. 预测:我们需要预测用户接下来可能会使用哪些模块。这通常基于业务逻辑判断,比如,一个薪酬计算页面,用户很可能会使用数据预览、公式配置等功能。
  2. 时机:我们不能在页面主要内容加载时去预加载,这会阻塞关键资源的渲染,得不偿失。最佳时机是浏览器空闲时,即浏览器完成了渲染、布局等主要工作后,处于等待用户交互的阶段。

requestIdleCallback API 正是为这个“最佳时机”而生的。它允许我们将一个函数注册到队列中,浏览器会在主线程空闲时执行这个回调函数,并且会传入一个 deadline 对象,我们可以通过它判断剩余的空闲时间,避免长时间任务影响用户响应。

基于此,我们的优化策略是:

  1. 分级预加载:将需要预加载的资源分为不同优先级(高、中、低),在有限的空闲时间内优先加载最可能被用到的资源。
  2. 智能调度:使用 requestIdleCallback 监听浏览器空闲,优雅地执行加载任务。
  3. 平稳退化:在不支持 requestIdleCallback 的浏览器中,降级为 setTimeout,保证功能的可用性。
  4. 手动触发:提供手动预加载接口,可以在特定交互(如 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')
}

代码解析

  1. PRELOAD_CONFIG:这是预加载的配置文件。我们将需要预加载的模块(通过动态 import() 函数包裹)按优先级分为 highPrioritylowPrioritydeferred 三类。这种配置方式清晰、易于维护。

  2. init() & schedulePreload()

    • init 方法确保预加载任务在页面完全加载后 (window.load) 才开始调度,避免与首屏渲染竞争资源。
    • schedulePreload 是调度的核心,优先使用 requestIdleCallback。如果浏览器不支持,则优雅降级到 setTimeout,保证了兼容性。我们还设置了 5 秒的 timeout,确保即使浏览器一直繁忙,预加载任务最终也能得到执行。
  3. preloadWithIdleTime(deadline)

    • 这是 requestIdleCallback 的执行体。它接收 deadline 对象。
    • 按优先级加载:首先加载高优先级资源,然后检查 deadline.timeRemaining() 是否还有富余时间(这里我们设置为大于 10ms),如果有,再继续加载低优先级资源。
    • deferred (延迟) 的资源通过一个独立的 setTimeout 在更晚的时候加载,完全不占用宝贵的初始空闲时间。
  4. preloadAssets(...)

    • 这是真正执行加载的函数。在循环加载每个资源前,它都会检查 deadline.timeRemaining(),如果空闲时间不足(小于 5ms),就 break 循环,暂停加载,将主线程控制权交还给浏览器。这体现了 requestIdleCallback 的合作式调度精神。
    • 通过 preloadedAssets 这个 Set 来记录已加载的资源,避免重复加载。
    • 在每次加载成功后,通过 sleep(100) 人为地制造一个短暂的间歇,让浏览器有机会处理其他可能出现的更高优先级的任务(如用户输入),使体验更流畅。
  5. 手动预加载

    • 除了自动的空闲加载,我们还导出了 preloadComponent 以及基于它封装的多个业务方法,如 preloadFormulaComponents
    • 这使得我们可以在更精确的时机进行预加载。例如,当用户的鼠标悬停在一个“打开弹窗”的按钮上时,我们就可以调用相应的方法去预加载这个弹窗组件的资源。

如何使用?

  1. 全局自动加载: 在你的项目入口文件,如 main.jsmain.ts 中,只需引入这个文件即可。AssetPreloader 实例会自动被创建并开始监听空闲时刻。

    JavaScript

    // main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    import './utils/preloader' // 引入即可,无需其他操作
    
    const app = createApp(App)
    app.mount('#app')
    
  2. 组件内手动加载: 在某个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。
  • 用户感知:如果网络慢,会看到一个明显的加载指示器(菊花图),或者按钮点击后“没反应”,直到资源加载完成,弹窗才出现。

(示意图:优化前,用户交互后才发起资源请求)

优化后

  • 行为

    1. 页面加载完成后,浏览器进入空闲状态。
    2. requestIdleCallback 触发,AssetPreloader 在后台开始静默加载 lowPriority 列表中的 FormulaConfig 组件。
    3. 或者,用户鼠标移动到“配置公式”按钮上,触发 mouseover 事件,手动预加载启动。
  • DevTools Network 面板:在用户有任何交互之前,FormulaConfig 组件的 JS Chunk 就已经被加载并缓存了。

  • 用户感知:点击按钮,弹窗立即出现,几乎没有延迟,交互体验如原生应用般流畅。

(示意图:优化后,浏览器空闲时或用户悬停时就已完成加载)

通过这种方式,我们将必要的网络等待时间从用户的交互路径中“移走”,隐藏在了用户无感的浏览器空闲期,极大地提升了应用的感知性能 (Perceived Performance)

总结

资源预加载是前端性能优化中一项高回报的策略。本文介绍的 AssetPreloader 工具,通过结合 requestIdleCallback API、优先级队列和手动触发机制,提供了一套完整、健壮且对主线程友好的智能预加载方案。

它具备以下优点:

  • 不阻塞渲染:利用浏览器空闲时间,对首屏加载零影响。
  • 智能调度:在有限的空闲时间内优先处理重要资源。
  • 体验流畅:有效消除代码分割带来的交互延迟,提升用户体验。
  • 易于集成:配置化管理,引入即用,对现有代码侵入性小。

在你的下一个项目中,不妨也试试这种方法,让你的应用也“快人一步”吧!