feat: uni-app 插件 gs-webfont —— 字体按需加载(子集化)上线准备

- webfont-sdk 引擎修复两处并发池 bug(原版排队分支丢任务;闭包复用会让
  队列真身丢失导致 pending 卡死),新增 per-state provider 与 submitText 分批
- 新增 packages/uni-webfont:字符累积 + uni.loadFontFace 同 family 重载策略,
  串行保序(maxConcurrent=1),二次 loadFont 从引擎播种累积集
- 产物同步 uni_modules/gs-webfont/js_sdk(自包含 ESM+iife+d.ts)
- 端到端验证:mock 全绿;线上真实服务 4 字 → 10.1KB 合法 TTF
This commit is contained in:
崮生(子虚) 2026-08-16 10:34:40 +08:00
parent f9160950ea
commit 140f84d3c7
18 changed files with 1844 additions and 27 deletions

View File

@ -0,0 +1,290 @@
var UniWebFontBundle = (function(exports) {
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region ../webfont-sdk/dist/engine.js
function createHttpProvider(baseUrl) {
return (fontName, text, outType) => {
const url = `${baseUrl}/api?font=${encodeURIComponent(fontName)}&text=${encodeURIComponent(text)}&outType=${outType}`;
return Promise.resolve({
url,
format: outType === "woff2" ? "woff2" : "truetype"
});
};
}
var IncrementalEngine = class {
constructor(config = {}) {
this.states = /* @__PURE__ */ new Map();
this.active = 0;
this.queue = [];
this.flying = 0;
this.config = {
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null
};
}
/** fontKeyfontName + family 唯一确定一个增量组 */
static fontKey(fontName, family) {
return fontName + "|" + family;
}
setProvider(provider) {
this.config.provider = provider;
}
getState(key) {
return this.states.get(key);
}
/** 获取或创建字体状态;已存在时按传入项更新 baseUrl / outType / 回调 */
ensureState(key, fontName, options) {
let state = this.states.get(key);
if (!state) {
state = {
fontName,
baseUrl: options.baseUrl,
outType: options.outType,
loadedChars: /* @__PURE__ */ new Set(),
failedChars: /* @__PURE__ */ new Set(),
pendingChars: /* @__PURE__ */ new Set(),
onLoadChunk: options.onLoadChunk ?? null,
provider: options.provider ?? null
};
this.states.set(key, state);
return state;
}
state.baseUrl = options.baseUrl;
state.outType = options.outType;
if (options.onLoadChunk) state.onLoadChunk = options.onLoadChunk;
if (options.provider !== void 0) state.provider = options.provider;
return state;
}
/** 删除状态(销毁时) */
removeState(key) {
this.states.delete(key);
}
/** 是否还有在途任务请求中或注册中ready() 轮询用) */
hasPending() {
if (this.flying > 0) return true;
for (const s of this.states.values()) if (s.pendingChars.size > 0) return true;
return false;
}
/** 清除失败记录(下次遇到这些字符会重新请求) */
retryFailed(key) {
this.states.get(key)?.failedChars.clear();
}
/**
* 提交一批文本过滤出新字符并异步请求子集
* 乐观标记 pending成功移入 loaded失败移入 failed
* 超过 maxCharsPerChunk 时自动分批uni 小程序 loadFontFace unicode-range
* 单次需携带全量累积文本长文本按批切分避免 URL 超限
*/
submitText(key, text, maxCharsPerChunk = Infinity) {
const state = this.states.get(key);
if (!state) return;
const newChars = [];
for (const ch of text) {
if (state.loadedChars.has(ch) || state.pendingChars.has(ch) || state.failedChars.has(ch)) continue;
/** 跳过控制字符 */
if (ch.charCodeAt(0) < 32) continue;
newChars.push(ch);
state.pendingChars.add(ch);
}
if (newChars.length === 0) return;
for (let i = 0; i < newChars.length; i += maxCharsPerChunk) {
const batch = newChars.slice(i, i + maxCharsPerChunk);
this.enqueue(() => this.loadChunk(state, batch));
}
}
/** 执行一次子集请求 + 注册(在并发槽内完成) */
async loadChunk(state, chars) {
this.flying++;
try {
const text = chars.join("");
const result = await (state.provider ?? this.config.provider ?? createHttpProvider(state.baseUrl))(state.fontName, text, state.outType);
/** 注册完成后才把字符记为已加载:注册失败可走 failedChars 重试路径 */
await state.onLoadChunk?.({
fontName: state.fontName,
chars,
url: result.url,
format: result.format
});
for (const ch of chars) {
state.loadedChars.add(ch);
state.pendingChars.delete(ch);
}
} catch {
for (const ch of chars) {
state.pendingChars.delete(ch);
state.failedChars.add(ch);
}
} finally {
this.flying--;
}
}
/** 并发池:超出 maxConcurrent 的任务排队等待 */
enqueue(fn) {
if (this.active < this.config.maxConcurrent) this.execute(fn);
else this.queue.push(fn);
}
/**
* 执行一个任务完成后从队列取下一个
* 注意这里必须直接调用 nextfn不能递归调用外层 run 闭包
* 那样会把下一个任务替换成本次任务重跑闭包捕获队列真身丢失
*/
execute(fn) {
this.active++;
fn().finally(() => {
this.active--;
const next = this.queue.shift();
if (next) this.execute(next);
});
}
setMaxConcurrent(n) {
this.config.maxConcurrent = Math.max(1, n | 0);
}
};
//#endregion
//#region src/index.ts
/**
* uni-webfont uni-app 字体按需加载小程序 / H5 / App 通用
*
* 原理中文字体动辄 5-20MB小程序主包限 2MB整包加载必死
* 本插件把页面实际用到的字符提交给子集化服务服务端按字符裁出
* KB 的字体片段再通过 uni.loadFontFace 注册文字用多少加载多少
*
* web@font-face unicode-range 多片段并存的关键差异
* 小程序 loadFontFace 不支持 unicode-range同名 family 只有一个生效字体
* 因此本层采用字符累积 + 全量重载策略
* - 引擎层webfont-sdk IncrementalEngine仍按字符去重只有新字符触发请求
* - 每次请求携带累积全集新字符 + 历史已加载字符服务端缓存按文本命中
* - 片段就绪后 uni.loadFontFace family 重载旧字形保持渲染直到新字体
* 就绪视觉上无闪烁
* - maxConcurrent 固定 1 family 的子集请求必须串行保证后到的
* 请求字符集是前者的超集并发乱序会让小集合后落地丢字符
*
* 用法
* ```ts
* import { UniWebFont } from 'uni-webfont'
*
* const loader = UniWebFont.loadFont({ fontName: '令东齐伋复刻体.ttf' })
* loader.update('静心茶舍 今日特饮')
* // 渲染前等待字体就绪(可选,旧字形兜底显示)
* await loader.ready()
* ```
*/
/**
* 取全局 uni 对象
* 不用 declare global 声明发布的 d.ts 会与用户工程里 @dcloudio/types
* uni 声明冲突重复标识符globalThis 交叉类型是零依赖的诚实写法
*/
function getUni() {
const g = globalThis;
if (!g.uni) throw new Error("[uni-webfont] 未检测到 uni 全局对象,请在 uni-app 环境中使用");
return g.uni;
}
/** fontFamily 里的文件后缀family 名不认扩展名) */
const FONT_EXT_RE = /\.(ttf|otf|woff2?|ttc)$/i;
var UniWebFontMode = class {
constructor(config = {}) {
this.defaultBaseUrl = "https://webfont.shenzilong.cn";
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl;
this.engine = new IncrementalEngine({
/** 串行必须:见文件头「字符累积 + 全量重载」策略说明 */
maxConcurrent: 1,
provider: null
});
}
getEngine() {
return this.engine;
}
/**
* 创建或复用一个字体的增量加载器
* 返回的 loader 可反复 update引擎按字符去重只有新字符触发网络请求
*/
loadFont(options) {
const fontName = options.fontName;
const family = options.family ?? fontName.replace(FONT_EXT_RE, "").trim();
const key = IncrementalEngine.fontKey(fontName, family);
const baseUrl = options.baseUrl ?? this.defaultBaseUrl;
const outType = options.outType ?? "ttf";
const global = options.global ?? true;
const maxCharsPerChunk = options.maxCharsPerChunk ?? 300;
const debug = options.debug ?? false;
/**
* 累积字符集目标全集每次子集请求都携带它保证新字体
* 一定是已渲染字体的超集失败字符也计入重试时靠它自愈
* 同字体二次 loadFont跨页面复用 state时从引擎播种已处理的字符
* 否则空累积集会让重载 URL 丢掉历史字符 unicode-range重载即替换
*/
const existing = this.engine.getState(key);
const accumulated = new Set(existing ? [
...existing.loadedChars,
...existing.failedChars,
...existing.pendingChars
] : []);
/** 累积全集 provider覆盖引擎默认的「仅新字符」URL 构造 */
const provider = (name, batchText, type) => {
for (const ch of batchText) accumulated.add(ch);
const text = Array.from(accumulated).join("");
if (debug) console.log(`[uni-webfont] subset ${family}: +${batchText.length} → 累积 ${text.length}`);
return Promise.resolve({
url: `${baseUrl}/api?font=${encodeURIComponent(name)}&text=${encodeURIComponent(text)}&outType=${type}`,
format: type === "woff2" ? "woff2" : "truetype"
});
};
/** 片段就绪 → uni.loadFontFace 重载同 familysource 直传 URL由平台下载 */
const onLoadChunk = (chunk) => new Promise((resolve, reject) => {
getUni().loadFontFace({
family,
source: `url("${chunk.url}")`,
global,
desc: options.desc,
success: () => {
if (debug) console.log(`[uni-webfont] ${family} 已生效(${chunk.chars.length} 字增量)`);
resolve();
},
fail: (err) => {
const msg = `uni-webfont loadFontFace 失败: ${family}${err?.errMsg ?? "未知错误"}`;
if (debug) console.error(msg);
reject(new Error(msg));
}
});
});
/** per-state provider累积全集 URL见文件头策略说明同 key 复用时不重复注入 */
this.engine.ensureState(key, fontName, {
baseUrl,
outType,
onLoadChunk,
provider
});
let disposed = false;
return {
update: (text) => {
if (disposed) return;
this.engine.submitText(key, text, maxCharsPerChunk);
},
isPending: () => {
const s = this.engine.getState(key);
return !!s && s.pendingChars.size > 0;
},
ready: async () => {
while (this.engine.hasPending()) await new Promise((r) => setTimeout(r, 50));
},
retryFailed: () => this.engine.retryFailed(key),
dispose: () => {
if (disposed) return;
disposed = true;
this.engine.removeState(key);
}
};
}
/** 是否有片段在请求/注册中(所有字体) */
hasPending() {
return this.engine.hasPending();
}
/** 等待全部在途片段就绪(截图/导出前调用) */
async ready() {
while (this.hasPending()) await new Promise((r) => setTimeout(r, 50));
}
};
//#endregion
exports.UniWebFont = new UniWebFontMode();
exports.UniWebFontMode = UniWebFontMode;
return exports;
})({});

