mirror of
https://github.com/2234839/web-font.git
synced 2026-09-04 14:53:32 +08:00
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:
parent
f9160950ea
commit
140f84d3c7
290
packages/uni-webfont/dist-bundle/index.iife.js
Normal file
290
packages/uni-webfont/dist-bundle/index.iife.js
Normal 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
|
||||
};
|
||||
}
|
||||
/** fontKey:fontName + 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);
|
||||
}
|
||||
/**
|
||||
* 执行一个任务,完成后从队列取下一个。
|
||||
* 注意:这里必须直接调用 next(fn),不能递归调用外层 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 重载同 family(source 直传 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;
|
||||
})({});
|
||||
287
packages/uni-webfont/dist-bundle/index.js
Normal file
287
packages/uni-webfont/dist-bundle/index.js
Normal 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
|
||||
};
|
||||
}
|
||||
/** fontKey:fontName + 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);
|
||||
}
|
||||
/**
|
||||
* 执行一个任务,完成后从队列取下一个。
|
||||
* 注意:这里必须直接调用 next(fn),不能递归调用外层 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 重载同 family(source 直传 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 };
|
||||
47
packages/uni-webfont/package.json
Normal file
47
packages/uni-webfont/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
packages/uni-webfont/scripts/sync-uni-modules.mjs
Normal file
32
packages/uni-webfont/scripts/sync-uni-modules.mjs
Normal 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.ts:IDE 智能提示 */
|
||||
copyFileSync(dts, resolve(targetDir, 'index.d.ts'))
|
||||
|
||||
console.log(`✓ synced → uni_modules/gs-webfont/js_sdk/`)
|
||||
215
packages/uni-webfont/src/index.ts
Normal file
215
packages/uni-webfont/src/index.ts
Normal 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'。
|
||||
* 小程序建议 ttf(iOS 低版本对 woff2 兼容性差);纯 H5 场景可传 'woff2' 省流量
|
||||
*/
|
||||
outType?: 'ttf' | 'woff2'
|
||||
/** 是否全局生效(微信 2.10.0+,需在 App.vue 调用才对全 app 生效),默认 true */
|
||||
global?: boolean
|
||||
/** 单次请求携带的最大字符数,超出自动分批串行加载,默认 300(URL 长度安全值) */
|
||||
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 重载同 family(source 直传 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()
|
||||
17
packages/uni-webfont/tsconfig.json
Normal file
17
packages/uni-webfont/tsconfig.json
Normal 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"]
|
||||
}
|
||||
42
packages/uni-webfont/tsdown.config.ts
Normal file
42
packages/uni-webfont/tsdown.config.ts
Normal 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 bundle(webfont-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)。
|
||||
* 不用 true:tsdown 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: [/./],
|
||||
}),
|
||||
]
|
||||
@ -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": [
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个任务,完成后从队列取下一个。
|
||||
* 注意:这里必须直接调用 next(fn),不能递归调用外层 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 {
|
||||
|
||||
@ -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
13
pnpm-lock.yaml
generated
@ -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':
|
||||
|
||||
@ -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);
|
||||
}
|
||||
/**
|
||||
* 执行一个任务,完成后从队列取下一个。
|
||||
* 注意:这里必须直接调用 next(fn),不能递归调用外层 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);
|
||||
|
||||
7
uni_modules/gs-webfont/changelog.md
Normal file
7
uni_modules/gs-webfont/changelog.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0(2026-08-16)
|
||||
|
||||
- 首个版本:字符累积 + uni.loadFontFace 同 family 重载策略,按需加载中文字体子集
|
||||
- 复用 webfont-sdk IncrementalEngine(字符去重 / 失败记忆 / 串行保序)
|
||||
- 平台:微信/支付宝/百度/抖音/QQ 小程序、H5、App(vue/uvue)
|
||||
62
uni_modules/gs-webfont/js_sdk/index.d.ts
vendored
Normal file
62
uni_modules/gs-webfont/js_sdk/index.d.ts
vendored
Normal 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'。
|
||||
* 小程序建议 ttf(iOS 低版本对 woff2 兼容性差);纯 H5 场景可传 'woff2' 省流量
|
||||
*/
|
||||
outType?: 'ttf' | 'woff2';
|
||||
/** 是否全局生效(微信 2.10.0+,需在 App.vue 调用才对全 app 生效),默认 true */
|
||||
global?: boolean;
|
||||
/** 单次请求携带的最大字符数,超出自动分批串行加载,默认 300(URL 长度安全值) */
|
||||
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 };
|
||||
290
uni_modules/gs-webfont/js_sdk/index.iife.js
Normal file
290
uni_modules/gs-webfont/js_sdk/index.iife.js
Normal 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
|
||||
};
|
||||
}
|
||||
/** fontKey:fontName + 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);
|
||||
}
|
||||
/**
|
||||
* 执行一个任务,完成后从队列取下一个。
|
||||
* 注意:这里必须直接调用 next(fn),不能递归调用外层 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 重载同 family(source 直传 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;
|
||||
})({});
|
||||
291
uni_modules/gs-webfont/js_sdk/index.js
Normal file
291
uni_modules/gs-webfont/js_sdk/index.js
Normal 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
|
||||
};
|
||||
}
|
||||
/** fontKey:fontName + 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);
|
||||
}
|
||||
/**
|
||||
* 执行一个任务,完成后从队列取下一个。
|
||||
* 注意:这里必须直接调用 next(fn),不能递归调用外层 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 重载同 family(source 直传 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 };
|
||||
86
uni_modules/gs-webfont/package.json
Normal file
86
uni_modules/gs-webfont/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
uni_modules/gs-webfont/readme.md
Normal file
97
uni_modules/gs-webfont/readme.md
Normal 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'` | 小程序建议 ttf(iOS 低版本 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)
|
||||
Loading…
x
Reference in New Issue
Block a user