2025-10-20 21:52:59
Nuxt Content 是 Nuxt 生态中用于处理 Markdown、YAML 等内容的强大模块。最近,我在使用 Nuxt v4 + Nuxt Content v3 重构博客(原为 Hexo)时,遇到了一个棘手的问题:v3 版本的默认查询 API 并未直接提供对数组字段进行“包含”($contains)操作的支持。
例如,这是我的正在写的这篇博客的 Front Matter:
---
title: Nuxt Content v3 中数组字段的筛选困境
date: 2025-10-20 21:52:59
sticky:
tags:
- Nuxt
- Nuxt Content
- JavaScript
---
我的目标是创建一个 Tag 页面,列出所有包含特定 Tag(例如 'Nuxt')的文章。
在 Nuxt Content v2 中,数据基于文件系统存储,查询方式是对文件内容的抽象,模拟了类似 MongoDB 的 JSON 文档查询语法。我们可以轻松地使用 $contains 方法获取所有包含 “Nuxt” 标签的文章:
const tag = decodeURIComponent(route.params.tag as string)
const articles = await queryContent('posts')
.where({ tags: { $contains: tag } }) // ✅ v2 中的 MongoDB Style 查询
.find()
但在使用 Nuxt Content v3 的 queryCollection API 时,我们很自然地会尝试使用 .where() 方法进行筛选:
const tag = decodeURIComponent(route.params.tag as string)
const { data } = await useAsyncData(`tag-${tag}`, () =>
queryCollection('posts')
.where(tag, 'in', 'tags') // ❌ 这样会报错,因为第一次参数必须是字段名
.order('date', 'DESC')
.select('title', 'date', 'path', 'tags')
.all()
)
遗憾的是,这样是行不通的。.where() 的方法签名要求字段名必须作为首个参数传入:where(field: keyof Collection | string, operator: SqlOperator, value?: unknown)。
由于 Nuxt Content v3 底层采用 SQLite 作为本地数据库,所有查询都必须遵循类 SQL 语法。如果设计时未提供针对数组字段的内置操作符(例如 $contains 的 SQL 等价形式),最终的解决方案往往会显得比较“别扭”。
本着“尽快重构,后续优化”的思路,我写出了以下代码:
// 初版实现:全量拉取后使用 JS 筛选
const allPosts = (
await useAsyncData(`tag-${route.params.tag}`, () =>
queryCollection('posts')
.order('date', 'DESC')
.select('title', 'date', 'path', 'tags')
.all()
)
).data as Ref<Post[]>
const Posts = computed(() => {
return allPosts.value.filter(post =>
typeof post.tags?.map === 'function'
? post.tags?.includes(decodeURIComponent(route.params.tag as string))
: false
)
})
这种方法虽然满足了需求,但也带来了明显的性能代价:_payload.json 文件体积的膨胀。
在 Nuxt 项目中,_payload.json 用于存储 useAsyncData 的结果等动态数据。在全量拉取的方案下,每一个 Tag 页面 都会加载包含所有文章信息的 _payload.json,造成数据冗余。很多 Tag 页面仅需一两篇文章的数据,却被迫加载了全部文章信息,严重影响了性能。


为了减少 useAsyncData 返回的查询结果,我查阅了 Nuxt Content 的 GitHub Discussions,发现在 v3.alpha.8 版本时就有人提出了一种“巧妙”的解决方案。
由于 Nuxt Content v3 使用 SQLite 数据库,原本在 Front Matter 中定义的 tags 数组(通过 z.array() 定义)最终会以 JSON 字符串的形式存储在数据库中(具体格式可在 .nuxt/content/sql_dump.txt 文件中查看)。

这意味着我们可以利用 SQLite 的字符串操作特性,通过 LIKE 动词配合通配符来完成数组包含的筛选,本质上是查询 JSON 字符串是否包含特定子串:
const tag = decodeURIComponent(route.params.tag as string)
const { data } = await useAsyncData(`tag-${route.params.tag}`, () =>
queryCollection('posts')
.where('tags', 'LIKE', `%"${tag}"%`)
.order('date', 'DESC')
.select('title', 'date', 'path', 'tags')
.all()
)
下面是优化后重新生成的文件占用,体积减小还是非常显著的
通过这种方法,我们成功将查询逻辑下推到了数据库层,避免了不必要的全量数据传输,显著降低了单个目录中 _payload.json 的体积,实现了性能优化。


