背景

前端页面的首屏速度直接影响用户留存和业务转化。一个反复出现的性能瓶颈是:页面渲染依赖接口数据,而接口请求要等到页面 JS 执行后才发起。

这形成了一条串行链路:加载代码 → 执行代码 → 发起请求 → 等待响应 → 渲染

小程序和 H5 环境下,这个问题的表现不同:

  • 小程序:用户从 A 页面跳转到 B 页面,B 页面的数据请求要等 onLoad 执行后才发起。页面栈操作 + 转场动画 + 请求耗时叠加,白屏时间长。如果 B 页面在分包中,还要加上分包下载时间。
  • H5(SPA):浏览器先下载 HTML,再下载 JS bundle,再解析执行,最后才发起数据请求。bundle 越大,首屏数据到达越晚。实测中,接口请求往往在页面加载 500ms 以后才发出。

核心矛盾一样:数据请求的发起时机太晚了。

解决思路也一样:把请求提前,让网络 IO 和页面加载并行。


一、小程序预请求:在前一个页面提前发起

1.1 思路分析

小程序页面跳转有一个天然的时间窗口:用户点击跳转到目标页面 onLoad 执行之间,有 200-400ms 的间隔(页面栈操作 + 转场动画)。这段时间 CPU 和网络都是空闲的。

把目标页面的数据请求提前到跳转发起的那一刻,让网络请求和页面初始化并行,目标页面加载时直接消费已经返回(或即将返回)的数据。

1.2 方案一:PrefetchManager 全局缓存

用一个全局的 Map 存储预请求的 Promise,跳转前存入,目标页面取出消费。

// prefetch-manager.js

class PrefetchManager {
  constructor() {
    this.cache = new Map();
  }

  // 发起预请求,返回 Promise 并缓存
  fetch(key, requestFn) {
    const promise = requestFn();
    this.cache.set(key, {
      promise,
      timestamp: Date.now(),
    });
    return promise;
  }

  // 消费预请求结果,取出后立即删除防止数据过期
  consume(key, maxAge = 10000) {
    const entry = this.cache.get(key);
    if (!entry) return null;

    this.cache.delete(key);

    // 超过 maxAge 的预请求视为过期,丢弃
    if (Date.now() - entry.timestamp > maxAge) {
      return null;
    }
    return entry.promise;
  }
}

export const prefetchManager = new PrefetchManager();

跳转发起时触发预请求:

// pages/list/index.js
import { prefetchManager } from '../../utils/prefetch-manager';
import { getDetailData } from '../../api/detail';

Page({
  onItemTap(e) {
    const { id } = e.currentTarget.dataset;

    // 跳转前发起预请求
    prefetchManager.fetch(`detail_${id}`, () => getDetailData(id));

    // 正常跳转,不阻塞用户操作
    wx.navigateTo({ url: `/pages/detail/index?id=${id}` });
  },
});

目标页面消费预请求:

// pages/detail/index.js
import { prefetchManager } from '../../utils/prefetch-manager';
import { getDetailData } from '../../api/detail';

Page({
  async onLoad(options) {
    const { id } = options;

    // 尝试消费预请求,没有则正常请求(兜底)
    const promise =
      prefetchManager.consume(`detail_${id}`) || getDetailData(id);

    try {
      const data = await promise;
      this.setData({ detail: data });
    } catch (err) {
      // 错误处理
    }
  },
});

1.3 方案二:EventChannel 页面间通信

微信小程序基础库 2.7.3 起支持 EventChannel,在两个页面之间建立事件通信通道,支持大数据量传输,避免了 URL 传参的长度限制。

这个方案的思路是:页面 A 发起请求,等数据返回后再跳转,通过 EventChannel 把数据直接传给页面 B。

// pages/list/index.js
Page({
  onItemTap(e) {
    const { id } = e.currentTarget.dataset;

    // 先发起请求
    wx.request({
      url: `https://api.example.com/detail/${id}`,
      success: (res) => {
        // 数据返回后跳转,通过 EventChannel 传递
        wx.navigateTo({
          url: '/pages/detail/index',
          success: (navRes) => {
            navRes.eventChannel.emit('preloadData', { detail: res.data });
          },
        });
      },
      fail: () => {
        // 请求失败也跳转,页面 B 自己降级请求
        wx.navigateTo({ url: `/pages/detail/index?id=${id}` });
      },
    });
  },
});
// pages/detail/index.js
Page({
  data: { detail: null, loading: true },

  onLoad(options) {
    const eventChannel = this.getOpenerEventChannel();

    eventChannel.on('preloadData', (data) => {
      this.setData({ detail: data.detail, loading: false });
    });

    // 超时兜底:3s 内没收到数据,自己请求
    this.fallbackTimer = setTimeout(() => {
      if (this.data.loading) {
        this.fetchDetail(options.id);
      }
    }, 3000);
  },

  fetchDetail(id) {
    wx.request({
      url: `https://api.example.com/detail/${id}`,
      success: (res) => {
        this.setData({ detail: res.data, loading: false });
      },
    });
  },

  onUnload() {
    clearTimeout(this.fallbackTimer);
  },
});

