feat: SDK 多模式架构 + 首页输入事件驱动

- 重构 webfont-sdk.js 为核心增量引擎 + 多触发器架构
- 支持 loadFont(轮询)、observeFont(MutationObserver)、loadText(手动传文本)三种模式
- 三种模式共享 loadedChars,按 fontName|family 自动去重增量加载
- loadFont interval 可从外部配置
- 首页改用 loadText 模式,输入即时触发字体加载
- textarea 高度根据文本行数动态变化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-04-09 10:41:53 +08:00
parent 909b91a8d5
commit 2f7ce0cb72
4 changed files with 405 additions and 104 deletions

View File

@ -1,7 +1,7 @@
{
"name": "webfont",
"private": true,
"version": "1.2.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "pnpx tsx scripts/dev-all.ts",

View File

@ -1,103 +1,378 @@
/**
* WebFont SDK 按需增量加载字体片段无闪烁
* 用法WebFont.loadFont({ fontName, selector, baseUrl, family })
*
* 架构核心增量引擎 + 多种触发方式
* - 核心FontLoader fontKey 管理已加载字符集只生成增量 CSS
* - 触发器loadFont轮询observeFontDOM 事件loadText手动传文本
* - 同一 fontKey 下所有触发器共享字符集绝不会重复请求
*
* 用法
* // 轮询模式
* WebFont.loadFont({ fontName, selector, family, interval });
*
* // 事件驱动模式
* var obs = WebFont.observeFont({ fontName, selector, family });
* obs.dispose();
*
* // 直接传文本模式
* var loader = WebFont.loadText({ fontName, text: "你好世界", family });
* loader.update("追加文字");
* loader.dispose();
*
* // 清理全部
* WebFont.disposeAll();
*/
var WebFont = (function () {
/** 按 selector 索引的活跃任务,重复调用同一选择器时自动清理旧任务 */
var tasks = {};
/* ============================================================
* 核心增量引擎 fontKey 管理已加载字符集生成增量 CSS
* ============================================================ */
/** @type {Object.<string, { loadedChars: Object.<string,boolean>, injectedStyles: Element[], applied: boolean, fontName: string, family: string, baseUrl: string }>} */
var loaders = {};
/**
* 生成 fontKey同一字体+family 归入同一组
*/
function fontKey(fontName, family) {
return fontName + "|" + family;
}
/**
* 获取或创建对应 fontKey 的加载器
*/
function getLoader(fontName, baseUrl, family) {
var key = fontKey(fontName, family);
if (!loaders[key]) {
loaders[key] = {
loadedChars: {},
injectedStyles: [],
applied: false,
fontName: fontName,
family: family,
baseUrl: baseUrl
};
}
return loaders[key];
}
/**
* 差量加载新字符生成 unicode-range CSS 并注入
* @param {Object} loader - getLoader 返回的加载器对象
* @param {string[]} newChars - 待加载的新字符数组
*/
function loadChars(loader, newChars) {
if (newChars.length === 0) return;
var fontName = loader.fontName;
var family = loader.family;
var baseUrl = loader.baseUrl;
var loadedChars = loader.loadedChars;
var text = newChars.join("");
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text);
var unicodeRanges = newChars
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
.join(", ");
var style = document.createElement("style");
style.textContent =
'@font-face {\n' +
' font-family: "' + family + '";\n' +
' src: url("' + url + '") format("truetype");\n' +
' unicode-range: ' + unicodeRanges + ';\n' +
'}\n';
document.head.appendChild(style);
loader.injectedStyles.push(style);
}
/**
* 从字符集中过滤出未加载的新字符标记为已加载并生成 CSS
* @param {Object} loader - getLoader 返回的加载器对象
* @param {Object.<string,boolean>} charSet - 待检查的字符集
* @returns {boolean} 是否有新字符被加载
*/
function processChars(loader, charSet) {
var loadedChars = loader.loadedChars;
var newChars = [];
for (var c in charSet) {
if (!loadedChars[c]) {
loadedChars[c] = true;
newChars.push(c);
}
}
loadChars(loader, newChars);
return newChars.length > 0;
}
/**
* 从字符串中过滤出未加载的新字符标记为已加载并生成 CSS
* @param {Object} loader - getLoader 返回的加载器对象
* @param {string} text - 待检查的文本
* @returns {boolean} 是否有新字符被加载
*/
function processText(loader, text) {
var loadedChars = loader.loadedChars;
var newChars = [];
for (var i = 0; i < text.length; i++) {
var c = text[i];
if (!loadedChars[c]) {
loadedChars[c] = true;
newChars.push(c);
}
}
loadChars(loader, newChars);
return newChars.length > 0;
}
/**
* 销毁加载器及其所有注入的样式
*/
function destroyLoader(key) {
var loader = loaders[key];
if (!loader) return;
for (var i = 0; i < loader.injectedStyles.length; i++) {
loader.injectedStyles[i].remove();
}
delete loaders[key];
}
/* ============================================================
* 辅助函数
* ============================================================ */
/**
* 获取元素的文本内容
*/
function getText(el) {
var tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
return el.value || "";
}
return el.textContent || "";
}
/**
* 收集选择器匹配元素中的所有字符
*/
function collectChars(selector) {
var charSet = {};
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
var text = getText(elements[i]);
for (var j = 0; j < text.length; j++) {
charSet[text[j]] = true;
}
}
return charSet;
}
/**
* 应用字体到元素
*/
function applyFamily(selector, family) {
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
elements[i].style.fontFamily = '"' + family + '", sans-serif';
}
}
/* ============================================================
* 任务管理 各触发器的清理
* ============================================================ */
/** 按 selector 索引的 loadFont 任务 */
var pollTasks = {};
/** 按选择器索引的 observeFont 任务 */
var observeTasks = {};
/* ============================================================
* 1. loadFont 定时器轮询模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName - 字体文件名 "思源黑体.ttf"
* @param {string} options.selector - CSS 选择器 ".title"
* @param {string} [options.baseUrl] - API 基础地址默认当前域名
* @param {string} [options.family] - font-family 名称默认 fontName 去掉扩展名
* @param {string} options.fontName
* @param {string} options.selector
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @param {number} [options.interval=1000] - 轮询间隔ms
*/
function loadFont(options) {
var selector = options.selector;
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var interval = options.interval || 1000;
/** 清理同一选择器的旧任务 */
if (tasks[selector]) {
clearInterval(tasks[selector].timer);
/** 移除旧任务注入的 style 标签 */
var oldStyles = tasks[selector].styles;
for (var s = 0; s < oldStyles.length; s++) {
oldStyles[s].remove();
}
/* 清理同一选择器的旧任务 */
if (pollTasks[selector]) {
clearInterval(pollTasks[selector].timer);
}
var loadedChars = {};
var injectedStyles = [];
var loader = getLoader(fontName, baseUrl, family);
var applied = false;
/** 获取元素的文本内容 */
function getText(el) {
var tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
return el.value || "";
}
return el.textContent || "";
}
function collectChars() {
var charSet = {};
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
var text = getText(elements[i]);
for (var j = 0; j < text.length; j++) {
charSet[text[j]] = true;
}
}
return charSet;
}
function loadNewChars() {
var current = collectChars();
var newChars = [];
for (var c in current) {
if (!loadedChars[c]) {
newChars.push(c);
}
}
if (newChars.length === 0) return;
for (var k = 0; k < newChars.length; k++) {
loadedChars[newChars[k]] = true;
}
var text = newChars.join("");
var url = baseUrl + "/api?font=" + encodeURIComponent(fontName) + "&text=" + encodeURIComponent(text);
var unicodeRanges = newChars
.map(function (c) { return "U+" + c.codePointAt(0).toString(16).padStart(4, "0"); })
.join(", ");
var style = document.createElement("style");
style.textContent =
'@font-face {\n' +
' font-family: "' + family + '";\n' +
' src: url("' + url + '") format("truetype");\n' +
' unicode-range: ' + unicodeRanges + ';\n' +
'}\n';
document.head.appendChild(style);
injectedStyles.push(style);
if (!applied) {
function tick() {
var current = collectChars(selector);
if (processChars(loader, current) && !applied) {
applied = true;
var elements = document.querySelectorAll(selector);
for (var i = 0; i < elements.length; i++) {
elements[i].style.fontFamily = '"' + family + '", sans-serif';
}
applyFamily(selector, family);
}
}
loadNewChars();
var timer = setInterval(loadNewChars, 1000);
tasks[selector] = { timer: timer, styles: injectedStyles };
tick();
var timer = setInterval(tick, interval);
pollTasks[selector] = { timer: timer };
}
return { loadFont: loadFont };
/* ============================================================
* 2. observeFont MutationObserver 事件驱动模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.selector
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @param {number} [options.debounceMs=50] - 防抖间隔ms
* @returns {{ dispose: function }}
*/
function observeFont(options) {
var selector = options.selector;
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var debounceMs = options.debounceMs || 50;
/* 清理同一选择器的旧任务 */
if (observeTasks[selector]) {
observeTasks[selector].dispose();
}
var loader = getLoader(fontName, baseUrl, family);
var applied = false;
var debounceTimer = null;
function doLoad() {
var current = collectChars(selector);
if (processChars(loader, current) && !applied) {
applied = true;
applyFamily(selector, family);
}
}
function debouncedLoad() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(doLoad, debounceMs);
}
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].type === "childList" || mutations[i].type === "characterData") {
debouncedLoad();
return;
}
}
});
var inputHandler = function () { debouncedLoad(); };
var elements = document.querySelectorAll(selector);
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
el.addEventListener("input", inputHandler);
}
}
doLoad();
var disposed = false;
var task = {
dispose: function () {
if (disposed) return;
disposed = true;
observer.disconnect();
for (var j = 0; j < elements.length; j++) {
var el2 = elements[j];
if (el2.tagName === "INPUT" || el2.tagName === "TEXTAREA") {
el2.removeEventListener("input", inputHandler);
}
}
if (debounceTimer) clearTimeout(debounceTimer);
delete observeTasks[selector];
}
};
observeTasks[selector] = task;
return task;
}
/* ============================================================
* 3. loadText 直接传文本模式
* ============================================================ */
/**
* @param {Object} options
* @param {string} options.fontName
* @param {string} options.text
* @param {string} [options.baseUrl]
* @param {string} [options.family]
* @returns {{ update: function(string): void, dispose: function(): void }}
*/
function loadText(options) {
var fontName = options.fontName;
var baseUrl = options.baseUrl || location.origin;
var family = options.family || fontName.replace(/\.[^.]+$/, "");
var loader = getLoader(fontName, baseUrl, family);
processText(loader, options.text);
return {
update: function (text) {
processText(loader, text);
},
dispose: function () {
/* loadText 不独占样式,样式由 loader 统一管理 */
}
};
}
/* ============================================================
* 公共 API
* ============================================================ */
/**
* 清理所有任务和加载器页面卸载时调用
*/
function disposeAll() {
for (var sel in pollTasks) {
clearInterval(pollTasks[sel].timer);
}
for (var oid in observeTasks) {
observeTasks[oid].dispose();
}
pollTasks = {};
observeTasks = {};
for (var key in loaders) {
destroyLoader(key);
}
}
return {
loadFont: loadFont,
observeFont: observeFont,
loadText: loadText,
disposeAll: disposeAll
};
})();

