Skip to content

APlus-UI中的cssinjs方案介绍

APlus-UI中的样式系统在v6版本以前进行了四次较大的迭代,

  1. 将样式直接写到组件目录中并显式的导入到组件中
  2. 将样式和组件独立成两个包单独维护,使用时需要全量导入样式包
  3. 样式仍然在一个单独的目录中,但将样式构建到组件包中,并且通过vite插件实现了样式的按需加载
  4. 将样式中的部分属性抽离成css变量,并人工的划分为基础变量/组件变量等,通过修改这些变量来更改主题

上述样式系统的迭代组件让APlus-UI拥有了自定义主题和按需加载的能力,但仍然遗留了两个痛点:

  • 样式按需加载配置复杂,容易遗漏,且包含众多模板代码
  • 虽然实现了多主题,但无法实现动态主题

CSS-in-JS能够很好的解决问题,

  • 样式和组件逻辑编写在一起,避免了样式污染,在运行/构建时生成唯一的className,避免了样式污染
  • 可以利用JS的计算能力,编写动态样式
  • 运行时生成样式,天然按需加载

APlus-UI最新版本(v7)中,样式系统使用了基于emotion实现的cssinjs方案,以下将会介绍实现细节。

提示

为了更好的本篇剩余内容,需要前置了解cssinjs的样式组织理念以及emotion库。

Design Token

Design Token设计令牌就是将部分可复用的样式值抽离出来,变成标准化的数据单元,在样式系统中,我们编写如下样式:

css
.action-item {
  background-color: #1890ff;
  border-radius: 4px;
}

如果UI改版需要增加暗黑主题或是统一调整UI风格为棱角分明,上述样式改动便会非常痛苦,

因此我们将以上两个值抽象成为Design Token

json
{
  "controlledBg": "#1890ff",
  "borderRadiusBase": "4px"
}

然后再通过某些方式,让我们最终的样式变为:

css
.action-item {
  background-color: var(--controlled-bg),
  border-radius: var(--border-radius-base)
}

现在再应对修改主题/切换UI风格的需求时,就从海量的样式修改变为Design Token的修改。

APlus-UI中,我们将Design Token分为了通用/组件两种,前者可以在所有组件中被访问,后者只能在组件内部被访问,在src/design-token/interface.ts中可以查看所有的token的类型定义。

编写样式时使用Design Token

使用emotion时,我们的样式必须为CSSInterpolation类型,因此最简单使用token的样式如下:

ts
import token from 'somewhere/token.ts';
const style: CSSInterpolation = {
  backGroundColor: token.controlledBg,
  borderRadius: token.borderRadiusBase
}

然后在组件中使用

vue
<template>
  <div :class="rootCls" />
</template>
<script setup lang="ts">
import { css } from '@emotion/css';
const rootCls = css(style);
</script>

在devtool里面查看元素,发现绑定了一个样式css-xxx的类,并且样式如下:

css
.css-xxx {
  background-color: #1890ff;
  border-radius: 4px;
}

现在修改token的值,刷新页面,再次查看元素,发现元素绑定的类名和样式都发生了变化。

Design Token转化为CSS变量

上例中,我们最终生成的样式中值是固定的,这意味着如果需要切换主题,需要重新生成样式,这将会造成较大的性能开销;如果将值作为CSS变量,切换主题时,无需重新生成组件样式,更新CSS变量即可,这样的开销明显更小。

现在来编写一个函数,将token转换为CSS变量的对象:

ts
function generateCssVar(token: any, option?: GeneraterOption) {
  const skipUnit = [...defaultSkipUnit, ...(option?.skipUnit || [])];
  return Object.entries(token).reduce((prev, [key, val]) => {
    const finalVal =
      typeof val === 'number' && !skipUnit.includes(key) ? `${val}px` : val;
    prev[camelToKebab(key)] = finalVal;
    return prev;
  }, {} as Recordable);
}

在挂载组件样式时,需要同时将生成的CSS变量也一同挂载上去。

接下来需要做的就是将样式中的token值切换为CSS变量,同样需要一个函数来将token中的key按照一定的规则转换为CSS变量名:

ts
function toCSSVarTokens<T extends object>(token: T): Record<keyof T, string> {
  const result = {} as Record<keyof T, string>;
  for (const key in token) {
    result[key] = `var(${camelToKebab(key)})`;
  }
  return result;
}

将token转换完成后,同时需要修改样式,使其使用转换后的token(也称cssVarToken)。

ts
import token from 'somewhere/token.ts';

const cssVarToken = toCSSVarTokens(token);
const style: CSSInterpolation = {
  backGroundColor: cssVarToken.controlledBg,
  borderRadius: cssVarToken.borderRadiusBase
}

完成这一切后,重新检查元素样式,发现样式中的值已经被替换为了CSS变量。

确定Design Token生效的范围

在APlus-UI中,我们将token简单的分为CommonToken/ComponentToken,公共token可以被所有组件访问,而组件token只能被组件内部访问,组件之间也无法互相访问。为了实现这样的效果,我们需要生成唯一公共token类,并且为每个组件都生成token类,最终元素上绑定的类可能如下:

