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

做了一款叫 句拾 的微信小程序,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

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

相关推荐
mldong7 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排7 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60618 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角9 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!9 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰9 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi11 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen11 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马12 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu12 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss