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中新增主题初始化和异步拉取逻辑
This commit is contained in:
@@ -46,21 +46,40 @@ export const langConfig = {
|
||||
|
||||
// 主题配置
|
||||
export const themeConfig = {
|
||||
// 主题列表 — 与 style_color.js 顶层的 preset 名一致
|
||||
themeList: ['default', 'red', 'green', 'blue', 'pink', 'gold', 'purple', 'yellow', 'black'],
|
||||
// 默认主题
|
||||
defaultTheme: 'default',
|
||||
// 远端 token 覆写相关(与 langConfig 同构)
|
||||
// source : 'local' | 'remote' | 'hybrid'
|
||||
// remote.* : 首屏拉取 + 缓存 + 三态机 + 多租户多端隔离
|
||||
overrideRemote: {
|
||||
overridesUrl: '/api/theme/overrides',
|
||||
bootTimeout: 2000,
|
||||
requestTimeout: 4000,
|
||||
storagePrefix: 'THEME_OVR_',
|
||||
refreshOnUniacidChange: true,
|
||||
source: 'remote',
|
||||
}
|
||||
// 主题列表 — 与 style_color.js 顶层的 preset 名严格一致(8 个)
|
||||
// 关键: 这里列出的名字必须能在 style_color.js (或后端 style_color.php 镜像) 中找到对应 preset
|
||||
// 历史上曾包含 'red' 但 style_color.js 从未定义 'red' preset, 切到 'red' 主题时
|
||||
// loadTheme() 会返回 undefined → 走 stub default 兜底样式, 远端也无法补齐
|
||||
// 2026-07-07 移除 'red' 以保持一致性
|
||||
themeList: ['default', 'green', 'blue', 'pink', 'gold', 'purple', 'yellow', 'black'],
|
||||
// 默认主题
|
||||
defaultTheme: 'default',
|
||||
// 远端 token 覆写相关(与 langConfig 同构)
|
||||
// source : 'local' | 'remote' | 'hybrid' | 'remote-only'
|
||||
// remote.* : 首屏拉取 + 缓存 + 三态机 + 多租户多端隔离
|
||||
overrideRemote: {
|
||||
overridesUrl: '/api/theme/overrides',
|
||||
// 全量 preset 拉取(remote-only 模式下使用)
|
||||
// 返回 8 个 preset 完整 token,替代本地 style_color.js
|
||||
presetsUrl: '/api/theme/presets',
|
||||
bootTimeout: 5000,
|
||||
requestTimeout: 60000,
|
||||
// 远端全量 preset 缓存 key 前缀
|
||||
storagePresetsPrefix: 'THEME_PRESETS_',
|
||||
storagePrefix: 'THEME_OVR_',
|
||||
refreshOnUniacidChange: true,
|
||||
// source: 'remote-only' 时,webpack 会用 NormalModuleReplacementPlugin
|
||||
// 把 style_color.js 替换为 empty-style-stub.js(只含 emergency fallback)
|
||||
// 节省主包 ~75KB(完整 8 preset × ~50 token = 80KB → stub 7KB)
|
||||
//
|
||||
// 与 lang/ 目录的 remote-only 模式完全同构:
|
||||
// - 构建期: webpack 替换为 stub
|
||||
// - 运行时: ThemeOverride.loadAllPresets() 异步拉 /api/theme/presets
|
||||
// - 首屏: 走 stub 的 default preset,不白屏
|
||||
// - 切换: 切到非 default preset 时,等远端拉完再应用(由 applyThemeWithRemote
|
||||
// 订阅 theme:presets-updated 事件驱动)
|
||||
source: 'remote-only',
|
||||
}
|
||||
};
|
||||
|
||||
// API配置
|
||||
@@ -144,23 +163,40 @@ class ConfigExternal {
|
||||
|
||||
/**
|
||||
* 加载主题配置(异步方式)
|
||||
* 关键: 在 remote-only 模式下, style_color.js 已被 webpack 替换为 empty-style-stub.js,
|
||||
* 只含 1 个 default preset. 其它 7 个 preset 由 ThemeOverride.loadAllPresets()
|
||||
* 异步拉取并合并到本地缓存. 这里的 loadTheme 优先从缓存取,缓存未命中时回退到 stub.
|
||||
*
|
||||
* @param {string} theme - 主题名称
|
||||
* @returns {Promise} - 加载结果
|
||||
*/
|
||||
async loadTheme(theme = themeConfig.defaultTheme) {
|
||||
// 1) 先查已加载的内存缓存(包括远端拉取后写入的)
|
||||
if (this.loadedConfigs[`theme_${theme}`]) {
|
||||
return this.loadedConfigs[`theme_${theme}`];
|
||||
}
|
||||
|
||||
// 2) remote-only 模式下,从 ThemeOverride 内存缓存(由 loadAllPresets 写入)取
|
||||
try {
|
||||
const ThemeOverride = require('./theme-override.js').default;
|
||||
const remotePresets = ThemeOverride.getAllPresets();
|
||||
if (remotePresets && remotePresets[theme]) {
|
||||
this.loadedConfigs[`theme_${theme}`] = remotePresets[theme];
|
||||
return remotePresets[theme];
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore — ThemeOverride 不可用时继续走 require stub
|
||||
}
|
||||
|
||||
// 3) 兜底: require style_color.js (local 模式全量, remote-only 模式 stub)
|
||||
if (this.loadPromises[`theme_${theme}`]) {
|
||||
return this.loadPromises[`theme_${theme}`];
|
||||
}
|
||||
|
||||
this.loadPromises[`theme_${theme}`] = new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 动态加载主题配置
|
||||
// 动态加载主题配置 (webpack 替换为 stub 时,只返回 1 个 default preset)
|
||||
const themeData = require(`@/common/js/style_color.js`)['default'][theme];
|
||||
// console.log('async themeData => ', themeData);
|
||||
this.loadedConfigs[`theme_${theme}`] = themeData;
|
||||
resolve(themeData);
|
||||
} catch (error) {
|
||||
|
||||
169
common/js/empty-style-stub.js
Normal file
169
common/js/empty-style-stub.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// ============================================================
|
||||
// 空主题预设占位(stub)
|
||||
//
|
||||
// 在 themeConfig.overrideRemote.source === 'remote-only' 模式下,
|
||||
// style_color.js 会被 webpack.NormalModuleReplacementPlugin 替换为这个 stub.
|
||||
//
|
||||
// 目的: 主包不再包含 8 个完整 preset (~80KB), 只保留最小 emergency fallback
|
||||
// (~5KB, 仅 1 个 default preset 的核心 token).
|
||||
//
|
||||
// 设计要点:
|
||||
// 1) 导出 default 对象, 结构与 style_color.js 一致: { 'default': { ... } }
|
||||
// 2) 包含最关键的 token(main_color, aux_color, bg_color 等),
|
||||
// 保证 themeColorSet 能写入基础 CSS 变量,首屏不白屏
|
||||
// 3) 缺失的 token 由远端 /api/theme/presets 异步补齐,补齐前 var() 解析失败
|
||||
// 时浏览器会回退到 SCSS 的 fallback 值(uni-app H5 已做 rpx→px)
|
||||
// 4) 其他 7 个 preset 完全由远端提供,本地不打包
|
||||
// 5) 失败兜底: 远端不可用时, 切到非 default preset 会落到 default 的样式
|
||||
//
|
||||
// token 数量统计: 8 preset × ~50 token = ~400 token. 完整 80KB.
|
||||
// 1 preset × ~50 token = ~5KB. 节省 ~94%.
|
||||
// ============================================================
|
||||
|
||||
export default {
|
||||
'default': {
|
||||
name: 'default',
|
||||
main_color: '#F4391c',
|
||||
aux_color: '#F7B500',
|
||||
bg_color: '#FF4646',
|
||||
bg_color_shallow: '#FF4646',
|
||||
promotion_color: '#FF4646',
|
||||
promotion_aux_color: '#F7B500',
|
||||
main_color_shallow: '#FFF4F4',
|
||||
price_color: 'rgb(252,82,39)',
|
||||
btn_text_color: '#FFFFFF',
|
||||
|
||||
goods_price: 'rgb(252,82,39,1)',
|
||||
promotion_tag: '#FF4646',
|
||||
goods_card_bg: '#201A18',
|
||||
goods_card_bg_shallow: '#7C7878',
|
||||
goods_card_color: '#FFD792',
|
||||
goods_coupon: '#FC5227',
|
||||
goods_cart_num_corner: '#FC5227',
|
||||
goods_btn_color: '#FF4646',
|
||||
goods_btn_color_shallow: '#F7B500',
|
||||
|
||||
pintuan_label_bg: '#F7B500',
|
||||
pintuan_label_color: '#FFFFFF',
|
||||
pintuan_color: '#FA6400',
|
||||
pintuan_promotion_color: '#FA3A1D',
|
||||
pintuan_promotion_aux_color: '#FD9A01',
|
||||
|
||||
super_member_start_bg: '#7c7878',
|
||||
super_member_end_bg: '#201a18',
|
||||
super_member_start_text_color: '#FFDBA6',
|
||||
super_member_end_text_color: '#FFEBCA',
|
||||
|
||||
bargain_promotion_color: '#F0353E',
|
||||
bargain_promotion_aux_color: '#FD9A01',
|
||||
|
||||
seckill_promotion_color: '#F83530',
|
||||
seckill_promotion_aux_color: '#FD9A01',
|
||||
|
||||
giftcard_promotion_color: '#FF3369',
|
||||
giftcard_promotion_aux_color: '#F7B500',
|
||||
|
||||
groupby_promotion_color: '#E64136',
|
||||
groupby_promotion_aux_color: '#F7B500',
|
||||
|
||||
hover_nav_bg_color: '#FFFC',
|
||||
hover_nav_text_color: '#000',
|
||||
|
||||
// ===== DIY 分类页字号 token =====
|
||||
diy_category_tree_select_size: '28rpx',
|
||||
diy_category_tree_normal_size: '20rpx',
|
||||
diy_category_item_title_size: '26rpx',
|
||||
diy_category_item_goods_title_size:'28rpx',
|
||||
diy_category_item_name_size: '24rpx',
|
||||
diy_category_item_goods_name_size:'28rpx',
|
||||
diy_category_item_goods_tag_size: '22rpx',
|
||||
diy_category_item_consult_now_size:'26rpx',
|
||||
diy_category_item_consult_now_bg: '#FF4646',
|
||||
diy_category_item_consult_now_text: '#FFFFFF',
|
||||
diy_category_item_consult_now_padding: '12rpx 30rpx',
|
||||
diy_category_item_consult_now_radius: '40rpx',
|
||||
diy_category_item_consult_now_weight: '500',
|
||||
diy_category_item_consult_now_line_height:'1.2',
|
||||
diy_category_item_consult_now_text_align: 'center',
|
||||
diy_category_item_consult_now_white_space:'nowrap',
|
||||
diy_category_item_consult_now_display: 'inline-block',
|
||||
|
||||
// ===== DIY 分类页 - 商品卡 token =====
|
||||
diy_category_item_goods_name_line_height:1.3,
|
||||
diy_category_item_goods_name_margin_top: '20rpx',
|
||||
diy_category_item_discount_price_weight: 'bold',
|
||||
diy_category_item_discount_price_line_height: 1,
|
||||
diy_category_item_discount_price_margin_top: '16rpx',
|
||||
diy_category_item_pro_info_margin_top: '16rpx',
|
||||
diy_category_item_delete_price_size: '24rpx',
|
||||
diy_category_item_delete_price_line_height: 1,
|
||||
diy_category_item_delete_price_text_decoration:'line-through',
|
||||
diy_category_item_member_price_tag_width: '60rpx',
|
||||
diy_category_item_member_price_tag_height: '28rpx',
|
||||
diy_category_item_member_price_tag_line_height: 1,
|
||||
diy_category_item_member_price_tag_margin_left: '6rpx',
|
||||
diy_category_item_info_wrap_padding: '0 20rpx',
|
||||
diy_category_item_info_wrap_margin_top: '12rpx',
|
||||
diy_category_item_footer_wrap_display: 'flex',
|
||||
diy_category_item_footer_wrap_justify_content: 'space-between',
|
||||
diy_category_item_footer_wrap_align_items: 'normal',
|
||||
diy_category_item_footer_wrap_margin_top: 0,
|
||||
diy_category_item_price_wrap_display: 'block',
|
||||
diy_category_item_price_wrap_align_items: 'normal',
|
||||
diy_category_item_price_wrap_flex_wrap: 'nowrap',
|
||||
diy_category_item_price_wrap_justify_content: 'normal',
|
||||
diy_category_item_price_wrap_margin_top: 0,
|
||||
diy_category_item_name_wrap_flex: '1',
|
||||
diy_category_item_consult_now_wrap_display: 'flex',
|
||||
diy_category_item_consult_now_wrap_justify_content:'flex-end',
|
||||
diy_category_item_consult_now_wrap_margin_top: 'auto',
|
||||
diy_category_item_consult_now_wrap_padding_top: '10rpx',
|
||||
diy_category_item_color_tip_color: '#909399',
|
||||
diy_category_item_price_font_weight: 500,
|
||||
|
||||
// ===== 营销 token(全 8 preset 一致) =====
|
||||
pintuan_label_grad_start: '#ffbd5b',
|
||||
pintuan_label_grad_end: '#fd882e',
|
||||
pintuan_avatar_border: '#ff3d3d',
|
||||
pintuan_success_start: '#3edb73',
|
||||
pintuan_success_end: '#1db576',
|
||||
bargain_promo_shallow: '#ffeadb',
|
||||
seckill_price_shallow: '#fcecd7',
|
||||
seckill_price_start: '#fdbe6c',
|
||||
seckill_discount_start: '#ff5f17',
|
||||
seckill_discount_end: '#fe2f18',
|
||||
member_card_gold_start: '#E3B66B',
|
||||
member_card_gold_end: '#F7DAA5',
|
||||
member_icon_grad_start: '#814635',
|
||||
member_icon_grad_end: '#3A221B',
|
||||
balance_page_bg_start: '#FE7849',
|
||||
balance_page_bg_end: '#FF1959',
|
||||
point_page_bg_start: '#FE7849',
|
||||
point_page_bg_end: '#FF1959',
|
||||
point_promo_start: '#F8F8F8',
|
||||
point_promo_end: '#E74A32',
|
||||
point_item_grad_start: '#F4402B',
|
||||
point_item_grad_end: '#FD7C40',
|
||||
invite_grad_start: '#ff2440',
|
||||
invite_grad_end: '#ff7b7b',
|
||||
promote_grad_start: '#ffe2ac',
|
||||
promote_grad_end: '#fdc174',
|
||||
fenxiao_withdraw_start: '#ff3c29',
|
||||
fenxiao_withdraw_end: '#ff6f29',
|
||||
level_1_grad_start: '#9BA7B1', level_1_grad_end: '#D3DEE6',
|
||||
level_2_grad_start: '#F57E2A', level_2_grad_end: '#FAD494',
|
||||
level_3_grad_start: '#F85151', level_3_grad_end: '#FF9999',
|
||||
level_4_grad_start: '#78B8B4', level_4_grad_end: '#AFE6E2',
|
||||
level_5_grad_start: '#4DA1E1', level_5_grad_end: '#58CBF0',
|
||||
level_6_grad_start: '#81C636', level_6_grad_end: '#D1F677',
|
||||
level_7_grad_start: '#6D7279', level_7_grad_end: '#A5AAB0',
|
||||
level_8_grad_start: '#866DDB', level_8_grad_end: '#D49BFE',
|
||||
level_9_grad_start: '#f1c74e', level_9_grad_end: '#f7dc81',
|
||||
level_10_grad_start: '#418CCF', level_10_grad_end: '#9CC6F1',
|
||||
}
|
||||
// 其他 7 个 preset (green/blue/pink/gold/purple/yellow/black)
|
||||
// 全部由远端 /api/theme/presets 异步提供
|
||||
// 远端拉取前切到非 default preset,会回退到 default 样式
|
||||
// 注: 历史曾尝试添加 'red' preset, 但前端 style_color.js 从未定义, 已在
|
||||
// config-external.js themeList 中移除以保持一致性
|
||||
};
|
||||
@@ -35,9 +35,16 @@ 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;
|
||||
@@ -236,6 +243,95 @@ 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,
|
||||
@@ -303,6 +399,10 @@ export default {
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -330,6 +430,26 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 取全量 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,
|
||||
|
||||
/**
|
||||
* 当前远端状态
|
||||
*/
|
||||
|
||||
9
main.js
9
main.js
@@ -17,6 +17,7 @@ import {
|
||||
import configExternal from './common/js/config-external.js'
|
||||
import PrivacyManager from './common/js/privacy.js'
|
||||
import splashState from './common/js/splash-state.js'
|
||||
import ThemeOverride from './common/js/theme-override.js'
|
||||
|
||||
Vue.prototype.$store = store //挂在vue
|
||||
|
||||
@@ -105,7 +106,15 @@ async function bootstrap() {
|
||||
if (Lang.isRemoteOnly && Lang.isRemoteOnly()) {
|
||||
splashState.show();
|
||||
try {
|
||||
// 1) i18n: 拉远端语言包 (langConfig.source === 'remote-only')
|
||||
await Lang.boot();
|
||||
// 2) theme: 阻塞式预热 brand 桶覆写 (themeConfig.overrideRemote.source)
|
||||
await ThemeOverride.boot();
|
||||
// 3) theme(remote-only): 异步拉全量 8 个 preset 替换 stub
|
||||
// 失败静默, 兜底用 webpack stub(只含 default),首屏不白屏
|
||||
ThemeOverride.loadAllPresets().catch((e) => {
|
||||
console.warn('[theme-presets] load fail', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[i18n] remote-only boot fail', e);
|
||||
} finally {
|
||||
|
||||
@@ -489,33 +489,92 @@ const store = new Vuex.Store({
|
||||
* 关键: 应用层一行不动 — Vuex.themeStyle / themeColor / 页面 wrapper / CSS var
|
||||
* 全部保持兼容,所有改动只发生在"加载"环节
|
||||
*
|
||||
* @param {string} presetName style_color.js 中的 preset 名(default/red/green/...)
|
||||
* remote-only 模式兼容:
|
||||
* - 首次调用时 loadAllPresets 可能尚未完成,loadTheme() 会拿到 webpack stub
|
||||
* (只含 default preset). 如果用户选了非 default preset(green/blue/...),
|
||||
* loadTheme 返回 undefined.
|
||||
* - 兜底: 订阅 theme:presets-updated 事件,远端拉完后重新应用.
|
||||
* 一次性订阅(once 语义,触发后即解绑),避免后续切主题时重复触发.
|
||||
*
|
||||
* @param {string} presetName style_color.js 中的 preset 名(default/green/blue/...)
|
||||
* @returns {Promise<{ ok: boolean, source: string, applied: object }>}
|
||||
*/
|
||||
async applyThemeWithRemote({ state, commit, dispatch }, presetName) {
|
||||
const finalPresetName = presetName || themeConfig.defaultTheme;
|
||||
try {
|
||||
// 1) 加载本地预设(style_color.js)
|
||||
const base = await configExternal.loadTheme(finalPresetName);
|
||||
if (!base) {
|
||||
console.warn('[theme] loadTheme 返回空:', finalPresetName);
|
||||
return { ok: false, source: 'empty', applied: null };
|
||||
|
||||
// 工具:订阅一次 presets-updated 后自动解绑
|
||||
const subscribeOnce = (cb) => {
|
||||
let called = false;
|
||||
const handler = async () => {
|
||||
if (called) return;
|
||||
called = true;
|
||||
try { uni.$off && uni.$off('theme:presets-updated', handler); } catch (e) {}
|
||||
try { await cb(); } catch (e) { console.error('[theme] presets-updated reapply fail', e); }
|
||||
};
|
||||
try {
|
||||
uni.$on && uni.$on('theme:presets-updated', handler);
|
||||
} catch (e) {
|
||||
console.warn('[theme] uni.$on bind fail', e);
|
||||
}
|
||||
return () => {
|
||||
if (called) return;
|
||||
called = true;
|
||||
try { uni.$off && uni.$off('theme:presets-updated', handler); } catch (e) {}
|
||||
};
|
||||
};
|
||||
|
||||
// 2) 拉远端 brand 桶(失败静默,使用本地缓存或空)
|
||||
// 工具:真正加载 + 合并 + 提交
|
||||
// 关键: 调用前清空 configExternal.loadedConfigs 缓存,避免第一次拿到 stub 后
|
||||
// 远端 preset 到达时 loadTheme 直接返回缓存而不读 ThemeOverride.
|
||||
const doApply = async (name) => {
|
||||
try {
|
||||
if (configExternal && configExternal.loadedConfigs) {
|
||||
delete configExternal.loadedConfigs[`theme_${name}`];
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
const base = await configExternal.loadTheme(name);
|
||||
if (!base) return null;
|
||||
const brandOverrides = ThemeOverride.getByTheme('brand') || {};
|
||||
|
||||
// 3) 浅合并(主题 token 是扁平叶子,不需要深合并)
|
||||
// preset 先, brand 后 — 远端覆盖本地
|
||||
const merged = mergeThemeBuckets(
|
||||
sanitizeFlatMap(base),
|
||||
sanitizeFlatMap(brandOverrides)
|
||||
);
|
||||
|
||||
// 4) 走原链路(完全不动下游) — Vuex.themeStyle = 合并后对象
|
||||
commit('setThemeStyle', merged);
|
||||
dispatch('themeColorSet');
|
||||
return { ok: true, source: 'merged', applied: merged };
|
||||
return merged;
|
||||
};
|
||||
|
||||
try {
|
||||
// 1) 尝试同步加载
|
||||
const first = await doApply(finalPresetName);
|
||||
|
||||
// 2) 加载失败(非 default preset 在 stub 中不存在)或 first 为空
|
||||
// → 等远端 preset 拉完后重试
|
||||
if (!first) {
|
||||
console.log('[theme] preset 暂不可用,等待远端 preset 拉完:', finalPresetName);
|
||||
// 不阻塞当前调用方,返回空结果(应用层会显示 stub default)
|
||||
// 远端到后会自动重应用
|
||||
subscribeOnce(async () => {
|
||||
const retry = await doApply(finalPresetName);
|
||||
if (retry) {
|
||||
console.log('[theme] 远端 preset 到达,重应用成功:', finalPresetName);
|
||||
} else {
|
||||
console.warn('[theme] 远端 preset 到达后仍找不到:', finalPresetName);
|
||||
}
|
||||
});
|
||||
return { ok: false, source: 'waiting-presets', applied: null };
|
||||
}
|
||||
|
||||
// 3) 首次成功,但远端 preset 也许之后会到(用于其他 preset 的切换/覆写)
|
||||
// 兜底订阅一次,如果远端 preset 包含了更全的 token 也能再补一次
|
||||
subscribeOnce(async () => {
|
||||
const retry = await doApply(finalPresetName);
|
||||
if (retry) {
|
||||
console.log('[theme] 远端 preset 补充,重应用:', finalPresetName);
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, source: 'merged', applied: first };
|
||||
} catch (e) {
|
||||
console.error('[theme] applyThemeWithRemote 失败,回退到纯预设:', e);
|
||||
// 兜底: 至少把预设应用了,避免页面无主题
|
||||
|
||||
@@ -107,13 +107,31 @@ function _readManifestBase(manifestPath) {
|
||||
* 用 fs 静态解析, 避免 webpack 把整个 config-external.js 链入 vue.config 编译路径
|
||||
*/
|
||||
function _readLangSource() {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf-8');
|
||||
const match = content.match(/source:\s*['"]([\w-]+)['"]/);
|
||||
return match ? match[1] : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf-8');
|
||||
const match = content.match(/source:\s*['"]([\w-]+)['"]/);
|
||||
return match ? match[1] : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 themeConfig.overrideRemote.source, 用于构建期判断是否启用 style_color 排除策略
|
||||
* remote-only 模式: style_color.js 替换为 empty-style-stub.js(只含 emergency fallback)
|
||||
* 节省主包 ~75KB
|
||||
*/
|
||||
function _readThemeSource() {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(__dirname, 'common/js/config-external.js'), 'utf-8');
|
||||
// 找到 overrideRemote 块中的 source 字段(可能有多个 source 字段,取 overrideRemote 块内的)
|
||||
const orBlock = content.match(/overrideRemote\s*:\s*\{([\s\S]*?)\n\s*\}/);
|
||||
if (!orBlock) return null;
|
||||
const match = orBlock[1].match(/source:\s*['"]([\w-]+)['"]/);
|
||||
return match ? match[1] : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,6 +475,33 @@ module.exports = {
|
||||
console.log(`[vue.config] lang/ 目录保留 (source=${langSource || 'unknown'})`);
|
||||
}
|
||||
|
||||
// style_color.js 排除: 仅在 themeConfig.overrideRemote.source === 'remote-only' 模式下生效
|
||||
// 原理: config-external.js 的 loadTheme() 用 require('@/common/js/style_color.js') 同步加载
|
||||
// 整个文件. 替换为 stub 后, 主包只包含 1 个 default preset (~5KB), 其他 7 个 preset
|
||||
// 由远端 /api/theme/presets 异步补齐. 节省主包 ~75KB.
|
||||
const themeSource = _readThemeSource();
|
||||
if (themeSource === 'remote-only') {
|
||||
try {
|
||||
const webpack = require('webpack');
|
||||
const stubPath = path.resolve(__dirname, 'common/js/empty-style-stub.js');
|
||||
if (webpack?.NormalModuleReplacementPlugin) {
|
||||
config.plugins.push(
|
||||
new NormalModuleReplacementPlugin(
|
||||
// 精确匹配: 解析后的绝对路径是 .../common/js/style_color.js
|
||||
// 不能匹配 style_color 目录里其它可能存在的同名文件
|
||||
/[\\/]common[\\/]js[\\/]style_color\.js$/,
|
||||
stubPath
|
||||
)
|
||||
);
|
||||
console.log(`[vue.config] ✅ style_color.js 已替换为 stub (source=remote-only) — 节省 ~75KB`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[vue.config] 配置 style_color 排除失败:', e.message);
|
||||
}
|
||||
} else {
|
||||
console.log(`[vue.config] style_color.js 保留 (source=${themeSource || 'unknown'})`);
|
||||
}
|
||||
|
||||
if (config.mode === 'production') {
|
||||
console.log('开始尝试配置极限压缩');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user