☰
前端字体加载优化:用SCSS与@font-face构建可维护的字体管理方案
2026/10/9 9:09:41 网站建设 项目流程

做前端这些年,几乎每个项目都逃不过跟自定义字体打交道。刚入行那会儿我都是手写 @font-face,一个字体一个字体地复制粘贴,文件一多就乱成一锅粥;中文字体动辄好几兆,加载慢不说,还会闪一下默认字体,体验很差。后来把字体声明全部收进 SCSS 统一管理,配合子集化、font-display 这些手段,才算把这块理顺了。这篇文章不打算把官方文档复读一遍,而是从实际项目出发,记录我在 SCSS 里高效组织 @font-face 的完整方案:从语法细节、字体格式取舍,到用 Map 和 Mixin 批量生成规则,再到预加载、子集化、可变字体这些进阶玩法,最后把真实踩过的坑也一并交代清楚。不管是刚接触自定义字体的新手,还是被字体加载性能折磨过的老手,这篇应该都能给你点有用的东西。

1. 为什么前端项目需要一套字体管理的"基础设施"

1.1 裸写CSS的@font-face有哪些问题

很多项目最开始是这样处理字体的:去字体平台下载一个字体包,解压之后丢进fonts/目录,然后在 CSS 里写:

@font-face { font-family: 'MyFont'; src: url('../fonts/myfont.ttf') format('truetype'); }

单看这一条规则,没有任何问题。但项目一复杂,问题就全冒出来了。

首先是重复代码。一个稍微正规点的网站,正文可能要 400 和 700 两个字重,标题要 900,再配一个斜体、一个图标字体,这就是四五个 @font-face 块。代码一多,大家就开始复制粘贴,有人复制的时候把font-family写错,有人漏掉了font-weight,还有人把两个字重的src指向同一个文件,页面上的"伪粗体""伪斜体"就这么来的。

其次是格式覆盖的问题。只放一个ttf,老 IE 不认,新浏览器能认但体积吃亏;只放woff2,老设备又可能空白。正确的做法是按浏览器能力分层提供多个格式,但手写的话,每加一个字体就要把这一大坨再写一遍,谁都不愿意碰。

再就是使用处的混乱。font-family命名随意,有人用中文名,有人用英文名,还有人直接把字体文件名当 family 名用。到后面想换一款字体,你根本不知道全局有多少个地方写死了旧名字,只能全文搜索硬改。

这些问题的根源不是 CSS 不行,而是字体的声明没有变成"可维护的数据"。SCSS 刚好能补这个位。

1.2 SCSS在字体管理上能补位的三个环节

SCSS 解决字体管理问题,核心就三件事:集中、抽象、批量。

第一,集中。字体文件路径、字重、格式这些信息,全部收进一个变量或 Map 里,项目里只有一个地方维护字体清单。换字体源、加字重,改一处就行。

第二,抽象。把 @font-face 的完整写法封装成 Mixin,所有字体声明走同一个函数,格式顺序、font-display这类容易被忽略的属性由 Mixin 统一保证,不会有人写漏。

第三,批量。配合@each循环,十套字体和一套字体的代码量差不多。你只需要在数据列表里加一行记录。

此外还有一层间接好处:SCSS 变量可以把 font-family 堆栈也一起管理起来。正文用哪套、标题用哪套、代码用哪套,统一在变量文件里定义,使用处只写font-family: $font-stack-body。这样字体层面和业务样式彻底解耦,后面调整字体优先级顺序也不会翻遍全项目。

所以我的建议是:哪怕你现在只有一个字体文件,也值得从一开始就走这套结构,等字体多起来你就能体会到差异了。

2. @font-face语法拆解:src顺序、format声明与各格式现状

2.1 src列表的书写顺序为什么不能乱

@font-face 的语法看起来简单,但src这一行的顺序有大学问。浏览器的解析逻辑是:从src列表的第一条开始往下找,碰到第一个它能识别的格式,就下载这个文件,然后停止。

