新发布!Fusion Next支持使用Css Variable了!

avatar
大前端 @阿里巴巴

文/游鹿

作为阿里内部存在了4年的前端基础组件库,Fusion Next老树开新花在1.20版本支持了css-variable能力,支持运行时换肤。本文旨在介绍Fusion Next为什么要支持这一能力,它是怎么做的,有哪些难点,如何把这一能力应用到业务项目中。

不得不提,本次FusionNext支持Css Variable的升级中,阿里云控制台团队**@萧雨**@流司 、阿里云混合云体验技术**@佐七** 全程参与贡献,深度参与并支持到了核心能力的调研与建设,真的是相当靠谱,点赞👍

一、为什么要支持?🤔️

css-variable已经不是新技术了,各大浏览器厂商,甚至是IE Edge也早在2017年3月支持了这一功能。

Fusion服务了阿里集团的绝大部分BU,其实从2019年开始,已经陆续收到用户诉求,希望支持css变量,以达到用户在线主题切换的诉求。我们当时给的方案都是,编译多份css文件,在线加载、卸载样式来实现。既然已经有解决方案了,那是什么样的契机让我们决定在2020的S1落地这件事呢?

1.1 跨端技术栈统一

随着移动业务的发展,有越来越多的中后台业务要求既可以在PC上办公,也可以在H5上使用。前端委员会也成立跨端方向,支持一码跨多端。这里的跨端包括跨PC、H5、各种小程序可用。

实现上分别使用@alifd/next (基于react)@alifd/meet(基于rax)来分别满足PC和Mobile(其中@alifd/meet会通过rax2react技术转为@alifd/meet-react 这份基于react的实现,再与PC做融合)。一码多端,在开发的时候不可避免地会用到样式变量,因此样式方案需要统一

小程序是基于rax的,受rax底层能力限制,样式都是直接写到style上的(不支持className),所以Fusion Mobile样式方案选择面很窄,只能使用css-variable方案,也就是说在Fusion Mobile端,所有变量都是css变量。所以统一跨端项目的技术栈为react+css variable

1.2 sass编译慢,影响开发效率

Fusion样式体系绑定了sass方案,Sass编译样式属于编译时方案,通过webpack等插件将sass代码转为css,项目工程特别复杂时,编译很慢,影响开发效率。从来自《icejs 工程构建的优化探索》 的数据可以看出,FusionDesignPro-TS在开发环境,初次编译样式上的耗时为48.3s,构建生产环境资源,样式的编译耗时为41.1s。

image.png

如果Fusion采用css-variable方案,相当于所有文件都是最终文件,不需要进行sass编译,可以节省出sass-loader其中一部分的时间。

1.3 在线换肤

「在线换肤」已经逐渐成为中后台业务的强诉求,这里我们可以将在线换肤分为两类:

  • 场景性的切换,比如亮色主题 <=> 暗色主题、业务1主题 <=> 业务2主题
  • 轻量&个性化定制,比如更改主题色,圆角 <=> 直角

对于第一类,我们可以通过作用域来整套切换 css 变量,对于第二类,我们只需覆盖主题色相关变量或者圆角变量即可满足。

可见,相比于之前的每个主题都需要生成主题包样式文件来替换,通过 css 变量来切换主题更加灵活&优雅。

另外,收到来自阿里云控制台中台团队的需求。由于业务需要,设计师每年升级一版样式,xconsole虽然支撑了控制台业务,但大部分具体控制台并不直接由他们开发的,所以推动业务进行升级样式比较困难。

如果改成css-var方案,相当于把样式变量的注入从sass的编译时变到了css-variable的运行时,这样不需要业务的升级,只需要在容器环境下处理一次,就能实现样式更新的目的

(此外,另外关于css-variable的兼容性问题可以在第四章节查看)

二、解决方案💻

2.1 前期调研

在新功能支持前必须做好调研,以减少具体执行过程中的资源浪费,本次功能迭代的要求是:**必须在支持新css变量功能的同时,尽量减少结构的变动、减少代码的修改。**按照执行顺序也就是:

  • 从最小改动出发,确定整体改造思路;
  • 从用户使用出发,确定组件库包结构;
  • 从技术角度出发,调研sass变量转为css变量的技术点。

2.1.1 整体改造思路

FusionNext的开发用到了scss变量,包括「基础变量」「组件变量」两类(其中基础变量是推荐用户使用到业务组件、页面当中的,组件变量不推荐用户使用)。

这些变量加起来有几百条,一一修改不现实,所以我们决定:基础组件库的开发仍然采用scss变量方案,只是在打包阶段,通过脚本产生一份sass变量到css变量的映射,用这份映射与代码进行编译,产出带css变量的新文件们。也就是说css变量与sass变量的变量名差异在于把 $ 替换成了 --

// 基础变量
$color-brand1-6: #5584FF !default;
$size-base: 4px !default;
$corner-1: 3px !default;
$line-zero: 0px !default;

// 组件变量
$btn-size-s-height: $s-5 !default;
$btn-size-s-padding: $s-2 !default;
$btn-size-s-corner: $corner-1 !default;
$btn-pure-secondary-color: $color-brand1-6 !default;

当然在方案落实中我们遇到了各种各样的问题,这个会在「sass变量转为css变量技术突破点」这一章节重点讲述。

2.1.2 组件库包结构

本着只新增不修改或少修改的原则,从样式角度出发,要支持用户「全量使用样式」、「按需引用样式」,同时不影响现有用户,所以在npm结构上主要体现为:

  • dist/ 下增加next.var.css文件打包所有样式;
  • lib/[component] es/[component] 目录下新增了style2.js这一带css变量的样式,用户可以选择引用;

最终npm结构如下:

├── dist
│   ├── next.var.css        # [新增]带 css 变量的全部组件样式,不包含 css-var 变量定义
│   └── next.var.min.css    # [新增]压缩版 next.var.css
├── lib
│   ├── component1
│   │   └── scss
│   │   │   └── variable.scss # 对于【本组件专用的样式变量】,将scss-var映射到实际值(主题可以覆盖它)。这个是过去fusion就有的文件
│   │   ├── index.jsx         # 组件入口
│   │   ├── main.scss         # 主要的sass样式代码,其中总是使用scss-var。同时支持【scss-var被映射到实际值】和【scss-var被映射到css-var】。
│   │   ├── style.js          # 兼容sass体系的按需加载
│   │   ├── style2.js               # [新增][自动生成] 支持css-variable的按需加载,被babel-plugin-import引入
│   │   ├── variable.css        # [新增][自动生成] 当前组件的css-var值
│   │   └── index.css           # [新增][自动生成] main.scss 自动生成带 css-variable 的样式代码,样式体
│   └── core2                               # [新增][自动生成] 与core相同,只不过从scss变量都改为css变量
│       ├── ...                             
│       └── style                           
│         └── _color.css            # [新增][自动生成] 都改为css 格式
├── es/                                             # 同 lib 
├── types/                                      # 无变化
└── package.json                            # 无变化

2.1.3 sass变量转为css变量技术突破点 ✨@萧雨

这里列举前期调研出来的技术难点及方案,方案及落地的核心贡献者为@萧雨

sass变量可/不可转为css变量的情况

我们所熟知的“CSS 变量”,其实是“CSS 自定义属性”(CSS custom properties),也叫做及联变量,它不能被用到“CSS选择器”中。因此作为类名存在的 $css-prefix 不能被转为CSS变量,这类变量在stylesheet中需要转为具体值,例如 .next- 等。同理还有 $font-custom-path 等。

// variables.scss
$css-prefix: '.next-';
$btn-pure-secondary-color: $color-brand1-6 !default;

// main.scss
#{$css-prefix}btn {
  background-color: $btn-pure-secondary-color;
}

涉及sass数学计算 如何处理

sass的数学计算,通过 calc 转为css计算,可以解决80%的问题,其中有部分坑点,可以在 「2.2.2 修改部分原有样式代码 · calc数学计算注意规则」 查看。

涉及sass颜色计算 如何处理

css没有对应的颜色计算处理函数,而Fusion Next中用到的颜色计算多为 rgba transparentize 等,而且比较少在10个左右,所以对于用到scss颜色计算的部分,直接算出最终值

