浏览器如何自定义默认字体(4):动态页面中的字体更新与验证
浏览器如何自定义默认字体 · 1 · 2 · 3 · 第 4 篇(当前)
样式来源转换后,普通新增节点可以直接匹配更新后的规则,但独立内联声明、受限来源和新文档仍需要处理。DOM 兜底读取计算后的字体列表,再写入个人候选;重算、恢复和任务调度因此必须区分网页作者的值与扩展自己的修改。
这里沿用可读 CSSOM、公共外链镜像、DOM 兜底的三层结构。前两层负责来源转换,最后一层处理无法接管的声明;监听变化、检查候选和实际写补丁也分别统计,不能由一次没有行内样式就判定整个网站只需 CSS。
DOM 兜底重算前,还原节点和相关祖先的补丁
getComputedStyle(element).fontFamily 返回的是当前计算结果。如果上一次已经给父元素插入了个人字体,子元素继承到的也可能是这份补丁;直接读取它,无法知道主题切换后原站真正想用什么。CSSOM 的接口定义没有一个「忽略扩展刚才写入」选项。
样式表层为每个改写属性保存原值、原优先级和实际写入值;原站后来写入不同值时,把它作为新的基线。DOM 兜底另外为每个改写节点保存原内联 font-family、优先级和本次写入值。处理一批候选前,先临时还原本批节点及其已改写的祖先;然后集中读取原站列表,计算结果,再集中写回。还原与重新应用处在同一个同步批次,临时原值不会跨帧停留。
正文和代码均选择「跟随网页」时,控制器使用这份记录撤销。停用整个扩展后,应刷新网页,让新文档按原站资源重新呈现。如果页面后来主动设置了不同的字体值,恢复逻辑保留作者的新值;颜色等其他 style 属性也不会被整段覆盖。
尽早处理首屏,再跟随动态页面
字体切换有两个时机:第一次进入页面时,脚本可能来晚了;页面已经打开后,新文字可能等处理队列。前者需要提前启动,后者需要优先处理直接变化的节点。
插件在 manifest 中把内容脚本设为 document_start,进入文档就开始读取扩展本地偏好。它不等到 DOMContentLoaded 或 document_idle 再初始化。Chrome 对注入时机的说明区分了这些阶段。
此时 html、body 可能尚未创建,控制器观察的是 document。解析器随后插入的根节点、正文和文字,都能进入队列;配置到达后先转换可用的样式来源,再检查已有正文。样式节点和样式表加载后重新发现声明,避免一直使用解析早期的默认结果。
扩展存储的读取仍是异步操作,内容脚本提前启动也不能保证偏好永远先于首次绘制到达。这里保留正常页面呈现,不通过隐藏全文等待字体来掩盖切换;字体资源未就绪、超大批内容或页面强制回写时仍可能有可见变化。
动态页面按受影响范围处理
MutationObserver监听新增内容、类名、属性和样式节点变化。普通新增只收集新子树,父节点仅补查自己的直接文本;属性变化检查变更子树,避免反复扫描未变化的已有兄弟。鼠标与焦点事件补充部分不产生 DOM 变化的交互状态。
检查范围还取决于原站选择器。字体、font/all 或自定义属性涉及 :nth-child()、相邻或后续兄弟选择器时,一次插入或 class 变化可能改变周围元素的匹配,需要检查相关容器。涉及 :has()、@scope、容器查询、转义选择器或不可读来源时,则保守扩大到文档;嵌套规则继承外层依赖。根主题、样式表、视口和深浅色条件变化也保留广范围重算,来源变化后重新判断范围。
新文字需要及时接住。观察阶段的微任务优先处理刚新增或直接改写的节点,让常见小批更新赶在下一次绘制之前;超出快速批次额度的待办留给动画帧。紧急批次已经收集的根从普通待办移除,不再因同一份入队记录检查第二遍。直接变化的节点排在前面,避免长列表末尾的新文字等待大量旧节点重算。
DOM 兜底每批最多取 160 个待办候选,并补入相关祖先和代码容器;每帧只开放一个额外微任务快速批次,防止网页自己的观察器与字体补丁反复互写。普通帧按待办先后顺序推进,保证旧任务能获得处理。同批相同用途与原列表共享转换结果;已检查文本的连续字符追加通常无需再扫描。删除子树时释放其中的补丁和队列引用。
直接变化无需固定等待;超大批新增仍然分帧处理。提前启动和微任务优先队列分别缩短初次进入与动态更新的等待,两者共同减少默认字体先显示出来的机会。
160 是候选数量,不是严格的时间预算;额外涉及的祖先、节点收集、样式计算仍有开销。浏览器还要为新字体执行 Style、Layout 和 Paint,脚本某一批的同步耗时不能代表全部渲染成本。web.dev 对布局抖动的说明也强调应减少读写交错。
排除自身写入,才能在空闲时停止工作
字体补丁会修改 style,观察器也会收到这次变化。只靠一个同步的 isUpdating 标志不够,观察回调会在写入之后交付;忽略所有 style 变化又会漏掉网页作者的新声明。
实现记录每次自身写入前后的 style 字符串,并根据同批 MutationRecord 的旧值反推每次变化后的值。只有匹配自身记录的变化才跳过,其余仍按页面变化处理。队列清空后没有轮询计时器,等待下一次真实事件。
DOM 观察并不能覆盖所有 CSS 来源。样式表层在相关 DOM 更新时检查来源集合和已知规则容器的数量变化,因此已有样式表中的 insertRule() 可以随这次更新重新发现。只有 CSSOM 属性值变化、没有 DOM 或资源事件时,仍可能需要「重新应用当前页面」。当前不主动遍历 Shadow DOM,也不主动给 Canvas、SVG、MathML、textarea 和伪元素添加元素补丁;安全的样式来源声明仍可转换,正常继承由浏览器决定。
内嵌登录表单需要自己的控制器
一个页面可以包含多个独立文档。在阿里云登录页上,外层已经用了所选字体,「账号名」「密码」却仍然显示微软雅黑。检查后发现,表单来自 passport.aliyun.com 的跨域 iframe,标签和占位文字使用 "PingFang SC", "Microsoft YaHei", "Helvetica Neue"。当时 manifest 的 all_frames 为 false,子文档没有注入控制器;这不是字体名单遗漏,也不是多等几帧能解决的问题。
iframe 里的页面有自己的 document 和样式表,父页面的 CSS 选择器不会进入它,父页面的 DOM 观察器也不会收到子文档内部的变化;即使 iframe 同源,字体也不会自动从父页面继承过去。因此,「什么时候执行」和「在哪个文档执行」要分开排查。document_start 能提前执行已匹配的脚本,却不能让原本未注入的子框架开始处理。
插件将 all_frames 设为 true,让浏览器向每个 URL 匹配的 HTTP/HTTPS 文档分别注入。每个框架都在自己的文档内执行三层处理,无需父页面跨域读取子页面,也不额外增加权限。Chrome 的框架注入说明解释了这个匹配机制。动态插入、嵌套和导航后的框架沿用相同入口;存储变化通知各控制器更新,重新应用消息也广播到各框架。
后台也要认识这些新文档。它使用浏览器提供的发送方身份,将请求按标签页、框架和文档隔离;两个框架恰好用了相同请求令牌时,取消一个不能终止另一个。导航后换了文档,也不能沿用旧文档的取源任务和 CSS 响应缓存。后台取回 CSS,只解决来源文本的获取;子框架是否注入、取回后是否应用,仍需分别验证。

这里的占位文字由 input::placeholder 规则控制,能通过来源转换处理,不需要给伪元素写 DOM 补丁。实际绘制资源确认标签使用 Yozai-Regular。当前只匹配普通 HTTP/HTTPS 框架,about:blank、srcdoc、data: 和 blob: 没有启用来源回退;不能据此宣称覆盖了所有嵌入内容。
一次知乎漏改:提前启动之后,为什么仍会失败
最初逐元素处理,是为了拿到浏览器已经解析过的字体列表。移除固定等待、把入口从 document_idle 提前到 document_start,确实缩短了首屏和新增文字的等待;但这些改动仍然依赖每一个元素最终经过队列。没有接管声明来源,任何未处理的段落都会继续使用原站字体。
在这个知乎问题页滚动检查时,发现有直接承载文字的段落缺少补丁,计算字体也确实没有个人选择。它们没有自己的字体声明,最终继承 html body 使用的 var(--zFontSansSerif)。一次临时实验里,覆盖这条来源规则后,23 个漏改段落都采用了所选字体,新增的测试段落也立即继承,已有段落的内联声明没有改变。实验结束后撤销了测试规则。
这个现场证明了来源规则更适合承担普通正文,但尚未记录到完整的事件序列,不能把现场每一个漏改都归结为同一个回调。继续检查队列时,另外找到了一个可以确定复现的缺陷:快速批次用过之后,同帧又收到广范围重算,待处理内容会留到普通帧;普通帧却仍然优先取广范围集合的前 160 个候选。
受控单测构造 520 个必须使用 DOM 兜底的内联字体段落,每次推进模拟动画帧前连续改变两次根属性。旧实现反复处理队首,手动推进 10 次后最后一个段落仍是 system-ui。调整普通帧的取出顺序后,同一用例通过。这里用的是 FIFO,即按入队先后处理;广范围重算合并范围,但不再每次抢占已经等待的任务。这项测试验证调度顺序,不把模拟帧当作实际绘制时间。
因此,这次修复分成两部分:来源可转换时,让 CSS 自动匹配后续节点;确实要走 DOM 队列时,保证旧任务能推进。提前注入继续保留,它解决的是入口来晚的问题,不能代替队列公平性或来源覆盖。
来源优先实现的一次现场复验中,在同一个知乎页面从上向下滚至底部,49 个正文段落的计算字体都包含当时选择的汉仪旗黑,段落内联字体为零。此前漏改的段落也通过 Rendered Fonts 核对了实际资源。这里转换的是页面字体变量的来源,不需要在插件中写知乎的选择器或变量名。
验证方法也需要补齐。几个低干扰的新增节点样本通过,只能说明这些更新有效,不能证明持续主题、样式或属性变化下的队尾也能完成。现在保留旧实现会失败的用例,同时在真实扩展中检查插入瞬间、600 段批量新增、跨域访问受限、作者后续改写与跟随网页后的恢复。判断标准是实际字体、原值恢复和队列能否推进,行内 style 的数量只是辅助信息。
能否提前知道某个网站只用 CSS 就够
可以检查当前已经加载的字体声明,判断哪些来源能由 CSS 直接匹配后续元素;这不足以给整个域名永久判定。页面稍后可能加载新的样式、切换路由或主题,也可能加入独立内联声明。同一个网站的正文和登录 iframe 还可能使用完全不同的来源。首屏看起来正确,或者一次检查没有产生 DOM 补丁,都不能证明后续不需要兜底。
这里还要区分「纯 CSS」和「JS 改写样式来源后,由 CSS 匹配」。前者依赖静态声明,仍有前文的 system-ui 等边界;后者正是当前扩展的主要路径,JS 负责解析与转换声明,浏览器负责后续匹配和继承,因此不必给每一段新文字写行内样式。来源变化仍需发现,未接管的声明仍需兜底。没有待办时不轮询,不等于整个页面不再运行扩展脚本。
当前优化的是重复工作:按来源依赖缩小失效范围,并消除紧急批次与普通批次之间的重复收集。所有启用文档仍监听变化;即使没有写行内字体,也可能检查候选和计算样式。可读来源与后台镜像也会随样式资源、主题或条件变化重新检查,不能视为一次性零成本。显式关闭的域名则直接停止字体控制器,保留接收设置变化的轻量入口。
因此,评估第三层时要分开看「监听变化」「检查候选」和「写入补丁」。观察器收到变化后,先确定受影响范围;候选进入检查后,只有计算字体仍需转换才写行内声明。来源覆盖充分的页面可以没有正文补丁,却仍发生候选检查。统计中的 examined 是累计检查次数,同一节点重算会重复计数;patched 是当前持有的元素补丁数,两者都不能单独代表 CPU 耗时。
在 Windows 独立 Chromium 的真实 MV3 中,用相同页面做了三次交替对照:先建立 1,000 个段落,再逐帧追加 24 行,随后逐帧改动一个元素的颜色 12 次。两份构建都等到队列排空,并断言正文、代码和艺术字结果一致;正文继续由来源规则覆盖,行内字体补丁为零。
| 操作 | 优化前候选检查 | 优化后候选检查 |
|---|---|---|
| 追加 24 行 | 4,567 次 | 24 次 |
| 12 次局部属性变化 | 2,635 次 | 12 次 |
这三次的计数一致。整个更新窗口的 CDP ScriptDuration 中位数从约 90 毫秒降到 10 毫秒,其中也包含页面更新与测试采样。它说明这个局部场景的重复脚本工作减少了,不能推导所有网站的 CPU 使用率同比下降。结构依赖、不可读来源、主题切换和新样式到达仍可能需要广范围处理,原始数据随浏览器验证记录弹窗保留。
加入跨区域推断后,另用相同完成条件比较上一份构建与新构建:内容区有未知字体和完整系统回退,独立导航使用另一条默认字体栈,两份构建都应保留内容区的原字体。三次交替测量中,追加与属性变更的候选检查仍分别为 24 次、12 次;整个窗口的脚本时间中位数约从 15 毫秒增至 24 毫秒。范围采样确实有成本,这个样本的新增脚本工作约为 9 毫秒,分布在 36 次更新中;它与前一组消除重复检查的实验分别保留,不能把两组数字直接拼成收益。
来源缓存另做同一新版的冷学习与暖访问对照,样本包含 2,400 条非字体规则和变量引用。三组独立配对中,暖访问的完整来源发现次数均为零,正文实际效果一致,新增段落仍没有行内字体补丁。就绪检查点的 CDP 累计脚本时间中位数约为冷访问 54 毫秒、暖访问 35 毫秒;这个窗口包含内容脚本、页面和测试采样。校验仍要读取 CSSOM,因此缓存节省的是重复分析,不能把这组数据解释成不再运行 JS,也不能与旧版的动态优化数字合并。
怎样验证实际效果与性能
判断字体是否生效,仍要在 DevTools 的 Computed 面板查看 Rendered Fonts。候选字符串变化只证明新列表已写入;英文、中文、标点是否各自使用预期资源,需要检查实际绘制。

