从样式切换聊起

本文记录了我对样式切换的一些尝试,涉及到的技术有: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

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 的名称。

module

现在我们就可以通过这个对象在组件的对应位置调用需要的 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 样式实现的。

切换

但不同外观对应样式之间的区别到底有多大呢?

diff

上图的例子虽然有点极端,但可以用来说明:不同外观对应 CSS 样式之间其实是有相当多的部分是不变的,但以 CSS class 为单位的样式切换不得不保留这些重复。

那我们可不可以只改变需要的部分,就比如只改变上图中 linear-gradient 的颜色数值来实现样式切换呢?这时候就可以使用到 CSS custom properties(自定义属性)了。

通过 CSS custom properties 我们可以实现:将数值集中定义在一处,然后在需要使用该数值的地方引用该定义。当需要修改此数值时,我们只需要在定义处修改,之后所有对该定义的引用都会对应变化。

以上图中 linear-gradient 为例,我们可以把使用到的颜色数值定义在组件最外层 CSS class,并将 base 样式使用到的颜色作为其初始值;接下来在 .main-image 中引用该颜色,这时候 linear-gradient 的数值就对应着 base 样式需要的颜色了。

(注:由于掘金编辑器提供的代码块似乎不包含对 CSS custom properties 的语义理解,从而会导致代码块语义着色混乱,所以从此处开始到文章结束的 CSS 代码块都未设置着色。很抱歉给小伙伴们带来不好的体验,对不起!)

.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 拆分到多个位置?诸如此类……

但转念一想,会有这样的局限性也难免,毕竟这一系列尝试之所以发生,也仅仅是因为我希望眼前这张卡片可以尽可能的多样和丰富。

就像卡片里的主人公一样

出现

猜你喜欢

转载自juejin.im/post/7088242933836546061