View File

@ -0,0 +1,287 @@
//#region ../webfont-sdk/dist/engine.js
function createHttpProvider(baseUrl) {
return (fontName, text, outType) => {
const url = `${baseUrl}/api?font=${encodeURIComponent(fontName)}&text=${encodeURIComponent(text)}&outType=${outType}`;
return Promise.resolve({
url,
format: outType === "woff2" ? "woff2" : "truetype"
});
};
}
var IncrementalEngine = class {
constructor(config = {}) {
this.states = /* @__PURE__ */ new Map();
this.active = 0;
this.queue = [];
this.flying = 0;
this.config = {
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null
};
}
/** fontKeyfontName + family 唯一确定一个增量组 */
static fontKey(fontName, family) {
return fontName + "|" + family;
}
setProvider(provider) {
this.config.provider = provider;
}
getState(key) {
return this.states.get(key);
}
/** 获取或创建字体状态;已存在时按传入项更新 baseUrl / outType / 回调 */
ensureState(key, fontName, options) {
let state = this.states.get(key);
if (!state) {
state = {
fontName,
baseUrl: options.baseUrl,
outType: options.outType,
loadedChars: /* @__PURE__ */ new Set(),
failedChars: /* @__PURE__ */ new Set(),
pendingChars: /* @__PURE__ */ new Set(),
onLoadChunk: options.onLoadChunk ?? null,
provider: options.provider ?? null
};
this.states.set(key, state);
return state;
}
state.baseUrl = options.baseUrl;
state.outType = options.outType;
if (options.onLoadChunk) state.onLoadChunk = options.onLoadChunk;
if (options.provider !== void 0) state.provider = options.provider;
return state;
}
/** 删除状态(销毁时) */
removeState(key) {
this.states.delete(key);
}
/** 是否还有在途任务请求中或注册中ready() 轮询用) */
hasPending() {
if (this.flying > 0) return true;
for (const s of this.states.values()) if (s.pendingChars.size > 0) return true;
return false;
}
/** 清除失败记录(下次遇到这些字符会重新请求) */
retryFailed(key) {
this.states.get(key)?.failedChars.clear();
}
/**
* 提交一批文本过滤出新字符并异步请求子集
* 乐观标记 pending成功移入 loaded失败移入 failed
* 超过 maxCharsPerChunk 时自动分批uni 小程序 loadFontFace unicode-range
* 单次需携带全量累积文本长文本按批切分避免 URL 超限
*/
submitText(key, text, maxCharsPerChunk = Infinity) {
const state = this.states.get(key);
if (!state) return;
const newChars = [];
for (const ch of text) {
if (state.loadedChars.has(ch) || state.pendingChars.has(ch) || state.failedChars.has(ch)) continue;
/** 跳过控制字符 */
if (ch.charCodeAt(0) < 32) continue;
newChars.push(ch);
state.pendingChars.add(ch);
}
if (newChars.length === 0) return;
for (let i = 0; i < newChars.length; i += maxCharsPerChunk) {
const batch = newChars.slice(i, i + maxCharsPerChunk);
this.enqueue(() => this.loadChunk(state, batch));
}
}
/** 执行一次子集请求 + 注册(在并发槽内完成) */
async loadChunk(state, chars) {
this.flying++;
try {
const text = chars.join("");
const result = await (state.provider ?? this.config.provider ?? createHttpProvider(state.baseUrl))(state.fontName, text, state.outType);
/** 注册完成后才把字符记为已加载:注册失败可走 failedChars 重试路径 */
await state.onLoadChunk?.({
fontName: state.fontName,
chars,
url: result.url,
format: result.format
});
for (const ch of chars) {
state.loadedChars.add(ch);
state.pendingChars.delete(ch);
}
} catch {
for (const ch of chars) {
state.pendingChars.delete(ch);
state.failedChars.add(ch);
}
} finally {
this.flying--;
}
}
/** 并发池:超出 maxConcurrent 的任务排队等待 */
enqueue(fn) {
if (this.active < this.config.maxConcurrent) this.execute(fn);
else this.queue.push(fn);
}
/**
* 执行一个任务完成后从队列取下一个
* 注意这里必须直接调用 nextfn不能递归调用外层 run 闭包
* 那样会把下一个任务替换成本次任务重跑闭包捕获队列真身丢失
*/
execute(fn) {
this.active++;
fn().finally(() => {
this.active--;
const next = this.queue.shift();
if (next) this.execute(next);
});
}
setMaxConcurrent(n) {
this.config.maxConcurrent = Math.max(1, n | 0);
}
};
//#endregion
//#region src/index.ts
/**
* uni-webfont uni-app 字体按需加载小程序 / H5 / App 通用
*
* 原理中文字体动辄 5-20MB小程序主包限 2MB整包加载必死
* 本插件把页面实际用到的字符提交给子集化服务服务端按字符裁出
* KB 的字体片段再通过 uni.loadFontFace 注册文字用多少加载多少
*
* web@font-face unicode-range 多片段并存的关键差异
* 小程序 loadFontFace 不支持 unicode-range同名 family 只有一个生效字体
* 因此本层采用字符累积 + 全量重载策略
* - 引擎层webfont-sdk IncrementalEngine仍按字符去重只有新字符触发请求
* - 每次请求携带累积全集新字符 + 历史已加载字符服务端缓存按文本命中
* - 片段就绪后 uni.loadFontFace family 重载旧字形保持渲染直到新字体
* 就绪视觉上无闪烁
* - maxConcurrent 固定 1 family 的子集请求必须串行保证后到的
* 请求字符集是前者的超集并发乱序会让小集合后落地丢字符
*
* 用法
* ```ts
* import { UniWebFont } from 'uni-webfont'
*
* const loader = UniWebFont.loadFont({ fontName: '令东齐伋复刻体.ttf' })
* loader.update('静心茶舍 今日特饮')
* // 渲染前等待字体就绪(可选,旧字形兜底显示)
* await loader.ready()
* ```
*/
/**
* 取全局 uni 对象
* 不用 declare global 声明发布的 d.ts 会与用户工程里 @dcloudio/types
* uni 声明冲突重复标识符globalThis 交叉类型是零依赖的诚实写法
*/
function getUni() {
const g = globalThis;
if (!g.uni) throw new Error("[uni-webfont] 未检测到 uni 全局对象,请在 uni-app 环境中使用");
return g.uni;
}
/** fontFamily 里的文件后缀family 名不认扩展名) */
const FONT_EXT_RE = /\.(ttf|otf|woff2?|ttc)$/i;
var UniWebFontMode = class {
constructor(config = {}) {
this.defaultBaseUrl = "https://webfont.shenzilong.cn";
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl;
this.engine = new IncrementalEngine({
/** 串行必须:见文件头「字符累积 + 全量重载」策略说明 */
maxConcurrent: 1,
provider: null
});
}
getEngine() {
return this.engine;
}
/**
* 创建或复用一个字体的增量加载器
* 返回的 loader 可反复 update引擎按字符去重只有新字符触发网络请求
*/
loadFont(options) {
const fontName = options.fontName;
const family = options.family ?? fontName.replace(FONT_EXT_RE, "").trim();
const key = IncrementalEngine.fontKey(fontName, family);
const baseUrl = options.baseUrl ?? this.defaultBaseUrl;
const outType = options.outType ?? "ttf";
const global = options.global ?? true;
const maxCharsPerChunk = options.maxCharsPerChunk ?? 300;
const debug = options.debug ?? false;
/**
* 累积字符集目标全集每次子集请求都携带它保证新字体
* 一定是已渲染字体的超集失败字符也计入重试时靠它自愈
* 同字体二次 loadFont跨页面复用 state时从引擎播种已处理的字符
* 否则空累积集会让重载 URL 丢掉历史字符 unicode-range重载即替换
*/
const existing = this.engine.getState(key);
const accumulated = new Set(existing ? [
...existing.loadedChars,
...existing.failedChars,
...existing.pendingChars
] : []);
/** 累积全集 provider覆盖引擎默认的「仅新字符」URL 构造 */
const provider = (name, batchText, type) => {
for (const ch of batchText) accumulated.add(ch);
const text = Array.from(accumulated).join("");
if (debug) console.log(`[uni-webfont] subset ${family}: +${batchText.length} → 累积 ${text.length}`);
return Promise.resolve({
url: `${baseUrl}/api?font=${encodeURIComponent(name)}&text=${encodeURIComponent(text)}&outType=${type}`,
format: type === "woff2" ? "woff2" : "truetype"
});
};
/** 片段就绪 → uni.loadFontFace 重载同 familysource 直传 URL由平台下载 */
const onLoadChunk = (chunk) => new Promise((resolve, reject) => {
getUni().loadFontFace({
family,
source: `url("${chunk.url}")`,
global,
desc: options.desc,
success: () => {
if (debug) console.log(`[uni-webfont] ${family} 已生效(${chunk.chars.length} 字增量)`);
resolve();
},
fail: (err) => {
const msg = `uni-webfont loadFontFace 失败: ${family}${err?.errMsg ?? "未知错误"}`;
if (debug) console.error(msg);
reject(new Error(msg));
}
});
});
/** per-state provider累积全集 URL见文件头策略说明同 key 复用时不重复注入 */
this.engine.ensureState(key, fontName, {
baseUrl,
outType,
onLoadChunk,
provider
});
let disposed = false;
return {
update: (text) => {
if (disposed) return;
this.engine.submitText(key, text, maxCharsPerChunk);
},
isPending: () => {
const s = this.engine.getState(key);
return !!s && s.pendingChars.size > 0;
},
ready: async () => {
while (this.engine.hasPending()) await new Promise((r) => setTimeout(r, 50));
},
retryFailed: () => this.engine.retryFailed(key),
dispose: () => {
if (disposed) return;
disposed = true;
this.engine.removeState(key);
}
};
}
/** 是否有片段在请求/注册中(所有字体) */
hasPending() {
return this.engine.hasPending();
}
/** 等待全部在途片段就绪(截图/导出前调用) */
async ready() {
while (this.hasPending()) await new Promise((r) => setTimeout(r, 50));
}
};
/** 默认实例(与 webfont-sdk 的 WebFont / WebFontCanvas 命名约定一致) */
const UniWebFont = new UniWebFontMode();
//#endregion
export { UniWebFont, UniWebFontMode };

View File

@ -0,0 +1,47 @@
{
"name": "uni-webfont",
"version": "0.1.0",
"description": "uni-app 字体按需加载 —— 小程序/H5/App 任意中文字体只加载页面实际用到的字符10 字 ≈ 10KB突破主包 2MB 与字体整包 10MB+ 限制",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "pnpm --filter webfont-sdk build && tsdown && node scripts/sync-uni-modules.mjs",
"typecheck": "tsc --noEmit"
},
"keywords": [
"uni-app",
"webfont",
"font-subset",
"loadFontFace",
"chinese-font",
"miniprogram"
],
"repository": {
"type": "git",
"url": "git+https://github.com/2234839/web-font.git",
"directory": "packages/uni-webfont"
},
"author": "崮生(子虚)",
"license": "MIT",
"dependencies": {
"webfont-sdk": "workspace:*"
},
"devDependencies": {
"tsdown": "^0.22.14",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,32 @@
/**
* 构建后同步 dist-bundle 产物写入 uni_modules/gs-webfont/js_sdk/
* uni_modules DCloud 插件市场的标准目录结构HBuilderX 从这里读插件
*/
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const pkgRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const esm = resolve(pkgRoot, 'dist-bundle/index.js')
const iife = resolve(pkgRoot, 'dist-bundle/index.iife.js')
const targetDir = resolve(pkgRoot, '../../uni_modules/gs-webfont/js_sdk')
const dts = resolve(pkgRoot, 'dist/index.d.ts')
mkdirSync(targetDir, { recursive: true })
/**
* ESM uni-app CLI 工程vite直接 import
* 头部 banner 声明来源提醒勿手改
*/
const banner = `/**
* gs-webfont uni-app 字体按需加载 packages/uni-webfont 构建勿手改
* 文档https://webfont.shenzilong.cn
*/
`
writeFileSync(resolve(targetDir, 'index.js'), banner + readFileSync(esm, 'utf8'))
/** iife 版HBuilderX 非 CLI 工程经 require/script 引入,挂全局 UniWebFontBundle */
copyFileSync(iife, resolve(targetDir, 'index.iife.js'))
/** d.tsIDE 智能提示 */
copyFileSync(dts, resolve(targetDir, 'index.d.ts'))
console.log(`✓ synced → uni_modules/gs-webfont/js_sdk/`)

View File

@ -0,0 +1,215 @@
/**
* uni-webfont uni-app / H5 / App
*
* 5-20MB 2MB
*
* KB uni.loadFontFace
*
* web@font-face unicode-range
* loadFontFace unicode-range family
* +
* - webfont-sdk IncrementalEngine
* - +
* - uni.loadFontFace family
*
* - maxConcurrent 1 family
*
*
*
* ```ts
* import { UniWebFont } from 'uni-webfont'
*
* const loader = UniWebFont.loadFont({ fontName: '令东齐伋复刻体.ttf' })
* loader.update('静心茶舍 今日特饮')
* // 渲染前等待字体就绪(可选,旧字形兜底显示)
* await loader.ready()
* ```
*/
import { IncrementalEngine, type SubsetProvider, type LoadedChunk } from 'webfont-sdk/engine'
/** uni.loadFontFace 参数(最小访问面,避免依赖 @dcloudio/types */
interface IUniLoadFontFaceOptions {
family: string
source: string
global?: boolean
desc?: { style?: string; weight?: string; variant?: string }
success?: () => void
fail?: (err: { errMsg?: string }) => void
complete?: () => void
}
/** uni 宿主全局对象的最小类型面 */
interface IUniGlobal {
loadFontFace(options: IUniLoadFontFaceOptions): void
}
/**
* uni
* declare global d.ts @dcloudio/types
* uni globalThis
*/
function getUni(): IUniGlobal {
const g = globalThis as typeof globalThis & { uni?: IUniGlobal }
if (!g.uni) {
throw new Error('[uni-webfont] 未检测到 uni 全局对象,请在 uni-app 环境中使用')
}
return g.uni
}
/** 单个字体的加载选项 */
export interface IUniFontOptions {
/** 字体文件名(如 '令东齐伋复刻体.ttf'),服务端支持模糊匹配 */
fontName: string
/** 子集化服务基地址,默认官方在线服务 */
baseUrl?: string
/** loadFontFace 注册的 family 名,默认去掉扩展名的字体名 */
family?: string
/**
* 'ttf'
* ttfiOS woff2 H5 'woff2'
*/
outType?: 'ttf' | 'woff2'
/** 是否全局生效(微信 2.10.0+,需在 App.vue 调用才对全 app 生效),默认 true */
global?: boolean
/** 单次请求携带的最大字符数,超出自动分批串行加载,默认 300URL 长度安全值) */
maxCharsPerChunk?: number
/** 字体描述符透传style / weight / variant */
desc?: { style?: string; weight?: string; variant?: string }
/** 是否在控制台输出调试日志 */
debug?: boolean
}
/** loadFont 返回的增量加载器 */
export interface IUniFontLoader {
/** 提交文本(自动去重,只请求出现过的字符) */
update(text: string): void
/** 该字体是否有片段在请求/注册中 */
isPending(): boolean
/** 等待全部在途片段就绪(截图/导出前调用) */
ready(): Promise<void>
/** 清除失败记录,配合 update 重试失败字符 */
retryFailed(): void
dispose(): void
}
/** fontFamily 里的文件后缀family 名不认扩展名) */
const FONT_EXT_RE = /\.(ttf|otf|woff2?|ttc)$/i
export class UniWebFontMode {
private engine: IncrementalEngine
/** 未显式传 baseUrl 时的默认服务地址 */
private defaultBaseUrl = 'https://webfont.shenzilong.cn'
constructor(config: { baseUrl?: string } = {}) {
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl
this.engine = new IncrementalEngine({
/** 串行必须:见文件头「字符累积 + 全量重载」策略说明 */
maxConcurrent: 1,
provider: null,
})
}
getEngine(): IncrementalEngine {
return this.engine
}
/**
*
* loader update
*/
loadFont(options: IUniFontOptions): IUniFontLoader {
const fontName = options.fontName
const family = options.family ?? fontName.replace(FONT_EXT_RE, '').trim()
const key = IncrementalEngine.fontKey(fontName, family)
const baseUrl = options.baseUrl ?? this.defaultBaseUrl
const outType = options.outType ?? 'ttf'
const global = options.global ?? true
const maxCharsPerChunk = options.maxCharsPerChunk ?? 300
const debug = options.debug ?? false
/**
*
*
* loadFont state
* URL unicode-range
*/
const existing = this.engine.getState(key)
const accumulated = new Set<string>(
existing
? [...existing.loadedChars, ...existing.failedChars, ...existing.pendingChars]
: [],
)
/** 累积全集 provider覆盖引擎默认的「仅新字符」URL 构造 */
const provider: SubsetProvider = (name, batchText, type) => {
for (const ch of batchText) accumulated.add(ch)
const text = Array.from(accumulated).join('')
if (debug) console.log(`[uni-webfont] subset ${family}: +${batchText.length} → 累积 ${text.length}`)
return Promise.resolve({
url: `${baseUrl}/api?font=${encodeURIComponent(name)}&text=${encodeURIComponent(text)}&outType=${type}`,
format: type === 'woff2' ? 'woff2' : 'truetype',
})
}
/** 片段就绪 → uni.loadFontFace 重载同 familysource 直传 URL由平台下载 */
const onLoadChunk = (chunk: LoadedChunk) =>
new Promise<void>((resolve, reject) => {
getUni().loadFontFace({
family,
source: `url("${chunk.url}")`,
global,
desc: options.desc,
success: () => {
if (debug) console.log(`[uni-webfont] ${family} 已生效(${chunk.chars.length} 字增量)`)
resolve()
},
fail: (err) => {
const msg = `uni-webfont loadFontFace 失败: ${family}${err?.errMsg ?? '未知错误'}`
if (debug) console.error(msg)
reject(new Error(msg))
},
})
})
/** per-state provider累积全集 URL见文件头策略说明同 key 复用时不重复注入 */
this.engine.ensureState(key, fontName, { baseUrl, outType, onLoadChunk, provider })
let disposed = false
return {
update: (text: string): void => {
if (disposed) return
this.engine.submitText(key, text, maxCharsPerChunk)
},
isPending: (): boolean => {
const s = this.engine.getState(key)
return !!s && s.pendingChars.size > 0
},
ready: async (): Promise<void> => {
while (this.engine.hasPending()) {
await new Promise((r) => setTimeout(r, 50))
}
},
retryFailed: (): void => this.engine.retryFailed(key),
dispose: (): void => {
if (disposed) return
disposed = true
this.engine.removeState(key)
},
}
}
/** 是否有片段在请求/注册中(所有字体) */
hasPending(): boolean {
return this.engine.hasPending()
}
/** 等待全部在途片段就绪(截图/导出前调用) */
async ready(): Promise<void> {
while (this.hasPending()) {
await new Promise((r) => setTimeout(r, 50))
}
}
}
/** 默认实例(与 webfont-sdk 的 WebFont / WebFontCanvas 命名约定一致) */
export const UniWebFont = new UniWebFontMode()

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"types": [],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "tsdown.config.ts"]
}

View File

@ -0,0 +1,42 @@
import { defineConfig } from 'tsdown'
export default [
/** ESM + d.ts —— npm 包产物bundler 用户 / CLI 工程) */
defineConfig({
entry: ['src/index.ts'],
format: 'esm',
dts: true,
clean: true,
outDir: 'dist',
platform: 'neutral',
}),
/**
* ESM bundlewebfont-sdk uni_modules
* npm
*/
defineConfig({
entry: ['src/index.ts'],
format: 'esm',
clean: false,
outDir: 'dist-bundle',
platform: 'neutral',
dts: false,
/**
* uni_modules node_modules webfont-sdk
* truetsdown 0.22 deps boolean pattern
*/
noExternal: [/./],
}),
/** iife —— HBuilderX 非 CLI 工程经 script 引入,挂全局 UniWebFontBundle */
defineConfig({
entry: ['src/index.ts'],
format: 'iife',
globalName: 'UniWebFontBundle',
clean: false,
outDir: 'dist-bundle',
platform: 'neutral',
dts: false,
/** 全部内联(同上,正则形式避开 tsdown boolean bug */
noExternal: [/./],
}),
]

View File

@ -16,6 +16,11 @@
"types": "./dist/api.d.ts",
"development": "./src/api.ts",
"import": "./dist/api.js"
},
"./engine": {
"types": "./dist/engine.d.ts",
"development": "./src/engine.ts",
"import": "./dist/engine.js"
}
},
"files": [

View File

@ -33,6 +33,11 @@ export interface IFontState {
* Promise ready()
*/
onLoadChunk: ((chunk: LoadedChunk) => void | Promise<void>) | null
/**
* provider
* uni unicode-range URL
*/
provider?: SubsetProvider | null
}
/** 一次成功加载的增量片段 */
@ -63,6 +68,8 @@ export interface IEnsureStateOptions {
outType: string
/** 注册回调(可后补) */
onLoadChunk?: (chunk: LoadedChunk) => void | Promise<void>
/** 该字体的专属 provider优先于引擎级配置 */
provider?: SubsetProvider | null
}
export function createHttpProvider(baseUrl: string): SubsetProvider {
@ -79,7 +86,7 @@ export class IncrementalEngine {
/** 并发池 */
private active = 0
private queue: Array<() => void> = []
private queue: Array<() => Promise<void>> = []
/** 在途任务数provider 请求 + 注册回调hasPending / ready 用 */
private flying = 0
@ -115,6 +122,7 @@ export class IncrementalEngine {
failedChars: new Set(),
pendingChars: new Set(),
onLoadChunk: options.onLoadChunk ?? null,
provider: options.provider ?? null,
}
this.states.set(key, state)
return state
@ -122,6 +130,7 @@ export class IncrementalEngine {
state.baseUrl = options.baseUrl
state.outType = options.outType
if (options.onLoadChunk) state.onLoadChunk = options.onLoadChunk
if (options.provider !== undefined) state.provider = options.provider
return state
}
@ -147,8 +156,10 @@ export class IncrementalEngine {
/**
*
* pending loaded failed
* maxCharsPerChunk uni loadFontFace unicode-range
* URL
*/
submitText(key: string, text: string): void {
submitText(key: string, text: string, maxCharsPerChunk = Infinity): void {
const state = this.states.get(key)
if (!state) return
const newChars: string[] = []
@ -160,7 +171,10 @@ export class IncrementalEngine {
state.pendingChars.add(ch)
}
if (newChars.length === 0) return
this.enqueue(() => this.loadChunk(state, newChars))
for (let i = 0; i < newChars.length; i += maxCharsPerChunk) {
const batch = newChars.slice(i, i + maxCharsPerChunk)
this.enqueue(() => this.loadChunk(state, batch))
}
}
/** 执行一次子集请求 + 注册(在并发槽内完成) */
@ -168,7 +182,7 @@ export class IncrementalEngine {
this.flying++
try {
const text = chars.join('')
const provider = this.config.provider ?? createHttpProvider(state.baseUrl)
const provider = state.provider ?? this.config.provider ?? createHttpProvider(state.baseUrl)
const result = await provider(state.fontName, text, state.outType)
/** 注册完成后才把字符记为已加载:注册失败可走 failedChars 重试路径 */
await state.onLoadChunk?.({
@ -193,16 +207,25 @@ export class IncrementalEngine {
/** 并发池:超出 maxConcurrent 的任务排队等待 */
private enqueue(fn: () => Promise<void>): void {
const run = (): void => {
this.active++
fn().finally(() => {
this.active--
const next = this.queue.shift()
if (next) run()
})
if (this.active < this.config.maxConcurrent) {
this.execute(fn)
} else {
this.queue.push(fn)
}
if (this.active < this.config.maxConcurrent) run()
else this.queue.push(() => undefined)
}
/**
*
* nextfn run
*
*/
private execute(fn: () => Promise<void>): void {
this.active++
fn().finally(() => {
this.active--
const next = this.queue.shift()
if (next) this.execute(next)
})
}
setMaxConcurrent(n: number): void {

View File

@ -3,7 +3,7 @@ import { defineConfig } from 'tsdown'
export default [
/** ESM + d.ts —— npm 包主产物leafer 插件 / bundler 用户api 为服务端 REST 客户端子路径 */
defineConfig({
entry: ['src/index.ts', 'src/api.ts'],
entry: ['src/index.ts', 'src/api.ts', 'src/engine.ts'],
format: 'esm',
dts: true,
clean: true,

13
pnpm-lock.yaml generated
View File

@ -86,6 +86,19 @@ importers:
specifier: workspace:*
version: link:../webfont-sdk
packages/uni-webfont:
dependencies:
webfont-sdk:
specifier: workspace:*
version: link:../webfont-sdk
devDependencies:
tsdown:
specifier: ^0.22.14
version: 0.22.14(typescript@7.0.2)(unrun@0.2.37)
typescript:
specifier: ^7.0.2
version: 7.0.2
packages/webfont-sdk:
dependencies:
'@napi-rs/canvas':

View File

@ -70,7 +70,8 @@
loadedChars: /* @__PURE__ */ new Set(),
failedChars: /* @__PURE__ */ new Set(),
pendingChars: /* @__PURE__ */ new Set(),
onLoadChunk: options.onLoadChunk ?? null
onLoadChunk: options.onLoadChunk ?? null,
provider: options.provider ?? null
};
this.states.set(key, state);
return state;
@ -78,6 +79,7 @@
state.baseUrl = options.baseUrl;
state.outType = options.outType;
if (options.onLoadChunk) state.onLoadChunk = options.onLoadChunk;
if (options.provider !== void 0) state.provider = options.provider;
return state;
}
/** 删除状态(销毁时) */
@ -97,8 +99,10 @@
/**
* 提交一批文本过滤出新字符并异步请求子集
* 乐观标记 pending成功移入 loaded失败移入 failed
* 超过 maxCharsPerChunk 时自动分批uni 小程序 loadFontFace unicode-range
* 单次需携带全量累积文本长文本按批切分避免 URL 超限
*/
submitText(key, text) {
submitText(key, text, maxCharsPerChunk = Infinity) {
const state = this.states.get(key);
if (!state) return;
const newChars = [];
@ -110,14 +114,17 @@
state.pendingChars.add(ch);
}
if (newChars.length === 0) return;
this.enqueue(() => this.loadChunk(state, newChars));
for (let i = 0; i < newChars.length; i += maxCharsPerChunk) {
const batch = newChars.slice(i, i + maxCharsPerChunk);
this.enqueue(() => this.loadChunk(state, batch));
}
}
/** 执行一次子集请求 + 注册(在并发槽内完成) */
async loadChunk(state, chars) {
this.flying++;
try {
const text = chars.join("");
const result = await (this.config.provider ?? createHttpProvider(state.baseUrl))(state.fontName, text, state.outType);
const result = await (state.provider ?? this.config.provider ?? createHttpProvider(state.baseUrl))(state.fontName, text, state.outType);
/** 注册完成后才把字符记为已加载:注册失败可走 failedChars 重试路径 */
await state.onLoadChunk?.({
fontName: state.fontName,
@ -140,15 +147,21 @@
}
/** 并发池:超出 maxConcurrent 的任务排队等待 */
enqueue(fn) {
const run = () => {
this.active++;
fn().finally(() => {
this.active--;
if (this.queue.shift()) run();
});
};
if (this.active < this.config.maxConcurrent) run();
else this.queue.push(() => void 0);
if (this.active < this.config.maxConcurrent) this.execute(fn);
else this.queue.push(fn);
}
/**
* 执行一个任务完成后从队列取下一个
* 注意这里必须直接调用 nextfn不能递归调用外层 run 闭包
* 那样会把下一个任务替换成本次任务重跑闭包捕获队列真身丢失
*/
execute(fn) {
this.active++;
fn().finally(() => {
this.active--;
const next = this.queue.shift();
if (next) this.execute(next);
});
}
setMaxConcurrent(n) {
this.config.maxConcurrent = Math.max(1, n | 0);

View File

@ -0,0 +1,7 @@
# Changelog
## 0.1.02026-08-16
- 首个版本:字符累积 + uni.loadFontFace 同 family 重载策略,按需加载中文字体子集
- 复用 webfont-sdk IncrementalEngine字符去重 / 失败记忆 / 串行保序)
- 平台:微信/支付宝/百度/抖音/QQ 小程序、H5、Appvue/uvue

View File

@ -0,0 +1,62 @@
import { IncrementalEngine } from "webfont-sdk/engine";
//#region src/index.d.ts
/** 单个字体的加载选项 */
interface IUniFontOptions {
/** 字体文件名(如 '令东齐伋复刻体.ttf'),服务端支持模糊匹配 */
fontName: string;
/** 子集化服务基地址,默认官方在线服务 */
baseUrl?: string;
/** loadFontFace 注册的 family 名,默认去掉扩展名的字体名 */
family?: string;
/**
* 'ttf'
* ttfiOS woff2 H5 'woff2'
*/
outType?: 'ttf' | 'woff2';
/** 是否全局生效(微信 2.10.0+,需在 App.vue 调用才对全 app 生效),默认 true */
global?: boolean;
/** 单次请求携带的最大字符数,超出自动分批串行加载,默认 300URL 长度安全值) */
maxCharsPerChunk?: number;
/** 字体描述符透传style / weight / variant */
desc?: {
style?: string;
weight?: string;
variant?: string;
};
/** 是否在控制台输出调试日志 */
debug?: boolean;
}
/** loadFont 返回的增量加载器 */
interface IUniFontLoader {
/** 提交文本(自动去重,只请求出现过的字符) */
update(text: string): void;
/** 该字体是否有片段在请求/注册中 */
isPending(): boolean;
/** 等待全部在途片段就绪(截图/导出前调用) */
ready(): Promise<void>;
/** 清除失败记录,配合 update 重试失败字符 */
retryFailed(): void;
dispose(): void;
}
declare class UniWebFontMode {
private engine;
/** 未显式传 baseUrl 时的默认服务地址 */
private defaultBaseUrl;
constructor(config?: {
baseUrl?: string;
});
getEngine(): IncrementalEngine;
/**
*
* loader update
*/
loadFont(options: IUniFontOptions): IUniFontLoader;
/** 是否有片段在请求/注册中(所有字体) */
hasPending(): boolean;
/** 等待全部在途片段就绪(截图/导出前调用) */
ready(): Promise<void>;
}
/** 默认实例(与 webfont-sdk 的 WebFont / WebFontCanvas 命名约定一致) */
declare const UniWebFont: UniWebFontMode;
//#endregion
export { IUniFontLoader, IUniFontOptions, UniWebFont, UniWebFontMode };

View File

@ -0,0 +1,290 @@
var UniWebFontBundle = (function(exports) {
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region ../webfont-sdk/dist/engine.js
function createHttpProvider(baseUrl) {
return (fontName, text, outType) => {
const url = `${baseUrl}/api?font=${encodeURIComponent(fontName)}&text=${encodeURIComponent(text)}&outType=${outType}`;
return Promise.resolve({
url,
format: outType === "woff2" ? "woff2" : "truetype"
});
};
}
var IncrementalEngine = class {
constructor(config = {}) {
this.states = /* @__PURE__ */ new Map();
this.active = 0;
this.queue = [];
this.flying = 0;
this.config = {
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null
};
}
/** fontKeyfontName + family 唯一确定一个增量组 */
static fontKey(fontName, family) {
return fontName + "|" + family;
}
setProvider(provider) {
this.config.provider = provider;
}
getState(key) {
return this.states.get(key);
}
/** 获取或创建字体状态;已存在时按传入项更新 baseUrl / outType / 回调 */
ensureState(key, fontName, options) {
let state = this.states.get(key);
if (!state) {
state = {
fontName,
baseUrl: options.baseUrl,
outType: options.outType,
loadedChars: /* @__PURE__ */ new Set(),
failedChars: /* @__PURE__ */ new Set(),
pendingChars: /* @__PURE__ */ new Set(),
onLoadChunk: options.onLoadChunk ?? null,
provider: options.provider ?? null
};
this.states.set(key, state);
return state;
}
state.baseUrl = options.baseUrl;
state.outType = options.outType;
if (options.onLoadChunk) state.onLoadChunk = options.onLoadChunk;
if (options.provider !== void 0) state.provider = options.provider;
return state;
}
/** 删除状态(销毁时) */
removeState(key) {
this.states.delete(key);
}
/** 是否还有在途任务请求中或注册中ready() 轮询用) */
hasPending() {
if (this.flying > 0) return true;
for (const s of this.states.values()) if (s.pendingChars.size > 0) return true;
return false;
}
/** 清除失败记录(下次遇到这些字符会重新请求) */
retryFailed(key) {
this.states.get(key)?.failedChars.clear();
}
/**
* 提交一批文本过滤出新字符并异步请求子集
* 乐观标记 pending成功移入 loaded失败移入 failed
* 超过 maxCharsPerChunk 时自动分批uni 小程序 loadFontFace unicode-range
* 单次需携带全量累积文本长文本按批切分避免 URL 超限
*/
submitText(key, text, maxCharsPerChunk = Infinity) {
const state = this.states.get(key);
if (!state) return;
const newChars = [];
for (const ch of text) {
if (state.loadedChars.has(ch) || state.pendingChars.has(ch) || state.failedChars.has(ch)) continue;
/** 跳过控制字符 */
if (ch.charCodeAt(0) < 32) continue;
newChars.push(ch);
state.pendingChars.add(ch);
}
if (newChars.length === 0) return;
for (let i = 0; i < newChars.length; i += maxCharsPerChunk) {
const batch = newChars.slice(i, i + maxCharsPerChunk);
this.enqueue(() => this.loadChunk(state, batch));
}
}
/** 执行一次子集请求 + 注册(在并发槽内完成) */
async loadChunk(state, chars) {
this.flying++;
try {
const text = chars.join("");
const result = await (state.provider ?? this.config.provider ?? createHttpProvider(state.baseUrl))(state.fontName, text, state.outType);
/** 注册完成后才把字符记为已加载:注册失败可走 failedChars 重试路径 */
await state.onLoadChunk?.({
fontName: state.fontName,
chars,
url: result.url,
format: result.format
});
for (const ch of chars) {
state.loadedChars.add(ch);
state.pendingChars.delete(ch);
}
} catch {
for (const ch of chars) {
state.pendingChars.delete(ch);
state.failedChars.add(ch);
}
} finally {
this.flying--;
}
}
/** 并发池:超出 maxConcurrent 的任务排队等待 */
enqueue(fn) {
if (this.active < this.config.maxConcurrent) this.execute(fn);
else this.queue.push(fn);
}
/**
* 执行一个任务完成后从队列取下一个
* 注意这里必须直接调用 nextfn不能递归调用外层 run 闭包
* 那样会把下一个任务替换成本次任务重跑闭包捕获队列真身丢失
*/
execute(fn) {
this.active++;
fn().finally(() => {
this.active--;
const next = this.queue.shift();
if (next) this.execute(next);
});
}
setMaxConcurrent(n) {
this.config.maxConcurrent = Math.max(1, n | 0);
}
};
//#endregion
//#region src/index.ts
/**
* uni-webfont uni-app 字体按需加载小程序 / H5 / App 通用
*
* 原理中文字体动辄 5-20MB小程序主包限 2MB整包加载必死
* 本插件把页面实际用到的字符提交给子集化服务服务端按字符裁出
* KB 的字体片段再通过 uni.loadFontFace 注册文字用多少加载多少
*
* web@font-face unicode-range 多片段并存的关键差异
* 小程序 loadFontFace 不支持 unicode-range同名 family 只有一个生效字体
* 因此本层采用字符累积 + 全量重载策略
* - 引擎层webfont-sdk IncrementalEngine仍按字符去重只有新字符触发请求
* - 每次请求携带累积全集新字符 + 历史已加载字符服务端缓存按文本命中
* - 片段就绪后 uni.loadFontFace family 重载旧字形保持渲染直到新字体
* 就绪视觉上无闪烁
* - maxConcurrent 固定 1 family 的子集请求必须串行保证后到的
* 请求字符集是前者的超集并发乱序会让小集合后落地丢字符
*
* 用法
* ```ts
* import { UniWebFont } from 'uni-webfont'
*
* const loader = UniWebFont.loadFont({ fontName: '令东齐伋复刻体.ttf' })
* loader.update('静心茶舍 今日特饮')
* // 渲染前等待字体就绪(可选,旧字形兜底显示)
* await loader.ready()
* ```
*/
/**
* 取全局 uni 对象
* 不用 declare global 声明发布的 d.ts 会与用户工程里 @dcloudio/types
* uni 声明冲突重复标识符globalThis 交叉类型是零依赖的诚实写法
*/
function getUni() {
const g = globalThis;
if (!g.uni) throw new Error("[uni-webfont] 未检测到 uni 全局对象,请在 uni-app 环境中使用");
return g.uni;
}
/** fontFamily 里的文件后缀family 名不认扩展名) */
const FONT_EXT_RE = /\.(ttf|otf|woff2?|ttc)$/i;
var UniWebFontMode = class {
constructor(config = {}) {
this.defaultBaseUrl = "https://webfont.shenzilong.cn";
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl;
this.engine = new IncrementalEngine({
/** 串行必须:见文件头「字符累积 + 全量重载」策略说明 */
maxConcurrent: 1,
provider: null
});
}
getEngine() {
return this.engine;
}
/**
* 创建或复用一个字体的增量加载器
* 返回的 loader 可反复 update引擎按字符去重只有新字符触发网络请求
*/
loadFont(options) {
const fontName = options.fontName;
const family = options.family ?? fontName.replace(FONT_EXT_RE, "").trim();
const key = IncrementalEngine.fontKey(fontName, family);
const baseUrl = options.baseUrl ?? this.defaultBaseUrl;
const outType = options.outType ?? "ttf";
const global = options.global ?? true;
const maxCharsPerChunk = options.maxCharsPerChunk ?? 300;
const debug = options.debug ?? false;
/**
* 累积字符集目标全集每次子集请求都携带它保证新字体
* 一定是已渲染字体的超集失败字符也计入重试时靠它自愈
* 同字体二次 loadFont跨页面复用 state时从引擎播种已处理的字符
* 否则空累积集会让重载 URL 丢掉历史字符 unicode-range重载即替换
*/
const existing = this.engine.getState(key);
const accumulated = new Set(existing ? [
...existing.loadedChars,
...existing.failedChars,
...existing.pendingChars
] : []);
/** 累积全集 provider覆盖引擎默认的「仅新字符」URL 构造 */
const provider = (name, batchText, type) => {
for (const ch of batchText) accumulated.add(ch);
const text = Array.from(accumulated).join("");
if (debug) console.log(`[uni-webfont] subset ${family}: +${batchText.length} → 累积 ${text.length}`);
return Promise.resolve({
url: `${baseUrl}/api?font=${encodeURIComponent(name)}&text=${encodeURIComponent(text)}&outType=${type}`,
format: type === "woff2" ? "woff2" : "truetype"
});
};
/** 片段就绪 → uni.loadFontFace 重载同 familysource 直传 URL由平台下载 */
const onLoadChunk = (chunk) => new Promise((resolve, reject) => {
getUni().loadFontFace({
family,
source: `url("${chunk.url}")`,
global,
desc: options.desc,
success: () => {
if (debug) console.log(`[uni-webfont] ${family} 已生效(${chunk.chars.length} 字增量)`);
resolve();
},
fail: (err) => {
const msg = `uni-webfont loadFontFace 失败: ${family}${err?.errMsg ?? "未知错误"}`;
if (debug) console.error(msg);
reject(new Error(msg));
}
});
});
/** per-state provider累积全集 URL见文件头策略说明同 key 复用时不重复注入 */
this.engine.ensureState(key, fontName, {
baseUrl,
outType,
onLoadChunk,
provider
});
let disposed = false;
return {
update: (text) => {
if (disposed) return;
this.engine.submitText(key, text, maxCharsPerChunk);
},
isPending: () => {
const s = this.engine.getState(key);
return !!s && s.pendingChars.size > 0;
},
ready: async () => {
while (this.engine.hasPending()) await new Promise((r) => setTimeout(r, 50));
},
retryFailed: () => this.engine.retryFailed(key),
dispose: () => {
if (disposed) return;
disposed = true;
this.engine.removeState(key);
}
};
}
/** 是否有片段在请求/注册中(所有字体) */
hasPending() {
return this.engine.hasPending();
}
/** 等待全部在途片段就绪(截图/导出前调用) */
async ready() {
while (this.hasPending()) await new Promise((r) => setTimeout(r, 50));
}
};
//#endregion
exports.UniWebFont = new UniWebFontMode();
exports.UniWebFontMode = UniWebFontMode;
return exports;
})({});

View File

@ -0,0 +1,291 @@
/**
* gs-webfont uni-app 字体按需加载 packages/uni-webfont 构建勿手改
* 文档https://webfont.shenzilong.cn
*/
//#region ../webfont-sdk/dist/engine.js
function createHttpProvider(baseUrl) {
return (fontName, text, outType) => {
const url = `${baseUrl}/api?font=${encodeURIComponent(fontName)}&text=${encodeURIComponent(text)}&outType=${outType}`;
return Promise.resolve({
url,
format: outType === "woff2" ? "woff2" : "truetype"
});
};
}
var IncrementalEngine = class {
constructor(config = {}) {
this.states = /* @__PURE__ */ new Map();
this.active = 0;
this.queue = [];
this.flying = 0;
this.config = {
maxConcurrent: config.maxConcurrent ?? 4,
provider: config.provider ?? null
};
}
/** fontKeyfontName + family 唯一确定一个增量组 */
static fontKey(fontName, family) {
return fontName + "|" + family;
}
setProvider(provider) {
this.config.provider = provider;
}
getState(key) {
return this.states.get(key);
}
/** 获取或创建字体状态;已存在时按传入项更新 baseUrl / outType / 回调 */
ensureState(key, fontName, options) {
let state = this.states.get(key);
if (!state) {
state = {
fontName,
baseUrl: options.baseUrl,
outType: options.outType,
loadedChars: /* @__PURE__ */ new Set(),
failedChars: /* @__PURE__ */ new Set(),
pendingChars: /* @__PURE__ */ new Set(),
onLoadChunk: options.onLoadChunk ?? null,
provider: options.provider ?? null
};
this.states.set(key, state);
return state;
}
state.baseUrl = options.baseUrl;
state.outType = options.outType;
if (options.onLoadChunk) state.onLoadChunk = options.onLoadChunk;
if (options.provider !== void 0) state.provider = options.provider;
return state;
}
/** 删除状态(销毁时) */
removeState(key) {
this.states.delete(key);
}
/** 是否还有在途任务请求中或注册中ready() 轮询用) */
hasPending() {
if (this.flying > 0) return true;
for (const s of this.states.values()) if (s.pendingChars.size > 0) return true;
return false;
}
/** 清除失败记录(下次遇到这些字符会重新请求) */
retryFailed(key) {
this.states.get(key)?.failedChars.clear();
}
/**
* 提交一批文本过滤出新字符并异步请求子集
* 乐观标记 pending成功移入 loaded失败移入 failed
* 超过 maxCharsPerChunk 时自动分批uni 小程序 loadFontFace unicode-range
* 单次需携带全量累积文本长文本按批切分避免 URL 超限
*/
submitText(key, text, maxCharsPerChunk = Infinity) {
const state = this.states.get(key);
if (!state) return;
const newChars = [];
for (const ch of text) {
if (state.loadedChars.has(ch) || state.pendingChars.has(ch) || state.failedChars.has(ch)) continue;
/** 跳过控制字符 */
if (ch.charCodeAt(0) < 32) continue;
newChars.push(ch);
state.pendingChars.add(ch);
}
if (newChars.length === 0) return;
for (let i = 0; i < newChars.length; i += maxCharsPerChunk) {
const batch = newChars.slice(i, i + maxCharsPerChunk);
this.enqueue(() => this.loadChunk(state, batch));
}
}
/** 执行一次子集请求 + 注册(在并发槽内完成) */
async loadChunk(state, chars) {
this.flying++;
try {
const text = chars.join("");
const result = await (state.provider ?? this.config.provider ?? createHttpProvider(state.baseUrl))(state.fontName, text, state.outType);
/** 注册完成后才把字符记为已加载:注册失败可走 failedChars 重试路径 */
await state.onLoadChunk?.({
fontName: state.fontName,
chars,
url: result.url,
format: result.format
});
for (const ch of chars) {
state.loadedChars.add(ch);
state.pendingChars.delete(ch);
}
} catch {
for (const ch of chars) {
state.pendingChars.delete(ch);
state.failedChars.add(ch);
}
} finally {
this.flying--;
}
}
/** 并发池:超出 maxConcurrent 的任务排队等待 */
enqueue(fn) {
if (this.active < this.config.maxConcurrent) this.execute(fn);
else this.queue.push(fn);
}
/**
* 执行一个任务完成后从队列取下一个
* 注意这里必须直接调用 nextfn不能递归调用外层 run 闭包
* 那样会把下一个任务替换成本次任务重跑闭包捕获队列真身丢失
*/
execute(fn) {
this.active++;
fn().finally(() => {
this.active--;
const next = this.queue.shift();
if (next) this.execute(next);
});
}
setMaxConcurrent(n) {
this.config.maxConcurrent = Math.max(1, n | 0);
}
};
//#endregion
//#region src/index.ts
/**
* uni-webfont uni-app 字体按需加载小程序 / H5 / App 通用
*
* 原理中文字体动辄 5-20MB小程序主包限 2MB整包加载必死
* 本插件把页面实际用到的字符提交给子集化服务服务端按字符裁出
* KB 的字体片段再通过 uni.loadFontFace 注册文字用多少加载多少
*
* web@font-face unicode-range 多片段并存的关键差异
* 小程序 loadFontFace 不支持 unicode-range同名 family 只有一个生效字体
* 因此本层采用字符累积 + 全量重载策略
* - 引擎层webfont-sdk IncrementalEngine仍按字符去重只有新字符触发请求
* - 每次请求携带累积全集新字符 + 历史已加载字符服务端缓存按文本命中
* - 片段就绪后 uni.loadFontFace family 重载旧字形保持渲染直到新字体
* 就绪视觉上无闪烁
* - maxConcurrent 固定 1 family 的子集请求必须串行保证后到的
* 请求字符集是前者的超集并发乱序会让小集合后落地丢字符
*
* 用法
* ```ts
* import { UniWebFont } from 'uni-webfont'
*
* const loader = UniWebFont.loadFont({ fontName: '令东齐伋复刻体.ttf' })
* loader.update('静心茶舍 今日特饮')
* // 渲染前等待字体就绪(可选,旧字形兜底显示)
* await loader.ready()
* ```
*/
/**
* 取全局 uni 对象
* 不用 declare global 声明发布的 d.ts 会与用户工程里 @dcloudio/types
* uni 声明冲突重复标识符globalThis 交叉类型是零依赖的诚实写法
*/
function getUni() {
const g = globalThis;
if (!g.uni) throw new Error("[uni-webfont] 未检测到 uni 全局对象,请在 uni-app 环境中使用");
return g.uni;
}
/** fontFamily 里的文件后缀family 名不认扩展名) */
const FONT_EXT_RE = /\.(ttf|otf|woff2?|ttc)$/i;
var UniWebFontMode = class {
constructor(config = {}) {
this.defaultBaseUrl = "https://webfont.shenzilong.cn";
if (config.baseUrl) this.defaultBaseUrl = config.baseUrl;
this.engine = new IncrementalEngine({
/** 串行必须:见文件头「字符累积 + 全量重载」策略说明 */
maxConcurrent: 1,
provider: null
});
}
getEngine() {
return this.engine;
}
/**
* 创建或复用一个字体的增量加载器
* 返回的 loader 可反复 update引擎按字符去重只有新字符触发网络请求
*/
loadFont(options) {
const fontName = options.fontName;
const family = options.family ?? fontName.replace(FONT_EXT_RE, "").trim();
const key = IncrementalEngine.fontKey(fontName, family);
const baseUrl = options.baseUrl ?? this.defaultBaseUrl;
const outType = options.outType ?? "ttf";
const global = options.global ?? true;
const maxCharsPerChunk = options.maxCharsPerChunk ?? 300;
const debug = options.debug ?? false;
/**
* 累积字符集目标全集每次子集请求都携带它保证新字体
* 一定是已渲染字体的超集失败字符也计入重试时靠它自愈
* 同字体二次 loadFont跨页面复用 state时从引擎播种已处理的字符
* 否则空累积集会让重载 URL 丢掉历史字符 unicode-range重载即替换
*/
const existing = this.engine.getState(key);
const accumulated = new Set(existing ? [
...existing.loadedChars,
...existing.failedChars,
...existing.pendingChars
] : []);
/** 累积全集 provider覆盖引擎默认的「仅新字符」URL 构造 */
const provider = (name, batchText, type) => {
for (const ch of batchText) accumulated.add(ch);
const text = Array.from(accumulated).join("");
if (debug) console.log(`[uni-webfont] subset ${family}: +${batchText.length} → 累积 ${text.length}`);
return Promise.resolve({
url: `${baseUrl}/api?font=${encodeURIComponent(name)}&text=${encodeURIComponent(text)}&outType=${type}`,
format: type === "woff2" ? "woff2" : "truetype"
});
};
/** 片段就绪 → uni.loadFontFace 重载同 familysource 直传 URL由平台下载 */
const onLoadChunk = (chunk) => new Promise((resolve, reject) => {
getUni().loadFontFace({
family,
source: `url("${chunk.url}")`,
global,
desc: options.desc,
success: () => {
if (debug) console.log(`[uni-webfont] ${family} 已生效(${chunk.chars.length} 字增量)`);
resolve();
},
fail: (err) => {
const msg = `uni-webfont loadFontFace 失败: ${family}${err?.errMsg ?? "未知错误"}`;
if (debug) console.error(msg);
reject(new Error(msg));
}
});
});
/** per-state provider累积全集 URL见文件头策略说明同 key 复用时不重复注入 */
this.engine.ensureState(key, fontName, {
baseUrl,
outType,
onLoadChunk,
provider
});
let disposed = false;
return {
update: (text) => {
if (disposed) return;
this.engine.submitText(key, text, maxCharsPerChunk);
},
isPending: () => {
const s = this.engine.getState(key);
return !!s && s.pendingChars.size > 0;
},
ready: async () => {
while (this.engine.hasPending()) await new Promise((r) => setTimeout(r, 50));
},
retryFailed: () => this.engine.retryFailed(key),
dispose: () => {
if (disposed) return;
disposed = true;
this.engine.removeState(key);
}
};
}
/** 是否有片段在请求/注册中(所有字体) */
hasPending() {
return this.engine.hasPending();
}
/** 等待全部在途片段就绪(截图/导出前调用) */
async ready() {
while (this.hasPending()) await new Promise((r) => setTimeout(r, 50));
}
};
/** 默认实例(与 webfont-sdk 的 WebFont / WebFontCanvas 命名约定一致) */
const UniWebFont = new UniWebFontMode();
//#endregion
export { UniWebFont, UniWebFontMode };

View File

@ -0,0 +1,86 @@
{
"id": "gs-webfont",
"version": "0.1.0",
"name": "字体按需加载(中文字体子集化)",
"description": "任意中文字体按实际字符动态裁剪加载10 字 ≈ 10KB。突破小程序 2MB 主包限制,无需构建期裁字、无需整包下载。支持微信/支付宝/百度/抖音/QQ 小程序、H5、App。",
"keywords": [
"字体",
"webfont",
"子集化",
"loadFontFace",
"中文字体",
"小程序字体"
],
"dcloudext": {
"type": "sdk",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件运行时会向子集化服务webfont.shenzilong.cn请求字体片段仅传输待渲染字符与字体名不含用户隐私数据。支持私有部署后零外部请求。",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/uni-webfont"
},
"uni_modules": {
"dependencies": [],
"uni-ext-api": {},
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "n",
"app-uvue": "y"
},
"小程序": {
"mp-weixin": "y",
"mp-alipay": "y",
"mp-baidu": "y",
"mp-toutiao": "y",
"mp-lark": "n",
"mp-qq": "y",
"mp-kuaishou": "n",
"mp-jd": "n",
"mp-360": "n"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "n",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序/云厂商": {},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}

View File

@ -0,0 +1,97 @@
# gs-webfont —— uni-app 字体按需加载(中文字体子集化)
> 任意中文字体,按页面**实际用到的字符**动态裁剪加载:**10 个字 ≈ 10KB**。
> 突破小程序 2MB 主包限制,无需构建期裁字、无需整包下载 10MB+ 字体、文字改了零成本生效。
## 为什么需要它
中文字体动辄 5-20MB而微信小程序主包限 2MB、整包下载超时白屏。官方文档的建议是
["抽离出部分中文,减少体积"](https://uniapp.dcloud.net.cn/api/ui/font)——每改一次文案就得重新裁一次字体,文案一多就漏字。
**gs-webfont 把裁字搬到运行时**:把页面文字提交给子集化服务,服务端秒级裁出只含这些字的字体片段,`uni.loadFontFace` 注册生效。文案随便改,用多少加载多少。
| 方案 | 体积 | 文案可变 | 跨端 |
|---|---|---|---|
| 整包 ttf | 5-20MB | ✅ | ❌ 超主包限制 |
| 构建期裁字fontmin 等) | 小 | ❌ 改文案需重裁 | ✅ |
| **gs-webfont 运行时子集** | **按字符数** | ✅ 随便改 | ✅ |
## 快速开始
```ts
// 页面或 App.vue
import { UniWebFont } from '@/uni_modules/gs-webfont/js_sdk/index.js'
const loader = UniWebFont.loadFont({ fontName: '令东齐伋复刻体.ttf' })
loader.update('静心茶舍 今日特饮')
// 样式里直接用family = 字体名去扩展名)
// <view style="font-family: 令东齐伋复刻体">静心茶舍</view>
```
首次 `update` 后字体异步生效,旧字形(系统字体)保持显示直到新字体就绪,**无闪烁**。
### 等待就绪(截图/导出场景)
```ts
loader.update('要渲染的文字')
await loader.ready()
// 此时字体必然已生效,再截图/生成 canvas
```
### 追加文本(打字机/动态内容)
```ts
loader.update('第一段文字')
loader.update('第二段文字') // 引擎自动去重,只请求新出现的字
```
## API
### `UniWebFont.loadFont(options): IUniFontLoader`
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `fontName` | `string` | — | 字体文件名(服务端[字体列表](https://webfont.shenzilong.cn)里可选,支持模糊匹配) |
| `baseUrl` | `string` | 官方服务 | 私有部署地址 |
| `family` | `string` | 去扩展名字体名 | CSS font-family 用的名字 |
| `outType` | `'ttf' \| 'woff2'` | `'ttf'` | 小程序建议 ttfiOS 低版本 woff2 兼容差) |
| `global` | `boolean` | `true` | 是否全局生效(微信 2.10.0+,在 App.vue 调用则全 app 生效) |
| `maxCharsPerChunk` | `number` | `300` | 单次请求最大字符数,超出自动分批 |
| `desc` | `object` | — | 字体描述符style/weight/variant |
| `debug` | `boolean` | `false` | 控制台输出加载日志 |
返回 loader`update(text)` / `isPending()` / `ready()` / `retryFailed()` / `dispose()`
### `UniWebFont.ready()` / `hasPending()`
全部字体的就绪等待(多字体页面导出前用)。
## 平台兼容
| 平台 | 支持 | 说明 |
|---|---|---|
| 微信/支付宝/百度/抖音/QQ 小程序 | ✅ | 需把 `webfont.shenzilong.cn` 加入小程序后台 downloadFile 合法域名(**https** |
| H5 | ✅ | 无需任何配置 |
| App (vue/uvue) | ✅ | |
| app-nvue | ❌ | 平台不支持 loadFontFace用 Weex DOM.addRule 自行处理 |
## 私有部署
插件默认使用官方免费服务 [webfont.shenzilong.cn](https://webfont.shenzilong.cn)Docker 一键部署见 [web-font](https://github.com/2234839/web-font)),商用或内网场景传 `baseUrl` 指向自建服务即可,插件零改动。
## 常见问题
**Q: 字体加载失败?**
微信小程序需在 mp.weixin.qq.com 后台「开发管理 → 开发设置 → 服务器域名」把 `https://webfont.shenzilong.cn` 加入 **downloadFile 合法域名**loadFontFace 内部走下载通道,不是 request 域名)。
**Q: 为什么默认 ttf 不是 woff2**
低版本 iOS 的 WebView 对 woff2 支持不全([官方文档](https://uniapp.dcloud.net.cn/api/ui/font)ttf 全端稳妥。纯 H5 场景可传 `outType: 'woff2'` 省约 30% 流量。
**Q: 请求会带什么数据?**
仅「字体名 + 待渲染字符」,无用户信息。服务端按文本缓存,同文案只裁一次。
## 来源
- 源码:[packages/uni-webfont](https://github.com/2234839/web-font/tree/new/packages/uni-webfont)MIT
- 服务端:[web-font](https://github.com/2234839/web-font) · 在线体验:[webfont.shenzilong.cn](https://webfont.shenzilong.cn)