优化了一中文字体拆分加载方案
一个英文字体文件通常只有 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” 分层策略:
Base 包 (基础层)
- 内容:ASCII 字符(英文、数字、标点符号)。
- 大小:< 10KB。
- 效果:确保网页的导航、日期、代码块瞬间加载。
Common 包 (常用层)
- 内容:依据《通用规范汉字表》,将 3500 个常用汉字分为 Level 1、Level 2、Level 3 三个等级。
- 大小:每个包约 80KB - 150KB(进一步拆分为多个 500 字的小包)。
- 效果:覆盖了日常 99.9% 的文章内容。
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 brotliimport 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()