个人qwik项目优化
前言
最近把自己的 qwik-blog-serve 做了一轮比较彻底的性能与稳定性优化,核心目标不是“再堆几个小技巧”,而是把内容系统、文章详情链路和埋点链路重新梳顺,让它们更符合一句话:能在构建阶段做完的事,就不要留到运行时反复做;能在服务端算好的数据,就不要再把原始材料丢给客户端自己算。
这一轮改造主要解决了 5 个问题:
- 文章索引依然依赖运行时扫描 Markdown,重复做了构建阶段本可以完成的工作。
- 文章详情页把原始 Markdown 一起带给前端,传输了很多页面并不需要的数据。
- 阅读量、点赞态、访客标识初始化分散在多个链路里,页面首屏时容易出现重复请求。
- 访问埋点仍然走普通请求链路,在页面关闭、路由切换等场景下不够稳。
- 动态路由和分享链接对特殊字符过于脆弱,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
新的策略是:
- 优先读取
tmp/content/posts-manifest.json; - 开发环境下如果产物不存在,再回退到文件系统扫描;
- 开发环境支持根据产物更新时间自动刷新,保证调试体验。
这其实就是典型的 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.tsxsrc/server/posts.tssrc/server/markdown.server.ts
三、阅读统计改为服务端直出元数据
之前一些阅读统计逻辑会在客户端二次计算,比如基于正文内容重新推导字数、阅读时长。
这次把 ReadingStats 调整成纯展示组件:
src/components/common/ReadingStats.tsx
现在它只接收:
wordCountreadingTime
这样做之后:
- 客户端不再重复扫描正文;
- 页面逻辑更简单;
- 文章详情页的数据职责更清晰。
四、合并文章互动初始化接口
这次把“获取阅读量 + 获取点赞状态 + 记录一次阅读”整合成更统一的初始化路径。
主要变更:
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
- 重写为
新的思路是:
- 页面初始化时拿一次访客 ID;
- 通过一个请求拿到互动状态;
- 同时按需记录阅读;
- 用共享上下文把阅读量、点赞数、点赞态提供给页面内部组件。
这样可以减少重复请求,也让页面状态来源更集中。
五、统一 visitor 获取与缓存
访客标识相关逻辑这次被统一收口到:
src/client/visitor.ts
实现上用了三层缓存:
- 内存中的全局值缓存;
- 全局 Promise 缓存,避免并发重复计算;
localStorage持久化缓存。
原先的旧入口:
src/utils/fingerprint.ts
现在也只是转调新的 visitor 模块,保证历史调用方式还能兼容。
这样做的结果是:
- 页面首次计算后,后续模块可以直接复用;
- 文章互动、访问埋点走同一个访客来源;
- 避免了多个模块各算各的情况。
六、埋点链路改成 sendBeacon 优先
访问埋点这次改到了更适合“页面即将离开”的发送方式:
src/apis/analytics/access.tssrc/client/monitor.tssrc/routes/layout.tsx
具体策略是:
- 优先使用
navigator.sendBeacon(); - 如果环境不支持,再退回
fetch(..., { keepalive: true }); - 布局层在空闲时预热 visitor ID,并复用到埋点链路里。
这样做的收益主要是稳定性:
- 用户快速切页时更容易成功上报;
- 页面卸载阶段不容易丢请求;
- 埋点逻辑与普通业务请求解耦。
七、补了一轮稳定性修复
这部分不是最初的主目标,但在真正跑构建时非常关键。
1. 动态路由统一用安全解码
项目里原本已经有:
decodeMaybeEncodedPath()
于是把文章详情、分类、标签、OG 图等动态路由都切到了这套安全解码逻辑,避免重复 decodeURIComponent 导致的 SSG 问题。
相关文件包括:
src/routes/posts/detail/[...slug]/index.tsxsrc/routes/posts/[category]/[page]/index.tsxsrc/routes/tags/[tag]/[page]/index.tsxsrc/routes/og/[...slug]/index.tssrc/server/blog-api.server.ts
2. 静态参数返回原始值,不再手工提前编码
文章详情、分类页、标签页在 onStaticGenerate 阶段都改成返回原始参数值,让 Qwik City 只做一次官方编码。
这个调整很小,但能明显降低“手工编码一次 + 框架再处理一次”的歧义空间。
3. 分享链接增加安全编码兜底
在 SSG 过程中,分享组件里对标题/摘要做 encodeURIComponent() 时,命中过一次脏 Unicode,直接报了:
URI malformed
于是补了:
src/utils/url.tstoWellFormedUnicode()safeEncodeURIComponent()
然后把分享链接和文章详情路径构造统一接到安全编码逻辑上:
src/components/posts/ArticleShareSection.tsxsrc/utils/posts.ts
这一步的意义不是“多写个工具函数”,而是:把线上内容的不确定性收敛到统一的边界层处理。
4. canonical 去重
文章页本身已经会输出 canonical,公共头部再输出一次就是重复了。
因此又顺手处理了:
src/components/router-head/RouterHead.tsx
让它在页面已经声明 canonical 的情况下不再重复注入。
这轮改造之后,链路变成了什么样?
可以把新的文章链路概括成下面这条路径:
content/中的 Markdown 仍然是内容源;- 构建阶段生成
tmp/content/posts-manifest.json; src/server/posts.ts在运行时优先读取 manifest;- 文章详情页 loader 只拿正文并渲染成 HTML;
- 页面接收的是
PostMeta + HTML + TOC + 互动状态; - visitor ID 在客户端统一缓存;
- 埋点优先走
sendBeacon。
如果用一句话总结,就是:
以前是“请求来了再现做”,现在是“构建时先备好,运行时尽量直取”。
实际验证结果
这一轮修改后,我做了两项直接验证:
1. 类型检查通过
执行:
pnpm typecheck
结果:通过。
2. 生产构建通过
执行:
pnpm build
结果:通过。
构建日志里有几条和本次改造直接相关的信息:
- 已生成文章构建索引:
1191篇 - 索引产物路径:
tmp/content/posts-manifest.json - SSG 结果:
1542 pages - SSG 总耗时:
11.0 s - 平均每页生成耗时:
7.1 ms
这至少说明两件事:
- manifest-first 的内容链路已经真正接进构建流程;
- 路由、详情页、分享链路相关的构建阻断问题已经被修复。
还有哪些后续优化可以继续做?
这轮改造已经把主链路理顺了,但还有两类事情值得继续推进。
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 负责做运行期索引;
- 服务端负责做正文渲染与元数据计算;
- 客户端负责消费结果,而不是重复加工原材料;
- 埋点和访客标识走统一链路;
- 路由与分享对异常编码有了更强的容错能力。
如果以后再做博客、知识库、文档站,类似的经验我会优先复用:
很多时候,性能优化的本质并不是“把代码写得更花”,而是把不该在运行时做的工作,提前挪走;把不该在客户端承担的计算,还给服务端。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录