两种方案的区别:PrefetchManager 方案是请求和跳转同时发起,用户不会感知到等待;EventChannel 方案是请求完成后再跳转,用户会感知到点击后的短暂延迟,但目标页面打开即有数据。根据接口耗时选择——接口快(< 200ms)用 EventChannel,接口慢用 PrefetchManager。

1.4 进阶:重写 Page 实现声明式预加载

上面两个方案都有一个痛点:页面 A 需要知道页面 B 要请求什么接口,存在耦合。

更好的做法是让每个页面自己声明"我需要预加载什么",由框架层在跳转时自动处理:

// framework/prefetch-page.js
// 重写 Page,支持声明式预加载

const preloadRegistry = {}; // 页面路径 → 预加载函数
const preloadDataCache = {}; // 页面路径 → 预加载数据

const originPage = Page;

Page = function (options) {
  // 注册预加载声明
  if (options.prefetch) {
    preloadRegistry[options.route] = options.prefetch;
  }

  // 重写 onLoad,优先消费预加载数据
  const originalOnLoad = options.onLoad;
  options.onLoad = function (query) {
    const path = this.route;
    if (preloadDataCache[path]) {
      this.setData(preloadDataCache[path]);
      delete preloadDataCache[path];
    }
    originalOnLoad && originalOnLoad.call(this, query);
  };

  originPage(options);
};

// 封装跳转方法,自动触发目标页面的预加载
export function navigateWithPrefetch(url) {
  const path = url.split('?')[0];
  const query = parseQuery(url);

  if (preloadRegistry[path]) {
    preloadRegistry[path](query).then((data) => {
      preloadDataCache[path] = data;
    });
  }

  wx.navigateTo({ url });
}

页面 B 只需声明自己的预加载需求:

// pages/detail/index.js
Page({
  route: 'pages/detail/index',

  // 声明预加载:框架会在跳转时自动调用
  prefetch(query) {
    return getDetailData(query.id).then((res) => ({ detail: res }));
  },

  onLoad(options) {
    // 如果预加载命中,此时 this.data.detail 已经有值
    if (!this.data.detail) {
      // 兜底请求
      getDetailData(options.id).then((res) => {
        this.setData({ detail: res });
      });
    }
  },
});

这样实现了关注点分离——页面 A 不需要知道页面 B 的接口细节。

1.5 补充:微信小程序数据预拉取

除了页面间预加载,微信小程序还提供了数据预拉取(Pre-fetch)能力。它在小程序冷启动时,由微信客户端提前向第三方服务器拉取业务数据,等代码包加载完时数据已经就绪。

这个能力需要在 MP 管理后台「开发管理 → 开发设置 → 数据预加载」中配置 HTTPS 数据下载地址。适合首页首屏数据的加速,和页面间预加载方案互补。

1.6 时序对比

【优化前】
点击 → 页面跳转动画(300ms) → onLoad → 发起请求(RTT 200ms) → setData → 渲染
总白屏: ~500ms+

【优化后 - PrefetchManager】
点击 → 发起请求 ─────────────────────────────────→ 数据返回
     → 页面跳转动画(300ms) → onLoad → consume → setData → 渲染
总白屏: 请求和跳转并行,约等于 max(跳转耗时, 请求耗时) - 跳转耗时

1.7 优点与适用场景

  • 实现简单,原生小程序和 Taro 都能用
  • 列表→详情、首页→二级页等跳转路径确定的场景效果明显
  • 兜底逻辑保证预请求失败时页面仍能正常工作
  • 不需要修改后端接口
  • 声明式方案可以做到零耦合

1.8 缺点与不适用场景

  • 跳转路径不确定时,预请求可能浪费带宽
  • 预请求数据有时效性问题,需要设置合理的过期时间
  • 目标页面的请求参数如果依赖页面自身的计算逻辑,无法提前发起
  • EventChannel 方案会让用户感知到跳转延迟(等请求返回才跳转)

二、H5 预请求:在 HTML head 中内联脚本提前发起

