携程商旅在 Atomic Css 下的探索

开发 前端
未来Css-In-Js 的 Atomic Css 解决方案无论是在业务代码还是基础 Components 中一定会是一个不错的方案。

作者简介

19组清风,携程资深前端开发工程师,负责商旅前端公共基础平台建设,关注NodeJs、研究效能领域。

一、引言

三年前 Facebook 开始思考在目前设计系统下面临的问题,那时它们在前端项目、系统组件等部分使用的是 cssmodule 的样式方案。

直至今日,Facebook 已经将所有的 Web 前端使用 React 进行重写的同时,也使用了一种新的 Atomic Css-in-JS 对于它们的 Css 方案进行了重写。

最近,Facebook 团队开源了他们内部的 Atomic Css 解决方案:stylex,正是这套解决方案让 Facebook 首页样式文件体积减少了至少 80%。

这篇文章中我们就着 Atomic Css 来聊聊 Facebook 最近刚好开源的 stylex。

二、Atomic Css

2.1 概念

Atomic Css 是一种通过为每个样式声明创建一个规则来减少定义规则总量的方法。

举一个不是那么恰当的例子,比如说你可以将 Atomic 理解为 a、b、c... 一个一个原子化的字母,而每一个元素最终生效的样式则是通过 a、b、c... 这样一个一个原子化的字母拼接而来。

假设我们想要得到一个长宽为百分百、背景色为红色的正方形,那么使用 Atomic Css 的方式来表示的话:

.w-full { width: 100%; };
.h-full { height: 100% };
.bg-red { backgroundColor: red }
<div class="w-full h-full bg-red">Square</div>

2.2 权衡

在Acss首次提出 Atomic Css 的实现方案后,之后有关于 Atomic Css 的相关讨论以及实践在前端社区内就如雨后春笋般四处开花。

无论是 Acss、Unocss、Tailwind 等等之类 Css 库,其实归根结底都是来源于同一个实现思路:Atmoic Css,那么 Atomic Css 究竟有什么好处呢?

接下来,聊聊我们关于 Atomic Css 的看法。

2.2.1 多 Npm Package 下的样式复杂性

我所在的团队日常除了常规的前端页面开发外,还负责了以下两个方面:

  • 日常项目中和业务强相关的偏业务性组件。
  • 日常项目中和业务无关性质的基础性组件。

无论是在业务还是基础组件的开发和维护上,如何缩减相关组件体积以至于可以和使用到该组件的不同业务团队最小化的结合一直是我们在寻求的目标。

举一个比较简单的例子,假设我们在开发一个 Button 组件的同时定义了一个 corp-button 的样式:

.corp-button {
    height: 100%;
    width:  100%;
}

此时当我们再次开发另一个 input 组件时,大多时候我们其实会经常用到 height:100%; width:100% 的样式,传统的 Scoped Css 解决方案下难免我们会重新定义一份样式声明:

.corp-input {
    height: 100%;
    width: 100%;
    font-weight: 500;
}

显而易见,往往在同一份组件库代码中不同的 class 定义存在无数重复的样式声明,无论是 CssModule 还是 Css-In-Js 都无法将这部分重复样式声明在构建/运行时删除掉。

上边的情况只是在单一存储库中很常见的问题,我们团队日常负责的 NpmPackage 远远不止一个,所以 Atomic Css 的概念可以帮助解决这个问题。

只要可以保证 Atomic 原子性,那么无论是存在多少 Package ,也可以在项目中最大程度的保证这份样式文件的复用。

举一个简单的例子,比如上述的 corp-button 使用 atomic css 的方案,可以拆分为更加原子化的 class 声明:

.w-full {
    width: 100%;
};
.h-full {
    height: 100%;
}


.corp-button => Compiled => .w-full .h-full

而在 input 组件中同样可以使用拆分后的 atomic:

.font-normal {
    font-weight: 500;
}


.corp-input => Compiled => .w-full .h-full .font-normal

这种情况下,Atomic Css 可以大大减少调用方在使用不同 Npm Package 下的样式文件体积,从而对于页面加载性能来说是一种极大的提升。

2.2.2 单一项目复杂度上升时的样式文件体积

往往大多数前端团队由于历史、规模原因,无法左右依赖组件方的技术架构方案。

与其息息相关更多的是随着频繁、快速的业务迭代,带来样式文件复杂度直线上升的问题。

那么怎么解释这里的样式文件复杂度直接上升的问题呢,我们来看一个稍微抽象的例子。

