作为一个个人博客系统,内容的更新频率通常相对较低(可能几天更新一次甚至更久)。在这样的场景下,我们该如何将 Next.js 博客的性能榨干到极致?
如果你的目标是将博客部署到 Cloudflare Pages / Workers 等具有严格代码包大小限制(例如免费额度压缩后 3MB)的边缘计算平台,体积和速度的平衡就显得尤为关键。
本文将结合本站的重构实践,分享五项极具实操价值的 Next.js 性能调优方案。
Next.js 的 React Server Components(RSC)和服务器缓存非常优秀,但对于需要与服务器通信的客户端渲染(Client Component)列表(例如:友情链接、评论区、AI 趋势板、作品集筛选等),每次切换页面或重新载入时都会出现短暂的白屏或 Loading 骨架屏。
为了实现 0ms 瞬间加载,我们可以叠加一层本地 localStorage 缓存,采用 SWR(先旧后新) 策略:
localStorage 中。localStorage 并将其渲染到屏幕上,实现 0 毫秒感知。同时,在后台静默向服务器发起请求进行校验。useLocalSWR我们可以封装一个通用的 Hook 来处理这个逻辑,同时做好 React 在服务端渲染(SSR)时的水合防错(Hydration Safety):
'use client';
import { useState, useEffect } from 'react';
export function useLocalSWR<T>(
cacheKey: string,
fetcher: () => Promise<T>,
options = { revalidateOnMount: true }
) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isValidating, setIsValidating] = useState(true);
// 1. 客户端挂载后,立即恢复本地缓存
useEffect(() => {
if (typeof window === 'undefined') return;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
setData(JSON.parse(cached));
} catch (e) {
localStorage.removeItem(cacheKey);
}
}
}, [cacheKey]);
// 2. 静默发起请求校验并更新
useEffect(() => {
let active = true;
async function revalidate() {
setIsValidating(true);
try {
const freshData = await fetcher();
if (!active) return;
const freshString = JSON.stringify(freshData);
const cachedString = localStorage.getItem(cacheKey);
if (freshString !== cachedString) {
setData(freshData);
localStorage.setItem(cacheKey, freshString);
}
} catch (err: any) {
if (active) setError(err);
} finally {
if (active) setIsValidating(false);
}
}
if (options.revalidateOnMount) {
revalidate();
}
return () => {
active = false;
};
}, [cacheKey, fetcher, options.revalidateOnMount]);
return { data, error, isValidating };
}
许多人写技术博客会使用 rehype-highlight 做代码高亮。但默认的 rehype-highlight(基于 Highlight.js)会在打包时静态引入所有 190 多种编程语言的解析语法包。
这会导致你 Next.js 构建出来的 Node.js/Edge Server Bundle 体积瞬间膨胀几兆,从而无法部署到 Cloudflare 免费版。
lowlight 定制轻量高亮插件既然是个人博客,我们常用的编程语言大概只有 10 种左右(例如:JS、TS、Rust、Python、Go、SQL、HTML/CSS、Bash、Markdown、JSON、YAML)。我们完全可以写一个自定义的 Rehype 插件,只静态注册我们需要的这几种语言,从物理上避开其它 150 多种语言的打包。
在你的 lib 目录下新建 rehype-custom-highlight.ts:
import { createLowlight } from 'lowlight';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
...
// 仅注册博客用得到的常用语言
const lowlight = createLowlight({
js: javascript,
javascript,
ts: typescript,
typescript,
py: python,
python,
...
});
// 手写简单的 HAST 树递归遍历,避免引入额外的 node_modules 辅助库
function walk(node: any, callback: (node: any, parent: any) => void, parent?: any) {
callback(node, parent);
if (node.children && Array.isArray(node.children)) {
for (const child of node.children) {
walk(child, callback, node);
}
}
}
export default function rehypeCustomHighlight() {
return function (tree: any) {
walk(tree, (node: any, parent: any) => {
if (node.tagName !== 'code' || !parent || parent.tagName !== 'pre') return;
const className = node.properties?.className || [];
const langClass = className.find((cls: any) => typeof cls === 'string' && cls.startsWith('language-'));
const lang = langClass ? langClass.slice(9) : null;
if (!lang) return;
try {
const text = node.children[0]?.value || '';
const result = lowlight.highlight(lang, text, { prefix: 'hljs-' });
if (result.children && result.children.length > 0) {
node.children = result.children;
}
if (!node.properties.className.includes('hljs')) {
node.properties.className.unshift('hljs');
}
} catch (err) {
// 遇到未知语言静默跳过高亮
}
});
};
}
将 MDXRemote 或编译流水线中的 rehypeHighlight 替换为上面的 rehypeCustomHighlight,打包体积即可降 1.5MB+ Gzip 压缩体积!
在 Next.js 服务端开发(尤其是在部署到 Cloudflare Workers 等 Edge runtime 资源限制严格的环境)中,引入第三方功能包是导致构建体积超限的主因。如何系统性地瘦身庞大的第三方依赖?可以遵循以下四项通用的精简法则:
在引入大包(例如富文本编辑器、数学公式排版、三维渲染等)之前,先通过 bundlephobia 检查其打包体积。
有些第三方重型包的工作可以拆分为“结构解析”与“渲染排版”两部分:
<span class="data-node">...</span>),借此防止渲染引擎把特殊符号错误地解析为 JS/JSX 表达式,避开 Acorn 等编译器崩溃的问题。<ScriptRenderer /> 组件异步承接。为了彻底将重型引擎从本地打包中剥离:
import 将引擎 JS/CSS 静态打包进项目。而是仅在需要的动态页面中,通过 HTML 的 <script defer src="https://cdn..." /> 异步加载外部 CDN 上的公共资源。usePathname() 钩子。只要监听到路由改变,就在浏览器中自动调用已从 CDN 加载完毕的 window.someHeavyEngine.render(node) 方法。这种“按需按时”初始化大大解放了服务端体积,且对首屏冷启动极其友好。很多时候我们为了使用一个大包中不到 5% 的特定小功能,就不惜把整个包全量导入。
walk 函数,这能省下几十甚至上百 KB 的第三方链条包体积。在传统开发中,我们会直接通过 npm 安装代码高亮样式表或 KaTeX 字体包,并通过 @import 导入到 globals.css 中。然而,在边缘 Worker/Edge 环境中,这些字体资产和外部 CSS 在被编译进 Next.js 构建体积时,会极大增加冷启动的负担。
globals.css 中 @import "highlight.js/styles/atom-one-dark.css" 导致构建时 PostCSS 将全套样式打包进主要 CSS 文件。<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css"
/>
这保证了全站其他页面(如首页、关于页)在加载时不需要承担这部分无关的代码负担,实现了资源按需最小化加载。
通过对 Next.js 流水线的全方位重构,我做到了:
| 优化项 | 服务端包体积减少 | 客户端加载首屏速度 (二次访问) | 部署限制兼容性 |
|---|---|---|---|
| 移除默认 rehype-highlight | ~1.5 MB | 无变动 | 极其友好 |
| KaTeX 服务端转客户端 | ~400 KB | 无变动 | 极其友好 |
| RSC 配合客户端 localStorage SWR | 无变动 | 从 ~400ms 降至 0ms | 友好 |
现在,个人博客在 Next.js 的高自由度配置下不仅能够实现极致的代码瘦身,还能在秒开表现上向纯静态生成的博客(Gatsby/Hugo)看齐,甚至拥有更流畅的后台静默校验及无感刷新体验。
No comments yet. Be the first!