问题起因是我想给自己博客页面切换时加一个进度条。博客前端技术栈是 React,使用 Next 做了 SSR。在进度条方面,npm 上有非常成熟的库 NProgress。我们只需要调用NProgress.start()
和NProgress.done()
即可开启和结束进度条。我们监听到页面开始、结束切换的时候,调用NProgress.start()
和NProgress.done()
即可。但是问题是,Next 13 版本以后推出的 App Router 模式并没有给出监听路由切换的 API。
先来看看 Pages Router 模式的做法:
可以发现,在 npm 上面有一个 Next 下封装 NProgress 的库:nextjs-progressbar,它是基于 Pages Router 开发的。
在 Pages Router 模式下,可以使用router.events
的 API 去监听路由切换:
1import { useEffect } from 'react'
2import { useRouter } from 'next/router'
3import NProgress from 'nprogress'
4import 'nprogress/nprogress.css'
5
6export function useProgressBar() {
7 const router = useRouter()
8 NProgress.configure({ showSpinner: false, speed: 400 })
9 useEffect(() => {
10 const startHandler = () => {
11 NProgress.start()
12 };
13 const completeHandler = () => {
14 NProgress.done()
15 };
16 router.events.on('routeChangeStart', startHandler)
17 router.events.on('routeChangeComplete', completeHandler)
18 }, [])
19}
是不是很方便呢,在 App Router 模式下就难搞了。
App Router 模式基于 React 的服务端组件对渲染的模式进行了更细致的划分,这看起来就很 Coooooooool,除了某些 API 不太方便之外。在 App Router 模式,没有监听路由跳转发起的 API,只能通过监听路径(usePathname
、useParams
)、query 参数(useSearchParams
)等方式知道路由已经发生变化了。
为了解决这个问题,这里参考了一下在 Next 的 issue:[Next 13] router.events removed?、[next/navigation] Next 13: useRouter events? 下面的讨论。
监听路由开始切换,可以通过监听Link
标签的click
事件实现。具体操作是使用MutationObserver
监听document
,在子树变更时搜索所有<a>
标签加上监听器。然后通过代理history.pushState
、history.replaceState
来在路由变更后关闭进度条。
借鉴 issue 中讨论的代码,这里自定义了 Hook 函数useProgressBar
来实现这一点。
1import { useEffect } from 'react'
2import NProgress from 'nprogress'
3import 'nprogress/nprogress.css'
4
5type PushStateInput = [any, string, string | URL | null | undefined]
6
7const handleAnchorClick = (event: MouseEvent) => {
8
9 if (event.ctrlKey || event.metaKey) {
10 return
11 }
12
13 const target = (event.currentTarget as HTMLAnchorElement | null)?.target
14 if (target && target !== '_self') {
15 return
16 }
17
18 const targetUrl = (event.currentTarget as HTMLAnchorElement | null)?.href
19
20 if (!targetUrl || targetUrl.includes('#')) {
21 return
22 }
23
24 const currentUrl = window.location.href
25 if (targetUrl !== currentUrl) {
26 NProgress.start()
27 }
28}
29
30export const useProgressBar = () => {
31 useEffect(() => {
32 NProgress.configure({ showSpinner: false, speed: 400 })
33
34 const handleMutation: MutationCallback = () => {
35 const anchorElements: NodeListOf<HTMLAnchorElement> = document.querySelectorAll('a[href]')
36 anchorElements.forEach((anchor) => anchor.addEventListener('click', handleAnchorClick))
37 }
38
39
40 const mutationObserver = new MutationObserver(handleMutation)
41 mutationObserver.observe(document, { childList: true, subtree: true })
42
43 window.history.pushState = new Proxy(window.history.pushState, {
44 apply: (target, thisArg, argArray: PushStateInput) => {
45 NProgress.done()
46 return target.apply(thisArg, argArray)
47 },
48 })
49 window.history.replaceState = new Proxy(window.history.replaceState, {
50 apply: (target, thisArg, argArray: PushStateInput) => {
51 NProgress.done()
52 return target.apply(thisArg, argArray)
53 },
54 })
55 }, [])
56}
useProgressBar
处理全局的Link
标签,如果是使用router.push
切换路由,类似地,我们也在router.push
触发一次进度条好了:
1import NProgress from 'nprogress'
2import { useRouter } from 'next/navigation'
3import { NavigateOptions } from 'next/dist/shared/lib/app-router-context.shared-runtime'
4
5type NextNavPushArgsType = [string, NavigateOptions | undefined]
6
7export const useProgressRouter = () => {
8 const router = useRouter()
9 router.push = new Proxy(router.push, {
10 apply: (target, thisArg, argArray: NextNavPushArgsType) => {
11 NProgress.start()
12 return target.apply(thisArg, argArray)
13 },
14 })
15 router.replace = new Proxy(router.replace, {
16 apply: (target, thisArg, argArray: NextNavPushArgsType) => {
17 NProgress.start()
18 return target.apply(thisArg, argArray)
19 },
20 })
21 return router
22}
到这里,进度条就施工完毕了。
approuter 可以用这个nextjs-toploader 库, 在body下引用<NextTopLoader />即可