设计层

一个 Wippy 前端由许多独立发布的模块组成,它们渲染进同一个应用。其中两个归属地是显而易见的:主题,每个 surface 都会消费它;以及模块,它只管自己。介于两者之间的空隙并不显眼,而重复恰恰就积累在那里——多个模块确实共享的某个概念,主题却没有对应的组件。

本页命名这三个层,给出在它们之间做选择的判据,并展示每种选择做对和做错时分别是什么样子。

这些层

层 触达范围 拥有
主题 每一个 surface,包括你并不拥有的模块 PrimeVue 组件、共享语义 token、有文档的类
共享设计层 只有选择加入的模块 这些模块共享的、背后没有主题组件支撑的词汇
模块 它自己 确实只属于某一个 surface 的东西

主题是通用的,这正是它的约束

主题为你并不拥有的标记提供样式。任何模块——包括某个从未见过你的应用的人所写的第三方插件——都渲染进同一个宿主,并由同一个主题绘制。这就是主题成为通用层的原因,而它是把双刃剑:

任何应用特有的东西都不得进入主题,因为它会被强加给每一个从未要求过它的模块。

模块不得依赖主题中存在任何应用特有的东西。 契约是 PrimeVue 组件 + 共享的 Wippy 语义 token + 有文档的类——不包括某个应用额外叠加的任何东西。请注意,PrimeVue 自己的 preset 同样不属于契约:Wippy 以 theme: 'none' 运行 PrimeVue,所以你依赖的是 Wippy 语义 token。

/* 好 —— 共享的 Wippy 语义 token,对每个模块都存在 */
.my-panel {
  color: var(--p-text-color);
  background: var(--p-content-background);
  border: 1px solid var(--p-content-border-color);
}

/* 坏 —— 应用特有的 token。你的模块现在只能在一个应用内工作,
   并且在其他任何地方都会悄悄丢掉这条声明:未定义的自定义属性会让
   声明在计算值阶段失效,于是它被丢弃,元素安静地改为继承。 */
.my-panel { background: var(--kx-surface-2); }

这同时也回答了*"我能把我们的共享词汇放进 facade 吗?"只有当它确实必须触达任意的、非你所有的标记时才可以。如果它的范围限定在你自己*那组模块内,它就不属于主题——它属于下面那一层。

骨干,以及组件何时可以退出

宿主所提供的 PrimeVue 和 Tailwind 是任何组件的推荐骨干。组件可以选择退出——但只要它渲染任何常规内容,退出的空间就会立即收窄,而这个阶梯只能单向走:

该组件…… 那么它必须加载
呈现中立——canvas、SVG、没有控件、没有 token、没有实用类、不滚动的图表 什么都不加载:hostCssKeys: []
消费语义 token 或深色模式 themeConfigUrl
可以滚动 iframeCssUrl
渲染 markdown markdownCssUrl
渲染任何 Tailwind 能表达的东西 Tailwind——写实用类,而不是手写 CSS
渲染任何 PrimeVue 已提供组件的东西——按钮、输入框、表单、表格、对话框、菜单、标签、提示框,任何反馈控件 primeVueCssUrl 以及 PrimeVuePlugin

canvas 上的图表是合理退出的典型例子:它没有经典 UI,所以不需要骨干中的任何东西。给同一张图表加上工具栏,它就不再是呈现中立的了——那个按钮是 PrimeVue 按钮,整套集成也就随之而来。

注意其中的耦合:Tailwind 实用类是随 primeVueCssUrl 一起交付的。 并不存在单独的 Tailwind 宿主 CSS key,所以实际上需要 Tailwind 的组件同时也在加载 PrimeVue 资源。(preflightCssUrl 不属于该 key 联合;如果 shadow root 内确实需要 Tailwind preflight,就以命令式方式加载它——这种情况很少见。)

对本页而言的实际结论是:模块想要的大部分东西骨干里已经有了。 共享设计层是它之上一条很窄的带,而不是用来重做 PrimeVue 和 Tailwind 已覆盖内容的地方。机制参见 CSS 注入。

共享设计层

有些概念在一组已知模块之间反复出现,而主题中没有对应组件:内容卡片、surface 的标题行、surface 在没有内容时展示什么、标签有哪些尺寸。真实存在、共享,且无家可归。

它们以已发布的包形式交付,在构建时物化进每个消费方。它必须是包而不是路径别名,因为消费方位于不同的仓库中——本层的可证伪判据是:位于另一个仓库、对生产方没有路径访问权限的模块,能够消费该词汇并完成构建。

生产方模块把该包声明为构建时产物,每个消费方将其物化进自己的目录树。关于声明方式、node-package 格式、运行时替你协调的内容,以及构建自身仍需提供的胶水代码,参见构建时产物。

模块

其余的一切,加上每一处对共享词汇的有意偏离。

判断某样东西属于哪里

按顺序发问。第一个"是"胜出。

  1. 它是一个值吗? 颜色、圆角、间距、层次、严重级别。 → 主题。 读取语义 token。绝不用字面量。
  2. 主题是否已经提供了对应组件? Button、Dialog、Select、Tag。 → 主题。 使用该组件。要调整它就在它上面加一个类——绝不重建它。
  3. 你的两个或更多模块是否需要同一个概念,而背后没有主题组件? → 共享设计层。
  4. 否则 → 模块。

第 2 问是最容易绊倒人的一问,它背后有一条明确的规则。

实例

