做了个「句拾」小程序,最难的不是业务,是字体

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

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

上线前真正把进度拖住的,不是接口,也不是排版,是 自定义中文字体

微信开发者工具里看起来已经换上了仿宋和手写体,真机打开:顶栏还是黑体,海报还是黑体,搜索框里的字叠成两层。控制台轮流刷三种报错:

  • url scheme is invalid
  • network error
  • loadFontFace:fail

官方文档只有几行。论坛里一半方案是把 ttf 丢进 /staticwxfile:// 去加载------开发者工具能过,真机不行。

这篇文章把句拾最终能跑通的方案写清楚,给同样被字体折磨的人当一份对照手册。你可以不关心句拾的业务,只拿走后面的加载链路、报错对照和检查清单。


1. 先把目标说死

句拾要用三套字:

场景 字体 完整包大概体积
品牌顶栏「句拾」 芝麻行 数 MB
全站 UI 华文仿宋 约 7MB woff
含汉字的句子 / 海报 马善政 约 3.7MB woff
英文句子 仿宋 马善政几乎没有拉丁字母

同时还有三条硬约束:

  1. 主包必须小于 2MB(不含分包)。三套完整字体加起来十几 MB,直接塞主包,审核都过不了。
  2. wx.loadFontFacesource 只认 https 和 Data URL 。本地路径、wxfile://http://usr 在真机上不是「偶发失败」,是明确不支持。
  3. 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.ttf
  • http://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 里写的名字,例如 HuawenFangsongMaShanZhengZhiMangXing。否则 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 把控制台刷满,用户也看不到字。

正确顺序:

  1. 子集 Data URL 先成功,页面已经是自定义字体(缺字再回退)。
  2. downloadFile 把完整 woff 存到 wx.env.USER_DATA_PATH
  3. stat 看体积,仿宋至少数 MB,明显偏小就是下到了 HTML 错误页。
  4. 确认能下之后,再用 同一个 https URLloadFontFace 覆盖 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: 700font-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 2Dtype="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/woffapplication/font-woff。配成 text/html 时,loadFontFace 也会挂。


9. 一份最小可跑通的清单

如果你只想在自己的小程序里换一套标题字体,按这个做就够:

  1. 用 fontTools 按实际文案子集,输出 woff,family 改成 MyTitleFont
  2. 转 base64,放主包 JSON,体积控制在主包预算内。
  3. App.onLaunchloadFontFacesource 用 Data URL,scopes: ['webview']global: true
  4. CSS:font-family: MyTitleFont;font-weight: 400
  5. 真机预览。成功后再考虑完整包 https。
  6. 若有海报:把同一份 woff writeFileUSER_DATA,Canvas 2D 里 canvas.loadFont(path)ctx.font 只用返回的那一个 family。
  7. 不要把字体 JSON 放分包再异步 require;不要 native + Data URL;不要用 <text>input

完整中文阅读、海报导出,再加:

  1. 服务器放完整 woff,downloadFile → 校验体积 → https loadFontFace 覆盖。
  2. 中英文分流。
  3. iconfont 必须 font-family: iconfont !important,否则全局衬线会把图标吃掉。

10. 句拾为什么值得为字体付这个成本

句子产品如果用系统黑体,详情页和海报会立刻变成「又一个文案站」。衬线和手写体是阅读气质本身,不是装饰。

代价是:加载链路变长、失败路径必须全部兜住。句拾的兜底是:

  • 子集失败:页面仍能打开,只是系统字体
  • 云端失败:继续用子集,不打断阅读
  • 海报失败:回退系统字导出,而不是白屏
  • native 失败:忽略,不影响已经成功的页面字体

业务可以一周做完。字体是因为它同时踩中了 体积、协议、双端 API、CSS 回退、分包 五件事,每一件在开发者工具里都不明显,合在真机上才爆炸。


写在最后

微信小程序自定义字体不是「把 ttf 放到 static 再写一行 CSS」。能稳定出字的路径只有两条:

  • 页面: https 或 Data URL → loadFontFace(webview)
  • 海报: 本地文件 → Canvas 2D loadFont

其余写法,多半是开发者工具给你的错觉。

相关推荐
FFF_634560231 小时前
简单的画板小工具,下载即用
前端·javascript·css
一朵好运莲1 小时前
智能体使用 Chrome DevTools MCP 调试浏览器
开发语言·javascript·react.js
嘟嘟07172 小时前
Next.js 里 SSR、CSR 和水合到底差在哪?从一段待办代码说起
前端·后端·next.js
sibylyue2 小时前
工作流表单和流程设计前端
开发语言·javascript·开源
用户526835677902 小时前
Kafka 消费倾斜死锁与 Partition 掉队:我用 Rust 写了个“数据管道物理哨兵”,比 Grafana 报警快了 18 秒
javascript
南雨北斗2 小时前
回调地狱、jQuery jqXHR .then 链式、Promise + async/await写法对比
前端
liuxiaocheng2 小时前
快速上手:5 分钟跑通第一个 AI SDK 例子
前端·人工智能·后端
嘟嘟07172 小时前
ESLint 入门:从 npm run lint 到 --fix 与规则级别一次讲清
javascript·代码规范·eslint
一个游离的指针2 小时前
JS中的对象的相关概念
开发语言·javascript·原型模式