免责声明:本文聚焦于改善客户端渲染的单页应用(SPA)性能的定制解决方案。如果您使用的是Next.js、Remix或类似的框架,这些优化通常会自动为您处理 :)
在我的经验中,实现客户端渲染时,一个重要的优化是在页面加载时预加载网络数据。根据我在最近三家公司的观察,大型SPA通常在页面加载时需要一系列网络请求。例如,加载用户认证数据、环境配置等。
当您开始编写React应用程序时,这些网络请求通常在React应用程序挂载后启动。虽然这种方法确实有效,但随着应用程序的扩展,它可能变得效率低下。当您可以在这些步骤并行运行网络请求时,为什么要等待应用程序包被下载、解析并加载React应用程序才开始网络请求呢?
预加载网络请求
现代浏览器提供了像link rel="preload"
和其他资源提示这样的工具来处理这些特定用例:它们可以被用来尽快启动必要的网络请求。然而,这些主要限于简单的、硬编码的请求。对于更复杂的情况,您可能需要依赖现有的框架解决方案或创建自定义实现。
在只能构建自定义解决方案的情况下,我的首选方法是将一个小的JavaScript脚本注入到HTML文档的头部,以立即开始网络请求。与浏览器提示不同,这个脚本完全由您控制,可以启用更复杂的行为,如条件请求、请求瀑布流、处理WebSocket连接等。
基本实现
作为一个例子,这里有一个如何预加载加载一些用户数据所需的网络请求的微小示例:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<script>
// 简化版本,仅展示从高层次如何预加载。
window.__userDataPromise = (async function () {
const user = await (await fetch("/api/user")).json();
const userPreferences = await (await fetch(`/api/user-preferences/${user.id}`)).json();
return { user, userPreferences };
})();
</script>
</head>
<body>
<script src="/my-app.js"></script>
</body>
</html>
my-app.js
// 再次,非常天真的方法。在真正的应用程序中,您可能会使用像React-Query或类似的工具来消费这个promise。
function MyApp() {
const [userData, setUserData] = useState();
async function loadUserData() {
setUserData(await window.__userDataPromise);
}
useEffect(() => {
loadUserData();
}, []);
}
这种方法适用于简单用例,但随着应用程序的增长,可能会变得繁琐。例如,您要预加载的流程往往是您在应用程序运行时会重新调用的流程:在上面的例子中,例如,您可能希望在用户再次登录或更改账户后重新获取用户和配置数据。
更“可扩展”的预加载模式
为了解决这个问题,我一直使用的模式是允许应用程序中的任何函数都可以“预加载”。高层次的步骤是:
- 在SPA的代码中定义要预加载的函数。
- 使用withPreload API包装该函数并导出它。
- 导入并在预加载脚本中启动预加载。
- 在运行时,函数在执行前检查预加载结果。
实现
这里有一个简化的代码示例,展示了这种模式如何实现:
my-app/data-preloader.ts
/**
* `DataPreloader` 是一个实用工具,用于尽快预加载数据并在需要时使用它。
* 例如,它可以用来预加载用户信息和配置数据,甚至在渲染应用程序之前,
* 避免了在UI渲染之前等待获取数据,避免了瀑布效应。
*
* `withPreload` 函数是一个高阶函数,用于包装您想要预加载数据的函数。
* 它返回一个新的函数,当被调用时,将返回预加载的promise(如果存在)或调用原始函数。
* 返回的函数还有一个preload方法,可以用来启动预加载数据。
*
* 这允许您在代码的一部分预加载数据,并在另一部分使用它,
* 而不必担心数据是否已经被预加载。
* 如果数据已经被预加载,将返回预加载的promise;
* 否则,将调用原始函数。
*/
type PreloadEntry<T> = {
id: string;
promise: Promise<T>;
status: "pending" | "resolved" | "rejected";
result?: T;
error?: unknown;
};
class DataPreloader {
private entries: Map<string, PreloadEntry<unknown>>;
constructor() {
// 如果在SPA的代码中调用,我们用在预加载脚本中创建的promise重新激活它。
if (window.__dataPreloader_entries) {
this.entries = window.__dataPreloader_entries;
// 如果这是预加载脚本,将promise暴露在window对象上。
} else {
this.entries = new Map();
window.__dataPreloader_entries = this.entries;
}
}
// 启动一个promise并将其存储在全局跟踪的promise列表中。
preload<T>(id: string, func: () => Promise<T>): Promise<T> {
const entry: PreloadEntry<T> = {
id,
promise: func(), // 这是启动预加载的内容
status: "pending",
};
// 这些主要是为了自省,如果您想检查promise的状态而不需要等待它。
entry.promise
.then((result) => {
entry.status = "resolved";
entry.result = result;
})
.catch((error) => {
entry.status = "rejected";
entry.error = error;
});
this.entries.set(id, entry);
return entry.promise;
}
// 如果给定promise的预加载存在,返回其结果并从列表中删除promise
// (以确保我们不会返回陈旧的数据)。
// 在这里改进的机会可能是使用预加载的promise
// 只有在它被“最近”预加载时——同样,为了避免陈旧的数据。
consumePreloadedPromise<T>(id: string) {
const preloadEntry = this.entries.get(id);
if (preloadEntry) {
this.entries.delete(id);
return preloadEntry.promise as Promise<T>;
}
}
}
// 将其作为单例导出
const dataPreloader = new DataPreloader();
// 在这里的另一个改进机会是允许传递参数给
// 函数。这将需要将参数序列化为字符串并使用它作为键,
// 以确保我们不会匹配使用不同参数完成的预加载,例如。
export const withPreload = <T,>(id: string, func: () => Promise<T>) => {
const preloadableFunc = () => {
const promise = dataPreloader.consumePreloadedPromise<T>(id);
if (promise) {
return promise;
} else {
return func();
}
};
// 在函数上公开一个“preload”方法,以便它可以被调用以启动其预加载。
preloadableFunc.preload = () => dataPreloader.preload(id, func);
return preloadableFunc;
};
my-app/load-user-data.ts
import { fetchUser, fetchUserPreferences } from "./api";
import { getUserAuthToken } from "./auth";
import { withPreload } from "./data-preloader";
type UserData =
| {
isLoggedIn: false;
}
| { isLoggedIn: true; user: User; userPreferences: UserPreferences };
const _loadUserData = async () => {
const userAuthToken = await getUserAuthToken();
if (!userAuthToken) {
return { isLoggedIn: false };
}
const user = await fetchUser();
const userPreferences = await fetchUserPreferences();
return { isLoggedIn: true, user, userPreferences };
};
// 要使上面的函数可预加载,只需用 `withPreload` 包装它并
// 给它分配一个在整个SPA中唯一的ID。
const LOAD_USER_DATA_PRELOAD_ID = "loadUserData";
export const loadUserData = withPreload(
LOAD_USER_DATA_PRELOAD_ID,
_loadUserData,
);
my-app/app.tsx
// 在应用程序的任何部分,您可以像使用 `loadUserData` 一样使用它,不用担心
// 数据是否已经被预加载。
const userData = await loadUserData();
my-app/preload-script-entry-point.ts
/**
* 此文件是数据预加载器的入口点。
* 它被作为与SPA其余部分分离的脚本注入,以便
* 它能够尽快开始预加载数据。
* 您通常希望使用像Webpack这样的打包器来确保这是分开的文件。
*/
import { loadUserData } from "./load-user-data";
(async function run() {
await loadUserData.preload();
})();
在这里,我们使用 withPreload
来预加载用户数据,但您可以轻松地扩展此方法以预加载任何其他信息。只需用 withPreload
包装您想要预加载的函数,并在预加载脚本中启动它。此外,您可以在预加载脚本中添加自定义逻辑,以确定是否应根据URL、cookies、本地存储等因素触发预加载。
优势和考虑事项
正如我提到的,这是一个简化的例子,展示了这种模式如何工作,还有许多方法可以进一步增强它,例如添加预加载过期逻辑和支持withPreload的参数匹配。通常,这种模式在我的用例中一直很有效,但重要的是要注意,它不是一个适合所有情况的解决方案。请谨慎确保您在预加载脚本中导入的函数不会引入过多的依赖,因为这可能导致脚本非常大,以至于下载和解析它变得不如预加载本身高效。