resin-blog
首页归档简历规划关于
...
赣ICP备2026011201号-1

博客优化

2026-09-14· 前端, 后端

从一个博客出发,聊透 Web 性能优化:按需加载、代码压缩、图片与缓存

整理博客代码时,我发现了一些很容易被“功能已经做完”掩盖的细节。

归档页只展示文章卡片,却收到了每篇文章的完整 HTML。首页只展示某一天的随记,却把所有历史正文都读了出来。用户只是打开后台列表,页面已经引入了编辑器;首页的光标还在通过定时器不断更新 React 状态。

这些实现并不妨碍功能正常工作。只是沿着数据和组件的调用链往下看,会发现有些工作做得太早,有些做得太多,还有些根本没有必要做。

这次优化,我没有先改打包参数,也没有更换框架,而是围绕三个问题逐一检查:

  • 页面真的需要这些数据吗?
  • 这些工作必须现在完成吗?
  • 相同的工作,为什么还要再做一次?

博客代码性能优化手绘总览:少传一点、晚做一点、少算一点

这篇文章从我的个人博客 resin-blog 出发,对应 Next.js 16.2.6、React 19.2.4、Prisma 和 PostgreSQL。但我不想只留下一份“改了哪些文件”的记录,还想沿着这些改动继续往外看:按需加载到底推迟了什么,代码压缩减少了什么,图片放到 OSS 后,为什么仍然可能很慢?

前六节是已经落地的代码实践;第七到十一节展开通用方法和后续候选方案;最后两节讲验证与排期。框架默认就有的功能、项目原本具备的 OSS 上传能力,以及尚未实施的方案,会分别说明,不混成这一轮的新增成果。

代码片段会省略无关展示和类型。重点是理解成本发生在哪里,再选择对应的手段,而不是把所有优化配置都打开。

也先交代一个边界:这轮完成了代码调整和回归测试,但没有完整的线上性能前后对照。因此,下面会讲清楚减少了什么工作,不会给出没有测量依据的“提速百分比”。

一、归档只展示摘要,就不要把正文也带过去

先看浏览器到底收到了什么

博客归档页需要标题、发布日期、分类、标签、摘要和阅读时间。原来的读取函数却为每篇文章生成了 HTML,并把这些全文数据一起传给客户端列表。

问题不只是“多查了一个字段”。

服务端需要读取和处理它,框架需要把它序列化成可以传输的数据,网络需要搬运它,浏览器还需要接收和保存它。最后,卡片根本没有用到这段正文。

所以我先收紧了列表的数据契约,也就是明确“这两个模块之间到底传什么”:

interface ArchivePostSummary {
  id: string;
  title: string;
  date: string;
  description: string;
  tags: string[];
  categoryId: string | null;
  readingTime: number;
}

归档不再接收 content、html 和未使用的 wordCount。相关卡片和列表组件统一使用这份摘要类型,也不再计算没有被展示的总字数。

这里有一个不能省略的取舍:阅读时间原来是根据正文计算的。我没有为了这一轮优化增加数据库字段、修改写入逻辑和迁移已有内容,而是保留原来的算法。

因此,不向浏览器传全文,并不等于服务端完全不读全文。

再处理重复计算

为了避免每次访问都重新读取正文、解析 Markdown、计算阅读时间,我给公共摘要加了一层缓存。

这份缓存只保存公开的文章信息,不包含当前用户是谁、是不是管理员、点过哪些赞。缓存键和失效标签分别承担不同职责:前者用于识别结果,后者方便内容修改后批量使结果过期。

项目实际使用的配置是:

// getArchivePostSummaries 的包装方式,省略查询与字段映射。
const getArchivePostSummaries = unstable_cache(
  readArchiveSummaries,
  ["archive-post-summaries-v1"],
  { tags: ["post-summaries"], revalidate: 3600 },
);

这里的 readArchiveSummaries 只是示意命名;实际实现把查询和映射直接写在回调中。缓存未命中时,仍然读取正文计算阅读时间;有效命中时,复用已经生成的摘要。

本项目没有开启 Cache Components,以上是在现有 unstable_cache 路径上的调整。Next.js 16 的本地文档已经给出了 use cache 方向;这段代码是项目复盘,不代表所有 Next.js 项目都应该照搬同一种缓存接口。

缓存必须有退出机制

一个博客后台刚改完文章,前台却继续展示旧摘要,这样的“变快”没有意义。

所以缓存和失效一起设计。原有写接口在数据库写入成功后会调用统一的 revalidateContent(),我在这里补上摘要标签的立即过期:

if (target.scope === "posts" || target.scope === "categories") {
  revalidateTag(POST_SUMMARIES_CACHE_TAG, { expire: 0 });
}

为什么分类操作也在这里?因为删除分类会改变文章的 categoryId。如果只刷新分类列表,文章摘要仍可能携带已经不存在的分类关系。

这个细节让我更明确了一件事:缓存不能只围绕“这个函数查了哪张表”设计,还要围绕“哪些业务操作会改变它的返回结果”设计。

原来的页面路径失效仍然保留。配置里的 3600 秒是重新验证设置,不是严格的实时同步承诺;绕过应用直接改数据库,也不会自动执行这些失效逻辑。

二、首页随记:日历需要日期,不需要整座历史档案

首页有一个随记日历。某一天有记录,就显示标记;用户点击日期,右侧展示当天内容。

