聊聊 React 组件库的技术选型与设计

聊聊 React 组件库的技术选型与设计

北京字节跳动网络技术有限公司

前言

最近在业务中开发了一套定制化的 C 端组件库,在这个过程中遇到了一些组件库技术选型和设计的问题,在参考公司内外的多个组件库后确定了最终的方案。本文希望通过向读者介绍技术选型的过程中的方案比较和组件库设计中的考量,让读者在组件库的技术选型和设计上有所启发。

一个完整的组件库方案的思路

组件库的技术选型

样式方案选择

Sass/Less
Atomic CSS
CSS-in-JS
完全支持样式覆盖
❗(需要使用className支持)
支持rem布局
❗(大部分库支持)
可读性
稍弱
上手成本
是否支持SSR
天然支持
天然支持
需要额外支持
是否支持流式渲染
天然支持
天然支持
需要额外支持
支持postcss
❗(有自己的plugin生态)

事实上,这三种样式方案可以并存,但实际开发以其中一种为主。

Sass/Less

这是大家最熟悉的方式,它的优点是足够灵活、开发成本低(绝大多数工程师都熟悉它们)、 完全支持外部覆盖组件的样式,缺点是难以调试(需要到 runtime 才能知道命中的规则),以及难以实现静态分析。

Atomic CSS

在 UI 足够标准化的情况下,使用 Atomic CSS 能实现更小的包体积大小,对于单个组件,除了极少数无法抽象的样式以及自定义动画,不再需要声明其他样式。当然它的缺点是代码可读性稍稍降低。同时开发者需要先熟悉项目的原子样式,增加了一定的开发成本。

CSS-in-JS

CSS-in-JS 指包括 styled-component、Emotion、JSS 等在内的,在运行时通过 js 生成 css 样式的第三方库。CSS-in-JS 这种方案的优点在于能有效解决“组件样式随着数据变化”的问题。但是,它的缺点在于为了支持从外部覆盖内部元素的样式,需要给内部元素加上 className,同时不支持 postcss,取而代之的是特定 CSS-in-JS 库自己的 plugin 生态,少部分库(如 emotion)没有支持 rem 的工具库。另外在做 SSR 和流式渲染时,都需要在 node 层增加提取样式逻辑,增加了开发成本和额外的开销。

小结: 在有成熟的 UI 规范的情况下,Atomic CSS 是一个不错的选择,其次,使用传统的 sass/less 来编写样式也利于维护(大部分前端开发者都熟悉它),在选用 CSS-in-JS 方案时则要考虑团队的开发习惯和上手成本。

icon 方案选择

React Component
iconfont
SVGUseElement
直接写入SVG元素
base64引入
按需加载
复用
icon翻转
样式覆盖
❗(支持部分)
❗(支持部分)
渲染质量
❗(webkit下失真)
修改svg内部元素

在选择 icon 方案的时候,除了关注渲染质量,我们还应该关注它的灵活性,以便具有更好的适配能力。

iconfont

iconfont 这种方案的优点在于兼容性最好,支持 IE6 及以上版本。但是,由于 iconfont 方案是将 icon 作为文本来使用,在 webkit 内核的浏览器下由于对文字有抗锯齿,导致渲染失真。另外,由于将所有的 icon 打包成一个字体文件,不支持按需加载,包体积偏大。这样很容易导致在加载完成 icon font 后页面的重刷新

base64 引入

base64 也是一种常用的方法,但是由于将 svg 作为背景图引入,只能控制它的大小,不能覆盖它的颜色,也更不能修改 svg 内部的元素,不够灵活。对于常常采用 MPA 结构的端内 h5,不利于 icon 在不同 SPA 之间复用。同时 base64 字符串的长度是 svg 文件(优化后)的 1.3 倍左右。

React Component、SVGUseElement 和直接写入 svg 元素

这三种方式本质上都是将 svg 作为 html 元素进行渲染,但具体的使用方式不同。

svg 的基本能力的兼容性除了在 IE11 以下不支持动画和缩放,基本没问题,而 svg effect(主要是使用 transform、filter 等属性)在 android4.4 以上的支持良好。svg 的动画性能有瓶颈,幸运的是我们可以使用 css 动画来替代它。

直接写入 SVG 元素的方式缺点在于完全无法复用同一个 icon

而 SVGUseElement 的具体实现方式有使用<defs>元素、 <symbol>元素和 SVG fragment identifiers 等方式,但总的来说,都是在顶部声明 svg 元素,在需要使用的地方使用<use>元素引入。具体可以参考使用 SVGUseElement 插入 icon 的例子。它的缺点在于不够灵活,icon 难以在不同页面复用,同时支持 SSR 也比较困难

目前调研的结果,最好的方式是使用 svgr 将 svg 转换为 React Component 来使用,它支持按需加载、完全的样式覆盖能力。同时,它支持自定义 AST 模板,可以在转换时给 svg 元素加入自定义的 className 等,容易实现 icon 自动适配 RTL、Dark Mode(这部分下文会详细介绍)。

svgr 集成了 svgo 对 svg 文件进行优化,它可以抹去 svg 中无用的属性、隐藏元素等,具体的配置可以参考 svgo-github

小结: 目前看来使用 svgr 将 svg 转换生成 React Component 来构建 icon 是最佳的方式,能很方便地按需加载、复用,适配能力也最强。我们可以将 icon 专门做成一个 npm 包,供组件库使用,也可以在业务仓库中直接使用。

组件库的核心设计

深色模式(Dark Mode)适配

事实上,本小节讨论的是业务上使用组件库的 Dark Mode 能力时会遇到的兼容性问题和实际业务场景。但组件库本身就是服务于业务的,从这个角度讲本小节的内容也属于组件库相关的一部分,它指导组件库如何去提供更好的 Dark Mode 适配能力。

多主题能力

深色模式本质上是一种运行时的多主题问题,主要是在运行时支持切换不同的主题色。我们可以使用 CSS 变量来定义颜色,然后在 Sass/Less/Css 中约定使用它:

:root{
    --bg-default: #fff;
}
:root[theme="dark"]{
    --bg-default: #000;
}
.button{
    background-color: var(--bg-default);
}
复制代码

这样,只要我们在<html>元素中设置自定义属性 theme 的值为 dark,颜色就会自动切换。且我们只要定义好颜色变量,并约定使用它,则开发组件的时候只写一次就可以支持多个主题。

可惜的是 CSS 变量在 android4、IE11 及以下等有兼容性问题。我们有如下三种方案:

css-vars-ponyfill
生成兜底属性
支持多色
额外包大小
7.3KB(minifed + gzipped后)
和主题色个数有关
几乎可以忽略
支持SSR

我们可以实现一个 postcss plugin 来生成兜底属性,做法类似于:

// 处理前
.button{
    background-color: var(--bg-default);
}

// 处理后
.button{
    background-color: #fff; // 对于不支持css变量的浏览器这行会生效
    background-color: var(--bg-default); // 对于支持css变量的浏览器这行会覆盖上一行属性
}
复制代码

它最大的优点在于增大的包大小几乎可以忽略不计,缺点在于对于不支持 CSS 变量的颜色实际上变成了强制展示一套兜底主题色。对于移动端内页面来说,不支持 css 变量的环境可以等同于没有深色模式的环境,可以使用浅色模式的主题色兜底。

我们还有另一种方式来实现兼容,比如下面这样:

.button{
    background-color: #fff;
}
.theme-a .button{
    background-color: #000;
}
.thema-b .button{
    background-color: #ccc;
}
复制代码

然后在某个根元素上(例如 html)增加 theme-a 这个 class 即可,这样的优点在于完全不会有兼容性问题,缺点在于增加了开发成本,幸运的是,我们可以使用postcss-css-variables来很方便地从 css 变量的写法生成这种声明。它的另一个缺点是随着主题色的增多,会成倍地产生额外的 CSS 包大小。

css-vars-ponyfill 能完美支持多主题色,缺点是会产生固定的额外包大小。

小结: 支持运行时多主题色主要使用 css 变量,而业务仓库的解决兼容性问题,可以根据具体情况选择。如果是端内 h5 且只需要深浅色模式,可以考虑使用 postcss plugin 生成兜底属性,否则可以使用 css-vars-ponyfill 或者 postcss-css-variables。

判断 Dark Mode

媒体查询(prefers-color-scheme
JS API监听媒体查询
白名单逃逸
不易支持
容易支持

媒体查询

我们可以很容易的利用 prefers-color-scheme 这个媒体特性来检测 Dark Mode,结合我们 css 变量的使用,就像这样:

:root{
    --bg-default: #fff;
}
@media (prefers-color-scheme: dark) {
    :root{
        --bg-default: #000;
    }
}
// 支持白名单逃逸,再写一次:root下的属性
:root[theme="light"]{
    --bg-default: #fff;
}
复制代码

白名单逃逸是指在我们的业务中,可能有一部分页面,如活动页、抽奖页等不支持 Dark Mode,我们可以通过在 html 上增加一个 theme 属性来强制为浅色模式。

媒体查询的优点是使用方便,媒体查询会自动监听系统设置的变化(是否开启深色模式)不用在 html 中增加额外代码。缺点在于对需要逃逸的情况,书写比较繁琐

JS API 监听媒体查询

使用 JS API 的例子如下:

<body>
    <script>
        const mql = window.matchMedia('(prefers-color-scheme: dark)');
        function matchMode(e) {
           const $root = document.documentElement;
            if (e.matches) {
                $root.setAttribute('theme', 'dark');
            } else {
                $root.removeAttribute('theme');
            }
        }
        mql.addListener(matchMode);
    </script>
    <div id="root"></div>
</body>
复制代码

样式的部分就像我们一开始介绍 CSS 变量的例子:

:root{
    --bg-default: #fff;
}
:root[theme="dark"]{
    --bg-default: #000;
}
复制代码

这个方案的好处是灵活,可以很容易地在脚本里加入其它逻辑支持白名单逃逸。缺点是为了支持 SSR,需要单独将这部分脚本写在 html 模板的 body 元素内最上方,对于组件库的使用方增加接入成本。

小结: 从实际业务可能出现的白名单逃逸问题以及业务的变化来看,虽然使用 JS API 监听媒体查询判断 Dark Mode 的方式会少许增加接入组件库的成本。但是和带来的灵活性收益相比来说是值得的,建议使用这种方式。

RTL 适配

组件库如果支持国际化,那么 RTL 是一个必不可少的部分。RTL(right to left) 是指部分语言,例如阿拉伯语是从右往左阅读的,由此带来 UI 上需要左右相反(大部分情况下,有些例外),一些 icon 也需要镜像,手势也是从右往左滑动的,input 输入框从右到左输入,更多细节具体可以参考 《bidirectionality - Material》

布局适配

我们可以利用原生的 dir 属性来支持大部分的 rtl 能力,即在 html 上设置属性 dir='rtl'。在浏览器环境下可以通过 NavigatorLanguage API 来获取页面语言,进而根据当前语言是否是 rtl 来设置 dir 的值。在 node 环境下可以通过请求头 Accept-Language 获取页面语言,判断得到 dir 的值后注入到返回的页面中。设置 dir='rtl'后,全局的 flex 水平布局会自动反向,文本也会自动右对齐(除非显示声明 text-align)。但包括 marin-left、left、border-left 这类属性(其他方向类似)无法自动适配,解决这个问题有多种方式,我们可以很直观地来看代码:

// 方法1: 样式覆盖方式,ant design使用此方式
.button{
    margin-left: 16px;
}
html[dir='rtl'] .button{
    margin-left: 0;// 要覆盖掉,否则左右都是16px的margin
    margin-right: 16px;
}


// 方法2: 另一种方式,虽然不用覆盖,但是需要将方位属性拆出来
.button{
   // 与方位无关的属性
}
html[dir='ltr'] .button{
    margin-left: 16px;
}
html[dir='rtl'] .button{
    margin-right: 16px;
}

// 方法3: 方法2 + Atomic CSS
html[dir='ltr'] .ms-16{
    margin-left: 16px;
}
html[dir='rtl'] .ms-16{
    margin-right: 16px;
}
复制代码

我们可以看到方法 1 和方法 2 都不是很方便,而方法 3 需要 UI 非常的规范化(将 margin、padding 收敛到可枚举的状态),也不能覆盖所有的情况。幸运的是,我们可以使用 margin-inline-start 这类 RTL 敏感的属性来解决(更多属性见CSS Logical Properties) :

.button{
   // 其他属性
   margin-inline-start: 16px;
}
复制代码

在实际使用中还存在一些兼容性问题,我们可以使用 postcss-bidirection 处理,会把上述声明转化为:

.button{
    // 其他属性
}
html[dir="ltr"] .button {
    margin-left: 16px;
}
html[dir="rtl"] .button {
    margin-right: 16px;
}
复制代码

小结:RTL 的布局适配我们可以使用 RTL 敏感属性,它与 Atomic CSS 不冲突,合适的情况下可以结合起来使用。

icon 适配

在 RTL 下,部分 icon 需要镜像。前面我们已经介绍,icon 的最佳方式是使用 svgr 将 svg 转换为 React Component。这样,我们可以在转换时为需要 RTL 翻转的 icon 增加一个 class,例如 flip-rtl,然后组件库提供以下 CSS 声明供业务使用:

[dir="rtl"] .flip-rtl {
  transform: scaleX(-1);
}
复制代码

icon 是否镜像可能是偏设计侧的事情,如果我们将 icon 的设计稿托管在 figma 平台上,我们可以和设计师约定需要 RTL 下需要翻转的 icon 的命名,然后实现一个自动下载 svg 源文件、 svgo 处理、 使用 svgr 转换成 React Component 的脚本,并且在转换过程中根据命名自动判断是否需要加上 flip-rtl 这个 class。这样,在组件库和业务开发过程中,研发都不需要关心 icon 的镜像问题,减少沟通和验收成本

手势适配

一些组件,如进度条组件,在传统 LTR 下是从左向右滑动,但是在 RTL 下则是从右向左滑动。我们可以简单地给这类组件增加一个 isRTL 这种 props,但是这显然不是一种很好的做法,使用的时候都要计算并传入 props 值。由此思考,我们可以为整个组件库抽象一些通用能力,全局注入。

全局化配置

对于 direction(LTR/RTL)、 prefixCls(类名前缀)等一些全局配置,我们可以使用 React 的 Context 来注入,例如应用的根节点外面包裹一个 ConfigProvider:

import ConfigProvider from 'myComponent';
// ...
export default () => (
  <ConfigProvider direction="rtl">
    <App />
  </ConfigProvider>);
复制代码

在组件中使用 hooks 获取:

import { useConfigContext } from 'myComponent';
export default () => {
    const { rtl, prefixCls, platform } = useConfigContext();
    //...
}
复制代码

使用 Context 甚至可以实现局部的配置和全局不同,它非常灵活,后期可以很方便地扩展全局配置的能力,也解决了我们反复将一些全局通用的属性作为 props 传入各个组件的痛点,缺点在于不利于代码的静态测试。

组件分层

在组件库开发之前,应该先规划好组件库的层级,以增加组件库的代码复用性和使用的灵活性。

我们应该先规划一些基础组件,避免后续的重构。Switch、Checkbox、Radio(它们在逻辑上区别仅仅在于点击激活态后是取消还是依旧激活)可以抽象出一个 BaseSwitch,在它的基础上实现这三个组件。对于 Button,在弹窗组件等其他组件中也会出现,我们可以抽象出一个 BaseButton 或者在其他组件中使用 ConfigProvier 的 prefixCls 重写它的样式。对于表单相关的组件,可以先实现一些原子的 input、textarea,再实现 Form 中带有 lable、 校验状态等和 UI 跟相关的 Form.input 等。对于弹层组件,可以封装一个 Portal 组件提供能力等等。在 Metrial UI 中还抽象了一个 Box 组件,所有的组件都基于 Box 组件编写,实现全局布局和样式的控制。

样式

样式上,如果没有使用 Atomic CSS,我们可以将 UI 规范(字重、文本大小和行高的组合)封装成 sass/less 中的 mixin,降低出错的可能性。还可以封装一些常用的能力,比如文本溢出显示省略号、 0.5px 边框的伪元素实现等。这些封装的变量和 mixin 不仅可以在组件库内部使用,还可以提供给业务方使用(尤其在定制组件库中)。同时要和 UI 约定组件库不同组件的 z-index,以避免不符合预期的层级。

其他

组件库中用到的一些 hooks(比如弹层组件用到的冻结页面的滚动)可以使用 react-use 等主流开源库,也可以定制开发。如果组件库期望支持 preact(一个和 react 语法基本一致但更轻量的库),可以参考 switching-to-preact 来避免在开发过程中使用不支持 preact 的语法。同时,组件库中用到的 utils(一些函数能力)也要考虑兼容 node 环境,以支持 SSR。在不会引入特别大的成本的前提下,组件库应该充分地去考虑业务方可能的技术选型,以避免限制业务上的技术实现。

组件库构建一般使用 tsc 或者 rollup,动画库则根据具体需求选择是否使用(使用 CSS 动画更轻量)。

组件库的其他细节

质量保障

组件库的质量保障从流程上来说,主要是 code review 和严格的 UI 验收、QA 测试等流程。从技术层面来说可以收敛发包权限,结合 semantic-release 在 CI/CD 中实现自动发包,杜绝研发过程中在非 master 分支上随意发包的危险操作。还有单元测试、快照测试、e2e 测试等常用的技术手段,限于本文篇幅不再详细阐述。

规范

制定规范的目的在于保证质量、 方便业务方使用和增加组件库的可扩展性。比如上文提到的对于样式的封装、常用 mixin 封装,强制使用颜色变量等。还有设计统一的组件库 API 风格规范,能降低业务方的使用成本。

提效

组件库一般有一个演示站点,主流的技术选型有 stylegudist、storybook 等,可以根据团队习惯选用。对于移动端组件库,可以通过 webpack 别名的方法重写它们的组件,以支持移动端预览,方便 UI 验收。对于国际化的组件,可以提供类似 vconsole 形式的 devtools,可视化切换 dark/light Mode、rtl/lrt 等能力,提高开发和测试流程中的效率。

一些思考

组件库的开发是一个强依赖 UI 的事情,我们需要和 UI 进行充分的沟通。同时我们不能局限于组件库本身,而要考虑到开发、测试过程中的效率,业务中接入的难易,以及是否能良好地应对业务的变化等,从更全局的视角去思考。另外,如果是通用组件库,则组件库的推广是一个重中之重的事情,有更多的业务方接入,才能推动组件库的进一步迭代,形成良性循环。

关于我们

字节跳动国际化电商招聘前端工程师(实习、校招、社招不限), 业务前景广阔, 技术挑战大, 投递发送邮件到「 tech@bytedance.com 」, 邮件标题备注【国际化电商+实习/校招/社招】


欢迎关注「 字节前端 ByteFE 」

简历投递联系邮箱「 tech@bytedance.com

文章分类
前端
文章标签