所以woff2必须放第一位。如果你把ttf写在前面,现代浏览器就会乖乖下载那个体积大好几倍的ttf,明明后面的woff2它也能用,但顺序决定了它不会"择优录取"。

我习惯的写法是这样:

@font-face { font-family: 'MyFont'; src: url('fonts/myfont.woff2') format('woff2'), url('fonts/myfont.woff') format('woff'), url('fonts/myfont.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; }

这里有两个细节要注意。

第一,format()里的字符串要写对。woff2就写'woff2',woff就写'woff',ttf要写'truetype',otf要写'opentype',eot 写'embedded-opentype'。不写format()的话,浏览器会尝试靠文件扩展名猜,多数情况下能猜中,但一旦服务器返回的 Content-Type 不对,或者文件路径没有扩展名,就可能加载失败。写了format()相当于给了浏览器一个明确的承诺,解析更可靠。

第二,不要在@media查询里嵌套 @font-face。虽然个别新规范讨论过允许嵌套,但老版本 Safari 和部分安卓内核碰到这种情况直接忽略字体声明,而且字体本身是全局资源,按媒体查询条件去加载本来就不合理——你难道希望手机上访问的时候字体规则突然消失?正确做法是全部放到顶层,用font-display、unicode-range这些属性来控制实际加载行为。

2.2 本地字体优先:local()的隐藏价值

src里还有一个local(),作用是先检查用户本机有没有安装同名字体,有的话直接用本地的,不发起网络请求。这个对自定义字体本身用处不大,因为用户电脑上不太可能有你自创的字体,但对中文系统字体兜底方案非常有用。

比如你的设计稿里指定了苹方(PingFang SC),但 Windows 用户没有苹方,你只能靠woff字体文件兜底。这时候可以在 @font-face 里把local('PingFang SC')写在第一个:

@font-face { font-family: 'PingFang-Fallback'; src: local('PingFang SC'), url('fonts/pingfang.woff2') format('woff2'); }

这样 macOS 用户直接走系统字体零加载,Windows 用户才下载那个 woff2。

但local()有两个坑。第一,名字必须精确匹配系统字体注册的 family name 或 PostScript name,大小写都不能错,写错了浏览器找不到就跳过,问题不大;真正麻烦的是第二个——老版本 Chrome 在local()指向不存在的字体名时,曾经出现过后续url()也不下载的 bug,表现就是字体区域空白。所以local()里的名字一定要在真实设备上验证过,不要凭空写。

2.3 WOFF2/WOFF/TTF/EOT:现在该怎么选

给字体文件选格式,本质上是在"体积"和"兼容范围"之间做权衡。我把主流格式的情况整理一下:

格式压缩方式与体积兼容情况我的建议
WOFF2Brotli 压缩,体积最小现代浏览器全支持(2018 年后)首选,必放第一位
WOFF老牌压缩格式,体积适中IE9+,几乎所有现代浏览器第二候选
TTF/OTF基本无压缩,体积最大几乎所有浏览器都能认最后兜底
EOTIE 专用格式仅 IE8 及更早可以扔了
SVG 字体矢量字形的特殊格式早期 iOS 用过已淘汰,别用

以现在的时间点看,一个面向普通用户的站点,提供woff2 + woff两份基本就够覆盖了,最多再补一份ttf给那些诡异的旧环境。只面向内部工具的站点,我甚至直接只放 woff2,省心。

当年做项目还要操心 EOT,现在真没必要了。IE 都进了博物馆,为一个早就没人用的内核多维护一份格式、多增加一堆字节,纯属给自己找事。

3. 用SCSS的Map与Mixin批量产出@font-face规则

3.1 第一步:定义字体数据Map

现在进入正题:在 SCSS 里怎么把字体声明变成一套可维护的结构。我的做法是,单独建一个_fonts.scss文件,里面只放两样东西:字体数据、生成规则。

字体数据我用一个 List of Map 来存,每个 Map 代表一个 @font-face 需要的全部信息:

$font-face-config: ( ( 'family': 'MySans', 'file': 'fonts/my-sans-regular', 'weight': 400, 'style': normal, 'display': swap, ), ( 'family': 'MySans', 'file': 'fonts/my-sans-bold', 'weight': 700, 'style': normal, 'display': swap, ), ( 'family': 'MyMono', 'file': 'fonts/my-mono-regular', 'weight': 400, 'style': normal, 'display': swap, ) );

file字段我故意不写扩展名,因为后面要同时拼.woff2和.woff。这样每新增一个字重,只需要在列表里追加一个 Map,路径、字重、展示策略一目了然。

这里有个设计决策要说明:我选择了"同一个 family 名 + 不同字重"的方案,而不是"每个字重一个独立 family 名"。两种方案都有人用,但我推荐前者的理由是——使用处写起来自然,font-family: 'MySans'; font-weight: 700;符合直觉,也不会因为漏写 family 名导致字体完全失效。独立 family 名(比如MySans-Bold)的优点是兼容性上限高,老浏览器表现更稳定,但代价是使用处心智负担重,团队里很容易有人问"这俩有啥区别"。二选一的话,我选同 family 多字重。

3.2 第二步:写一个通用的字体生成Mixin

数据有了,接下来写一个从数据生成 @font-face 规则的 Mixin:

@mixin font-face-rule($config) { @font-face { font-family: map-get($config, 'family'); src: url('#{map-get($config, 'file')}.woff2') format('woff2'), url('#{map-get($config, 'file')}.woff') format('woff'); font-weight: map-get($config, 'weight'); font-style: map-get($config, 'style'); font-display: map-get($config, 'display'); } }

然后在文件顶层循环执行:

@each $config in $font-face-config { @include font-face-rule($config); }

这段代码跑完,编译出来的 CSS 就是三个(或更多)完整的 @font-face 块。以后加字体,不用碰 Mixin 本身,只改$font-face-config这个纯数据。别小看这一步,它把"字体声明"从代码层面提升到了配置层面,团队里谁都会改,改错了一眼就能看出来。

顺带说一句,Mixin 里我把font-display是写死从数据里取的,而不是硬编码成swap。因为不同类型的字体对font-display的需求不同——图标字体可能想用block,正文字体用swap,这个差异应该体现在数据里,而不是靠改 Mixin。

3.3 字重与font-family命名规范

数据结构和代码都齐了,最后统一一下命名规范。这个容易被忽略,但恰恰是多人协作时最容易翻车的地方。

第一个规范:font-family的命名用英文、无空格、首字母大写驼峰,比如MySans、MyDisplay。别用中文名,也别把文件名当 family 名——文件名叫SourceHanSansSC-Regular.otf,你难道真要写font-family: 'SourceHanSansSC-Regular'?使用处会疯掉的。

第二个规范:字重必须和文件真实字重一致。你放了一个 700 的字体文件,font-weight就写 700,页面里font-weight: 700才能正确匹配。如果声明 400 但文件是 700,浏览器匹配不到真实字重,会拿 400 的文件做伪粗体渲染,笔画糊成一团,效果很糟糕。

第三个规范:使用处不要绕开变量。在_variables.scss里定义字体堆栈:

$font-stack-body: 'MySans', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; $font-stack-mono: 'MyMono', 'SF Mono', 'Consolas', monospace;

业务代码里一律写font-family: $font-stack-body。这样换字体源只需要动这一处变量,全站生效。

4. 加载性能三件套:font-display、unicode-range与preload

4.1 font-display的五种取值及实际体验

自定义字体最大的体验问题不是"加载慢",而是"加载期间页面长什么样"。font-display 就是控制这个过程的属性,它有五个值,对应五种不同的渲染策略:

取值行为表现适用场景
auto由浏览器自己决定,绝大多数等于 block默认值,不推荐主动写
block字体加载完成前隐藏文本,最长约 3 秒,超时后显示回退字体图标字体、对品牌字形要求极高的标题
swap立即用回退字体渲染,加载完成后直接替换正文字体,宁可先看回退字也不能白屏
fallback短暂隐藏(约 100ms)后显示回退字体,若 3 秒内加载完成则替换,超时则永久使用回退对排版稳定性和加载速度都有要求时
optional隐藏极短时间,加载快就用,慢就永久回退,且低配网络下浏览器可能干脆不下载非关键装饰字体

我给项目的默认选择是swap。正文场景下,用户看到回退字体一两秒,比看到一坨空白强得多。标题类的展示字体,如果对字形非常在意,可以用block保证第一眼就是正确字体。图标字体一定用block,不然页面一加载先闪出一堆乱码字符,极其掉价。

要注意的是,font-display 只控制渲染表现,不控制什么时候开始下载。想真正影响加载时机,得靠下面的 unicode-range 和 preload。

4.2 unicode-range:给中文字体做子集化的关键

英文自定义字体通常几百 KB 到头了,但中文字体文件动辄几 MB。如果你把一个 5MB 的全量中文字体挂上去,用户首屏光等字体就把带宽吃光了,不管用什么 display 策略都是白搭。

解法是子集化(subsetting)加 unicode-range。思路是:把中文字体按常用程度拆成多个子集文件,每个子集在 @font-face 里用unicode-range声明自己覆盖的码位区间。浏览器解析页面时,发现文字命中了某个 @font-face 的 unicode-range,才会去下载对应的子集文件。页面只用了 500 个字,那就只下载包含这 500 个字的子集,其他子集碰都不碰。

拆完子集的 SCSS 大概是这种感觉:

@font-face { font-family: 'MyCJK'; src: url('fonts/mycjk-l1.woff2') format('woff2'); unicode-range: U+4E00-4EFF, U+4F00-4FFF; /* 示例,实际区间由子集工具生成 */ font-display: swap; } @font-face { font-family: 'MyCJK'; src: url('fonts/mycjk-l2.woff2') format('woff2'); unicode-range: U+5000-56FF, U+5700-57FF; font-display: swap; }

实际做的时候,我不会手工算码位,而是用字体工具去切。常用的有 fontmin、glyphhanger 这类工具,它们可以按指定字符集(比如《现代汉语常用字表》的 3500 字)拆分字体文件,并且自动输出每个子集对应的 unicode-range 代码,直接粘进 SCSS 就行。

子集文件的数量建议控制在 2 到 5 个。拆得太细确实能省流量,但每个子集都是一个 HTTP 请求,手机弱网下请求排队的时间反而可能抵消体积优势。我一般拆两份:一份覆盖最高频的 3000 字,另一份兜底剩余常用字。这样首屏文字一个请求就搞定,再偏的生僻字才触发第二个请求。

这里有一个必须强调的点:unicode-range和子集文件必须严格对应。如果某个子集文件里没有某个字,但 unicode-range 却把它圈进来了,浏览器下载了这个子集却发现缺字,页面就会显示方块。验证方法很简单,把产品页面常见文案都过一遍,尤其是人名、地名这些容易用生僻字的地方。

4.3 preload的正确打开方式与注意事项

unicode-range 解决的是"按需下载",preload 解决的是"提前下载"。字体文件的下载时机默认受浏览器调度影响,可能排到很后面,对于首屏关键字体,你希望它一开始就抢带宽。做法是在 HTML 里加一行 link:

<link rel="preload" href="/fonts/mycjk-l1.woff2" as="font" type="font/woff2" crossorigin>

这行代码有两个坑,新手必踩。

第一个坑是crossorigin必须写。字体请求天然走 CORS 模式,即使字体和页面同源也一样。不写crossorigin,有些浏览器会忽略 preload 提示,或者造成字体重复加载。带上它就对了。

第二个坑是只 preload 真正首屏需要的字体。preload 是浏览器的高优先级加载指令,你把五个字体全 preload 了,等于五个字体一起跟图片、接口抢带宽,首屏反而更慢。我的习惯是只 preload 正文主字体的高频子集,标题字体如果确实关键也可以加,装饰性字体一律不碰。

还有个联动技巧:preload 和font-display: optional搭配,能避免"既下载了又不一定用上"的浪费。optional 模式下浏览器如果判断网络不佳会直接放弃字体,配合 preload 可以让首屏快速拿到字体,同时保证弱网用户不被拖累。

如果你用了构建工具,建议让打包插件根据编译产物自动生成 preload link,因为一旦字体文件加了内容 hash,手工维护 href 一定会漏。别问我是怎么知道的。

5. FOIT/FOUT之外:真实项目里的字体问题排查实录

5.1 字体突然变成方块/空白的原因与排查

font-display 解决了"闪一下"的体验问题,但真正让人头大的是字体压根不显示,满屏方块或白板。这类问题基本逃不出下面几个原因。

最蠢也最常见的是:文件格式造假。有人没有 woff2 文件,就把 ttf 直接改后缀名变成.woff2,指望浏览器认。浏览器不傻,它下载完会校验内容,发现头不对直接报Failed to decode downloaded font,然后在控制台告诉你。所以文件格式必须用工具真实转换,不能改后缀。

第二个是路径问题。url()里的路径是相对于最终 CSS 文件的位置,不是相对于 SCSS 文件,也不是相对于页面 HTML。如果你用构建工具把 SCSS 编译到了dist/css/,字体在dist/fonts/,那url()就得是../fonts/xxx.woff2。很多人开发环境能显示,打包部署就白屏,基本都是这个原因。

第三个是 MIME 类型。有些服务器/对象存储没有给字体文件配置正确的 Content-Type,返回text/plain或者干脆不返回 Content-Type,部分浏览器会拒绝渲染。这个排查起来有点隐蔽,但看 Network 面板里字体请求的 response headers 就知道了。

我的排查链路是固定的:先开 DevTools 的 Network 面板,过滤font,看每一个字体请求的状态码、MIME、大小;然后看 Console 有没有解码失败的报错;再用document.fonts.check('16px MyFont')在控制台验证字体是否真的注册成功。三步走完,90% 的问题都能定位。

5.2 字体文件缓存失灵怎么破

字体文件特别吃缓存——因为字体不像页面内容那样频繁变,一个字体文件通常可以缓存一年。但有两次我遇到字体更新后,线上怎么都不生效的情况,排查了好久,最后发现都是缓存策略的问题。

第一类是用查询参数做版本号:myfont.woff2?v=2。这种方式在部分 CDN 和浏览器内核里会被直接跳过缓存,每次重新请求;或者反过来,某些代理服务器会把带查询参数的 URL 当成动态资源,完全不缓存。更稳妥的做法是改文件名或者用内容 hash,比如myfont-2a3f9b.woff2,文件内容变了名字就变,没有歧义。

第二类是服务端没有主动配置字体文件的缓存头。字体文件建议设置Cache-Control: public, max-age=31536000, immutable,一年起步。反正文件名带 hash,缓存再久也不会误伤旧版本。这个配置通常在 Nginx 或 CDN 控制台里加,我自己是把字体和静态图片一起配了规则。

另外提醒一句:开发环境调试字体时,记得把 DevTools 里的"Disable cache"勾上,不然你以为改了文件,实际加载的永远是最早那个版本。

5.3 Safari与网页内置浏览器的特殊性

字体这块,不同浏览器的脾气差异非常大。我最常被坑的是 Safari 和各类 App 内置浏览器。

Safari 有几个历史遗留问题:一是对local()的处理行为和其他浏览器不一样,写错名字可能直接忽略整个 src;二是以前对 @font-face 嵌套在 @media 里的解析有问题,所以我才在前面强调必须把声明放在顶层;三是可变字体的支持比 Chrome 晚了一拍,老版本 iOS 上可变字体可能退化成默认字重,要做回退。

App 内置浏览器的情况更复杂。这类 WebView 内核版本老旧,对 woff2 的字体解码能力参差不齐。我做过一个 H5 页面,在 App 内置浏览器里中文字体整页渲染成空白,查到最后是内核不认文件头的某个标志位,换了字体生成工具重新转换才解决。这种问题基本没法预防,只能做到两件事:一是把回退字体堆栈写完整,保证字体加载失败时页面还能正常阅读;二是上线前去目标 App 里实测一遍,不要假设"别的浏览器没问题它就没事"。

6. 进阶思路:字体加载状态管理与可变字体的SCSS接入

6.1 字体加载完成前的降级体验设计

前面说的 font-display: swap 已经能在纯 CSS 层面处理大部分场景,但有些时候你还需要精确知道"字体到底加载完没有",比如要测量首屏字体加载耗时、要避免布局跳动、要做多语言切换后的字体一致性。这时候就要用到浏览器的 Font Loading API。

核心就两个方法:document.fonts.load()主动加载某个字体,document.fonts.ready等待所有字体加载完成。配合 SCSS 可以做一整套降级设计:

if (document.fonts && document.fonts.load) { document.fonts.load('16px MySans').then(function () { document.documentElement.classList.add('font-loaded'); }); }

SCSS 这边这样写:

body { font-family: $font-stack-fallback; } html.font-loaded body { font-family: $font-stack-body; }

这套方案的效果是:字体加载完成前,页面用系统回退字体正常渲染,内容可见、可读、可交互;字体一加载完,整个页面批量切换到目标字体。相比 font-display: swap 那种"加载完立刻换、加载中逐个换"的处理,JS 方案的优点是切换时机可控,你可以在切换时做过渡动画,或者配合统计接口记录字体耗时。

但我要泼一盆冷水:不要为了这点控制力就把整页文字藏起来等待字体加载。见过一些团队做成"字体没加载完就显示 loading 占位,加载完才展示正文",弱网下用户要干等好几秒钟,体验非常糟糕。字体是排版增强,不是内容本身,永远保证内容先可读。

6.2 可变字体在SCSS中的配置与性能优势

最后一个进阶话题:可变字体(Variable Fonts)。它把一个字族的所有字重、宽度甚至斜体,全部塞进一个文件,通过字轴(axis)来插值出任意字重。对项目来说最直观的好处是:以前 400、700、900 三个文件可能总共 1MB,可变字体一个文件 500KB 不到,体积直接减半。

在 SCSS 里声明可变字体和平常几乎一样,只是font-weight要写成一个范围:

@font-face { font-family: 'MyVarSans'; src: url('fonts/my-var-sans.woff2') format('woff2'); font-weight: 100 900; font-style: normal; font-display: swap; }

使用的时候,你可以写任意精确字重,比如font-weight: 620,浏览器会实时插值渲染出对应粗细。这对于设计稿里那些"介于 600 和 700 之间的标题字重",简直是福音。

不过有两个性能细节要提醒。第一,不要在 CSS 动画里过渡font-weight。浏览器每次变化都要重新做字形插值,帧率会掉得很难看。粗细分档切换可以用 JS 控制 class 一次性切换。第二,老浏览器(IE、旧 Edge、以及 2020 年前的某些安卓内核)不认可变字体,会把整个 @font-face 当作无效而跳过。所以使用可变字体必须同时准备一份静态字体回退:

$font-stack-body: 'MyVarSans', 'MySans-Fallback', -apple-system, 'PingFang SC', sans-serif;

我把可变字体也纳入前面那套 Map 管理,只是weight字段从数字变成字符串'100 900',Mixin 完全不用改。这就是数据结构化的好处——规则对数据是无感的。

挑一款合适的可变中文字体目前还没有英文那么丰富,但趋势已经很明确了。等将来项目要接可变字体,SCSS 这套基础设施依然无缝适用,这也是我愿意把字体管理提前工程化的原因。

最后分享一个我自己的习惯:在项目里我会把_fonts.scss当成和_variables.scss同级的基础文件,里面只维护字体数据和生成规则,业务代码永远不碰。每次从设计稿拿到新字体,我只需要做三件事——转换格式、切分子集、往$font-face-config里加一行记录。这套流程固定下来之后,字体问题基本再没在我的项目里复发过,希望这篇也能帮你省下那些年踩坑的功夫。

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

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

立即咨询