首页 / 资讯中心 / 文章详情

SCSS封装@font-face:打造可复用、高性能的字体加载方案

SCSS封装@font-face:打造可复用、高性能的字体加载方案 ★ FEATURED ARTICLE
做前端时间久了你会发现自定义字体这件事看着不起眼但几乎每个项目都要在这里折腾一阵。要么样式文件里散落着七八段手写的font-face要么字体路径一换就全体失效要么字重加载混乱导致页面文字忽粗忽细。这篇就聚焦一个核心问题如何用SCSS把font-face封装成一套可复用、可维护、性能合格的字体引入方案。内容会覆盖语法细节、mixin设计思路、字体格式兼容策略、实际落地代码和踩坑实录适合正在做项目工程化整理、或者被字体折腾过几次的前端开发者参考。1. 字体引入的痛点与font-face基础认知1.1 为什么自定义字体总是让人头疼先说说我见过的几种典型乱象。第一种是“复制粘贴流”每个组件样式里都放一段完整的font-face字体文件地址散落各处想换字体目录就得全局搜索替换稍不注意就漏掉几个。第二种是“格式混乱流”同一个字体家族有的地方声明了woff有的地方只放ttf老浏览器加载不到合适格式直接降级成系统字体视觉上和设计稿差出一大截。第三种是“字重无主流”只引了一个Regular字重页面里却用了font-weight: 600/700浏览器没有办法只能自己合成粗体斜体也同理合成出来的字形又糊又脏放大了看简直是灾难。这些问题的根源在于font-face本身是一个低层级的CSS规则它只负责“声明字体资源”并不负责组织、复用和性能策略。换句话说用原生CSS写字体引入就像在仓库里随手丢箱子没有统一货架时间一长必然乱套。SCSS恰好能补上这一层工程化能力变量管理路径、mixin封装声明、模块化组织文件把散落的状态收敛成单一入口。这也是这篇文章的出发点不是教你怎么写一段font-face而是教你如何用一套SCSS模块管住所有字体。生活里可以这样类比字体文件是货物font-face是货架标签SCSS则是仓库管理系统的货架编码规则。没编码之前找货靠翻编码之后取货靠查。1.2 font-face核心语法逐项拆解在封装之前先把font-face本身吃透。基础声明长这样font-face { font-family: MyFont; src: url(myfont.woff2) format(woff2), url(myfont.woff) format(woff), url(myfont.ttf) format(truetype); font-weight: 400; font-style: normal; font-display: swap; unicode-range: U0000-00FF, U4E00-9FFF; }逐项说明一下这些属性在封装时都会变成mixin参数。font-family是字体家族名它只在本项目内部作为标识使用不要求和你下载的字体文件名一致。但注意同一家族的多个字重应当共用同一个font-family值再用font-weight区分。如果每个字重都起了不同的家族名比如MyFont-Bold和MyFont-Regular那CSS里就没法通过font-weight切换字体必须整个font-family切换使用体验差很多。src是资源来源列表可以写多个url()浏览器会按顺序选第一个能识别的格式。这就是多格式兜底的基本原理。format()描述了资源格式千万别省略否则浏览器只能靠扩展名猜一旦猜错就可能导致资源下载了却不可用。另外src里还可以放local(FontName)用于优先使用用户本机安装的同一字体避免重复下载但用的时候要谨慎本地字体版本不可控容易出现和设计稿不一致的情况具体我在后面章节展开。font-weight和font-style很好理解就是声明这一份字体文件对应的字重和字形。注意这里支持数字和关键字两种写法比如400等价于normal700等价于bold但为了精确起见建议使用数字。因为有些字体家族字重很细有450、550这种中间值关键字表达不了。font-display控制字体加载期间的显示策略默认值是auto。这个属性的行为差异很大我后面会专门分析。unicode-range则声明了这份字体文件覆盖的字符区间是“按需加载”的重要抓手同样值得单独展开。2. SCSS封装方案设计让字体管理变得可控2.1 用变量统一管理字体路径与命名封装第一件事就是先定变量。字体相关的变量至少要有两个维度文件所在路径、字体家族的对外命名。实践中我的习惯是在styles/fonts/_variables.scss里统一维护// styles/fonts/_variables.scss $font-path: ../assets/fonts !default; $base-font-family: MyFont, -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif !default;$font-path是字体文件的目录。之所以抽成变量是因为在真实项目里字体目录经常变本地开发可能用src/assets/fonts打包发布后要用CDN地址跨平台协作时Windows和macOS的路径斜杠也不一样。抽出来后任何环境切换只需要改这一处。$base-font-family则是整个站点默认字体栈MyFont放在最前面后面跟一串系统回退字体。这个变量的作用不光在body里使用更重要的是在font-face声明里作为font-family字段统一引用保证“声明字体”和“使用字体”用同一个名字。如果两边各写各的一旦改名就要两处同步又回到复制粘贴的老路上去了。!default是个常用技巧意思是“如果这个变量在此前已经被赋值则保持原值否则用这里的默认值”。它的好处是允许业务层覆盖默认配置比如某个子系统导入这份模块前自己定义一份$font-path就能把字体指到自己的目录而不必修改公共模块源码。命名这块有一个容易踩的坑字体文件名尽量全小写、用连字符分隔不要用中文、空格或特殊字符。这不是洁癖问题而是字体文件会被打包工具处理很多构建工具在处理包含特殊字符的资源路径时会转义、重编码出了错极难排查。另外文件名最好带上字重标识比如myfont-regular.woff2、myfont-bold.woff2否则后面同时管理七八个文件时光看文件名完全分不清谁是谁。2.2 编写通用mixin一行代码引入字体路径和命名管住之后下一步是把font-face声明本身封装成mixin。一个相对成熟的mixin长这样// styles/fonts/_font-face.scss use ./variables as *; mixin font-face( $font-file, $font-family: $base-font-family, $font-weight: 400, $font-style: normal, $font-display: swap ) { font-face { font-family: $font-family; src: url(#{$font-path}/#{$font-file}.woff2) format(woff2), url(#{$font-path}/#{$font-file}.woff) format(woff), url(#{$font-path}/#{$font-file}.ttf) format(truetype); font-weight: $font-weight; font-style: $font-style; font-display: $font-display; } }这个mixin的关键参数只有第一个$font-file后面都有默认值。也就是说大多数时候你只需要传文件名前缀连文件扩展名都不用写。为什么我故意不把扩展名放进参数因为扩展名和格式应当是mixin内部策略而不是调用方关心的事情。如果调用方各自传woff2或ttf有人图省事只传一种格式又会造成兼容性参差不齐。把格式写死在mixin里才能保证全项目引用字体时的基础一致。调用方式非常简洁use ./font-face as *; include font-face(myfont-regular, $base-font-family, 400, normal); include font-face(myfont-bold, $base-font-family, 700, normal);注意这里多次include声明了同一font-family名、不同font-weight的字体最终编译产物是两段独立的font-face规则。浏览器会依据font-familyfont-weightfont-style的组合去匹配这正好符合语义化的用法CSS中只需写font-family: $base-font-family; font-weight: 700;就能命中粗体字体文件。有一点要提醒mixin本身只能把声明生成出来它管不了“重复引用”的问题。如果你的项目里_font-face.scss被多个业务模块各自use最终可能在编译后的CSS里生成多份相同的font-face。好在现代SCSS模块系统use天然解决了这个问题一个模块在一个CSS编译上下文中只会被执行一次。所以务必使用use而不是importimport不会做去重还会污染全局命名空间迁移成本也不小。我在早期版本中也尝试过用“哨兵变量”防重复类似if not $font-face-loaded再执行mixin的方式。但后来发现完全没必要只要用对use这套机制本身就保证了每个mixin在最终产物中只生成一次。冗余地加哨兵反而让代码复杂化这也是“能交给工具解决的就不该手写”的一个例子。3. 字体格式、性能与兼容性的平衡3.1 格式选择策略与兼容矩阵字体文件格式是绕不开的话题尤其在面向存量用户的项目里。把主流格式放一起比较格式压缩率支持情况适用场景woff2最优体积小Chrome/Edge/Firefox/Safari较新版本首选格式woff较好IE11、老版本浏览器中等兜底ttf/otf基本无压缩兼容性最广最后兜底eot压缩率一般仅IE8及更早版本基本可以放弃所以mixin里src的顺序就是woff2先、woff次之、ttf最后垫底。不要小看这个顺序它决定了现代浏览器的加载体积认识woff2的浏览器只会下载woff2文件后面的woff和ttf声明对它来说只是可选的备用分支不会真去下载。如果顺序写反有些浏览器会优先下载ttf白白浪费流量和加载时间。字体加载请求有个特点浏览器处理font-face的src列表时不一定全都发起请求而是解析到第一个匹配的format就会停住。这一点和图片srcset的“会尝试候选资源”策略不同字体src是严格顺序优先的。基于这个特性更合理的霸道其实是把最现代、体积最小的格式放最前面把兼容格式放后面让绝大部分用户都拿到最优体验老浏览器自动降级。local()的用法也在这儿一并说清。它是用来引用用户本机已安装字体的优先级在url()之前。什么时候值得用当字体文件非常大、而目标用户群体中大概率已安装该字体时能用local()避免一次网络请求。但隐患在于本地字体版本不受你控制如果用户装了某个老旧版本显示效果可能和你设计的字形不一致而且这个问题极难排查因为开发机上可能是对的到了用户设备上就变了。我的建议是除非你明确知道目标系统预装字体且版本可控否则尽量少用local()让字体始终从服务器加载。3.2 font-display与加载策略font-display是容易被人忽略却影响很大的属性。它决定了从“字体请求发出”到“字体可用”这段时间页面文字如何呈现。来看几个值的具体行为auto浏览器默认策略Chrome等通常表现为block策略。block字体加载期间文字始终不显示最多等待3秒3秒后还没有就用回退字体字体加载完成后立即替换。用户的直观感受是“白屏闪字”。swap字体加载期间立即展示回退字体字体下载完成后替换。直观感受是“字体跳动”。fallback前100毫秒不显示文字之后展示回退字体如果3秒内字体加载完成则替换否则本次不再替换。optional前100毫秒不显示之后展示回退字体但字体是否替换取决于浏览器对网络质量的判断弱网下很可能不替换。对正文类页面我个人最常推荐fallback。它比swap多了一层保护如果字体加载太慢用户不会看到文字在加载过程中反复跳动体验稳定。对图标字体则相反更适合block或swap。图标字体一旦显示成回退的空方块整页观感直接崩塌宁可等它加载出来也不要用占位字符撑3秒钟再变好。页面里既然配了fallback回退字体栈的合理性就显得很重要。在$base-font-family里不能只写一个系统字体必须把目标平台的默认字体都列上。比如中文用户大概率有微软雅黑或苹方字体栈里就加上macOS用户没装微软雅黑但一定有苹方顺序上先写苹方再写微软雅黑会更合适。字体栈的排列隐含了一套“逐个探测、取第一个存在”的逻辑这个细节决定了字体加载失败时页面是否仍然可读。3.3 unicode-range与字体子集化的实际应用unicode-range是性能优化的另一个关键点它允许在同一font-family下按字符区间拆分成多个字体文件浏览器只加载页面中实际出现字符对应的文件。中文场景最有价值一套全量中文字体动辄几MB到十几MB如果整包加载页面首屏性能基本必崩。真实项目里的常见拆法是把常用汉字区域和其他字符区域分开font-face { font-family: MyCNFont; src: url(mycnfont-latin.woff2) format(woff2); unicode-range: U0000-00FF, U2000-206F; font-weight: 400; font-style: normal; } font-face { font-family: MyCNFont; src: url(mycnfont-cjk.woff2) format(woff2); unicode-range: U4E00-9FFF; font-weight: 400; font-style: normal; }页面中如果只有英文和常见符号那就只触发mycnfont-latin.woff2的下载。只有页面里出现了中文才会向服务器请求CJK子集文件。这种“按需分段”策略能把首屏字体体积缩小到原来的十分之一以下。生成子集文件可以用OpenType工具链常见做法是使用pyftsubset配合字体文件处理。一个粗略的命令形如pyftsubset myfont.woff2-source --unicodesU0000-00FF,U2000-206F,U4E00-9FFF --output-filemyfont-subset.woff2这里要明确一点子集化不只是CSS层面声明几个字符区间它实际在字体文件层面裁掉了用不到的字符文件体积才会真正降下来。如果只声明unicode-range而字体文件本身仍是全量字符浏览器虽然能判断“这段子集不需要加载”但一旦某个页面确实需要使用这个字体仍然会下载全量大文件优化效果大打折扣。图标字体场景也常用unicode-range。很多图标字体其实就覆盖了E000-F8FF区域可以只加载这一段的子集。不过图标字体更常见的问题不是体积而是FOUT期间出现方块字这又回到上一节的font-display选择了。4. 实操一套完整的SCSS字体模块落地4.1 目录结构与任务拆分前面讲了理念这一节给一套可以直接套用的工程结构。假设项目使用Vite或WebpackSCSS目录里独立出一个fonts模块src/styles/ ├── fonts/ │ ├── _variables.scss │ ├── _font-face.scss │ └── index.scss src/assets/fonts/ ├── myfont-regular.woff2 ├── myfont-regular.woff ├── myfont-bold.woff2 └── myfont-bold.woff_variables.scss管路径和字体栈变量_font-face.scss管mixin定义index.scss是模块入口集中调用mixin完成所有字体的声明。业务组件直接use这个入口或者入口已经在全局样式里被引入业务侧只需要使用字体栈变量即可。这样拆的好处是职责单一路径变了改_variables.scss新增字重改index.scssmixin逻辑有调整只动_font-face.scss。后来者接手项目看到这个结构就能明白“字体一切事务在这里集中处理”不需要在几十个CSS文件里考古。4.2 完整代码实现把三份文件完整的示例写出来。_variables.scss// 字体文件基础路径部署到CDN时只需修改这一处 $font-path: ../assets/fonts !default; // 默认字体栈优先使用项目字体失败后按平台回退 $base-font-family: MyFont, -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif !default;_font-face.scssuse ./variables as *; mixin font-face( $font-file, $font-family: $base-font-family, $font-weight: 400, $font-style: normal, $font-display: fallback ) { font-face { font-family: $font-family; src: url(#{$font-path}/#{$font-file}.woff2) format(woff2), url(#{$font-path}/#{$font-file}.woff) format(woff), url(#{$font-path}/#{$font-file}.ttf) format(truetype); font-weight: $font-weight; font-style: $font-style; font-display: $font-display; } }index.scssuse ./variables as *; use ./font-face as *; // 在这里登记项目需要使用的字体字重 include font-face(myfont-regular, $base-font-family, 400, normal); include font-face(myfont-bold, $base-font-family, 700, normal);编译后的CSS会生成两段完整的font-face规则且因为use模块去重无论index.scss被多少业务模块引用最终产物里这两段规则只会出现一次。这一点用import是无法保证的import会把被引用文件的内容复制到每个引用处重复声明逃都逃不掉。4.3 在项目中如何调用字体声明好之后使用层面就非常简单了。全局设置可以直接打在body上// styles/global.scss use ./fonts as *; body { font-family: $base-font-family; // 这里不需要再写字体相关任何逻辑 }业务中某个局部要用粗体也只需要正常使用font-weight不需要关心字体文件在哪里.announcement-title { font-family: $base-font-family; // 继承全局其实可以不写 font-weight: 700; }真正顺畅的开发体验是字体文件的下载、格式兜底、加载策略都在模块内部解决业务代码里完全无感知。这也是SCSS封装font-face的核心价值——把复杂度收敛到一处而不是把复杂度扩散到所有调用点。如果你在用的是Webpack或Vite还有一个路径细节要提醒不要把字体文件路径写成纯绝对路径/assets/fonts。构建工具的public目录和资源打包策略各有差异直接写绝对路径在本地跑Git到服务器、或者部署到CDN时很容易出现“本地正常线上404”的诡异问题。更稳妥的方式是使用相对路径让构建工具根据文件引用关系重新计算资源地址配合output.publicPath统一调整发布位置。5. 常见问题与排查实录5.1 字体加载不显示的经典排查路径遇到“页面就是不出字体”的情况先按这套顺序排查能解决绝大部分问题。第一打开浏览器Network面板刷新页面过滤Font请求看字体文件有没有发出去。如果请求都没发问题大概率在CSS选择器或font-family值不匹配换言之你声明的字体名和实际使用的字体名不一致。第二如果请求已经发出但状态是404检查$font-path和index.scss里传入的文件名是否和实际资源文件完全一致包括大小写和扩展名。第三如果请求返回200但页面仍然显示系统字体点击请求看Response Headers里的Content-Type。woff2文件应为font/woff2或application/octet-stream如果返回的是text/html或text/plain说明服务器没有为字体扩展名配置正确的MIME类型需要让后端或运维调整Nginx等服务器的mime.types配置。第四如果一切看起来正常但只在某些浏览器里不显示多半是src顺序问题或者字体格式不被该浏览器支持回到第3章的兼容矩阵检查。还有一个非常隐蔽的坑font-family被别处覆盖。项目全局可能有一段样式把某个元素的font-family设成了别的字体栈而你测试时只改了自己写的class没注意到选择器优先级被覆盖了。排查这种问题直接在DevTools的Computed面板里找到继承的font-family值倒推是哪条规则把它覆盖掉的比在代码里乱翻快得多。5.2 字重与字体的坑字重问题是我见过最多、也最容易误判的一类。典型场景是页面里要显示加粗文字设计师给的字体包里只有Regular一个字重开发图省事直接写了font-weight: 700浏览器找不到700的字体文件自动用“合成粗体”模拟。模拟出来的粗体本质上是对Regular字形做了模糊加粗处理笔画边缘发虚在缩放屏幕上尤其明显。正规做法是拿到多个字重文件后给每个字重单独声明font-face就像_font-face示例里那样Regular和Bold分开声明。但要格外小心font-weight声明的匹配细节如果声明了Bold为700页面里同时用font-weight: 700和font-weight: bold都能命中如果声明的是600页面用了700就匹配不上又会退回合成。不要指望浏览器理解字重之间的“数学关系”它就是按精确值匹配的。另外同一字重下font-style: italic也要单独声明。很多人以为给文字加font-style: italic浏览器会调用同族的斜体字体文件其实不会。没有声明italic版本时浏览器只是把常规字形做了倾斜变换效果和字体设计好的斜体差别很大尤其中文无衬线字体倾斜后重心不稳能明显看出不是原生的。项目如果要用斜体就得在index.scss里补上一条相应字重的italic声明。5.3 字体加载性能与缓存策略字体文件有个特点通常变动不频繁但体积不小很适合长缓存。最佳实践是把文件名带上内容哈希比如myfont-regular.a1b2c3.woff2构建工具每次根据文件内容生成新的哈希内容不变文件名不变服务器可以设置Cache-Control: max-age31536000, immutable项目发布后浏览器会直接命中本地缓存不再发请求。内容一变文件名跟着变浏览器自然请求新文件不会出现“字体更新了用户还在看旧版”的情况。如果你的构建流程还没做这层处理至少也要保证字体请求带上合理的缓存头不要让它默认被当作普通动态资源每刷新一次就重新下载一次。关键字体的预加载也值得加上。在HTML中显式提前请求最重要的字体文件能显著降低首屏字体出现时间link relpreload href/assets/fonts/myfont-regular.woff2 asfont typefont/woff2 crossorigin注意两点一是asfont不能少少了浏览器不知道这是个字体文件预加载的优先级可能不够高二是crossorigin属性必须写对于字体请求即使你的站点本身是普通HTTP字体跨域请求也要走CORS模式不带这个属性会导致某些浏览器直接忽略预加载结果。另外只预加载首屏必需的字体不要贪多预加载太多反而会挤占其他更关键的请求带宽。图标字体或者超大字体在弱网下的表现我建议你实际用DevTools的Network节流测试一遍。设置Slow 3G刷新页面观察文字出现的时机和顺序很多性能问题在本地局域网毫无感知一上真实网络就原形毕露。字体加载策略这块提前用option模拟慢速网络调出来的结果比上线后用户反馈要靠谱得多。最后再分享一个实际项目中踩过的调整经验SCSS封装font-face看起来很工程化但它并不是银弹。字体管理最终拼的还是“约定”和“流程”。我见过有人把mixin写得极其华丽支持几十个参数结果团队里没人愿意用也见过只用三四个参数的简洁封装因为调用门槛低反而被所有人坚持使用。工具链再好也得团队里每个人愿意走这条路才有效。如果你的项目字体比较少那封装适度即可如果字体很多、经常换版本那这套方案的收益就会非常明显。字体加载的坑在每个项目里都不太一样但管理思路是通的希望这篇能帮你少走点弯路。
阅读完成 · 觉得有帮助?
咨询建站