diff --git a/packages/uni-webfont/dist-bundle/index.iife.js b/packages/uni-webfont/dist-bundle/index.iife.js new file mode 100644 index 0000000..452751b --- /dev/null +++ b/packages/uni-webfont/dist-bundle/index.iife.js @@ -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; +})({}); diff --git a/packages/uni-webfont/dist-bundle/index.js b/packages/uni-webfont/dist-bundle/index.js new file mode 100644 index 0000000..f1985fe --- /dev/null +++ b/packages/uni-webfont/dist-bundle/index.js @@ -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 }; diff --git a/packages/uni-webfont/package.json b/packages/uni-webfont/package.json new file mode 100644 index 0000000..8d22c81 --- /dev/null +++ b/packages/uni-webfont/package.json @@ -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" + } +} diff --git a/packages/uni-webfont/scripts/sync-uni-modules.mjs b/packages/uni-webfont/scripts/sync-uni-modules.mjs new file mode 100644 index 0000000..7d5dffa --- /dev/null +++ b/packages/uni-webfont/scripts/sync-uni-modules.mjs @@ -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/`) diff --git a/packages/uni-webfont/src/index.ts b/packages/uni-webfont/src/index.ts new file mode 100644 index 0000000..818da34 --- /dev/null +++ b/packages/uni-webfont/src/index.ts @@ -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 + /** 清除失败记录,配合 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( + 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((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 => { + 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 { + while (this.hasPending()) { + await new Promise((r) => setTimeout(r, 50)) + } + } +} + +/** 默认实例(与 webfont-sdk 的 WebFont / WebFontCanvas 命名约定一致) */ +export const UniWebFont = new UniWebFontMode() diff --git a/packages/uni-webfont/tsconfig.json b/packages/uni-webfont/tsconfig.json new file mode 100644 index 0000000..8cbd26f --- /dev/null +++ b/packages/uni-webfont/tsconfig.json @@ -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"] +} diff --git a/packages/uni-webfont/tsdown.config.ts b/packages/uni-webfont/tsdown.config.ts new file mode 100644 index 0000000..b7aaa0f --- /dev/null +++ b/packages/uni-webfont/tsdown.config.ts @@ -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: [/./], + }), +] diff --git a/packages/webfont-sdk/package.json b/packages/webfont-sdk/package.json index 110f6df..9dde601 100644 --- a/packages/webfont-sdk/package.json +++ b/packages/webfont-sdk/package.json @@ -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": [ diff --git a/packages/webfont-sdk/src/engine.ts b/packages/webfont-sdk/src/engine.ts index d4fdf0e..f6c4fd6 100644 --- a/packages/webfont-sdk/src/engine.ts +++ b/packages/webfont-sdk/src/engine.ts @@ -33,6 +33,11 @@ export interface IFontState { * 返回 Promise 时引擎会等它完成才释放并发槽(保证 ready() 语义)。 */ onLoadChunk: ((chunk: LoadedChunk) => void | Promise) | null + /** + * 该字体的专属 provider(优先于引擎级配置)。 + * uni 小程序模式用:无 unicode-range,需按累积全集而非增量构造 URL + */ + provider?: SubsetProvider | null } /** 一次成功加载的增量片段 */ @@ -63,6 +68,8 @@ export interface IEnsureStateOptions { outType: string /** 注册回调(可后补) */ onLoadChunk?: (chunk: LoadedChunk) => void | Promise + /** 该字体的专属 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> = [] /** 在途任务数(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 { - 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 { + this.active++ + fn().finally(() => { + this.active-- + const next = this.queue.shift() + if (next) this.execute(next) + }) } setMaxConcurrent(n: number): void { diff --git a/packages/webfont-sdk/tsdown.config.ts b/packages/webfont-sdk/tsdown.config.ts index c4a98b1..41c05b0 100644 --- a/packages/webfont-sdk/tsdown.config.ts +++ b/packages/webfont-sdk/tsdown.config.ts @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 240f4c6..150b9b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/public/webfont-sdk.js b/public/webfont-sdk.js index 7ed91c4..0bcaf2d 100644 --- a/public/webfont-sdk.js +++ b/public/webfont-sdk.js @@ -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); diff --git a/uni_modules/gs-webfont/changelog.md b/uni_modules/gs-webfont/changelog.md new file mode 100644 index 0000000..244b23a --- /dev/null +++ b/uni_modules/gs-webfont/changelog.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.1.0(2026-08-16) + +- 首个版本:字符累积 + uni.loadFontFace 同 family 重载策略,按需加载中文字体子集 +- 复用 webfont-sdk IncrementalEngine(字符去重 / 失败记忆 / 串行保序) +- 平台:微信/支付宝/百度/抖音/QQ 小程序、H5、App(vue/uvue) diff --git a/uni_modules/gs-webfont/js_sdk/index.d.ts b/uni_modules/gs-webfont/js_sdk/index.d.ts new file mode 100644 index 0000000..c89b967 --- /dev/null +++ b/uni_modules/gs-webfont/js_sdk/index.d.ts @@ -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; + /** 清除失败记录,配合 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; +} +/** 默认实例(与 webfont-sdk 的 WebFont / WebFontCanvas 命名约定一致) */ +declare const UniWebFont: UniWebFontMode; +//#endregion +export { IUniFontLoader, IUniFontOptions, UniWebFont, UniWebFontMode }; \ No newline at end of file diff --git a/uni_modules/gs-webfont/js_sdk/index.iife.js b/uni_modules/gs-webfont/js_sdk/index.iife.js new file mode 100644 index 0000000..452751b --- /dev/null +++ b/uni_modules/gs-webfont/js_sdk/index.iife.js @@ -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; +})({}); diff --git a/uni_modules/gs-webfont/js_sdk/index.js b/uni_modules/gs-webfont/js_sdk/index.js new file mode 100644 index 0000000..bca3f97 --- /dev/null +++ b/uni_modules/gs-webfont/js_sdk/index.js @@ -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 }; diff --git a/uni_modules/gs-webfont/package.json b/uni_modules/gs-webfont/package.json new file mode 100644 index 0000000..5a90a16 --- /dev/null +++ b/uni_modules/gs-webfont/package.json @@ -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" + } + } + } + } +} diff --git a/uni_modules/gs-webfont/readme.md b/uni_modules/gs-webfont/readme.md new file mode 100644 index 0000000..e258077 --- /dev/null +++ b/uni_modules/gs-webfont/readme.md @@ -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 = 字体名去扩展名) +// 静心茶舍 +``` + +首次 `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)