做分享海报的页面里有个很眼熟的现象。样式里声明了字体,代码紧跟着往 canvas 上写字,导出来的图却是系统默认的字体。你在控制台把同一段绘制代码原样再跑一遍,字体又对了,中间没有报错,代码也一个字没改。我以前把它归成「字体还没下完,晚一点就好」,后来才想到一个没验过的问题:如果没有那第一次画,字体会自己下下来吗?
我写了个很小的测试页,把几种时机都跑了一遍。先交代两个词。@font-face 是 CSS 里的一条声明,告诉浏览器某个字体名对应哪个文件。fillText 是 canvas 里负责往画布上写字的方法。字体用的是开源的思源宋体(Noto Serif SC,SIL OFL 1.1 许可)裁出来的 3755 字子集,WOFF2 格式,1,046,216 字节。画的是 48px 的「秋季新品 全场八折」。font 字符串里字体名后面还跟着一个 sans-serif,自定义字体用不上的时候浏览器就拿它顶上。这叫回落,下面说的「默认字体」指的就是它。判断用没用上我不靠眼睛看。每次画完把整张画布的像素算成一个校验值,再和两张参照图比。一张是确定字体加载完之后画的,一张是直接用 sans-serif 画的。
js
const posterFont = '48px "SongSubset", sans-serif';
styleTag.textContent =
'@font-face{font-family:"SongSubset";src:url("fonts/song-3755.woff2")}';
paintOnce(posterFont); // 第 1 次:声明完马上画
setTimeout(() => paintOnce(posterFont), 50); // 第 2 次:什么都不等,只是晚 50 毫秒
setTimeout(() => console.table(performance.getEntriesByType('resource')
.filter(e => e.name.includes('fonts/'))), 2500); // 字体请求几时发出、几时收完
机器是一台 Apple M4 的 Mac,浏览器跑了 Chromium 149、Chrome 155、Firefox 151 和 WebKit 26.5。四个的结果一样。声明完马上画的那一次是默认字体。之后过 50 毫秒、200 毫秒、1000 毫秒、2500 毫秒各再画一次,中间不调任何等待的接口。从 50 毫秒那次起画出来的全是思源宋体。
光看这个结果还分不清字体是谁要来的,所以我去翻了资源计时。浏览器会给页面发出的每个请求记下开始和结束的时刻,上面代码最后两行打印的就是这份记录。Chromium 里字体请求是第 11 毫秒发出、第 35 毫秒收完的,和第一次画落在同一个时刻前后。第一次画没用上字体,但字体是在它前后才开始下载的。
时刻挨着还不能算数,开头那个问题得单独验。我做了个对照:写好 @font-face 以后什么都不画,干等 2 秒。这 2 秒里资源计时里一条字体请求都没有,四个浏览器各跑 3 次,12 次都是 0 条。到第 2 秒才第一次 fillText,画出来是默认字体,字体请求在这次画之后 2 到 32 毫秒内发出,再过 300 毫秒重画就是思源宋体了。不画,字体就不下。把第一次画推迟到页面的 load 事件里也一样,只要页面上没有别的元素用这个字体,那一次画出来照样是默认字体,再过 1500 毫秒画才对。
那 50 毫秒后就好了,算不算规律?这个数我一开始真当成了规律,其实不是。我在本机的服务里把字体的回应拖慢 800 毫秒再跑。立刻、50 毫秒、200 毫秒三次都是默认字体,到 1000 毫秒那次才换成思源宋体。前面 50 毫秒就对了,是因为本机上这个文件第 35 毫秒就已经收完。
我现在是这么记的。第一次画像是路过电梯顺手按了一下按钮,按完就自己走了楼梯。电梯确实是它叫来的,可它没等电梯到。画这个动作会「要」字体,不会「等」字体。
字体切片的时候这件事更隐蔽一些。切片是用 unicode-range 把同一个字体名拆成几条声明,每条只管一段字符,用到哪段才下哪个文件。我拆了两片。拉丁片 52,376 字节管 U+0020 到 007E,汉字片 986,556 字节管 U+4E00 到 9FFF。document.fonts.load() 是明确让浏览器去加载某个字体的接口。它返回一个 Promise,可以拿来等。我先只传 font 字符串去等,它返回了 1 个字体,这时到位的只有拉丁片。接着画中文,汉字是默认字体,中间那个空格用的却是拉丁片,整张图和两张参照都对不上。资源计时里汉字片的请求是在这次画中文的前后发出的,Chromium 里第 7 毫秒发出、第 19 毫秒收完。又是画把它要来的,这一笔同样没等。把要画的字作为第二个参数带上再等一次,返回的是 2 个字体,再画就是整行思源宋体了。

这张图看上下两行的字形。上面一行是只传 font 字符串去等之后画的,汉字是默认字体,笔画没有衬线。下面一行是带上文字去等之后画的,整行换成了宋体的衬线。
放到做图的产品里,对应的就是用户刚选完字体、字体还在路上就点了导出。我拿一个现成的工具看了它在这一步的表现。我用的是图映 ImgIng(imging.cn/)的在线做图,它是在电... 4.5 MB。我把韩文字体的回应故意拖慢,选中之后大约 0.2 秒就点了「导出当前页」。导出的文件是字体回应到了以后才写出来的。这张 PNG 和等字体到位再导出的那张逐字节相同,都是 39,765 字节,用系统字体导出的那张是 39,870 字节。我只在 Chromium 149 里看了这一个流程,只试了韩文这一种字体,它内部怎么写的我没看。

截图里画的是韩文「가을 신상품 세일」,右侧字体一栏选的是 Noto Sans 한국어。要看的是最底下那行状态栏,它写着正在按需下载并载入当前文字图层的字体,这时候画布上的预览还是系统字体,文字框宽 648。字体到位以后我再看,宽度变成了 677。
所以我自己的页面里不再指望「画过一次,晚点就好」。动笔之前先 await document.fonts.load(posterFont, 要画的那行字),返回了再画。量等待时间的那一轮一共画了 96 次,都是等它返回之后才画,画出来全是自定义字体。Firefox 有一处我没看懂。用 <link rel="preload"> 把字体预先下完再立刻画,画出来仍然是默认字体,过 1000 毫秒才对。另外三个浏览器没有这个现象。原因我没去查。手机浏览器、Windows、Safari 正式版和 Edge 这次都没测。
想在自己的页面上确认字体是不是被第一次画叫来的,画完之后到控制台执行 performance.getEntriesByType('resource'),找到字体那一行。再拿它的 startTime 和第一次 fillText 的时刻对一下。