2025-10-16 15:38:50
2023 年 8 月,CA/Browser Forum 通过了一项投票——不再强制要求 Let’s Encrypt 等公开信任的 CA 设立 OCSP Server
2024 年 7 月,Let's Encrypt 发布博客,披露其计划关闭 OCSP Server
同年 12 月,Let's Encrypt 发布其关闭 OCSP Server 的时间计划表,大致情况如下:
Let's Encrypt 是全世界最大的免费 SSL 证书颁发机构,而这一举动标志着我们已逐渐步入后 OCSP 时代。
Let's Encrypt 这一举动的背后,是人们对 OCSP(在线证书状态协议)长久以来累积的不满。OCSP 作为一种实时查询证书有效性的方式,最初的设想很美好:当浏览器访问一个网站时,它可以向 CA(证书颁发机构) 的 OCSP 服务器发送一个简短的请求,询问该证书是否仍然有效。这似乎比下载一个巨大的 CRL(证书吊销列表) 要高效得多。
然而,OCSP 在实际应用中暴露出众多缺陷:
首先是性能问题。尽管单个请求很小,但当数百万用户同时访问网站时,OCSP 服务器需要处理海量的实时查询。这不仅给 CA 带来了巨大的服务器压力,也增加了用户访问网站的延迟。如果 OCSP 服务器响应缓慢甚至宕机,浏览器可能会因为无法确认证书状态而中断连接,或者为了用户体验而不得不“睁一只眼闭一只眼”,这都削弱了 OCSP 的安全性。
更严重的是隐私问题。每一次 OCSP 查询,都相当于向 CA 报告了用户的访问行为。这意味着 CA 能够知道某个用户在何时访问了哪个网站。虽然 OCSP 查询本身不包含个人身份信息,但将这些信息与 IP 地址等数据结合起来,CA 完全可以建立起用户的浏览习惯画像。对于重视隐私的用户和开发者来说,这种“无声的监视”是不可接受的。即使 CA 故意不保留这些信息,地区法律也可能强制 CA 收集这些信息。
再者,OCSP 还存在设计上的安全缺陷。由于担心连接超时影响用户体验,浏览器通常默认采用 soft-fail 机制:一旦无法连接 OCSP 服务器,便会选择放行而非阻断连接。攻击者恰恰可以利用这一点,通过阻断客户端与 OCSP 服务器之间的通信,使查询始终超时,从而轻松绕过证书状态验证。
基于上面这些缺陷,我们有了 OCSP 装订 (OCSP stapling) 方案,这在我去年的博客里讲过,欢迎回顾。
OCSP Must-Staple 是一个在 ssl 证书申请时的拓展项,该扩展会告知浏览器:若在证书中识别到此扩展,则不得向证书颁发机构发送查询请求,而应在握手阶段获取装订式副本。若未能获得有效副本,浏览器应拒绝连接。
这项功能赋予了浏览器开发者 hard-fail 的勇气,但在 OCSP 淡出历史之前,Let's Encrypt 似乎是唯一支持这一拓展的主流 CA,并且这项功能并没有得到广泛使用。
~~本来不想介绍这项功能的(因为根本没人用),但考虑到这东西快入土了,还是给它在中文互联网中立个碑,~~更多信息参考 Let's Encrypt 的博客。
OCSP 的隐私和性能问题并非秘密,浏览器厂商们早就开始了各自的探索。2012 年,Chrome 默认禁用了 CRLs、OCSP 检查,转向自行设计的证书校验机制。
众所周知,吊销列表可以非常庞大。如果浏览器需要下载和解析一个完整的全球吊销列表,那将是一场性能灾难(Mozilla 团队在今年的博客中提到,从 3000 个活跃的 CRL 下载的文件大小将达到 300MB)。Chromium 团队通过分析历史数据发现,大多数被吊销的证书属于少数高风险类别,例如证书颁发机构(CA)本身被攻破、或者某些大型网站的证书被吊销。基于此洞察,CRLSets 采取了以下策略:
虽然 CRLSets 是一种“不完美”的解决方案,但它在性能和可用性之间找到了一个平衡点。它确保了用户在访问主流网站时的基础安全,同时避免了 OCSP 带来的性能和隐私开销。对于 Chromium 而言,与其追求一个在现实中难以完美实现的 OCSP 方案,不如集中精力解决最紧迫的安全威胁。
与 Chromium 的“只取一瓢”策略不同,Firefox 的开发者们一直在寻找一种既能保证全面性,又能解决性能问题的方案。
为了解决这个问题,Mozilla 提出了一个创新的方案:CRLite。CRLite 的设计理念是通过哈希函数和布隆过滤器等数据结构,将庞大的证书吊销列表压缩成一个小巧、可下载且易于本地验证的格式。
CRLite 的工作原理可以简单概括为:
与 CRLSets 相比,CRLite 的优势在于它能够实现对所有吊销证书的全面覆盖,同时保持极小的体积。更重要的是,它完全在本地完成验证,这意味着浏览器无需向任何第三方服务器发送请求,从而彻底解决了 OCSP 的隐私问题。
Firefox 当前的策略为每 12 小时对 CRLite 数据进行一次增量更新,每日的下载数据大约为 300KB;每 45 天进行一次全量的快照同步,下载数据约为 4MB。
Mozilla 开放了他们的数据看板,你可以在这里找到近期的 CRLite 数据大小:https://yardstick.mozilla.org/dashboard/snapshot/c1WZrxGkNxdm9oZp7xVvGUEFJCELfApN
自 2025 年 4 月 1 日发布的 Firefox Desktop 137 版本起,Firefox 开始逐步以 CRLite 替换 OCSP 校验;同年 8 月 19 日,Firefox Desktop 142 针对 DV 证书正式弃用 OCSP 检验。
CRLite 已经成为 Firefox 未来证书吊销验证的核心方案,它代表了对性能、隐私和安全性的全面追求。
随着 Let's Encrypt 等主要 CA 关闭 OCSP 服务,OCSP 的时代正在加速落幕。我们可以看到,浏览器厂商们已经开始各自探索更高效、更安全的替代方案。
这些方案的共同点是:将证书吊销验证从实时在线查询(OCSP)转变为本地化验证,从而规避了 OCSP 固有的性能瓶颈和隐私风险。
未来,证书吊销的生态系统将不再依赖单一的、中心化的 OCSP 服务器。取而代之的是,一个更加多元、分布式和智能化的新时代正在到来。OCSP 这一技术可能逐渐被淘汰,但它所试图解决的“证书吊销”这一核心安全问题,将永远是浏览器和网络安全社区关注的重点。
2025-09-05 05:54:17
在今年八月的时候,我这边所在的一个 Github Organization 在私有项目开发阶段频繁触发 CI,耗尽了 Github 为免费计划 (Free Plan) 提供的每月 2000 分钟 Action 额度(所有私有仓库共享,公有仓库不计)。大致看了下,CI 流设置得是合理的,那么就要另寻他法看看有没有办法去提供更宽裕的资源,因此也就盯上了文章标题中所提到的 Github Action Self-hosted Runner。
对于这个 Self-hosted Runner,与 Github 官方提供的 runner 相比,主要有以下几个优势
由于不清楚需要的网络环境,我这次测试直接选用了一台闲置的香港 vps,4核4G + 80G 硬盘 + 1Gbps 大口子的配置,除了硬盘读写稍微拉胯一些,别的地方可以说是拉满了。
Self-hosted Runner 的配置本身是相当直接和清晰的,照着官方提供的方案基本没什么问题。

