从样式切换聊起
本文记录了我对样式切换的一些尝试,涉及到的技术有:CSS Modules、CSS Custom Properties 和 React
说起样式切换,大家最先想到的可能是选择网站全局主题。比如打开“暗黑模式”后,整个网站的配色会变为暗色系。
但因为现代网站由一个个组件构成,所以全局主题的切换实质上包含其中各个组件样式的切换。那么从组件层面来看,样式切换是如何实现的呢?以及网站中的大量组件是如何被调度来实现全局主题切换的呢?
接下来就让我们以实现下图中卡片样式切换为线索,一起来讨论样式切换实践吧!
样式?style!
基础组件由其内部一个个基本 HTML 元素(element)组成,而元素的样式可以直接通过行内 style
属性控制。那要实现样式切换,最直接的方法就是为组件的每一种外观设计一组 style
属性值,然后根据目标外观对应传递:
首先我们约定好组件外观对象 cardStyle
的接口
<>
<article style={cardStyle.card}>
<section style={cardStyle.header}>
<span style={cardStyle.headerIcon}></span>
<div style={cardStyle.titleBorder}><h1 style={cardStyle.title}>害 羞 小 向 晚</h1></div>
</section>
<img src={Ava} alt="Nifty Little Ava" style={cardStyle.mainImage} />
<section style={cardStyle.detail}>
<p style={cardStyle.effect}>场上所有顶碗人变得“热情”</p>
<p style={cardStyle.caption}>
<p><span style={cardStyle.captionText}>“你们发喜欢……<br />也不是不可以啦……”</span></p>
</p>
</section>
</article>
</>
然后把组件外观对应 CSS 样式处理为单独的 JS 对象模块:
// ./styles/base.ts
export const StyleBase: {[index: string]: React.CSSProperties} = {
card: {
width: "220px",
height: "300px",
// ...
},
header: { /*...*/ },
headerIcon: { /*...*/ },
// ...
}
接下来把各个外观对象模块通过 import
引入到组件中,并通过判断逻辑选出当前需要的样式即可:
// Card/index.tsx
import { StyleBase } from "./styles/base";
import { StyleAva } from "./styles/ava";
export const Card: React.FC<Props> = props => {
const { customized } = props;
const cardStyle = customized ? StyleAva : StyleBase;
return (/* ... */);
};
但这样“耿直”的方法存在很多问题,其中我认为比较关键的是:CSS 作为前端开发的“三驾马车”之一,多年来其实已经积累了相当多优秀的实践经验和开发工具。像这样把 CSS 样式直接写到 HTML 中,其实是简单地把 CSS 当成普通数据,然后用“向接口传递参数”的逻辑来处理。这样的主动“降维”,我觉得着实有些浪费。
CSS Class?
既然说不要把 CSS 样式当作普通数据直接写在 HTML 中,那就用 CSS class(类)把样式装起来呗!的确,通过 CSS class,我们可以把 CSS 样式从 HTML 元素行内抽离,然后组织到单独的 CSS class 中,最后通过 HTML 元素的 class
属性调用需要的样式。
<style>
.card {
border: 4px solid transparent;
border-radius: 4px,
/* ... */
}
/* ... */
</style>
<article class="card">
<!-- ... -->
</article>
但不幸的是,CSS class 刚把我们从火坑里救了出来,又把我们丢下了油锅。因为 CSS class 是全局生效的,这意味着:假如我新编写的 .card
碰巧和某个旧有 class 重名了,两个重名 class 之间就会进行优先级比较,借此来决定到底谁生效。假如旧有 .card
优先级更高还好,无非是在编写 CSS 样式过程中多受点“怎么这样式没效果啊?”的折磨。而假如是新编写的 .card
优先级更高,还好巧不巧,旧有 .card
又是在网站中大量使用的关键 class……
<style>
.card { color: #9AC8E2; }
.card h1 { /* pink! win! */ color: pink; }
.card { /* green? no~ no~ no~ */ color: green; }
</style>
<article class="card"><h1>Ava</h1></article>
而且在很多时候,相互冲突的 CSS class 是很难被找到的;即便找到了,确定优先级也并非易事;哪怕确定了优先级,要修改“陈年老代码”也需要不小的勇气……这时候,恶魔的低语在开发者耳边响起:
“!important !important !important”
一旦没能抵挡住诱惑,开始大量使用 !important
来把当前样式强制确定为最高优先级,就几乎意味着样式被完全“写死”了。自此之后,对样式的扩展将越来越步履维艰,整个 CSS 代码的“腐朽”也不远了。
CSS Modules!
为了应对 CSS class 全局生效带来的问题,开发者们各显神通,提出了许多解决方案。其中较为经典的有:BEM、OOCSS、SMACSS……而今天我想要介绍的解决方案是 CSS Modules。
首先我们来看一下 CSS Modules 的定义:
A CSS Module is a CSS file in which all class names and animation names are scoped locally by default
从定义中我们可以得知:一个 CSS Module 依然是一个 CSS 文件,但其中所有的 class(类)和 animation(动画)都被限定在局部生效。不是要解决 CSS class 全局生效带来的问题吗?好了,现在 CSS Modules 让 CSS class 局部生效了。
但空口无凭,接下来我们一起看一看 CSS Modules 到底是如何把 CSS class 控制在局部的:
首先我们在 CSS 文件中把样式组织在约定好的接口 class 里,每一个 CSS 文件对应组件的一种外观:
/* base.module.css */
.card {
width: 220px;
height: 300px;
/* ... */
}
.header: { /*...*/ };
.header-icon: { /*...*/ };
/* ... */
然后把组件不同外观对应的 CSS 文件通过 import
引入到组件处。此时在组件处会对应生成一个 JS 对象,该对象的属性名对应着 CSS 文件中各个接口 class 的名称。
现在我们就可以通过这个对象在组件的对应位置调用需要的 CSS class 了:
<>
<article className={StyleAva["card"]}>
<section className={StyleAva["header"]}>
<span className={StyleAva["header-icon"]}></span>
/* ... */
</section>
</article>
</>
从上述步骤可以看到,CSS Modules 的使用非常直接:把样式组织在 CSS class 里,然后引入到组件中,接着通过生成对象的属性名调用需要的 class。可 CSS Modules 如何保证使用到的 CSS class 只在局部生效呢?玄机就藏在对应生成 JS 对象的属性值中:
从上图中我们可以看到,CSS 文件对应 JS 对象的属性值是按一定规则产生的字符串,而这些字符串就是先前编写的 CSS class 经过构建后的名称。比如之前编写的 .header
,经过构建后就变成了 ._header_ijh04_51
。同时在组件对应 HTML 元素处使用的也是 CSS class 经过构建后的名称。
CSS class 的名称被转化为复杂字符串后,各个 class 之间重名的几率变得非常低。这就保证了:虽然 CSS class 依然是全局生效的,但各个 class 唯一对应其所在的组件,也就只会影响该组件控制的区域,即被限定在“局部”。
CSS 的能力
使用 CSS Modules 后,我们可以方便地将组件的样式组织在 CSS 文件中,然后在组件处使用约定好 CSS class 接口,要切换样式时传递对应 CSS 生成对象即可。
// Card/index.tsx
import StyleBase from "./styles/base.module.css";
import StyleAva from "./styles/ava.module.css";
export const Card: React.FC<Props> = props => {
const { customized } = props;
const cardStyle = customized ? StyleAva : StyleBase;
return (
<>
<article className={cardStyle["card"]}>
<section className={cardStyle["header"]}>
<span className={cardStyle["header-icon"]}></span>
// ...
</section>
// ...
</article>
</>
);
};
但 CSS 和 CSS Modules 的能力远不止如此:
模板化
上述代码中的样式切换实质上是以 CSS class 为单位,通过切换组件各个样式接口实际对应 CSS 样式实现的。
但不同外观对应样式之间的区别到底有多大呢?
上图的例子虽然有点极端,但可以用来说明:不同外观对应 CSS 样式之间其实是有相当多的部分是不变的,但以 CSS class 为单位的样式切换不得不保留这些重复。
那我们可不可以只改变需要的部分,就比如只改变上图中 linear-gradient
的颜色数值来实现样式切换呢?这时候就可以使用到 CSS custom properties(自定义属性)了。
通过 CSS custom properties 我们可以实现:将数值集中定义在一处,然后在需要使用该数值的地方引用该定义。当需要修改此数值时,我们只需要在定义处修改,之后所有对该定义的引用都会对应变化。
以上图中 linear-gradient
为例,我们可以把使用到的颜色数值定义在组件最外层 CSS class,并将 base
样式使用到的颜色作为其初始值;接下来在 .main-image
中引用该颜色,这时候 linear-gradient
的数值就对应着 base
样式需要的颜色了。
.custom-properties {
--image-border-color: #fffaf6, #ececea;
/* ... */
}
/* 以下部分样式不再需要主动修改 */
.main-image {
/* ... */
background-image:
/* ... */
linear-gradient(to top, var(--image-border-color));
}
此时在组件处就可以通过只改变 --image-border-color
的数值来实现样式切换。
const CustomProperties: { [index: string]: string } | {} = customized ?
{ "--image-border-color": "#8F41E9, #578AEF" } : {};
return (
<article style={CustomProperties} className={CSSModules["custom-properties"]}>
// ...
)
有的小伙伴可能发现了:怎么绕了一大圈,又回到利用 HTML 元素的 style
接口传数据了?这是因为我是通过“覆盖”来实现对 --image-border-color
的修改。从 CSS class 的视角来看类似如下代码:
.custom-properties {
/* base 样式颜色 */
--image-border-color: #fffaf6, #ececea;
/* ... */
/* Ava 样式颜色覆盖了 base */
--image-border-color: #8F41E9, #578AEF;
}
但是我暂时没想到比较简洁的方法实现在 CSS 层面向 CSS class 注入样式。所以只能利用优先级更高的 style
接口来实现覆盖了。
而如果用“替换”逻辑,即通过替换 --image-border-color
所在的最外层 CSS class 实现对其数值的修改,则又会遇到上文中提到的“重复”问题。因为切换不同样式可能并不需要同时修改所有的 custom properties。
虽然存在一些不够“优雅”的地方,但引入 CSS custom properties 后,CSS 就好像从一个比较呆板、冗余的文件,变成一个非常灵活、通过数据驱动的模板。约定好接口后,我们只要传入不同数据就可以改变组件的外观,而不是像之前一样需要一个一个地替换 CSS class,代码重复的问题也得到了很大改善。
模块化
和 module(模块)密不可分的一个词就是“复用”。那么 CSS Modules 对模块复用的支持如何呢?依然是以我们的卡片为例:
如上图所示:中心图片周围的白色渐变边框和整个卡片的灰色渐变边框使用到的 CSS 样式其实一样的,只不过是部分数值不同
我们可以把这些重复的“逻辑”单独抽离出来,然后同样利用上文提到的 CSS custom properties 把这部分 CSS 变成一个数据驱动的模板:
/* src/shared/styles/gradient-border.module.css */
.gradient-border {
/* 把模块使用到的 custom properties 直接定义在内部,实现类似“默认值”的效果 */
--border-width: 4px;
--background-color: #fff;
--gradient-color: #888888, #8b8e90;
border: var(--border-width) solid transparent;
border-radius: 4px;
background-clip: padding-box, border-box;
background-origin: padding-box, border-box;
background-image:
linear-gradient(var(--background-color), var(--background-color)),
linear-gradient(to top, var(--gradient-color));
}
接着我们在组件对应的 CSS 文件中,利用 @import
引入该 gradient-border
模块:
/* src/components/Card/index.module.css */
@import "src/shared/styles/gradient-border.module.css";
.custom-properties { /*...*/ }
.card { /*...*/ }
.main-image { /*...*/ }
/* ... */
现在我们就可以在需要的 CSS class 中通过 composes
复用渐变边框了:
.card {
composes: gradient-border;
/* 同样是通过“覆盖”实现对 gradient-border 模块内部 custom properties 的修改 */
--border-width: 8px;
--gradient-color: #888888, #8b8e90;
--background-color: #3f3f3f;
/* ...省略卡片其它样式 */
}
.main-image {
composes: gradient-border;
/* 因为 gradient-border 模块内部存在默认值,所以 --background-color 可以被省略 */
--border-width: 2px;
--gradient-color: #8F41E9, #578AEF;
/* ...省略中心图片其它样式 */
}
当然,引入的样式模块同样可以实现受组件层面传入数据驱动:
.custom-properties {
/* 卡片背景颜色 */
--card-background-color: #3f3f3f;
/* 卡片边框颜色 */
--card-border-color: #888888, #8b8e90;
/* ...省略其它 custom properties */
}
.card {
composes: gradient-border;
--border-width: var(--_card-border-width);
--gradient-color: var(--card-border-color);
--background-color: var(--card-background-color);
--_card-border-width: 8px;
/* ...省略卡片其它样式 */
}
我全局主题切换呢?
全局主题切换,我怎么会忘了它呢?但在此之前我们得先稍微了解一下 CSS custom properties 中引用寻找定义值的逻辑:
<style>
.outer { --ava-is: "jellyfish"; }
.ava::after { content: var(--ava-is); }
.inner { --ava-is: "little pig"; }
</style>
<article class="outer">
<!-- 那么 Ava 到底是什么呢? -->
<div class="ava">Ava is </div>
<div class="inner"></div>
</article>
上述 HTML 得到会是 Ava is jellyfish
,而不是 Ava is little pig
。这是因为当 var(--ava-is)
尝试寻找 --ava-is
的定义时,首先会在自身 CSS class 寻找;如果找不到,会直接尝试在上一层 HTML 元素处寻找;如果还找不到,则会向再上一层,直到最外层元素 <html>
。
为了预防即使到最外层元素 <html>
也找不到所需 custom properties 的定义,我们可以在 var()
引入“后备值”
<style>
/* 若找不到 --ava-is 的定义, var(--ava-is, "jellyfish") 将返回 "jellyfish" */
.ava::after { content: var(--ava-is, "jellyfish"); }
</style>
<div class="ava">Ava is </div>
那如果 custom properties 的定义是被故意留空的呢?比如组件默认情况下不缩放文字,但考虑到之后可能会有“适老化”的需求,我们可以在涉及到文字的组件处故意留出一个全局文字缩放的接口:
/* 因为 --global-font-scale 未定义,所以组件文字缩放系数 --card-font-scale 为后备值 1 */
.custom-properties { --card-font-scale: var(--global-font-scale, 1); }
/* 在组件内部指定文字大小时添加缩放逻辑 */
.card-caption { font-size: calc(14px * var(--card-font-scale)); }
当进行“适老化”处理,需要将全局字体放大 1.5 倍时,我们就可以在根元素处对 --global-font-scale
进行定义。因为在所有涉及到文字的组件处故意留空的 --global-font-scale
都会一直查找到根元素的 CSS class,即 :root
处。所以在 :root
对约定 custom properties 进行定义就能够实现对全局文字缩放的调度:
:root { --global-font-scale: 1.5; }
/* 在根元素处找到了 --global-font-scale 的定义,此时组件文字缩放系数 --card-font-scale 为 1.5 */
.custom-properties { --card-font-scale: var(--global-font-scale, 1); }
.card-caption { font-size: calc(14px * var(--card-font-scale)); }
全局主题的调度我认为就可以基于上述方法实现。
尾声
以上就是我从组件出发,对样式切换的一些简单、粗糙且不成熟的尝试。说实话颇有“纸上谈兵”的意味,因为我目前并没有机会在较大规模场景下验证上述想法:比如当网站中包含大量组件时,把 custom properties 高度内聚在组件内部会不会出现另一种“腐烂”?当存在大量 custom properties 定义留空时,网站性能会受多大的影响?需不需要把全局样式调度从 :root
拆分到多个位置?诸如此类……
但转念一想,会有这样的局限性也难免,毕竟这一系列尝试之所以发生,也仅仅是因为我希望眼前这张卡片可以尽可能的多样和丰富。
就像卡片里的主人公一样
转载自:https://juejin.cn/post/7088242933836546061