用Idle预加载技术提升前端页面加载性能实战

码农雅茹 优化 阅读 3,003
赞 10 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月搞一个中后台管理系统,页面多、模块杂,用户经常在首页点来点去,跳转到各种子页面。产品提了个需求:希望用户点开新页面时“快一点”,别卡半天。一开始我直接上了路由懒加载 + code-splitting,Webpack 自动分包,首屏确实快了,但次级页面第一次打开还是得等 1-2 秒——虽然不算慢,但产品经理说“感觉不够丝滑”。

用Idle预加载技术提升前端页面加载性能实战

我就琢磨,能不能在用户空闲的时候,偷偷把可能用到的资源预加载了?查了下,浏览器有 requestIdleCallback 这个 API,正好适合干这活。思路很简单:用户不操作页面的那几秒,CPU 空闲,就拿来预加载下一跳的 JS chunk。名字也起得直白——Idle 预加载,听着就靠谱。

核心代码就这几行

先写个基础版,用 requestIdleCallback 包一层,判断当前路由可能跳转的目标,然后动态 import 对应的模块。关键是要避免阻塞主线程,所以只在 idle 时执行:

// idlePreload.js
const preloadQueue = new Set();

function schedulePreload(moduleImport) {
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback((deadline) => {
      while (deadline.timeRemaining() > 0 && preloadQueue.size > 0) {
        const next = preloadQueue.values().next().value;
        if (next) {
          next(); // 触发 import()
          preloadQueue.delete(next);
        }
      }
      // 如果还有剩余任务,继续调度
      if (preloadQueue.size > 0) {
        schedulePreload();
      }
    }, { timeout: 1500 }); // 最多等 1.5 秒,避免一直不执行
  } else {
    // 降级:直接 setTimeout,低优先级
    setTimeout(() => {
      moduleImport();
    }, 100);
  }
}

export function addPreload(moduleImport) {
  preloadQueue.add(moduleImport);
  schedulePreload();
}

然后在路由守卫里,根据当前路径预测用户下一步可能去哪。比如在 /dashboard,大概率会点进 /user 或 /report,就提前加进去:

// router.js
import { addPreload } from './idlePreload';

router.beforeEach((to, from, next) => {
  // ...其他逻辑

  // 预测性预加载
  if (from.path === '/dashboard') {
    if (to.path.startsWith('/user')) {
      addPreload(() => import('@/views/UserList.vue'));
      addPreload(() => import('@/views/UserDetail.vue'));
    } else if (to.path.startsWith('/report')) {
      addPreload(() => import('@/views/ReportOverview.vue'));
    }
  }

  next();
});

跑起来一看,效果立竿见影:第二次进入子页面,JS 已经在缓存里了,直接秒开。亲测有效。

最大的坑:性能问题

但上线灰度后,QA 报了个诡异问题:低端安卓机上,偶尔会卡顿,甚至页面无响应。我一开始以为是预加载太多,把内存打爆了。查了 Chrome DevTools 的 Performance 面板,发现不是内存问题,而是 requestIdleCallback 回调里执行了太多次 import(),导致微任务队列堆积,主线程被占满。

原来,我之前的实现有个致命缺陷:每次调用 addPreload 都会触发一次 schedulePreload,而 schedulePreload 内部又会递归调用自己。如果短时间内加了 5 个预加载任务,就会启动 5 个并行的 idle callback,每个都在抢时间片,反而造成调度混乱。

折腾了半天,发现得加个“调度锁”——同一时间只允许一个 idle 任务在跑。改完后代码变成这样:

let isScheduling = false;

function schedulePreload() {
  if (isScheduling || preloadQueue.size === 0) return;
  
  isScheduling = true;
  if (typeof requestIdleCallback !== 'undefined') {
    requestIdleCallback((deadline) => {
      let didWork = false;
      while (deadline.timeRemaining() > 0 && preloadQueue.size > 0) {
        const next = preloadQueue.values().next().value;
        if (next) {
          next();
          preloadQueue.delete(next);
          didWork = true;
        }
      }
      isScheduling = false;
      if (preloadQueue.size > 0) {
        schedulePreload(); // 继续调度
      }
    }, { timeout: 1500 });
  } else {
    // 降级处理
    setTimeout(() => {
      if (preloadQueue.size > 0) {
        const next = preloadQueue.values().next().value;
        next();
        preloadQueue.delete(next);
      }
      isScheduling = false;
      if (preloadQueue.size > 0) {
        schedulePreload();
      }
    }, 100);
  }
}

加了 isScheduling 标志位后,卡顿问题基本消失。这里注意我踩过好几次坑:标志位必须在回调结束前设为 false,否则一旦某个 import 抛错,调度就永远卡住了。

另一个头疼事:预加载时机太激进

解决了性能问题,又遇到新问题:用户只是在首页随便滑动一下,系统就预加载了三四个模块,白白浪费带宽。尤其在弱网环境下,反而拖慢了当前页面的交互响应。

后来调整了策略:不再在路由跳转时立刻预加载,而是等用户“真正空闲”一段时间(比如 2 秒没操作)再开始。借助 lodash.debounce 简单实现:

import debounce from 'lodash/debounce';

const startPreload = debounce(() => {
  // 根据当前路由决定预加载内容
  const currentPath = router.currentRoute.value.path;
  if (currentPath === '/dashboard') {
    addPreload(() => import('@/views/UserList.vue'));
    addPreload(() => import('@/views/ReportOverview.vue'));
  }
}, 2000);

// 监听用户交互
['click', 'scroll', 'keydown'].forEach(event => {
  window.addEventListener(event, () => {
    startPreload.cancel(); // 取消之前的计划
    startPreload(); // 重新计时
  }, { passive: true });
});

这样只有用户停手 2 秒后才开始预加载,体验更合理。不过这个方案有个小瑕疵:如果用户一直在快速点击,预加载就永远不会触发。但权衡之下,宁可少预加载,也不能影响当前操作流畅度。

回顾与反思

最终上线后,数据看板显示:次级页面的首次加载时间平均减少了 65%,用户跳出率也略有下降。算是达到了预期目标。

做得好的地方:

  • requestIdleCallback 确实能安全利用空闲时间,不干扰主线程
  • 加了调度锁和防重入机制,避免低端机卡死
  • 结合用户行为延迟触发,减少无效请求

还能优化的点:

  • 目前预加载策略是硬编码的,应该做成可配置的规则引擎,比如根据用户角色、历史行为动态调整
  • 没有做带宽检测,4G 和 WiFi 下应该用不同策略,现在统一处理有点浪费
  • 错误处理比较简陋,如果某个 chunk 加载失败,没有重试或降级机制

其实还有一个遗留问题没解决:Safari 对 requestIdleCallback 支持不好,虽然我们加了 setTimeout 降级,但在 iOS 上预加载时机不太准,偶尔会晚几秒。不过因为我们的用户大部分用 Chrome,影响不大,就先放着了。

总的来说,Idle 预加载是个“低成本高回报”的优化手段,特别适合中后台这种路径可预测的场景。但一定要注意控制节奏,别让“优化”变成“负担”。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的调度策略或者 Safari 兼容方案,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论