UI 主题是指以一致的方式更改视觉样式,从而定义网站的“外观和感觉”。更换调色板,例如深色模式或其他方式,就是一个很好的例子。从用户的角度来看,主题涉及更改视觉样式,无论是使用 UI 选择主题样式,还是网站自动尊重用户在操作系统级别的颜色主题偏好。从开发人员的角度来看,用于主题的工具应该易于使用,并在开发时定义主题,然后在运行时应用它们。
本文介绍了如何使用 Mimcss(一个 CSS-in-JS 库)进行主题设计,该库使用类继承——这对于大多数开发人员来说应该是一种直观的方法,因为主题通常是关于覆盖 CSS 属性值,而继承非常适合这些覆盖。
**完全披露:**我是 Mimcss 的作者。如果你认为这是一次无耻的宣传,你并不完全错误。不过,我真的相信我们在本文中介绍的主题技术是独一无二的、直观的,值得探索的。
一般主题注意事项
Web UI 中的样式通过让 HTML 元素引用 CSS 实体(类、ID 等)来实现。由于 HTML 和 CSS 本质上都是动态的,因此可以通过以下方法之一更改视觉表示
- 更改 HTML 元素的 CSS 选择器,例如不同的类名或 ID。
- 在保留选择器的同时,更改该 HTML 元素的实际 CSS 样式。
根据上下文,一种方法可能比另一种方法更有效。主题通常由有限数量的样式实体定义。是的,主题不仅仅是颜色和字体的集合——它们可以定义填充、边距、布局、动画等等。但是,似乎主题定义的 CSS 实体数量可能少于引用这些实体的 HTML 元素数量,特别是如果我们谈论的是诸如表格、树或代码编辑器之类的重量级小部件。有了这个假设,当我们想要更改主题时,我们宁愿替换样式定义,而不是遍历 HTML 元素并(很可能)更改它们的 class
属性的值。
在纯 CSS 中进行主题设计
在常规 CSS 中,支持主题设计的一种方法是使用 备用样式表。这允许开发人员在 HTML <head>
中链接多个 CSS 文件
<link href="default.css" rel="stylesheet" type="text/css" title="Default Style">
<link href="fancy.css" rel="alternate stylesheet" type="text/css" title="Fancy">
<link href="basic.css" rel="alternate stylesheet" type="text/css" title="Basic">
在任何给定时间,以上样式表中只有一个可以处于活动状态,并且浏览器应该提供 UI,用户可以通过该 UI 选择从 <link>
元素的 title
属性的值中获取的主题名称。备用样式表中的 CSS 规则名称(即类名)应该相同,例如
/* default.css */
.element {
color: #fff;
}
/* basic.css */
.element {
color: #333;
}
这样,当浏览器激活不同的样式表时,不需要更改 HTML。浏览器只重新计算样式(和布局),并根据“获胜”的值重新绘制页面,如层叠样式表所决定。
不幸的是,备用样式表不受主流浏览器的良好支持,并且在其中一些浏览器中,只有使用特殊扩展才能正常工作。正如我们将在后面看到的那样,Mimcss 建立在备用样式表的基础上,但在纯 TypeScript 框架中利用了它。
在 CSS-in-JS 中进行主题设计
现在有太多 CSS-in-JS 库,我们不可能在一篇文章中完全涵盖 CSS-in-JS 中的主题设计工作原理,因为这太庞大了。就与 React 紧密集成的 CSS-in-JS 库而言(例如,Styled Components),主题设计在 ThemeProvider
组件和 Context API 上实现,或者在 withTheme
高阶组件上实现。在这两种情况下,更改主题都会导致重新渲染。就与框架无关的 CSS-in-JS 库而言,主题设计是通过专有机制实现的,如果它 überhaupt 支持主题设计的话。
大多数 CSS-in-JS 库——无论是特定于 React 的还是与框架无关的——都专注于将样式规则“限定”到组件,因此主要关注为 CSS 实体创建唯一名称(例如 CSS 类)。在这种环境中,更改主题必然意味着更改 HTML。这与上面介绍的备用样式表方法相违背,该方法通过仅仅更改样式来实现主题设计。
Mimcss 库与众不同。它试图将主题设计的两个世界的优点结合起来。一方面,Mimcss 遵循备用样式表方法,通过定义具有相同名称的 CSS 实体的多个样式表变体来实现。另一方面,它提供了面向对象的方法和强大的 TypeScript 类型系统,具有 CSS-in-JS 动态编程和类型安全的所有优势。
在 Mimcss 中进行主题设计
Mimcss 属于后者一组 CSS-in-JS 库,因为它与框架无关。但它也是为了实现以下主要目标而创建的:以类型安全的方式允许原生 CSS 允许的所有功能,同时利用 TypeScript 类型系统的全部功能。特别是,Mimcss 使用 TypeScript 类来模拟原生 CSS 样式表文件。就像 CSS 文件包含规则一样,Mimcss 的 *样式定义* 类包含规则。
类为使用类继承来实现主题设计提供了机会。总体思路是基类声明主题使用的 CSS 规则,而派生类为这些规则提供不同的样式属性值。这与原生备用样式表方法非常相似:激活不同的主题类,并且无需更改 HTML 代码,样式就会改变。
但首先,让我们简要了解一下 Mimcss 中如何定义样式。
Mimcss 基础知识
Mimcss 中的样式表被建模为样式定义类,它们将 CSS 规则定义为其属性。例如
import * as css from "mimcss"
class MyStyles extends css.StyleDefinition
{
significant = this.$class({
color: "orange",
fontStyle: "italic"
})
critical = this.$id({
color: "red",
fontWeight: 700
})
}
Mimcss 语法试图尽可能接近常规 CSS。当然,它稍微冗长一些;毕竟,它是纯 TypeScript,不需要任何插件或预处理。但它仍然遵循常规 CSS 模式:对于每个规则,都有规则名称(例如 significant
),规则类型(例如 $class
)以及规则包含的样式属性。
除了 CSS 类和 ID 之外,样式定义属性还可以定义其他 CSS 规则,例如标签、关键帧、自定义 CSS 属性、具有任意选择器的样式规则、媒体、@font-face、计数器等等。Mimcss 还支持嵌套规则,包括具有伪类和伪元素的规则。
在定义样式定义类后,应该激活样式
let styles = css.activate(MyStyles);
激活样式会创建样式定义类的实例,并将 CSS 规则写入 DOM。为了使用样式,我们在 HTML 渲染代码中引用实例的属性
render()
{
return <div>
<p className={styles.significant.name}>
This is a significant paragraph.
</p>
<p id={styles.critical.name}>
This is a critical paragraph.
</p>
</div>
}
我们使用 styles.significant.name
作为 CSS 类名。请注意,styles.significant
属性不是字符串,而是一个具有 name
属性和 CSS 类名的对象。该属性本身还提供对 CSS 对象模型规则的访问,这允许直接操作规则;但是,这超出了本文的范围(尽管 Louis Lazaris 有一篇关于它的 很棒的文章)。
如果不再需要样式,可以将其停用,这会将其从 DOM 中删除
css.deactivate(styles);
CSS 类和 ID 名称由 Mimcss 唯一生成。生成机制在库的开发版和生产版中有所不同。例如,对于 significant
CSS 类,名称在开发版中生成为 MyStyles_significant
,在生产版中生成为类似于 n2
的内容。这些名称是在样式定义类首次激活时生成的,并且无论激活和停用该类多少次,它们都保持不变。名称的生成方式取决于它们在哪个类中首次声明,这在我们开始继承样式定义时变得非常重要。
样式定义继承
让我们来看一个简单的例子,看看 Mimcss 在存在继承的情况下会做什么。
class Base extends css.StyleDefinition
{
pad4 = this.$class({ padding: 4 })
}
class Derived extends Base
{
pad8 = this.$class({ padding: 8 })
}
let derived = css.activate(Derived);
当我们激活 Derived
类时,没有发生任何意外:derived
变量提供了对 pad4
和 pad8
CSS 类的访问权限。Mimcss 为这些属性中的每一个生成一个唯一的 CSS 类名。在库的开发版本中,这些类的名称分别是 Base_pad4
和 Derived_pad8
。
当 Derived
类覆盖基类中的属性时,有趣的事情就开始发生了。
class Base extends css.StyleDefinition
{
pad = this.$class({ padding: 4 })
}
class Derived extends Base
{
pad = this.$class({ padding: 8 })
}
let derived = css.activate(Derived);
为 derived.pad.name
变量生成一个名称。该名称为 Base_pad
;但是,样式为 { padding: 8px }
。也就是说,名称是使用基类的名称生成的,而样式是从派生类中获取的。
让我们尝试另一个从同一个 Base
类派生的样式定义类。
class AnotherDerived extends Base
{
pad = this.$class({ padding: 16 })
}
let anotherDerived = css.activate(AnotherDerived);
正如预期的那样,anotherDerived.pad.name
的值为 Base_pad
,样式为 { padding: 16px }
。因此,无论我们有多少不同的派生类,它们都使用相同的名称来表示继承的属性,但会为它们分配不同的样式。这是 Mimcss 的关键特性,它允许我们使用样式定义继承来实现主题。
在 Mimcss 中创建主题
Mimcss 中主题的主要思想是,有一个主题声明类,它声明了多个 CSS 规则,以及多个从声明类派生的实现类,这些实现类通过提供实际的样式值来覆盖这些规则。当我们需要 CSS 类名以及代码中的其他命名 CSS 实体时,可以使用主题声明类中的属性。然后,我们可以激活这个或那个实现类,瞧,我们可以用很少的代码完全改变应用程序的样式。
让我们考虑一个非常简单的例子,它很好地演示了 Mimcss 中主题的整体方法:一个主题只是定义了元素边框的形状和样式。
首先,我们需要创建主题声明类。主题声明是从 ThemeDefinition
类派生的类,ThemeDefinition
类本身是从 StyleDefinition
类派生的(为什么我们需要 ThemeDefinition
类,以及为什么主题不应该直接从 StyleDefinition
类派生,这是一个以后再讨论的话题)。
class BorderTheme extends css.ThemeDefinition
{
borderShape = this.$class()
}
BorderTheme
类定义了一个 CSS 类 borderShape
。请注意,我们没有为它指定任何样式。我们只使用这个类来定义 borderShape
属性类型,并让 Mimcss 为它创建唯一的名称。从某种意义上说,它很像接口中的方法声明——它声明了自己的签名,应该由派生类来实现。
现在让我们定义两个实际的主题——使用 SquareBorderTheme
和 RoundBorderTheme
类——它们从 BorderTheme
类派生,并通过指定不同的样式参数来覆盖 borderShape
属性。
class SquareBorderTheme extends BorderTheme
{
borderShape = this.$class({
border: ["thin", "solid", "green"],
borderInlineStartWidth: "thick"
})
}
class RoundBorderTheme extends BorderTheme
{
borderShape = this.$class({
border: ["medium", "solid", "blue"],
borderRadius: 8 // Mimcss will convert 8 to 8px
})
}
TypeScript 确保派生类只能使用在基类中声明的相同类型来覆盖属性,在本例中,该类型是 Mimcss 内部用于定义 CSS 类的类型。这意味着开发人员不能使用 borderShape
属性错误地声明不同的 CSS 规则,因为这会导致编译错误。
现在我们可以激活其中一个主题作为默认主题。
let theme: BorderTheme = css.activate(SquareBorderTheme);
当 Mimcss 首次激活一个样式定义类时,它会为类中定义的所有 CSS 实体生成唯一的名称。正如我们之前所见,为 borderShape
属性生成的名称只生成一次,并在激活从 BorderTheme
类派生的其他类时重复使用。
activate
函数返回一个已激活类的实例,我们将它存储在类型为 BorderTheme
的 theme
变量中。拥有这个变量会告诉 TypeScript 编译器,它可以访问 BorderTheme
中的所有属性。这让我们可以为一个虚构的组件编写以下渲染代码。
render()
{
return <div>
<input type="text" className={theme.borderShape.name} />
</div>
}
剩下的就是编写允许用户选择两个主题之一并激活它的代码。
onToggleTheme()
{
if (theme instanceof SquareBorderTheme)
theme = css.activate(RoundBorderTheme);
else
theme = css.activate(SquareBorderTheme);
}
请注意,我们没有必要停用旧的主题。ThemeDefinition
类(与 StyleDefintion
类相反)的一个特性是,对于每个主题声明类,它只允许一个主题同时处于活动状态。也就是说,在本例中,RoundBorderTheme
或 SquareBorderTheme
之一可以处于活动状态,但不能同时处于活动状态。当然,对于多个主题层次结构,多个主题可以同时处于活动状态。也就是说,如果我们有另一个层次结构,它具有 ColorTheme
声明类以及派生的 DarkTheme
和 LightTheme
类,那么一个 ColorTheme
派生类可以与一个 BorderTheme
派生类同时处于活动状态。但是,DarkTheme
和 LightTheme
不能同时处于活动状态。
引用 Mimcss 主题
在我们刚才看到的例子中,我们直接使用了主题对象,但主题通常定义了诸如颜色、大小和字体之类的元素,这些元素可以被其他样式定义引用。这对于将定义主题的代码与只想要使用当前活动主题定义的元素的组件样式定义代码分离特别有用。
CSS 自定义属性非常适合声明可以构建样式的元素。因此,让我们在主题中定义两个自定义属性:一个用于前景色,另一个用于背景色。我们还可以创建一个简单的组件,并为它定义一个单独的样式定义类。以下是我们定义主题声明类的方式。
class ColorTheme extends css.ThemeDefinition
{
bgColor = this.$var( "color")
frColor = this.$var( "color")
}
$var
方法定义了一个 CSS 自定义属性。第一个参数指定 CSS 样式属性的名称,该名称决定了可接受的属性值。请注意,我们没有在这里指定实际的值;在声明类中,我们只希望 Mimcss 为 CSS 自定义属性创建唯一的名称(例如 --n13
),而值是在主题实现类中指定的,我们将在下面进行操作。
class LightTheme extends ColorTheme
{
bgColor = this.$var( "color", "white")
frColor = this.$var( "color", "black")
}
class DarkTheme extendsBorderTheme
{
bgColor = this.$var( "color", "black")
frColor = this.$var( "color", "white")
}
由于 Mimcss(当然还有 TypeScript)的类型系统,开发人员不能错误地重复使用,例如,使用不同类型的 bgColor
属性;他们也不能指定颜色类型不可接受的值。这样做会立即产生编译错误,这可以为开发人员节省不少时间(这是 Mimcss 的目标之一)。
让我们通过引用主题的 CSS 自定义属性来定义组件的样式。
class MyStyles extends css.StyleDefinition
{
theme = this.$use(ColorTheme)
container = this.$class({
color: this.theme.fgColor,
backgroundColor: this.theme.bgColor,
})
}
MyStyles
样式定义类通过调用 Mimcss 的 $use
方法来引用 ColorTheme
类。这将返回 ColorTheme
类的实例,可以通过它访问所有属性,并使用它们为 CSS 属性分配值。
我们不需要编写 var()
函数调用,因为 Mimcss 在引用 $var
属性时已经完成了。实际上,container
属性的 CSS 类创建了以下 CSS 规则(当然,使用唯一生成的名称)。
.container {
color: var(--fgColor);
backgroundColor: var(--bgColor);
}
现在我们可以定义我们的组件(以伪 React 样式)。
class MyComponent extends Component
{
private styles = css.activate(MyStyles);
componentWillUnmount()
{
css.deactivate(this.styles);
}
render()
{
return <div className={this.styles.container.name}>
This area will change colors depending on a selected theme.
</div>
}
}
请注意上面代码中的一个重要事项:我们的组件与实现实际主题的类完全分离。我们的组件唯一需要知道的类是主题声明类 ColorTheme
。这为轻松“外部化”主题的创建打开了大门——主题可以由第三方供应商创建,并作为常规 JavaScript 包交付。只要它们从 ColorTheme
类派生,就可以激活它们,我们的组件会反映它们的值。
想象一下,为,例如,Material Design 样式创建一个主题声明类,以及多个从这个类派生的主题类。唯一的限制是,由于我们正在使用现有的系统,CSS 属性的实际名称不能由 Mimcss 生成——它们必须是 Material Design 系统使用的确切名称(例如 --mdc-theme--primary
)。值得庆幸的是,对于所有命名的 CSS 实体,Mimcss 提供了一种方法来覆盖其内部名称生成机制,并使用显式提供的名称。以下是使用 Material Design CSS 属性进行操作的方法。
class MaterialDesignThemeBase extends css.ThemeDefinition
{
primary = this.$var( "color", undefined, "mdc-theme--primary")
onPrimary = this.$var( "color", undefined, "mdc-theme--on-primary")
// ...
}
$var
调用中的第三个参数是名称,该名称将赋予 CSS 自定义属性。第二个参数设置为 undefined
,这意味着我们没有为该属性提供任何值,因为这是一个主题声明,而不是一个具体的主题实现。
实现类不需要担心指定正确的名称,因为所有名称分配都基于主题声明类。
class MyMaterialDesignTheme extends MaterialDesignThemeBase
{
primary = this.$var( "color", "lightslategray")
onPrimary = this.$var( "color", "navy")
// ...
}
页面上的多个主题
如前所述,从同一个主题声明类派生的主题中,只有一个主题实现可以处于活动状态。原因是不同的主题实现为具有相同名称的 CSS 规则定义了不同的值。因此,如果允许多个主题实现同时处于活动状态,我们将有多个相同名称的 CSS 规则定义。这当然是一个灾难的先兆。
通常,一次只激活一个主题完全没有问题——这很可能是在大多数情况下我们想要的。主题通常定义整个页面的整体外观和感觉,并且不需要不同的页面部分使用不同的主题。但是,如果我们处于这种罕见的情况下,我们需要将不同的主题应用于页面的不同部分,该怎么办?例如,如果用户在选择浅色或深色主题之前,我们想要让他们并排比较这两种模式?
解决方案基于自定义 CSS 属性可以在 CSS 规则下重新定义这一事实。由于主题定义类通常包含大量自定义 CSS 属性,Mimcss 提供了一种简单的方法,可以在不同的 CSS 规则下使用来自不同主题的值。
让我们考虑一个示例,我们需要在同一页面上使用两个不同的主题来显示两个元素。我们的想法是为我们的组件创建一个样式定义类,以便我们可以编写以下渲染代码
public render()
{
return <div>
<div className={this.styles.top.name}>
This should be black text on white background
</div>
<div className={this.styles.bottom.name}>
This should be white text on black background
</div>
</div>
}
我们需要定义 CSS top
和 bottom
类,以便我们在每个类下重新定义自定义属性,并从不同的主题中获取值。我们本质上想要以下 CSS
.block {
backgroundColor: var(--bgColor);
color: var(--fgColor);
}
.block.top {
--bgColor: while;
--fgColor: black;
}
.block.bottom {
--bgColor: black;
--fgColor: white;
}
出于优化目的以及展示 Mimcss 如何处理继承 CSS 样式,我们使用 block
类,但这可选。
以下是 Mimcss 中的实现方式
class MyStyles extends css.StyleDefinition
{
theme = this.$use(ColorTheme)
block = this.$class({
backgroundColor: this.theme.bgColor,
color: this.theme.fgColor
})
top = this.$class({
"++": this.block,
"--": [LightTheme],
})
bottom = this.$class({
"++": this.block,
"--": [DarkTheme],
})
}
就像我们之前做的那样,我们引用我们的 ColorTheme
声明类。然后,我们定义一个辅助 block
CSS 类,该类使用主题中的自定义 CSS 属性设置前景色和背景色。然后我们定义 top
和 bottom
类,并使用 "++"
属性来指示它们继承自 block
类。Mimcss 支持几种样式继承方法;"++"
属性只是将引用类的名称追加到我们的类名。也就是说,styles.top.name
返回的值为 "top block"
,其中我们将两个 CSS 类组合在一起(实际名称是随机生成的,所以它应该是类似 "n153 n459"
的东西)。
然后,我们使用 "--"
属性来设置自定义 CSS 变量的值。Mimcss 支持在规则集中重新定义自定义 CSS 属性的几种方法;在我们的例子中,我们只是引用相应的主题定义类。这会导致 Mimcss 使用其对应值重新定义在主题类中找到的所有自定义 CSS 属性。
你怎么看?
Mimcss 中的主题设计有意地基于样式定义继承。我们仔细研究了它的工作原理,我们获得了主题世界的最佳之处:能够使用备用样式表以及使用面向对象方法替换 CSS 属性值的能力。
在运行时,Mimcss 应用主题,而无需更改任何 HTML。在构建时,Mimcss 利用了久经考验且易于使用的类继承技术。请 查看 Mimcss 文档,以更深入地了解我们在这里介绍的内容。您也可以 访问 Mimcss Playground,在那里您可以探索许多示例并轻松尝试您自己的代码。
当然,请告诉我您对这种方法的看法!这我一直以来用于主题的解决方案,我希望能根据像您这样的开发者的反馈继续改进它。
我已经使用 TypeScript 和其他 CSS in JS 库几年了,体验非常棒,尤其是在传递设计令牌方面。
组件的核心概念是 CSS、HTML 和驱动显示逻辑的任何 JS 都代表同一个关注点。从顶部任意地对组件树进行样式设置,违反了这些关注点。组件可能会发生变化,并且会意外地使其与顶层主题的关系失效。(这种隐式关系是使级联如此脆弱的原因。)
范围组件的挑战之一是响应式设计,例如根据屏幕宽度更改命名网格区域的顺序,因为父元素无法进入其子元素并通过类选择器进行更改,并且子元素没有概念在其页面上的位置。(您可以通过从子组件提供钩子来作弊,但这很容易被滥用。Shadow DOM 在很大程度上通过设计来阻止这种情况。)自定义属性通过将样式更改的表面积缩小到单个属性值(而不是整个类)来缓解这个问题。通过媒体查询更改自定义属性的值会通过您的组件级联,这些组件希望按预期使用自定义属性。
自定义属性的问题是它们和级联的其余部分一样脆弱:它们可以用任意值覆盖,容易发生命名冲突,并且没有办法在使用之前验证它们。还存在性能损失,因为解析自定义属性必须在运行时完成。如果字体和颜色不需要在运行时更改(只需在用户更改主题时刷新页面),则没有理由将它们绑定到自定义属性。
如果您使用的是 CSS in JS 解决方案,您的 JS 可以更明确地跟踪变量,并在组件实际使用它们之前验证它们。例如,您可以在组件实际使用颜色变量之前确保颜色变量同时存在并与预期值匹配。使用 SASS,您可以在编译时完成所有这些操作,因此在运行时没有性能损失,只需使用不同的 CSS 类分配来渲染组件即可。
不过,拥有静态类型的 CSS 真是太棒了。我无法过分强调重构和编写 CSS 的便捷程度,当您的 IDE 自动完成诸如您可以使用哪些断点之类的事情时。