font-src安全策略配置踩坑记:从CSP报错到完美解决的实战经验

耘博酱~ 安全 阅读 2,924
赞 13 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

最近接手一个老项目,用户反馈页面加载特别慢,尤其是首页。Chrome DevTools 显示首屏渲染要6-8秒,其中字体资源占了很大一部分时间。优化前的状态就是,页面内容都出来了,但是字体还没加载完,看着就像网页出bug了一样,全是方框和默认字体,用户体验差到爆。

font-src安全策略配置踩坑记:从CSP报错到完美解决的实战经验

问题主要出在自定义字体上,项目里用了几个WOFF2格式的中文字体文件,加起来有好几MB。而且这些字体都是从CDN加载的,网络请求阻塞了页面渲染,还经常出现FOIT(Flash of Invisible Text)问题。

找到瓶颈了!

用Lighthouse跑了一下性能测试,发现字体加载时间平均在2-3秒,有些用户的网络环境更差。Performance面板显示,CSS字体声明导致了渲染阻塞,字体加载完成前页面空白或者显示默认字体。

通过Network面板进一步分析,发现font-src配置不当,允许了过多的字体来源,而且没有做预加载优化。Security面板也提示CSP策略太宽松,存在安全风险的同时也影响了性能。

字体加载优化的核心代码

首先是对字体资源的优化,这是最核心的部分。我采用了多种策略组合:

<!-- 预连接,提前建立DNS连接 -->
<link rel="preconnect" href="https://jztheme.com/fonts/" crossorigin>

<!-- 字体预加载 -->
<link rel="preload" href="/fonts/custom-font.woff2" as="font" type="font/woff2" crossorigin>

<style>
/* 优化前的低效写法 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font.woff2') format('woff2');
  font-display: swap; /* 这个其实已经不错了 */
}

/* 优化后的高效写法 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom-font-subset.woff2') format('woff2');
  font-display: optional; /* 关键优化点 */
  font-weight: 400;
  font-style: normal;
}
</style>

这里的重点是 font-display: optional,比之前的 swap 更激进一些。它会在字体加载失败或超时时完全回退到系统字体,避免长时间等待。不过要注意,这种设置对品牌字体可能不太友好。

针对font-src的CSP策略我也做了收紧:

// 优化前的宽松策略
// Content-Security-Policy: font-src *;

// 优化后的精准策略  
const cspHeader = [
  "default-src 'self'",
  "font-src 'self' https://jztheme.com/fonts/ https://fonts.googleapis.com",
  "style-src 'self' 'unsafe-inline'",
  "script-src 'self'"
].join('; ');

app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', cspHeader);
  next();
});

字体子集化处理

这个优化带来了最大收益。原来完整的中文字体文件有2MB多,加载时间感人。通过字体子集化,只保留项目中实际使用的字符,体积缩小到200KB左右。

我是用fonttools来处理的,命令如下:

# 提取文本中使用的字符
pyftsubset custom-font.ttf --text-file=used-chars.txt --output-file=custom-font-subset.woff2 --flavor=woff2

用Python脚本扫描项目中的文本内容,生成实际使用的字符列表:

import os
import re

def extract_chars_from_html(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
    
    # 提取所有中文字符
    chars = set(re.findall(r'[u4e00-u9fff]', content))
    return chars

# 扫描项目文件
all_chars = set()
for root, dirs, files in os.walk('./public'):
    for file in files:
        if file.endswith('.html'):
            all_chars.update(extract_chars_from_html(os.path.join(root, file)))

with open('used-chars.txt', 'w', encoding='utf-8') as f:
    f.write(''.join(all_chars))

异步加载策略

对于非关键字体,我采用异步加载方式:

function loadFontAsync(fontUrl, fontName) {
  const link = document.createElement('link');
  link.rel = 'preload';
  link.href = fontUrl;
  link.as = 'font';
  link.type = 'font/woff2';
  link.crossOrigin = 'anonymous';
  
  link.onload = () => {
    // 字体加载完成后应用
    document.documentElement.style.setProperty('--loaded-font', fontName);
  };
  
  document.head.appendChild(link);
}

// 页面加载后再加载装饰性字体
window.addEventListener('load', () => {
  loadFontAsync('/fonts/decoration-font.woff2', 'DecorationFont');
});

性能数据对比

优化前后的数据对比很直观:

  • FIRST CONTENTFUL PAINT: 从4.2s缩短到1.8s
  • LARGEST CONTENTFUL PAINT: 从6.8s缩短到2.1s
  • FONTS LOAD TIME: 从2.8s降低到0.6s
  • PAGE WEIGHT: 减少了1.8MB

Lighthouse分数也有明显提升,移动端从65分提高到88分。用户反馈页面响应速度快了很多,不会再出现长时间白屏的情况。

缓存策略调整

配合font-src优化,我也重新设计了字体缓存策略:

# Nginx配置
location ~* .(woff|woff2|ttf|eot)$ {
  expires 1y;
  add_header Cache-Control "public, immutable";
  add_header Access-Control-Allow-Origin "*";
}

字体文件基本不会变,所以设置了1年的强缓存。这样用户第二次访问时几乎不需要重新下载字体。

一些踩坑记录

过程中也踩了不少坑。最开始我想用system font stack来替代自定义字体,但产品方坚持要用品牌字体,所以这条路走不通。

还有一次在测试font-display的时候,设成了block,结果在慢网速下页面卡死了5秒多才显示文字,体验还不如原来的swap。后来改成optional才解决了这个问题。

CSP配置也折腾了很久,刚开始把font-src限制得太死,导致第三方组件的字体加载失败。经过多次调试才找到平衡点。

监控和后续

部署后我加了字体加载的监控:

// 监控字体加载性能
if ('fonts' in document) {
  document.fonts.ready.then(() => {
    performance.mark('fonts-loaded');
    const perfEntries = performance.getEntriesByName('fonts-loaded');
    console.log('Font loading time:', perfEntries[0].startTime);
  });
}

目前线上运行稳定,字体相关的性能指标保持良好。不过字体子集化的维护成本有点高,每次新增文案都需要重新生成字体文件,这部分还需要自动化流程支持。

以上是我个人对font-src性能优化的完整讲解,有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论