前端必学-完美组件封装原则
点击关注公众号,“技术干货” 及时达!此文总结了我多年组件封装经验,以及拜读antd、element-plus、vant、fusion等多个知名组件库所提炼的完美组件封装的经验;是一个开发者在封装项目组件,公共组件等场景时非常有必要遵循的一些原则,希望和大家一起探讨,也希望世界上少一些半吊子组件??
---- 持续更新
?下面以 react 为例,但是思路是相通的,在 vue 上也适用
?1.基本属性绑定原则任何组件都需要继承className,style两个属性
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineimport classNamesfrom'classnames';exportinterfaceCommonProps{/** 自定义类名 */ className?:string;/** 自定义内敛样式 */ style?:React.CSSProperties;}exportinterfaceMyInputPropsextendsCommonProps{/** 值 */value:any}constMyInput=forwardRef((props: MyInputProps, ref: React.LegacyRefHTMLDivElement) ={const{ className, ...rest } = props;constdisplayClassName =classNames('chc-input', className);return( divref={ref}{...rest}className={displayClassName} span/span /div});exportdefaultChcInput2.注释使用原则原则上所有的props和ref属性类型都需要有注释且所有属性(props和ref属性)禁用// 注释内容语法注释,因为此注释不会被 ts 识别,也就是鼠标悬浮的时候不会出现对应注释文案常用的注视参数@description描述,@version新属性的起始版本,@deprecated废弃的版本,@default默认值面向国际化使用的组件一般描述语言推荐使用英文bad ?
interfaceMyInputsProps{// 自定义class className?:string}consttest: MyInputsProps = {}test.className应该使用如下注释方法
after good ?
interfaceMyInputsProps{/** custom class */ className?:string/** *@descriptionCustom inline style *@version2.6.0 *@default'' */ style?: React.CSSProperties;/** *@descriptionCustom title style *@deprecated2.5.0 废弃 *@default'' */ customTitleStyle?: React.CSSProperties;}consttest: MyInputsProps = {}test.className3.export 暴露组件props类型必须export导出如有useImperativeHandle则ref类型必须export导出组件导出funtion必须有名称组件funtion一般export default默认导出在没有名称的组件报错时不利于定位到具体的报错组件
bad ?
interfaceMyInputProps{ ....}exportdefault(props:MyInputProps) = {returndiv/div;};after good ?
// 暴露 MyInputProps 类型exportinterfaceMyInputProps{ ....}funtionMyInput(props:MyInputProps) {returndiv/div;};// 也可以自己挂载一个组件名称if(process.env.NODE_ENV!=='production') {MyInput.displayName='MyInput';}exportdefaultMyInputindex.ts
export*from'./input'export{defaultasMyInput}from'./input';当然如果目标组件没有暴露相关的类型,可以通过ComponentProps和ComponentRef来分别获取组件的props和ref属性
typeDialogProps=ComponentPropstypeofDialogtypeDialogRef=ComponentReftypeofDialog4.入参类型约束原则「入参类型必须遵循具体原则」
确定入参类型的可能情况下,切忌不可用基本类型一笔带过公共组件一般不使用枚举作为入参类型,因为这样在使用者需要引入此枚举才可以不报错部分数值类型的参数需要描述最大和最小值bad ?
interfaceInputProps{ status:string}after good ?
interfaceInputProps{ status:'success'|'fail'}bad ?
interfaceInputProps{/** 总数 */ count: number}after good ?
interfaceInputProps{/** 总数 0-999 */ count: number}5.class 和 style 定义规则禁用 CSS module 因为此类写法会让使用者无法修改组件内部样式;vue 的话可以用 scoped 标签来防止样式重复 也可以实现父亲可修改组件内部样式。书写组件时,内部的class一定要加上统一的前缀来区分组件内外class,避免和外部的 class 类有重复。class 类的名称需要语意化。组件内部的所有 class 类都可以被外部使用者改变禁用 important,不到万不得已不用行内样式可以为颜色相关 CSS 属性留好 CSS 变量,方便外部开发主题切换bad ?
importstylesfrom'./index.module.less'exportdefaultfuntionMyInput(props:MyInputProps) {return( divclassName={styles.input_box} spanclassName={styles.detail}21312312/span /div};after good ?
import'./index.less'constprefixCls ='my-input'// 统一的组件内部前缀exportdefaultfuntionMyInput(props:MyInputProps) {return( divclassName={`${prefixCls}-box`} spanclassName={`${prefixCls}-detail`}21312312/span /div};after good ?
.my-input-box{height:100px;background:var(--my-input-box-background,#000);}6.继承透传原则书写组件时如果进行了二次封装切忌不可将传入的属性一个一个提取然后绑定,这有非常大的局限性,一旦你基础的组件更新了或者需要增加使用的参数则需要再次去修改组件代码
bad ?
import{Input}from'某组件库'exportinterfaceMyInputProps{/** 值 */value:string/** 限制 */limit:number/** 状态 */state:string}constMyInput= (props: PartailMyInputProps) = {const{ value, limit, state } = props// ...一些处理return( Inputvalue={value}limit={limit}state={state} / )}exportdefaultMyInput以extends继承基础组件的所有属性,并用...rest承接所有传入的属性,并绑定到我们的基准组件上。
after good ?
import{Input,InputProps}from'某组件库'exportinterfaceMyInputPropsextendsInputProps{/** 值 */value:string}constMyInput= (props: PartialMyInputProps) = {const{ value, ...rest } = props// ...一些处理return( Inputvalue={value}{...rest} / )}exportdefaultMyInput7.事件配套原则任何组件内部操作导致UI视图改变都需要有配套的事件,来给使用者提供全量的触发钩子,提高组件的可用性
bad ?
exportdefaultfuntionMyInput(props:MyInputProps) {// ...省略部分代码const[open, setOpen] =useState(false)const[showDetail, setShowDetail] =useState(false)constcurrClassName =classNames(className, { `${prefixCls}-box`:true, `${prefixCls}-open`: open,// 是否采用打开样式 })constonCheckOpen= () = { setOpen(!open) }constonShowDetail= () = { setShowDetail(!showDetail) }return( divclassName={currClassName}style={style}onClick={onCheckOpen} spanonClick={onShowDetail}{showDetail ? '123' : '...'}/span /div};所有组件内部会影响外部 UI 改变的事件都预留了钩子
after good ?
exportdefaultfuntionMyInput(props:MyInputProps) {const{ onChange, onShowChange } = props// ...省略部分代码const[open, setOpen] =useState(false)const[showDetail, setShowDetail] =useState(false)// ...省略部分代码constcurrClassName =classNames(className, { `${prefixCls}-box`:true, `${prefixCls}-open`: open,// 是否采用打开样式 })constonCheckOpen= () = { setOpen(!open) onChange?.(!open)// 实现组件内部open改变的事件钩子 }constonShowDetail= () = { setShowDetail(!showDetail) onShowChange?.(!showDetail)// 实现组件详情展示改变的事件钩子 }return( divclassName={currClassName}style={style}onClick={onCheckOpen} spanonClick={onShowDetail}{showDetail ? '123' : '...'}/span /div};8.ref 绑定原则任何书写的组件在有可能绑定ref情况下都需要暴露有ref属性,不然使用者一旦挂载ref则会导致控制台报错警告。
原创组件:useImperativeHandle 或 直接 ref 绑定组件根节点interfaceChcInputRef{/** 值 */setValidView:(isShow?:boolean) =void,/** 值 */field:Field}constChcInput= forwardRefChcInputRef,MyProps((props, ref) ={const{ className, ...rest } = props;useImperativeHandle(ref,() =({ setValidView(isShow =false) { setIsCheckBalloonVisible(isShow); }, field }), []);return( divclassName={displayClassName} ... /div});exportdefaultChcInputconstChcInput=forwardRef((props: MyProps, ref: React.LegacyRefHTMLDivElement) ={const{ className, ...rest } = props;constdisplayClassName =classNames('chc-input', className);return( divref={ref}className={displayClassName} span/span ... /div});exportdefaultChcInput二次封装组件:则直接 ref 绑定在原基础组件上 或 组件根节点import{Input}from'某组件库'constChcInput=forwardRef((props: InputProps, ref: React.LegacyRefInput) ={const{ className, ...rest } = props;constdisplayClassName =classNames('chc-input', className);returnInputref={ref}className={displayClassName}{...rest} /;});exportdefaultChcInput9.自定义扩展性原则在组件封装时,遇到组件内部会用一些固定逻辑来渲染 UI 或者计算时,最好预留一个使用者可以随意自定义的入口,而不是只能死板采用组件内部逻辑,这样可以
增加组件的扩展灵活性减少迭代修改bad ?
exportdefaultfuntionMyInput(props:MyInputProps) {const{ value } = propsconstdetailText =useMemo(() ={ returnvalue.split(',').map(item=`组件内部复杂的逻辑:${item}`).join('\n') }, [value])return( div span{detailText}/span /div};after good ?
exportdefaultfuntionMyInput(props:MyInputProps) {const{ value, render } = propsconstdetailText =useMemo(() ={ // render 用户自定义渲染 returnrender ?render(value) : value.split(',').map(item=`组件内部复杂的逻辑:${item}`).join('\n') }, [value])return( div span{detailText}/span /div};同理复杂的 ui 渲染也可以采用用户自定义传入render方法的方式进行扩展
10.受控与非受控模式原则对于 react 组件,我们往往都会要求组件在设计时需要包含受控和非受控两个模式。
非受控: 的情况可以实现更加方便的使用组件
受控: 的情况可以实现更加灵活的使用组件,以增加组件的可用性
bad ?(只有一种受控模式)
importclassNamesfrom'classnames';constprefixCls ='my-input'exportdefaultfuntionMyInput(props:MyInputProps) {const{ value, className, style, onChange } = propsconstcurrClassName =classNames(className, { `${prefixCls}-box`:true, `${prefixCls}-open`: value,// 是否采用打开样式 })constonCheckOpen= () = { onChange?.(!value) }return( divclassName={currClassName}style={style}onClick={onCheckOpen} span12312/span /div};after good ?
importclassNamesfrom'classnames';constprefixCls ='my-input'exportdefaultfuntionMyInput(props:MyInputProps) {const{ value, defaultValue =true, className, style, onChange } = props// 实现非受控模式const[open, setOpen] =useState(value || defaultValue)useEffect(() ={ if(typeofvalue !=='boolean')return setOpen(value) }, [value])constcurrClassName =classNames(className, { `${prefixCls}-box`:true, `${prefixCls}-open`: open,// 是否采用打开样式 })constonCheckOpen= () = { onChange?.(!open) // 非受控模式下 组件内部自身处理 if(typeofvalue !=='boolean') { setOpen(!open) } }return( divclassName={currClassName}style={style}onClick={onCheckOpen} span12312/span /div};11.最小依赖原则所有组件封装都要遵循最小依赖原则,在条件允许的情况下,简单的方法需要引入新的依赖的情况下采用手写方式。这样避免开发出非常依赖融于的组件或组件库
bad ?
import{ useLatest }from'ahooks'// 之前组件库无ahooks, 会引入新的依赖!importclassNamesfrom'classnames';constChcInput=forwardRef((props: InputProps, ref: React.LegacyRefInput) ={const{ className, ...rest } = props;constdisplayClassName =classNames('chc-input', className);constfuncRef =useLatest(func);// 解决回调内无法获取最新state问题returndivclassName={displayClassName}{...rest}/div;});exportdefaultChcInputafter good ?
// hooks/index.tsximport{ useRef }from'react';exportfunctionuseLatest(value) {constref =useRef(value); ref.current= value;return}...// 组件import{ useLatest }from'@/hooks'// 之前组件库无ahooks引入新的依赖!importclassNamesfrom'classnames';constChcInput=forwardRef((props: InputProps, ref: React.LegacyRefInput) ={const{ className, ...rest } = props;constdisplayClassName =classNames('chc-input', className);constfuncRef =useLatest(func);// 解决回调内无法获取最新state问题returndivclassName={displayClassName}{...rest}/div;});exportdefaultChcInput当然依赖包是否引入也要参考当时的使用情况,比如如果ahooks在公司内部基本都会使用,那这个时候引入也无妨。
12.功能拆分,单一职责原则如果一个组件内部能力很强大,可能包含多个功能点,不建议将所有能力都只在组件内部体现,可以将这些功能拆分成其他的公共组件, 一个组件只处理一个功能点(单一职责原则),提高功能的复用性和灵活性。
当然业务组件除外,业务组件可以在组件内实现多个组件的整合完成一个业务能力的单一职责。
bad ?
constMyShowPage=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, imgList, ...rest } = props;return( div Tableref={ref}data={data}{...rest} {/* 表格显示相关功能封装 ...省略一堆代码 */} /Table div {/* 图例相关功能封装 ...省略一堆代码 */} /div /div )});将表格和图例两个功能点拆分成单独的两个公共组件
after good ?
constMyShowPage=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, imgList, ...rest } = props;return( div {/* 表格组件只处理表格内容 */} MyTableref={ref}data={data}{...rest}/Table {/* 图片组件只处理图片展示能力 */} MyImgdata={imgList} /div )});当然如果完全没有复用价值的组件或功能点也是没必要拆分的。
13.通用组件去业务,业务组件内置业务组件分为通用组件和业务组件,两者比较有明确的界限
通用组件更看重通用功能性和基本内容展示,组件涵盖的使用范围广业务组件更看重业务的实现,组件的使用范围绑定具体的业务内容1. 通用组件内部不能包含业务组件内部如果包含了业务内容,就会大大失去他的通用性,增加开发者心智负担。
比如:有个通用的 table 组件,负责将传入的数据进行展示,内部封装了当数据值小于 0 时,还是以正数的形式展示,但是使用红色字体:
bad ?
constMyTable=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, columns, ...rest } = props;
constdataRender= (item: ListItem) = { returnMath.abs(item.value) }conststyleRender= (item: ListItem) = { returnitem.value0? {color:'red'} :undefined }consttableColumns =useMemo(() ={ returncolumns.map(item={ if(item.name==='value') { return{ ...item, render: dataRender, styleRender: styleRender } return{ ...item }; }) }, [column])
return( Tableref={ref}data={data}{...rest} {columns.map(column =Table.Column{...column}/)} /Table )});显然这样的逻辑在一个通用组件内是不合理的,业务性太强,开发者在使用的时候还要纳闷为什么值都是正数,难道是接口返回有问题?
通用组件只承接通用的展示能力,上面的业务就放入到使用层去处理即可
after good ?
组件内部:
constMyTable=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, columns, ...rest } = props;
return( Tableref={ref}data={tableData}{...rest} {columns.map(column =Table.Column{...column}/)} /Table )});使用:
constcolumns = [ { title:'名称' name:'name', }, { title:'数值', name:'value', render:item=Math.abs(item.value), style:item=item.value0? {color:'red'} :undefined }, ...]constHome= (props: MyTableProps, ref: React.LegacyRefTable) = {const[list, setList] =useState([]); ...
return( View Tabledata={list}columns={columns}/ /View )};2. 业务组件尽可能的在内部实现业务,降低使用者的使用负担我们在封装业务组件的时候,切忌不可将相关复杂的业务逻辑以及运算放到组件外面由使用者去实现,在组件内部只是一些简单的封装;这很难达到业务组件的价值最大化,业务组件的目的就是聚焦某个业务尽可能的帮开发者快速完成。
比如:有个音乐 table 组件,负责将传入的数据进行一个音乐业务渲染和展示:
bad ?
constMyMusicTable=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, ...rest } = props;return( Tableref={ref}data={data}{...rest} Table.ColumndataIndex="test1"title="标题1"/ Table.ColumndataIndex="test2"title="标题2"/ Table.ColumndataIndex="data"title="值"/ /Table )});但是有一个业务是当数据的type=1时,data 的值要乘 2 展示,则上面的组件使用者只能这样使用:
constres = [...]constdata= useMemo(() = {returnres.map(item = ({ ...item, data: item.type ===1? item.data*2: item.data }))}, [res])return( MyMusicTabledata={data}/)显然这样的封装在使用者这边会有一些心智负担,假如一个不熟悉业务的人来开发很容易会遗漏,所以这个时候需要业务组件内置业务,降低使用者的门槛
after good ?
constMyMusicTable=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, ...rest } = props;constdataRender= (item: ListItem) = { returnitem.type===1? item.data*2: item.data }return( Tableref={ref}data={data}{...rest} Table.ColumndataIndex="test1"title="标题1"/ Table.ColumndataIndex="test2"title="标题2"/ Table.ColumndataIndex="data"title="值"render={dataRender}/ /Table )});使用者无需关心业务也可以顺利圆满完成任务:
constres = [...]return(MyMusicTabledata={res}/)3. 通用组件和业务组件混淆业务开发的时候经常会出现因为样式一样,就把两个毫不相关的业务揉到一个组件里去,通过 if else 隔离,这是一个很不好的行为,这是混淆了业务组件和通用组件的概念,也没做到业务隔离。
如下例子因为光源和声音的 UI 样式差不多,用户将两个功能都封装到了一个LightSound业务组件内:
bad ?
constHome= () = {return( div ... LightSoundisSoundvalue={sound}onChange={onSoundChange}/ LightSoundvalue={light}onChange={onLightChange}/ /div )});LightSound
constLightSound= (props: LightSoundProps) = {const{ show, title, isSound, value, onChange } = props;
constshowValue =useMemo(() ={ if(isSound) { ... }else{ ... } }, [value, isSound])
consthandleChange= () = { if(isSound) { ... }else{ ... } }
return( Popupshow={show}className={styles.lightSound} divclassName={styles.lightSoundContent} divclassName={styles.lightSoundTitle}{title}/div {isSound ? ( divclassName={styles.soundContent} ... /div ): ( divclassName={styles.lightContent} ... /div )} /div /Popup )});正确做法应该是将 光源和声音相似的 UI 抽离成公共组件如下例如MyPopup,然后分别封装光源Light和声音Sound业务组件依赖此公共组件MyPopup
after good ?
constHome= () = {return( div ... Soundvalue={sound}onChange={onSoundChange}/ Lightvalue={light}onChange={onLightChange}/ /div )});Sound
constSound= (props: SoundProps) = {const{ show, value, onChange } = props; ...return( MyPopupshow={show}title={Strings.getLang('soundTitle')} divclassName={styles.soundContent} ... /div /MyPopup )});Light
constLight= (props: LightProps) = {const{ show, value, onChange } = props; ...return( MyPopupshow={show}title={Strings.getLang('lightTitle')} divclassName={styles.lightContent} ... /div /MyPopup )});14.最大深度扩展性当组件传入的数据可能会有树形等有深度的格式,而组件内部也会针对其渲染出有递归深度的 UI 时,需要考虑到使用者对于数据深度的不可控性,组件内部需要预留好无限深度的可能
如下渲染组件方式只有一层的深度,很有局限性
bad ?
interfaceColumnsextendsTableColumnProps{ columns:TableColumnProps[]}constMyTable=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, columns = [], ...rest } = props;constrenderColumn =useMemo(() ={ returncolumns.map(item={ returnitem.columns? ( Table.Column{...item} {item.columns.map(column =Table.Column{...column}/)} /Table.Column ) : Table.Column{...item}/ }) }, [columns])return( Tableref={ref}data={data}{...rest} {renderColumn} /Table )});after good ?
interfaceColumnsextendsTableColumnProps{columns:Columns[]// 改变为继承自己}constMyTable=forwardRef((props: MyTableProps, ref: React.LegacyRefTable) ={const{ data, columns = [], ...rest } = props;return( Tableref={ref}data={data}{...rest} {/* 采用外部组件 */} MyColumncolumns={columns}/ /Table )});constMyColumn= (props: MyColumnProps) = {const{ columns = [] } = propsreturn( item.columns? ( Table.Column{...item} {/* 递归渲染数据,实现数据的深度无限性 */} MyColumncolumns={item.columns}/ /Table.Column ) : Table.Column{...item}/ )}15.多语言可配制化组件内部所有的语言都需要可以修改,兼容多语言的使用场景默认推荐英文内部语言变量较多时可以统一暴露一个例如strings对象参数,其内部可以传入所有可以替换文案的 keystrings={{title:'标题',cancel:'取消', ....}}bad ?
constprefixCls ='my-input'// 统一的组件内部前缀exportdefaultfuntionMyInput(props:MyInputProps) {const{ title ='标题'} = props;return( divclassName={`${prefixCls}-box`} spanclassName={`${prefixCls}-title`}{title}/span spanclassName={`${prefixCls}-detail`}详情/span /div};after good ?
constprefixCls ='my-input'// 统一的组件内部前缀exportdefaultfuntionMyInput(props:MyInputProps) {const{ title ='title', detail ='detail'} = props;return( divclassName={`${prefixCls}-box`} spanclassName={`${prefixCls}-title`}{title}/span spanclassName={`${prefixCls}-detail`}{detail}/span /div};16.异常捕获和提示对于用户传入意外的参数可能带来错误时要控制台 console.error 提示不要直接在组件内部 throw error,这样会导致用户的白屏缺少某些参数或者参数不符合要求但不会导致报错时可以使用 console.warn 提示bad ?
exportdefaultfuntionMyCanvas(props:MyCanvasProps) {const{ instanceId } = props;
useEffect(() ={ initDom(instanceId) }, [])return( div canvasid={instanceId}/ /div};after good ?
exportdefaultfuntionMyCanvas(props:MyCanvasProps) {const{ instanceId } = props;
useEffect(() ={ if(!instanceId){ console.error('missing instanceId!') return } initDom(instanceId) }, [])return( div canvasid={instanceId}/ /div};17.语义化原则组件的命名,组件的 api,方法,包括内部的变量定义都要遵循语义化的原则,严格按照其代表的功能来命名。
AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding
点击"阅读原文"了解详情~
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线