背景
前端页面的首屏速度直接影响用户留存和业务转化。一个反复出现的性能瓶颈是:页面渲染依赖接口数据,而接口请求要等到页面 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(...))也能工作,但有两个问题:
- 业务 JS 执行时,如果 Promise 已经 resolve 了,
await仍然是微任务异步,会多一帧延迟 - 如果预请求脚本因为某些原因没执行(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 / EventChannel | window 全局对象 + 发布订阅 |
| 适用页面 | 任意页面跳转 | 仅首次加载(刷新/直接访问) |
| 工程化程度 | 可重写 Page 实现声明式 | 可用构建插件自动化 |
| 降级策略 | 目标页面自行请求 | 发布订阅 + 业务方兜底请求 |
| 实测优化幅度 | FMP 优化约 30% | 首屏秒开率提升约 25% |
选择建议:
- 小程序列表→详情页跳转:用 PrefetchManager,简单直接
- 小程序页面多、预加载需求多样:用重写 Page 的声明式方案
- H5 首屏优化(尤其 App 内嵌 WebView):用 head 内联脚本
- H5 项目有完善构建流程:用构建插件自动化生成预请求脚本
四、注意事项
两个原则始终要记住:
- 预请求是优化手段,不是必要路径。 预请求失败时业务逻辑不能中断,必须保留降级路径。
- 只预请求关键数据。 预请求的接口应该是页面渲染所依赖的首屏数据,不要无差别地对所有接口预请求,避免浪费带宽和服务器资源。
其他注意点:
- 请求去重:如果业务代码中也发起了相同接口的请求,需要确保不会重复请求
- 数据过期:设置合理的 maxAge,超时的预请求数据应该丢弃
- 错误隔离:预请求的异常不应该影响页面正常流程
- SSR 场景:如果页面使用了服务端渲染,预请求逻辑在服务端没有意义,需要判断运行环境
五、后续完善计划
-
小程序侧:结合路由拦截器自动化预请求。通过路由配置声明式地定义"某个页面需要预请求哪些接口",减少手动在每个跳转处添加代码的成本。
-
H5 侧:将内联脚本的生成集成到构建流程中(Webpack plugin 或 Vite plugin),从路由配置自动提取首屏接口,避免手动维护两份逻辑。
-
监控:增加预请求命中率和过期率的埋点,用数据验证优化效果,及时发现预请求浪费或过期率过高的问题。
-
缓存策略:对于变化频率低的接口(配置信息、用户基本信息),结合本地缓存做二级兜底——先用缓存渲染,预请求返回后静默更新。