共计 4733 个字符,预计需要花费 12 分钟才能阅读完成。
大家都知道,Next.js App Router 可以通过在 app 文件夹中放置 loading.js 文件来定义导航指示器。
当用户访问 https://{example.xx}/hello/ 时,Next.js 会动态加载 /src/app/hello/page.js,并在下载过程中展示 /src/app/loading.js 组件。
如果你没定义 loading.js,而你的页面又加载得很慢,用户可能会认为你的应用卡住了。
但你知道吗?在显示 loading.js 之前还有个额外步骤!
为什么点击后会有延迟?
如果你网速比较慢,你可能已经注意到了,点击链接后,loading.js 中定义的加载指示器并不会立即显示。
这是因为 Next.js 需要先下载一个小数据文件,只有下载完成后才会显示 loading.js 组件。
而这个等待时间没有上限!如果这个小文件需要 5 秒才能下载完,页面就会在这 5 秒内完全没有反应。
这绝对是糟糕的用户体验。
用户点击链接却没有任何反馈,这谁顶得住?
通过链接预加载减少延迟
Next.js 会预加载视口内可见的所有链接,这样当你点击时,页面已经在缓存中了,立即就能加载。
但这方法也不是万无一失的。比如你页面上链接太多,用户在预加载完成前就点了最后一个链接(记住,Chrome 每个主机名最多只有 6 个并发连接)。
或者你的开发者关闭了链接预加载功能,因为网站上有太多变动的链接,导致服务器一次收到太多请求。
无法先显示加载指示器?
在下载小数据文件时不显示加载指示器是 Next.js 的设计选择,这影响了 CSR 和 SSR 两种渲染方式。
虽然这只是个边缘情况,但我觉得创建一个低成本的解决方案还是很有必要的。
任何在 1 - 3 秒内看不到视觉反馈的用户都可能认为网站挂了。
Next.js 中没有官方方法检测页面导航
Next.js App Router(至少到 14 版本)在请求页面导航时不会触发事件,因为它现在完全依赖 React 的 Suspense 来传达加载状态。
大多数解决方案建议创建自定义 Link 组件,并在 useRouter().push() 外面包一层触发自定义加载指示器的逻辑。问题是这需要改写现有代码。
理想的方案不应该要求修改你的应用。
我们可以通过拦截浏览器的 fetch 调用来检测 Next.js 的请求何时匹配已知模式。
实现方案
- 检测点击后的网络请求。我们假设点击后 1 秒内发出的网络请求都跟点击有关。
- 显示自定义加载指示器。
- 等页面导航到新 URL。此时,loading.js 中的 Suspense 加载组件会接管,继续下载 page.js。
- 隐藏我们的加载指示器。
使用 Service Worker 检测网络调用
先在 public 文件夹根目录创建一个 sw.js 文件(/public/sw.js)。如果已有,直接编辑它。
Service worker 默认不会立即运行,我们通过在 install 事件中调用 skipWaiting 让它立刻生效:
self.addEventListener("install", () => self.skipWaiting());
然后创建 fetch 事件监听器:
//public/sw.js
let ignore = {image: 1, audio: 1, video: 1, style: 1, font: 1};
self.addEventListener("fetch", e => {let { request, clientId} = e;
let {destination} = request;
if (!clientId || ignore[destination]) return;
e.waitUntil(self.clients.get(clientId).then(client =>
client?.postMessage({
fetchUrl: request.url,
dest: destination,
}),
),
);
});
这段代码会:
- 通过 clientId 找出哪个标签触发了事件
- 获取请求 URL
- 获取请求目的地,确保是页面请求而非资源请求
- 用 postMessage 把信息发送到页面
在应用启动时加载 sw.js
找到 app 文件夹根目录的 layout.js 文件(/src/app/layout.js),在顶部添加:
import "./injectAtRoot.js";
然后在同一文件夹创建 injectAtRoot.js:
//injectAtRoot.js
"use client";
if (
typeof navigator !== "undefined"
&& "serviceWorker" in navigator
) {navigator.serviceWorker.register("/sw.js")
.catch(console.error);
}
创建钩子来监听 service worker 消息和鼠标点击
创建一个 useOnNavigate.js 文件(如 /src/app/hooks/useOnNavigate.js):
//useOnNavigate.js
"use client";
import {usePathname} from "next/navigation";
import {useEffect, useState} from "react";
let clickTime = 0;
let pathWhenClicked = "";
export function useOnNavigate() {const curPath = usePathname();
const [loading, setLoading] = useState(false);
useEffect(() => {
clickTime = 0;
if (curPath !== pathWhenClicked) {setLoading(false);
}
}, [curPath]);
useEffect(() => {if (typeof navigator === "undefined") return;
const onMessage = ({data}) => {if (Date.now() - clickTime > 1000) return;
const url = toURL(data.fetchUrl);
if (url?.search.startsWith("?_rsc=")
&& data.dest === ""
) {
clickTime = 0;
setLoading(true);
}
};
const sw = navigator.serviceWorker;
sw?.addEventListener("message", onMessage);
const onClick = (e) => {clickTime = Date.now();
pathWhenClicked = location.pathname;
};
addEventListener("click", onClick, true);
return () => {sw?.removeEventListener("message", onMessage);
removeEventListener("click", onClick, true);
};
}, []);
return loading;
}
function toURL(url) {
try {if (url) return new URL(url);
} catch (e) {}
return null;
}
这个钩子会:
- 用 useState 存储加载状态
- 添加事件监听器检测点击和 service worker 消息
- 点击时保存时间戳和当前路径
- 收到 service worker 消息时,检查最近是否有点击,URL 是否匹配 Next.js 小数据文件格式
- 路径变化时重置加载状态
创建一个缓慢淡入快速淡出的加载指示器
创建 LoadingBar.jsx 文件(如 /src/app/components/LoadingBar.jsx):
//LoadingBar.jsx
"use client";
import styles from "./LoadingBar.module.css";
import {useOnNavigate} from "./useOnNavigate";
export default function LoadingBar() {const loading = useOnNavigate();
return (
<div
aria-busy={loading}
className={styles.loading}></div>
);
}
然后创建 LoadingBar.module.css:
/* LoadingBar.module.css */
.loading {
transition: opacity 0.3s ease-in;
opacity: 0;
will-change: opacity;
position: fixed;
height: 20px;
width: 100%;
left: 0;
top: -10px;
filter: blur(8px);
background: red;
}
.loading[aria-busy="true"] {
opacity: 1;
transition-duration: 1s;
}
这会让加载条缓慢淡入(1 秒)并快速淡出(0.3 秒)。
点击链接时会在顶部显示发光的红色加载条
在应用中添加加载指示器
找到 app 文件夹根目录的 layout.js,在 body 标签内添加 LoadingBar 组件:
//layout.js
//...
return (
<html lang="en">
<body>
<LoadingBar />
{children}
</body>
</html>
);
//...
Chrome 测试注意事项
你可以在 Firefox 中测试这个加载指示器,应该能看到点击链接时顶部出现红色模糊条。
如果你的网站不是在 HTTPS 上运行且没有有效证书,Chrome 会拒绝加载 Service Workers(Chrome 不支持自签名证书)。
这种情况下可以先在 Firefox 上测试,因为它接受自签名 HTTPS 证书。
给加载条添加动画效果
我们来给加载条添加两个来回摆动的聚光灯效果,一个黑色(从右到左),一个白色。
加载条上的聚光灯动画效果
修改 LoadingBar.module.css,删除 background: red,添加以下内容:
.loading::before,
.loading::after {
content: "";
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0.5;
mix-blend-mode: color;
background: 50%/200% repeat-x;
animation: 1s ease-in-out infinite alternate;
}
.loading::before {
background-image: linear-gradient(90deg, transparent, black, transparent);
animation-name: loading-rg;
}
.loading::after {
background-image: linear-gradient(90deg, transparent, white, transparent);
animation-name: loading-lt;
}
@keyframes loading-lt {from { background-position: 100% 0}
to {background-position: 0% 0}
}
@keyframes loading-rg {from { background-position: 0% 0}
to {background-position: 100% 0}
}
这样就能让两个聚光灯在加载条上来回移动,提供更明显的视觉反馈!