比如 A 同学在负责 ProjectA 项目,跟随着频繁的业务迭代难免一直会有新的页面、功能增加到现有的项目中。

那么,对于前端工程师来说,随着需求的频繁增加,难免需要增加很多个新的 class 来编写这部分新增的样式。

传统的 CssModule 以及 Css-In-Js 方案,可以让我们在 class 的声明上无需考虑新命名和旧命名重复的问题,但它仍无法解决随着新的需求到来,仍然会增加新的样式声明内容,从而带来更大的样式文件体积影响页面性能。

如果我们使用 Atomic 方案来处理 Css 文件的话,无论在多么频繁的需求迭代背景下,样式文件体积并不会跟随项目复杂度而直线上升,原子化的 Css 文件体积到达一个极限的拐点之后会渐渐趋于平稳。

上面的描述稍微有些抽象,但是并不难理解。就好比如果在项目中需要增加一个背景色为红色,宽高均为百分五十的 div 时,在之前的方案中我们会直接声明:

.new-demand-block {
    width: 50%;
    height: 50%;
    background: red;
}

最终构建之后的样式文件中,会加入 .new-demand-block 这部分的样式。

而如果使用 Aotmic Css 的方案,由于之前已经定义过 width:50%、height:50%、background:red 的 Atomic Class ,所以新的样式文件中并不会存在这些根据新需求而来的样式。

不过有些同学会疑惑,这不是会将样式文件体积转化到了 HTML 中去了吗?

实际的确是这样,但是这也仅仅是首屏 HTML 会携带这部分 Atomic ClassName。同时对于 HTML 模版中的相同 Atomic Css,Gzip 会帮我们把这部分重复的 ClassName 压缩到一个足够小的体积。

2.2.3 日常业务交付标准下的样式复用“错乱”性

同样,Atomic Css 方案还有一个和日常开发息息相关的影响点。

相信绝大多数开发同学都会碰到,伴随着新需求上线或者修复某些 Bug 的同时,突然发现影响了之前已经经过验证的页面样式。

这也是 Utility Css 会带来的问题,为了节省样式体积或是节约开发成本,我们往往会选择在项目中复用相同类型的样式。

但是随着项目日积月累,我们会面临修改这部分样式时带来的隐患:修改 A 模块的 class 样式内容,或许会影响到 B、C 等模块。

这无异对于开发还是测试来说都是一种灾难,Atomic Css 的出现可以很好地帮助我们解决这个问题。

每一处的元素都是由一个一个 Atomic 组成的样式,在编写新的 Css 声明时由于已经是 Atomic 方案,所以大可不必担心样式体积的冗余而抽离一些 Utility Css。

自然,当我们修改某一处样式文件内容时,也完全无需担心会影响到别的地方,因为每次我们修改的并不是 class 代表的意义,而是使用一个一个 Atomic Class 来拼装获得当前元素最终的样式。

当前,如果你能保证你团队的样式系统是百分百的标准,以及 Utility 的声明非常规范化,Atomic Css 在这个问题下的解决方案就稍微显得有些牵强。不过在我看来,绝大多数业务项目由于客观原因,是无法和组件库之类的对齐做到百分百的样式系统规范化。

2.3 成果 

样式文件体积过大,⼀直是携程商旅在性能上存在的痛点,我们也在积极探索通过 Atomic 的方式寻找更好的用户体验。目前我们在国际站 Trip.Biz 已经从 CssModule 的方案全量切入 Atomic 方案,在样式文件体积上取得了指数级变化的成果。 

比如同样为 App 端首页,在采用 Atomic 方案后的国际站首页对比 CssModule 方案的国内站首页,相似的页面样式下,国际站首页在首屏渲染时仅需要加载 13.2KB 样式文件,而国内 App 端首页则需要加载 694KB 样式文件, 前后对比首屏需要加载的样式文件体积足足相差 96% 。 

在国内站的改版页面中,同样也取得了显著的成果。比如在商旅 PC新版大首页中,前后同样一个查询框业务组件,在使用了 Atomic 方案之后,新版首页中查询框组件所需要的样式规则完全可以被项目覆盖,最终单个查询框组件跟随页面编译后的样式文件体积可以趋近于0。

三、Stylex

3.1 开始之前

Atmoic Css 在 stylex 出现之前也有许多优秀的解决方案,比如 Tailwind、WindCss、UnoCss 等。

我们团队目前在使用的也并非 stylex 而是 Tailwind ,这篇文章更多是和大家介绍 Stylex 的用法以及我个人对于 stylex 的一些见解。

