Skip to content

皮肤机制

这一篇讲清楚皮肤从「定义」到「生效」的全过程:作用域如何注入、特异性为什么够、CSS 变量与结构字段各自的边界。理解了它,你写任何组件的皮肤都不会踩坑。源码集中在 src/utils/skin-css.tssrc/types/skin.tssrc/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; // 是否已购(仅远端)
}

csscssUrl 二选一:内置皮肤把 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 结构而非纯视觉——典型是加班机的 displayModeclassic 经典版式 vs simple 简约版式),两种模式渲染完全不同的子树。这类字段不能靠 CSS 切换,必须落到元素数据上。

皮肤的 data 就是干这个的。应用皮肤时(draw.tsapplyElementSkin):

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 解析一次 (ensureSkinInjectedsrc/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 等全部可用。两条硬规则:

  1. @keyframes 必须写在顶层,不能嵌进 .wrapper[data-skin-id] { ... } 块内。
  2. 动画名带皮肤前缀避免全局撞名,如 zbj-aurora-spinzbj-aurora-breathe

参考 overtime.classic-aurora:面板背后用 ::before 放旋转极光、::after 放斜向流光,倒计时用 order: -2 重排上移成发光胶囊——一张皮肤即可彻底重排版式,全部靠 CSS。