原来最直接的实现是:把所有随记取回来,在客户端按日期过滤。文章少时很方便,但这让首屏传输量跟全部历史正文绑定在了一起。

把需求拆开后,数据其实有两种完全不同的用途:

  1. 日历标记只需要知道哪些日期有记录。
  2. 正文区域只需要选中日期的记录。

于是首页的数据读取改成两条并行查询:一条只选取日期,另一条按日期范围读取正文。顶部介绍和其他首页结构也不再等待这块随记数据全部完成。

返回客户端的结果可以概括为:

{
  today,
  selectedDate,
  dates,      // 日历标记需要的日期索引
  jottings,   // 选中日期的正文,今天为空时可回退最近一条
}

点击历史日期时,客户端更新 jottingDate 查询参数,让服务端读取相应日期;回到今天则移除这个参数。使用 router.replace,并保留滚动位置,避免看一条随记就往浏览器历史里添加一次记录。

但收窄数据范围以后,不能把原来的交互语义顺便丢掉。

同一天有多条记录,仍然全部展示;今天没有记录,可以展示最近一条;用户主动选中一个历史空白日,就应该看到空状态,而不是又跳出一篇别的日期的内容。日期参数也必须校验,不能把一个看起来像日期的字符串直接交给查询。

复查时还发现了一个和异步切换有关的边界:从今天切向历史日期,加载还没完成,再点今天。如果代码只判断“点击日期等于当前 props 日期”,第二次选择就会被吞掉。

最后把早退条件收紧为:

if (!pending && date === selectedDate) return;

只有当前没有切换正在进行,才认为这次重复选择不需要处理。跨月返回今天时,也通过选中年月的 key 让日历同步月份,避免正文已经更新、日历还停在旧月份。

这项改动减少的是历史正文的读取和传输,日期索引仍会增长;切日仍是 Next.js 路由更新,也不能说只有随记组件一定会重新执行。优化后的边界,需要和收益一样清楚。

三、正文先到,点赞和前后篇不用一起等

文章详情页原来有一条串行等待链:

读文章,查相邻文章,取登录会话,再查点赞状态,最后返回文章内容区域。

其中有必要的依赖,也有展示层人为绑在一起的等待。正文需要文章数据,但它并不需要先知道用户有没有点赞。

我把点赞区域和相邻导航拆成独立的异步组件,再分别放进 Suspense。可以把它理解成一个等待边界:这块内容没准备好时先展示占位,不必让整个父区域陪它等。

文章详情异步边界手绘图:正文与目录不再等待点赞和相邻导航

图示表达的是文章内容区域的依赖关系,不是实测耗时;“整页返回”在这里指 PostPage 内容,公共布局不在图内。“登录与点赞”指读取会话和点赞状态,不是要求访客先登录才能阅读。

页面结构可以简化为:

<>
  <ArticleBody html={post.html} />

  <Suspense fallback={<LikePlaceholder />}>
    <PostLikeButton postId={post.id} />
  </Suspense>

  <Suspense fallback={null}>
    <AdjacentPostLinks postId={post.id} />
  </Suspense>
</>

这里真正重要的不是多写了两个 Suspense 标签,而是把异步读取放进了它包裹的组件里。如果先在父组件把所有数据都 await 完,再给已经准备好的 JSX 套一个边界,就没有拆开原来的等待。

正文仍然需要等待文章查询和 Markdown 转换,数据库也不会因为 Suspense 而执行得更快。改变的是“哪些内容必须一起准备好”,不是查询本身的耗时。

归档页也做了类似的调度调整:文章摘要、分类和会话并行启动,拿到文章 ID 后再并行读取评论数与点赞数。归档只展示点赞数量,因此不再额外查询当前用户的 liked 状态。

同一篇文章,也没必要反复查询

详情页的标题元信息、正文和相邻导航都可能需要当前文章。它们服务于同一次页面渲染,可以复用同一份查询结果:

const getPostRecord = cache(async (id: string) =>
  prisma.post.findUnique({ where: { id } }),
);

getPostById() 又对正文转换做了一层 React cache()。这里是服务端渲染请求内的复用,和前面跨请求保存摘要的 Next.js 数据缓存不是一回事。

相邻文章也不再先取出全部文章、再 findIndex()。现在按当前文章的完整日期和 ID 向前、向后各查一条。同日期用 ID 保持顺序稳定,当前文章不存在则返回空导航。

减少返回行数和应用侧遍历,是这一步可以明确说明的收益。数据库实际扫描了多少行,还取决于索引和执行计划,不能仅凭 findFirst() 就宣布查询变成了常数时间。

目录是另一份摘要

目录组件原来拿整段 HTML,在浏览器里提取标题。我把提取挪到了服务端,只传一个小数组:

type PostHeading = {
  id: string;
  text: string;
  level: 1 | 2 | 3;
};

正文照常输出,但不再为了目录交互重复传一份全文属性。这和归档的修改是同一个思路:组件需要什么,就传什么。

四、重型组件可以晚到,搜索索引可以留下

动态导入,只解决了一半问题

后台打开文章列表时,不需要立刻用到编辑器。GitHub 贡献日历在首页下方,用户刚进入页面时也未必会看到它。

编辑器改成动态导入,并保留表单的条件挂载:

const Editor = dynamic(() => import("@/components/Editor"), {
  ssr: false,
  loading: () => <div role="status">编辑器加载中…</div>,
});

// 位于客户端后台页面中;其余表单属性省略。
{editing && <Editor initialValue={content} onChange={setContent} />}