在 Windows Chromium 136 的实际 MV3 上下文中,已验证目录与表单、正文和代码独立保存、艺术字体、Font Awesome 私用区图标、新增内容、祖先主题类名、媒体条件、作者后续内联写入、连续字符更新和暂停恢复。LiteLoader 实际页面的中文标题通过内容脚本自动处理,Rendered Fonts 中出现所选汉仪旗黑资源。
动态时机样本包含局部新增、500 段已有内容后的追加、节点替换与作者内联字体更新。当前实现在这四个样本的动画帧前检查点已应用偏好,随后帧末采样也未出现默认字体。它们与持续重算的队尾回归分别检查,不能互相替代。
另有 600 段正文的来源继承实验:在插入的同一个同步阶段检查,全部已使用偏好字体,行内字体补丁为零。跨域测试将页面放在 localhost、CSS 放在 127.0.0.1,不返回 CORS 许可,确认原表持续抛出 SecurityError,后台接管后新段落仍立即继承。另以私有响应、服务拒绝、命名空间和非法导入顺序验证 DOM 兜底;命名层与匿名重要层、导入条件、资源基址、换链接、作者禁用和停止恢复分别断言。正文的实际资源是汉仪旗黑,艺术字体仍是楷体。
DOM 样本还检查 400 个新增段落分批处理、移除后的引用释放,以及静置时批次数不增长。末尾兜底另外检查未知字体缺失、原有楷体可用、拉丁字体缺少中文、独立字体图标,以及新增子节点立即继承,实际资源分别核对,而不是要求计算列表完全不变。119 项单测覆盖候选解析、全局值和表达式边界、来源所有权、导入展开、后台消息边界、请求去重、来源方案校验、冷暖复用、导出、缓存保存失败、取消、跨框架请求隔离、异步取源完成时的位置复核、跨区域范围判断和队尾回归。保存与撤销测试覆盖串行保存、后台重启后撤销、失败重试和键盘确认边界。
Popup 的保存、撤销、布局和主题另做 12 组检查。action.openPopup() 用来创建原生弹层并读取尺寸,交互与截图使用同一生产 Popup 文档和真实 Chrome API,不把文档测试称作工具栏鼠标点击。目录按需加载、关闭重开、受控延迟保存、失败回退和小尺寸布局都有独立断言;原生窗口为 380×480,没有额外的右侧滚动条占位。标题在面板切换前后保持相同中心与基线,深浅主题切换也不丢当前搜索和选择。
首次进入另外使用流式 HTML 做对照:先发送首屏文字,450 毫秒后才结束文档,分别检查首次导航、刷新和链接跳转。旧入口在 FCP 通知时仍是系统字体;提前启动后,这三个样本在通知时都已写入偏好,FCP 之后的字体状态采样未出现默认值。样式采样不能替代所有合成帧截图,也不证明存储迟到时绝无切换。测试环境、实际资源与范围保存在插件的浏览器验证记录弹窗。
框架回归包含旧 manifest 的负向对照、同源与跨域、嵌套、动态创建、导航、即时更新、重新应用和恢复原样;匿名会话另行检查阿里云实际表单与实际绘制字体。
缩小检查范围另有 10 组生产扩展回归,覆盖普通追加、位置与兄弟选择器、:has()、内联字体引用的变量、嵌套规则、@scope 和取源失败。文字清空后再出现时,也会重新判断 :empty 对周围元素的影响;节点移除后释放补丁,静置时批次数不继续增长。它们用于确认减少检查没有跳过这些必要更新,持续重算期间的队尾推进仍由独立用例验证。
这些测试覆盖了已实现的机制,没有完成所有网站的性能画像。很长的文档、多框架或高频变化页面仍应录制 Performance,分别观察节点收集、脚本、样式、布局、绘制和交互延迟;macOS、特殊协议框架、Shadow DOM、封闭组件及编辑器度量仍按前述范围单独验收。
总结
动态字体更新既要及时,也要能正确恢复。重算前还原本批节点与相关祖先,避免把旧补丁当成作者声明;直接变化优先处理,普通队列按入队顺序推进,持续重算时也不能让队尾永远等待。自身写入需要与作者后续修改分别识别,每个匹配的 iframe 文档也有独立控制器。
来源继承、队列回归、实际绘制和性能采样验证的是不同环节。局部检查次数下降不能外推为所有网页的 CPU 收益,提前注入也不保证配置永远先于首帧到达。保留这些条件,才能判断减少检查是否仍覆盖了必要更新。
上一篇:在原位置转换字体声明。
浏览器插件的运行验证
打开原文件 runtime-font-checks.json
记录包括实际加载的 MV3 生产扩展、本机目录 API、字体绘制资源、Popup 文档、动态与首屏时机、样式来源继承、后台取源与原位镜像、原生跨域限制和窄屏检查。队尾缺陷的负向回归记录区分旧实现失败与当前实现通过。
可控集成样本在 Windows 上的隔离 Chromium 136 中运行,使用专用临时浏览器资料,不修改系统字体或浏览器的全局字体设置。实际公开页面包括 LiteLoader 安装文档。三层实现的一份 Edge 知乎现场记录使用 Yozai,49 个段落的计算字体全部匹配,行内字体为零,抽查实际资源为 Yozai-Regular。这一页走可读 CSSOM 路径,后台分支由跨域构造页验证。旧来源优先构建的汉仪旗黑现场记录另行保留,不能把同为 0.1.0 的不同构建混成一份证据。
苹方字面名修复后,再在日常 Edge 会话复验虎扑:右侧 92 处统计和时间文字、3 个表头都采用 Yozai,行内字体为零,时间列抽查资源为 Yozai-Regular,脚本指纹与该次字面名修复构建相同。同次刷新知乎并滚至底部,49 个段落的计算字体仍全部匹配;该文档当次调试取证不可用,因此只记录 DOM 检查,未补写新的脚本指纹或绘制资源。
末尾兜底构建另有完整现场记录:同一 Edge 配置下,虎扑 92 处文字与 3 个表头仍匹配 Yozai,知乎滚至底部后的 49 段也全部匹配,均没有段落内联字体。两页都核对了本次脚本指纹和 Yozai-Regular 实际资源。未知字体、部分缺字及图标资源的分支由受控 MV3 样本覆盖,不把常规入口已经生效的现场当成所有未知字体的证明。
基础入口调整前,在真实 Edge 会话核对了 Grok 的 .font-body、变量链和 Imagine 的绘制资源:英文使用 Universal Sans,示例图标是 SVG。当前构建的 8 组受控 MV3 验证覆盖基础类、共享规则、局部新增匹配、整条变量、主题切换、后台镜像、混合图标栈、连续换字体与停止恢复;实际绘制分别检查悠哉、楷体和 Font Awesome。修改后的 Grok 现场刷新遇到浏览器 ERR_QUIC_PROTOCOL_ERROR,该次没有取得新构建的真实页面绘制结果,受控验证与现场复验分别保留。
跨区域判断新增 11 组生产 MV3 验证,覆盖来源前插、实际资源、同步新增、移除与异步出现、ARIA 角色变化、流式文字增长与清空、嵌套目录、连续字体切换、层与媒体条件、后台镜像及空闲停止。2026-10-30 重载扩展后,在真实 Edge 的 X 帖子页确认英文导航、中文正文、点赞数字均绘制为 Yozai-Regular。滚动后帖子从 24 个增至 48 个,检查的 48 处正文都以悠哉为首位候选,行内字体补丁为零;新回复另查实际绘制资源。原始诊断、现场复验与受控构建分别记录,受控页截图不作为 X 的现场截图。
缓存机制另有 8 组生产 MV3 检查,覆盖首次学习、刷新复用、相同 CSS 的结构变化、等数量规则的内容变化、同步新增、强制重学、深浅主题导出与空闲停止。缓存命中后的正文绘制为悠哉,局部艺术字仍为楷体,代码仍为 Ligconsolata。固化工具在隔离副本中实际写入同一份导出文件,另查旧版输入被拒绝且目标字节不变;正式内置表保持为空。同日重载后,在日常 Edge 的 X 页面核对到相同缓存构建指纹及 2 条本地来源方案;刷新后 24 处正文、滚动后的 47 处正文均以悠哉为首位,行内字体补丁为零,英文导航、中文正文和新回复另查到 Yozai-Regular。现场验证了保存与实际绘制,内部缓存命中计数未在这份日常会话读取,仍由隔离 MV3 检查覆盖。
批次耗时只代表记录中的同步脚本部分,不能推导所有站点的总渲染成本。macOS 和真实工具栏点击尚未在本轮自动化中验收。