做了一款叫 句拾 的微信小程序,Slogan 六个字:拾一句喜欢的话。

业务并不花哨:今日拾句、按心情和题材分类、合集和话题、收藏同步、签到解锁笺纸、详情页把句子做成海报。Node.js + MongoDB + 管理后台,该有的都有。

上线前真正把进度拖住的,不是接口,也不是排版,是 自定义中文字体。
微信开发者工具里看起来已经换上了仿宋和手写体,真机打开:顶栏还是黑体,海报还是黑体,搜索框里的字叠成两层。控制台轮流刷三种报错:
url scheme is invalidnetwork errorloadFontFace:fail
官方文档只有几行。论坛里一半方案是把 ttf 丢进 /static 再 wxfile:// 去加载------开发者工具能过,真机不行。
这篇文章把句拾最终能跑通的方案写清楚,给同样被字体折磨的人当一份对照手册。你可以不关心句拾的业务,只拿走后面的加载链路、报错对照和检查清单。
1. 先把目标说死
句拾要用三套字:
| 场景 | 字体 | 完整包大概体积 |
|---|---|---|
| 品牌顶栏「句拾」 | 芝麻行 | 数 MB |
| 全站 UI | 华文仿宋 | 约 7MB woff |
| 含汉字的句子 / 海报 | 马善政 | 约 3.7MB woff |
| 英文句子 | 仿宋 | 马善政几乎没有拉丁字母 |
同时还有三条硬约束:
- 主包必须小于 2MB(不含分包)。三套完整字体加起来十几 MB,直接塞主包,审核都过不了。
wx.loadFontFace的source只认 https 和 Data URL 。本地路径、wxfile://、http://usr在真机上不是「偶发失败」,是明确不支持。- UI 出字和 Canvas 出海报,不是同一套 API。 页面用
loadFontFace,海报用 Canvas 2D 的canvas.loadFont+ 本地文件。混用一定翻车。
如果你的需求只是「某几个标题换个手写体」,不必上完整中文字库,子集就够。如果你还要海报导出,必须从第一天就把两条链路分开设计。

2. 微信到底允许怎么加载字体
先背官方规则,后面所有坑都从这里长出来。
js
wx.loadFontFace({
global: true,
family: 'HuawenFangsong',
source: 'url("https://your.domain/font.woff")',
// 或 Data URL:
// source: 'url("data:font/woff;charset=utf-8;base64,AAAA...")',
scopes: ['webview'],
success() {},
fail(err) { console.warn(err) }
})
要点:
source 只能是:
url("https://...")公网 https,域名要配进 downloadFile 合法域名url("data:font/woff;charset=utf-8;base64,...")
source 不能是:
/static/fonts/xxx.ttf(这是包内路径,给<image>用的)wxfile://usr/xxx.ttfhttp://usr/xxx.ttf- 相对路径
./fonts/xxx.woff
开发者工具对本地路径更宽容,所以很多人在模拟器里「已经成功了」,提审或真机才发现完全没换字。调试自定义字体,以真机为准。
scopes:
| 值 | 作用 | 句拾怎么用 |
|---|---|---|
webview |
页面里的 <text>、<view> |
UI 字体只走这个 |
native |
原生组件、Canvas 旧接口 | Data URL / 本地路径配 native,真机常直接 network error,而且会把整次 loadFontFace 带失败 |
结论:UI 加载 不要把 native 和 Data URL 写在同一次调用里 。需要原生 input 也换字时,等你有 https 完整字体 再单独调一次 scopes: ['native'],失败就忽略,不要影响已经成功的 webview 字体。
global: true: 一次加载,后续页面都能用这个 family。句拾在 App.onLaunch 里启动加载,避免每个页面自己 loadFontFace 打一场。
3. 最终架构:两套字、两条路
句拾最后拆成这样:
text
启动
├─ 主包子集(base64 JSON)
│ └─ Data URL → loadFontFace(scopes: webview)
│ └─ 首屏立刻有字(可能缺生僻字)
│
├─ 同时把子集 woff 写到 USER_DATA(给海报用)
│
└─ 延迟约 1s
└─ https 下载完整 woff 到 USER_DATA
├─ 确认体积正常
├─ loadFontFace(https, webview) 覆盖 UI
└─ loadFontFace(https, native) 尝试给 input(失败忽略)
导出海报
├─ 等 USER_DATA 里有文件
├─ Canvas 2D:先设 canvas.width / height
├─ canvas.loadFont(本地路径)
├─ 用 loadFont 的返回值当 ctx.font 的 family
└─ 等约 120ms 再绘制
可以记成一句话:
页面看字走 Data URL / https;海报画字走本地文件。
4. 体积:先子集,再考虑云端
完整中文字库不可能进主包。句拾用 fontTools 做子集,再转 woff、再转 base64 JSON,主包 require 进来。
4.1 三种切法
- 品牌短句子集(芝麻行):只保留「句拾」「拾一句喜欢的话」等标题会用到的字,体积可以压到很小。首页一打开就要看见,必须放主包。
- 界面用字子集 (仿宋):扫描项目里
.vue/.js出现过的汉字,加上标点和 ASCII。够撑起 Tab、按钮、说明文字。 - 正文常用字子集(马善政):句子会遇到更多字。子集先保证常见字能显示,缺的字等云端完整包覆盖。
不要试图把 GBK 全字库塞进主包。2MB 限制是物理定律。
4.2 改 family 名
子集之后,用 fontTools 把 name table 改成你在 CSS 里写的名字,例如 HuawenFangsong、MaShanZheng、ZhiMangXing。否则 CSS 写的是 A,字体内部还叫「华文仿宋」或一串英文,真机对不上。
4.3 主包里怎么携带
不要把 .ttf 直接丢 static 再在运行时读文件------又会走到非法 scheme。句拾把子集 woff 编成 JSON:
json
{
"base64": "d09GRgABAAAA...",
"format": "woff",
"family": "HuawenFangsong"
}
启动时拼 Data URL:
js
function dataFontSource(format, base64) {
const mime = format === 'woff' ? 'font/woff' : 'font/ttf'
return 'url("data:' + mime + ';charset=utf-8;base64,' + base64 + '")'
}
uni.loadFontFace({
global: true,
family: 'HuawenFangsong',
source: dataFontSource('woff', payload.base64),
scopes: ['webview']
})
JSON 会变大(base64 大约是原文件的 4/3),所以子集一定要狠。芝麻行这种「只用于几个字」的字体,主包放子集;完整仿宋 / 马善政放自己的 https 域名,例如 https://your.domain/public/fonts/xxx.woff。
4.4 云端覆盖的顺序不能反
曾经直接对云端 URL 调 loadFontFace,文件还没传到服务器,真机一串 404,network error 把控制台刷满,用户也看不到字。
正确顺序:
- 子集 Data URL 先成功,页面已经是自定义字体(缺字再回退)。
downloadFile把完整 woff 存到wx.env.USER_DATA_PATH。stat看体积,仿宋至少数 MB,明显偏小就是下到了 HTML 错误页。- 确认能下之后,再用 同一个 https URL 调
loadFontFace覆盖 UI。
同一 family 可以加载两次:先子集,后完整包。force 再载一次即可。
5. UI 怎么写 CSS
loadFontFace 成功只是注册了 family,CSS 还要对上,而且不要自己把字体踢掉。
css
page {
--js-font-serif: HuawenFangsong, 'Songti SC', 'STSong', serif;
--js-font-quote: MaShanZheng, HuawenFangsong, 'Songti SC', serif;
--js-font-zhimang: ZhiMangXing, HuawenFangsong, 'Songti SC', serif;
font-family: var(--js-font-serif);
}
.js-font-quote {
font-family: var(--js-font-quote);
}
.js-font-zhimang {
font-family: var(--js-font-zhimang);
}
.iconfont {
font-family: 'iconfont' !important;
}
5.1 font-weight: 700 会让字体「看起来没生效」
很多免费中文手写体、仿宋 只有 Regular 。小程序里给标题写 font-weight: 700 或 font-weight: 600,引擎会认为「这套字没有 Bold」,直接回退到系统黑体。
表现就是:family 已经 load 成功,顶栏还是黑体。
品牌、手写、仿宋标题一律:
css
.brand__name {
font-weight: 400; /* 不要 700 */
font-family: ZhiMangXing, var(--js-font-zhimang);
}
需要「更重」就换字号、字间距、颜色,不要加粗。
5.2 中英文不要混用同一套手写体
马善政几乎没有完整拉丁字母。英文句子硬套上去会变成方框或回退成另一套字,一行里中英风格撕裂。
句拾用很简单的判断:文本里有 CJK 就用马善政,否则用仿宋。
js
const CJK_RE = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/
export function quoteFontClass(text) {
return CJK_RE.test(String(text || '')) ? 'js-font-quote' : ''
}
模板:
html
<text :class="quoteFontClass(item.content)">{{ item.content }}</text>
5.3 原生 input 不继承 page 的字体
<text> 会走 webview 字体,<input> / <textarea> 是原生组件,不继承 page { font-family }。
可以做的:
css
input, textarea {
font-family: HuawenFangsong, 'Songti SC', 'STSong', serif;
font-weight: 400;
}
再给 placeholder-style 写一遍 family(placeholder-class 在原生组件上经常丢)。
不要做的:用一层 <text> 盖在 input 上模拟自定义字体。失焦时原生字和覆盖层叠在一起,就是重影。句拾踩过,已经撤掉。
input 要稳定换字,只能等 https 完整字体加载成功后,再单独:
js
uni.loadFontFace({
global: true,
family: 'HuawenFangsong',
source: 'url("https://your.domain/public/fonts/HuawenFangsong-Regular.woff")',
scopes: ['native']
})
失败就失败,搜索框继续用系统字,至少不会把页面字体搞挂。
6. 海报:另一条完全不同的路
详情页「下载海报」是 Canvas 合成:背景图 + 句子 + 作者。这里 loadFontFace 不够。
旧版 canvas 组件加自定义字体,真机经常静默回退。句拾统一用 Canvas 2D (type="2d")。


6.1 文件必须在 USER_DATA
canvas.loadFont 吃的是本地文件路径,例如:
text
wx.env.USER_DATA_PATH + '/HuawenFangsong.full.v1.woff'
来源可以是:启动时把子集 base64 writeFile 进去,或 downloadFile 完整包。不要把包内 /static/fonts/xxx.ttf 直接丢给 canvas------路径一丢前导 /,会被拼成 /pages/当前页/static/...,请求 500。
6.2 先设宽高,再 loadFont
改 canvas.width / canvas.height 会重置上下文。顺序必须是:
js
canvas.width = W * dpr
canvas.height = H * dpr
ctx.scale(dpr, dpr)
// 之后才 loadFont
const family = canvas.loadFont(localPath) || 'HuawenFangsong'
await sleep(120) // 真机注册后要喘一口气
ctx.font = '42px ' + family
ctx.fillText(content, x, y)
6.3 ctx.font 只写一个 family
网页习惯:
js
ctx.font = '42px MaShanZheng, HuawenFangsong, serif'
真机 Canvas 看到后面的系统回退,经常直接忽略自定义字。只写一个:
js
ctx.font = '42px ' + family
canvas.loadFont(path) 在部分基础库会 返回字体内部的真实 family 字符串。必须用这个返回值,不要想当然用你在 CSS 里起的别名。
js
function registerCanvasFont(canvas, filePath, fallbackFamily) {
if (!canvas || !filePath || typeof canvas.loadFont !== 'function') {
return fallbackFamily
}
try {
const ret = canvas.loadFont(filePath)
if (typeof ret === 'string' && ret.trim()) return ret.trim()
canvas.loadFont(filePath, fallbackFamily)
} catch (e) {
console.warn('[poster] loadFont', e)
}
return fallbackFamily
}
6.4 背景图也只能是「canvas 认识的路径」
包内图片给 canvas 用,要保证是根路径 /static/... 或先复制到 USER_DATA。https 图要先 downloadFile。微信隐私协议没同意时,saveImageToPhotosAlbum 会卡住,保存前先走隐私授权。
7. 分包:首页要用的字不要放分包
字体 JSON 一度被我放进 media 分包,主包 require.async。打包后路径失效,首页顶栏永远是系统字。
微信还有一条: 主包不能引用分包里的静态资源 。海报大图、签到笺纸可以放分包;一打开就要看见的品牌字体,必须在主包。
pages.json 里给首页、详情、列表配 preloadRule,提前下载 media 分包,只解决图片和签到页,解决不了首屏标题。
8. 报错对照表
真机调试时对着这张表看,少走很多弯路。
| 报错 / 现象 | 常见原因 | 怎么处理 |
|---|---|---|
url scheme is invalid |
source 用了 wxfile://、http://usr、包内路径 |
改成 Data URL 或 https |
network error(loadFontFace) |
Data URL 配了 scopes: ['native'];或 https 404 / 不是合法域名 |
UI 只用 webview;https 先用浏览器能打开确认 |
loadFontFace:fail 无细节 |
family 冲突、source 空、base64 截断 | 打日志把 family 和 source 前缀打出来 |
| 工具里有字,真机没有 | 工具放行了本地路径 | 以真机为准 |
| 控制台 success,页面仍是黑体 | font-weight: 700 回退;或 CSS family 和注册名不一致 |
改 400;统一 name table |
| 标题有字,正文部分字是黑体 | 子集缺字 | 补子集或等完整包 https 覆盖 |
| 海报是黑体,页面是自定义字 | 只做了 loadFontFace,没 canvas.loadFont | 走 USER_DATA + Canvas 2D |
| 海报偶发变回黑体 | loadFont 写在改 canvas 尺寸之前;或没 delay | 先设宽高,再 loadFont,再等 100ms+ |
| 英文变成方框 | 手写体没有拉丁字母 | 按是否含汉字切换 family |
| 搜索框重影 | <text> 覆盖在 <input> 上 |
去掉覆盖层 |
| 主包超 2MB | 完整 ttf/woff 进了主包 | 子集 + 云端完整包 |
| https 字体 404 | 文件没部署到 /public/fonts,或 Nginx 没配 woff MIME |
浏览器直接打开 URL 应能下载 |
合法域名在小程序后台要配:
downloadFile合法域名:完整字体 https- 若用 downloadFile 存 USER_DATA,同样是这个域名
woff 的 Content-Type 建议 font/woff 或 application/font-woff。配成 text/html 时,loadFontFace 也会挂。
9. 一份最小可跑通的清单
如果你只想在自己的小程序里换一套标题字体,按这个做就够:
- 用 fontTools 按实际文案子集,输出 woff,family 改成
MyTitleFont。 - 转 base64,放主包 JSON,体积控制在主包预算内。
App.onLaunch里loadFontFace,source用 Data URL,scopes: ['webview'],global: true。- CSS:
font-family: MyTitleFont;且font-weight: 400。 - 真机预览。成功后再考虑完整包 https。
- 若有海报:把同一份 woff
writeFile到USER_DATA,Canvas 2D 里canvas.loadFont(path),ctx.font只用返回的那一个 family。 - 不要把字体 JSON 放分包再异步 require;不要
native+ Data URL;不要用<text>盖input。
完整中文阅读、海报导出,再加:
- 服务器放完整 woff,downloadFile → 校验体积 → https
loadFontFace覆盖。 - 中英文分流。
- iconfont 必须
font-family: iconfont !important,否则全局衬线会把图标吃掉。
10. 句拾为什么值得为字体付这个成本
句子产品如果用系统黑体,详情页和海报会立刻变成「又一个文案站」。衬线和手写体是阅读气质本身,不是装饰。
代价是:加载链路变长、失败路径必须全部兜住。句拾的兜底是:
- 子集失败:页面仍能打开,只是系统字体
- 云端失败:继续用子集,不打断阅读
- 海报失败:回退系统字导出,而不是白屏
- native 失败:忽略,不影响已经成功的页面字体
业务可以一周做完。字体是因为它同时踩中了 体积、协议、双端 API、CSS 回退、分包 五件事,每一件在开发者工具里都不明显,合在真机上才爆炸。
写在最后
微信小程序自定义字体不是「把 ttf 放到 static 再写一行 CSS」。能稳定出字的路径只有两条:
- 页面: https 或 Data URL →
loadFontFace(webview) - 海报: 本地文件 → Canvas 2D
loadFont
其余写法,多半是开发者工具给你的错觉。