三个主流平台都有,如果好好加以利用,应该可以涵盖包括 iPhone 应用打包等一系列的需求。

在观察一下我这边拿到手的 2.328.0 版本的 runner 安装文件压缩包的体积在 220MB 左右,内置了 node20 和 node24 各两个版本的运行环境。


在执行完 config.sh 后,当前目录下就会多出一个 svc.sh,可以帮助利用这东西来调用 systemd 实现进程守护之类的需求。

再次刷新网页,就可以看到 Self-hosted Runner 处于已经上线的状态了

这一步很简单,只需在原 Action 的 yml 文件中改变 runs-on 字段即可
jobs:
run:
+ runs-on: self-hosted
- runs-on: ubuntu-latest
当我满心欢喜地将 CI 流程从 Github 官方的 runner 切换到自托管的 runner 后,问题很快就浮现了,而这也正是我“爱不起来”的主要原因。问题集中体现在我习以为常的 setup-python 这一由 Github 官方维护的 Github Action Flow 中,提示 3.12 版本没找到。

在 Github 官方提供的虚拟环境中,这些 Action 会为我们准备好指定版本的开发环境。例如,uses: actions/setup-python 加上 with: python-version: '3.12' 就会自动在环境中安装并配置好 Python 3.12.x。我对此已经习以为常,认为这是一个“开箱即用”的功能。但在 Self-hosted Runner 上,情况略有些不同。setup-python 在文档中指出
Python distributions are only available for the same environments that GitHub Actions hosted environments are available for. If you are using an unsupported version of Ubuntu such as
19.04or another Linux distribution such as Fedora,setup-pythonmay not work.
setup-python 这个 Action 只支持 Github Action 所采用的同款操作系统,而我 VPS 的 Debian 不受支持,因此有这个误报,同时也给我的 Debian 判了死刑。
我潜意识里认为,Self-hosted Runner 仅仅是将计算成本从 Github 服务器转移到了本地,而 actions/setup-python 这种官方标准动作,理应会像 Github-hosted Runner 中那样,优雅地为我下载、安装、并配置好我需要的一切。然而,Self-hosted Runner 的本质只是从 Github 接收任务,并在当前的操作系统环境中执行指令,并不保证和 Github 官方提供的 Runner 的运行环境一致。
Self-hosted Runner 不是一个开箱即用的“服务”,而是一个需要你亲自管理的“基础设施”。你需要负责服务器的安装、配置、安全更新、依赖管理、磁盘清理等一系列运维工作。它更适合那些对 CI/CD 有更高阶需求的团队或个人:比如 CI/CD 消费大户、需要特定硬件(如 ARM、GPU)进行构建的团队、或者 CI 流程深度依赖内部网络资源的企业。对于像我这样只是愿意拿出更多的本地计算资源来获取更多 Action 运行时长的普通开发者而言,它带来的运维心智负担,似乎是有一点重了。
2025-08-11 00:06:40
去年夏天,我兴致勃勃地写了好几篇博文,详细讲述了我如何搭建博客图床。核心目标很明确:分地区解析 DNS,让国内外的访客都能嗖嗖地加载图片,体验拉满。想法嘛,绝对是走在技术前沿的,堪称完美!然而……现实它总是喜欢给你来点小惊喜,对吧?

