做了几年小程序开发,我几乎每周都能看到有人发同样的问题:开发工具里图标显示得好好的,一到手机预览就变成小方框。换了字体文件、清了缓存、关了域名校验,怎么折腾都不行。这个问题的本质其实很简单——你网页版的 iconfont 走的是线上字体请求,到了微信小程序环境里,外部字体资源默认是不让加载的。与其去研究各种绕路方案,不如直接把字体文件转成 base64,真正实现离线引入。这篇文章把完整思路、操作步骤、体积控制方法,还有我踩过的坑一次性讲清楚。适合正在被小程序字体图标困扰的开发者,无论你用的是原生开发还是自定义组件,这套方案都通用。
1. 为什么 iconfont 在微信小程序里会变成方框,而不是一上来就贴代码
1.1 图标不显示的直接原因:字体文件根本没加载成功
网页版 iconfont 的运行方式很简单:页面里写一个<span class="iconfont icon-home"></span>,浏览器根据@font-face里声明的src: url('//at.alicdn.com/.../iconfont.ttf')发起一个字体文件请求,下载成功后在页面里绘制出对应字形。
这套逻辑在浏览器里成立,换到小程序环境就开始出问题。小程序对网络请求有严格管控,所有请求都必须走后台配置的合法请求域名,字体文件同样属于被管控的范畴。你在开发工具里看着没问题,是因为开发者工具默认勾选了“不校验合法域名”;一到真机,校验开启,字体请求直接就断了。
请求被拦截的后果就是字体没有注册进当前页面。图标本来应该显示特殊字形,结果系统只能用默认字体渲染那个码位,最后你看到的要么是空白方框,要么是一个带数字的方框。很多同学遇到这个问题后的第一反应是怀疑字体文件坏了、类名错了、或者是某个样式冲突了,其实绝大多数情况下就是字体网络请求没通过。
1.2 网上常见的 wx.loadFontFace 方案为什么也不省心
搜过这个问题的人一定看过类似代码:
wx.loadFontFace({ family: 'iconfont', source: 'url("https://xxx.com/iconfont.ttf")', success: () => console.log('加载成功') })这个方法确实能够动态加载网络字体,但它依然绕不开两个问题。第一,source 指向的如果是一个线上 URL,这个 URL 同样要满足合法域名校验,没配置白名单还是加载失败;第二,wx.loadFontFace 是异步加载的,页面初始化时图标区域会先渲染成默认字体,字体文件下载完成后才重新绘制,这中间会有肉眼可见的“闪字”过程,尤其在弱网环境下体验很难看。
还有个隐蔽问题:部分基础库版本对wx.loadFontFace的全局字体支持做得不彻底,你明明只调用了一次,但实际上它可能没有注册到所有页面,导致某些路由跳转后图标突然变成方框,排查起来特别费劲。
我并不是说这个 API 没有存在的价值,但用它来加载一整套 iconfont 图标库,明显有点舍近求远。既然我们有更彻底的离线方案,为什么不直接用呢?
1.3 三种主流方案的取舍对比
我把几种常见实现方式放在一起实际对比过,这也是我给团队做技术选型时整理的一张表:
| 方案 | 是否需要域名白名单 | 是否离线可用 | 开发配置成本 | 体积占用 | 适用场景 |
|---|---|---|---|---|---|
| 网络字体 URL 在线引用 | 是 | 否 | 高,需要申请域名、配白名单 | 不占包体,但首次加载慢 | 不推荐 |
| 本地字体文件,WXSS 里写相对路径 | 否 | 是 | 低,但小程序对字体文件路径支持很差 | 占包体 | 基本不可行 |
| base64 内联进 WXSS | 否 | 是 | 极低,转换一次永久复用 | 占包体,膨胀约 33% | 推荐,适合中小型图标集 |
第一种方案你可能已经尝试过了,稍微做过小程序的人都会遇到合法域名校验的坑。因为你在开发工具里勾选“不校验合法域名”后确实能正常跑起来,于是一旦真机出问题,很容易误以为是代码写错了。
第二种方案也挺迷惑人:很多教程说把 iconfont.ttf 丢进项目里,然后用@font-face { src: url('/fonts/iconfont.ttf') }引入。小程序对本地资源路径的支持其实很严格,尤其当字体文件放在分包或者自定义组件的相对目录下时,很多时候根本编译不进去,或者路径解析莫名其妙出错,字体文件就成了一个打包时被忽略的孤儿文件。
第三种 base64 方案是目前我在项目里默认采用的。它的核心思想很简单:既然小程序不让我在运行时下载字体文件,我就在编译期把字体文件本身变成一段纯字符串,直接嵌进样式文件里,字体定义随着代码包一起发布,完全不需要网络请求。对小程序这种“一次打包,离线运行”的环境来说,这是最符合平台特性的方式。
2. 动手前准备:iconfont 资源整理与 base64 转换
2.1 从图标库下载资源时,真正需要的只有两个文件
要从字体图标平台导出你需要的一套图标,操作上大家应该都比较熟了:把需要的图标加入项目,然后点击下载代码。解压后你会发现压缩包里有好几个文件,大部分实际用不上。
需要明确的要点是:我们真正需要的只是一个 TTF 字体文件,以及一份图标名称和码位的对应关系。
压缩包里通常会有iconfont.css、iconfont.ttf、iconfont.woff、iconfont.svg、demo_index.html。其中:
iconfont.css很重要,里面的.icon-xxx:before { content: "\e600" }就是类名和 Unicode 码位的映射表,后续写小程序样式时直接依赖它;iconfont.ttf是 base64 转换的主要对象,建议选 TTF 而不是 WOFF 或 WOFF2;demo_index.html只是一个本地预览页面,可以丢弃。
为什么选 TTF?原因很简单:小程序 WXSS 对字体格式的支持以 TTF 最稳妥,真机兼容性最好。WOFF2 压缩率虽然更高,但在小程序里不是所有机型的基础 WebView 都认得它,遇到一次兼容性翻车会让你怀疑人生。不用为了省那十几 KB 体积去冒这个险。
压缩包里还有一个容易忽略的点:如果图标平台默认生成的 TTF 文件包含整个项目里所有图标,而不是只包含你这次新增的图标,那么这个文件的大小可能已经很大了。你真正用到的图标可能不到十分之一,但字体文件里打包了全部字形。这个问题我们在后面子集化部分专门处理。
2.2 base64 转换的三条路,总有一条适合你
拿到 iconfont.ttf 之后,下一步是把二进制文件转成一段纯文本的 base64 字符串。这一步有非常多的实现方式,挑一种你顺手的就行。
第一种方式,使用命令行工具,macOS Linux 都适用:
base64 -w0 iconfont.ttf > iconfont_base64.txt-w0表示输出时不换行。如果没有这个参数,生成的字符串默认每 76 个字符就插入一个换行符,后续复制进 CSS 时要手动清理断行,非常麻烦。Windows 的 PowerShell 可以使用[Convert]::ToBase64String([IO.File]::ReadAllBytes("iconfont.ttf"))。
第二种方式,使用 Python:
import base64 with open('iconfont.ttf', 'rb') as f: data = base64.b64encode(f.read()).decode('utf-8') with open('iconfont_base64.txt', 'w', encoding='utf-8') as f: f.write(data) print('base64 length:', len(data))第三种方式,用在线转换工具。注意选一个支持上传本地文件的工具,上传后直接在输出框复制结果。用在线工具要特别小心一件事:有些工具会在输出里自动带上data:font/truetype;charset=utf-8;base64,前缀,有些不会,还有些会强制换行。你最终只需要那个纯 base64 的字符串,前缀和换行都不需要。
我个人更推荐命令行或者 Python 脚本,因为后面每次更新图标库都要重新转一次,脚本可以一直保留在项目里重复使用,比打开网页手动复制稳定得多。
2.3 先给字体“减减肥”:子集化只保留你实际会用的图标
如果你打算引入的图标数量只有十几个,做完 base64 转换后,字体文件大概也就 3-8KB,直接用是没有问题的。但如果你图省事,把项目里一百多个图标全部导成一个 iconfont.ttf,转换出来的 base64 字符串很可能突破 200KB,放在 WXSS 里会让整个样式文件变得极其臃肿,主包体积也会被推高。
所以我的习惯是先做字体子集化:从字体文件中抽取出我真正会用到的几个码位,丢弃其余所有字形,生成一个新的小字体文件,然后再对这个小型字体做 base64 转换。
子集化工具我推荐 fonttools,一个 Python 库,自带命令行工具 pyftsubset。安装方法一条命令:
python3 -m pip install fonttools安装好之后,从 iconfont.css 中把你需要的图标码位拿出来。比如样式文件里写着.icon-home:before { content: "\e600" },那么这个图标的 Unicode 码位就是e600。把所有需要保留的码位整理出来,执行:
pyftsubset iconfont.ttf --unicodes=U+e600,U+e601,U+e602,U+e603 --output-file=iconfont-subset.ttf执行完成后也会输出一行类似Glyph order: 4 glyphs的结果,确认一下数字是否和你保留的图标数量一致。如果发现少了,继续往--unicodes参数里补。
这里我强烈建议把这条命令直接写入项目的 README 或者一个 shell 脚本里,因为图标库总会有新增的一天。到时候你只需要往--unicodes后面追加新码位,重新跑一遍命令,再用 2.2 节的方法转 base64,最后替换 WXSS 里的字符串即可,全过程不需要任何人工整理,也不会遗漏某个图标。
3. 在 WXSS 中引入 base64 字体的完整实操
3.1 @font-face 的正确写法,以及三个最容易踩的细节
字体文件转成 base64 之后,真正落到 WXSS 里的代码其实非常少。我习惯在全局app.wxss最顶部定义:
@font-face { font-family: 'iconfont'; src: url('data:font/truetype;charset=utf-8;base64,这里放你生成出来的超长base64字符串') format('truetype'); font-weight: normal; font-style: normal; } .iconfont { font-family: 'iconfont'; font-size: 32rpx; font-style: normal; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }这里有几个细节值得单独展开。
第一个细节是 MIME 类型。网上有很多教程写的是data:application/font-ttf;charset=utf-8;base64,,虽然很多时候也能生效,但在某些旧版本基础库上解析会出问题。保险起见,TTF 文件统一写data:font/truetype;charset=utf-8;base64,。如果你是拿 WOFF 文件转的 base64,则应该写成data:font/woff;charset=utf-8;base64,,并且格式描述改成format('woff')。
第二个细节是 replace 字符串里绝对不要包含换行。我见过一些在线转换工具输出的 base64 是分成了好几行文本的,直接复制到 WXSS 里也不报错,但会造成样式表编译异常,而且排查起来特别困难。所以请确保你粘贴进去的是一整行连续字符。
第三个细节是format('truetype')一定要保留。虽然缺省时大多数基础库也能识别出来,但加上之后可以让字体解析器直接跳过不必要的猜测流程,减少兼容性隐患。
3.2 图标类名与字体定义怎么关联
上面定义的 font-family 只是一个字体的标识名字,它本身不会自动让所有图标类生效。你还需要参照图标库平台生成的 CSS,把对应的类名和码位关系写进 WXSS。
原平台的 CSS 格式大致是这样的:
.icon-home:before { content: "\e600"; } .icon-cart:before { content: "\e601"; }你可以直接把它们复制到 app.wxss 中,把双引号改成单引号,然后结合.iconfont基类一起使用。完整形式如下:
.icon-home:before { content: '\e600'; } .icon-cart:before { content: '\e601'; }这一小步其实很容易出错,我再强调一次:content 里写的是反斜杠\加十六进制码位,不是让编译器直接把\e600当成英文字符输出,而是表示码位 U+E600。不要把转义符写成双反斜杠\\e600,也不要在码位前多加空格。
处理完后,你在 WXML 里的使用方式可以有两种:
<text class="iconfont icon-home"></text>这是一种方式。另一种是直接在 text 里写实际字符:
<text class="iconfont">我 的某个图标字符</text>实际字符在编辑器里很难看,复制错一个码位就会显示成另一个图标,所以我强烈建议使用类名方式,至少从源码上可读性好很多。
3.3 用 text 而不是 view,偏门但关键的渲染细节
在 WXML 里展示字体图标的标签,我统一用<text>。
原因是在小程序的渲染机制中,text组件对字体渲染的支持更稳定,而且它对内部文本的继承规则更明确。如果你用view去承载图标,虽然不是一定出问题,但在某些 Android 机型和基础库版本上,图标偶尔会渲染成一个虚框或者位置偏移。换成text后这类现象几乎绝迹。
当你想调整图标大小时,直接给 text 加 font-size 样式:
<text class="iconfont icon-home" style="font-size: 44rpx; color: #ff6600;"></text>需要注意,图标的大小不是通过 width/height 控制的,字体图标本质上是“字”,所以必须用 font-size 撑大缩小;颜色也不是用背景色,而是用 color 控制。
3.4 自定义组件里字体不生效,一多半是样式隔离闹的
如果你的项目使用了自定义组件,在组件内部直接写<text class="iconfont icon-home"></text>,很可能会发现图标渲染不出来,但同一个类名放到页面里又完全正常。
这是因为小程序自定义组件默认开启样式隔离,全局 app.wxss 里的.iconfont、.icon-home:before这些样式不会渗透到组件内部。换句话说,组件内部既没有找到 iconfont 这个基类的 font-family,也没有找到伪元素的 content。
这个问题有三种解法,按推荐程度排序:
第一种,在组件的 wxss 文件中导入公共字体样式文件。把 3.1 和 3.2 里的 @font-face、 .iconfont、所有 icon 类名统一放到一个独立的font.wxss,然后在组件 wxss 顶部写:
@import '../../styles/font.wxss';第二种,在组件 json 里关闭样式隔离:
{ "options": { "styleIsolation": "apply-shared" } }这种方式会让 app.wxss 的样式影响组件内部,但副作用是全局其他样式也会渗透进来,可能带来一些你预料不到的覆盖问题。我只建议在内部工具类组件里使用,正式业务组件还是别这么干。
第三种,直接把 @font-face 和图标类定义写到组件自己的 wxss 里。缺点是一旦组件多了,每个组件都复制一份字体定义,会产生大量重复代码,而且这段超长字符串会分别在每个组件代码包里占一份体积。所以除非是临时调试,我不推荐这种方案。
4. 体积、性能与真机兼容性:上线前必须检查的几件事
4.1 base64 体积膨胀的数学账,你得心里有数
base64 编码的规则是把每 3 个字节的二进制数据映射成 4 个 ASCII 字符。也就是说,任意二进制文件转成 base64 后,体积大约膨胀 33%。
举个具体的数字:iconfont.ttf 原始大小如果是 50KB,转换后 base64 字符串大约 67KB;100KB 的文件会变成大约 133KB;200KB 则变成 267KB。
小程序主包大小限制是 2MB,如果再加上业务代码、图片资源,一个 200KB 的 WXSS 文件放在主包里是相当吃紧的。当然,如果你的全部代码包还有充足余量,并且你只用十几个图标,这部分体量完全可以忽略;但如果图标集很大,就必须结合 2.3 节的子集化手段先把字体压下来。
| 原始字体大小 | base64 后大约体积 | 放在主包中的感受 |
|---|---|---|
| 5KB | 6.7KB | 几乎无感 |
| 50KB | 67KB | 可以接受 |
| 100KB | 133KB | 有点扎眼,建议子集化 |
| 300KB | 400KB | 明显膨胀,必须优化 |
4.2 子集化把 180KB 压到 4KB 的实测记录
我做过一个内部管理类小程序,图标库全量导出的 TTF 文件是 180KB,里面大概有二百多个图标。但实际上用户在前端会用到的只有 20 个左右,于是我用 pyftsubset 提取了这 20 个码位。
子集化之后生成的字体文件只有 3.6KB,再转成 base64 大约 4.8KB。也就是说,从 240KB 左右的 base64 字符串缩小到 4.8KB,体积减少了 98%。这样的 WXSS 放在全局包里,负担几乎可以忽略不计。
实际操作里可能有人会问:那我以后想加图标怎么办?很简单,在图标平台上把新增图标加入项目,重新下载 TTF,然后把你之前用的那串--unicodes参数拿出来,在后面追加新码位,重新执行一次子集化,再转 base64,替换原来的字符串,同步补上新的.icon-xxx:before定义。整个过程不超过一分钟。
4.3 真机测试的检查方法,别只看开发工具
开发工具显示正常不意味着真机一定没问题,这句话在字体图标方面也同样成立。上线之前一定要按下面几条做检查:
先把调试基础库调到正式环境常用的版本,用真机调试模式打开项目。打开 vConsole 面板,观察控制台有没有字体加载相关的报错。因为 base64 已经内联进样式,正常情况不会产生任何字体请求,也看不到网络层面的请求记录。如果控制台干净,说明字体这条链路是通着的。
接下来分别在 Android 和 iOS 上各看一遍实际渲染效果。iOS 的字体渲染相对稳定,Android 则因为机型碎片化,偶尔会有 WebView 对超长 base64 字符串解析较慢的问题。注意选择一两台低端 Android 机型做测试,在低端机上如果首屏图标区域出现短暂空白或者延迟渲染,多半是字体定义放在了某个页面组件内部而不是全局定义位置,导致页面渲染时字体还没就绪。
还有一个小检查点:用 1px 的字体大小去显示图标是没有意义的,图标会糊成一团。如果你的图标看起来像个小黑点或者小方块,先确认 font-size 是不是给成了 20rpx 以下。字体图标虽然没有“像素对齐”一说,但尺寸太小确实会影响观感,通常我会把最小尺寸控制到 32rpx。
4.4 网络加载问题到底还要不要处理
用了 base64 方案之后,你不需要再配置任何合法的字体域名,也不需要在小程序后台添加下载白名单。开发工具那个“不校验合法域名”的勾选,可以依个人习惯保留也可以取消,都不会影响字体图标的加载。
很多开发者被网上过时的教程带偏,一直以为字体图标离不开线上 URL。base64 方案的好处就在于把运行时的问题变成了编译时的问题:字体数据已经碾进样式文件里了,不存在“请求失败”“超时”“域名校验不通过”这些状态,你只需要关注包体积和样式本身的正确性。
5. 高频问题与排查技巧实录
5.1 改了 @font-face 但图标依然不显示,按什么顺序排查
如果你严格按照上面的步骤做完,图标还是不显示,先别慌,按以下顺序逐项排查。
第一,在开发者工具中打开调试器,切到 Sources 面板,找到编译后的 app.wxss,搜索你的 font-family 名字。如果你能看到一个超长的 base64 字符串,说明字体定义已经编译进去;如果这里找不到内容,问题出在构建或文件引用上。
第二,临时在页面放一个测试节点,比如<text class="iconfont">字</text>,看它能不能显示出一个正常的中文字符。这个测试的目的不是渲染图标,而是验证字体注册本身是否生效。
第三,检查图标类的 content 是否被覆盖。某些第三方组件库的全局样式可能对:before伪元素做了统一定义,把你的 content 值覆盖掉了。可以在页面 WXSS 里写一个更高优先级的规则尝试修复:
.icon-home:before { content: '\e600' !important; }如果加了 !important 之后能显示,说明问题确实出在样式覆盖上。不过不要长期依赖 !important,找到覆盖来源才是正解。
5.2 开发工具正常,真机不行,大概率还是老问题
这种情况网上聊得最多。先说结论:真机不行基本就是字体没有成功打进包里或者被请求拦截了。base64 方案已经解决了请求拦截的部分,所以你要重点检查是不是字体定义压根没有随页面一起发布。
常见的现象是,字体的 @font-face 写在了一个单独页面的 wxss 里,而那个页面又被设置成了分包页面,分包还没有正式加载,字体自然不存在。解决办法就是回到全局 app.wxss 里定义,保证任何页面访问前字体都已经存在。
另一个隐蔽情况是开发者工具里样式实时编译正常,但真机上读取的是旧的代码包。清缓存、重新编译预览,确认上传的版本里包含最新 app.wxss。
5.3 图标显示为带叉方框、空心方框或者乱码符号,分别代表什么
经常有人不分情况地用一个词“图标不显示”来概括所有现象,其实方框的类型会告诉我们具体出了什么问题。
空心方框,就是那种四四方方的白色框,代表字体没有加载成功,或者 font-family 根本没有应用到该元素。优先检查字体定义是否在全局生效,以及组件样式隔离。
带十六进制数字的方框,比如框里显示 e600 这样的字符,代表字体加载成功了,但当前字体文件里没有这个码位对应的字形。这种情况在子集化之后特别常见:你的码位列表漏掉了某个图标,或者图标的 Unicode 值在更新字体文件后发生了变化。
乱码或者奇怪的黑色符号,通常是 content 写错了。确认你在 WXSS 里写的是'\e600',而不是把双引号里的"\e600"换成了错误转义,更不要把 e600 前面的反斜杠漏掉。
5.4 分包场景下最容易出现的重复定义问题
有些开发者会觉得,既然自定义组件里要用字体,那我就在每个组件的 wxss 里都放一份 @font-face,这样组件不就独立了吗?
物理上确实能跑,但代价是同一个超长 base64 会在不同分包里各占一份体积,一个项目里放三份,100KB 的字体就变成 300KB,这是完全没必要的浪费。
正确姿势是:字体定义只在全局出现一次。业务分包里的组件如果要使用图标,通过 import 的方式引用全局的 font.wxss,这个 import 本质上是把类名和字体定义在编译时共享,不会重复占用字体体积。如果确实遇到 import 路径过于繁琐的问题,可以用 3.4 节提到的关闭样式隔离来做临时处理,但要在代码注释里标明原因,免得后来维护的人踩坑。
5.5 更新图标库时的固定操作流程
现在把维护流程整理成一个模板,你照着做就行:
- 重新下载包含新增图标的 TTF 文件,覆盖旧的 iconfont.ttf;
- 从新生成的 CSS 文件中找到所有需要的码位(旧码位保留,新增码位追加);
- 执行 pyftsubset 命令,生成新的子集字体文件;
- 用 base64 命令或 Python 脚本将子集字体转成字符串;
- 替换 app.wxss 中的旧 base64;
- 如果新增了图标类,把对应的
.icon-xxx:before规则同步到全局样式; - 真机预览,肉眼检查新增图标是否显示正确,原有图标是否有变化。
这套流程我走了很多遍之后,建议你把步骤 3 和 4 的命令行固定在一个 shell 脚本里,参数预留出来,每次只需要修改 unicodes 列表。这样既能避免手滑漏命令,也能快速让新同事上手。
5.6 一个小习惯:把 @font-face 放在 app.wxss 最顶上
这不算什么技术难题,但确实能省去一些看着像玄学的问题。小程序页面样式和全局样式的加载顺序并不是完全可控的,如果页面自己的 wxss 在前面定义了一个同名 font-family,等 app.wxss 加载完再注册字体,就有可能在首帧渲染时出现短暂的无字体状态。
把 @font-face 放在 app.wxss 第一行,配合 3.1 节提到的 format 声明和 TTF 格式,能最大程度保证页面一开始渲染时就拿到正确的字体定义。这个小习惯在后续项目里帮我减少了很多次“刷新一次好一次”的诡异问题。
我个人在实际项目里的体会是:字体图标不显示,先想字体有没有真正加载进来,再想码位写没写对,别一上来就上网找一段复杂的封装。把图标数量控制在真实需要的范围内,始终坚持子集化加 base64 的组合拳,这套方法是可以稳定复用到任何小程序项目里的。后面图标库更新了,也不需要重新梳理整套逻辑,替换的就是一段字符串和几行 CSS 而已。