动态导入定义了代码加载的边界,editing 才定义了用户什么时候需要它。若组件声明成动态导入后仍无条件渲染,就不能说它已经延迟到了点击编辑时才加载。

贡献日历同理:用 IntersectionObserver 观察它是否接近视口,在约 200px 的提前区域里触发首次挂载,让用户滚动过来之前就有机会完成加载。等待时保留一定高度的占位,减少内容突然出现造成的布局变化。

日历首次进入后会断开观察,不会在每次离开视口时卸载重来。这是一次性的加载时机控制,不是一个不断开关组件的机制。

把搜索库加载和请求处理一起检查

顶部搜索框的 Fuse 实现改为首次聚焦或输入时动态加载,和文章请求并行进行。后续输入使用当前索引检索;重新聚焦时仍按原要求获取新文章数据。

这又带出了一个细节:聚焦和输入可能发生得很近,只用 loading state 防止重复请求不够直接,因为事件触发时 React 可能还没提交新的状态。

因此这里用 ref 保存当前 AbortController,同步判断是否已有在途请求。卸载时取消请求,结果返回时确认它仍然有效;请求失败可以重试,关闭的搜索面板也不会因为请求完成而自动弹回来。

按需加载不是把一个 import 改成另一种写法就结束了。加载变成异步以后,等待、失败和用户已经离开的情况,都要有明确的处理。

归档搜索不必每次重新造索引

归档是另一个搜索入口。它已经拿到了整份摘要列表,用户会在这份数据上反复输入关键词、选择分类和切换排序。

这里把索引的生命周期绑定到文章集合,而不是绑定到每次筛选:

const fuse = useMemo(
  () => new Fuse(normalPosts, searchOptions),
  [normalPosts],
);

文章集合没变,索引就复用;关键词变化,只执行新的搜索。分类、标签和年月再从结果中筛选。集合引用变了,比如刷新得到一份新数组,索引仍然重建。

测试也不只看“搜到了文章”,还使用真实 Fuse 检索,并统计构造次数,检查哪些交互会重建索引。

列表里还顺手去掉了一处循环中的数组复制。原来分类分组不断把已有数组展开成新数组;现在对本次计算中新建的局部分组数组追加元素。没有修改传入的 React 数据,却避免了同分类文章越多、复制越多的问题。

五、动画保留,但没有观众时可以休息

首页的樱花和打字效果是博客的一部分,我不想把性能优化简单做成“删除所有装饰”。更合适的问题是:哪些动画需要持续运行,哪些时候可以停下来?

先区分调度频率和绘制频率

requestAnimationFrame 会跟随浏览器的刷新节奏。如果每次回调都清空画布并重画,高刷新率屏幕就可能执行更多绘制。

我保留 rAF 调度,但给实际 Canvas 绘制加了约 30 FPS 的时间门限。桌面保持 40 片花瓣,屏幕宽度小于 768px 时使用 18 片。

降低绘制次数以后,位置不能继续按“每帧移动固定距离”更新,否则动画会跟着变慢。需要按真实经过时间推进:

petal.x += petal.vx * seconds;
petal.y += petal.vy * seconds;
petal.r += petal.vr * seconds;

这段代码里的速度以每秒为单位。恢复页面时也不补算后台停留的全部时间,避免花瓣突然跳到很远的位置。

这里说的约 30 FPS 是绘制门限,不是 rAF 回调本身固定只执行 30 次,更不是一份 CPU 降幅测试结果。

比降帧更直接的,是该停就停

页面进入后台时,樱花取消帧调度;系统开启“减少动态效果”时,清空并暂停画布,尚未请求的花瓣图片也不再启动加载。

首页 Hero 打字组件则持续观察自身是否在视口中,离屏或页面隐藏后暂停计时器,返回时从当前位置继续。减少动画模式直接显示完整文案,不让用户等待打字完成。

光标改为 CSS 闪烁,避免只是为了明暗变化而反复更新 React state。CSS 动画也有成本,因此它同样会在不需要时暂停;普通打字 hook 在完成后清除计时器,没有显示光标的地方也不再维护光标更新。

最后补齐生命周期清理:取消帧和定时器、移除监听、清除图片回调。否则组件已经卸载,一次迟到的图片加载仍可能把动画重新启动。

这些处理没有让页面变得“没有动效”,而是让动效的执行和用户实际看到它的时机更接近。

六、图片和组件边界,也一起收拾一下

项目展示图改用 next/image,通过 fill 和响应式 sizes 描述展示尺寸,默认懒加载。归档中透明度很低的装饰背景图则取消了优先加载,它不应该和主要内容竞争相同的加载优先级。

组件边界上,静态 Footer 由服务端 Layout 创建,再作为插槽传给客户端 AppShell。Header 和电梯导航改为动态组件,后台分支继续不挂载它们;只承担 CSS 容器作用的 template 则去掉了不必要的客户端声明。

我没有移除全局登录和主题 Provider,它们仍有真实消费者。也没有把“客户端外壳包着 children”误解成“所有子页面都变成客户端代码”:服务端构造的内容可以通过 children 或插槽交给客户端外壳。

数据库入口还处理了一个开发体验问题:热更新可能重新执行模块初始化,所以把 Pool 和 PrismaClient 一起保存在非生产环境的全局对象上复用。生产逻辑保持原有分支,没有借这一步调整生产连接数。

这些改动没有前面几项显眼,但目标一致:让模块的成本和它的实际使用范围对应起来。

