☰
SCSS封装@font-face:打造可复用、高性能的字体加载方案
2026/10/5 10:40:59 网站建设 项目流程

做前端时间久了你会发现,自定义字体这件事看着不起眼,但几乎每个项目都要在这里折腾一阵。要么样式文件里散落着七八段手写的@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: U+0000-00FF, U+4E00-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等价于normal,700等价于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-family+font-weight+font-style的组合去匹配,这正好符合语义化的用法:CSS中只需写font-family: $base-font-family; font-weight: 700;,就能命中粗体字体文件。

有一点要提醒:mixin本身只能把声明生成出来,它管不了“重复引用”的问题。如果你的项目里,_font-face.scss被多个业务模块各自@use,最终可能在编译后的CSS里生成多份相同的@font-face。好在现代SCSS模块系统(@use)天然解决了这个问题:一个模块在一个CSS编译上下文中只会被执行一次。所以务必使用@use而不是@import,@import不会做去重,还会污染全局命名空间,迁移成本也不小。

我在早期版本中也尝试过用“哨兵变量”防重复,类似@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: U+0000-00FF, U+2000-206F; font-weight: 400; font-style: normal; } @font-face { font-family: 'MyCNFont'; src: url('mycnfont-cjk.woff2') format('woff2'); unicode-range: U+4E00-9FFF; font-weight: 400; font-style: normal; }

页面中如果只有英文和常见符号,那就只触发mycnfont-latin.woff2的下载。只有页面里出现了中文,才会向服务器请求CJK子集文件。这种“按需分段”策略,能把首屏字体体积缩小到原来的十分之一以下。

生成子集文件可以用OpenType工具链,常见做法是使用pyftsubset配合字体文件处理。一个粗略的命令形如:

pyftsubset myfont.woff2-source --unicodes="U+0000-00FF,U+2000-206F,U+4E00-9FFF" --output-file="myfont-subset.woff2"

这里要明确一点:子集化不只是CSS层面声明几个字符区间,它实际在字体文件层面裁掉了用不到的字符,文件体积才会真正降下来。如果只声明unicode-range而字体文件本身仍是全量字符,浏览器虽然能判断“这段子集不需要加载”,但一旦某个页面确实需要使用这个字体,仍然会下载全量大文件,优化效果大打折扣。

图标字体场景也常用unicode-range。很多图标字体其实就覆盖了E000-F8FF区域,可以只加载这一段的子集。不过图标字体更常见的问题不是体积,而是FOUT期间出现方块字,这又回到上一节的font-display选择了。


4. 实操:一套完整的SCSS字体模块落地

4.1 目录结构与任务拆分

前面讲了理念,这一节给一套可以直接套用的工程结构。假设项目使用Vite或Webpack,SCSS目录里独立出一个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.scss,mixin逻辑有调整只动_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.scss:

@use './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.scss:

@use './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-age=31536000, immutable,项目发布后浏览器会直接命中本地缓存,不再发请求。内容一变文件名跟着变,浏览器自然请求新文件,不会出现“字体更新了用户还在看旧版”的情况。

如果你的构建流程还没做这层处理,至少也要保证字体请求带上合理的缓存头,不要让它默认被当作普通动态资源,每刷新一次就重新下载一次。

关键字体的预加载也值得加上。在HTML中显式提前请求最重要的字体文件,能显著降低首屏字体出现时间:

<link rel="preload" href="/assets/fonts/myfont-regular.woff2" as="font" type="font/woff2" crossorigin>

注意两点:一是as="font"不能少,少了浏览器不知道这是个字体文件,预加载的优先级可能不够高;二是crossorigin属性必须写,对于字体请求,即使你的站点本身是普通HTTP,字体跨域请求也要走CORS模式,不带这个属性会导致某些浏览器直接忽略预加载结果。另外只预加载首屏必需的字体,不要贪多,预加载太多反而会挤占其他更关键的请求带宽。

图标字体或者超大字体在弱网下的表现,我建议你实际用DevTools的Network节流测试一遍。设置Slow 3G刷新页面,观察文字出现的时机和顺序,很多性能问题在本地局域网毫无感知,一上真实网络就原形毕露。字体加载策略这块,提前用option模拟慢速网络调出来的结果,比上线后用户反馈要靠谱得多。


最后再分享一个实际项目中踩过的调整经验:SCSS封装@font-face看起来很工程化,但它并不是银弹。字体管理最终拼的还是“约定”和“流程”。我见过有人把mixin写得极其华丽,支持几十个参数,结果团队里没人愿意用;也见过只用三四个参数的简洁封装,因为调用门槛低,反而被所有人坚持使用。工具链再好,也得团队里每个人愿意走这条路才有效。如果你的项目字体比较少,那封装适度即可;如果字体很多、经常换版本,那这套方案的收益就会非常明显。字体加载的坑在每个项目里都不太一样,但管理思路是通的,希望这篇能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询