View File

@ -38,15 +38,15 @@ const s = {
} as const,
textarea: {
width: "100%",
height: "72px",
padding: "8px 12px",
"font-size": "32px",
border: "1px solid #d9d9d9",
"border-radius": "6px",
resize: "vertical",
resize: "none",
"box-sizing": "border-box",
outline: "none",
color: "#e74c3c",
"line-height": "1.4",
} as const,
pre: {
background: "#f7f7f8",
@ -102,7 +102,7 @@ function App() {
set_fonts(fontList);
set_serverConfig(config);
if (fontList.length > 0) {
set_selectedFont(fontList[0].name);
onFontChange(fontList[0].name);
}
});
@ -119,28 +119,41 @@ function App() {
}`;
});
/** 字体切换时使用 SDK 重新加载 */
const prevFontRef = { value: "" };
createMemo(() => {
const font = selectedFont();
if (!font) return;
if (font !== prevFontRef.value) {
prevFontRef.value = font;
const el = document.getElementById("webfont-preview");
if (el) el.style.fontFamily = 'inherit';
(globalThis as any).WebFont?.loadFont({
fontName: font,
selector: "#webfont-preview",
family: "CustomFont",
});
}
/** loadText loader 引用,字体或文本变化时增量加载 */
let textLoader: { update: (text: string) => void; dispose: () => void } | null = null;
/** 文本变化时增量加载字体 */
const onTextChange = (value: string) => {
set_text(value);
if (!textLoader) return;
textLoader.update(value);
};
/** 根据文本行数动态计算 textarea 高度 */
const textareaRows = createMemo(() => {
const lines = text().split("\n").length;
return Math.max(2, Math.min(lines, 10));
});
/** 字体切换时为当前文本加载新字体 */
const onFontChange = (font: string) => {
set_selectedFont(font);
if (!font) return;
if (textLoader) textLoader.dispose();
textLoader = (globalThis as any).WebFont?.loadText({
fontName: font,
text: text(),
family: "CustomFont",
}) ?? null;
const el = document.getElementById("webfont-preview");
if (el) el.style.fontFamily = '"CustomFont", sans-serif';
};
async function refreshFonts() {
const fontList = await fetchFonts();
set_fonts(fontList);
if (fontList.length > 0 && !selectedFont()) {
set_selectedFont(fontList[0].name);
onFontChange(fontList[0].name);
}
}
@ -165,7 +178,7 @@ function App() {
<select
style={s.select}
value={selectedFont()}
onChange={(e) => set_selectedFont(e.target.value)}
onChange={(e) => onFontChange(e.target.value)}
>
<option value="">-- --</option>
<For each={fonts()}>
@ -185,8 +198,9 @@ function App() {
style={{
...s.textarea,
}}
rows={textareaRows()}
value={text()}
onInput={(e) => set_text(e.target.value)}
onInput={(e) => onTextChange(e.target.value)}
placeholder="在此输入文本..."
/>
</section>
@ -242,15 +256,17 @@ function App() {
.title { font-family: "MyFont"; }
</style>
<h1 class="title"></h1>`}</pre>
<p style={{ "margin-top": "12px" }}><b></b>使 JS SDK <a href="/webfont-sdk.js" download="webfont-sdk.js"> SDK</a></p>
<p style={{ "margin-top": "12px" }}><b>JS SDK</b><a href="/webfont-sdk.js" download="webfont-sdk.js"> SDK</a></p>
<pre style={{ ...s.pre, "font-size": "12px", "margin-top": "4px" }}>{`<script src="${location.origin}/webfont-sdk.js"><\/script>
<script>
WebFont.loadFont({
fontName: "${selectedFont()}",
selector: ".title",
fontName: "字体文件名.ttf",
selector: ".my-element",
family: "MyFont",
interval: 1000,
});
<\/script>`}</pre>
<p style={{ "margin-top": "8px" }}> <code>WebFont.observeFont()</code>MutationObserver <code>WebFont.loadText()</code>使SDK </p>
</section>
<footer style={{ "margin-top": "48px", "padding-top": "16px", "border-top": "1px solid #eee", "font-size": "12px", color: "#999", "text-align": "center" }}>