七、把按需加载想清楚:不是越晚越好,而是让重要的先到

前面延迟了编辑器、搜索库和贡献日历,很容易得出一个过度简化的结论:所有东西都懒加载,首屏就会更快。

但用户打开一篇文章,本来就是来看正文和主图的。如果它们也要等到客户端脚本执行完、观察器触发后才开始请求,就可能把最重要的内容推到了最后。

资源加载时机手绘图:首屏必需尽早到达,临近视口提前加载,重型交互按用户意图加载

先分三类,再决定用什么 API

首屏必要内容应尽早可用,包括正文、必要样式,以及真正位于首屏、承担主要展示任务的图片。是不是关键图片,要看实际页面和视口,不能仅凭文件名叫 hero 就判断。

接近视口的内容可以提前一点准备,例如长文章的后续配图、页面下方的日历。触发距离是在“用户可能很快看到”和“不要为未阅读内容花太多流量”之间取舍,不是所有项目都必须设成 200px。

明确交互后才需要的能力适合等用户表达意图,例如点击编辑、展开图表、打开导出面板。第一次使用多一次等待是代价;可以用占位和失败重试兜底,也可以在聚焦等较可靠的意图信号出现时提前准备。

这三类不是组件永久不变的属性。同一个图表,在数据看板上可能是首屏核心,在文章末尾可能只是附加内容。

ssr: false 不是性能加速按钮

关闭服务端预渲染,解决的是某些组件只能在浏览器执行的问题;它也意味着该部分内容无法通过同样的服务端预渲染路径先交给用户。不能为了“减少服务端工作”,把正文和所有组件都改为客户端加载。

import()、组件挂载条件和服务端渲染边界需要分别考虑。Next.js 的官方例子也明确区分“独立成包但立即加载”和“满足条件才加载”;当前版本在 Server Component 内动态导入 Client Component,还存在自动代码拆分的限制。Next.js 懒加载指南

预加载和懒加载,不是互相矛盾

preload 用于当前页面很快需要的资源;prefetch 更偏向可能发生的后续导航。它们是在不同时间点分配加载机会,不是让带宽变成无限大。给所有资源加提前加载提示,反而可能让真正重要的资源排队。MDN:推测性加载

我的判断方法是:先在 Network 里找出首屏关键资源何时被发现、被什么阻塞,再决定是否补提示。先证明“发现太晚”,而不是先追求“提示加得足够多”。

八、代码压缩、删除无用代码和分包,解决的是不同问题

“把包压小”听起来像一件事,实际至少包含四件事:删除不需要的代码,把保留的代码写得更紧凑,把它拆成不同块,以及压缩网络上传输的字节。

代码体积优化手绘图:Tree shaking、Minify、Code splitting 和 gzip/Brotli 的职责区别

图中“分包决定何时加载”是概括说法:分包提供独立加载的边界,具体时机还由路由、挂载条件和预加载策略决定。四个格子不是固定的构建执行顺序。

Tree shaking:不需要的东西,就不带走

Tree shaking 可以理解为构建时剔除可确认未使用的代码。它依赖构建工具对模块引用和副作用的判断,不是写了命名导入,就一定只剩下那个函数。

这里的副作用,是模块导入时除了导出值,还会做的事情,例如注册插件、初始化全局状态、引入样式。随手把 sideEffects 全设成 false,可能让必要初始化或 CSS 被误删。webpack 的文档适合解释这个机制,但其配置不能直接当作本项目 Turbopack 的配置。webpack:Tree Shaking

对业务开发者来说,第一步不是手写裁剪规则,而是看依赖为什么进入了客户端:某个很大的工具库是否只用了一小部分?纯服务端工具是否被客户端公共入口间接引用?两个相近依赖是否承担了重复职责?这些都需要从实际导入链确认。

Minify:同样的逻辑,使用更紧凑的表达

例如下面的示意代码:

// 开发时,优先可读性。
function addTax(price, rate) {
  return price * (1 + rate);
}

// 压缩器可能生成类似的紧凑表达;不是项目实际构建输出。
function addTax(n,t){return n*(1+t)}

删除冗余空白、缩短局部变量名、做安全的表达式优化,都属于这类工作。但一个重复执行很多次的循环,写成一行以后仍会重复执行。不能拿 Minify 替代前面那些减少查询、复用索引的业务优化。

本项目的 Next.js 16 生产构建已经默认做代码压缩,这不是本轮新增的功能。也不应从旧教程补上已移除的 swcMinify: true。Next.js 编译器:Minification

Code splitting:让不同旅程,不必背同一个包

访客读文章不需要后台编辑器;只访问首页的人不需要立即下载一个从未打开的复杂表单。这是按路由、按功能建立代码边界的价值。

但拆包不保证总字节数下降。拆得过碎可能增加请求和调度成本;先加载 A,A 才发现 B,B 再请求 C,还可能制造新的等待链。应围绕用户流程拆分,而不是追求文件数量。

本项目默认走 Turbopack。后续如果分析生产产物,可按对应版本使用其内置实验性分析器,例如 pnpm exec next experimental-analyze --output,再按路由、客户端或服务端追踪模块来源。本文没有运行这条命令,也没有生成新的包体积报告。Next.js 包分析指南

分析时不要把三种“大小”混在一起:node_modules 占用影响本地安装,服务端部署产物影响发布和运行环境,浏览器实际加载的路由资源才直接进入访客的下载成本。一个大依赖只在服务端使用,不等于每位访客都下载了它。

