Next.js 实现 PJAX 刷新

前言

大概24年10月左右，我把整个站点的技术栈从原生JS+自己写的EJS静态站点生成器换成了Nextjs+React，因此确实可以说是“破坏性迁移”，以前的所有文章和评论基本都丢失了，而且因为新架构的评论系统与账户系统绑定，所以评论是彻底无法恢复了。

但当时我发现React与Pjax的相性并不好，具体来说，当时的我是这么分析的:

我们可以看看 Pjax 的必要要求：

不直接通过 script 标签的方式在跳转后的页面引入新的 js 文件，即每个页面的 js 文件应该是相同的，因为因为 pjax 而更改的 script 标签不会运行

其实倒也不是绝对不行，我们还可以手动给会变化的 js 打个标签，页面重载后将其使用 document 操作放进页面中

服务器应该要返回 Pjax 所需要的元素，也可以是渲染后的整个文档

原生 React 在浏览器端渲染，这个是肯定不行了，虽然 React 本身也可以在服务器端渲染完然后传字符串过来，但是打完包似乎也不能正常工作

这个我在当时还发了篇文写，我妥协了，React与Pjax不可兼得 | RavelloH's Blog，其后我倒是也一直有尝试，但也都没成功，不过最近终于是有了进展。

尝试

下面算是半个站点的发展史吧，想直接看Nextjs如何实现Pjax可以转到#实现

一开始整个站点使用的Pjax方案就比较抽象，v3初期的主题实际上就是我自己把整个站点需要用到的所有函数都放在script.js中一起引用的，所以支持Pjax的操作倒也比较简单，之前是使用的MoOx/pjax: Easily enable fast Ajax navigation on any website (using pushState + xhr)作为Pjax的实现方案。

当时确实是没什么问题，而且我还为Pjax的支持做了个播放器和加载进度条（目前的功能都恢复正常了），后来我确实厌烦了“维护HTML”，（是的，实际上RTheme V3之前，甚至V3之后的一段时期，它就是个纯HTML+CSS+JS的网页，写文章都要自己新建个HTML来写，写完还得自己往列表页里面加索引），于是直接一口气写了一套静态站点生成器，支持EJS与类Wiki语法：RavelloH/RenderBuild: 基于NodeJS的模板化站点生成器，使用类MediaWiki模板语法与ejs语法快速构建静态/Serverless/动态站点。

有了这套系统，好处是之后的操作总之是简单很多了，这也让这个主题终于能用了，写文章也只需要在对应文件夹里面创建个markdown，直接写完build一下即可。

实际上当时的版本非常高级，整个博客在0.2s内就能直接渲染完，甚至也支持实时的dev模式:

当时的项目我存档到了RavelloH/ravelloh.github.io: A GitHub personal site # # # 基于GithubPage的个人博客，实际上这套静态站生成工具确实相当好用，因为是我自己写的所以解决了当时我很需要的一个问题——不同项目之间如何同步同一套站点主题。

换句话说就是，除了博客本身，我还有一些项目也用到了RTheme，不过当时的RTheme与我个人的博客的样式存在偏差，也就是说我希望让其他所有的项目在build的时候都能直接同步我博客的框架主题。于是我给这套静态站生成器加上了“远程下载资源”的功能，你可以向Wiki中的{{page}}语法一样，把任何页面直接引入到你的页面里。

直到现在它似乎仍是唯一一个有此功能的静态站生成器(笑)。目前这个生成器已经烂尾了，但是功能还是够用的，目前整个索引 | PSGameSpider的几万个页面就是由它渲染而成的。

不过后来我就不仅仅满足于"静态站“，于是开始着手将整个站点改为Nextjs。当时改的很粗造，以至于我到现在还在修当时更改的bug，不过好处就是易用性大大增加了，现在确实是在站点内点点按钮就能管理文章。

不过问题就是React并不能使用传统的Pjax，通过直接修改DOM树来进行内容切换反而会导致水合错误，另外也没法正常加载每个页面都不相同的style和script。

举例来说，想要实现“切换到新页面时加载对应脚本”的效果，需要你每次pjaxload结束后重新加载一下新地址的所有script:

const executeScripts = (newDoc) => {
const scripts = newDoc.querySelectorAll('script');
scripts.forEach((oldScript) => {
const newScript = document.createElement('script');

// 复制所有属性
Array.from(oldScript.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});

// 如果是外部脚本，直接插入
if (oldScript.src) {
newScript.src = oldScript.src;
newScript.async = false; // 维持同步执行
} else {
// 如果是内联脚本，复制内容
newScript.textContent = oldScript.textContent;
}

// 替换旧的 script
oldScript.replaceWith(newScript);
});
};

