首页/文章/前端性能

个人qwik项目优化

2026-05-16
399814 分钟
...

前言

最近把自己的 qwik-blog-serve 做了一轮比较彻底的性能与稳定性优化,核心目标不是“再堆几个小技巧”,而是把内容系统、文章详情链路和埋点链路重新梳顺,让它们更符合一句话:能在构建阶段做完的事,就不要留到运行时反复做;能在服务端算好的数据,就不要再把原始材料丢给客户端自己算。

这一轮改造主要解决了 5 个问题:

  1. 文章索引依然依赖运行时扫描 Markdown,重复做了构建阶段本可以完成的工作。
  2. 文章详情页把原始 Markdown 一起带给前端,传输了很多页面并不需要的数据。
  3. 阅读量、点赞态、访客标识初始化分散在多个链路里,页面首屏时容易出现重复请求。
  4. 访问埋点仍然走普通请求链路,在页面关闭、路由切换等场景下不够稳。
  5. 动态路由和分享链接对特殊字符过于脆弱,SSG 时容易被异常编码数据“绊一跤”。

下面把这次改造的思路和落地细节完整记下来。

为什么原来的实现不够理想?

1. 内容索引重复劳动

博客的 Markdown 内容本来就在 content/ 目录里,构建时也已经有同步文章数据的过程。

但运行时如果还要再次递归扫描目录、解析 frontmatter、整理分类/标签/时间线,就会有两个明显问题:

  • 服务端每次启动或首次访问都要重复做 IO 和解析;
  • 同一份内容在“构建阶段”和“运行阶段”被做了两遍。

这类问题最典型的优化方式就是:构建一次,运行期直接读产物。

2. 文章详情页传了太多东西

详情页真正需要的其实是:

  • 文章元信息(标题、日期、标签、分类、阅读统计等);
  • 已经渲染好的 HTML;
  • 目录 TOC;
  • 上下篇与相关文章。

而不是把原始 Markdown 正文也一并传给客户端。

原始 Markdown 留在服务端更合理,因为:

  • 客户端并不需要再重新解析它;
  • 阅读时长和字数可以直接由服务端提供;
  • 页面负载更轻,序列化/传输更省。

3. 互动初始化链路过散

原先文章详情页在初始化时,阅读量、点赞态、访客 ID 这些数据并没有形成一个统一入口。

这会带来几个实际问题:

  • 同一个页面加载过程中容易产生重复请求;
  • 同一个访客标识可能被多处重复计算;
  • 页面状态更新点分散,不利于后续维护。

4. 埋点方式不够适合“离开页面”场景

访问埋点如果仍然使用普通 fetch 请求,在浏览器关闭标签页、刷新页面、快速路由切换时,最常见的问题就是:请求还没发完,页面先走了。

这类场景更适合 navigator.sendBeacon(),实在不行再退回 fetch(..., { keepalive: true })

5. 编码问题会在 SSG 阶段集中爆发

静态生成和普通运行时不一样,SSG 会批量遍历大量路径与内容。

只要某个 slug、标签、摘要文本里混入边界字符、脏 Unicode 或重复编码/解码问题,就可能把整个构建打断。事实证明,这一类问题平时未必显眼,但在大规模 SSG 时非常致命。

这次改了什么?

一、把内容索引改成构建产物

这次新增了一个构建阶段的内容索引脚本:

  • scripts/content-index.mjs

它会在构建时扫描 Markdown,整理出文章索引数据,并写入:

  • tmp/content/posts-manifest.json

同时把原先同步文章到数据库的脚本也调整成先生成索引产物,再基于同一份数据同步数据库:

  • scripts/sync-articles.mjs

这样做的好处很明确:

  • 内容目录只在构建时被完整扫描一次;
  • 运行时直接读 JSON 产物,避免重复解析 frontmatter;
  • 分类、标签、时间线、搜索数据都统一来自同一份清单;
  • 数据源更统一,减少“这里一份、那里一份”的分叉状态。

对应的运行时读取层也做了重写:

  • src/server/posts.ts

新的策略是:

  1. 优先读取 tmp/content/posts-manifest.json
  2. 开发环境下如果产物不存在,再回退到文件系统扫描;
  3. 开发环境支持根据产物更新时间自动刷新,保证调试体验。

这其实就是典型的 manifest-first 思路。

二、文章详情页不再下发原始 Markdown

这次专门把文章类型拆成了“元信息”和“正文”的边界:

  • src/types/index.ts
    • 新增 PostMeta
    • Post extends PostMeta { content?: string }

随后详情页 loader 调整为:

  • 服务端读取文章正文;
  • 服务端把 Markdown 渲染成 HTML;
  • 返回给页面的是 post: PostMeta + contentHtml + toc

也就是说,原始 Markdown 仍然是内容源,但不再跟着详情页一起下发给客户端。

这里要特别说明一下,“去除原始 Markdown”并不是删除 content/ 目录的源文件,而是:

  • 保留 Markdown 作为内容事实来源;
  • 但在页面加载链路里,不再把它作为前端必须接收的数据。

这一步对应的主要改动文件有:

  • src/routes/posts/detail/[...slug]/index.tsx
  • src/server/posts.ts
  • src/server/markdown.server.ts

三、阅读统计改为服务端直出元数据

之前一些阅读统计逻辑会在客户端二次计算,比如基于正文内容重新推导字数、阅读时长。

这次把 ReadingStats 调整成纯展示组件:

  • src/components/common/ReadingStats.tsx

现在它只接收:

  • wordCount
  • readingTime

这样做之后:

  • 客户端不再重复扫描正文;
  • 页面逻辑更简单;
  • 文章详情页的数据职责更清晰。

四、合并文章互动初始化接口

这次把“获取阅读量 + 获取点赞状态 + 记录一次阅读”整合成更统一的初始化路径。

主要变更:

  • src/apis/content/article.ts
    • 新增 getArticleEngagement(slug, clientId)
  • src/routes/article/[...path]/index.ts
    • 支持 track_view=1
  • src/server/blog-api.server.ts
    • 只有在 trackView 为真时才累加阅读量
  • src/components/posts/ArticleStats.tsx
    • 重写为 ArticleEngagementProvider + ArticleViewCount + ArticleLikeButton

新的思路是:

  1. 页面初始化时拿一次访客 ID;
  2. 通过一个请求拿到互动状态;
  3. 同时按需记录阅读;
  4. 用共享上下文把阅读量、点赞数、点赞态提供给页面内部组件。

这样可以减少重复请求,也让页面状态来源更集中。

五、统一 visitor 获取与缓存

访客标识相关逻辑这次被统一收口到:

  • src/client/visitor.ts

实现上用了三层缓存:

  1. 内存中的全局值缓存;
  2. 全局 Promise 缓存,避免并发重复计算;
  3. localStorage 持久化缓存。

原先的旧入口:

  • src/utils/fingerprint.ts

现在也只是转调新的 visitor 模块,保证历史调用方式还能兼容。

这样做的结果是:

  • 页面首次计算后,后续模块可以直接复用;
  • 文章互动、访问埋点走同一个访客来源;
  • 避免了多个模块各算各的情况。

六、埋点链路改成 sendBeacon 优先

访问埋点这次改到了更适合“页面即将离开”的发送方式:

  • src/apis/analytics/access.ts
  • src/client/monitor.ts
  • src/routes/layout.tsx

具体策略是:

  1. 优先使用 navigator.sendBeacon()
  2. 如果环境不支持,再退回 fetch(..., { keepalive: true })
  3. 布局层在空闲时预热 visitor ID,并复用到埋点链路里。

这样做的收益主要是稳定性:

  • 用户快速切页时更容易成功上报;
  • 页面卸载阶段不容易丢请求;
  • 埋点逻辑与普通业务请求解耦。

七、补了一轮稳定性修复

这部分不是最初的主目标,但在真正跑构建时非常关键。

1. 动态路由统一用安全解码

项目里原本已经有:

  • decodeMaybeEncodedPath()

于是把文章详情、分类、标签、OG 图等动态路由都切到了这套安全解码逻辑,避免重复 decodeURIComponent 导致的 SSG 问题。

相关文件包括:

  • src/routes/posts/detail/[...slug]/index.tsx
  • src/routes/posts/[category]/[page]/index.tsx
  • src/routes/tags/[tag]/[page]/index.tsx
  • src/routes/og/[...slug]/index.ts
  • src/server/blog-api.server.ts

2. 静态参数返回原始值,不再手工提前编码

文章详情、分类页、标签页在 onStaticGenerate 阶段都改成返回原始参数值,让 Qwik City 只做一次官方编码。

这个调整很小,但能明显降低“手工编码一次 + 框架再处理一次”的歧义空间。

3. 分享链接增加安全编码兜底

在 SSG 过程中,分享组件里对标题/摘要做 encodeURIComponent() 时,命中过一次脏 Unicode,直接报了:

  • URI malformed

于是补了:

  • src/utils/url.ts
    • toWellFormedUnicode()
    • safeEncodeURIComponent()

然后把分享链接和文章详情路径构造统一接到安全编码逻辑上:

  • src/components/posts/ArticleShareSection.tsx
  • src/utils/posts.ts

这一步的意义不是“多写个工具函数”,而是:把线上内容的不确定性收敛到统一的边界层处理。

4. canonical 去重

文章页本身已经会输出 canonical,公共头部再输出一次就是重复了。

因此又顺手处理了:

  • src/components/router-head/RouterHead.tsx

让它在页面已经声明 canonical 的情况下不再重复注入。

这轮改造之后,链路变成了什么样?

可以把新的文章链路概括成下面这条路径:

  1. content/ 中的 Markdown 仍然是内容源;
  2. 构建阶段生成 tmp/content/posts-manifest.json
  3. src/server/posts.ts 在运行时优先读取 manifest;
  4. 文章详情页 loader 只拿正文并渲染成 HTML;
  5. 页面接收的是 PostMeta + HTML + TOC + 互动状态
  6. visitor ID 在客户端统一缓存;
  7. 埋点优先走 sendBeacon

如果用一句话总结,就是:

以前是“请求来了再现做”,现在是“构建时先备好,运行时尽量直取”。

实际验证结果

这一轮修改后,我做了两项直接验证:

1. 类型检查通过

执行:

  • pnpm typecheck

结果:通过。

2. 生产构建通过

执行:

  • pnpm build

结果:通过。

构建日志里有几条和本次改造直接相关的信息:

  • 已生成文章构建索引:1191
  • 索引产物路径:tmp/content/posts-manifest.json
  • SSG 结果:1542 pages
  • SSG 总耗时:11.0 s
  • 平均每页生成耗时:7.1 ms

这至少说明两件事:

  1. manifest-first 的内容链路已经真正接进构建流程;
  2. 路由、详情页、分享链路相关的构建阻断问题已经被修复。

还有哪些后续优化可以继续做?

这轮改造已经把主链路理顺了,但还有两类事情值得继续推进。

1. 继续拆大 chunk

当前构建日志里仍然有提示:

  • Some chunks are larger than 1000 kB after minification

这说明虽然内容链路更干净了,但客户端仍然存在较大的打包块。后面可以继续从下面几个方向切:

  • 更细粒度地拆分工具页或低频组件;
  • 检查是否有重量级依赖进入主包;
  • 把明显偏管理后台/重交互的逻辑再往懒加载推。

2. 清理未在构建期解析的静态资源

当前构建日志里还保留了几条运行时解析资源的提示,例如:

  • /font/zhuzie-3600.woff2
  • /font/jetbrains-regular.woff2
  • /imgs/light.webp
  • /imgs/dark.webp

它们目前不会阻断构建,但说明资源引用方式还可以进一步规范化。

3. 如果还要继续提速,可以考虑预渲染更多内容产物

比如后续还可以继续思考:

  • 搜索索引是否还能再拆分得更细;
  • 某些 Markdown 渲染结果是否值得做更激进的构建期缓存;
  • 相关文章、时间线等聚合结果是否需要进一步静态化。

总结