我们完全不用片面的认为 Atomic Css 就一定是 Tailwind 或者 Stylex 之类的某种实现框架。

无论 tailwind 还是 stylex ,他们都是 Atomic Css 方案的不同实现方案而已,至于应该选择哪一种框架来实现 Atomic Css ,更多还是根据大家各自团队中的实际情况来见仁见智。

究竟是 Utility 方式的 Tailwind 还是 Css-In-Js 方式的 Stylex ,哪一种更优秀,这篇文章中并不会讨论。讨论这些,就好比我在告诉你应该使用 Vue 还是 React 来写前端一样。

3.2 简介

stylex是 Facebook 最近开源的一套 Css-In-Js 的 Atomic Css 解决方案。

Stylex 的工作原理是通过 Babel 在编译阶段将编写的 Css-In-JS 代码生成一个一个 Atomic Css 样式,为输出的元素增加这些 classname 的同时最终输出在样式文件中。虽然写法上和 Css-In-Js 类似,但是 stylex 几乎没有任何运行时的成本。

同时对于需要结合不同变量增加不同样式的运行时场景,Stylex 会在必要时根据不同条件来快速的生成组件的类名字符串,添加到对应元素中。

3.3 stylex.create/stylex.props

我们可以通过 stylex.create 方法创建 Atomic 样式内容,从而使用 stylex.props 将 stylex.create 方法生成的 Atomic Css 应用到元素上。

比如:

import * as stylex from '@stylexjs/stylex';


// stylex.create 创建样式内容
const styles = stylex.create({
  root: {
    backgroundColor: 'red',
    padding: '1rem',
    paddingInlineStart: '2rem'
  },
  title: {
    backgroundColor: 'blue'
  },
  dynamic: (opacity) => ({
    opacity
  })
});


function HomePage() {
  return (
    // stylex.props 应用创建的样式内容到元素上
    <div {...stylex.props(styles.root)}>
      <h2 {...stylex.props(styles.title)}>Stylex</h2>
      <p {...stylex.props(styles.dynamic(0.2))}>
        Introduction to the basics of stylex.
      </p>
    </div>
  );
}


export default HomePage;

上边的代码经过编译后的 Css 样式文件输出如下:

.x1uz70x1:not(#\#){padding:1rem}
.x1t391ir:not(#\#):not(#\#){background-color:blue}
.xrkmrrc:not(#\#):not(#\#){background-color:red}
.x1u4uod0:not(#\#):not(#\#){opacity:var(--opacity,revert)}
.xld8u84:not(#\#):not(#\#){padding-inline-start:2rem}

我们可以看到对于 stylex.create 创建的样式内容,均被编译成为了一个一个 Atomic Css 的 classname。

同时对于页面上的元素,在经过 stylex 的 babel 插件编译后,元素的 classname 上会增加上一个又一个编译后的 Atomic classname:

图片

唯一需要注意的一点是:在 p 标签中我们使用了 styles.dynamic,它表示一个动态生成的 Css 透明度样式。

透过上述编译后的内容,我们可以清楚地看到在 stylex 内部是将这部分需要运行时生成的 Css 样式内容的值,编译为了 Css 变量的形式。

从而对于需要使用到动态 Css 变量的元素,动态替换它的 Css 变量值从而实现更新元素样式的效果,这个实现思路还是比较巧妙的。

3.4 stylex.defineVars/stylex.createTheme

3.4.1 stylex.defineVars

stylex 中还提供了 defineVars Api 来帮助我们快速定义样式变量的值。

// src/components/ButtonTokens.stylex.ts
import * as stylex from '@stylexjs/stylex';


// 通过 stylex 定义一系列 Button 相关样式变量
export const buttonTokens = stylex.defineVars({
  bgColor: 'green',
  textColor: 'red',
  cornerRadius: '4px',
  paddingBlock: '4px',
  paddingInline: '8px'
});


// src/components/Button.ts
import * as stylex from '@stylexjs/stylex';
import './ButtonTokens.stylex';
import { buttonTokens } from './ButtonTokens.stylex';


const styles = stylex.create({
  base: {
    borderWidth: 0,
    backgroundColor: buttonTokens.bgColor,
    color: buttonTokens.textColor,
    borderRadius: buttonTokens.cornerRadius,
    paddingBlock: buttonTokens.paddingBlock,
    paddingInline: buttonTokens.paddingInline
  }
});
function Button() {
  return <button {...stylex.props(styles.base)}>This is Single Button</button>;
}


export default Button;

需要额外注意的是,官网文档中明确标注关于 defineVars 方法需要满足在文件名为 .stylex.js/*.stylex.ts 的文件中被具名导出。

需要注意虽然文档上没提,但是 import './ButtonTokens.stylex'; 必不可少。如果缺少这句导入,实际样式内容并不会正常显示。

此时页面中的 Button :

图片

图片

3.4.2 stylex.createTheme

stylex.createTheme 接受两个参数,第一个参数为通过 defineVars 创建的变量集合,第二个参数为用于覆盖第一个参数的值,它是一个对象。

我们可以通过 stylex.createTheme 创建一个 StyleXStyles 对象,从而提供给 stylesx.props 方法使用。

同时,我们也可以使用stylex.createTheme来通过覆盖stylex.defineVars 声明的变量,从而创建主题,比如:

我们对上述的按钮稍作修改,让按钮可以支持一个自定义主题的传入:

import * as stylex from '@stylexjs/stylex';
import './ButtonTokens.stylex';
import { buttonTokens } from './ButtonTokens.stylex';


const styles = stylex.create({
  base: {
    borderWidth: 0,
    backgroundColor: buttonTokens.bgColor,
    color: buttonTokens.textColor,
    borderRadius: buttonTokens.cornerRadius,
    paddingBlock: buttonTokens.paddingBlock,
    paddingInline: buttonTokens.paddingInline
  }
});


// Button 组件可以额外接受一个 theme 的主题
function Button(props: { theme?: stylex.Theme<typeof buttonTokens> }) {
  return (
    <button {...stylex.props(props.theme, styles.base)}>
      This is Single Button
    </button>
  );
}


export default Button;

然后再将使用 Button 的地方稍做修改:

import * as stylex from '@stylexjs/stylex';
import Button from './components/Button';
import { buttonTokens } from './components/ButtonTokens.stylex';


const otherTheme = stylex.createTheme(buttonTokens, {
  bgColor: '#000',
  textColor: 'yellow',
  cornerRadius: '4px',
  paddingBlock: '4px',
  paddingInline: '8px'
});


function HomePage() {
  return (
    <div>
      {/* 未传入特定主题,使用默认主题 */}
      <Button />
      {/* 传入特定主题,覆盖原本主题 */}
      <Button theme={otherTheme} />
    </div>
  );
}


export default HomePage;

此时,页面上会出现两个不同主题的按钮:

图片

实际上 createTheme 对于默认的 defineVars 的覆盖,也是通过 Css 变量优先级来确定主题优先级的:

图片

图片

红色文字按钮的样式变量,来源于 defineVars 的全局 Css 变量,而黄色按钮通过在元素上编译为同样的  Css 变量的方式,自然优先级会比全局 Css 更高。

四、展望

以上和大家简单聊 stylex 的 Api,以及它的基本使用姿势。

目前我们对于 stylex 也并没有太多的实践经验,看起来相较于目前流行的 Tailwind 这种类似 Utility Css 的 atomic css 方案, Css-In-Js 的解决方案在代码组织上,以及类型约束上,的确对于代码的可读性以及组织性会更加便携一些。

不过 stylex 现阶段无论是从构建生态、内置实现(比如#197,#40 都是我在编写 Demo 时碰到的一些问题)来说,可能对于在生产应用上使用还是有所欠缺。

总的来讲,未来Css-In-Js 的 Atomic Css 解决方案无论是在业务代码还是基础 Components 中一定会是一个不错的方案。

后续我们也会关注 stylex 的更新,并带来更多关于 stylex 的实践,希望本文的内容可以帮助到大家。

责任编辑:张燕妮 来源: 携程技术
相关推荐

2017-02-23 21:17:00

致远

2023-06-06 11:49:24

2023-10-27 09:34:34

携程应用

2024-11-05 09:56:30

2022-06-17 10:44:49

实体链接系统旅游AI知识图谱携程

2022-08-06 08:23:47

云计算公有云厂商成本

2023-08-18 10:49:14

开发携程

2023-07-07 12:26:39

携程开发

2024-03-22 15:09:32

2014-12-25 17:51:07

2024-04-18 09:41:53

2022-07-21 19:36:35

乐高携程前端

2023-11-13 11:27:58

携程可视化

2017-07-06 19:57:11

AndroidMVP携程酒店

2023-06-06 16:01:00

Web优化

2022-11-29 20:32:07

2022-04-07 17:30:31

Flutter携程火车票渲染

2022-03-30 18:39:51

TiDBHTAPCDP

2022-11-29 21:00:39

技术搜索
点赞
收藏

51CTO技术栈公众号