接手一个 uniapp 项目的时候,产品丢过来一份带三十多个图标的视觉稿,让我在微信小程序里实现。第一反应当然是引 iconfont 字体图标:在 H5 项目里这东西几乎是零成本,复制一段 CSS 就能用。结果开发工具里跑得挺欢,一预览到真机,满屏方块。更麻烦的是,这个项目已经进入提测阶段,换方案意味着所有页面都得动一遍。
这篇文章不打算科普 iconfont 是什么,而是把“在 uniapp 项目里引入 iconfont 字体图标,并让它在微信小程序端稳定可用”这条链路完整拆开。我会讲清楚选型、引入方式、微信小程序的加载限制,以及我实际排查“白屏/方块”问题时的完整思路。如果你正在被小程序图标问题折磨,这篇应该能帮你少走不少弯路。
1. 为什么小程序端的图标方案,绕不开 iconfont
1.1 先对比一下几种图标方案的取舍
在动手之前,很多人的直觉是:微信小程序里用图标,直接切图不就行了?确实可以,但项目一多,问题就来了。
切图方案的核心问题是多端适配。同一个图标为了兼容不同 DPR,通常要准备 1x、2x、3x 三套图,光命名就要花点心思;要是产品中途要换图标颜色,就得重新导出。更不用说一张张图片挤进 2MB 主包体积后的罪恶感了。
SVG 方案在 H5 里体验很好,但微信小程序对动态 SVG 的支持并不友好,尤其是use引用外部 symbol 的方式,在多数小程序基础库下都容易出兼容问题,真机上偶发不渲染。uni-app 的 HBuilderX 虽然能对部分 SVG 做处理,但坑比想象中多:矢量图里夹杂少量位图信息时,经常直接编译失败。
字体图标本质上是把图标做成字体文件,然后用文字的方式渲染。它在小程序端的优势非常明显:单个 ttf 文件,颜色可以由 CSS 的color控制,大小用font-size控制,不引入额外图片请求,代码里维护的只是一串类名。这也是为什么很多 uniapp 老项目最终都会落到 iconfont 上。
1.2 font class 方式在小程序里的渲染原理
font class 方式能工作,靠的是 CSS 的@font-face声明一个自定义字体族,比如:
@font-face { font-family: "iconfont"; src: url("iconfont.ttf?t=123456") format("truetype"); }声明之后,每个图标本质上是一个字符编码。font class 方式进一步把这些字符编码包装成了伪元素:
.icon-home::before { font-family: "iconfont"; content: "\e601"; }你在页面里写<text class="iconfont icon-home"></text>,浏览器或者小程序运行环境就会把.icon-home::before渲染成一个文字,而这个文字的字体是 iconfont,所以显示出来的就是一个图形。
WXSS 对::before伪元素是支持的,所以这套机制在微信小程序里天然能跑通——前提是@font-face里的字体源正确加载了。后面要讲的坑,几乎全部集中在这个“加载”环节。
1.3 三种接入方式的取舍
iconfont 平台有三种使用方式:Unicode、Font class、Symbol。
| 方式 | 实现原理 | 小程序端兼容性 | 推荐度 |
|---|---|---|---|
| Unicode | 直接写字符实体,例如 | 可用,模板里难维护 | 低 |
| Font class | CSS 伪元素注入字符编码 | 可用,最推荐 | 高 |
| Symbol | SVG 雪碧图 +<use>引用 | 兼容性不稳定 | 低 |
Unicode 方式的问题在于可读性:页面里出现一坨字符实体,谁看了都头疼;Symbol 方式在小程序端经常碰到<use>引用不生效的问题;而 font class 方式对模板、样式、JS 动态绑定都非常友好。所以我在项目里最终采用的就是 font class 方案,后面所有内容也围绕这个方式展开。
2. 从挑图标到下载字体包:工程选型阶段就决定生死
2.1 只挑实际用到的图标,不要贪多
很多人在阿里的 iconfont 平台上一打开图标库,先不管三七二十一,全选加进购物车,下载下来再说。这是后面包体积失控的第一个隐患。
一个全量字体文件通常有 150KB 到 300KB,你可能实际用到的不到二十个图标,剩下一大半都是无效载荷。把没用的字符编码从字体里剔除,文件大小可能直接从 200KB 降到 10KB 左右。这个差异在小程序 2MB 主包限制下,完全是两个概念。
所以从 pick 图标开始,就应该严格按设计稿里的实际清单来:先整理一份“用到的图标名字列表”,再到平台里一个一个添加。图标多的时候可以在项目里建分组,按业务模块区分,方便后续维护。
2.2 下载包里哪些文件真正有用
在 iconfont 平台选择 Font class 方式下载,得到的包里有demo.css、demo_index.html、iconfont.css,以及一堆字体文件,比如:
iconfont.eoticonfont.svgiconfont.ttficonfont.wofficonfont.woff2
这些文件不是全都有用。.eot是给老版本 IE 用的,.svg是给早期 iOS 用的,现代场景下基本可以无视。微信小程序端最稳的是.ttf,部分基础库对.woff的兼容性在不同安卓机型上表现不一,为了避免玄学问题,我一般只保留.ttf,并在 CSS 的src里也只保留truetype这一条。
如果你发现下载包里没有.ttf,可以在 iconfont 平台的项目设置里调整“字体格式”选项,确保勾选了 TTF。
2.3 把字体文件安排进 uniapp 工程
推荐的项目目录结构是单独建一个目录,比如common/iconfont,里面放iconfont.css和iconfont.ttf:
src/common/iconfont/ ├── iconfont.css └── iconfont.ttf然后把这个 css 全局引入。uniapp 项目里最稳妥的做法是在App.vue的<style>里引入,因为App.vue的样式默认是全局生效,不会被scoped限制:
<style> @import './common/iconfont/iconfont.css'; </style>这里有个容易被忽略的坑:如果你把@import放到某个页面的<style scoped>里,编译后字体文件的相对路径可能会基于当前页面目录去解析,导致在真机上找不到字体文件。局部页面的图标正常,其他页面的图标全是方块。所以 iconfont 的全局样式,就老老实实放在App.vue的全局 style 里。
3. 微信小程序的字体加载机制与路径问题
3.1 为什么 H5 里好好的相对路径,到小程序里就失效
很多人的第一版做法,是把 iconfont 下载包里的 css 原封不动拿过来,里面src写的是相对路径:
@font-face { font-family: "iconfont"; src: url("iconfont.ttf?t=123456") format("truetype"); }这套写法在 H5 页面里完全没问题,浏览器会顺着相对路径去请求字体文件。但微信小程序不是浏览器,它的 WXSS 对 CSS 里 URL 资源的处理能力非常弱。uniapp 编译器不会像 webpack 处理图片那样,把这个url()里的字体文件自动打包成一个可用的资源引用。
结果是:开发工具里因为本地文件能直接读取,看起来是正常的;等到了真机上,小程序环境根本不知道这个相对路径对应哪个资源,字体自然加载失败,图标就退化成一个个方框或者干脆什么都不显示。
3.2 三套可用方案:base64 内嵌、网络地址、loadFontFace
针对这个限制,我从项目实践里总结出三种比较靠谱的姿势。
第一套:把字体文件转成 base64 内嵌到 CSS 里
这是最省心、也最稳的方案,不依赖外部域名,不受微信小程序合法域名校验的限制。做法很简单:把.ttf文件转成 base64 字符串,然后拼到src里:
@font-face { font-family: "iconfont"; src: url("data:font/truetype;charset=utf-8;base64,AAEAAAA...") format("truetype"); }在终端里可以先转出 base64 文本:
base64 -i iconfont.ttf -o iconfont_base64.txt然后把内容替换进iconfont.css。这种方案的缺点很直白:base64 会让字体文件体积膨胀大约 33%,而且全部写进 CSS 后,这个 css 文件本身会变大不少。但它换来的是一劳永逸的稳定性,特别适合一个项目里图标种类有限、变化不频繁的场景。
第二套:把字体放到 CDN,使用网络地址加载
如果你对体积非常敏感,可以考虑把iconfont.ttf传到自己的 CDN,然后 CSS 里写完整 HTTPS 地址:
@font-face { font-family: "iconfont"; src: url("https://your-cdn.example.com/fonts/iconfont.ttf") format("truetype"); }但微信小程序对网络资源有严格校验。字体文件请求会被认为是下载类请求,需要在小程序公众平台后台的“开发管理-服务器域名”里配置 downloadFile 合法域名。如果只是本地调试,要在微信开发者工具的“详情-本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。我记得很久之前有个项目忘记配这个域名,结果图标只在开发工具里显示,真机上一片空白,排查了半天才发现是域名校验的问题。
第三套:用 wx.loadFontFace 动态加载
微信小程序官方提供了wx.loadFontFace接口,可以在运行时加载网络字体。这个 API 的好处是加载时机可控、页面切换时不阻塞渲染,适合字体文件较大、只在个别页面用到的场景。但它同样走网络请求,域名校验问题绕不开,而且加载是异步的,首次渲染时图标会出现短暂空白闪烁。我在项目里只在“某个独立活动页必须用一个特殊字体”时用过,普通的 iconfont 集成不推荐优先考虑它。
3.3 打包体积临界点:base64 膨胀与 2MB 主包限制
uniapp 编译微信小程序时,主包体积限制是 2MB。网上经常看到有人报错source size 2612kb exceed max limit 2mb,这种问题很多时候就是 iconfont 的 base64 体积惹的祸。
计算一下:如果一个全量 iconfont 字体文件是 250KB,转成 base64 后大约是 333KB。这还只是一个字体文件。如果项目中再有一些引用的本地图片、公共 js 库,很容易逼近体积红线。
控制体积的几个关键手段,按优先级我建议这样做:
- 从源头收紧:下载图标时只挑实际使用的,这比任何后端压缩都管用,因为我见过一百个图标的字体文件压缩到只剩 15KB 后,效果依旧完美。
- 使用字体子集化工具:如果已经下载了全量字体文件,可以用
font-spider这类工具,分析出真正被content引用的字符,生成只有这些字符的新字体文件:
前提是你准备一个包含所有图标类名的临时 HTML 文件,让工具去抓取。npx font-spider ./index.html - 把不常用的页面拆成微信小程序分包:如果你用的是 base64 方案且图标数量较多,可以把一部份图标的 CSS 从
App.vue挪到对应的分包页面里,这样至少不会把所有体积都堆进主包。但要注意,同一样字体被复制到多个分包,总包体积未必划算,操作前先算一笔账。 - 最后才考虑网络字体方案:网络字体不占用本地包体积,但要接受字体加载延迟和域名配置成本。如果项目对首次渲染要求很高,需要配合骨架屏或者按需加载去优化体验。
4. 封装 Icon 组件:日常开发中最高频的几类用法
4.1 一个基础 Icon 组件应该长什么样
直接在页面模板里写<text class="iconfont icon-home">不是不行,但时间久了你会发现每个页面都要关心前缀、样式、事件,代码十分零散。我习惯封装一个Icon组件,把细节收敛起来。
<template> <text class="iconfont" :class="'icon-' + name" :style="{ fontSize: realSize, color: color }" @click="$emit('click', $event)" ></text> </template> <script> export default { name: 'AppIcon', props: { name: { type: String, required: true }, size: { type: Number, default: 32 }, color: { type: String, default: '' } }, computed: { realSize() { return this.size + 'rpx' } } } </script>这里有两个细节值得注意:
- 微信小程序里的
<text>组件和 HTML 的<i>标签行为不完全一样,它的默认font-size在某些机型上会被重置。所以封装组件时最好不要依赖继承字号,显式设置fontSize是最稳妥的。 - 颜色同样要显式传,不加
color时字号可以继承,颜色却未必符合预期,尤其当页面里其他元素有独立文字颜色时。
4.2 动态图标、循环渲染和状态切换的写法
项目里最常遇到的不是静态图标,而是动态图标。比如列表数据里每个 item 的图标名来自接口,或者点击按钮后图标要在“展开/收起”之间切换。
动态绑定的写法注意别漏掉iconfont这个基础类:
<AppIcon :name="item.iconName" :size="28" color="#333" />如果你是在v-for里使用,且图标名来自接口字段,我建议使用数组形式绑定类名,避免字符串拼接可能带来的前后空格问题:
<text :class="['iconfont', 'icon-' + item.iconName]"></text>状态切换的时候,常见做法是计算属性里根据条件返回不同的 name:
computed: { iconName() { return this.expanded ? 'fold' : 'unfold' } }这种方式比在模板里写一堆v-if分支清晰得多,后续要加第三种状态时,也只改一处计算逻辑。
4.3 tabBar、多色图标等边角问题
老实说,字体图标并不是万能的。微信小程序的原生 tabBar 只支持图片格式,iconPath必须是本地 png/jpg 路径,网络图片、base64 图片都不行。所以 tabBar 里的图标,基本还是要走切图路线。如果你特别想用字体图标方案,只能通过自定义 tabBar 组件实现,但需要自己处理页面切换、选中状态、安全区适配这些细节,成本并不低。我的项目里是直接让设计导出 png 的。
另一个问题是多色图标。font class 方式渲染的图标,颜色完全由 CSS 的color控制,所以它本质上是单色图标。iconfont 平台里很多精美图标是多色的,这类图标如果坚持用字体方案,会发现颜色永远不对。处理方式有几种:找同图形的单色版本,或者把多色图标单独截成小图。不要把多色图标强行塞进 font class 里,我试过一次,最后不得不重新走图片方案,白折腾一轮。
5. 一次真实的白屏排查:开发工具正常、真机不显示
5.1 从“显示方块”到锁定字体加载失败
我印象最深的一次,是项目已经准备提测了,同事突然跑过来告诉我“首页图标在安卓真机上全是方块”。开发工具里看是完全正常的,预览二维码扫出来却不正常。
遇到这种问题,第一件事不是改代码,而是确认现象的真实原因。我当时按这三步走:
- 打开微信开发者工具的“真机调试”,把页面跑起来,看 console 里有没有字体加载失败的报错。
- 在开发者工具的 Sources 面板里,检查一下
iconfont.css是不是真的被编译进去了,字体文件有没有作为一个资源被打包。 - 在页面样式里搜索
@font-face,确认它声明的src在真机环境下还能不能访问。
最终问题定位得很清晰:CSS 里的src是相对路径url("iconfont.ttf"),H5 端能正常加载,但小程序真机上找不到对应文件,整个@font-face声明就形同虚设,图标自然全部塌方成方块。
5.2 修复过程:相对路径换成 base64,步骤全记录
决定用 base64 方案后,我操作的完整过程是这样:
第一步,先备份原来的iconfont.css,避免改一半想回退找不到原文件。
第二步,把字体文件转成 base64:
base64 -i iconfont.ttf -o iconfont_base64.txt第三步,打开生成的文件,把整段 base64 字符串复制出来,替换掉iconfont.css里的src:
@font-face { font-family: "iconfont"; src: url("data:font/truetype;charset=utf-8;base64,AAEAAAA...") format("truetype"); }第四步,删掉工程里对原iconfont.ttf的引用,避免 HBuilderX 打包时仍然把字体文件误塞进包里占体积。
第五步,微信开发者工具里清一次缓存:“工具-清除缓存-全部清除”,然后重新编译、真机预览。
这里我说一个特别实际的排查点:改了 CSS 之后,开发工具如果还在用旧缓存,页面显示正常很容易让人误以为已经修复完成。但实际上真机可能还是旧代码。所以提交前一定要清缓存、重新预览,最好换一台之前没连着开发工具的测试机扫码验证。
5.3 预防复发:工程层面的几个固定习惯
这个坑踩过一次之后,我后续所有 uniapp 小程序项目都固定了几个习惯:
- iconfont 的 CSS 永远放在
App.vue全局样式里,不放进单个页面的 scoped 样式。 - 字体文件进工程之前,先检查
src是不是 base64,如果是相对路径,当场处理,不等到真机出问题再补。 - 下载图标时只选实际使用的,并把图标分组管理,这样即便以后要生成新字体文件,改动范围可控。
- 每次改完 iconfont 相关文件,都走一遍“清缓存-重新编译-真机预览”三步,这不是矫情,是真机环境才有最终发言权。
平时开发时,真机上出现图标问题的概率并不高,但一旦出了,排查成本往往远高于从一开始就采用稳妥方案的代价。我在实际项目里还有一个小技巧:如果一个页面有大量动态图标,且图标数量经常变动,可以在接口数据里直接返回图标名字,前端只维护一套映射。这样即使后续图标更换,也不会牵扯到样式文件,只要 iconfont 平台侧重新生成了对应的字符编码,更新 CSS 声明即可。这样的工作方式,在交付给别的同事接手时也能减少很多沟通成本。