1. 小程序里想用个自定义字体,为什么这么难
前阵子接了个品牌调性很强的项目,设计稿上标题用的是一款很有辨识度的手写体,正文是另一款几何无衬线体。设计稿看着漂亮,落到小程序里就是一场灾难——iOS 真机上一半文字还是系统默认字体,Android 上标题直接不显示,canvas 生成的分享海报更是全程宋体。折腾了差不多三天,才把从 .ttf 到实际渲染这条链路彻底走通。
这篇内容就是把这条链路上的坑一次性讲清楚。核心关键词其实就三个:小程序自定义字体、.ttf 字体文件处理、wx.loadFontFace。如果你正准备在小程序里用一款非系统字体,或者你已经在用但真机上偶发失效,那这篇基本能覆盖你 90% 的问题。读者最好懂一点小程序基础语法,但不需要懂字体工程——我会把子集化、格式转换这些平时听着玄乎的词,用能上手的方式讲一遍。
先说结论,让急着上手的同学有个方向:小程序里用自定义字体,不要试图把字体文件放进代码包,正确姿势是把字体处理后托管到 HTTPS 静态资源服务上,然后用wx.loadFontFace动态加载,加载成功后再渲染使用该字体的内容。中间涉及四件事:字体子集化瘦身、字体格式选择、托管服务的域名与响应头配置、加载时序控制。四件事任意一件出问题,表现都是"真机上字体没生效",而且报错信息极其吝啬,所以你只能靠排查法一个个排除。
1.1 三条技术路线的真实差距
在小程序里落地自定义字体,本质上就三条路,我把它们的成本、稳定性、适用场景列出来,你可以直接对照自己的需求选。
| 方案 | 实现方式 | 体积影响 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| wxss 内联 base64 | @font-face里写data:font/woff2;base64,... | 直接进 wxss,膨胀 33% | 中等,受 wxss 体积限制 | 十几个字符的图标字体 |
| wxss 引用远程地址 | @font-face里写 HTTPS 地址 | 不占包体 | 较差,机型差异明显 | 不推荐作为主方案 |
wx.loadFontFace动态加载 | JS 调用 API 加载后使用 | 不占包体,可控 | 好,官方推荐 | 绝大多数场景 |
第一条路,也就是把字体转成 base64 塞进 wxss,是很多教程里最省事的做法,但它有个硬性天花板:小程序主包体积本身就紧张,base64 会让体积再膨胀三分之一,一个 200KB 的 woff2 转完就是 270KB 左右的纯文本挂在样式文件里,编译速度肉眼可见地变慢。更要命的是,wxss 里一旦有超大段的 base64,开发者工具的热重载会变得非常迟钝,改一行样式等三秒钟。所以这条路我只推荐给图标字体——那种只有几十个字形的小文件,转完也就二三十 KB,一次性内联最省心。
第二条路,wxss 里直接写远程地址,理论上最符合前端习惯,但小程序里的@font-face行为并不完全等同于浏览器。不同基础库版本、不同机型内核对远程字体的处理策略不一样,有的版本会静默丢弃,有的版本会延迟到首屏渲染之后才应用,导致你看到明显的字体跳变。这条路我在两个项目里试过,最后都改成了动态加载。
第三条路wx.loadFontFace是官方给的正解。它的本质是让运行时去下载字体文件并注册到字体系统里,注册成功后用font-family名就能引用。它支持global参数把字体注册到全局,避免每个页面重复加载。缺点是需要自己管理加载时序和失败降级,但这个成本完全值得。
1.2 为什么 .ttf 不能塞进代码包
很多人第一反应是:我把字体文件放到项目的assets目录,然后 wxss 里src: url('./font.ttf')不就行了?不行。
小程序打包时,wxss 会被编译处理,其中的相对路径资源引用不在打包器的处理范围内,字体文件不会被正确提取和映射。你在开发者工具里可能碰巧看到它渲染出来了(工具模拟器对本地路径的容忍度更高),但一到真机就彻底失效。这个现象特别有迷惑性,因为它让你误以为方案是对的,只是"某台手机有问题"。
还有一个隐性原因:代码包里的资源要经过小程序的完整性校验和分发流程,字体这种大体积二进制文件会显著拖慢包体下载和首包冷启动。哪怕技术上能跑通,产品体验上也是亏的。
所以结论很明确:字体文件必须是外部资源,走 HTTPS 网络加载。这句话背后还有一层含义——你的字体文件必须有一个稳定、可访问、响应头正确的托管地址,这就引出了后面第三章的内容。
2. 字体文件瘦身:从几 MB 到几十 KB 的关键一步
一个完整的中文 .ttf 字体,动辄 5MB 到 15MB。这个体积直接上传、直接加载,在移动网络下基本等于劝退——首屏多等三秒,用户已经走了。所以子集化不是"优化项",是"必做项"。
我实测过一款中等完整度的中文字体,原始 ttf 是 8.6MB,转成 woff2 后约 5.2MB,做了字符子集化之后只剩 41KB。差了 200 倍。这个量级差异意味着,子集化做得好,字体加载几乎无感;不做,就等于给首屏挂了一个巨型资源。
2.1 子集化到底在做什么
用生活化的说法:一个字体文件就像一个超大的字库字典,里面存了几万个字的笔画数据。但你的小程序页面上,标题可能就 8 个字,按钮文案 4 个字,加起来几十个字符。子集化就是拿一把剪刀,从这本大字典里只剪下你实际用到的那几十页,重新装订成一本薄册子。
技术上,子集化工具会解析字体的 cmap 表(字符到字形的映射表),根据你给的字符集筛出需要的字形,然后重建一套只包含这些字形的字体文件。因为字体内部有大量共享的轮廓数据、hinting 指令、GPOS/GSUB 排版表,单纯删除字形并不能线性减小体积,所以还需要配合布局特性的裁剪。
这里有个容易忽略的点:中文字体的体积大头不在字形轮廓,而在各种排版表和 hinting 数据。所以你在命令里加不加--no-hinting、--desubroutinize、--layout-features这些参数,对最终体积的影响可能比你想的大得多。我第一次做的时候只加了字符集限制,出来的文件还有 800 多 KB,加上这几个参数之后直接掉到 40KB 出头。
2.2 用 pyftsubset 走一遍完整命令
工具就用 fonttools 生态里的pyftsubset,跨平台,命令行友好,不需要图形界面。
# 安装,brotli 是 woff2 压缩必需的依赖,漏装会报错 pip install fonttools brotli # 把页面里所有用到的字符导出成一个文本文件 # 实际操作中,我是从设计稿文案 + 常量配置文件里手动汇总的 # 一行一个字符或者一整段都行,工具会自己去重 cat > chars.txt <<'EOF' 新品上市限时特惠立即抢购 会员专享折扣券已到账 EOF # 执行子集化 pyftsubset brand-source.ttf \ --text-file=chars.txt \ --flavor=woff2 \ --layout-features='' \ --no-hinting \ --desubroutinize \ --drop-tables+=DSIG \ --output-file=brand-subset.woff2 # 看结果 ls -lh brand-subset.woff2参数逐个解释一下,这些是我踩过坑之后才搞明白的:
--text-file给的是字符集来源,也可以直接用--text="新品上市"内联,或者用--unicodes="U+0020-007E"按码位区间取。做英文数字为主的场景,码位区间最省事。--flavor=woff2直接输出 woff2 格式,省掉额外的转换步骤。woff2 用的是 Brotli 压缩,比 woff 的 zlib 压缩率更高,移动端解码开销也更低。--layout-features=''清空排版特性。如果你不需要连字、字距调整这些高级排版能力,清空能省下可观体积。但要注意:如果你的字体是手写体或者艺术字,字形之间依赖 GSUB 做上下文替换,清空之后可能出现字形错乱,这种情况要保留--layout-features='*'并实测。--no-hinting去掉微调指令。在小程序这种高 DPI 屏幕上,hinting 的作用本来就有限,去掉基本无损。--desubroutinize展开子程序。这个参数对 CFF 轮廓(.otf 系)的字体效果明显,能提升部分渲染引擎的兼容性。
注意:子集化之后的字体只能渲染字符集里的字。超出范围的字符会回退到系统字体,或者渲染成空白方块。这个特性后面会单独讲,它是动态文本场景最大的坑。
2.3 动态文本场景下的子集化翻车与补救
上面那套流程对静态文案完美适用。但只要你的页面里有用户生成内容——昵称、评论、地址、订单备注——子集化就立刻变成陷阱。
我遇到过的真实情况:活动页标题用了子集化字体,静态文案全部正常。上线第二天运营反馈"用户昵称里带生僻字的地方显示成方框"。原因很直白,子集化字体里根本没有那个字的字形,浏览器回退到后备字体,而后备字体也没有,就成了豆腐块。
解决思路有三条,按推荐度排序:
第一条,分区使用。把自定义字体严格限制在"我们自己写的文案"上,比如标题、按钮、标签。凡是用户输入的内容,一律用系统字体。这条最简单也最稳,代价是视觉上略有割裂,但通过字号、字重、颜色的设计可以弱化。
第二条,扩大字符集覆盖。用--unicodes="U+0020-007E,U+4E00-9FA5"把常用汉字区全部包含进去。代价是体积从 40KB 涨到 2MB 以上,这就失去了子集化的意义。折中方案是取 GB2312 一级字库(约 3755 字),体积大概在 300-600KB,勉强可接受,但仍需配合加载时序优化。
第三条,双字体方案。准备一个极小的子集字体用于品牌文案,同时给用户内容区声明完整的字体回退链。CSS 里这样写:
.brand-title { font-family: 'BrandFont', 'PingFang SC', sans-serif; } .user-content { font-family: -apple-system, 'PingFang SC', sans-serif; }回退链的意义在于:即使 BrandFont 没加载成功、或者某个字符在 BrandFont 里缺失,渲染引擎会顺着链往下找,而不是直接摆烂。这条链必须写,它是最便宜的一层保险。
3. 上传与托管:那些在上线后才暴露的坑
字体处理完了,接下来是托管。这一步看起来最没有技术含量——不就是传到静态资源服务器上拿个链接吗?但我在这一环上浪费的时间,比写代码多得多。
3.1 域名白名单、协议与 Content-Type
第一个坑:域名白名单。wx.loadFontFace内部走的是网络下载能力,所以字体所在的域名必须配置在小程序后台的 downloadFile 合法域名列表里。开发阶段你可能勾了"不校验合法域名",一切正常;提审上线后,真机上一加载就 fail,日志里只有一句含糊的错误信息。
配置时注意三点:必须是 HTTPS,必须备案,必须和实际请求的域名完全一致(包括子域名,cdn.example.com和example.com是两个域名)。我见过有人把字体放在static.example.com,白名单里配了example.com,然后死活加载不出来。
第二个坑:Content-Type。绝大多数对象存储服务对.ttf的默认 MIME 类型是application/octet-stream。这个类型在大多数情况下也能用,因为加载器最后是靠文件内容而不是头部来判断格式的。但在少数机型上,尤其是较老的内核,MIME 类型不匹配会导致解析失败。稳妥的做法是在上传时把元数据设对:
| 文件后缀 | 推荐 Content-Type |
|---|---|
| .woff2 | font/woff2 |
| .woff | font/woff |
| .ttf | font/ttf |
| .otf | font/otf |
用命令行上传的话,以常见的对象存储 CLI 为例,思路是在上传时附加 content-type 元信息,具体命令各家不同,但核心就是别用默认值。如果服务商支持控制台批量修改元数据,上传完统一刷一遍更省事。
第三个坑:缓存策略。字体文件是典型的"一次上传、长期不变"的资源,所以缓存时间可以设得很长,一年都没问题。但如果你用文件名做版本管理(强烈建议),每次更新字体就换一个新文件名,比如brand-20240518.woff2,这样既能长缓存又不会读到旧版本。用同名覆盖的同学,会在灰度期间遇到"部分用户是新字体、部分用户是旧字体"的诡异现象,排查起来非常痛苦。
3.2 私有读链接过期与 CORS
如果你把字体放在私有读的存储桶里,拿到的会是带签名的临时链接,通常几小时就过期。这种链接绝对不能硬编码进代码里。表现就是:今天测试全好,明天早上打开全崩。
正确做法是把字体放在公开读的存储桶或者 CDN 上。字体文件本身不包含敏感信息,公开读没有安全顾虑。如果出于管理规范必须私有读,那就得在服务端做一个转发接口,小程序请求自己的接口拿到字体内容,再转成 base64 交给loadFontFace。这条链路成本高、性能差,除非有硬性要求,否则不建议。
CORS 这块,wx.loadFontFace走的是原生下载通道,一般不受浏览器同源策略约束。但如果你同时在 wxss 里用了@font-face引用远程地址,那就会受影响。稳妥起见,CDN 上统一加一条响应头:
Access-Control-Allow-Origin: *反正字体是公开资源,通配没有风险,省得后面为了一个跨域问题反复折腾。
4. loadFontFace 的落地写法
前面都是准备工作,真正干活的是这个 API。它的签名不复杂,但参数含义和调用时机上有不少细节。
4.1 最小可用调用与参数含义
wx.loadFontFace({ family: 'BrandFont', source: 'url("https://cdn.example.com/font/brand-20240518.woff2")', desc: { style: 'normal', weight: 'normal' }, global: true, scopes: ['webview', 'native'], success(res) { console.log('字体加载成功', res.status) }, fail(err) { console.error('字体加载失败', err) } })几个关键点,逐条说:
family是你自己起的名字,之后 CSS 里font-family就用这个名字。建议用纯英文、无空格、无引号的驼峰命名,比如BrandFont。我试过用中文名和带空格的名字,在部分 Android 机型上会被解析成两个字体名,导致匹配失败。这个坑很难查,因为代码看起来毫无问题。
source必须是url(...)包裹的形式,里面的地址用双引号括起来。官方示例就是这样写的,我实测过直接裸写 URL,在部分基础库版本上会 fail。别图省事。
desc描述的是字体的样式属性。如果你只有一款常规体,写normal/normal就行。如果你要同时加载粗体和常规体两个文件,那必须用不同的 family 名,因为同一 family 下的不同 weight 在小程序里支持得并不完整,很容易出现"只有一个生效"的情况。稳妥做法是BrandFont和BrandFontBold分开加载,CSS 里分别引用。
global: true让字体注册到全局,所有页面都能用。不加的话只在当前页面生效,页面切换后就要重新加载。这个参数需要基础库 2.3.0 以上,现在基本没有兼容压力。
scopes是控制字体作用范围的关键。webview表示普通视图层,native表示原生渲染组件(canvas、部分原生组件)。如果你要在 canvas 里用自定义字体画海报,这一项必须带native,否则 canvas 里永远是系统字体,而且不会有任何报错提示。这个坑我卡了整整一个下午。
4.2 加载时序:渲染必须等字体就绪
这是最容易出视觉问题的地方。loadFontFace是异步的,如果你在onLoad里调用它,同时页面数据已经开始渲染,用户会先看到系统字体的内容,几百毫秒后字体加载完成,文字整体"跳"一下变成新字体。这个现象在前端叫 FOUT(Flash of Unstyled Text),视觉上非常廉价。
处理办法是把渲染推迟到字体就绪之后。最直接的方式是用一个状态位控制:
Page({ data: { fontReady: false, bannerText: '新品上市 限时特惠' }, onLoad() { this.initFont() }, async initFont() { try { await loadFont({ family: 'BrandFont', url: 'https://cdn.example.com/font/brand-20240518.woff2' }) } catch (e) { console.warn('字体加载失败,降级系统字体', e) } // 无论成功失败都放行,避免白屏 this.setData({ fontReady: true }) } })视图层用wx:if或者透明度过渡来控制:
<view class="banner {{fontReady ? 'is-ready' : ''}}"> <text class="brand-title">{{bannerText}}</text> </view>.banner { opacity: 0; transition: opacity 0.2s ease; } .banner.is-ready { opacity: 1; }这里有个设计取舍:如果字体加载失败,你是让它一直白屏,还是降级显示系统字体?我的做法是永远降级放行。宁可字体不对,也不能让用户看到空白页。品牌方可能会不满意,但可用性优先于视觉一致性,这是基本的产品判断。
还有一种更精细的做法:加一个超时兜底。字体加载超过 800ms 就直接放行渲染,别让用户干等。
function loadFontWithTimeout(options, timeout = 800) { return Promise.race([ loadFont(options), new Promise((_, reject) => setTimeout(() => reject(new Error('font timeout')), timeout) ) ]) }4.3 封装一个可复用的字体加载器
同一款字体在多个页面用到,如果每个页面都调一次loadFontFace,就是重复下载和重复注册。我封装了一个带缓存的加载器,核心思路是用 family 名做 key 缓存 Promise,这样并发调用也只会触发一次真实加载。
// utils/font-loader.js const cache = Object.create(null) const CDN = 'https://cdn.example.com/font/' const FONT_MAP = { BrandFont: `${CDN}brand-20240518.woff2`, BrandFontBold: `${CDN}brand-bold-20240518.woff2` } function loadFont(family) { if (cache[family]) { return cache[family] } const url = FONT_MAP[family] if (!url) { return Promise.reject(new Error(`未注册的字体: ${family}`)) } cache[family] = new Promise((resolve, reject) => { wx.loadFontFace({ family, source: `url("${url}")`, desc: { style: 'normal', weight: 'normal' }, global: true, scopes: ['webview', 'native'], success: resolve, fail: err => { // 失败时清掉缓存,允许后续重试 delete cache[family] reject(err) } }) }) return cache[family] } function loadFonts(list) { return Promise.all(list.map(family => loadFont(family).catch(() => null))) } module.exports = { loadFont, loadFonts }fail里删缓存这个细节很重要。如果不删,一次网络抖动导致的失败会被永久缓存,后面所有页面都拿不到字体,只能重启小程序才能恢复。
loadFonts里对每个字体加了.catch(() => null),是为了让多个字体里某一个失败时,不影响其他的。比如粗体挂了,常规体还能正常用。
4.4 canvas 里画自定义字体
分享海报是小程序里最常见的需求,canvas 里用自定义字体又是重灾区。核心要点三个:scopes带native、字体加载完成后再设置ctx.font、字体名要和注册时完全一致。
async function drawPoster() { await loadFont('BrandFont') const query = wx.createSelectorQuery() query.select('#poster').fields({ node: true, size: true }).exec(res => { const canvas = res[0].node const ctx = canvas.getContext('2d') const dpr = wx.getWindowInfo().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, res[0].width, res[0].height) ctx.fillStyle = '#1a1a1a' ctx.font = 'bold 28px BrandFont' ctx.textBaseline = 'top' ctx.fillText('专属邀请函', 24, 32) }) }两个额外提醒:
第一,ctx.font的字符串格式必须严格遵守[style] [weight] [size]px [family]的顺序。写28px bold BrandFont是无效的,浏览器和 canvas 都不认,会静默回退到默认字体。这个语法错误特别隐蔽,因为不报错,只是"没效果"。
第二,如果你用 canvas 画长文本需要换行,得自己算宽度,ctx.measureText在字体加载完成后才能量到准确值。如果字体没加载完就去量,量到的是系统字体的宽度,等你真正绘制时用自定义字体,文字就会超出容器。所以顺序必须是:先加载字体,再测量,再绘制。
5. 真机排查实录:模拟器好好的,手机上全变样
这一章是我这三天里最有价值的部分。所有问题在开发者工具里都是正常的,一到真机就出各种幺蛾子。我把遇到的、以及后来跟同行交流收集到的情况整理成对照表。
5.1 iOS 与 Android 的差异对照
| 现象 | iOS 表现 | Android 表现 | 主要原因 |
|---|---|---|---|
| 字体完全不生效 | 偶发 | 较常见 | 域名未配置白名单,或字体加载失败被静默忽略 |
| 首屏字体跳变 | 明显 | 更明显 | 渲染早于字体加载完成 |
| 粗体加载不出来 | 常见 | 偶发 | 同一 family 多 weight 支持不完整 |
| canvas 字体不生效 | 常见 | 常见 | scopes未包含native |
| 文字被裁切 | 偶发 | 较常见 | 自定义字体行高与系统字体不同,容器高度写死了 |
| 生僻字显示方框 | 常见 | 常见 | 子集化字符集未覆盖 |
| 字体加载慢 | 少见 | 较常见 | Android 低端机解析 woff2 耗时较长 |
最后一条要展开说一下。在低端 Android 机上,woff2 的解码开销比 ttf 大。如果你的字体子集化后还有几百 KB,在千元机上可能出现 300-500ms 的加载延迟。这种情况我建议同时提供 ttf 和 woff2 两份,用基础库版本或者系统信息做简单判断,虽然麻烦,但对低端机体验改善明显。不过更多时候,把字体压到 100KB 以内是更划算的做法。
还有一个特别隐蔽的问题:自定义字体的行高和系统字体不一致。你的容器是按系统字体调的,换成自定义字体后文字上下被裁。这种情况不要写死高度,用line-height配合padding留出余量,或者干脆给容器加min-height。
5.2 高频问题速查表
| 问题描述 | 排查顺序 | 解决方式 |
|---|---|---|
| 真机字体不生效,工具正常 | 1. 看 fail 回调 2. 查域名白名单 3. 检查source格式 | 补白名单,url("...")包裹 |
| 字体加载成功但页面没用上 | 1.font-family拼写 2. 是否被其他样式覆盖 3. 是否在 scoped 样式中 | 统一用变量管理字体名 |
| 偶发加载失败 | 1. CDN 是否稳定 2. 链接是否过期 3. 是否命中限流 | 换公开读链接,加失败重试 |
| 只有部分文字变了字体 | 1. 子集化字符集 2. 兜底字体链 | 扩大字符集或分区域使用 |
| canvas 里始终是系统字体 | 1.scopes是否含native2.ctx.font语法 | 补native,检查语法顺序 |
| 首屏闪烁 | 1. 渲染时序 | 字体就绪后再 setData |
| 分享图文字错位 | 1. 测量时机 2. dpr 缩放 | 先加载再 measure |
排查这件事,我的经验是永远从 fail 回调开始。很多人只在 success 里打日志,fail 里什么都不写,结果就是"加载没成功但不知道为什么"。把 fail 的错误对象完整打出来,里面通常有errMsg,能直接指向是网络问题、格式问题还是域名问题。
再分享一个调试技巧:在真机上打开调试模式,然后在loadFontFace前后打时间戳,看看实际耗时。如果超过 1 秒,那大概率是字体太大或者 CDN 节点太远,这时候优化体积或者换节点比改代码有效得多。
6. 工程化收尾与几个替代方案
单个页面跑通之后,剩下的是工程化问题。字体这种东西,一旦项目里有超过三个页面在用,就必须统一管理,否则后面改一个字体文件名要改十几个地方。
6.1 缓存、降级与首屏体验
关于缓存,要说清楚一个事实:loadFontFace加载的字体只在本次运行期间有效,小程序冷启动后会重新下载。这意味着每次冷启动都有一次网络请求。如果你的字体在 100KB 以内,配合 CDN 的长缓存,实际耗时通常在 50-150ms,用户基本无感。但如果字体很大,冷启动时的等待就会变成实打实的体验损耗。
进阶做法是把字体文件先下载到本地临时目录,再以 base64 的方式交给loadFontFace。这样理论上配合本地存储可以做持久缓存:
async function loadFontWithLocalCache(family, url) { const fs = wx.getFileSystemManager() const cachePath = `${wx.env.USER_DATA_PATH}/${family}.woff2` try { fs.accessSync(cachePath) } catch (e) { // 本地没有,下载一次 const { tempFilePath } = await new Promise((resolve, reject) => { wx.downloadFile({ url, success: resolve, fail: reject }) }) fs.copyFileSync(tempFilePath, cachePath) } const base64 = fs.readFileSync(cachePath, 'base64') return new Promise((resolve, reject) => { wx.loadFontFace({ family, source: `url("data:font/woff2;base64,${base64}")`, global: true, scopes: ['webview', 'native'], success: resolve, fail: reject }) }) }这条路能用,但要注意两点:一是 base64 编码会让内存占用膨胀约三分之一,字体太大(比如超过 1MB)时在低端机上有内存压力;二是用户数据目录有容量上限,不要无节制地往里塞。我的实践是,只对小于 300KB 的字体用这套缓存策略,大字体老老实实走网络。
降级这块,除了前面说的字体回退链,还要考虑加载失败的视觉兜底。如果自定义字体是有强烈品牌特征的(比如手写体),降级到系统字体后整个设计会垮掉,这时候可能需要换用图片方案。这个判断要在设计阶段就做,别等上线了才发现。
6.2 什么时候干脆别用字体
说了这么多技术细节,其实最该说在前面的是:有些场景压根不该用自定义字体。
第一种,只有寥寥几个字的品牌标语。这种完全可以用图片或者 SVG 实现,一张 3 倍图也就二三十 KB,还不用担心加载时序、机型差异、字符集缺失。图片的唯一缺点是改文案要重新出图,但品牌标语本来就不常改。
第二种,字体只在某一个低频页面出现。用户半年才进来一次,为它单独加载一个字体文件,性价比极低。这种页面用系统字体加设计排版完全够看。
第三种,图标类需求。很多人想用图标字体替代图片,觉得省事。实际上现在更推荐直接用 SVG 或者小尺寸 PNG,图标字体在字体加载失败时会显示成一堆方块或者乱码字符,视觉灾难程度比图片加载失败严重得多。
真正值得用自定义字体的场景,我的判断标准是:字体是核心视觉语言的一部分,且在多处高频出现。比如一个内容社区类应用的正文阅读字体,一个电商活动页的品牌标题字体。这类场景用户停留时间长、字体出现频次高,值得为它做完整的加载优化。
字体版权这块也提一句,虽然不属于技术范畴,但踩了就是大事故。商用字体需要授权,网上下载的免费字体很多只允许个人使用。稳妥做法是选 OFL 或类似开源协议授权的字体,这类协议的字体可以免费商用、可以修改和嵌入,改字形做子集化也不违规。用之前花两分钟看一眼授权文件,比事后处理纠纷便宜得多。
最后分享一个我在实际项目里的体会:字体这件事,把体积压到极致,比任何加载优化技巧都管用。我做过一个对比,同样一款字体,原始 5MB 走完整体积优化后 40KB,加载时间从平均 1.2 秒降到 80 毫秒,而且失败率从千分之三降到几乎为零——因为体积小了,弱网环境下也能很快下完。所以如果你时间有限,优先把子集化做到位,这比研究缓存策略、研究降级方案都更立竿见影。