12
task.md
View File

@ -3,9 +3,19 @@
把基准测试结果文档保存在本地 benchmark_results/ 这样我方便查看。你的文档中应该在每个重大节点更新基准测试结果benchmark_results/OPTIMIZATION_LOG.md这样我能方便看到你使用了哪些优化方法得到了什么样的优化效果。
=== 字体裁剪基准测试 ===
8个汉字: avg=23.6ms min=18.4ms max=37.2ms 输出=16,508 bytes ssim=1.0000
拉丁+数字: avg=16.4ms min=13.7ms max=18.2ms 输出=1,272 bytes ssim=1.0000
千字文前段: avg=59.4ms min=47.3ms max=76.5ms 输出=161,344 bytes ssim=1.0000
=== 一晚上优化后的 字体裁剪基准测试 ===
8个汉字: avg=7.7ms min=3.8ms max=20.7ms 输出=16,572 bytes ssim=1.0000
拉丁+数字: avg=3.2ms min=1.6ms max=6.6ms 输出=1,272 bytes ssim=1.0000
千字文前段: avg=11.7ms min=6.8ms max=21.7ms 输出=161,816 bytes ssim=1.0000
## 其他方向
就是有一个纯前端的优化咱们提供的js SDK好像是通过定时器扫描的吧这当然是一种方式也是最省心的一种方式但是咱们是不是还可以考虑另外一种方式就是通过配置来启用定时扫描还是由用户自己的事件来触发甚至由用户直接传递文本这样的话对于首页上的demo来说可能会有更高的及时性响应