但是这样React就会检查水合，理所当然的就会出现水合错误，并且就算忽略水合也不能正常触发钩子进行渲染。

最后我发现Nextjs似乎本身就带有一定的Pjax特征——如果你用<Link>标签的话（可惜我没用），就会注意到Nextjs已经有类似的局部加载机制了，Vercel的官网就是这样。除此之外，你也可以直接用Functions: useRouter | Next.js来更改整个页面路由，这样也会直接使用这种局部加载的机制。

于是我打了个包，加了几个事件输出的节点，这样就能实现一个完美的Pjax了——不需要提前加载好每个页面用到的资源，也能直接实现比肩SPA(单页应用程序)的Pjax效果。

实现

代码

// @/components/Pjax.jsx
'use client';

import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useBroadcast } from '@/store/useBroadcast';

const Pjax = ({ children }) => {
const [loadStart, setLoadStart] = useState(false);
const router = useRouter();
const broadcast = useBroadcast((state) => state.broadcast);
const pathname = usePathname();

useEffect(() => {
const handleClick = (e) => {
const target = e.target.closest('a');
if (
!target ||
e.ctrlKey ||
e.metaKey ||
e.shiftKey ||
target.target === '_blank' ||
target.target === '_self'
) {
return;
}

const href = target.href;
if (
!href ||
new URL(href).origin !== location.origin ||
href.replace(location.origin + '/', '').startsWith('#')
)
return;

e.preventDefault();
if (target.pathname === location.pathname) return;
broadcast({ type: 'LOAD', action: 'loadStart' });
setLoadStart(true);

router.push(href);

};

document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [router, broadcast]);

useEffect(() => {
if (!loadStart) return;
broadcast({ type: 'LOAD', action: 'loadEnd' });
setLoadStart(false);
}, [pathname]);

useEffect(() => {
const handlePopState = (e) => {
broadcast({ type: 'LOAD', action: 'loadEnd' });
};

window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [router, broadcast]);

return children || null;
};

export default Pjax;

// @/store/useBroadcast
import { create } from 'zustand';

export const useBroadcast = create((set, get) => ({
callbacks: [],

registerCallback: (callback) => {
set((state) => ({
callbacks: [...state.callbacks, callback],
}));
},

broadcast: (message) => {
get().callbacks.forEach((callback) => callback(message));
},

unregisterCallback: (callback) => {
set((state) => ({
callbacks: state.callbacks.filter((cb) => cb !== callback),
}));
},
}));

使用

据我测试，任何Nextjs站点应该都能直接使用这个Pjax，方法如下:

创建以上两个文件，其中useBroadcast是用来通知其他组件pjax的加载状态的，方便你加入过渡动画或者加载指示器，具体使用方法见Zustand实现React全局状态管理 | RavelloH's Blog，对此文中的方法，接入的时候只需要在其他组件里这样写:

import { useBroadcast } from '@/store/useBroadcast';
export default function LoadingShade() {
const registerBroadcast = useBroadcast((state) => state.registerCallback);
const unregisterBroadcast = useBroadcast((state) => state.unregisterCallback);

useEffect(() => {
const handlePageLoad = (message) => {
if (message.action === 'loadStart') {
// 开始Pjax加载了
}
if (message.action === 'loadEnd') {
// Pjax加载完了
}
};
registerBroadcast(handlePageLoad);
return () => {
unregisterBroadcast();
};
}, [registerBroadcast, unregisterBroadcast]);
return <></>;
}

随后在你的layout.jsx中随便找个位置引入就行了，

import Pjax from '@/components/Pjax';

export default function RootLayout({ children }) {
return (
<Pjax />
{children}
)
}

没能成功加载(404，500等错误) 的话，页面会直接正常跳转到目标页面。

不过目前还是存在小问题的，因为使用router进行导航实际上是无法获取是否完成的信息的，我这里是使用的usePathname();来监听地址变化，变化则认为是Pjax已经加载完了。

这样的一个问题是无法获取Pjax加载当前页的状态，毕竟地址也没变，所以这里的Pjax行为改为了当点击指向当前页链接的时候什么都不做，主要逻辑在这里:

if (target.pathname === location.pathname) return;

你也可以按自己的需求更改，比如原地刷新什么的。

另外如果你准备在Pjax加载触发的时候加个渐变动画什么的(如此博客的效果)，那么我建议你修改下上面的Pjax.jsx，把router.push(href);做个延迟，防止实际加载速度太快打断动画。

router.prefetch(href);
setTimeout(() => {
router.push(href);
}, 300);

这里的时间300ms可按你的动画所需时间来，另外这里因为使用了router.prefetch(href);来预加载，实际上不会浪费多少时间，充其量会把本来300ms之内就加载完的页面的加载事件固定在300ms。