下面的例子来自 Kickside,一个 Wippy 应用;在长出这一层之前,它的模块 CSS 有 15.4% 是完全克隆的重复。

绝不重建主题组件

PrimeVue 提供了 Button。Kickside 有九个模块放弃它,在原生 <button> 上手写了 .kx-btn;另外七个模块使用了该组件。两种方言在各自局部都算合理——只是没有一个共享的地方放按钮,于是半个应用各自发明了一个。相互对照来看,它们只在 font-size 和 line-height 上一致,其余全不相同。

坏: 一个带 .kx-btn .kx-btn-primary 的原生 button 元素——对主题已提供组件的第二次实现。(这里故意写成选择器:文档校验会拒绝示例代码中的原生产品控件,那正是这条规则在上一层的强制体现。)

好: 使用主题组件,需要调整时在它上面加一个类。

<Button label="Save" class="kx-save" />

当主题组件不合适时,那并不构成重建它的许可。在组件上加一个类并为该类写样式——如果这项调整是全应用范围的就放在 facade 里,如果是局部的就放在模块里。Kickside 的 knowledge 模块仍在原生按钮上带着 .kn-btn / .kn-primary;那是一项未完成的迁移,而不是可以照搬的模式。

严重级别属于主题,不属于你

严重级别——success、danger、warn、info——是主题语义,带有已发布的色阶。Kickside 用四套命名方案重新推导了它十六次(tone-gn、t-ok、kx-tone-success、tone-success)。同一个类名在三个模块里意味着三种不同的颜色,因此发布其中任何一份定义都会悄悄重绘其他几处。

/* 坏 —— 在模块本地名称下重新推导严重级别 */
.tone-gn { color: #16a34a; }

/* 好 —— 严重级别来自主题 */
.status-dot.success { background: var(--p-success-500); }

共享层中仍然可以存在色调——但只能作为装饰性的分类颜色,绝不能作为严重级别。如果它可能表示"这个失败了",那它就是严重级别,就属于主题。

主题没有位置的共享词汇

/* 好 —— PrimeVue 没有 Card,没有 surface Header,没有 EmptyState。
   这些概念在多个模块间反复出现且背后没有主题组件,正是共享层的用武之地。 */
@import "@kickside/ui-kit/kx-card.css";
@import "@kickside/ui-kit/kx-state.css";

采用意味着导入并删除

CSS 的 @import 必须位于样式表中所有其他规则之前。因此共享样式表总是落在最前面,模块之后声明的任何内容在同等特异性下都会胜过它。一个导入了包却保留自己那份副本的模块,什么都没有改变。

/* 坏 —— 这个导入是无效的;本地副本仍然胜出 */
@import "@kickside/ui-kit/kx-card.css";
.kx-card { border-radius: 14px; border: 1px solid var(--p-content-border-color); }

/* 好 —— 导入,删除本地副本,只保留一处有文档说明的差异 */
@import "@kickside/ui-kit/kx-card.css";
/* 这个 surface 的卡片内联在密集列表中,因此不要浮起效果。 */
.kx-card:hover { transform: none; }

只保留差异——绝不复述整段内容。也绝不把两种意图折叠进一个名称:如果一个类名在两个模块里含义不同,那就是两个概念共用一个名字。拆分名称;不要选一个胜者然后重绘输家。

与主题争夺特异性

模块的 CSS 先被注入 shadow root;主题的 PrimeVue 样式表随后追加。两者都是 <style> 元素,所以由文档顺序决定,而主题在后。必须胜过主题组件类的模块规则需要更高的特异性——而不是在文件中出现得更靠后。(adoptedStyleSheets 承载的是 facade 的自定义 CSS,而不是主题,所以求助于 adopted 样式表同样赢不了这一局。)

这一点在透传类上最为致命,因为你的类会落在主题元素上:

/* 坏 —— 这个类被应用到 PrimeVue 自己的 footer 元素上,因此在同等特异性下
   主题胜出,这段 padding 永远不会生效。 */
.kx-modal-foot { padding: 14px 18px; }

/* 好 —— 限定在对话框根元素之下,因此特异性高于主题 */
.kx-modal > .kx-modal-foot { padding: 14px 18px; }

共享层可以包含什么

一组模块确实共享、而主题并不拥有的一切:CSS 词汇、派生 token、内部组件、辅助函数、测试脚手架。重复的性质完全相同——Kickside 除了克隆的 CSS 之外,还有同一份测试引导代码的十九个副本。

以语义化的块交付。 每个单元应当是消费方能够理解的一个具名概念——kx-card、kx-state、kx-tag。优先选择更细粒度的包,让消费方只取它需要的部分;一个包内提供若干名称清晰的单元是可行的,但那不是应当追求的形态。

绝不做大杂烩。 不要 common,不要 shared,不要 misc,不要 utils。名字说不清里面装了什么的单元,会把所有无处安放的东西都吸进去,于是你就重建了这一层本来要解决的问题。

归一化是一次视觉变更

合并已经漂移的副本会移动像素。Kickside 有一个选择器带着十九份定义,分属十七种不同的内容。逐份对比,选定标准版本,记录你为何这样选,把有意的偏离保留为有文档说明的覆盖——然后看结果。单元测试看不见布局。

相关内容

  • 主题化 —— token 目录,以及主题如何同时触达宿主和子组件
  • 合规检查清单 —— 前端被检查时所依据的逐模块规则
  • 构建时产物 —— 声明包,以及把它物化进消费方
  • 依赖管理 —— 声明并解析模块所消费的内容