文/游鹿
作为阿里内部存在了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。
如果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个空格,否则无效(为保持统一建议/ *也加)
-
calc()前不允许有负号,需要通过`0px -` 来解决
-
calc()内若有具体值,需写清楚单位例如px,不写单位在大多数css属性里是不合法的(line-height除外)
// right -(b) => calc( 0px - #{a} - #{b}) // wrong -(b) => -calc(#{a} + #{b}) -(b) => -calc(#{a}+#{b})
// right a + 2 => calc(#{a} + 2px) // wrong a + 2 => calc(#{a} + 2) a + 2 => calc(#{a} +2)
// right -(b * 2) => -calc(#{a} + #{b} * 2) // 推荐 -(b * 2) => -calc(#{a} + #{b}*2) // 不报错,但不推荐
// right a} - 50%) // 推荐 a} + -50%) // 这里列举出来仅为告诉读者,这个是正确写法,不会无效,fusion代码里采用上一行的写法
// right (b - 2)/2 => calc((#{a} + #{b} - 2px) / 2) // 推荐 (b - 2)/2 => calc((#{a} + #{b} - 2px)/2) // 不报错,但不推荐 // wrong (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版本里增加这一能力?
-
本次改动属于能力新增,没有BreakChange
-
吸取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🏆