尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SCSS实战指南:@font-face字体加载优化与性能调优

SCSS实战指南:@font-face字体加载优化与性能调优 做前端这么多年几乎每个成规模的网站都绕不开自定义字体。iconfont、品牌字体、特殊中文字体只要设计师交来一套字体文件紧随其后的就是怎么在项目里优雅地引入和加载。这个过程中font-face算是最基础、也最容易被用糙的特性。很多人写font-face还是复制粘贴老三样src里堆一串格式路径用相对路径也不管格式顺序结果字体要么不生效要么加载堵塞首屏。今天这篇不做泛泛的科普而是基于我在SCSS项目中实践过的方案完整聊聊怎么把font-face声明的定义、封装、性能优化和问题排查一次讲透。这适合刚接触SCSS的初级工程师也适合那些字体管理已经开始混乱、想系统性重构的老项目维护者。如果你只是想在页面里快速加一个iconfont这篇文章的内容也足够覆盖你要处理的所有底层逻辑。1. font-face 的核心工作机制1.1 六行声明背后到底发生了什么font-face表面上看只是几行CSS声明但它的执行机制远比“定义一个字体”复杂。浏览器解析到font-face规则时会把它当作一种“字体资源映射表”注册到页面字体系统中。这个映射关系包含多个维度font-family定义了字体家族的引用名src定义了字体源文件的路径和格式font-weight、font-style定义了这条字体规则参与匹配的生效条件font-display定义了字体加载期间文本的渲染策略。用一个最基础的形式来看font-face { font-family: BrandFont; src: url(../fonts/brand-regular.woff2) format(woff2); font-weight: 400; font-style: normal; }到这里页面上凡是声明了font-family: BrandFont且font-weight: 400的文本都会触发浏览器向目标地址发起字体请求然后使用该字体渲染。这个机制有一个关键点浏览器不会在页面加载时主动拉取所有font-face资源而是等到某个文本元素真正匹配到这条字体规则之后才按需发起请求。这就是为什么字体文件的请求时机有时候看起来“很晚”——它是被渲染过程驱动的。理解这一点之后你就会明白为什么字体声明中的font-weight和font-style必须写准确因为任何一点不匹配浏览器都会重新选择一组字体规则甚至完全放弃加载。1.2 字体格式的兼容性取舍早期的字体格式混战在今天已经基本收敛。现在的标准做法是优先加载woff2必要时用woff兜底ttf只在极特殊场景下保留。svg格式基本可以放弃它体积大、渲染慢除了十年前的老IE几乎没有任何现代浏览器依赖它。eot更是可以彻底丢掉的遗产。我目前主推的格式策略是只准备两份文件woff2面向所有现代浏览器体积收益最大woff作为老版本浏览器的回退体积稍大但在可接受范围。在src中按woff2在前、woff在后的顺序排列这样浏览器优先解析woff2一旦成功加载就用它不会浪费时间请求woff。反过来写会导致有些浏览器先下载woff哪怕woff2的压缩率更优。如果你用的是本地字体转换工具建议把原始字体文件ttf或otf保留在项目源码库之外单独存放。ttf格式只用于开发和转换中间产物不要直接打进前端静态资源目录。它体积大且没有压缩优势线上使用没有理由。1.3 src属性里三个最容易踩的坑src属性是整个font-face规则里最值得细抠的部分踩过的坑我一个个列出来第一个坑是local()的使用。很多人写src: local(BrandFont), url(../fonts/brand-regular.woff2) format(woff2);这行CSS的意思是如果用户本地已安装这个字体就直接用本地字体。本意是加快加载但副作用很隐蔽——本地字体的版本不可控如果用户系统里有一份旧的、风格完全不同的同名字体页面就会用它渲染导致视觉回归。真实案例中设计师的电脑上装了旧版字体页面预览正常项目经理的电脑没装字体就加载网络文件两边看到的效果不一样。排查这种问题非常耗时我现在的方案是如果自定义字体是品牌专用字体就完全不用下local()如果确实想兼顾已安装场景至少把local()放在url()之后并确认本地字体版本与线上一致。第二个坑是format()与实际文件格式不符。浏览器遇到src中格式声明与真实文件内容不一致时会拒绝使用该文件并继续尝试下一个候选源。这个不报错也不影响其他样式效果就是字体“神秘地不生效”。遇到加载了文件却渲染错误的情况先检查format是不是写错了。第三个坑是src内部的顺序。浏览器按顺序解析多个src一旦匹配成功立即使用不再回头尝试后面的。所以要把最优格式放在最前面。很多老模板喜欢先写eot或者svg这会让字体请求多走一次弯路。2. SCSS 封装把重复劳动变成配置项2.1 用Mixin免除重复的font-face手写在没有SCSS的时代每加一个字体族就要完整写一遍font-face规则通常还是复制上一段然后改文件名。这造成两个问题一是字体格式越来越多时需要同步改好几处二是项目里面对同一个字体族可能存在多处不一致声明。SCSS的mixin可以把这件事封装得极其优雅。这是我在项目中长期使用的基础版本mixin font-face($font-name, $font-path, $font-weight: 400, $font-style: normal) { font-face { font-family: #{$font-name}; src: url(#{$font-path}.woff2) format(woff2), url(#{$font-path}.woff) format(woff); font-weight: $font-weight; font-style: $font-style; font-display: swap; } }调用方式非常简洁include font-face(BrandFont, ../fonts/brand-regular, 400, normal); include font-face(BrandFont, ../fonts/brand-medium, 500, normal); include font-face(BrandFont, ../fonts/brand-bold, 700, normal);这里有一个值得注意的设计决策同一个字体族名用不同字重分别定义。这样在使用时font-family: BrandFont; font-weight: 500;就能精确匹配到对应字重文件浏览器也能按需加载不会多下载其它文件。比起“同一个字体族名绑定多个文件却无法精确区分字重”的老式写法这种每个字重独立声明的结构更利于浏览器做字体匹配也能让我们后续做preload时有机会按字重精准预加载。2.2 用 each 批量注册多个字体文件实际项目里字体经常不止一个族。有品牌字体、有中文正文字体、有数字专用字体每个字体又分好几种字重。手动一行行调用include font-face还是重复于是我把字体配置抽成了变量。先定义一个字体配置表$font-configs: ( BrandFont: ( regular: (file: ../fonts/brand-regular, weight: 400, style: normal), medium : (file: ../fonts/brand-medium, weight: 500, style: normal), bold : (file: ../fonts/brand-bold, weight: 700, style: normal), ), NumberFont: ( regular: (file: ../fonts/num-regular, weight: 400, style: normal), ) );然后写一个循环一次性注册所有字体each $family, $variants in $font-configs { each $variant-name, $variant-data in $variants { $file: map-get($variant-data, file); $weight: map-get($variant-data, weight); $style: map-get($variant-data, style); include font-face($family, $file, $weight, $style); } }这套方案的好处是把字体管理和页面结构彻底分离。以后设计加新字体只需要改一行配置不再需要搜索CSS里的font-face在哪儿。而且因为字体配置全部集中在一个文件里排查问题的时候也更省心。2.3 字体族命名的“隐藏坑”有朋友一直习惯把所有字重的font-family写成同一个名字然后期望靠font-weight来映射。理论上浏览器支持这样做但实际场景里有一个很头疼的问题如果某一条字体规则只声明了font-weight: 500页面里恰好有font-weight: 600的文本浏览器为了匹配会尝试“合成”粗体效果——也就是在不加载任何字体文件的情况下对400或500的字形做人工加粗。这类字形是浏览器算法生成的倾斜或加粗形态质感与原版字体完全不同。Font-family统一命名还会在变量化字体场景下更麻烦。如果你用的是可变字体字重是连续范围的命名需要更仔细。我的习惯是普通字重族统一用同一个名字可变字体单独用另一个名字并在变量配置中明确font-weight范围。如果你不确定自己该用哪种最简单的判断标准是字体文件是多个独立静态文件就统一命名并分开注册字重是单个可变字体文件就独立命名并在font-weight里声明范围。这样能避免浏览器在渲染临界字重时产生意外的合成字形。3. 字体加载策略与性能优化3.1 font-display 四个值背后的真实表现font-display是控制“字体文件未加载完时文本以什么方式呈现”的属性。很多人知道它是用来处理FOUT无样式文本闪烁的但四个值之间的细微差别却没完全搞清楚。我在项目里的实践经验可以对号入座值行为适用场景block字体加载期间隐藏文本最多等待3秒超时后先显示后备字体图标字体、品牌Logo等极端依赖字形的场景swap字体加载期间立即用后备字体渲染加载完成后无感切换普通自定义字体能接受闪烁fallback短暂等待100ms左右不等待则先用后备字体如果字体在3秒内加载完成则切换到自定义字体否则长期沿用后备字体页面正文既要性能又要效果optional几乎不等待浏览器自行决定是否下载字体弱网、移动端非关键文本实际项目中正文我基本都用fallback。它的等待窗口足够短用户体验上几乎感知不到文字闪烁但又给了字体文件一个在3秒内加载完成的机会。swap更常用于强调标题或数字因为这类文本对品牌视觉要求更高闪烁可接受。block我几乎不使用除了iconfont等特殊场景外绝大多数情况下3秒隐藏文本的体验都很糟糕。3.2 字体子集化把中文字体从几MB瘦身到几十KB最容易被低估的性能问题来自中文字体。一个完整的中文字体文件动辄几MB甚至十几MB直接加载基本等于宣告首屏报废。子集化是解决这个问题的必要手段——它把字体文件里用不到的字符删除只保留需要的字形。用Python工具链来做子集化流程非常稳定。首先安装fonttools库pip install fonttools brotli然后运行子集化命令保留常用字符集pyftsubset source-font.ttf \ --text其一二三四五六七八九十百千万a-zA-Z0-9... \ --flavorwoff2 \ --output-fileresult.woff2更现实的场景是生成中文字符集子集我一般直接用--unicodes配置区间pyftsubset source-font.ttf \ --unicodesU4E00-9FFF,U3000-303F,UFF00-FFEF \ --flavorwoff2 \ --output-filechinese-subset.woff2这样做可以把包含常用3500字的字体从5MB左右压缩到几十KB到一两百KB且视觉无损。这里要特别提醒子集化一定要保留数字、逗号、句号以及品牌词中涉及的特殊字符。很多项目只按常用表子集化后页面里商品价格数字变成了方框。最稳妥的做法是从实际页面文本中提取字符集去生成子集而不是用一个固定的字符表撞运气。我甚至会专门跑一段脚本扫描项目模板和文案把出现的字符去重后再交给子集化工具。3.3 预加载字体把关键请求提前即使子集化后字体文件已经很小字体请求的时机仍然可能拖慢渲染。原因是字体文件必须在CSS解析并触发渲染后才开始下载中间有额外排队时间。解决方式是使用link relpreload主动告诉浏览器提前下载关键字体link relpreload href/fonts/brand-regular.woff2 asfont typefont/woff2 crossoriginanonymous配套在SCSS中我经常给字体加一层“关键字体列表”管理需要preload的字体单独列出来在HTML模板中生成链接。这里有个隐蔽的坑preload字体链接必须携带crossoriginanonymous属性。如果漏掉浏览器会在控制台报CORS错误preload的请求被丢弃字体后续又要重新加载一遍。我在接入第三方字体时踩过这个坑排查了近半小时最后只是少加了一个属性。除了preload缓存策略也很重要。字体文件的Cache-Control建议设置为public, max-age3600, immutable因为是静态资源一般不会频繁变更。文件指纹hash可以进一步保障更新时不会撞缓存。Nginx或CDN上需要给woff2加上正确的MIME类型font/woff2。如果MIME不对有些浏览器的严格模式会直接拒绝渲染。4. 常见问题与排查实录4.1 字体文件不生效的排查清单字体不生效可能是前端让人最躁的问题之一因为它不报错肉眼看到的只是字体“不太对”。我的排查顺序一向固定从可能性最高的开始路径检查开发服务器下相对路径和绝对路径的解析规则不一样。SCSS里的url(../fonts/...)相对于的是编译后的CSS文件位置不是SCSS源文件位置。很多项目在这个环节就错了。格式检查打开Network面板看看字体请求是不是200Response的类型是不是font/woff2或font/woff。如果Content-Type是application/octet-stream或text/plain大概率是服务器MIME没配好。src顺序检查确认woff2在第一位避免浏览器先加载woff老格式。font-family匹配检查元素上的font-weight是否与font-face声明的对应字重完全一致。差一个数值都会导致匹配失败。浏览器缓存检查开发时改过字体文件可能浏览器还留着旧缓存。无痕窗口试一次能排除缓存干扰。4.2 中文站的字体加载策略中文项目做自定义字体和英文项目是两个世界。英文只有26个字母加数字子集化后几KB就能搞定中文常用字就有3500多个无论怎么压缩体积都摆在那里。这个现实决定了策略上的收敛。我的做法是优先保证数字和英文使用定制字体中文正文继续用系统字体栈。系统字体栈里用好现代的UI字体组合比如font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif;这样做的好处是首屏完全不载入中文字体性能没有负担。如果设计师对标题的中文字体有硬性要求那再做子集化而且只覆盖页面标题中实际出现的汉字。之前我参与过的一个营销站就是这样几十个页面标题的中文字符加在一起才一千多个字子集化后字体文件从4.8MB降到了96KB体验提升巨大。4.3 开发环境中的字体处理细节SCSS开发里还有个常见问题Webpack或Vite构建时字体文件引用路径会因public路径配置变化而变化。最稳妥的方案是把字体文件的引用交给构建工具处理而不是手写一个可能被改写失败的相对路径。Vite中url()的路径如果写成/fonts/xx.woff2默认会走public目录如果写成相对路径构建工具会尝试打包并重写路径。在实际项目中我的做法是所有字体文件统一放在src/assets/fonts/下SCSS中url(../assets/fonts/xx.woff2)让构建工具自动处理指纹和路径重写。这样开发环境拿到的路径和线上发布后的路径会有区别但构建工具会自动修正不需要人工干预。需要注意本地开发时如果对字体目录做了软链接要确认开发服务器是否允许静态文件按目录访问。某些dev-server配置会拦截静态资源请求导致字体404。这种现象在Windows上做项目时尤其常见UNIX系统下相对较少。还有一个细节字体文件名里的空格会导致路径解析异常。不管用什么工具转出来的字体文件命名时都去掉空格和特殊符号用连字符连接。这个习惯能免掉非常多的隐性麻烦。5. 工具选型与SCSS组织结构的最后建议很多时候字体问题的根源不是某一句CSS写错了而是整个字体管理流程没有形成规范。我记得有一个项目字体配置分散在四个文件里每份措辞还不太一样排查问题时来回翻最后统一收编到一个_fonts.scss才恢复正常。SCSS最大的优势在于组织性用得好能极大幅度降低字体声明的维护成本。我建议把字体相关的SCSS独立拆分成一个_fonts.scss局部文件里面只放变量、mixin和字体声明。通过use引入到入口文件而不是散落在多个页面级SCSS中。这样做的好处是改动任何字体配置开发者只需要打开一个文件。关于工具选型再补充一句字体转换和子集化首选本地CLI如fonttools、woff2_compress不要依赖在线转换工具。在线工具的隐私问题和体积上限是硬伤偶尔产出损坏文件也不在少数。本地工具配合CLI脚本可以固化流程晚些时候想重新生成子集只需要重复执行一条命令。我自己在每次字体改动后都会加一道验证步骤用本地浏览器打开页面检查Network面板确认字体请求数量、格式、大小都在预期内。如果有额外请求说明有某个字体规则没被匹配上我就能在提测前发现。说实话font-face本身并不是一个多复杂的CSS特性字体文件也不难获得真正复杂的从来都是方案选择和组织方式格式选什么、字体怎么命名、加载策略是什么、子集化怎么做、文件放在哪里。把这些环节固定的方案沉淀成公司内部文档或者脚手架代码后续任何新项目都能直接复用不会再为了字体来回踩坑。我个人最深的体会是字体性能优化是一个投入产出比极高的“冷门”方向。大多数人只关注图片和接口却忽略了字体文件可能占据页面体积的大头。如果你正在做一个内容型的站点别等到线上首屏出现大段空白文字才想起来排查字体那时可能已经损失了不少访问。早点把字体策略这关过了后面一路都舒服。
返回列表