设计稿上那款字重饱满的手写体落到微信小程序里全变成了系统默认的黑体这是不少做小程序的人第一个被卡住的点。设计师在 Figma 里调好的标题到了真机上怎么看怎么像没做完问题往往不在布局而在微信小程序导入外部字体这一步没走通。小程序不是浏览器它没有font-face直接读本地文件的自由也没有网页那种天然的系统字体降级链字体这件事必须按它自己的规矩来办。这篇内容就是把我自己从踩坑到跑通的全过程整理出来字体文件怎么选、怎么裁剪到几十 KB、wx.loadFontFace的参数怎么填、合法域名怎么配、base64 什么时候该用、真机不生效又该从哪查。不管你是刚接触小程序的新手还是接了品牌项目要做视觉还原的老手这里面的流程和坑都能直接拿去用。1. 先把方案想清楚小程序加载字体的三条路1.1 为什么 wxss 里直接写 font-face 不靠谱很多从 H5 转过来的人第一反应是在.wxss里写一段font-face然后把字体文件往项目目录一丢用相对路径引用。这个做法在小程序里基本走不通原因在于小程序的 WXSS 最终会被编译、打包静态资源路径并不像网页那样可以随便引用本地文件。你在开发者工具里偶尔看到它好像生效了多半是工具模拟层的宽容一上真机就原形毕露。小程序对本地字体的态度可以用一句话概括它只接受告诉渲染层去加载某个字体资源这种指令式做法而不是让样式表自己去解析文件路径。所以官方给出的正解是wx.loadFontFace这个 API它做的事情就是主动把一份字体资源注册进渲染层注册成功之后WXSS 里就能像用系统字体一样通过font-family引用它。这里要顺带说清一个容易混淆的点字体图标iconfont和正文字体不是一回事。图标字体一般只有几十个字形体积小很多团队会把它转成 base64 塞进 WXSS 里用这种用法在小程序里是可行的也是历史遗留比较久的做法。但正文用的中文字体动辄几 MB塞进样式文件会让主包直接爆掉所以必须另走加载路径。把这两件事分开想方案就不会乱。1.2 三种方案的对比与选型实际能落地的方案有三条我把它们的差别整理成一张表你先按自己的场景对号入座方案实现方式适用场景主要限制网络地址加载wx.loadFontFace的 source 传 HTTPS 链接中文正文、多页面共用、字体体积较大必须配合法域名首次加载有网络耗时base64 内联source 传data:font/woff;base64,...小体积字体、图标字体、离线场景体积膨胀约 33%代码包会被撑大WXSS font-face样式表内写 base64 或网络地址图标字体、极少数兼容场景行为不稳定中文大字体基本不可用选型的判断标准其实只有两条字体有多大、要不要离线可用。中文正文字体子集化之后通常还有 50KB 到 300KB这个体量放在网络加载最合适既不占包体也方便换版本。如果只是十来个字形的小图标字体base64 反而更省事一次性写死在代码里不依赖网络也不会因为域名没配好而全盘失效。我在项目里最常用的组合是中文正文字体走网络地址 global: true全局注册在app.js的onLaunch里发起加载图标字体走 base64 内联。这套组合的好处是主包体积可控同时首屏不会被字体下载拖慢——因为加载是异步的页面该渲染就渲染字体到了再切换。注意wx.loadFontFace从基础库 2.1.0 开始支持项目里如果你的最低基础库版本压得很低记得用wx.canIUse(loadFontFace)做一次判断避免低版本设备直接报错。2. 字体文件准备选型、子集化和格式转换2.1 选字体的两个硬约束授权和体积字体这件事第一道坎从来不是技术而是授权。商用项目里随手下载一款网上的字体直接打进小程序是实打实的法律风险。稳妥的做法是优先挑明确开源可商用的字体家族比如思源黑体、思源宋体这类有明确开源协议的字体或者使用企业已经购买授权的品牌字体。授权这一关过不去后面技术做得再漂亮也没意义。第二道坎是体积。一套完整的中文字体TTF 格式通常在 8MB 到 20MB 之间因为汉字有几万个字形每个字形都要存轮廓数据。这个体量直接上传别说小程序主包 2MB 的限制就是走网络加载用户在地铁里等十几秒也受不了。所以子集化是必做的一步。所谓子集化就是只保留项目里真正会用到的那些字把剩下几万个用不到的字形全部剔除。实际操作中一个小程序用到的汉字通常不超过 1500 个加上英文数字符号子集化之后体积能压到原来的百分之一甚至更低。那么问题来了怎么知道项目里会用到哪些字我的做法是分两步走。第一步从页面的静态文案里提取一遍也就是代码里写死的中文第二步针对用户昵称、商品标题这类动态内容估算一个覆盖范围把常用的三千五百个常用汉字加上标点符号一起保留进去。这样既有确定性的覆盖又能兜住大部分动态内容。提示如果动态内容真的不可控比如用户能输入生僻字那就老老实实用系统字体兜底做 fallback别指望一份子集字体包打天下。2.2 用 fontmin 或 pyftsubset 做子集化工具上有两个主流选择一个是 Node 生态的fontmin一个是 Python 生态的fonttools里的pyftsubset。两者都能做子集化加格式转换我一般看项目环境来选。如果项目本身就是 Node 工程fontmin接进去最顺写一个构建脚本挂在打包流程前面就行const Fontmin require(fontmin); const chars 你要保留的全部字符包括中文、标点、数字和字母; const fontmin new Fontmin() .src(src/fonts/AlibabaPuHuiTi.ttf) .use(Fontmin.glyph({ text: chars, hinting: false })) .use(Fontmin.ttf2woff()) .dest(dist/fonts/); fontmin.run((err, files) { if (err) throw err; files.forEach((file) { console.log(${file.path} - ${(file.contents.length / 1024).toFixed(1)}KB); }); });这段脚本跑完控制台会把压缩后的体积打出来我第一次跑的时候从 9.6MB 压到了 68KB那种感觉还是挺爽的。hinting: false这一句是关掉字体微调信息屏幕显示场景下基本用不上关掉之后体积还能再小一截。如果只有 Python 环境用pyftsubset更直接一条命令搞定pyftsubset SourceHanSansCN-Regular.ttf \ --text-filechars.txt \ --flavorwoff \ --with-zopfli \ --layout-features* \ --output-fileSourceHanSansCN.subset.woff--text-file指定一个文本文件里面放你要保留的所有字符工具会自动去重。--layout-features*这个参数我建议保留它会把连字、字距调整这些排版特性一起带上去掉的话某些标点组合的间距会变得很怪。子集化最容易踩的坑是忘记带标点。很多人只把中文汉字提取出来结果中文引号、破折号、省略号全变成了系统字体的样子视觉上非常割裂。提取字符集的时候中文标点一定要一起放进去。2.3 TTF、WOFF、WOFF2 到底选哪个格式这件事官方文档里有一句关键提示建议使用 TTF 和 WOFFWOFF2 在低版本 iOS 上会不兼容。这一句话基本定了调子。从压缩率看WOFF2 最优比 WOFF 还能再小 30% 左右WOFF 居中TTF 最大但兼容性最好。我的实际选择是主用 WOFFTTF 作为兜底。因为 WOFF 体积比 TTF 小不少同时不像 WOFF2 那样在老设备上翻车。如果你的用户画像明确都是新机型用 WOFF2 也不是不行但要做好降级方案——加载失败时页面能自动回落到系统字体而不是整个标题变成空白。这个降级逻辑其实不用额外写代码只要在 WXSS 的font-family里把系统字体写在后面就行.brand-title { font-family: MyBrandFont, -apple-system, BlinkMacSystemFont, PingFang SC, sans-serif; }这行的意思是优先用自定义字体加载失败或者还没加载完就用系统字体顶上。后面这一串不是凑数它能让字体切换的过程平滑很多。3. wx.loadFontFace 实操从调试到真机跑通3.1 接口参数逐项拆解wx.loadFontFace的参数不算多但每一项都有讲究我先逐条过一遍参数类型是否必填说明globalBoolean否是否全局生效false 时只在当前页面生效familyString是自定义的字体名称后续在 WXSS 中引用sourceString是字体资源地址支持网络 URL 和 base64descObject否字体描述符包含 style、weight、variantscopesArray否作用范围可选 webview 和 nativesuccess / fail / completeFunction否回调函数family这个字段是整个流程的枢纽它相当于你给这份字体起的外号。WXSS 里写font-family的时候用的就是这个外号跟字体文件内部的名称没关系。我习惯用项目前缀加字重的方式命名比如BrandFontRegular、BrandFontBold这样后面加字重不会乱。desc里的weight要特别注意。如果你注册的是常规字重就写normal如果要注册粗体写bold或者对应的数字700。这里的 weight 必须和 WXSS 里实际使用时的字重对得上否则浏览器会认为我要的是粗体但注册的只有常规体直接回落到系统字体。这个坑我踩过一次排查了小半天才反应过来。source的写法有个细节网络地址要包在url(...)里不能直接写裸链接。source: url(https://static.example.com/fonts/BrandFont.subset.woff)写成裸字符串在某些版本上会加载失败虽然文档没强制要求但加上url()是更保险的写法跟 CSS 的语法保持一致。3.2 网络地址方案HTTPS 与合法域名配置网络地址方案最大的门槛不在代码而在域名配置。字体资源必须走 HTTPS这一点没有商量余地。同时你还需要把这个资源的域名配置到小程序的downloadFile 合法域名列表里。这个配置在后端管理页面操作配完之后需要重新编译或者等一下配置生效。这里有一个极其常见的假成功现象开发者工具里一切正常传到手机上就是没效果。原因就是工具里的不校验合法域名开关默认是勾上的工具对域名校验网开一面真机则是严格校验。所以每次做完字体功能我都会把工具里那个校验开关关掉再跑一遍提前暴露问题。配置好之后app.js里的加载代码大概长这样App({ onLaunch() { const fontUrl https://static.example.com/fonts/BrandFont.subset.woff; wx.loadFontFace({ global: true, family: BrandFont, source: url(${fontUrl}), desc: { style: normal, weight: normal }, success: (res) { console.log(字体加载成功, res.status); }, fail: (err) { console.error(字体加载失败, err); } }); } });放在onLaunch里有几个好处只执行一次、全局注册、页面进入时字体可能已经就绪。但它也有代价——如果网络差字体可能比页面内容晚到首屏会看到一次字体切换的跳动感。这个后面会专门讲怎么缓解。注意如果你发现配了域名还是失败先确认链接能不能在手机浏览器里直接打开。有些 CDN 对 Referer 做了限制小程序发起的请求带不上正常的来源信息会被直接拒绝。3.3 base64 方案什么时候用、怎么生成base64 方案的本质是把字体文件转成一长串字符直接写在代码里不依赖任何网络请求。它的优点是必定可达缺点是体积膨胀转换后大约会变成原文件的 1.33 倍。生成 base64 很简单命令行一行搞定# Linux / macOS base64 -i BrandFont.subset.woff | tr -d \n font.b64.txt # Windows PowerShell [Convert]::ToBase64String([IO.File]::ReadAllBytes(BrandFont.subset.woff)) font.b64.txt然后把这串内容拼接到 source 里const fontBase64 d09GMgABAAAAAA...; // 省略中间部分 wx.loadFontFace({ global: true, family: IconFont, source: url(data:font/woff;base64,${fontBase64}), success: () console.log(图标字体就绪) });我的使用边界很明确只对体积在 30KB 以内的字体用 base64。图标字体一般在这个范围内正文中文字体基本都超标。一旦超过这个量级代码包会被撑大解析这串长字符串本身也要花时间得不偿失。还有一个实践细节这串 base64 别直接写在业务代码文件里单独放一个fontBase64.js文件导出字符串。这样代码可读性不会被破坏也能避免某些编辑器在打开超长单行文件时卡死。3.4 全局生效还是页面生效global这个参数看着不起眼但用错了会带来莫名其妙的时好时坏。设为true时字体在当前小程序的整个生命周期内、所有页面都生效。设为false或者不传字体只在调用它的那个页面生效页面销毁后失效下一个页面要用就得重新加载一次。我的判断标准是全站通用的品牌字体一律用 global只在某个活动页临时用的装饰字体用页面级加载。活动页字体通常体积大、字形少、用过就扔全局注册反而是浪费。页面级加载写在页面的onLoad里Page({ onLoad() { wx.loadFontFace({ family: ActivityFont, source: url(https://static.example.com/fonts/activity.subset.woff), scopes: [webview, native], success: () { this.setData({ fontReady: true }); } }); } });这里我加了一个fontReady标记目的是控制字体加载完成前不渲染相关文案避免字体切换时的视觉跳动。这个技巧后面还会展开讲。scopes这个参数涉及渲染层差异。小程序存在不同的渲染方式字体注册时要落到对应的渲染层否则会出现注册成功了但页面没用上的情况。稳妥的做法是显式把两个作用范围都写上让它自己去找能用上的那层别依赖默认值。3.5 在 WXSS 里把字体用起来字体注册只是把资源准备好真正让文字变样还得靠样式。这一步看似简单错的人却不少。.brand-title { font-family: BrandFont, -apple-system, PingFang SC, sans-serif; font-weight: normal; } .brand-subtitle { font-family: BrandFont, -apple-system, PingFang SC, sans-serif; font-weight: bold; }两个关键点。第一family 名字必须和注册时完全一致大小写敏感差一个字母都不行。第二font-weight 必须和 desc 里注册的 weight 匹配上面那个 subtitle 如果用的是同一个字体文件粗体的效果是渲染层模拟出来的实际观感和真粗体有差别。如果想要真实的粗体效果就得分两次注册同一个字体的不同字重用两个不同的 family 名// 常规字重 wx.loadFontFace({ global: true, family: BrandFontRegular, source: url(https://static.example.com/fonts/BrandFont-Regular.subset.woff), desc: { style: normal, weight: normal } }); // 粗体字重 wx.loadFontFace({ global: true, family: BrandFontBold, source: url(https://static.example.com/fonts/BrandFont-Bold.subset.woff), desc: { style: normal, weight: bold } });代价是多一份字体文件的下载量所以我的做法是正文只注册常规字重只有标题这类视觉重点才额外注册粗体。这样既保证了关键位置的质感又不至于让下载量翻倍。4. 加载时机、体积与体验优化4.1 首屏字体闪烁怎么解字体是异步加载的页面不会等它。所以必然存在一个时间窗口页面已经用系统字体渲染出来了字体文件才下载完成然后画面突然切换。这个现象在排版上是真实存在的问题尤其在标题位置字宽变化会导致整行文字的换行位置跳动。我试过三种缓解方式效果从弱到强依次是第一种让 fallback 字体尽量接近目标字体。在font-family列表里选一个字形宽度和行高表现接近的系统字体作为兜底。比如目标字体是比较方正的黑体就优先选系统的无衬线黑体而不是衬线字体。这样即使切换跳动的幅度也小很多。第二种给关键文案加加载态。用一个fontReady标记控制渲染字体没准备好时不显示文字或者显示一个占位符。这种方式彻底消除了跳动但代价是首屏文案出现会延迟用户可能察觉到文字是后冒出来的。第三种把字体加载提前到启动阶段并缓存加载状态。在app.js里发起加载同时用本地缓存记录这个字体已经加载过下次启动直接信任缓存不再重复校验。注意这里缓存的是状态而不是字体本身字体文件本身的缓存由系统 HTTP 缓存机制负责我们要做的是给字体资源配上足够长的缓存时间。提示CDN 上的字体文件一定要配长缓存字体文件是一次发布长期不变的典型资源缓存时间设成一年都不过分。文件名里带上版本号或者内容哈希这样更新字体时改了文件名就能自然绕过缓存。4.2 分包与按需加载小程序的体积限制是有明确上限的主包和各种分包都有各自的容量天花板一旦超限连上传都过不去。所以字体文件绝不能往包里塞网络加载几乎是唯一解。但网络加载也有讲究。如果所有字体都在启动时加载小程序的启动耗时会变长尤其在中低端安卓机上感知明显。我的策略是按页面重要性分级主包首页用到的品牌字体在onLaunch里加载二级页面、活动页用到的装饰字体在页面onLoad里加载纯内容页如果本来就没用自定义字体就不要注册省一次请求。如果项目用了分包还有一个更细的做法把字体加载逻辑放到分包内部让字体请求和分包加载并行进行。用户进入分包页面时字体和页面代码同时在路上实际等待时间会被摊薄。// 分包页面里单独加载自己的字体 Page({ onLoad() { wx.loadFontFace({ family: SpecialFont, source: url(https://static.example.com/fonts/special.subset.woff) }); } });这样做的另一个好处是职责清晰哪个页面用哪份字体写在哪出了问题好定位也不会出现全局注册了五份字体实际只用了两份的浪费。4.3 多字重与缓存策略做到一定程度就会遇到字重问题。设计稿里标题是 Heavy副标题是 Medium正文是 Regular一份字体文件是不行的。我的方案是用同一款字体家族的不同字重文件但要控制数量。实际项目里通常只需要两份常规和粗体。轻量、细体这类字重在移动端小字号下视觉差异极小投入产出比很低不如把这两份做扎实。缓存这块除了前面说的 CDN 长缓存还可以在小程序侧做一层状态缓存const FONT_CACHE_KEY brand_font_loaded; App({ onLaunch() { const loaded wx.getStorageSync(FONT_CACHE_KEY); if (loaded) { // 已经加载过直接注册不再走校验逻辑 this.registerFont(); return; } this.registerFont(); }, registerFont() { wx.loadFontFace({ global: true, family: BrandFont, source: url(https://static.example.com/fonts/BrandFont.subset.woff), success: () { wx.setStorageSync(FONT_CACHE_KEY, true); } }); } });说白了wx.loadFontFace每次调用都要走一遍资源定位和解析流程缓存标记能省掉一部分重复工作。这个优化在字体较多的时候效果明显单份字体的话提升有限属于锦上添花。5. 常见问题排查速查表5.1 工具里正常真机不生效这是最高频的问题九成以上是域名没配。排查顺序我固定成下面这几步第一检查资源域名是否在downloadFile 合法域名里。没配就配配了就看是不是配到了别的类别里。第二检查链接是不是 HTTPS。HTTP 在小程序里会被直接拦掉。第三把开发者工具里的不校验合法域名开关关掉再跑一次。这一步能提前暴露问题别等真机才发现。第四把链接复制到手机浏览器里直接打开看能不能正常下载。如果浏览器都加载不了那就是服务器或者 CDN 的问题跟小程序无关。第五检查字体文件的 MIME 类型。服务器返回的 Content-Type 如果不是字体类型某些情况下会被拒绝。让后端把 WOFF 文件返回为font/woff。5.2 加载成功但文字没变回调里打印出了成功日志页面文字还是老样子这种情况通常是下面几个原因。family 名字对不上。注册用的名字和 WXSS 里写的名字哪怕差一个大小写都不会生效。这个错误不会报错只能肉眼比对。字重不匹配。注册的是normal样式里写font-weight: bold渲染层会认为没有匹配的字体直接回落。选择器优先级问题。有些全局样式或者组件库自带的样式把字体覆盖掉了。用开发者工具的样式面板查一下最终生效的font-family是什么一眼就能看出来。元素上有内联样式或者第三方组件强制设置了字体。一些 UI 组件库会在内部给组件写死字体这时候需要看看组件是否暴露了样式穿透的口子。5.3 canvas、web-view 和原生组件的特殊处理canvas 绘制文字需要单独设置字体而且必须等字体加载完成之后再绘制否则第一次绘制会用到系统字体且不会自动重绘。wx.loadFontFace({ family: CanvasFont, source: url(https://static.example.com/fonts/CanvasFont.subset.woff), success: () { const ctx wx.createCanvasContext(myCanvas); ctx.setFontSize(20); ctx.font normal 20px CanvasFont; ctx.setFillStyle(#333333); ctx.fillText(测试文字, 20, 40); ctx.draw(); } });注意ctx.font的写法遵循 canvas 的语法前面是样式和字重然后是字号和 family 名中间的空格位置不能乱。web-view 内部的网页是独立的渲染环境小程序注册的字体对它完全无效。网页里要用的字体得在网页自己的 CSS 里处理两边互不干扰。这一点经常被误解以为在小程序里注册一次就能全局通吃。部分原生组件上的文字不受自定义字体影响这类组件由系统原生渲染走的是系统字体。遇到文字样式怎么调都不变的情况先确认一下用到的组件是不是原生渲染的如果是那就只能换个实现方式比如改用普通视图加样式模拟。5.4 其他零碎坑排查表中还整理了其他几个我遇到过的典型问题现象可能原因解决方式字体加载耗时特别长字体文件没做子集化体积过大重新做子集化控制在 300KB 以内部分标点显示为系统字体子集化时漏掉了中文标点提取字符集时补全标点符号首次进入页面文字错位字体异步加载导致的切换跳动用接近的 fallback 字体或加加载态低版本设备不生效基础库版本低于接口要求降级到系统字体保证可用性换字体后线上没变化CDN 缓存未刷新文件名加版本号强制刷新缓存同一字体重复注册多个页面各自调用了一次提到全局注册或用缓存标记控制这张表基本覆盖了我这两年遇到的大部分情况。真正的排查顺序其实很简单先看是不是域名和网络的问题再看是不是命名和字重对不上的问题最后才怀疑渲染层和组件。按这个顺序走绝大多数问题十分钟内能定位。6. 踩坑之后的一些经验字体这块做完之后回头看最省时间的做法其实是一开始就把样本量降到最低。我见过有项目一口气注册了四五份字体中英文各一套、粗细各两套结果每个页面的首屏都在等字体用户体验反而更差。后来我改成只保留一份常规、一份粗体其他视觉需求用字号、间距、颜色去补效果并没有变差加载速度倒是好了一大截。另外一个深刻的体会是字体一定要在前端接入的早期就介入。等到页面都开发完了再发现字号对不上、行高算不准返工成本很高。字体的字形宽度会直接影响换行和截断逻辑做列表和标题的时候如果还在用系统字体估位置等真字库上了会发现部分文案超出一行或者高度不够。我的习惯是提前把字体接进最简页面先把标题、正文、按钮这三种文本类型的实际渲染效果跑一遍再开始铺页面。最后一个细节如果你做的是长期维护的项目建议把字体版本号一起打进 family 名字里比如BrandFontV2。这样换字体的时候老页面加载的是老版本新页面用新版本灰度过渡会平滑很多也不会因为 CDN 缓存没刷新导致一部分人看到旧字形、一部分人看到新字形。这个做法看起来有点笨但在多端多版本并存的真实环境里确实是麻烦最少的方案。