2.1 思路分析

SPA 的典型加载流程:

HTML 下载 → JS bundle 下载 → JS 解析执行 → React/Vue 挂载 → 发起数据请求

JS bundle 通常几百 KB,下载和解析需要时间。但 HTML 文档本身很早就到达了。如果在 HTML 的 <head> 中放一段内联的立即执行函数(IIFE),在 JS bundle 还没下载完的时候就发起数据请求,请求和 bundle 加载就能并行。

这和 <link rel="preload"> 不同——preload 用于提前加载静态资源(CSS、JS、字体),但无法处理动态 API 请求。自定义内联脚本可以实现条件请求、请求瀑布等复杂行为。

参考来源:Matteo Mazzarolo - Flexible network data preloading in large SPAs

2.2 基础实现

在 HTML 模板的 <head> 最顶部注入内联脚本:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>App</title>

  <!-- 接口预请求:放在 head 最顶部,尽早执行 -->
  <script>
    ;(function () {
      var baseUrl = '/api';
      var path = window.location.pathname;

      // 发布订阅:兼容「数据先到」和「JS 后到」两种时序
      var listeners = {};
      window.__prefetchReady__ = function (key, callback) {
        var entry = window['__PREFETCH_' + key + '__'];
        if (entry && entry.resolved) {
          callback(entry.data);
        } else {
          listeners[key] = listeners[key] || [];
          listeners[key].push(callback);
        }
      };

      function resolve(key, data) {
        window['__PREFETCH_' + key + '__'] = { resolved: true, data: data };
        (listeners[key] || []).forEach(function (cb) { cb(data); });
        listeners[key] = [];
      }

      function reject(key) {
        window['__PREFETCH_' + key + '__'] = { resolved: true, data: null };
        (listeners[key] || []).forEach(function (cb) { cb(null); });
        listeners[key] = [];
      }

      // 首页数据
      if (path === '/' || path === '/home') {
        fetch(baseUrl + '/home/feed', { credentials: 'include' })
          .then(function (res) { return res.json(); })
          .then(function (data) { resolve('HOME', data); })
          .catch(function () { reject('HOME'); });
      }

      // 用户信息(几乎所有页面都需要)
      var token = document.cookie.match(/token=([^;]+)/);
      if (token) {
        fetch(baseUrl + '/user/info', {
          headers: { Authorization: 'Bearer ' + token[1] },
          credentials: 'include',
        })
          .then(function (res) { return res.json(); })
          .then(function (data) { resolve('USER', data); })
          .catch(function () { reject('USER'); });
      }
    })();
  </script>
</head>
<body>
  <div id="root"></div>
  <script defer src="/static/js/bundle.js"></script>
</body>
</html>

业务代码中消费预请求:

// utils/consume-prefetch.ts

export function consumePrefetch<T>(key: string): Promise<T | null> {
  return new Promise((resolve) => {
    if (window.__prefetchReady__) {
      window.__prefetchReady__(key, (data: T | null) => {
        resolve(data);
      });
    } else {
      resolve(null);
    }
  });
}
// hooks/useHomeData.ts
import { useEffect, useState } from 'react';
import { consumePrefetch } from '../utils/consume-prefetch';
import { fetchHomeFeed } from '../api/home';

export function useHomeData() {
  const [data, setData] = useState(null);

  useEffect(() => {
    async function load() {
      // 优先消费预请求
      const prefetched = await consumePrefetch('HOME');
      if (prefetched) {
        setData(prefetched);
        return;
      }
      // 兜底:正常请求
      const res = await fetchHomeFeed();
      setData(res);
    }
    load();
  }, []);

  return data;
}

2.3 为什么用发布订阅而不是直接挂 Promise

直接把 Promise 挂到 window 上(window.__PREFETCH__ = fetch(...))也能工作,但有两个问题:

  1. 业务 JS 执行时,如果 Promise 已经 resolve 了,await 仍然是微任务异步,会多一帧延迟
  2. 如果预请求脚本因为某些原因没执行(CSP 策略、脚本加载失败),业务代码拿到 undefined 后需要额外判断

发布订阅模式可以优雅地兼容「数据先到」和「数据后到」两种时序,且降级逻辑更清晰。

2.4 进阶:Webpack/Vite 插件自动化

手动维护 HTML 中的内联脚本容易和业务代码不同步。社区已有方案将其封装为构建插件:

思路是在构建时扫描路由配置,提取每个路由对应的首屏接口,自动生成预请求脚本并注入 HTML。业务方只需在路由配置中声明:

// router.config.js
export default [
  {
    path: '/home',
    component: () => import('./pages/Home'),
    prefetch: [
      { url: '/api/home/feed', method: 'GET' },
    ],
  },
  {
    path: '/detail/:id',
    component: () => import('./pages/Detail'),
    prefetch: [
      { url: '/api/detail/{id}', method: 'GET' },
    ],
  },
];

构建插件读取这份配置,生成对应的内联脚本注入 HTML。这样预请求逻辑和业务代码保持单一数据源,不会出现两边不同步的问题。

2.5 时序对比

【优化前】
HTML ──→ bundle.js 下载(800ms) ──→ 解析执行(200ms) ──→ React 挂载 ──→ fetch ──→ 渲染
                                                                        ↑ 数据请求在这里才开始

【优化后】
HTML ──→ 内联脚本立即执行 ──→ fetch 发出 ─────────────────→ 数据返回
     ──→ bundle.js 下载(800ms) ──→ 解析执行 ──→ React 挂载 ──→ consume ──→ 渲染
                                                              ↑ 数据大概率已经回来了

实际效果:接口在页面加载 100ms 左右就已发出,业务 JS 执行前数据已返回。在 App 内嵌 H5 场景下,首屏秒开率提升约 25%,页面加载成功率提升 3-5 个百分点。

2.6 优点与适用场景

  • 零依赖,纯原生 JS,不受框架版本影响
  • 内联脚本体积极小(< 1KB),不阻塞页面解析
  • 对首屏必需的接口(用户信息、配置、首页数据)效果显著
  • 可以根据 URL path、cookie 等条件动态决定预请求哪些接口
  • 预请求失败时,业务代码的兜底逻辑保证功能正常

2.7 缺点与不适用场景

  • 内联脚本无法使用项目中的请求封装(拦截器、错误处理),需要用原生 fetch/XHR 重写
  • 请求参数如果依赖 JS bundle 中的逻辑(动态签名、加密),无法提前发起
  • 对于 SPA 内部路由跳转不生效——只在首次加载 HTML 时有用
  • 内联脚本和主应用代码分离维护,不用构建插件的话容易不同步
  • 需要注意 CSP(Content Security Policy)对内联脚本的限制

三、方案对比

维度小程序预请求H5 head 内联预请求
触发时机用户交互(点击跳转)时HTML 解析到 head 时
利用的时间窗口页面跳转动画 + 页面初始化耗时JS bundle 下载和解析耗时
数据传递方式全局 Map / EventChannelwindow 全局对象 + 发布订阅
适用页面任意页面跳转仅首次加载(刷新/直接访问)
工程化程度可重写 Page 实现声明式可用构建插件自动化
降级策略目标页面自行请求发布订阅 + 业务方兜底请求
实测优化幅度FMP 优化约 30%首屏秒开率提升约 25%

选择建议:

  • 小程序列表→详情页跳转:用 PrefetchManager,简单直接
  • 小程序页面多、预加载需求多样:用重写 Page 的声明式方案
  • H5 首屏优化(尤其 App 内嵌 WebView):用 head 内联脚本
  • H5 项目有完善构建流程:用构建插件自动化生成预请求脚本

四、注意事项

两个原则始终要记住:

  1. 预请求是优化手段,不是必要路径。 预请求失败时业务逻辑不能中断,必须保留降级路径。
  2. 只预请求关键数据。 预请求的接口应该是页面渲染所依赖的首屏数据,不要无差别地对所有接口预请求,避免浪费带宽和服务器资源。

其他注意点:

  • 请求去重:如果业务代码中也发起了相同接口的请求,需要确保不会重复请求
  • 数据过期:设置合理的 maxAge,超时的预请求数据应该丢弃
  • 错误隔离:预请求的异常不应该影响页面正常流程
  • SSR 场景:如果页面使用了服务端渲染,预请求逻辑在服务端没有意义,需要判断运行环境

五、后续完善计划

  1. 小程序侧:结合路由拦截器自动化预请求。通过路由配置声明式地定义"某个页面需要预请求哪些接口",减少手动在每个跳转处添加代码的成本。

  2. H5 侧:将内联脚本的生成集成到构建流程中(Webpack plugin 或 Vite plugin),从路由配置自动提取首屏接口,避免手动维护两份逻辑。

  3. 监控:增加预请求命中率和过期率的埋点,用数据验证优化效果,及时发现预请求浪费或过期率过高的问题。

  4. 缓存策略:对于变化频率低的接口(配置信息、用户基本信息),结合本地缓存做二级兜底——先用缓存渲染,预请求返回后静默更新。


参考