UI组件库之:css-in-js

190 阅读7分钟

css-in-js的起源

1996 年,Netscape 实现了JSSS ,又名。基于 JavaScript 的样式表作为 CSS 的替代品。 Netscape 甚至将该规范提交给 W3C 进行标准化。然而,它并没有获得足够的关注,因为“CSS 得到了更广泛的行业认可” 。最终,在 2000 年,Netscape 放弃了对 JSSS 的所有支持。

现在所说的CSS-in-JS是在 2014 年创造的。有两位已知的先驱者,即 Facebook 的Christopher Chedeau和 JSS(不要与 1996 年 Netscape 的 JSSS 混淆)的作者Oleg Isonen 。

从本质上讲, CSS-in-JS 只是解决可扩展 CSS 问题的替代解决方案

  • 封装样式:CSS 类哈希是大多数 CSS-in-JS 库借用的一种技术来确定其样式范围
  • 显式依赖项:样式的定义与其用法之间存在明确的引用
  • 上下文样式:能够在单个样式定义中定义嵌套规则、媒体查询和伪,从而防止代码重复
  • 避免僵尸代码:当我们删除该组件或不再导入它时,它的样式在捆绑过程中也将被忽略

超越静态样式

  • 纯 CSS 不提供代码导航功能,例如转到定义或查找所有引用。 JavaScript 定义的样式释放了特定于编程语言的工具的强大功能,显着改善了开发体验。
  • 动态样式在高度交互的应用程序中很普遍。基于状态的样式(例如切换样式或组件变体以及用户定义的样式)使用 CSS-in-JS 实现起来非常简单且简单。
  • 从 (S)CSS 到 JavaScript共享变量(反之亦然)在技术上是可行的,但需要额外的样板文件,而且很容易出错。通过 CSS-in-JS,我们不仅可以共享constants或variables ,还可以共享types 、 functions或任何其他 JS 代码。
  • 常规 CSS 无法进行类型检查。如果样式丢失或者类名拼写错误,没有任何工具可以帮助我们注意到问题
  • 服务器端渲染页面的关键 CSS 提取在所有支持 SSR 的 CSS-in-JS 库中实现
  • 延迟加载样式随动态加载的组件一起出现。

基础知识

让我们想象一个支持典型 CSS-in-JS 功能的虚构库。我们要做的第一件事是导入样式 API,通常是一个名为css或styled函数:

import { css } from "css-in-js-library";

样式定义

对象语法
``` JavaScript
const title = css({
    fontSize: "2rem",
    color: DARK_BLUE,
});
```
</div>
<div>
标记模板语法

``` JavaScript
const title = css`
    font-size: 2rem;
    color: ${DARK_BLUE};
`;
```
</div>

大多数库更喜欢Object 语法,因为它的性能更高,因为标记模板需要从string到object额外解析步骤。然而,一些库(如Emotion 、 Goober 、 JSS或Compiled )非常灵活,支持这两种语法。

现在我们已经定义了样式,让我们探讨如何将它们应用到 HTML 元素。 CSS-in-JS 库通常支持三种不同的方法。

最直观和最流行的 API 返回一个唯一生成的string ,表示元素的 CSS 类名称。

const title = css(/* CSS rules */);
// 返回一个唯一字符串: "1dbj"

这种方法的主要好处是它类似于传统的造型方式。但是,与此同时,此方法与底层 JS 框架无关,可以与任何 JavaScript 框架一起使用,或使用普通的 DOM API:

使用 JSX 语法
``` JSX
export const Page = () => (
    <h1 className={title}>...</h1>
);
```
</div>
<div>
使用 DOM API

``` JavaScript
document.body.innerHTML = `
    h1 class="${title}">...</h1>
`;
```
</div>

第二种方法因styled-components库而流行,它也因此得名

const Title = styled("h1")(/* CSS rules */);

// 返回 new <Title /> component

结果,它将返回一个已应用 CSS 类的新组件。此方法适用于基于组件的方法,将元素的定义及其样式封装起来,并删除标准 CSS 所需的映射。

export const Page = () => (
  <Title>...</Title>
);

因此,最终结果将是相同的:

<h1 class="1dbj">...</h1>

支持Styled组件 API 的库包括styled-components 、 Emotion 、 JSS 、 Goober 、 Compiled和Stitches 。值得注意的是,这种方法主要用于基于 JSX 的框架。

第三种方法由Glamour引入并由Emotion推广,使用非标准属性(通常名为css )来指定元素的样式。

export const Page = () => (
  <h1 css={/* CSS rules */}>...</h1>
);

prop API 不太流行,支持的库较少,包括Emotion 、 styled-components 、 Compiled或Goober 。这种方法通常使用基于 JSX 的框架来实现,其中属性称为 props,因此得名该方法。

样式输出

浏览器如何处理用 JavaScript 编写的样式呢?

由于浏览器无法解析 JavaScript 文件中定义的 CSS,因此在浏览器中呈现样式的一种方法是在运行时注入它们。这种方法称为运行时样式表,它是现有库中最流行的一种。

额外的 JavaScript 代码(称为库运行时)必须捆绑并发送到浏览器才能正常工作。此代码会将所需的样式注入浏览器,并在用户事件触发样式更改时相应地更新它们。

runtime.png

运行时样式表方法并不是唯一的方法。有些库能够生成静态 CSS 文件,可以像任何常规样式表一样引用它们。此外,其他解决方案甚至可以生成Atomic CSS 。

静态 CSS 提取

像Astroturf 、 Linaria或vanilla-extract这样的库实现静态 CSS 文件提取,在构建时生成实际的.css文件,这些文件作为任何常规 CSS 样式表包含在我们的文档中。

static.png

该技术增加了零运行时间成本。因此,我们获得了 CSS-in-JS 在开发体验方面的所有优势,同时,与常规 CSS 类似,没有运行时成本。

原子 CSS

Fela 、 Compiled 、 Stitches和Stylex等一些库将 CSS-in-JS 提升到了另一个水平。他们没有生成包含所有已定义规则的 CSS 类,而是专注于生成独特的原子类。

<Image src={'/images/react/css-in-js/static.png'} width={1600} height={900} alt="atomic" />

atomic.png 现在,Atomic CSS-in-JS 的美妙之处在于我们不必学习特定 Atomic CSS 框架的特定类名集。相反,我们像通常使用 CSS-in-JS 那样编写样式。

以上内容大部分引用自 andreipfeiffer.dev/blog 的博客

antd5

详细的介绍可以看ant-design.antgroup.com/docs/blog/c…

里面提到我们通过独特的CSS-in-JS方案,Ant Design 获得了相较于其他 CSS-in-JS 库更高的性能, 但代价则是牺牲了其在应用中自由使用的灵活性。

频繁的计算 hash会很耗性能,antd通过简化hash的算法和缓存来提高性能

  • 计算hash: 对所有的 antd 组件应用相同的 hash, 对当前的版本和主题变量进行 hash 计算, 可以不需要频繁的计算
  • 组件缓存: hash 相同的情况下,同一个组件无论使用了多少次、渲染了多少次,样式永远只会在第一次 mount 时生成一次,剩下的时间里都会命中缓存,这便是『组件级』CSS-in-JS 方案的第二重保险

局限

  • 由于特殊的 hash 计算方法和组件缓存,在套用 antd 的 CSS-in-JS 方案时,开发者必须自己提供稳定的 hash 和独特的组件名
  • 像 css module 这样的自动 hash 的能力反而是更为需要的

以Button组件为例子

组件里引入样式

import useStyle from './style';

const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);

const classes = classNames(
    prefixCls,
    hashId,
    cssVarCls,
)

return wrapCSSVar(
    <a
        className={classNames(classes, {
            [`${prefixCls}-disabled`]: mergedDisabled,
        })}
    >
        {iconNode}
        {kids}
    </a>,
);

样式文件(4之前是xxx.less)变成了 style.ts

export default genStyleHooks(
  'Button',
  (token) => {
    const buttonToken = prepareToken(token); // 合并后的token, 了解一下antd的token定义

    return [
      // Shared
      genSharedButtonStyle(buttonToken),
      // ...
    ];
  },
  prepareComponentToken,
  {
    // ...
  },
)

可以看到prefixCls和之前的版本一致,多了hashId来区分。

hashId是通过@ant-design/cssinjs计算出来,样式也是通过@ant-design/cssinjs生成插入到style里。所以接下来看看hashId是怎么生成的,还有就是样式是如何插入到<style/>标签里的

@ant-design/cssinjs

简介:

通过上下文来管理样式,它的主要作用

  • 样式缓存共享
  • 动态样式转换
  • 样式隔离
  • 服务端渲染支持

StyleContext.tsx 是 @ant-design/cssinjs 的核心模块之一,主要负责管理样式的上下文。这种上下文机制为动态样式的生成、缓存和注入提供了统一的管理方式。

github.com/ant-design/…

antd5是通过useStyleRegister来简介调用上下文的共享变量,而Provider没有直接使用StyleProvider,使用自己的ConfigProvider

github.com/ant-design/…

useStyleRegister 在内部通过 StyleContext 共享样式缓存和配置信息。

cache对象:github.com/ant-design/…

hashId

从使用的函数入手,略过寻找的一些代码,找到hashId的生成


import hash from '@emotion/hash';
// ...

export default function useCacheToken(
  theme: Theme<any, any>,
  tokens: Partial<DesignToken>[],
  option: Option<DerivativeToken, DesignToken> = {},
) {
  const {
    cache: { instanceId },
    container,
  } = useContext(StyleContext);
  const {
    salt = '',
    override = EMPTY_OVERRIDE,
    formatToken,
    getComputedToken: compute,
    cssVar,
  } = option;

  // Basic - We do basic cache here
  const mergedToken = memoResult(() => Object.assign({}, ...tokens), tokens);
  const tokenStr = flattenToken(mergedToken);
  const overrideTokenStr = flattenToken(override);
  const cssVarStr = cssVar ? flattenToken(cssVar) : '';

  const cachedToken = useGlobalCache(
    TOKEN_PREFIX,
    [salt, theme.id, tokenStr, overrideTokenStr, cssVarStr],
    () => {
      let mergedDerivativeToken = compute //...
      const tokenKey = token2key(mergedDerivativeToken, salt);

      const hashId = `${hashPrefix}-${hash(tokenKey)}`;
      mergedDerivativeToken._hashId = hashId; // Not used

      return [
        mergedDerivativeToken,
        hashId,
        actualToken,
        cssVarsStr,
        cssVar?.key || '',
      ];
    },
  );

  return cachedToken;
}

可以看到const hashId = ${hashPrefix}-${hash(tokenKey)};是通过@emotion/hash里传入token来生成的,这个@emotion/hash咱们问下AI

@emotion/hash 是一个轻量级的 JavaScript 库,用于生成字符串的哈希值,常用于 CSS-in-JS 库(如 Emotion)为动态生成的样式创建唯一标识符。它基于 MurmurHash2 算法,提供了性能优异、冲突率低的哈希计算方式。

MurmurHash 是一个非加密型哈希函数,广泛用于分布式系统和哈希表,因为:

  • 它速度快(适合高性能场景)。
  • 冲突率低(适合需要唯一标识符的场景)。
  • 简单易实现。

MurmurHash2 的特点:

  • 输入是一串字符(string)。
  • 输出是一个 32 位整数。
  • 基于位操作和数学运算(如位移、异或)来生成散列值。

hashId和样式

组件里写的样式


const genSharedButtonStyle: GenerateStyle<ButtonToken, CSSObject> = (token): CSSObject => {
  const { componentCls, iconCls, fontWeight } = token;
  return {
    [componentCls]: {
      outline: 'none',
      position: 'relative',
      display: 'inline-flex',
      gap: token.marginXS,
    },
  };
};

export default genStyleHooks(
  'Button',
  (token) => {
    const buttonToken = prepareToken(token); // 合并后的token, 了解一下antd的token定义

    return [
      // Shared
      genSharedButtonStyle(buttonToken),
      // ...
    ];
  },
  prepareComponentToken,
  {
    // ...
  },
)

在antd/cssinjs会被执行

styleFn(mergedToken, {
    hashId,
    prefixCls,
    rootPrefixCls,
    iconPrefixCls,
})

注入style


// 注入 hash 值
function injectSelectorHash(
  key: string,
  hashId: string,
  hashPriority?: HashPriority,
) {
  if (!hashId) {
    return key;
  }

  const hashClassName = `.${hashId}`;
  const hashSelector =
    hashPriority === 'low' ? `:where(${hashClassName})` : hashClassName;

  // 注入 hashId
  const keys = key.split(',').map((k) => {
    const fullPath = k.trim().split(/\s+/);

    // 如果 Selector 第一个是 HTML Element,那我们就插到它的后面。反之,就插到最前面。
    let firstPath = fullPath[0] || '';
    const htmlElement = firstPath.match(/^\w+/)?.[0] || '';

    firstPath = `${htmlElement}${hashSelector}${firstPath.slice(
      htmlElement.length,
    )}`;

    return [firstPath, ...fullPath.slice(1)].join(' ');
  });
  return keys.join(',');
}

// Generate style
const styleObj = styleFn();
const [parsedStyle, effectStyle] = parseStyle(styleObj, {
    hashId,
    hashPriority,
    layer: enableLayer ? layer : undefined,
    path: path.join('-'),
    transformers,
    linters,
});

const styleStr = normalizeStyle(parsedStyle);
const styleId = uniqueHash(fullPath, styleStr);

// ...
// Inject style
const style = updateCSS(styleStr, styleId, mergedCSSConfig);

(style as any)[CSS_IN_JS_INSTANCE] = cache.instanceId;

// Used for `useCacheToken` to remove on batch when token removed
style.setAttribute(ATTR_TOKEN, tokenKey);

// Debug usage. Dev only
if (process.env.NODE_ENV !== 'production') {
style.setAttribute(ATTR_CACHE_PATH, fullPath.join('|'));
}

大概类似我们写了这样的样式

const styles = {
  '.my-class': {
    color: 'red',
    '&:hover': {
      color: 'blue',
    },
  },
};

会被编译成

.my-class.hashId {
  color: red;
}
.my-class.hashId:hover {
  color: blue;
}

我们通过useStyle拿到hashId,插入我们的组件,css-in-js会把这个hashId结合的样式注入到style里。

这样管理好hashId,通过缓存机制可以减少计算,具体的细节大家可以看看代码一起讨论。

wrapCSSVar

最后提一下wrapCSSVar

  • 将特定的 CSS 属性值或表达式包装为变量(CSS variable)。
  • 管理样式与动态 token(主题变量)之间的映射。
  • 处理一些特殊标记,如 _skip_check__multi_value_ 等,用于灵活控制 CSS 属性的解析和组合。

总结

了解了antd5的样式主要是为业务代码的样式更好的和组件样式结合起来,antd4的时候我们通过less变量来做到网站的样式和antd的样式一致

在我们切换antd5的时候,遇到了一些问题,less变量没有了,虽然antd提供了降级的方案,但是我们在写less文件的时候还是觉得结合的不够紧密

通过上面的了解,我们可以通过寻找类似wrapCSSVar的方法来处理我们的样式变量,达到和组件库一致,或者写useStyle来写样式,后者我们实践过,但是需要一些约束,比如组件名不能一样

后期考虑wrapCSSVar这种来处理,来替代我们手动加入的一些变量