一个英文字体文件通常只有 20KB-50KB,而一个包含全量汉字的中文字体(如 SourceHanSansCN-Regular.ttf)体积往往在 4MB 到 25MB 之间。

过去博客是这样加载字体的:

用户打开网页 -> 浏览器尝试下载一个 NMB 的 .woff2 文件 -> 页面文字留白数秒 -> 字体下载完成 -> 文字突然跳动变化。

直到发现了 unicode-range分层子集化(Subsetting) 技术,配合 HTTP/2,彻底解决了这个问题。

核心思路:只下载页面里边会看到的字

Web 开发中有一个被低估的神器属性:CSS 的 unicode-range

简单说,允许将一个巨大的字体文件拆分成无数个小文件,并在 CSS 中通过 unicode-range 标记每个文件包含的字符范围。浏览器会扫描当前页面上有哪些字,然后只下载包含这些字的那个小切片。

这就好比去图书馆,需要查一个成语,不需要把整本《词典》搬回家,只需要复印那一页就够了。

实施方案:3+N 智能分包策略

单纯的随机切片虽然有效,但请求数可能过多。结合中文的使用习惯,用了一套 Python 脚本,采用 “3+N” 分层策略

  1. Base 包 (基础层)

    • 内容:ASCII 字符(英文、数字、标点符号)。
    • 大小:< 10KB。
    • 效果:确保网页的导航、日期、代码块瞬间加载。
  2. Common 包 (常用层)

    • 内容:依据《通用规范汉字表》,将 3500 个常用汉字分为 Level 1、Level 2、Level 3 三个等级。
    • 大小:每个包约 80KB - 150KB(进一步拆分为多个 500 字的小包)。
    • 效果:覆盖了日常 99.9% 的文章内容。
  3. Rare 包 (生僻层)

    • 内容:剩余的数万个生僻字。
    • 策略:切分成每包 200 字的微型切片(Chunk)。
    • 效果:只有当文章里出现了“生僻字”时,浏览器才会去下载对应的那个 5KB 的小文件。

技术实现细节

使用了 Python 的 fonttools 库配合 brotli 压缩算法。脚本不仅会对字体进行物理切割,还会进行极限压缩(移除无用的 OpenType 表如 GSUB),并使用 Hash 命名以利用浏览器强缓存。

生成的 CSS (font.css) 看起来是这样的:

/* Base 包:包含英文数字,所有页面必载 */
@font-face {
  font-family: 'HarmonySans';
  src: url('myfont-base-0-1dcefe30.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E;
}

/* Level 1 Part 0:只有当页面出现该范围内的常用字时才加载 */
@font-face {
  font-family: 'HarmonySans';
  src: url('myfont-level1-0-a05569bc.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+4E00-4E01, U+4E03, U+4E07-4E0B...;
}

关键的最后一步:开启 Nginx HTTP/2

当将一个大字体切分成几十个小文件后,产生了一个新问题:并发请求数。

在旧的 HTTP/1.1 协议下,浏览器对同一域名的并发请求有限制(通常是 6 个)。如果一个页面需要加载 10 个字体切片,就会发生“排队阻塞”,导致加载变慢。

解决方案是 HTTP/2 (Multiplexing)。

HTTP/2 允许在同一个 TCP 连接上并行传输多个文件。开启后,几十个小字体文件可以同时通过管道传输,没有任何阻塞。

最后 AI风格浓郁的 python 代码

其中 level-1/2/3.txt 搜索 通用规范汉字表 一级表 3500字 txt 可获取。

pip install fonttools brotli
import os
import math
import hashlib
from fontTools import subset
from fontTools.ttLib import TTFont

# ================= 🔧 配置区域 🔧 =================
# 1. 输入文件 (请确保文件名正确)
INPUT_FONT = "HarmonyOS_Sans_SC_Regular.ttf"

# 2. 输出配置
OUTPUT_DIR = "fonts"
FONT_FAMILY = "HarmonyOS_Sans"
FILE_PREFIX = "myfont" # 文件名前缀

# 3. 分包大小配置 (每个包包含多少个字符)
CHUNK_CONFIG = {
    "base": 500,    
    "level1": 500,  
    "level2": 500,  
    "level3": 500,  
    "rare": 200     
}

# 4. 字表路径
FILES = {
    "level1": "level-1.txt",
    "level2": "level-2.txt",
    "level3": "level-3.txt"
}
# =================================================

def get_subset_hash(char_list):
    """根据字符内容生成短哈希值 (8位),用于文件名防缓存"""
    sorted_chars = sorted(list(char_list))
    raw_str = "".join(str(c) for c in sorted_chars)
    return hashlib.md5(raw_str.encode('utf-8')).hexdigest()[:8]

def format_unicode_range(char_list):
    """生成 CSS unicode-range"""
    if not char_list: return ""
    sorted_chars = sorted(list(char_list))
    ranges = []
    start = sorted_chars[0]
    prev = start
    
    for c in sorted_chars[1:]:
        if c == prev + 1:
            prev = c
        else:
            if start == prev:
                ranges.append(f"U+{start:X}")
            else:
                ranges.append(f"U+{start:X}-{prev:X}")
            start = c
            prev = c
    if start == prev:
        ranges.append(f"U+{start:X}")
    else:
        ranges.append(f"U+{start:X}-{prev:X}")
    return ", ".join(ranges)

def generate_woff2(font_path, output_path, chars):
    """执行子集化并压缩为 woff2"""
    options = subset.Options()
    options.flavor = 'woff2'          
    options.ignore_missing_unicodes = True
    
    # === 极限压缩参数 ===
    options.desubroutinize = True     
    options.obfuscate_names = True    
    
    # 🔴 修复点:删除了 'cmap'。
    # 'name' 表也被移除了,如果生成后的字体在某些老旧软件不显示名称,可以把 'name' 也删掉不drop
    # 目前只 drop 不影响核心渲染的表
    options.drop_tables += ['GSUB', 'GPOS', 'GDEF', 'DSIG'] 
    
    subsetter = subset.Subsetter(options=options)
    subsetter.populate(unicodes=chars)
    
    try:
        font = TTFont(font_path)
        subsetter.subset(font)
        font.save(output_path)
        font.close()
    except Exception as e:
        print(f"❌ 生成失败: {output_path} - {e}")

def process_group(font_path, group_name, chars, chunk_size, css_list):
    """处理每一组字符(切分 -> 生成文件 -> 记录CSS)"""
    if not chars:
        return

    char_list = sorted(list(chars))
    total_chars = len(char_list)
    chunks_count = math.ceil(total_chars / chunk_size)
    
    print(f"📦 处理 [{group_name}]: 共 {total_chars} 字, 切分为 {chunks_count} 个包...")

    for i in range(chunks_count):
        start = i * chunk_size
        end = start + chunk_size
        chunk_chars = char_list[start:end]
        
        # 生成唯一哈希文件名
        file_hash = get_subset_hash(chunk_chars)
        filename = f"{FILE_PREFIX}-{group_name}-{i}-{file_hash}.woff2"
        output_path = os.path.join(OUTPUT_DIR, filename)
        
        # 生成字体文件
        generate_woff2(font_path, output_path, chunk_chars)
        
        # 记录 CSS
        css_list.append(f"""
/* {group_name} part {i} */
@font-face {{
  font-family: '{FONT_FAMILY}';
  src: url('{filename}') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: {format_unicode_range(chunk_chars)};
}}""")

def load_txt_chars(path):
    if not os.path.exists(path): return set()
    with open(path, 'r', encoding='utf-8') as f:
        content = f.read().replace('\n', '').replace(' ', '').replace('\r', '')
        return set(ord(c) for c in content)

def main():
    if not os.path.exists(INPUT_FONT):
        print(f"❌ 错误: 找不到输入字体 {INPUT_FONT}")
        return
    if not os.path.exists(OUTPUT_DIR):
        os.makedirs(OUTPUT_DIR)

    # 1. 分析原始字体
    print("⏳ 正在读取原始字体结构...")
    font = TTFont(INPUT_FONT)
    all_chars = set(font.getBestCmap().keys())
    font.close()

    # 2. 准备各个集合
    # Base: ASCII
    base_chars = set(range(0x0020, 0x007F)) & all_chars
    
    # Level 1-3
    l1_chars = (load_txt_chars(FILES['level1']) & all_chars) - base_chars
    l2_chars = (load_txt_chars(FILES['level2']) & all_chars) - base_chars - l1_chars
    l3_chars = (load_txt_chars(FILES['level3']) & all_chars) - base_chars - l1_chars - l2_chars
    
    # Rare: 剩下的所有
    rare_chars = all_chars - base_chars - l1_chars - l2_chars - l3_chars

    # 3. 开始批量切片处理
    css_output = []

    process_group(INPUT_FONT, "base", base_chars, CHUNK_CONFIG['base'], css_output)
    process_group(INPUT_FONT, "level1", l1_chars, CHUNK_CONFIG['level1'], css_output)
    process_group(INPUT_FONT, "level2", l2_chars, CHUNK_CONFIG['level2'], css_output)
    process_group(INPUT_FONT, "level3", l3_chars, CHUNK_CONFIG['level3'], css_output)
    process_group(INPUT_FONT, "rare", rare_chars, CHUNK_CONFIG['rare'], css_output)

    # 4. 写入 CSS
    css_path = os.path.join(OUTPUT_DIR, "font.css")
    with open(css_path, "w", encoding='utf-8') as f:
        f.write("\n".join(css_output))

    print(f"\n✅ 全部完成!CSS 已生成: {css_path}")
    print(f"   生成文件总数: {len(css_output)} 个 woff2 切片")

if __name__ == "__main__":
    main()
--- EOF ---
订阅本站:feed
声明:博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

标签: none

添加新评论