这次优化对我来说,最重要的不只是“快了一点”,而是把系统的职责边界重新梳清楚了:

  • Markdown 负责做内容源;
  • manifest 负责做运行期索引;
  • 服务端负责做正文渲染与元数据计算;
  • 客户端负责消费结果,而不是重复加工原材料;
  • 埋点和访客标识走统一链路;
  • 路由与分享对异常编码有了更强的容错能力。

如果以后再做博客、知识库、文档站,类似的经验我会优先复用:

很多时候,性能优化的本质并不是“把代码写得更花”,而是把不该在运行时做的工作,提前挪走;把不该在客户端承担的计算,还给服务端。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
前端性能2025-08-07
JS篇之性能优化
很多时候面试官,面试你的时候会问你:同学你在项目中做过哪些性能优化呢? 可能你是这样回答的: 1. 无用代码的删除,减小代码打的体积 2. 使用cdn加速 3. 图片压缩 4. 分包 5. 骨架屏 ........ 其实性能优化是个很宽泛的东西,可以从多方面进行入手 性能优化不是视觉体验打分,网上部分评判如下 | 核心指标 | 定义 | 优化方向 | | | | | | LCP(最大内容绘制) | 首屏最大内容加载完成时间 | 图片压缩...
学习面试
前端性能2025-08-03
前端PerformanceObserver
是 Web Performance API 的一部分,它提供了一种异步、高效地收集浏览器性能指标的方式。与传统的 或 方法不同, 允许你订阅特定类型的性能事件,并在这些事件发生时获得通知,而不是在某个时间点一次性获取所有已发生的事件。这对于实时监控和报告性能数据非常有用,尤其是在单页应用 (SPA) 中,因为页面加载后的用户交互也会产生新的性能事件。 的作用 主要用于: 1. 监控页面加载性能: 获取导航时间、资源加载时间等。 2. 跟...
学习
前端性能2025-07-27
前端常见的性能指标采集
前言 作为前端,我们的任务便是给用户一个好的产品体验感,不管是从首次进入页面的加载时长,还是对于交互时页面的响应流畅度,都是我们应该关注的点。 RAIL模型是由谷歌提出的,一种以用户为中心的性能模型,RAIL分别代表Web应用生命周期的四个方面:响应、动画、空闲、加载。 一般不管是页面打开还是流程交互,又或者是网络反馈,这也操作我们应该尽量在1000ms内完成,用户的体验感才不会差,用户留存率也将得到提升。 下面看一下我们常见的一些性能...
学习面试
前端性能2025-01-08
前端性能报告
数据说明 本报告的大部分内容均基于 Chrome 用户体验报告 ( ) 中的真实用户数据。 Chrome 用户体验报告(CrUX)反映了真实世界中 用户在热门网站上的体验,是 项目的官方数据集,展示了所有用户中心的核心 指标。 数据基于全球真实浏览器用户收集,并通过多种 和第三方工具公开提供,用于 搜索的页面体验排名因素。并非所有原点或页面都在数据集中,需公开可发现且访客数量足够多以达统计显著性。 核心网页指标(Core Web Vit...
学习
前端性能2024-05-28
Performance的使用攻略
Performance是Chrome浏览器自带的性能监测工具。根据我的使用,简单理解就是我们可以通过它录制一段时间的浏览器活动,通过活动的数据去分析页面是否存在提升的空间。想要获取页面的活动数据,那我们的第一步便是录制浏览器的活动。 打开F12,进入Perfomance标签页,我们可以看到出现了提示。我们不想看这个文本在说什么,我们只想马上开始用(不是),由于我已经百度翻译过了(不是,看过了),所以文本提示是在说箭头所指的地方,实心圆代...
学习
前端性能2024-05-13
唠唠 Chrome DevTools 中最新的实用技巧
以下的内容基于 Chrome 浏览器 122.0.6261.112 版本撰写! 随着 Chrome 浏览器版本的不断迭代,Chrome DevTools 引入了不少实用的功能,其中有些功能对日常工作效率提升是十分明显的,在此,我本来想具体写写自己的使用体验和理解。 不过,我所使用过的功能,结合自己看过的文章资讯,基本上能找到更详实、更完善的内容介绍,因此,这篇文章,我更多是基于使用到的技巧,然后罗列出对应功能的文章链接出处,帮助大家有更...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录