项目中 ali-oss 的服务端外部化是已有的构建兼容性修复,也不能包装成“前端少下载了一整个 OSS SDK”的性能成果。

gzip / Brotli:压缩的是传输包裹

压缩后的 JavaScript 通过网络到达浏览器,再解压、解析和执行。传输字节变小,并不意味着解压后的业务逻辑消失,也不意味着主线程执行成本同比下降。

浏览器通过 Accept-Encoding 表达支持的编码,响应里的 Content-Encoding 才说明实际采用了什么。检查时还要看按编码协商的缓存是否正确区分版本。已压缩的图片、视频等资源,通常不该机械叠加同一套文本压缩策略。MDN:HTTP 压缩

本地 Next.js 文档说明默认服务端提供 gzip,但生产域名的实际响应可能经过反向代理或 CDN,不能只看框架默认值就宣布线上已经生效。Brotli 是否启用、放在哪一层、对当前资源是否值得,都需要实际验证;本文没有新增这类配置。

构建耗时也是另一条线。依赖安装缓存、增量编译、Docker 层缓存主要改善开发和发布效率,不会因为构建快了,就自动让读者的页面也快。两类收益都值得做,但应使用各自的指标记录。

九、图片上传 OSS,只完成了图片性能链路的一部分

前端把 JavaScript 减掉了一点,文章却直接放了几张数 MB 的原图,传输预算很快又被用掉。因此,图片不应该只是“能显示就行”的附件。

图片的成本至少有三段:作者上传原始素材,存储与分发系统提供文件,读者下载并解码展示。OSS 主要解决对象存储;缩放、格式转换、缓存、CDN 和页面布局,需要另外设计。

图片上传与分发手绘图:应用校验后上传 OSS,读者侧按尺寸格式获取图片,CDN 为可选环节

上半部分对应项目已有上传方式;下半部分是可选的优化分发链路,尺寸处理和 CDN 不代表已经在本项目启用。箭头表示素材流向,不是 HTTP 请求时序。

先说清楚这个博客现在做到了哪里

当前编辑器把图片交给 POST /api/upload。接口验证管理员身份,检查单文件、8 MiB 上限和文件头类型与声明类型的一致性,再由应用服务器把原始字节上传 OSS,成功后返回 URL。文件头检查不是完整图片解码验证。

对象名使用年月目录和 UUID,禁止覆盖,写入了一年有效期的 public, max-age=31536000, immutable 缓存头。这是项目已有上传逻辑,不是这轮文章扩写新增的改动。

但目前还没有自动缩放、格式转换或浏览器直传。图片仍经过应用服务器内存;返回地址指向原图,编辑器再把它插入 Markdown。文章存入数据库的是 Markdown 内容,其中包含图片 URL,并非图片二进制。

更容易被忽略的是阅读端:文章正文由 marked 转成 HTML,再输出原生 <img>;它没有自动进入 next/image 管线。首页项目展示图用了 next/image,不代表文章里的图片也已经获得同样的优化。

第一步:下载接近展示需求的尺寸

一张图片在手机上只有几百 CSS 像素宽,通常不需要无条件下载数千像素宽的原图。也不能只按 CSS 宽度固定给一张小图:高像素密度屏幕可能需要更多实际像素,应结合展示宽度、设备像素比和画质选择。

我会先准备有限几个宽度档位,例如 480、960、1440,再用 srcset 和 sizes 描述候选图与布局。这些是待验证的起点,不是全站强制标准。手绘说明图文字较多,必要时还可以提供原图查看入口。

OSS 支持读取时缩放。例如下面是处理参数示意,不是真实线上地址,也不是覆盖原图的操作:

?x-oss-process=image/resize,w_960,limit_1/format,webp

这里请求宽度为 960 像素的等比缩放版本,limit_1 避免放大小图,再转为 WebP。实际可用性取决于源图、访问权限、处理策略与域名配置,应验证返回的类型、尺寸和体积。OSS 图片缩放、OSS 格式转换

第二步:选格式和质量,不要只追求最小文件

照片、UI 截图、带透明区域的素材、文字密集的手绘图,不应共用一个盲目的质量参数。判断时要同时看字节数和可读性,尤其检查汉字笔画、细线和透明边缘。

WebP 可以作为比较候选;AVIF 也需要检查当前 OSS 能力、地域和兼容要求,不能直接写成所有桶都默认支持。动图还要单独验证,不要在转换中把动画语义丢掉。OSS 格式转换

quality,q_80 不表示“文件压成原来的 80%”。不同格式的质量参数含义有区别,PNG 单独调 quality 也不能这样实现压缩,应按官方支持范围组合处理并实测。OSS 质量变换

这篇文章的配图也一样:生成 PNG 是保留可编辑、可再次派生的高质量母版,不是要求移动端把全部母版照单全收。上传后,还要选择适合正文宽度的交付版本。

第三步:让正文图片真正接入优化链路

只给 CSS 写 max-width: 100%,可以让图片看起来不溢出,但不会让浏览器自动下载更小的文件。

对于这个博客,后续可以在受控的 Markdown 图片渲染中输出尺寸、响应式候选图和加载属性;也可以调整为 React 图片组件,接入合适的图片 loader。仅修改 next.config.ts,不会把已有 HTML 字符串里的 <img> 自动变成 next/image。

还需要预留宽高或比例,避免图片加载后把正文推开;非首屏图可以懒加载,关键首屏图不应统一延后。使用 Next 的远程图片优化时,应明确尺寸与 sizes,并把远程来源限制到实际需要的域名和路径。Next.js 图片指南

如果 OSS 已经完成尺寸和格式处理,可以让页面直接使用这些变体;如果选择由 Next 的图片优化服务处理,就评估相应的服务端成本。尽量让一种方案承担主要编码工作,不要无意识地把同一张有损图片反复转换。

旧 /uploads、第三方图片和动图需要兼容分支,不能简单对所有 URL 拼上 OSS 参数。图片改造也不能放松现有的内容安全边界。

第四步:CDN 负责分发,不负责替代前面的设计

CDN 可以理解为靠近读者的共享缓存节点。OSS 接入 CDN 仍需要加速域名、源站、HTTPS 和缓存配置;项目有自定义公开图片域名的变量,并不能证明这些步骤已经完成。OSS 接入 CDN

图片变体如果通过查询参数区分,缓存规则也必须区分不同尺寸和格式,并把必要参数传给源站。否则可能出现“请求小图却命中大图”或不同变体互相串用的问题。

修改公开域名配置也只会影响之后生成的地址,已经写进旧文章的绝对 URL 不会自动改写。域名迁移需要兼容或独立迁移方案,不应趁性能优化顺手替换整库内容。

上传直传可以讨论,但要看真正的瓶颈

如果上传量增加,应用服务器中转的带宽和内存确实成为瓶颈,可以考虑由服务端鉴权后签发短期授权,让浏览器直接上传到 OSS。这样减少的是上传链路中转,不是读者打开文章时的原图体积。

直传不能把长期密钥放到浏览器里。签发接口仍需管理员权限,授权要限定对象或前缀、有效期和必要操作;CORS 也不是鉴权的替代品。OSS 浏览器临时授权与直传

更关键的是,直传绕过了当前应用服务器接收文件的校验路径。后续应有私有暂存、服务端检查和确认发布的机制,不能只信客户端传来的 MIME 或“上传成功”。对当前低频、单图有上限的博客,我会先优化读图链路,再用证据决定是否增加直传复杂度。

十、缓存有好几层,“加了缓存”不是一个完整描述

同样叫缓存,存的东西、共享范围和失效方式可能完全不同。评估收益前,应先回答:这份结果保存在哪里,谁可以复用,什么操作会让它不再正确?

层次 缓存内容与目的 需要守住的边界
React 请求内复用 同一次服务端渲染重复读取的结果 不等于后续请求继续命中
应用数据缓存 公共摘要等可跨请求复用的结果 写入后失效,不能混入用户私有状态
HTTP 浏览器缓存 当前浏览器已下载的响应 新鲜度、校验和 URL 版本
CDN 等共享缓存 多个读者可复用的公开响应 用户隔离、缓存键、变体和刷新策略

React cache() 的请求内复用边界见 React 官方说明。

Next.js 的缓存接口则应按项目版本和是否启用 Cache Components 选择,不能把不同版本的示例直接拼在一起。线上文档会随版本更新,不是本项目 16.2.6 的版本锁。Next.js 缓存指南

no-cache 和 no-store 不是同义词

这里讨论的是 HTTP Cache-Control 响应指令,不是 Next.js 数据缓存或 fetch 的同名选项。

no-cache 允许保存响应,但复用前需要重新验证;未变化时可以得到 304,减少正文传输,仍有网络往返。no-store 要求不要保存这次响应,但不负责主动清除以前的副本。private 则禁止共享缓存复用。MDN:HTTP 缓存

因此,不该为了“一律最新”就给所有资源写 no-store,也不该为了“尽可能命中”就给登录态接口设置公开长缓存。是否值得缓存,首先是数据语义问题。

长缓存,需要稳定的内容版本约定

带内容哈希的静态资源,或者本项目这种新图使用新 UUID、禁止覆盖的对象,适合考虑长缓存。内容更新时换地址,旧地址仍对应旧内容,浏览器才可以放心复用。

反过来,同一个 cover.png 地址频繁覆盖,却声明一年内不变,就会让缓存和更新需求发生冲突。长缓存的关键不是数字够大,而是“同一 URL 的内容是否真的保持不变”。MDN:缓存版本与 immutable

写成功以后,更新的不止数据库

文章变更可能同时影响详情、归档摘要、首页、分类计数和搜索结果。前面补充分类操作的摘要失效,就是在处理这类依赖。

应用数据标签失效,不等于自动清掉所有 CDN 节点和读者浏览器里的副本;如果后续叠加新的缓存层,就要补上对应的更新策略。否则某一层很快命中了旧结果,整体体验仍然是错的。

另外,构建缓存、开发热更新缓存、React 缓存、生产 HTTP 缓存,也不能用同一张“命中了”的截图证明。记录时应写明层次和观察到的证据。

十一、下载完成以后,浏览器还有很多工作要做

网络面板里资源已经到齐,页面却仍然点不动,这时要把注意力从“下载多少”转向“主线程正在忙什么”。浏览器主线程承担大量 JavaScript、样式和界面更新工作,不能同时无限处理它们。

长任务:问题可能是一次占用太久

超过 50ms 的任务通常被归为长任务。这不是说小于 50ms 就绝对流畅,而是帮助定位可能阻塞交互的工作。把一个大函数拆成五个小函数、仍在同一次同步调用里连续执行,并没有真正给浏览器让出处理机会。web.dev:优化长任务

