前端原子化业务组件探索

来聊聊前端原子化组件相关的问题吧。

自从TailwindCSS横空出世与Atomic Design被提出之后,“原子化”这个概念现在也是前端绕不过去的话题。这里想和大家聊聊原子化设计的业务组件在实际业务开发中的应用以及到底好处在哪,解决了什么问题。

当一个前端项目进行一段时间后,项目组往往会根据业务的实际需求来封装一些与业务强相关的组件来方便开发者调用。这里拿常见的最常见的弹窗组件举例:

那么这个时候这个组件可能会封装成这样:

1
2
3
<Modal title="" onClose={()=>{}} onClickButton={()=>{}} visible={setVisible} setVisible={setVisible}>
{children}
</Modal>

看上去很完美对吧,属性很少,调用也很简单,往代码里面一引轻松完成需求。

但是随着业务的变化,弹窗多了几个需求:

这个时候这个组件已经在代码库里面大范围用了,那么为了兼容性与业务,这个组件就变成了这样:

1
2
3
4
<Modal title="" onClose={()=>{}} onClickButton={()=>{}} banner="" onClickLeftButton={()=>{}} 
showLeftButton visible={visible} setVisible={setVisible}>
{children}
</Modal>

props的数量好像慢慢变多了,但是还处于能接受的范围。

又过了一段时间,业务方对于弹窗的要求又变成了这样,且页面的不同元素是否展示都有复杂的判断逻辑:

这个时候噩梦就逐渐开始了,组件变成了这样:

1
2
3
4
5
<Modal title="" onClose={()=>{}} onClickButton={()=>{}} banner="" 
onClickLeftButton={()=>{}} showLeftButton titleInfo={'title info'} bannerInfo={'banner info'}
{/*...省略一堆props*/} footerInfo={'footer info'} visible={visible} setVisible={setVisible} >
{children}
</Modal>

开发每次用的时候都要思考研究这么多属性到底是干嘛的,每次用甚至可能需要点开组件的类型定义一个个看,尤其是业务繁忙的时候大家可能根本没有时间为此写注释,想搞明白它的行为只能去看源码。而且此时的源码里面已经有相当多的if-else判断或者useEffect了,要读懂源码已经要花费不少时间了。。

随着业务继续迭代,属性的数量可能已经来到了50个以上,一旦有个新需求,就往组件里面新加一个props。这个组件的最终结局就是臃肿且性能巨差,甚至可能有2次以上的重复渲染。一旦线上出现什么奇怪的bug的话debug会异常困难,而且因为已经陷入了牵一发动全身的局面,修复也很困难。

怎么解决上述问题呢?

让我们用原子化的思路来重新审视并封装一下这个组件:

首先,对于模态框来说,遮罩层是必不可少的,其次,选择模态框的挂载位置也是一个必要的需求,这是两个前置必然要满足的需求。这个时候再来观察业务方的要求,这个框需要有个标题,中间是放内容的,最下面还有个footer。那么这个时候可以把组件拆分成这样:

  • 遮罩层,作为背景:<ModalOverlay/>
  • 挂载位置选定,一般都默认挂在document.body上 <ModalPortal/>
  • 模态框顶层,控制开闭,支持受控与非受控 <ModalRoot/>
  • 顶部标题 <ModalTitle/>
  • 中间内容区<ModalContent/>
  • 底部 <ModalFooter/>

调用的时候就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<ModalRoot visiblie={visible} setVisible={setVisible}>
<ModalOverlay/>
<ModalPortal>
<ModalTitle>
title
</ModalTitle>
<ModalContent>
content
</ModalContent>
<ModalFooter>
<Button>button</Button>
</ModalFooter>
</ModalPortal>
</ModalRoot>

你可能会觉得这样调用JSX会变得有些冗余与繁琐,别着急,接着往下看。

不过对于一个稳定的前端项目来说,遮罩层和位置变化的概率其实很低,这个时候可以把ModalRoot,ModalOverlay和ModalPortal的功能合并:

1
2
3
4
5
6
7
8
9
10
11
<ModalRoot visible={visible} setVisible={setVisible} portal={document.body}>
<ModalTitle>
title
</ModalTitle>
<ModalContent>
content
</ModalContent>
<ModalFooter>
<Button>button</Button>
</ModalFooter>
</ModalRoot>

我们接着往下看这种模式如何应对业务的迭代:

分析一下其实就多了个横幅和按钮对吧,横幅我完全可以新建一个组件出来叫<Banner/>,你可能会问我为什么没有直接叫<DialogBanner/>呢,是考虑到项目里面可能别的地方也有横幅。那么调用就变成了这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
<ModalRoot visible={visible} setVisible={setVisible}>
<ModalTitle>
title
</ModalTitle>
<Banner>banner</Banner>
<ModalContent>
content
</ModalContent>
<ModalFooter>
<Button>button</Button>
<Button>button</Button>
</ModalFooter>
</ModalRoot>

再看看我们的终极形态吧:

页面上多了一些图标用来展示一些额外的信息,那么我们可以考虑封装一个组件叫<Info/>,最终我们的JSX就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<ModalRoot visible={visible} setVisible={setVisible}>
<ModalTitle>
<Info/>
title
</ModalTitle>
<Banner>
<Info/>
banner
</Banner>
<ModalContent>
content
</ModalContent>
<ModalFooter>
<Info/>
<Button>button</Button>
<Button>button</Button>
</ModalFooter>
</ModalRoot>

是不是比我们之前的单一组件写法要直观很多,回归到了HTML声明式的原点,同时减少了很多对于props的记忆的心智负担。之后如果再有新的需求过来,有了这些原子化的业务组件,就可以随心所欲地组装出业务方想要的弹窗。换句话说,就是选择自己项目最合适的封装方式,避免过度封装。

接下来分享一些封装原子化业务组件的一些技巧:

处理variance

举个例子,对于像<Banner/>这种信息展示类的组件来说业务方有个很常见的需求就是根据展示内容的不同有不同的样式,如下:

通常我们可能会直接在组件内部根据传进来的type改样式

1
2
3
4
5
6
7
8
9
10
if(props.type === "error"){
return "red"
}

// 或者用switch

switch(props.type){
case "error":
return "red"
}

但是variance一多之后组件内部的判断逻辑就会很复杂,满屏的if else与switch,也非常的不好维护,这个时候可以使用class-variance-authority(具体使用方法可以看官网说明)来简化这个流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const bannerVariants = cva("", {
variants: {
variant: {
info: "bg-info", // 设置info类型的背景色
error: "bg-danger", // 设置error类型的背景色
success: "bg-positive" // 设置success类型的背景色
},
size: {
small: ["text-sm", "py-1", "px-2"], // 不同的组件大小,适配不同屏幕或者业务场景
medium: ["text-base", "py-2", "px-4"],
},
},
defaultVariants: {
intent: "primary",
size: "medium",
},
});

组件代码就可以简化成:

1
2
3
4
5
const Banner = (props)=>{
return (
<div className={bannerVariants({variant: props.variant})}>{props.children}</div>
)
}

className透传

在敏捷实践中,业务方的对于修改样式的需求往往是多变的,因此,组件暴露出class供开发overwrite是很重要的,可以这样处理:

1
2
3
4
5
const Banner = (props)=>{
return (
<div className={bannerVariants({variant: props.variant, className: props.className})}>{props.children}</div>
)
}

如果用了tailwindcss或者unocss这类原子化CSS引擎的话还可以引入tailwind-mergeclsx来解决潜在的样式冲突问题:

1
2
3
4
5
const Banner = (props)=>{
return (
<div className={twMerge(clsx(bannerVariants({variant: props.variant, className: props.className})))}>{props.children}</div>
)
}

Ref转发

Ref转发在React里面无论什么时候都是一个很有用的东西,可以用来获取具体的元素,这对我们的组件库来说是必要的,因为开发使用者很有可能需要拿到组件里面的某个元素。假设我们自己写了这么一个button组件:

1
2
3
4
5
const Button = (props)=>{
return (
<button>{props.children}</button>
)
}

有时候开发需要获取真实的DOM中的button元素,那么就可以用forwardRef这样封装:

1
2
3
4
5
const Button = React.forwardRef((props,ref)=>{
return (
<button ref={ref}>{props.children}</button>
)
})

开发可以直接这样用来获得button元素:

1
2
3
4
5

const ref = React.useRef(null)
return (
<Button ref={ref}>a button with ref</Button>
)

为了使用的方便,这里建议封装组件库的时候尽量能转发ref的就转发ref,尤其是涉及到对于DOM原生元素封装的场景。

及时重构

当组件不满足需求的时候应该及时重新审视一下封装,而不是一味地加props,仔细思考一下是不是有什么地方欠缺考虑,及时修改,而还是积重难返让技术债务逐渐拖垮整个应用。