955 毫秒! 看到这个 DNS 解析时长的时候,我差点把刚喝下去的霸王茶姬喷在屏幕上。这简直就是一个隐形刺客,在我精心设计的图床网络架构背后,悄咪咪地给了致命一击。想象一下,访客满怀期待地点开你的博客,结果光是为了搞清楚图片服务器在哪,就要等上差不多一秒钟?这体验优化了个寂寞啊!
为啥之前没发现?这得“感谢”DNS 缓存这位老好人。它勤勤恳恳地帮后来的访客记住了答案,让我的本地测试和复访测试都一片祥和。直到最近,有群友向我反馈了首次访问时图片的加载速度过慢,我才如梦初醒。再结合最近为了秋招准备的八股文中里面关于 DNS 解析那套繁琐的流程(递归查询、权威查询、根域名、顶级域名……查个地址堪比查户口本),我瞬间锁定了罪魁祸首:首次访问时的 DNS 解析延迟。
来,复盘一下我那“曲折离奇”的 DNS 寻址之旅(访客视角):
static.031130.xyz 的图片。031130.xyz 的权威 DNS: 问了一圈,发现权威服务器原来在 Cloudflare (国外)。static.031130.xyz 啊?它是个马甲 (CNAME),真身是 cdn-cname.zhul.in,你去找它吧!”zhul.in 的权威 DNS: 这次权威服务器在 DNSPod (国内)。cdn-cname.zhul.in 也是个马甲 (CNAME),它实际是 small-storage-cdn.b0.aicdn.com,接着找!”small-storage-cdn.b0.aicdn.com: 最终,它可能还会再 CNAME 到类似 nm.aicdn.com 这样的 CDN 节点主机名。发现问题没?关键的第一步和第二步,权威 DNS 查询指向了国外的 Cloudflare! 对于国内用户,虽然最终解析到的 CDN 节点 (small-storage-cdn.b0.aicdn.com/nm.aicdn.com) 是国内的、速度飞快,但光是前两步跨越重洋的 DNS 查询,就足够让首次访问的用户体验跌入谷底。那个 955ms 的解析时长,基本就是花在跟国外 DNS 服务器“跨国聊天”上了。
优化方案:三管齐下,围剿 DNS 延迟
既然找到了病根,就得下猛药:
dns-prefetch): 在博客的 HTML <head> 里,早早地加上 <link rel="dns-prefetch" href="//static.031130.xyz">。这相当于浏览器在渲染页面时,就悄悄开始解析图床域名了,等真需要加载图片时,DNS 结果可能已经准备好了,神不知鬼不觉。当然也可以使用 preconnect 等等更激进的策略,但本文着重讲 DNS 解析,因此不做拓展。static.031130.xyz 这个 CNAME 记录的 TTL 值调大。以前都设置得较短,方便快速切换。现在为了缓存,适当延长(比如几小时甚至一天)。这样,一旦有用户解析过,本地 DNS 服务器就能记住更久,后续用户(包括同一用户再次访问)就能直接从缓存拿到结果,省掉跨国查询。031130.xyz 域名的权威 DNS 服务器,从 Cloudflare 搬回国内 DNSPod。这样一来:
031130.xyz 的权威服务器时,直接找到的就是国内的 DNSPod,响应飞快。static.031130.xyz -> small-storage-cdn.b0.aicdn.com 完全在国内完成,丝般顺滑,不需要 cdn-cname.zhul.in 当分区域解析的工具人效果如何?
受限于 DNS 缓存带来的测试困难,最终的效果确实很难在短时间内测试出来。但迁移权威 DNS 到 DNSPod + 调整 TTL + 加上预取之后,再测试首次访问的 DNS 解析时间总算是降到了可接受的程度,这才是 CDN 优化该有的样子!
教训总结:
dns-prefetch 和合理设置 TTL 能有效缓解。!!! 超级重要补充:警惕 CNAME 拉平 !!!
最后,必须给各位提个醒!如果你和我一样,需要依赖分地区解析来让访客访问到最近的 CDN 节点(比如让国内走国内CDN,国外走Cloudflare),那么千万要避开 CNAME Flattening (CNAME 拉平) 这个“优化”方案!
CNAME 记录(比如 static.example.com -> cdn.cname.target.com),它主动帮你去找 cdn.cname.target.com 的最终 A/AAAA 记录 (IP地址),然后把 最终的 IP 地址 直接返回给查询者,而不是返回 CNAME。DNS View 或 GeoDNS) 的功能是在权威 DNS 服务器层面实现的。当权威服务器执行 CNAME 拉平时,它是在它自己所在的位置去查询 cdn.cname.target.com 的 IP。比如你的权威 DNS 在 Cloudflare (美国节点),它拉平查询时,拿到的 cdn.cname.target.com 的 IP 大概率是给美国用户用的最优 IP。然后它把这个 IP 返回给了所有地区的查询者,包括中国用户!你精心配置的让中国用户解析到国内 CDN IP 的策略就完全失效了!GeoDNS) 功能,绝对不要在你希望应用分地区解析的域名上启用 CNAME Flattening (或 ALIAS, ANAME 等实现类似拉平效果的功能)。老老实实用 CNAME 指向另一个支持 GeoDNS 的域名(就像我初始方案里 static.031130.xyz -> cdn-cname.zhul.in,而 zhul.in 在 DNSPod 上做分地区解析),才能保证你的分流策略正确执行。2025-07-13 00:01:35
morphdom 遇上 Vue在上一篇文章中,我们经历了一场 Markdown 渲染的性能优化之旅。从最原始的 v-html 全量刷新,到按块更新,最终我们请出了 morphdom 这个“终极武器”。它通过直接比对和操作真实 DOM,以最小的代价更新视图,完美解决了实时渲染中的性能瓶颈和交互状态丢失问题。
然而,一个根本性问题始终存在:在 Vue 的地盘里,绕过 Vue 的虚拟 DOM (Virtual DOM) 和 Diff 算法,直接用一个第三方库去“动刀”真实 DOM,总感觉有些“旁门左道”。这就像在一个精密的自动化工厂里,引入了一个老师傅拿着锤子和扳手进行手动修补。虽然活干得漂亮,但总觉得破坏了原有的工作流,不够“Vue”。
那么,有没有一种更优雅、更“原生”的方式,让我们既能享受精准更新的快感,又能完全融入 Vue 的生态体系呢?
带着这个问题,我询问了前端群里的伙伴们。
如果就要做一个渲染器,你这个思路不是最佳实践。每次更新时,你都生成全量的虚拟 HTML,然后再对 HTML 做减法来优化性能。然而,每次更新的增量部分是明确的,为什么不直接用这部分增量去做加法?增量部分通过 markdown-it 的库无法直接获取,但更好的做法是在这一步进行改造:先解析 Markdown 的结构,再利用 Vue 的动态渲染能力生成 DOM。这样,DOM 的复用就可以借助 Vue 自身的能力来实现。—— j10c
可以用 unified 结合 remark-parse 插件,将 markdown 字符串解析为 ast,然后根据 ast 使用 render func 进行渲染即可。—— bii & nekomeowww
我们之前的方案,无论是 v-html 还是 morphdom,其核心思路都是:
Markdown 字符串 -> markdown-it -> HTML 字符串 -> 浏览器/morphdom -> DOM
这条链路的问题在于,从 HTML 字符串 这一步开始,我们就丢失了 Markdown 的原始结构信息。我们得到的是一堆非结构化的文本,Vue 无法理解其内在逻辑,只能将其囫囵吞下。
而新的思路则是将流程改造为:
Markdown 字符串 -> AST (抽象语法树) -> Vue VNodes (虚拟节点) -> Vue -> DOM
AST (Abstract Syntax Tree) ,即抽象语法树,是源代码或标记语言的结构化表示。它将一长串的文本,解析成一个层级分明的树状对象。对于 Markdown 来说,一个一级标题会变成一个 type: 'heading', depth: 1 的节点,一个段落会变成一个 type: 'paragraph' 的节点,而段落里的文字,则是 paragraph 节点的 children。
一旦我们将 Markdown 转换成 AST,就相当于拥有了整个文档的“结构图纸”。我们不再是面对一堆模糊的 HTML 字符串,而是面对一个清晰、可编程的 JavaScript 对象。
为了实现 Markdown -> AST 的转换,我们引入 unified 生态。
unified 插件,专门负责将 Markdown 文本解析成 AST(具体来说是 mdast 格式)。首先,我们需要安装相关依赖:
npm install unified remark-parse
然后,我们可以轻松地将 Markdown 字符串转换为 AST:
import { unified } from 'unified'
import remarkParse from 'remark-parse'
const markdownContent = '# Hello, AST!\n\nThis is a paragraph.'
// 创建一个处理器实例
const processor = unified().use(remarkParse)
// 解析 Markdown 内容
const ast = processor.parse(markdownContent)
console.log(JSON.stringify(ast, null, 2))
运行以上代码,我们将得到一个如下所示的 JSON 对象,这就是我们梦寐以求的 AST:
{
"type": "root",
"children": [
{
"type": "heading",
"depth": 1,
"children": [
{
"type": "text",
"value": "Hello, AST!",
"position": { ... }
}
],
"position": { ... }
},
{
"type": "paragraph",
"children": [
{
"type": "text",
"value": "This is a paragraph.",
"position": { ... }
}
],
"position": { ... }
}
],
"position": { ... }
}
拿到了 AST,下一步就是将这个“结构图纸”真正地“施工”成用户可见的界面。在 Vue 的世界里,描述 UI 的蓝图就是虚拟节点 (VNode),而 h() 函数(即 hyperscript)就是创建 VNode 的画笔。
我们的任务是编写一个渲染函数,它能够递归地遍历 AST,并为每一种节点类型(heading, paragraph, text 等)生成对应的 VNode。
下面是一个简单的渲染函数实现:
function renderAst(node) {
if (!node) return null
switch (node.type) {
case 'root':
return h('div', {}, node.children.map(renderAst))
case 'paragraph':
return h('p', {}, node.children.map(renderAst))
case 'text':
return node.value
case 'emphasis':
return h('em', {}, node.children.map(renderAst))
case 'strong':
return h('strong', {}, node.children.map(renderAst))
case 'inlineCode':
return h('code', {}, node.value)
case 'heading':
return h('h' + node.depth, {}, node.children.map(renderAst))
case 'code':
return h('pre', {}, [h('code', {}, node.value)])
case 'list':
return h(node.ordered ? 'ol' : 'ul', {}, node.children.map(renderAst))
case 'listItem':
return h('li', {}, node.children.map(renderAst))
case 'thematicBreak':
return h('hr')
case 'blockquote':
return h('blockquote', {}, node.children.map(renderAst))
case 'link':
return h('a', { href: node.url, target: '_blank' }, node.children.map(renderAst))
default:
// 其它未实现类型
return h('span', { }, `[${node.type}]`)
}
}
整合上述逻辑,我们可以构建一个 Vue 组件。鉴于直接生成 VNode 的特性,采用函数式组件或显式 render 函数最为适宜。
<template>
<component :is="VNodeTree" />
</template>
<script setup>
import { computed, h, shallowRef, watchEffect } from 'vue'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
const props = defineProps({
mdText: {
type: String,
default: ''
}
})
const ast = shallowRef(null)
const parser = unified().use(remarkParse)
watchEffect(() => {
ast.value = parser.parse(props.mdText)
})
// AST 渲染函数 (同上文 renderAst 函数)
function renderAst(node) { ... }
const VNodeTree = computed(() => renderAst(ast.value))
</script>
现在就可以像使用普通组件一样使用它了:
<template>
<MarkdownRenderer :mdText="markdownContent" />
</template>
<script setup>
import { ref } from 'vue'
import MarkdownRenderer from './MarkdownRenderer.vue'
const markdownContent = ref('# Hello Vue\n\nThis is rendered via AST!')
</script>
切换到 AST 赛道后,我们获得了前所未有的超能力:
v-html 的暴力刷新,也不再需要 morphdom 这样的“外援”。所有更新都交由 Vue 自己的 Diff 算法处理,这不仅性能极高,而且完全符合 Vue 的设计哲学,是真正的“自己人”。<h2>)无缝替换为自定义 Vue 组件(如 <FancyHeading>),仅在 renderAst 函数中调整对应 case 逻辑即可。<a> 添加 target="_blank" 与 rel="noopener noreferrer" 属性,或为图片 <img> 包裹懒加载组件,此类操作在 AST 层面易于实现。unified 丰富的插件生态(如 remark-gfm 支持 GFM 语法,remark-prism 实现代码高亮),仅需在处理器链中引入相应插件(.use(pluginName))。remark)、渲染逻辑(renderAst)和业务逻辑(Vue 组件)被清晰地分离开来,代码结构更清晰,维护性更强。回顾优化历程:
通过采用 AST,我们不仅解决了具体的技术挑战,更重要的是实现了思维范式的转变——从面向结果(HTML 字符串)的编程,转向面向过程与结构(AST)的编程。这使我们能够深入内容本质,从而实现对渲染流程的精确控制。
本次从“全量刷新”到“结构化渲染”的优化实践,不仅是一次性能提升的技术过程,更是一次深入理解现代前端工程化思想的系统性探索。最终实现的 Markdown 渲染方案,在性能、功能性与架构优雅性上均达到了较高水准。
2025-07-12 20:48:56
在最近接手的 AI 需求中,需要实现一个类似 ChatGPT 的对话交互界面。其核心流程是:后端通过 SSE(Server-Sent Events)协议,持续地将 AI 生成的 Markdown 格式文本片段推送到前端。前端负责动态接收并拼接这些 Markdown 片段,最终将拼接完成的 Markdown 文本实时渲染并显示在用户界面上。
Markdown 渲染并不是什么罕见的需求,尤其是在 LLM 相关落地产品满天飞的当下。不同于 React 生态拥有一个 14k+ star 的著名第三方库——react-markdown,Vue 这边似乎暂时还没有一个仍在活跃维护的、star 数量不低(起码得 2k+ 吧?)的 markdown 渲染库。cloudacy/vue-markdown-render 最后一次发版在一年前,但截止本文写作时间只有 103 个 star;miaolz123/vue-markdown 有 2k star,但最后一次 commit 已经是 7 年前了;zhaoxuhui1122/vue-markdown 更是 archived 状态。
简单调研了一圈,发现 Vue 生态里确实缺少一个能打的 Markdown 渲染库。既然没有现成的轮子,那咱就自己造一个!
根据大部分文章以及 LLM 的推荐,我们首先采用 markdown-it 这个第三方库将 markdown 转换为 html 字符串,再通过 v-html 传入。
PS: 我们这里假设 Markdown 内容是可信的(比如由我们自己的 AI 生成)。如果内容来自用户输入,一定要使用 DOMPurify 这类库来防止 XSS 攻击,避免给网站“开天窗”哦!
示例代码如下:
<template>
<div v-html="renderedHtml"></div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import MarkdownIt from 'markdown-it';
const markdownContent = ref('');
const md = new MarkdownIt();
const renderedHtml = computed(() => md.render(markdownContent.value))
onMounted(() => {
// markdownContent.value = await fetch() ...
})
</script>
上述方案虽然能实现基础渲染,但在实时更新场景下存在明显缺陷:每次接收到新的 Markdown 片段,整个文档都会触发全量重渲染。即使只有最后一行是新增内容,整个文档的 DOM 也会被完全替换。这导致两个核心问题:
markdown-it 解析和 DOM 重建的开销呈线性上升。为了解决这两个问题,我们在网上找到了分块渲染的方案 —— 把 Markdown 按两个连续的换行符 (\n\n) 切成一块一块的。这样每次更新,只重新渲染最后一块新的,前面的老块直接复用缓存。好处很明显:
代码调整后像这样:
<template>
<div>
<div
v-for="(block, idx) in renderedBlocks"
:key="idx"
v-html="block"
class="markdown-block"
></div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import MarkdownIt from 'markdown-it'
const markdownContent = ref('')
const md = new MarkdownIt()
const renderedBlocks = ref([])
const blockCache = ref([])
watch(
markdownContent,
(newContent, oldContent) => {
const blocks = newContent.split(/\n{2,}/)
// 只重新渲染最后一个块,其余用缓存
// 处理块减少、块增多的场景
blockCache.value.length = blocks.length
for (let i = 0; i < blocks.length; i++) {
// 只渲染最后一个,或新块
if (i === blocks.length - 1 || !blockCache.value[i]) {
blockCache.value[i] = md.render(blocks[i] || '')
}
// 其余块直接复用
}
renderedBlocks.value = blockCache.value.slice()
},
{ immediate: true }
)
onMounted(() => {
// markdownContent.value = await fetch() ...
})
</script>
分块渲染虽然解决了大部分问题,但遇到 Markdown 列表就有点力不从心了。因为 Markdown 语法里,列表项之间通常只有一个换行符,整个列表会被当成一个大块。想象一下一个几百项的列表,哪怕只更新最后一项,整个列表块也要全部重来,前面的问题又回来了。
morphdom 是一个仅 5KB(gzip 后)的 JavaScript 库,核心功能是:接收两个 DOM 节点(或 HTML 字符串),计算出最小化的 DOM 操作,将第一个节点 “变形” 为第二个节点,而非直接替换。
其工作原理类似虚拟 DOM 的 Diff 算法,但直接操作真实 DOM:
Markdown 把列表当整体,但生成的 HTML 里,每个列表项 (<li>) 都是独立的!morphdom 在更新后面的列表项时,能保证前面的列表项纹丝不动,状态自然就保住了。
这不就是我们梦寐以求的效果吗?在 Markdown 实时更新的同时,最大程度留住用户的操作状态,还能省掉一堆不必要的 DOM 操作!
<template>
<div ref="markdownContainer" class="markdown-container">
<div id="md-root"></div>
</div>
</template>
<script setup>
import { nextTick, ref, watch } from 'vue';
import MarkdownIt from 'markdown-it';
import morphdom from 'morphdom';
const markdownContent = ref('');
const markdownContainer = ref(null);
const md = new MarkdownIt();
const render = () => {
if (!markdownContainer.value.querySelector('#md-root')) return;
const newHtml = `<div id="md-root">` + md.render(markdownContent.value) + `</div>`
morphdom(markdownContainer.value, newHtml, {
childrenOnly: true
});
}
watch(markdownContent, () => {
render()
});
onMounted(async () => {
// 等待 Dom 被挂载上
await nextTick()
render()
})
</script>
下面这个 iframe 里放了个对比 Demo,展示了不同方案的效果差异。
小技巧: 如果你用的是 Chrome、Edge 这类 Chromium 内核的浏览器,打开开发者工具 (DevTools),找到“渲染”(Rendering) 标签页,勾选「突出显示重绘区域(Paint flashing)」。这样你就能直观看到每次更新时,哪些部分被重新绘制了——重绘区域越少,性能越好!

从最开始的“暴力全量刷新”,到“聪明点的分块更新”,再到如今“精准手术刀般的 morphdom 更新”,我们一步步把那些不必要的渲染开销给砍掉了,最终搞出了一个既快又能留住用户状态的 Markdown 实时渲染方案。
不过,用 morphdom 这个第三方库来直接操作 Vue 组件里的 DOM,总觉得有点...不够“Vue”?它虽然解决了核心的性能和状态问题,但在 Vue 的世界里这么玩,多少有点旁门左道的意思。
下篇预告: 在下一篇文章里,咱们就来聊聊,在 Vue 的世界里,有没有更优雅、更“原生”的方案来搞定 Markdown 的精准更新?敬请期待!