上周一个做校园跑腿小程序的朋友找我,说设计稿上那个圆润的手写体标题,在微信小程序里怎么都还原不出来,font-family写了跟没写一样,最后只能截图当图片用。这个场景我太熟了——微信小程序导入外部字体看着是个小需求,但真动手做,从字体格式、跨域、基础库版本到真机差异,处处都是暗坑。小程序不像 H5 可以直接挂一个@font-face就完事,它有一套自己的字体加载 API(wx.loadFontFace),还有一堆平台限制。这篇就按我实际项目里跑通过的流程,从方案选型、字体文件处理、代码落地,到真机排查,一条条拆给你看。不管你是做校园跑腿、婚礼邀请函,还是品牌电商类的小程序,只要涉及自定义字体,这套流程都能直接抄。如果你只是刚接触小程序、连app.json都还没摸熟,也别急着划走,我会把每一步为什么这么做讲清楚。
1. 小程序字体为什么这么难搞:需求背景与方案选型
1.1 三个真实场景逼着你必须换字体
先说清楚什么时候你真的需要导入外部字体,而不是瞎折腾。第一种是品牌类需求,电商、餐饮、活动页,设计稿里那个字就是甲方花钱买的品牌字,用系统默认的黑体替代,整个页面的调性直接掉一个档次,视觉还原度上不去,验收就过不了。第二种是内容型需求,比如婚礼邀请函小程序、手写风格的心情记录本、儿童教育类的识字卡片,这类场景对手写体、圆体、毛笔字体的依赖度极高,字体本身就是产品体验的一部分。第三种是 Canvas 海报绘制,做分享海报的时候,ctx.font如果用不了自定义字体,用户辛辛苦苦填的名字只能用系统字体渲染,导出图片的质感很差。
除了这三种,还有一种是被动触发的场景:你在用 uni-app 或者 HBuilderX 打包发布小程序时,发现 H5 端字体好好的,小程序端就是不出来,这时候你才意识到两端渲染机制根本不一样。这个坑我在用 uni-app 打包微信小程序的项目里踩过不止一次,后面会单独讲。
1.2 四种可行方案的横向对比
小程序里让自定义字体生效,说起来能落地的方案就四种,各有各的适用边界,我先把对比摊开,你对着自己的场景挑。
| 方案 | 实现方式 | 体积影响 | 生效范围 | 适用场景 | 主要缺点 |
|---|---|---|---|---|---|
wx.loadFontFace网络地址 | source 传 HTTPS 字体链接 | 主包零体积 | 页面级或全局 | 中大型字体、多页面复用 | 需配 CORS、需域名白名单、首屏有延迟 |
wx.loadFontFace+ base64 | source 传 base64 字符串 | 主包急剧膨胀 | 页面级或全局 | 体积很小的子集字体 | base64 膨胀约 33%,大字体直接卡死 |
| 图片替代文字 | 设计稿导出 PNG/SVG | 图片资源体积 | 局部 | 固定标题、固定文案 | 不可动态改文案、不可搜索、SEO 为零 |
| Canvas 自绘文字 | 加载字体后 ctx.fillText | 同网络方案 | Canvas 内部 | 分享海报、二维码图 | 样式调试成本高、不适合正文 |
这里我必须提醒一句:方案三(图片替代)看着最省事,但它是所有方案里技术债最高的。文案一改就得重新找设计切图,多语言版本直接爆炸,而且小程序审核对全图页面是敏感的,纯图片拼出来的页面容易被判定为体验不佳。短期救急可以,长期项目别走这条路。
1.3 我在项目里的选型结论
综合下来,我的默认选择是:wx.loadFontFace+ 网络地址 + 子集化字体 + 全局生效。理由很直接。子集化能把一个十几兆的中文字体压到一两百 KB 甚至几十 KB,网络加载的耗时基本可以忽略;全局生效只需要在app.js里加载一次,所有页面都能用,不用每个页面重复写;网络地址方案不占主包体积,主包大小对小程序启动速度的影响是实打实的,官方对主包体积有硬性限制,能省则省。
只有一种情况我会改用 base64:字体子集极小(比如只渲染固定的几个字)并且这个页面是核心首屏,不能接受任何网络抖动。这种情况下 base64 内嵌到 JS 里,一次加载永久可用,代价是包体积增加。
注意:不管选哪种方案,商用字体一定先确认授权范围。很多品牌字体只授权了设计稿使用、没有授权线上嵌入,小程序属于线上分发场景,授权没谈好是要出问题的。开源字体(思源系列、阿里巴巴普惠体、站酷系列等)可以放心用,但也要留意各自的许可条款。
2. 核心原理拆解:wx.loadFontFace 到底干了什么
2.1 字体加载的完整链路
很多人调用wx.loadFontFace失败后就开始瞎试,其实把这个 API 的执行链路捋清楚,大部分问题自己就能定位。这个 API 做的事情分三步:第一步,小程序运行时根据你传入的source,发起一次资源请求(网络地址就走小程序网络层,base64 就直接解析);第二步,解析字体文件的二进制内容,把字体注册到当前小程序的渲染引擎里,注册的键名就是你传的family;第三步,触发一次重新布局,让页面上声明了对应font-family的节点用新字体重绘。
这个链路有两个关键含义。一是它是异步的,loadFontFace的 success 回调没回来之前,页面上用的还是系统默认字体。所以如果你在加载完成的瞬间就截图、就导海报,字体大概率还没生效。二是它是运行时注册,不是编译期注入,所以字体文件本身必须能被正确解析,格式错了、文件损坏了、服务器返回了 HTML 错误页而不是字体二进制,都会直接走 fail 回调。
2.2 为什么必须 HTTPS 且要配 CORS
小程序的所有网络请求都强制 HTTPS,字体地址也不例外。但比 HTTPS 更容易翻车的是CORS。字体文件的加载走的是浏览器的字体加载机制,浏览器(或者说小程序的 WebView 内核)对字体资源执行同源策略检查,服务器必须返回Access-Control-Allow-Origin响应头,否则字体请求会被拦掉。
这里有个特别阴的点:开发者工具里可能不报错,真机上一片默认字体。原因是开发者工具的网络层校验比真机宽松,很多人本地调通了就以为完事了,一上真机直接懵。所以我的习惯是,字体这块一定在真机上验证,别信模拟器。
2.3 format 与 global 两个参数的真实含义
wx.loadFontFace的参数不多,但有两个常年被误解。
source的值必须是url("...")这种带url()包裹的格式,或者url("data:font/ttf;charset=utf-8;base64,....")这种 base64 格式。直接丢一个裸链接进去是不行的,这个格式要求官方文档写得不算显眼,很多人第一次就栽在这。
global参数决定字体的生效范围,默认false表示只在当前页面生效,改成true就是全小程序生效。这个参数要求基础库 2.10.0 及以上。如果你的app.json里 lowest 基础库版本设得比较低,global可能不生效,得先确认基础库配置。全局生效的好处是只在app.js里加载一次,坏处是加载时机偏早,如果字体文件大,会跟首页渲染抢资源,这个后面性能那节细说。
desc参数用来声明字体的样式特征,包括style(normal / italic)、weight(normal / bold)、variant、stretch。如果你的字体是粗体版本,一定要在这里声明 weight 为 bold,否则在某些机型上,系统会认为这个字体不匹配粗体请求,然后回退到默认字体,表现就是"我明明加载了粗体字体,页面上却是细的"。这个坑我在一个活动页项目里排查了大半天。
3. 字体文件准备:从下载到子集化压缩
3.1 拿到字体文件后的第一件事:授权确认
字体文件从哪来?开源字体一般能在官方仓库或者发布页直接下载到 TTF / OTF / WOFF2 格式;品牌字体通常由设计团队提供,可能是 OTF 或 TTF。拿到文件先做两件事:确认授权覆盖线上嵌入场景,确认字体名称(用字体查看工具看内部 family name,这个名称和你文件名可能不一样)。
3.2 用 fonttools 做子集化,这一步能省 90% 的体积
中文字体动辄十几兆,直接丢上去加载,用户等着转圈吧。子集化就是只保留你实际用到的字符,把其余字形全部裁掉。工具首选fonttools,Python 生态里最成熟的一个。
# 安装,brotli 是导出 woff2 格式必需的 pip install fonttools brotli # 方式一:直接指定字符集 pyftsubset SourceHanSansSC-Regular.otf \ --text="限时秒杀新人专享立即抢购" \ --output-file=subset.ttf \ --flavor=woff2 # 方式二:从文件读取字符集(推荐,方便维护) pyftsubset SourceHanSansSC-Regular.otf \ --text-file=chars.txt \ --output-file=subset.woff2 \ --flavor=woff2 \ --layout-features='*' \ --no-hinting几个参数值得解释。--flavor=woff2表示输出 woff2 格式,压缩率最高;--layout-features='*'保留 OpenType 的布局特性,你要用到连字、替代字形就得带上;--no-hinting会去掉字体微调指令,能再省一点体积,但在小字号下的显示锐度会略微下降。中文字体在这个维度上损失不大,我一般会带上。
chars.txt怎么来?最省事的做法是从项目里扫一遍固定的 UI 文案,把用到的字都丢进去;如果是动态内容(比如用户昵称),那就没法完全子集化,得保留常用的几千个汉字。我的经验是:固定文案的营销页、活动页,用精确子集,几十 KB 搞定;涉及用户输入的通用页面,用常用字集,控制在 1MB 以内。
3.3 格式选择与体积量级参考
| 字体格式 | 兼容性 | 体积量级(中文全量) | 我的建议 |
|---|---|---|---|
| OTF | 一般 | 10-20MB | 不推荐直接上线,先转换 |
| TTF | 好 | 8-15MB | 兼容性最稳,兜底首选 |
| WOFF | 较好 | 5-10MB | 可用,压缩一般 |
| WOFF2 | 较好但旧机型有风险 | 2-5MB | 体积最优,需真机验证 |
关于 WOFF2 我要多说一句。它的压缩率确实好,但部分老安卓机型的 WebView 内核对它的支持不一致,出现过"iOS 正常、某几款安卓机加载失败"的情况。所以我的做法是:主用 WOFF2,同时准备一份 TTF 作为 fail 回调里的降级资源,两边都试,哪边成功用哪边。多留一份文件成本很低,但能挡住线上事故。
子集化后的体积感受一下:一个全量思源黑体 OTF 大概十几兆,裁到三千五百常用字之后能压到 1.5MB 上下,转成 WOFF2 再压到 500-700KB;如果只是十几个固定文案的字,能压到 30-60KB。这个量级的字体走网络加载,基本不影响首屏。
4. 实操全流程:从零接入自定义字体
4.1 字体托管与跨域配置
字体文件托管我一般放对象存储或者自有服务器,关键是三件事:HTTPS、CORS、缓存头。用 Nginx 的话配置大概长这样。
location ~* \.(ttf|otf|woff|woff2)$ { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods 'GET, OPTIONS'; add_header Access-Control-Allow-Headers '*'; add_header Cache-Control "public, max-age=31536000, immutable"; types { font/ttf ttf; font/otf otf; font/woff woff; font/woff2 woff2; } }缓存头我给了immutable一年,因为字体文件内容基本不变。但这里有个隐藏坑:如果你改了子集内容重新上传,文件名却没变,用户端的强缓存会让他继续用旧字体。解决办法是文件名带上版本号或内容哈希,比如brand-font-v3.woff2,每次更新换文件名。这个习惯我从做静态资源那会儿就养成了,能省掉无数次"我明明更新了为什么没生效"。
另外别忘了在小程序后台把这个域名加到downloadFile 合法域名列表里。loadFontFace的网络请求走的是这个白名单,不在列表里,真机直接失败,开发者工具里可能因为勾了"不校验合法域名"而蒙混过关。
4.2 app.js 里做全局字体加载
全局生效的写法,我建议封装一下,别裸调。
// utils/fontLoader.js const FONT_CONFIG = { family: 'BrandFont', sources: [ 'https://static.example.com/fonts/brand-font-v3.woff2', 'https://static.example.com/fonts/brand-font-v3.ttf' ] } function loadFont(source) { return new Promise((resolve, reject) => { wx.loadFontFace({ family: FONT_CONFIG.family, source: `url("${source}")`, desc: { style: 'normal', weight: 'normal', variant: 'normal', stretch: 'normal' }, global: true, scopes: ['webview', 'native'], success: res => resolve({ ok: true, status: res.status }), fail: err => reject(err) }) }) } async function loadBrandFont() { for (const src of FONT_CONFIG.sources) { try { const res = await loadFont(src) console.log('font loaded:', src, res.status) return true } catch (e) { console.warn('font failed, try next:', src, e) } } console.warn('all font sources failed, fallback to system font') return false } module.exports = { loadBrandFont, FONT_FAMILY: FONT_CONFIG.family }然后在app.js的onLaunch里调用。注意不要 await 阻塞启动,让它异步跑就行,字体是渐进增强。
const { loadBrandFont } = require('./utils/fontLoader') App({ onLaunch() { loadBrandFont() } })scopes参数也顺带说一下,它控制字体注册到哪个渲染层,webview是普通 WebView 渲染层,native是原生渲染层。有些场景(比如设置了renderer为 skyline 的页面)需要两个都声明,否则字体只在一部分节点上生效。
4.3 页面级与组件级字体接入
WXSS 里声明时,font-family一定要带上完整的降级栈,别只写一个自定义字体名。
.brand-title { font-family: 'BrandFont', -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif; font-weight: 400; }降级栈的作用是:字体没加载完或者加载失败时,页面不至于丑得没法看。这个细节看着不起眼,但真出了网络问题,它决定了你的页面是"字体不太对"还是"排版全乱"。
WXML 里,<text>组件的style绑定的字体同样受用:
<text class="brand-title" style="font-family: 'BrandFont', sans-serif;">限时秒杀</text>这里有个必须知道的限制:input、textarea这类表单组件是原生组件,自定义字体在它们上面不生效。你没法给输入框换字体,这是平台机制决定的,别在这上面浪费时间。如果设计稿真的要求输入框用手写体,只能退而求其次用自定义的遮罩层模拟输入,成本很高,一般我都会跟设计沟通改成系统字体。
4.4 Canvas 与分享海报里的字体处理
Canvas 是字体需求的重灾区,因为分享海报的文字往往是产品的门面。写法和页面不一样,分两步。
const { loadBrandFont } = require('../../utils/fontLoader') Page({ data: { posterUrl: '' }, async onLoad() { await loadBrandFont() // 关键:必须等字体加载完 this.drawPoster() }, drawPoster() { 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.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) // 字体已注册,这里可以直接用 family 名 ctx.font = 'bold 36px BrandFont' ctx.fillStyle = '#222' ctx.fillText('王小明', 40, 120) wx.canvasToTempFilePath({ canvas, success: r => this.setData({ posterUrl: r.tempFilePath }) }) }) } })这里的关键点有三个。一是一定要 await 字体加载完成再绘制,否则ctx.font里指定的字体不生效,会用默认字体渲染,而且不会报错,你只能通过导出的图片看出来。二是 Canvas 2D 的ctx.font是标准的 CSS font 简写格式,顺序是 weight size family,写错了整条都不生效。三是旧版 Canvas(wx.createCanvasContext)对自定义字体的支持很差,基本只能用系统字体,所以海报场景我强烈建议直接用 Canvas 2D,基础库 2.9.0 以上都支持。
4.5 把字体加载做成可复用的工程能力
项目做多了以后,我把字体加载抽成了一个通用模块,核心是多源降级加本地缓存。用wx.downloadFile把字体下载到本地用户目录,下次直接读本地,省掉一次网络请求。
const FONT_URL = 'https://static.example.com/fonts/brand-font-v3.woff2' const FONT_FAMILY = 'BrandFont' function getLocalFontPath() { const fs = wx.getFileSystemManager() const dir = `${wx.env.USER_DATA_PATH}/fonts` try { fs.accessSync(dir) } catch (e) { fs.mkdirSync(dir, true) } return `${dir}/brand-font-v3.woff2` } function loadFromLocalThenRemote() { const localPath = getLocalFontPath() try { wx.getFileSystemManager().accessSync(localPath) return loadFontFace(`url("${localPath}")`).catch(() => downloadAndLoad(localPath)) } catch (e) { return downloadAndLoad(localPath) } }这套逻辑的价值在于:首屏第一次访问走网络,后续所有访问直接读本地文件,加载耗时从几百毫秒降到几十毫秒。对于字体是核心体验的产品(比如手写风格的工具类小程序),这个优化是值得做的。wx.env.USER_DATA_PATH是小程序提供给开发者的用户目录,可以用来存这类缓存资源。
5. 常见问题与排查速查表
5.1 iOS 和安卓的表现差异
真机调试阶段,最常见的困惑就是"两端不一样"。我整理了几种高频差异。
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| iOS 正常,安卓不生效 | WOFF2 兼容性或 CORS 头缺失 | 加 TTF 降级源,检查响应头 |
| 开发者工具正常,真机不生效 | 域名未加入 downloadFile 白名单 | 后台配置合法域名 |
| 首屏闪一下默认字体再变 | 异步加载导致的字体替换 | 关键标题先用图片占位,或做骨架屏 |
| 粗体请求渲染成常规体 | desc.weight 未声明 | desc 里显式写 weight: 'bold' |
| input / textarea 无效果 | 原生组件不支持自定义字体 | 换用普通 text 或调整设计 |
| 全局生效无效 | 基础库低于 2.10.0 | 调高基础库版本或改为页面级加载 |
5.2 高频报错与定位思路
loadFontFace的 fail 回调会带回一个错误对象,虽然信息不总是很明确,但几个关键词能帮你快速定位。看到fail url not in domain list,基本就是域名白名单没配;看到fail invalid source或者解析类错误,先检查source是不是漏了url("...")包裹,或者文件是不是被服务器返回成了 404 页面;如果 fail 里什么都没有,先怀疑 CORS,用 curl 看一下响应头里有没有Access-Control-Allow-Origin。
# 检查响应头和跨域配置 curl -I -H "Origin: https://servicewechat.com" https://static.example.com/fonts/brand-font-v3.woff2这条命令我很推荐,它能在你打开开发者工具之前就告诉你答案。很多所谓的"小程序字体加载失败",本质上就是服务器没配好,跟小程序一点关系没有。
5.3 缓存和版本更新的坑
前面提过一次,这里再强调:字体文件被强缓存后,你改了文件内容但没换文件名,用户端拿的还是旧文件。但更隐蔽的是另一种情况——代码里把字体加载逻辑放在了页面onLoad里,每次进页面都重新注册一次字体,这个操作本身开销不大,但如果字体地址后面带了随机参数防缓存,那就是每次进页面都重新下载一遍字体,流量和耗时都白费。正确的做法是全局加载一次,页面直接用。
提示:调试字体问题时,可以临时在字体地址后面加版本参数来强制刷新缓存,但上线前一定要改成稳定的、带内容哈希的固定地址,不要保留随机参数。
6. 性能优化与工程化落地
6.1 体积、时机与首屏的三角平衡
字体加载的本质是一次资源请求,它和首屏渲染是竞争关系。我的经验法则有三条。第一条,字体文件控制在 500KB 以内,超过这个量级就要考虑进一步子集化或者拆分字体。第二条,全局字体加载不阻塞启动,用异步方式触发,让页面先渲染,字体到了再替换。第三条,如果自定义字体是首屏核心视觉,给它加个 fallback 占位,比如用系统的近似字重先渲染,视觉上过渡更平滑。
6.2 加载失败的兜底策略
兜底我一般做三层。第一层是字体源的降级,WOFF2 失败换 TTF;第二层是样式的降级,font-family里挂完整的系统字体栈;第三层是业务的降级,如果字体是用来渲染动态内容(比如用户昵称),字体没加载成功时用系统字体渲染完全可接受,但如果是海报导出,就必须等字体就绪,宁可多等 300ms 也不能导出错误的图片。这三层的处理逻辑不一样,别一锅炖。
6.3 多字体、多字重的管理方式
一个项目里往往不止一种字体,标题一种、正文一种,甚至还分粗细。我的做法是维护一份字体配置表,每一项包含 family 名、多个源地址、desc 参数,统一在一个模块里管理。
const FONTS = [ { family: 'BrandTitle', weight: 'bold', files: ['brand-title.woff2', 'brand-title.ttf'] }, { family: 'BrandText', weight: 'normal', files: ['brand-text.woff2', 'brand-text.ttf'] } ]同一个 family 名不要注册多次不同字重,容易在某些机型上产生冲突,导致部分节点渲染异常。粗体和常规体用不同的 family 名区分开,代码里显式声明,这样最不容易出错。这个做法看着有点笨,但线上跑了一年多,没出过字体相关的故障。
7. 几个只有真机跑过才知道的细节
最后分享几个我在实际项目里踩出来、文档里基本不会写的点。第一个是Skyline 渲染模式下的字体作用域,如果页面开启了 Skyline,scopes里只写webview是不够的,得把native也加上,否则部分节点根本拿不到字体。第二个是字体加载完成后的重绘时机,有些复杂页面在success回调里立刻调用setData反而会触发一次额外渲染,稳妥的做法是让字体自然生效,别去手动触发。
第三个是基础库版本的分水岭,我现在的项目最低基础库基本都设到 2.10.0 以上,就是为了用global参数和scopes,低于这个版本的项目,字体方案要多写不少兼容代码,收益不划算。第四个是关于lazyCodeLoading这类优化,如果你开了按需注入,字体加载模块要确保被正确引用,别被摇树摇掉了,这种问题在小程序里排查起来特别费劲。
写到这里,其实核心就一句话:把字体文件准备好、把服务器配好、把加载时机控好,剩下的都是细节。我做过的手写风格工具类、婚礼邀请函、品牌活动页这几个项目,用的都是同一套流程,从选字体格式到上线,顺利的话一个下午就能搞定,前提是别在 CORS 和基础库版本上浪费时间。字体这东西不复杂,但它是那种"你以为简单,一动手就卡住"的典型,按流程走一遍,后面就都是肌肉记忆了。