优化Next.js加载指示器的实用技巧

90次阅读
没有评论

共计 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. 检测点击后的网络请求。我们假设点击后 1 秒内发出的网络请求都跟点击有关。
  2. 显示自定义加载指示器。
  3. 等页面导航到新 URL。此时,loading.js 中的 Suspense 加载组件会接管,继续下载 page.js。
  4. 隐藏我们的加载指示器。

使用 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}
}

这样就能让两个聚光灯在加载条上来回移动,提供更明显的视觉反馈!

正文完
 0
历史的配角
版权声明:本站原创文章,由 历史的配角 于2025-04-13发表,共计4733字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)
验证码

您无法复制此页面的内容

了解 未来日记 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读