Files
lucky_shop/common/js/theme-override.js
ZF sun 82fd3839a9 feat: 新增remote-only主题模式,优化首屏包体积
1. 新增theme-override.js实现主题远端拉取逻辑
2. 新增empty-style-stub.js作为主题包占位符,减少主包~75KB
3. 在vue.config.js中添加构建期主题stub替换逻辑
4. 重构config-external.js的主题配置,新增remote-only模式支持
5. 重构store的applyThemeWithRemote方法,适配远端主题拉取
6. 在bootstrap中新增主题初始化和异步拉取逻辑
2026-07-07 14:56:13 +08:00

477 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 主题 token 远端覆写运行时
*
* 设计原则(与 lang.js 完全同构):
* 1) 后端只存"对要个性化的 token"做覆写 KV,前端拿本地 style_color.js + 远端覆写 合并
* 2) 三态机: unknown / ok / fail
* 3) 多租户 × 多端平台 × 多模式(brand/light/dark)三维隔离缓存
* 4) 失败归一: timeout / network / http / parse / exception
* 5) 失败时使用本地 style_color.js 兜底
* 6) 不修改 Vuex.themeStyle / themeColorSet / 页面 wrapper,只把"合并后的 token map"
* 提供给 store/index.js 的 applyThemeWithRemote action 使用
*
* 暴露的 API:
* ThemeOverride.boot() 首屏阻塞拉取 + 缓存预热(失败静默)
* ThemeOverride.refresh(force) 重新拉取 + 重新应用
* ThemeOverride.getByTheme(theme) 取单个模式桶(brand/light/dark)的合并 KV
* ThemeOverride.getAll() { brand, light, dark }
* ThemeOverride.onUniacidChange() 切租户后清旧缓存
* ThemeOverride.getRemoteStatus() 'unknown' | 'ok' | 'fail'
* ThemeOverride.onRemoteStatusChange(fn) 订阅状态变化
* ThemeOverride.__debug_cache 内部缓存(测试用)
*
* 关键: 一次 boot 只发一次 HTTP 请求,后端 getAllByTenant 一次出 brand/light/dark 三桶
* 避免对每个 theme 单独请求造成的 3 倍带宽浪费
*/
import { themeConfig } from './config-external.js';
import Config from './config.js';
import http from './http.js';
import { Platform } from './platform.js';
const THEMES = ['brand', 'light', 'dark'];
// 远端覆写:内存缓存
// shape: { [theme]: { version, map, ts, global_version } }
const overrideCache = {};
// 远端全量 preset 内存缓存(remote-only 模式用)
// shape: { [presetName]: { ...tokenMap } }
const presetsCache = {};
// 远端覆写:并发去重(以"全量拉取"为粒度)
let allInflight = null;
// 远端全量 preset 拉取:并发去重
let presetsInflight = null;
// 远端可用性三态机
let remoteStatus = 'unknown';
let remoteStatusAt = 0;
const remoteStatusListeners = [];
// 配置项缓存
const REMOTE = (themeConfig && themeConfig.overrideRemote) || {};
/**
* 校验 source 配置:只接受 local / remote / hybrid,其它一律视为 remote(安全默认)
*/
function normalizeSource(s) {
return ['local', 'remote', 'hybrid'].includes(s) ? s : 'remote';
}
const SOURCE = normalizeSource(REMOTE.source || 'remote');
/**
* 远端失败原因归一
*/
function normalizeReason(s) {
const allow = ['timeout', 'network', 'http', 'parse', 'exception', 'unknown'];
return allow.includes(s) ? s : 'unknown';
}
/**
* 远端成功/失败状态机
*/
function setRemoteStatus(s, detail) {
if (remoteStatus === s) return;
const prev = remoteStatus;
remoteStatus = s;
remoteStatusAt = Date.now();
const safeDetail = {
reason: normalizeReason(detail && detail.reason),
theme: detail && detail.theme,
};
remoteStatusListeners.forEach(fn => {
try { fn(s, safeDetail); } catch (e) { /* 忽略监听器异常 */ }
});
try { uni.$emit && uni.$emit('theme:remote-status', { from: prev, to: s, ...safeDetail }); } catch (e) {}
if (s === 'fail') {
try { uni.$emit && uni.$emit('theme:fallback', { reason: safeDetail.reason }); } catch (e) {}
}
}
/**
* 获得当前租户 id
*/
function getCurrentUniacid() {
const u = uni.getStorageSync('uniacid');
return u ? parseInt(u, 10) || Config.uniacid : Config.uniacid;
}
/**
* 当前平台类型
*/
function getCurrentAppType() {
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
}
/**
* 持久化 key: <prefix><uniacid>_<appType>_<theme>
*/
function getOverrideStorageKey(theme) {
const u = getCurrentUniacid();
const a = getCurrentAppType();
return `${REMOTE.storagePrefix || 'THEME_OVR_'}${u}_${a}_${theme}`;
}
/**
* 内存 cache key: <uniacid>_<appType>_<theme>
*/
function getOverrideMemKey(theme) {
return `${getCurrentUniacid()}_${getCurrentAppType()}_${theme}`;
}
/**
* 从 storage 预热单个 theme 桶到内存(首屏兜底)
* 仅在内存还没有该 key 时才读 storage,避免覆盖更新值
*/
function preheatFromStorage(theme) {
const mk = getOverrideMemKey(theme);
if (overrideCache[mk]) return;
try {
const cached = uni.getStorageSync(getOverrideStorageKey(theme));
if (cached && cached.map) {
overrideCache[mk] = {
version: cached.version || 0,
map: cached.map || {},
ts: cached.ts || 0,
global_version: cached.global_version || 0,
};
return;
}
} catch (e) { /* 忽略 */ }
overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 };
}
/**
* 把"单次 HTTP 响应"按 by_theme 拆到 3 个内存桶 + 持久化
*/
function applyRemoteResponse(d) {
const byTheme = (d && d.by_theme && typeof d.by_theme === 'object') ? d.by_theme : {};
const gv = (d && d.global_version) || 0;
const ts = Date.now();
THEMES.forEach(theme => {
const remoteMap = (byTheme[theme] && typeof byTheme[theme] === 'object') ? byTheme[theme] : {};
const mk = getOverrideMemKey(theme);
const baseCache = overrideCache[mk] || { version: 0, map: {}, ts: 0, global_version: 0 };
const merged = Object.assign({}, baseCache.map, remoteMap);
overrideCache[mk] = {
version: gv,
map: merged,
ts,
global_version: gv,
};
try { uni.setStorageSync(getOverrideStorageKey(theme), overrideCache[mk]); } catch (e) {}
});
}
/**
* 取"3 个桶里最大的 global_version"(穿透缓存用)
*/
function maxCachedGlobalVersion() {
let gv = 0;
THEMES.forEach(t => {
const mk = getOverrideMemKey(t);
const c = overrideCache[mk];
if (c && c.global_version > gv) gv = c.global_version;
});
return gv;
}
/**
* 一次 HTTP 拉齐 3 个 theme 桶
* 这是 boot() / refresh() / onUniacidChange() 共用的"拉取入口"
*
* 关键: 1 次 HTTP, 拆 3 桶,避免对每个 theme 单独请求造成 3 倍浪费
*
* @returns {Promise<{ source:'cache'|'remote'|'local', ok:boolean, reason?:string, themes:string[] }>}
*/
async function loadAllOverrides() {
if (SOURCE === 'local') return { source: 'local', ok: true, themes: THEMES };
// 并发去重:同一刻只允许一个在飞
if (allInflight) return allInflight;
allInflight = (async () => {
// 1) 先预热 storage 兜底
THEMES.forEach(preheatFromStorage);
// 2) 一次 HTTP 拉齐 3 桶
try {
const res = await Promise.race([
http.sendRequest({
url: REMOTE.overridesUrl || '/api/theme/overrides',
data: {
global_version: maxCachedGlobalVersion(),
},
async: false,
}),
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)),
]);
if (!res || res.code !== 0 || !res.data) {
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
setRemoteStatus('fail', { reason, theme: 'all' });
return { source: 'local', ok: false, reason, themes: THEMES };
}
applyRemoteResponse(res.data);
setRemoteStatus('ok', { theme: 'all' });
emitUpdated('all');
return { source: 'remote', ok: true, themes: THEMES };
} catch (e) {
setRemoteStatus('fail', { reason: 'exception', error: String(e && e.message || e), theme: 'all' });
return { source: 'local', ok: false, reason: 'exception', themes: THEMES };
}
})();
try {
return await allInflight;
} finally {
allInflight = null;
}
}
/**
* 触发"主题已更新"事件
* 注意:Vuex 的 themeColorSet action 已经在 setThemeStyle 后跑过,
* CSS 变量已挂到页面 wrapper 上,组件会自动刷新。
* 这里只发通知,方便业务代码(如自定义 JS 直读 themeStyle 的页面)主动 re-render。
*/
function emitUpdated(theme) {
try { uni.$emit && uni.$emit('theme:updated', { theme }); } catch (e) {}
}
// 远端全量 preset 持久化 key: <storagePresetsPrefix><uniacid>_<appType>
function getPresetsStorageKey() {
const u = getCurrentUniacid();
const a = getCurrentAppType();
return `${REMOTE.storagePresetsPrefix || 'THEME_PRESETS_'}${u}_${a}`;
}
// 远端全量 preset 内存 cache key
function getPresetsMemKey() {
return `${getCurrentUniacid()}_${getCurrentAppType()}`;
}
// 从 storage 预热全量 preset 到内存(首屏兜底)
function preheatPresetsFromStorage() {
const mk = getPresetsMemKey();
if (presetsCache[mk] && Object.keys(presetsCache[mk]).length) return;
try {
const cached = uni.getStorageSync(getPresetsStorageKey());
if (cached && typeof cached === 'object') {
presetsCache[mk] = cached;
return;
}
} catch (e) { /* 忽略 */ }
}
/**
* 一次 HTTP 拉齐 8 个 preset(remote-only 模式)
* 关键: 不阻塞首屏,失败静默. store/index.js 的 applyThemeWithRemote 会优先用
* 内存缓存(由 loadAllPresets 写入), 缓存未命中时回退到 webpack stub(只含 default).
*
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
*/
async function loadAllPresets() {
if (SOURCE === 'local') return { source: 'skipped', ok: true, presets: {} };
if (!REMOTE.presetsUrl) return { source: 'skipped', ok: false, reason: 'no-url' };
// 并发去重
if (presetsInflight) return presetsInflight;
presetsInflight = (async () => {
// 1) 先预热 storage 兜底
preheatPresetsFromStorage();
// 2) 一次 HTTP 拉齐 8 个 preset
try {
const res = await Promise.race([
http.sendRequest({
url: REMOTE.presetsUrl,
data: {},
async: false,
}),
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)),
]);
if (!res || res.code !== 0 || !res.data) {
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
console.warn('[theme-presets] remote load fail, keep stub:', reason);
return { source: 'local', ok: false, reason };
}
// 3) 解析响应. 期望格式: { by_preset: { default: {...}, green: {...}, ... } }
const byPreset = (res.data.by_preset && typeof res.data.by_preset === 'object') ? res.data.by_preset : null;
if (!byPreset) {
console.warn('[theme-presets] response missing by_preset');
return { source: 'local', ok: false, reason: 'parse' };
}
// 4) 写入内存缓存 + 持久化
const mk = getPresetsMemKey();
presetsCache[mk] = byPreset;
try { uni.setStorageSync(getPresetsStorageKey(), byPreset); } catch (e) {}
// 5) 通知已更新
try { uni.$emit && uni.$emit('theme:presets-updated'); } catch (e) {}
return { source: 'remote', ok: true, presets: byPreset };
} catch (e) {
console.warn('[theme-presets] remote load exception:', e && e.message);
return { source: 'local', ok: false, reason: 'exception' };
}
})();
try {
return await presetsInflight;
} finally {
presetsInflight = null;
}
}
// 导出主体
export default {
SOURCE,
/**
* 阻塞式预热:App.onLaunch 在 Lang.boot 后调用
* 失败静默,不阻塞首屏
*
* @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:string, reason?:string, themes:string[]}>}
*/
async boot() {
if (SOURCE === 'local') {
return { ok: true, source: 'local', status: remoteStatus, themes: THEMES };
}
const bootTimeout = REMOTE.bootTimeout || 2000;
const t = new Promise((r) => setTimeout(() => r({ __timeout: true }), bootTimeout));
// 切租户检测
const currentUid = getCurrentUniacid();
try {
const lastUid = uni.getStorageSync('theme_last_uniacid');
if (lastUid && String(lastUid) !== String(currentUid)) {
this.onUniacidChange();
}
uni.setStorageSync('theme_last_uniacid', currentUid);
} catch (e) {}
const result = await Promise.race([loadAllOverrides(), t]);
if (result && result.__timeout) {
return { ok: false, source: 'timeout', status: 'fail', reason: 'timeout', themes: THEMES };
}
return {
ok: remoteStatus === 'ok',
source: remoteStatus === 'ok' ? 'remote' : 'local',
status: remoteStatus,
themes: THEMES,
};
},
/**
* 主动重新拉取(管理后台改了之后 EventBus 通知触发)
* @param {boolean} [force=false] true = 绕过缓存强刷
*/
async refresh(force = false) {
if (force) {
// 强刷:清掉内存 + 持久化缓存,逼 loadAllOverrides 走远端
THEMES.forEach(t => {
const mk = getOverrideMemKey(t);
if (overrideCache[mk]) overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 };
try { uni.removeStorageSync(getOverrideStorageKey(t)); } catch (e) {}
});
}
return loadAllOverrides();
},
/**
* 切租户后清旧缓存
*/
onUniacidChange() {
if (SOURCE === 'local') return;
if (REMOTE.refreshOnUniacidChange === false) return;
THEMES.forEach(t => {
const sk = getOverrideStorageKey(t);
try { uni.removeStorageSync(sk); } catch (e) {}
delete overrideCache[getOverrideMemKey(t)];
});
// 同步清空全量 preset 缓存(remote-only 模式)
const psk = getPresetsStorageKey();
try { uni.removeStorageSync(psk); } catch (e) {}
delete presetsCache[getPresetsMemKey()];
loadAllOverrides();
},
/**
* 取某个 theme 桶(同步:有缓存返缓存;无缓存时该桶返回 {})
* store/index.js 的 applyThemeWithRemote action 直接用
* @param {'brand'|'light'|'dark'} theme
* @returns {object} 扁平 KV map (没有时返回 {})
*/
getByTheme(theme) {
const mk = getOverrideMemKey(theme);
const cached = overrideCache[mk];
return (cached && cached.map) ? cached.map : {};
},
/**
* 取所有 theme 桶
* @returns {{ brand: object, light: object, dark: object }}
*/
getAll() {
return {
brand: this.getByTheme('brand'),
light: this.getByTheme('light'),
dark: this.getByTheme('dark'),
};
},
/**
* 取全量 preset 内存缓存(remote-only 模式)
* store/index.js 的 applyThemeWithRemote 通过这个取到远端拉的全量 preset
* @returns {{ [presetName: string]: object }}
*/
getAllPresets() {
const mk = getPresetsMemKey();
return presetsCache[mk] || {};
},
/**
* 主动拉全量 preset(remote-only 模式)
* - 失败静默, 不抛错
* - 内存 + 持久化双写
* - 拉成功后发 uni.$emit('theme:presets-updated')
*
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
*/
loadAllPresets,
/**
* 当前远端状态
*/
getRemoteStatus() { return remoteStatus; },
/**
* 订阅远端状态变化
* @param {(status:string, detail:object) => void} fn
* @returns {() => void} unsubscribe
*/
onRemoteStatusChange(fn) {
if (typeof fn !== 'function') return () => {};
remoteStatusListeners.push(fn);
try { fn(remoteStatus, { at: remoteStatusAt, source: SOURCE }); } catch (e) {}
return () => {
const i = remoteStatusListeners.indexOf(fn);
if (i >= 0) remoteStatusListeners.splice(i, 1);
};
},
// 内部测试/调试用
__debug_overrideCache: overrideCache,
__debug_loadAllOverrides: loadAllOverrides,
}