微信小程序外部字体导入:wx.loadFontFace 与子集化避坑
发布时间:2026/9/30 19:27:26来源:尧图网络
上周一个做校园跑腿小程序的朋友找我说设计稿上那个圆润的手写体标题在微信小程序里怎么都还原不出来font-family写了跟没写一样最后只能截图当图片用。这个场景我太熟了——微信小程序导入外部字体看着是个小需求但真动手做从字体格式、跨域、基础库版本到真机差异处处都是暗坑。小程序不像 H5 可以直接挂一个font-face就完事它有一套自己的字体加载 APIwx.loadFontFace还有一堆平台限制。这篇就按我实际项目里跑通过的流程从方案选型、字体文件处理、代码落地到真机排查一条条拆给你看。不管你是做校园跑腿、婚礼邀请函还是品牌电商类的小程序只要涉及自定义字体这套流程都能直接抄。如果你只是刚接触小程序、连app.json都还没摸熟也别急着划走我会把每一步为什么这么做讲清楚。1. 小程序字体为什么这么难搞需求背景与方案选型1.1 三个真实场景逼着你必须换字体先说清楚什么时候你真的需要导入外部字体而不是瞎折腾。第一种是品牌类需求电商、餐饮、活动页设计稿里那个字就是甲方花钱买的品牌字用系统默认的黑体替代整个页面的调性直接掉一个档次视觉还原度上不去验收就过不了。第二种是内容型需求比如婚礼邀请函小程序、手写风格的心情记录本、儿童教育类的识字卡片这类场景对手写体、圆体、毛笔字体的依赖度极高字体本身就是产品体验的一部分。第三种是 Canvas 海报绘制做分享海报的时候ctx.font如果用不了自定义字体用户辛辛苦苦填的名字只能用系统字体渲染导出图片的质感很差。除了这三种还有一种是被动触发的场景你在用 uni-app 或者 HBuilderX 打包发布小程序时发现 H5 端字体好好的小程序端就是不出来这时候你才意识到两端渲染机制根本不一样。这个坑我在用 uni-app 打包微信小程序的项目里踩过不止一次后面会单独讲。1.2 四种可行方案的横向对比小程序里让自定义字体生效说起来能落地的方案就四种各有各的适用边界我先把对比摊开你对着自己的场景挑。方案实现方式体积影响生效范围适用场景主要缺点wx.loadFontFace网络地址source 传 HTTPS 字体链接主包零体积页面级或全局中大型字体、多页面复用需配 CORS、需域名白名单、首屏有延迟wx.loadFontFace base64source 传 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;charsetutf-8;base64,....)这种 base64 格式。直接丢一个裸链接进去是不行的这个格式要求官方文档写得不算显眼很多人第一次就栽在这。global参数决定字体的生效范围默认false表示只在当前页面生效改成true就是全小程序生效。这个参数要求基础库 2.10.0 及以上。如果你的app.json里 lowest 基础库版本设得比较低global可能不生效得先确认基础库配置。全局生效的好处是只在app.js里加载一次坏处是加载时机偏早如果字体文件大会跟首页渲染抢资源这个后面性能那节细说。desc参数用来声明字体的样式特征包括stylenormal / italic、weightnormal / bold、variant、stretch。如果你的字体是粗体版本一定要在这里声明 weight 为 bold否则在某些机型上系统会认为这个字体不匹配粗体请求然后回退到默认字体表现就是我明明加载了粗体字体页面上却是细的。这个坑我在一个活动页项目里排查了大半天。3. 字体文件准备从下载到子集化压缩3.1 拿到字体文件后的第一件事授权确认字体文件从哪来开源字体一般能在官方仓库或者发布页直接下载到 TTF / OTF / WOFF2 格式品牌字体通常由设计团队提供可能是 OTF 或 TTF。拿到文件先做两件事确认授权覆盖线上嵌入场景确认字体名称用字体查看工具看内部 family name这个名称和你文件名可能不一样。3.2 用 fonttools 做子集化这一步能省 90% 的体积中文字体动辄十几兆直接丢上去加载用户等着转圈吧。子集化就是只保留你实际用到的字符把其余字形全部裁掉。工具首选fonttoolsPython 生态里最成熟的一个。# 安装brotli 是导出 woff2 格式必需的 pip install fonttools brotli # 方式一直接指定字符集 pyftsubset SourceHanSansSC-Regular.otf \ --text限时秒杀新人专享立即抢购 \ --output-filesubset.ttf \ --flavorwoff2 # 方式二从文件读取字符集推荐方便维护 pyftsubset SourceHanSansSC-Regular.otf \ --text-filechars.txt \ --output-filesubset.woff2 \ --flavorwoff2 \ --layout-features* \ --no-hinting几个参数值得解释。--flavorwoff2表示输出 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-age31536000, 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 classbrand-title stylefont-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写错了整条都不生效。三是旧版 Canvaswx.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: boldinput / 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 渲染模式下的字体作用域如果页面开启了 Skylinescopes里只写webview是不够的得把native也加上否则部分节点根本拿不到字体。第二个是字体加载完成后的重绘时机有些复杂页面在success回调里立刻调用setData反而会触发一次额外渲染稳妥的做法是让字体自然生效别去手动触发。第三个是基础库版本的分水岭我现在的项目最低基础库基本都设到 2.10.0 以上就是为了用global参数和scopes低于这个版本的项目字体方案要多写不少兼容代码收益不划算。第四个是关于lazyCodeLoading这类优化如果你开了按需注入字体加载模块要确保被正确引用别被摇树摇掉了这种问题在小程序里排查起来特别费劲。写到这里其实核心就一句话把字体文件准备好、把服务器配好、把加载时机控好剩下的都是细节。我做过的手写风格工具类、婚礼邀请函、品牌活动页这几个项目用的都是同一套流程从选字体格式到上线顺利的话一个下午就能搞定前提是别在 CORS 和基础库版本上浪费时间。字体这东西不复杂但它是那种你以为简单一动手就卡住的典型按流程走一遍后面就都是肌肉记忆了。
网站建设高端定制企业官网