这里需要注意,css的rgba()函数,与sass的rgba()函数不完全相同,例如rgba(#FFF, 0.5)

sass可以转为rgba(255, 255, 255, 0.5),而css上是无效的

$btn-ghost-dark-color-disabled: rgba($btn-ghost-dark-color-disabled-rgb, $btn-ghost-dark-color-disabled-opacity);
=>
--btn-ghost-dark-color-disabled: rgba(255, 255, 255, 0.4);

涉及sass流程控制语句 如何处理

css-var不能参与任何sass的【编译时计算】过程,因为它的数据是运行时才能确定的。流程控制语句也属于【编译时计算】。

好在fusion源码中违背这个原则的地方非常少,最主要的是 @mixin icon-size 。其他大部分@if语句都不涉及css-var,因此不违背这个原则。

问题

以下代码如果不加改造,直接在编译把$shadow替换为var(--shadow)(一个sass字符串值), @if $shadow == $shadow-zero 做的是字符串比较,完全没有意义,并且会静默地执行这种错误的行为。

@if $shadow == $shadow-zero {
  $shadow-top: null;
  $shadow-right: null;
  $shadow-bottom: null;
  $shadow-left: null;
}
方案

解决这个问题的主要难点在于,这类包含流程控制的mixin很难用原生css实现。

由于fusion源码中,涉及css-var的流程控制语句非常少,因此我们可以先放弃这类样式基于css-var的动态切换能力

也就是说,这类@if语句是在主题包scss构建的时候就展开了,产生了对应的css rule。热切换css-var的时候,这些css rule不会切换。如果由于样式变量的改变,@if语句的展开行为需要改变,则用户项目需要重新构建发布。

在fusion中增加一个工具函数:

@use "sass:list";
@use "sass:meta";
@use "sass:map";
@use "sass:string";

/* 在编译时解析样式变量的值 */
@function get-compiling-value($var) {
  @if(type-of($var) == 'number') {
    @return $var;
  }
  @return map.get($var-map, $var);
}

其中,$var-map包含了在编译scss时确定的样式变量值:

/* sass支持map数据结构,我们用它来存储【在编译时知道的】样式变量的值 */
$var-map: (
    var(--color-brand1-1): #F3FAFF,
    var(--checkbox-size): 14px,
)

sass将var(--color-brand1-1)这种东西视为普通字符串!

对【涉及css-var的流程控制语句】做如下改造:

@if ($size < 12) {} @else {}
// 改为
@if (get-compiling-value($size) < 12) {} @else {}

假设编译到这条@if语句的时候, $size 的值是var(--checkbox-size)(一个sass字符串值),那么 resolveBuildTimeValue($size) 返回 14px ,@if被展开为negative分支。

这个方案的改造量非常小。

缺点

这个展开行为在编译时就确定了,即使线上热切换了css-variable --checkbox-size: 8px ,依然是@if语句的negative分支在生效。但是我们认为目前可以接受这个不一致性,因为:

  • fusion中涉及css-var的流程控制语句非常少

  • css-var的热切换一般发生在同系列主题切换(比如dark/light mode)、主题升级上。对于icon-size这种mixin,@if语句在切换前后是走进同一个分支的

  • 最终的结果是热切换造成的不一致性几乎不可察觉

如果希望用最新的样式变量重新展开一次@if语句,重新构建发布应用即可。

2.2 动手改造FusionNext组件库 ✨@萧雨 ✨@佐七

这一部分占据了本次升级70%的工作量,为主要共建点, ✨@萧雨 ✨@佐七 都参与其中

回忆一下,我们的方案是:**基础组件库的开发仍然采用scss变量方案,只是在打包阶段,通过脚本产生一份sass变量到css变量的映射,用这份映射与代码进行编译,产出带css变量的新文件们。**所以第一步是解决「sass变量到css变量的过渡问题」,我们在上文也提到了,由于sass语法比css语法更全面(比如@if)更好用(比如四则运算等),所以在从大集合收拢到小集合的过程中,仍然需要「修改部分原有样式代码」。

2.2.1 解决sass变量到css变量的过渡问题

sass文件与css文件存在关联,我们决定采用「编译时增加临时文件」的方式记录这些关联。项目原有的文件有 variable.scss main.scss

/* main.scss 每个组件的样式 */
.box {
  background-color: $main-bg-color;
}

/* variable.scss 每个组件的样式 */
$main-bg-color: $color-brand1-6;

临时文件,将通过variable.scss生成以下两种:

  • scss-var-to-css-var.scss : 将scss-var映射到css-var,它实际上可以由./variable.scss自动生成

  • css-var-def-default.scss : 定义css-var的实际值(主题可以覆盖它),它实际上可以由./variable.scss自动生成

    /* scss-var-to-css-var.scss 读取variables.scss用脚本生成 */ $main-bg-color: var(--main-bg-color) !default;

    /* css-var-def-default.scss 读取variables.scss用脚本生成 */ --main-bg-color: var(--color-brand1-6);

最终编译带css变量的样式的入口文件变化为:

/* index.scss sass变量版本*/
@import "variables.scss"
@import "main.scss"
  
/* index.scss css变量版本*/
@import "scss-var-to-css-var.scss"
@import "css-var-def-default.scss"
@import "main.scss"

基础变量、组件变量的这些mapping分别放在 src/core-temp lib/[component]/sass/ 目录下,构建完成后删除,结构为

├── src
│   ├── core
│   │   └── style
│   │     └── _color.scss       
│   ├── core-temp                                               # 临时文件
│   │   └── style
│   │     ├── _color-to-css-var.scss        # [临时文件]【基础变量】,将scss-var映射到css-var。文件名待定。它实际上可以由../core/style/_color.scss自动生成
│   │     └── _color-def-default.css        # [临时文件]【基础变量】,定义css-var的实际值(主题可以覆盖它)。文件名待定。它实际上可以由../core/style/_color.scss自动生成
│   └── index.js                            
├── lib
│   └── component1
│       └── scss
│       │   ├── scss-var-to-css-var.scss    # [临时文件]【本组件专用的样式变量】,将scss-var映射到css-var。它实际上可以由./variable.scss自动生成
│       │   ├── css-var-def-default.scss    # [临时文件]【本组件专用的样式变量】,定义css-var的实际值(主题可以覆盖它)。它实际上可以由./variable.scss自动生成
│       │   └── variable.scss                       # 这个是过去fusion就有的文件
│       └── style.js            
├── test
├── types
└── package.json

2.2.2 修改部分原有样式代码

通过上述修改基本可以完成sass变量到css变量的转化,转化完后有些细(keng)节(dian)问题需要尤其注意,比如四则运算。sass中可以直接用+ - * / 而css中则需要用calc包裹起来。有同学可能会担心 calc 的兼容性如何,性能是否OK,这些问题我们也做了调研及处理:

calc性能及兼容✨@萧雨

这里使用calc不会有性能问题,因为在fusion改造中,它做的都是简单的计算(比如 24px / 2)。一般要在涉及百分比的时候 calc() 才会造成性能问题。对于这种简单计算, calc() 再怎么样也比那些css-in-js方案要快。

而且对于简单计算,calc(24px / 2),我们会使用 postcss-calc 插件将它变为 12px。

同时,calc兼容到ie9,FusionNext基本上没有在calc里使用百分比,对于calc兼容性的担心duck不必。

实际操作过程中我们发现,用 calc 替换sass数学计算,某些组件样式代码里存在编译失败的问题,我们Debug后发现是calc的使用需要注意以下几点:

calc细节问题

  1. + - 运算符前后需要保留1个空格,否则无效(为保持统一建议/ *也加)

  2. calc()前不允许有负号,需要通过`0px -` 来解决

  3. calc()内若有具体值,需写清楚单位例如px,不写单位在大多数css属性里是不合法的(line-height除外)

    // right -(a+a + b) => calc( 0px - #{a} - #{b}) // wrong -(a+a + b) => -calc(#{a} + #{b}) -(a+a + b) => -calc(#{a}+#{b})

    // right a + 2 => calc(#{a} + 2px) // wrong a + 2 => calc(#{a} + 2) a + 2 => calc(#{a} +2)

    // right -(a+a + b * 2) => -calc(#{a} + #{b} * 2) // 推荐 -(a+a + b * 2) => -calc(#{a} + #{b}*2) // 不报错,但不推荐

    // right a50a - 50% => calc(#{a} - 50%) // 推荐 a50a - 50% => calc(#{a} + -50%) // 这里列举出来仅为告诉读者,这个是正确写法,不会无效,fusion代码里采用上一行的写法

    // right (aa - b - 2)/2 => calc((#{a} + #{b} - 2px) / 2) // 推荐 (aa - b - 2)/2 => calc((#{a} + #{b} - 2px)/2) // 不报错,但不推荐 // wrong (aa - b - 2)/2 => calc((#{a}+ #{b}- 2px) / 2)

2.3 生成带css变量的主题包

为支持css变量版本的在线换肤,需要先通过主题包产出当前主题的css变量集合,好让用户使用,也就是@alifd/theme-xxx/variable.css,这个文件包含了「基础变量」「组件变量」。

若当前主题自定义添加了icon,则需要额外引入icons.var.css。

这里需要注意 主题包的 dist/next.var.css 与 基础组件的 dist/next.var.css 可能不完全相同。主题包的是基于设计师配置后的结果,重新编译的,比如 $font-custom-path 这类编译时确定的值不同,生成的next.var.css也不同。

最终主题包npm结构为:

.
├── dist
│   ├── next.var.css                        # [新增]使用了 css-variable 的全部组件样式 
│   └── next.var.min.css                # [新增]next.var.css的压缩版
├── icons.var.css                               # [新增]css-variable 格式的icons 类名集合
├── variables.css                                   # [新增]css-variable 格式的 varaibles 变量 保留对应关系
└── package.json

三、如何使用这一新能力 ✨@佐七

参考@佐七 提供的 css-var 项目 demo ,思路如下,在项目中引入1份基础样式文件,及多份变量声明文件:

/* App.css */
@import '~@alifd/next/dist/next.var.css';
@import '~@alifd/theme-3/variables.css';
@import '~@alifd/theme-1/variables.css';

.App {
  text-align: center;
}

.App-header {
  background-color: var(--color-fill1-2);
  transition: all .3s;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: var(--color-text1-4);
}

通过插件为 @alifd/theme-1/variables.css @alifd/theme-2/variables.css 修改样式作用域:

/* @alifd/theme-3/variables.css */
:root {
  --color-fill1-2: #474959;
}

/* 转化为=> */

html.dark {
  --color-fill1-2: #474959;
}

通过为body增加不同的className,达到切换主题的目的:

/* App.js */
import React, { useState } from 'react';
import { Switch } from '@alifd/next';
import './App.css';

function App() {
  const [content, setContent] = useState('light theme');
  const handleChange = checked => {
    document.documentElement.classList.toggle('dark');
    if (checked) {
      setContent('dark theme');
    } else {
      setContent('light theme');
    }
  }
  return (
    <div className="App">
      <header className="App-header">
        <Switch unCheckedChildren="🌞" checkedChildren="🌙" onChange={handleChange}/>
        <h1>{content}</h1>
      </header>
    </div>
  );
}

export default App;

四、其他问题

为什么不升级2.x,而是选择在1.x的minor版本里增加这一能力?

  1. 本次改动属于能力新增,没有BreakChange

  2. 吸取0.x升级的教训,升级2.x之前,一定会做好能力对齐,在1.x里做好对应warning处理

如何处理css-variable的兼容性问题

css变量算是比较新(?)的能力,IE浏览器目前完全不支持。考虑到Fusion是面向中后台的,大部分中后台业务对浏览器要求比较宽松,甚至到chrome就可以。同时技术能力要拥抱未来,不能为了适配而停滞不前,所以Fusion做了这一升级。

这并不意味着我们完全抛弃了IE用户,我们在编译出来的文件里设置了fallback方案,IE下可能不支持换肤,但是仍可以正常使用。

/* dist/next.var.css */
...
.box {
  /* for IE, 通过 postcss-custom-properties 插件添加 https://github.com/postcss/postcss-custom-properties */
  color: rgba(255, 255, 255, 0.4);
  color: var(--btn-ghost-dark-color-disabled, rgba(255, 255, 255, 0.4));
}
...

五、尾声

重要的事情不得不首尾呼应再说一遍,本次升级,阿里云控制台团队@萧雨@流司,阿里云混合云体验技术@佐七 等同学的强力参与,才让这一功能得以尽快落地,他们在支持自己业务的同时参与到共建中,效率高又靠谱。

尤其是@萧雨 同学,在共建中对整体方案制定代码细节落实等都有建设性贡献,作为ADC持续输出,堪称本次战斗MVP🏆

参考资料