html
<div class="component-class css-xxx1 css-xxx2 " />

也就是说,每个组件至少绑定两个hash类,其中一个包含了通用CSS变量,另一个包含了组件CSS变量和样式,这样元素上就多出了两个意义不明确的hash类,是否有优化的空间待定。

确定组件样式生效的范围

组件样式生效的范围有两种,组件内生效/全局生效。如果是组件内生效,在使用emotion方案时,会默认生成一个hash类,这个hash类的层级高于所有组件样式,例如有如下样式:

js
const genXXComponentStyle: GenStyleFunc = token => ({
  [token.componentCls]: {
    color: token.colorPrimary
  }
})

const hashId = css(genXXComponentStyle(token)) // css-xxx

最终生成的样式为:

css
.css-xxx .component-name { color: var(--color-primary) }

这和元素上绑定的类css-xxx component-name明显不符,样式不生效!

为了解决这个问题,我们可以开发一个额外的插件来处理规则,比如处理成.css-xxx.component-name {},这样样式就生效了,但是选择器优先级比较高;也可以通过自定义插件将规则处理成:where(.css-xxx).component-name,不但样式可以生效,优先级也没有升高,APlus-Antdv使用的就是这个方案。

全局生效更为简单,使用injectGlobal将组件样式挂载到全局,没有额外的hash类,不需要额外处理,缺点就是可能会有样式污染的风险。

最终APlus-UI采用的是全局样式的方案,组件的样式一般是固定的,放在全局并无不妥,生成的样式规则更自然,全局样式的缺点可以通过特定的命名空间来解决,减少预期外的样式污染发生的概率。

额外的优化

我们将token分为通用token/组件token并分别挂载到不同的hash类下,由于组件样式已经被挂载到了全局,组件hash类下只有组件自身的CSS变量,一个额外的hash类就显得没那么必要,如果能将CSS变量挂载到通用token的hash类的规则下,就可以安全的删除组件hash类了。

优化前:

css
.css-xxx-common {
  --color-primary: red;
}

.css-xxx1-component-a {
  --component-font-size-base: 12px;
}

优化后:

css
.css-xxx-common {
  --color-primary: red;
}

.css-xxx-common.component-a {
  --component-font-size-base: 12px;
}

优化后的样式中,组件CSS变量被挂载到了包含通用hash类的规则下面,现在可以安全的移除组件hash类了。唯一需要注意的是,组件的元素上需要同时挂载.css-xxx-common.component-a

为了实现上述效果,需要自定义一个stylis插件,在处理规则时,将默认生成的hash类移除并生成.common-xxx.comonent-name这样的规则来替换即可。

在Vue中实现动态主题

为了实现动态主题,我们需要将所有的Design Token支持通过ConfigProvider传入,

  • 通用token:在检测到通用token变更后重新挂载CSS变量
  • 组件样式:组件加载时通过injectGlobal函数挂载到全局,并且当namespace变更后重新挂载(开销大,不建议动态修改namespace)
  • 组件token:在通用token类或组件token变化后重新挂载

得益于emotion高效的缓存策略,即使在组件渲染时挂载组件样式,相同的样式也不会多次挂载,不会导致样式膨胀。

总结

在 APlus-UI v7 中,我们的样式系统从最初的“组件内写死样式”一路演进到基于 cssinjs 的完整方案,逐步解决了按需加载、多主题、动态主题等痛点。

通过 Design Token 的引入与分类(通用 Token / 组件 Token),我们实现了样式值的标准化与复用,使主题切换从修改大量样式文件转变为只需调整 Token,大幅提升了可维护性与灵活性。同时,借助 CSS 变量,主题切换不再需要重新生成样式,性能与用户体验得到明显优化。

在作用域的处理上,我们权衡了“组件内样式隔离”与“全局样式自然性”的取舍,最终选择了基于 全局样式挂载 + 命名空间约束 的方式,既保证了样式规则的直观性,也有效降低了意外污染的风险。

针对实现过程中的冗余问题,我们通过 stylis 插件优化,将组件 Token 的变量合并到通用 Token 的规则下,成功减少了额外的 hash 类,从而让最终生成的 DOM 和 CSS 更加简洁可控。

最后,在 Vue 的实现层面,我们结合 ConfigProvider 提供动态注入能力,确保了通用 Token 与组件 Token 的实时更新,同时利用 emotion 的缓存机制避免了重复挂载和样式膨胀,保证了动态主题切换的高效与稳定。

整体而言,v7 的样式系统兼顾了 灵活性、性能、可维护性:

  • 灵活性:支持 Design Token、自定义主题和动态切换;

  • 性能:运行时按需加载,CSS 变量降低切换成本;

  • 可维护性:样式与逻辑统一,规则简化,命名空间减少冲突。

这使得 APlus-UI 在保持易用性的同时,也为未来的主题扩展和跨项目复用提供了坚实的基础。

以上🫡

Last updated: