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

{"type":"doc","content":[{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"前言"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"最近在业务中开发了一套定制化的 C 端组件库,在这个过程中遇到了一些组件库技术选型和设计的问题,在参考公司内外的多个组件库后确定了最终的方案。本文希望通过向读者介绍技术选型的过程中的方案比较和组件库设计中的考量,让读者在组件库的技术选型和设计上有所启发。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/f4\/f4fa8d8b2431f9a8d5d4e606e87a4c06.png","alt":null,"title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一个完整的组件库方案的思路"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"组件库的技术选型"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"样式方案选择"}]},{"type":"image","attrs":{"src":"https:\/\/static001.geekbang.org\/infoq\/24\/248c15374c57d407c3d87cfdab05e576.png","alt":null,"title":null,"style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"事实上,这三种样式方案可以并存,但实际开发以其中一种为主。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"Sass\/Less"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这是大家最熟悉的方式,它的优点是足够灵活、开发成本低(绝大多数工程师都熟悉它们)、 完全支持外部覆盖组件的样式,缺点是难以调试(需要到 runtime 才能知道命中的规则),以及难以实现静态分析。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"Atomic CSS"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 UI 足够标准化的情况下,使用 Atomic CSS 能实现更小的包体积大小,对於单个组件,除了极少数无法抽象的样式以及自定义动画,不再需要声明其他样式。当然它的缺点是代码可读性稍稍降低。同时开发者需要先熟悉项目的原子样式,增加了一定的开发成本。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"CSS-in-JS"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"CSS-in-JS 指包括 styled-component、Emotion、JSS 等在内的,在运行时通过 js 生成 css 样式的第三方库。CSS-in-JS 这种方案的优点在于能有效解决“组件样式随着数据变化”的问题。但是,它的缺点在于为了支持从外部覆盖内部元素的样式,需要给内部元素加上 className,同时不支持 postcss,取而代之的是特定 CSS-in-JS 库自己的 plugin 生态,少部分库(如 emotion)没有支持 rem 的工具库。另外在做 SSR 和流式渲染时,都需要在 node 层增加提取样式逻辑,增加了开发成本和额外的开销。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"小结:在有成熟的 UI 规范的情况下,Atomic CSS 是一个不错的选择,其次,使用传统的 sass\/less 来编写样式也利于维护(大部分前端开发者都熟悉它),在选用 CSS-in-JS 方案时则要考虑团队的开发习惯和上手成本。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"icon 方案选择"}]},{"type":"image","attrs":{"src":"https:\/\/static001.infoq.cn\/resource\/image\/d1\/92\/d1c58f2b3244e0d37bb1acaa3bafa492.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在选择 icon 方案的时候,除了关注渲染质量,我们还应该关注它的灵活性,以便具有更好的适配能力。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"iconfont"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"iconfont 这种方案的优点在于兼容性最好,支持 IE6 及以上版本。但是,由于 iconfont 方案是将 icon 作为文本来使用,"},{"type":"text","marks":[{"type":"strong"}],"text":"在 webkit 内核的浏览器下由于对文字有抗锯齿,导致渲染失真"},{"type":"text","text":"。另外,由于将所有的 icon 打包成一个字体文件,不支持按需加载,包体积偏大。这样很容易导致"},{"type":"text","marks":[{"type":"strong"}],"text":"在加载完成 icon font 后页面的重刷新"},{"type":"text","text":":"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.infoq.cn\/resource\/image\/d5\/3f\/d50b341260106b9ebee6d1982670ca3f.gif","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"base64 引入"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"base64 也是一种常用的方法,但是由于将 svg 作为背景图引入,只能控制它的大小,不能覆盖它的颜色,也更不能修改 svg 内部的元素,不够灵活。对于常常采用 MPA 结构的端内 h5,不利于 icon 在不同 SPA 之间复用。同时 base64 字符串的长度是 svg 文件(优化后)的 1.3 倍左右。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"React Component、SVGUseElement 和直接写入 svg 元素"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这三种方式本质上都是将 svg 作为 html 元素进行渲染,但具体的使用方式不同。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"svg 的基本能力的兼容性除了在 IE11 以下不支持动画和缩放,基本没问题,而 svg effect(主要是使用 transform、filter 等属性)在 android4.4 以上的支持良好。svg 的动画性能有瓶颈,幸运的是我们可以使用 css 动画来替代它。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"直接写入 SVG 元素的方式缺点在于"},{"type":"text","marks":[{"type":"strong"}],"text":"完全无法复用同一个 icon"},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而 SVGUseElement 的具体实现方式有使用元素、 元素和 SVG fragment identifiers 等方式,但总的来说,都是在顶部声明 svg 元素,在需要使用的地方使用元素引入。具体可以参考使用 SVGUseElement 插入 icon 的"},{"type":"text","marks":[{"type":"strong"}],"text":"例子"},{"type":"text","text":"[1]。它的缺点在于"},{"type":"text","marks":[{"type":"strong"}],"text":"不够灵活,icon 难以在不同页面复用,同时支持 SSR 也比较困难"},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"目前调研的结果,最好的方式是使用 "},{"type":"text","marks":[{"type":"strong"}],"text":"svgr"},{"type":"text","text":"[2] 将 svg 转换为 React Component 来使用,它支持按需加载、完全的样式覆盖能力。同时,它支持自定义 AST 模板,可以在转换时给 svg 元素加入自定义的 className 等,容易实现 icon 自动适配 RTL、Dark Mode(这部分下文会详细介绍)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"svgr 集成了 svgo 对 svg 文件进行优化,它可以抹去 svg 中无用的属性、隐藏元素等,具体的配置可以参考 "},{"type":"text","marks":[{"type":"strong"}],"text":"svgo-github"},{"type":"text","text":"[3]。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"小结:目前看来使用 svgr 将 svg 转换生成 React Component 来构建 icon 是最佳的方式,能很方便地按需加载、复用,适配能力也最强。我们可以*"},{"type":"text","marks":[{"type":"italic"},{"type":"strong"}],"text":"将 icon 专门做成一个 npm 包*"},{"type":"text","marks":[{"type":"strong"}],"text":",供组件库使用,也可以在业务仓库中直接使用。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"组件库的核心设计"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"深色模式(Dark Mode)适配"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"事实上,本小节讨论的是业务上使用组件库的 Dark Mode 能力时会遇到的兼容性问题和实际业务场景。但组件库本身就是服务于业务的,从这个角度讲本小节的内容也属于组件库相关的一部分,它指导组件库如何去提供更好的 Dark Mode 适配能力。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"多主题能力"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"深色模式本质上是一种运行时的多主题问题,主要是在运行时支持切换不同的主题色。我们可以使用 CSS 变量来定义颜色,然后在 Sass\/Less\/Css 中约定使用它:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":":root{\n --bg-default: #fff;\n}\n:root[theme=\"dark\"]{\n --bg-default: #000;\n}\n.button{\n background-color: var(--bg-default);\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这样,只要我们在元素中设置自定义属性 theme 的值为 dark,颜色就会自动切换。且我们只要定义好颜色变量,并约定使用它,则开发组件的时候只写一次就可以支持多个主题。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"可惜的是 CSS 变量在 android4、IE11 及以下等有兼容性问题。我们有如下三种方案:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https:\/\/static001.infoq.cn\/resource\/image\/6d\/a4\/6d7e694055408d073ff3be8419da74a4.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们可以实现一个 postcss plugin 来生成兜底属性,做法类似于:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"\/\/ 处理前\n.button{\n background-color: var(--bg-default);\n}\n\n\/\/ 处理后\n.button{\n background-color: #fff; \/\/ 对于不支持css变量的浏览器这行会生效\n background-color: var(--bg-default); \/\/ 对于支持css变量的浏览器这行会覆盖上一行属性\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"它最大的优点在于增大的包大小几乎可以忽略不计,缺点在于对于不支持 CSS 变量的颜色实际上变成了强制展示一套兜底主题色。对于移动端内页面来说,不支持 css 变量的环境可以等同于没有深色模式的环境,可以使用浅色模式的主题色兜底。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们还有另一种方式来实现兼容,比如下面这样:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":".button{\n background-color: #fff;\n}\n.theme-a .button{\n background-color: #000;\n}\n.thema-b .button{\n background-color: #ccc;\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然后在某个根元素上(例如 html)增加 theme-a 这个 class 即可,这样的优点在于完全不会有兼容性问题,缺点在于增加了开发成本,幸运的是,我们可以使用"},{"type":"text","marks":[{"type":"strong"}],"text":"postcss-css-variables"},{"type":"text","text":"[4]来很方便地从 css 变量的写法生成这种声明。它的另一个缺点是随着主题色的增多,会成倍地产生额外的 CSS 包大小。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"css-vars-ponyfill 能完美支持多主题色,缺点是会产生固定的额外包大小。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"小结:支持运行时多主题色主要使用 css 变量,而业务仓库的解决兼容性问题,可以根据具体情况选择。如果是端内 h5 且只需要深浅色模式,可以考虑使用 postcss plugin 生成兜底属性,否则可以使用 css-vars-ponyfill 或者 postcss-css-variables。"}]},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"判断 Dark Mode"}]},{"type":"image","attrs":{"src":"https:\/\/static001.infoq.cn\/resource\/image\/49\/f9\/49173e91c1dd6a3e8f8cce7553e39ff9.png","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"heading","attrs":{"align":null,"level":5},"content":[{"type":"text","text":"媒体查询"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我们可以很容易的利用 prefers-color-scheme 这个媒体特性来检测 Dark Mode,结合我们 css 变量的使用,就像这样:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":":root{\n --bg-default: #fff;\n}\n@media (prefers-color-scheme: dark) {\n :root{\n --bg-default: #000;\n }\n}\n\/\/ 支持白名单逃逸,再写一次:root下的属性\n:root[theme=\"light\"]{\n --bg-default: #fff;\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"白名单逃逸是指在我们的业务中,可能有一部分页面,如活动页、抽奖页等不支持 Dark Mode,我们可以通过在 html 上增加一个 theme 属性来强制为浅色模式。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"媒体查询的优点是"},{"type":"text","marks":[{"type":"strong"}],"text":"使用方便"},{"type":"text","text":",媒体查询会自动监听系统设置的变化(是否开启深色模式)"},{"type":"text","marks":[{"type":"strong"}],"text":"不用在 html 中增加额外代码"},{"type":"text","text":"。缺点在于"},{"type":"text","marks":[{"type":"strong"}],"text":"对需要逃逸的情况,书写比较繁琐"},{"type":"text","text":"。"}]},{"type":"heading","attrs":{"align":null,"level":5},"content":[{"type":"text","text":"JS API 监听媒体查询"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用 JS API 的例子如下:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"text"},"content":[{"type":"text","text":"\n
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章