对于大批量计算,先减少输入和重复执行,再考虑分段调度。对于确实适合离开主线程的重计算,可以评估 Web Worker;它不能直接操作页面 DOM,通信和数据复制也有成本,所以不值得为了一个很小的过滤函数专门增加线程。MDN:使用 Web Workers

这里也要分清几种措施:防抖减少连续输入的执行次数;记忆化复用相同输入的结果;虚拟列表减少同时挂载的节点;分页减少每次读取和传输的数据。它们作用在不同阶段,不能互相替代。

本项目已有虚拟列表,不应重复算成本轮新增成果。即使只渲染了可见卡片,如果仍一次下载全部正文,也没有解决数据传输问题;而有了分页,当前页的昂贵计算仍可能需要优化。

字体、CSS 和第三方脚本,也会参与竞争

字体值得检查实际使用的字族和字重,尤其不要为了少量装饰文字让整页承担不必要的下载。Next.js 的 next/font 支持字体自托管和加载优化,但这并不意味着任意字体文件都自动很小;字体范围和回退布局仍需要验证。Next.js 字体指南

样式也要追踪来源和使用范围。编辑器专用样式是否在读文章时就进入页面?大面积滤镜、阴影和动画有没有形成绘制负担?应在实际页面观察,而不是看到一条 CSS 属性就宣布它一定很慢。本轮没有完成全站 CSS 成本审计。

统计、聊天、埋点等第三方脚本,即便不在自己的源码里,也会占用下载和执行预算。后续接入时可以按需要选择 afterInteractive 或 lazyOnload 等策略,并验证业务事件是否仍能正确采集。延后加载并不消除脚本成本,也不能代替隐私与授权判断。Next.js 脚本指南

十二、测试通过,不等于性能已经量化

性能优化闭环手绘图:测量基线、定位瓶颈、小步修改、同条件复测、记录与回归

回归测试,先确认没有把正确行为优化掉

这轮修改最需要警惕的,不是少了一个 useMemo,而是为了减少工作,把正确行为也一起减掉了。

所以回归测试覆盖了这些边界:

改动 重点防止的回归
归档摘要与缓存 正文重新混入列表、阅读时间口径改变、分类修改后摘要没失效
随记按日读取 同日多条丢失、历史空白日错误回退、快速切日与跨月不同步
搜索异步加载 请求重复、旧结果生效、面板被重新打开、卸载后继续更新
索引复用 数据更新后索引没更新、筛选和相关性排序改变
动画暂停 高刷额外绘制、后台继续运行、迟到回调重启动画
连接池复用 模块重载重复创建对象、生产分支被意外改变

2026 年 9 月 8 日,这个性能任务结束前的最后一轮执行是 37 个测试文件、202 项通过,TypeScript 和优化模块的定向 ESLint 也通过了。

不过,这不代表项目所有检查都已变绿。当时全量 ESLint 仍有 11 个既有错误和 4 个警告;仅编译检查还被 OSS SDK 的可选依赖打包问题阻断,后续由独立构建修复处理。

后来的综合提交还记录过另一次上传权限测试超时,单独复测通过。不同运行的结果需要分别保留,不能拿一次成功覆盖另一次失败;本文成稿时也没有重跑整套验证。

更重要的是,这些测试回答的是“调整后是否仍按约定工作”,不是“用户究竟快了多少”。生产环境里还需要看响应体和 JavaScript 下载量、缓存冷热请求、数据库耗时,以及低端设备上的交互和动画开销。

性能指标,回答用户实际经历了什么

Core Web Vitals 关注加载、交互和视觉稳定性。可以先用三个问题理解它们:主要内容什么时候出现?操作后多久看到反馈?阅读时布局会不会突然跳动?

指标 关注点 官方良好阈值,不是本项目实测结果
LCP 视口内最大内容元素的呈现时间 不超过 2.5 秒
INP 交互到下一次绘制的响应体验 不超过 200 毫秒
CLS 非预期布局偏移 不超过 0.1

线上评价需要按移动端、桌面端等分组看第 75 百分位,不能用一次开发机测试的最好成绩替代。普通 Lighthouse 页面加载测试没有完整的用户交互,TBT 也不能直接当成线上 INP。web.dev:Web Vitals 与测量方式

这些指标是结果,定位原因还要回到具体链路:响应首字节是否慢,主图是否发现太晚,下载体积是否过大,脚本是否占用主线程,图片是否没有预留空间。

对于交互,我还会保留业务口径:首次打开编辑器多久能输入,搜索多久展示有效结果,切换日期多久出现正确内容。一个页面得分正常,不代表每条业务路径都没有等待。

用可复现的条件比较,而不是凭刷新后的感觉

我会选首页、归档、文章详情和后台编辑四条代表路径,在相同数据集和受控测试环境下比较。生产模式验证前先确认环境,不能为了生成报告而误连生产数据库或触发发布。

需要固定或记录设备、浏览器、视口、网络与 CPU 限速、登录身份和操作顺序。冷缓存、热缓存分开跑多次,保留原始记录,避免只展示最好的一次。

Network 用来确认真正传了哪些资源、传输体积与解压后大小分别是多少;Performance 用来找长任务、布局和绘制;服务端日志与查询计划帮助定位数据库和渲染耗时。三者对应不同阶段,不必让一个工具回答所有问题。

特别注意:浏览器勾选 Disable cache,不等于同时清空了 Next 数据缓存、CDN 和数据库缓存。测试记录里要说明“冷的是哪一层”,不要为测冷缓存去清理生产共享缓存。

下面是后续可填写的记录模板;不是已经获得的测量结果:

页面与操作:首页首次访问 / 归档筛选 / 文章阅读 / 打开编辑器
版本与数据:改动前提交、改动后提交、同一测试数据集
环境:设备、浏览器、视口、网络、CPU、登录身份
缓存:浏览器 / 应用数据 / CDN,各层状态分别记录
样本:重复次数、原始报告位置、采用的统计口径
结果:资源字节、关键请求、LCP / CLS、交互记录、长任务
正确性:权限、内容更新、空状态、失败重试、旧链接
结论:已证实的收益、代价、仍不能确定的部分

十三、如果继续优化,我会怎么排顺序

并不是文章提到了的方案,都应该马上接进这个博客。我的下一步会按下面的顺序验证,而不是同时改构建、图片、缓存和数据库,再猜究竟是哪一项生效。

顺序 候选工作 完成标志
1 建立代表页面的基线 同条件、可重复,区分资源体积与执行耗时
2 正文图片尺寸与格式交付 实际请求命中合适变体,文字清晰,旧图兼容,布局稳定
3 分析客户端包和导入链 确认哪些模块在何时下载,再决定删减或延后
4 验证 HTTP 压缩与缓存 以真实响应头、资源体积和更新行为证明生效
5 按证据优化数据与计算 再选择阅读时间持久化、分页、索引或 Worker

CDN 和上传直传是可选的独立工作,要有流量、地域、上传量或服务器负担作为依据。没有这些数据时,给个人博客加复杂基础设施,可能只是把维护成本换成另一种形式。

每一项都应同时记录收益和代价:首屏变轻,首次打开编辑器是否更慢?图片变小,手绘文字是否还能看清?缓存命中更多,后台发布后是否仍能及时更新?

写在最后

回头看,性能优化不是收集一串 API 名称,而是不断判断:这个工作有没有必要,由谁做,在什么时候做,做完能不能复用。

归档和目录少传了正文;首页随记按使用范围读取;正文不再等待无关辅助内容;编辑器和日历晚一点加载;搜索索引留着复用;动画在后台停下来。

再往外看,Tree shaking 减少无用代码,Minify 缩短保留代码的表达,分包配合加载策略决定何时交付,gzip / Brotli 减少传输字节;图片处理控制尺寸和画质,OSS 负责存储,CDN 与浏览器缓存减少重复搬运。不同手段解决不同阶段的问题,不能只看名字都带一个“优化”。

以后再审一个页面,我会先沿着数据和资源走一遍:它为什么被查出来,为什么被传到客户端,为什么现在就要加载和计算,为什么页面已经看不见了还在运行。

先减少不必要的工作,再优化必要工作的交付与执行,最后用真实测量确认收益。这条思路不只适用于博客,也适用于后台、内容站、电商页面和复杂交互应用。


评论

On this page

  • 从一个博客出发,聊透 Web 性能优化:按需加载、代码压缩、图片与缓存
  • 一、归档只展示摘要,就不要把正文也带过去
  • 先看浏览器到底收到了什么
  • 再处理重复计算
  • 缓存必须有退出机制
  • 二、首页随记:日历需要日期,不需要整座历史档案
  • 三、正文先到,点赞和前后篇不用一起等
  • 同一篇文章,也没必要反复查询
  • 目录是另一份摘要
  • 四、重型组件可以晚到,搜索索引可以留下
  • 动态导入,只解决了一半问题
  • 把搜索库加载和请求处理一起检查
  • 归档搜索不必每次重新造索引
  • 五、动画保留,但没有观众时可以休息
  • 先区分调度频率和绘制频率
  • 比降帧更直接的,是该停就停
  • 六、图片和组件边界,也一起收拾一下
  • 七、把按需加载想清楚:不是越晚越好,而是让重要的先到
  • 先分三类,再决定用什么 API
  • ssr: false 不是性能加速按钮
  • 预加载和懒加载,不是互相矛盾
  • 八、代码压缩、删除无用代码和分包,解决的是不同问题
  • Tree shaking:不需要的东西,就不带走
  • Minify:同样的逻辑,使用更紧凑的表达
  • Code splitting:让不同旅程,不必背同一个包
  • gzip / Brotli:压缩的是传输包裹
  • 九、图片上传 OSS,只完成了图片性能链路的一部分
  • 先说清楚这个博客现在做到了哪里
  • 第一步:下载接近展示需求的尺寸
  • 第二步:选格式和质量,不要只追求最小文件
  • 第三步:让正文图片真正接入优化链路
  • 第四步:CDN 负责分发,不负责替代前面的设计
  • 上传直传可以讨论,但要看真正的瓶颈
  • 十、缓存有好几层,“加了缓存”不是一个完整描述
  • no-cache 和 no-store 不是同义词
  • 长缓存,需要稳定的内容版本约定
  • 写成功以后,更新的不止数据库
  • 十一、下载完成以后,浏览器还有很多工作要做
  • 长任务:问题可能是一次占用太久
  • 字体、CSS 和第三方脚本,也会参与竞争
  • 十二、测试通过,不等于性能已经量化
  • 回归测试,先确认没有把正确行为优化掉
  • 性能指标,回答用户实际经历了什么
  • 用可复现的条件比较,而不是凭刷新后的感觉
  • 十三、如果继续优化,我会怎么排顺序
  • 写在最后
← 个人博客上线Mysql →