主题
皮肤机制
这一篇讲清楚皮肤从「定义」到「生效」的全过程:作用域如何注入、特异性为什么够、CSS 变量与结构字段各自的边界。理解了它,你写任何组件的皮肤都不会踩坑。源码集中在 src/utils/skin-css.ts、src/types/skin.ts、src/stores/draw.ts。
皮肤的数据结构
一张皮肤就是一个 Skin 对象(src/types/skin.ts),关键字段:
ts
interface Skin {
id: string; // 全局唯一,命名约定 "<elementType>.<variant>"
name: string; // 显示名
elementType: ElementType; // 适用的组件类型
thumbnail?: string; // 缩略图(选择器/商城预览用)
description?: string;
tags?: string[];
price?: number; // 0/undefined = 免费
data?: Record<string, unknown>; // 应用时合并进元素 data 的字段(结构性)
css?: string; // 内置皮肤:完整 CSS 文本,原样注入 <style>
cssUrl?: string; // 远端皮肤:CSS 链接,走 <link>/fetch
marketplaceId?: number; // 商城商品 id(仅远端)
hasPurchased?: boolean; // 是否已购(仅远端)
}css 与 cssUrl 二选一:内置皮肤把 CSS 打包成字符串走 css;商城远端皮肤走 cssUrl。
作用域注入(加班机 / 礼物菜单)
元素根节点带 data-skin-id
每个支持皮肤的组件,根节点都绑了当前皮肤 id:
vue
<!-- el-gift-menu.vue -->
<div class="gift-menu" :data-skin-id="data.skinId || undefined"> ... </div>
<!-- el-overtime-machine.vue -->
<div class="overtime-wrapper" :data-skin-id="data.skinId || undefined"> ... </div>没套皮肤(skinId 为 null)时属性不存在,元素呈现「默认外观」。
CSS 被自动作用域到 .包裹层[data-skin-id]
注入逻辑 ensureScoped()(skin-css.ts)按元素类型查到包裹层类名,把皮肤 CSS 收进 .{wrapper}[data-skin-id="{id}"] 之下:
ts
const WRAPPER_CLASS_MAP = {
[ElementType.OVERTIME_MACHINE]: "overtime-wrapper",
[ElementType.GIFT_MENU]: "gift-menu"
};- 内置皮肤已经手写了
.wrapper[data-skin-id="本 id"]前缀(出现该串就原样返回,避免双层嵌套)。 - 远端/上传的裸 CSS(没写前缀的)会被自动包一层:裸
.desc { ... }借 CSS 原生嵌套变成后代选择器.wrapper[data-skin-id="id"] .desc { ... }。 - 写了别的 / 写错了 id 前缀(如把远端皮肤硬写成
gift.neon-pink):自动把该包裹层前缀的 id 替换成真实运行时 id,规则照常命中——不会再因为「再包一层」变成永不命中的后代选择器。
所以内置皮肤按约定手写前缀,远端皮肤直接写裸 CSS 即可(写不写前缀、甚至写错 id 都能生效)。
为什么不用 !important
| 选择器 | 特异性 |
|---|---|
组件 Vue scoped:.desc[data-v-xxx] | (0,2,0) |
皮肤前缀:.gift-menu[data-skin-id="x"] .desc | (0,3,0) |
皮肤前缀比组件自身样式高一个类/属性选择器,稳定胜出,因此皮肤里不需要 !important。
注意
el-gift-menu.vue的 scoped 样式故意不嵌套——如果它把.desc嵌进.gift-menu里,scoped 也会变成(0,3,0),与皮肤打平,再靠源顺序让 scoped 后注入获胜,皮肤就盖不动了。给组件加新样式时要留意这条。
CSS 变量桥
组件把「可被用户在面板里调的视觉值」都写成 inline CSS 变量,挂在根节点上。例如礼物菜单:
ts
// el-gift-menu.vue —— giftMenuVars
{
"--font-family": data.fontFamily || "inherit",
"--font-size-base": data.fontSize + "px",
"--font-weight": data.fontBold ? "bold" : "normal",
"--text-color": data.color,
"--stroke": stroke, // "2px #000" 或 "0 transparent"
"--text-shadow": shadow // "Xpx Ypx Bpx color" 或 "none"
}组件 scoped 样式再用 var(--xxx) 消费它们。各组件可用的变量见对应 DOM 参考页。
皮肤要覆盖「属性」,不要去「重定义变量」
这些变量是内联设在元素上的(style="--text-color: ..."),内联变量的优先级高于样式表里的 --text-color: ... 重定义。所以皮肤里写 & .desc { --text-color: red } 不会生效;要直接写 & .desc { color: red } 覆盖最终属性。
换句话说:变量是给「用户面板」用的(用户调色板 → 变量 → 默认外观);皮肤是更高一层的覆盖,直接改 color / background / text-shadow 等最终属性。
结构字段 data
有些字段决定的是 DOM 结构而非纯视觉——典型是加班机的 displayMode(classic 经典版式 vs simple 简约版式),两种模式渲染完全不同的子树。这类字段不能靠 CSS 切换,必须落到元素数据上。
皮肤的 data 就是干这个的。应用皮肤时(draw.ts 的 applyElementSkin):
ts
if (skin.data) Object.assign(element, skin.data);
element.skinId = skinId;所以加班机皮肤都会声明:
ts
{
id: "overtime.classic-aurora",
elementType: ElementType.OVERTIME_MACHINE,
data: { displayMode: "classic", width: 280 }, // 绑定版式 + 统一宽度
css: `.overtime-wrapper[data-skin-id="overtime.classic-aurora"] { ... }`
}- 写
displayMode让皮肤自带版式——用户套上「极光脉冲」就一定是经典版式。 - 写
width: 280统一宽度;不写height——加班机autoSize=true,高度由内容自适应,写死只会在切换瞬间闪一下再被覆盖。
弹幕姬的
data走特殊分支:只取versionType(决定条目 DOM),不整体Object.assign(避免width/height灌进外壳尺寸打架)。详见 弹幕姬主题。
注入与生命周期
启动 → injectAllBuiltinSkins() 把所有内置皮肤的 css 注入 <style id="skin-{id}">(upsert,按节点 id 幂等)
用户选皮肤 → applyElementSkin(elId, skinId)
├─ 远端皮肤先 ensureMarketplaceSkinReady() 拉详情 + injectSkinCss()
├─ Object.assign(element, skin.data)
└─ element.skinId = skinId → 根节点 data-skin-id 变化 → 作用域命中
编辑器渲染/刷新 → 组件 onMounted → ensureSkinInjected(element.skinId)
├─ 内置(gift.*/overtime.*):启动已注入,幂等补一次
└─ 远端(remote:N):缓存有 CSS 直接注入;否则走【鉴权】详情接口补齐
(ensureMarketplaceSkinReady,仅编辑器已登录时)→ ensureScoped 注入
预览/推流/虚拟摄像头 → WS 推送的预览数据内嵌 skins 映射(编辑器 buildSkinMap)→ hydratePreviewSkins 预灌缓存
→ 组件 ensureSkinInjected 命中缓存注入(免鉴权、不调商城接口)
用户点「默认」→ clearElementSkin(elId)
└─ element.skinId = null + 恢复默认尺寸皮肤引用化:元素只持久化 skinId,不再把整段 CSS 存到每个元素上。CSS 按 id 解析一次 (ensureSkinInjected,src/utils/marketplace-skin.ts)。每张皮肤的 <style id="skin-{id}"> 用 upsert 注入(同 id 覆盖刷新、按 id 去重),切换皮肤只改元素的 data-skin-id,不重复造节点。
安全:免鉴权的预览/推流/虚拟摄像头窗口不开放「按任意 id 取 CSS」的公开接口。 它们的皮肤 CSS 由
GET /smart-scene/scene/preview/:previewId在服务端解析本场景引用到的 皮肤后内嵌下发(skins映射)——能看到的皮肤被previewId这张「能力票」限定, 无法靠遍历商品 id 白嫖未购买的皮肤。编辑器侧走的是原有鉴权详情接口,暴露面不变。
例外:弹幕姬是「全局
<link>整表主题」(非作用域),仍把skinCssUrl持久化到元素上由el-chat-renderer.vue自注入;它本就是 URL 引用、不存 CSS 文本,故不走ensureSkinInjected。
@keyframes 与动效
css 文本原样注入,所以 @keyframes / @media / 伪元素 / conic-gradient 等全部可用。两条硬规则:
@keyframes必须写在顶层,不能嵌进.wrapper[data-skin-id] { ... }块内。- 动画名带皮肤前缀避免全局撞名,如
zbj-aurora-spin、zbj-aurora-breathe。
参考 overtime.classic-aurora:面板背后用 ::before 放旋转极光、::after 放斜向流光,倒计时用 order: -2 重排上移成发光胶囊——一张皮肤即可彻底重排版式,全部靠 CSS。