前端必学-完美组件封装原则
此文总结了我多年组件封装经验,以及拜读 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 classNames from 'classnames';export interface CommonProps {/** 自定义类名 */className?: string;/** 自定义内敛样式 */style?: React.CSSProperties;}export interface MyInputProps extends CommonProps {/** 值 */value: any}const MyInput = forwardRef((props: MyInputProps, ref: React.LegacyRef<HTMLDivElement>) => {const { className, ...rest } = props;const displayClassName = classNames('chc-input', className);return (<divref={ref} {...rest} className={displayClassName}><span></span></div>);});export default ChcInput
2.注释使用原则
原则上所有的 props和ref属性类型都需要有注释且所有属性( props和ref属性)禁用// 注释内容语法注释,因为此注释不会被 ts 识别,也就是鼠标悬浮的时候不会出现对应注释文案常用的注视参数 @description描述,@version新属性的起始版本,@deprecated废弃的版本,@default默认值面向国际化使用的组件一般描述语言推荐使用英文
bad ❌
interface MyInputsProps {// 自定义classclassName?: string}const test: MyInputsProps = {}test.className
应该使用如下注释方法
after good ✅
interfaceMyInputsProps{/** custom class */className?: string/*** @description Custom inline style* @version 2.6.0* @default ''*/style?: React.CSSProperties;/*** @description Custom title style* @deprecated 2.5.0 废弃* @default ''*/customTitleStyle?: React.CSSProperties;}const test: MyInputsProps = {}test.className
3.export 暴露
组件 props类型必须export导出如有 useImperativeHandle则ref类型必须export导出组件导出 funtion必须有名称组件 funtion一般export default默认导出
在没有名称的组件报错时不利于定位到具体的报错组件
bad ❌
interface MyInputProps {....}export default (props: MyInputProps) => {return <div></div>;};
after good ✅
// 暴露 MyInputProps 类型export interface MyInputProps {....}funtion MyInput(props: MyInputProps) {return <div></div>;};// 也可以自己挂载一个组件名称if (process.env.NODE_ENV !== 'production') {MyInput.displayName = 'MyInput';}export default MyInput
index.ts
export * from './input'export { default as MyInput } from './input';
当然如果目标组件没有暴露相关的类型,可以通过ComponentProps和ComponentRef来分别获取组件的props和ref属性
type DialogProps = ComponentProps<typeof Dialog>type DialogRef = ComponentRef<typeof Dialog>
4.入参类型约束原则
「入参类型必须遵循具体原则」
确定入参类型的可能情况下,切忌不可用 基本类型一笔带过公共组件一般不使用 枚举作为入参类型,因为这样在使用者需要引入此枚举才可以不报错部分数值类型的参数需要描述最大和最小值
bad ❌
interface InputProps {status: string}
after good ✅
interface InputProps {status: 'success' | 'fail'}
bad ❌
interface InputProps {/** 总数 */count: number}
after good ✅
interface InputProps {/** 总数 0-999 */count: number}
5.class 和 style 定义规则
禁用 CSS module 因为此类写法会让使用者无法修改组件内部样式;vue 的话可以用 scoped 标签来防止样式重复 也可以实现父亲可修改组件内部样式。 书写组件时,内部的 class一定要加上统一的前缀来区分组件内外class,避免和外部的 class 类有重复。class 类的名称需要语意化。 组件内部的所有 class 类都可以被外部使用者改变 禁用 important,不到万不得已不用行内样式 可以为颜色相关 CSS 属性留好 CSS 变量,方便外部开发主题切换
bad ❌
import styles from './index.module.less'export default funtion MyInput(props: MyInputProps) {return (<divclassName={styles.input_box}><spanclassName={styles.detail}>21312312</span></div>);};
after good ✅
import './index.less'const prefixCls = 'my-input' // 统一的组件内部前缀export default funtion MyInput(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 '某组件库'export interface MyInputProps {/** 值 */value: string/** 限制 */limit: number/** 状态 */state: string}const MyInput = (props: Partail<MyInputProps>) => {const { value, limit, state } = props// ...一些处理return (<Inputvalue={value}limit={limit}state={state} />)}export default MyInput
以extends继承基础组件的所有属性,并用...rest 承接所有传入的属性,并绑定到我们的基准组件上。
after good ✅
import { Input, InputProps } from '某组件库'export interface MyInputProps extends InputProps {/** 值 */value: string}const MyInput = (props: Partial<MyInputProps>) => {const { value, ...rest } = props// ...一些处理return (<Inputvalue={value} {...rest} />)}export default MyInput
7.事件配套原则
任何组件内部操作导致UI视图改变都需要有配套的事件,来给使用者提供全量的触发钩子,提高组件的可用性
bad ❌
export default funtion MyInput(props: MyInputProps) {// ...省略部分代码const [open, setOpen] = useState(false)const [showDetail, setShowDetail] = useState(false)const currClassName = classNames(className, {`${prefixCls}-box`: true,`${prefixCls}-open`: open, // 是否采用打开样式})const onCheckOpen = () => {setOpen(!open)}const onShowDetail = () => {setShowDetail(!showDetail)}return (<divclassName={currClassName}style={style}onClick={onCheckOpen}><spanonClick={onShowDetail}>{showDetail ? '123' : '...'}</span></div>);};
所有组件内部会影响外部 UI 改变的事件都预留了钩子
after good ✅
export default funtion MyInput(props: MyInputProps) {const { onChange, onShowChange } = props// ...省略部分代码const [open, setOpen] = useState(false)const [showDetail, setShowDetail] = useState(false)// ...省略部分代码const currClassName = classNames(className, {`${prefixCls}-box`: true,`${prefixCls}-open`: open, // 是否采用打开样式})const onCheckOpen = () => {setOpen(!open)onChange?.(!open) // 实现组件内部open改变的事件钩子}const onShowDetail = () => {setShowDetail(!showDetail)onShowChange?.(!showDetail) // 实现组件详情展示改变的事件钩子}return (<divclassName={currClassName}style={style}onClick={onCheckOpen}><spanonClick={onShowDetail}>{showDetail ? '123' : '...'}</span></div>);};
8.ref 绑定原则
任何书写的组件在有可能绑定ref情况下都需要暴露有ref属性,不然使用者一旦挂载ref则会导致控制台报错警告。
原创组件:useImperativeHandle 或 直接 ref 绑定组件根节点
interface ChcInputRef {/** 值 */setValidView: (isShow?: boolean) => void,/** 值 */field: Field}const ChcInput = forwardRef<ChcInputRef, MyProps>((props, ref) => {const { className, ...rest } = props;useImperativeHandle(ref, () => ({setValidView(isShow = false) {setIsCheckBalloonVisible(isShow);},field}), []);return (<divclassName={displayClassName}>...</div>);});export default ChcInput
const ChcInput = forwardRef((props: MyProps, ref: React.LegacyRef<HTMLDivElement>) => {const { className, ...rest } = props;const displayClassName = classNames('chc-input', className);return (<divref={ref}className={displayClassName}><span></span>...</div>);});export default ChcInput
二次封装组件:则直接 ref 绑定在原基础组件上 或 组件根节点
import { Input } from '某组件库'const ChcInput = forwardRef((props: InputProps, ref: React.LegacyRef<Input>) => {const { className, ...rest } = props;const displayClassName = classNames('chc-input', className);return <Inputref={ref}className={displayClassName} {...rest} />;});export default ChcInput
9.自定义扩展性原则
在组件封装时,遇到组件内部会用一些固定逻辑来渲染 UI 或者计算时,最好预留一个使用者可以随意自定义的入口,而不是只能死板采用组件内部逻辑,这样可以
增加组件的扩展灵活性 减少迭代修改
bad ❌
export default funtion MyInput(props: MyInputProps) {const { value } = propsconst detailText = useMemo(() => {return value.split(',').map(item => `组件内部复杂的逻辑:${item}`).join('\n')}, [value])return (<div><span>{detailText}</span></div>);};
after good ✅
export default funtion MyInput(props: MyInputProps) {const { value, render } = propsconst detailText = useMemo(() => {// render 用户自定义渲染return render ? render(value) : value.split(',').map(item => `组件内部复杂的逻辑:${item}`).join('\n')}, [value])return (<div><span>{detailText}</span></div>);};
同理复杂的 ui 渲染也可以采用用户自定义传入render方法的方式进行扩展
10.受控与非受控模式原则
对于 react 组件,我们往往都会要求组件在设计时需要包含受控和非受控两个模式。非受控: 的情况可以实现更加方便的使用组件受控: 的情况可以实现更加灵活的使用组件,以增加组件的可用性
bad ❌(只有一种受控模式)
import classNames from 'classnames';const prefixCls = 'my-input'export default funtion MyInput(props: MyInputProps) {const { value, className, style, onChange } = propsconst currClassName = classNames(className, {`${prefixCls}-box`: true,`${prefixCls}-open`: value, // 是否采用打开样式})const onCheckOpen = () => {onChange?.(!value)}return (<divclassName={currClassName}style={style}onClick={onCheckOpen}><span>12312</span></div>);};
after good ✅
import classNames from 'classnames';const prefixCls = 'my-input'export default funtion MyInput(props: MyInputProps) {const { value, defaultValue = true, className, style, onChange } = props// 实现非受控模式const [open, setOpen] = useState(value || defaultValue)useEffect(() => {if(typeof value !== 'boolean') returnsetOpen(value)}, [value])const currClassName = classNames(className, {`${prefixCls}-box`: true,`${prefixCls}-open`: open, // 是否采用打开样式})const onCheckOpen = () => {onChange?.(!open)// 非受控模式下 组件内部自身处理if(typeof value !== 'boolean') {setOpen(!open)}}return (<divclassName={currClassName}style={style}onClick={onCheckOpen}><span>12312</span></div>);};
11.最小依赖原则
所有组件封装都要遵循最小依赖原则,在条件允许的情况下,简单的方法需要引入新的依赖的情况下采用手写方式。这样避免开发出非常依赖融于的组件或组件库
bad ❌
import { useLatest } from 'ahooks' // 之前组件库无ahooks, 会引入新的依赖!import classNames from 'classnames';const ChcInput = forwardRef((props: InputProps, ref: React.LegacyRef<Input>) => {const { className, ...rest } = props;const displayClassName = classNames('chc-input', className);const funcRef = useLatest(func); // 解决回调内无法获取最新state问题return <divclassName={displayClassName} {...rest}></div>;});export default ChcInput
after good ✅
// hooks/index.tsximport { useRef } from 'react';export function useLatest(value) {const ref = useRef(value);ref.current = value;return ref;}...// 组件import { useLatest } from '@/hooks' // 之前组件库无ahooks引入新的依赖!import classNames from 'classnames';const ChcInput = forwardRef((props: InputProps, ref: React.LegacyRef<Input>) => {const { className, ...rest } = props;const displayClassName = classNames('chc-input', className);const funcRef = useLatest(func); // 解决回调内无法获取最新state问题return <divclassName={displayClassName} {...rest}></div>;});export default ChcInput
当然依赖包是否引入也要参考当时的使用情况,比如如果ahooks在公司内部基本都会使用,那这个时候引入也无妨。
12.功能拆分,单一职责原则
如果一个组件内部能力很强大,可能包含多个功能点,不建议将所有能力都只在组件内部体现,可以将这些功能拆分成其他的公共组件, 一个组件只处理一个功能点(单一职责原则),提高功能的复用性和灵活性。
当然业务组件除外,业务组件可以在组件内实现多个组件的整合完成一个业务能力的单一职责。
bad ❌
const MyShowPage = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {const { data, imgList, ...rest } = props;return (<div><Tableref={ref}data={data} {...rest}>{/* 表格显示相关功能封装 ...省略一堆代码 */}</Table><div>{/* 图例相关功能封装 ...省略一堆代码 */}</div></div>)});
将表格和图例两个功能点拆分成单独的两个公共组件
after good ✅
const MyShowPage = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {const { data, imgList, ...rest } = props;return (<div>{/* 表格组件只处理表格内容 */}<MyTableref={ref}data={data} {...rest}></Table>{/* 图片组件只处理图片展示能力 */}<MyImgdata={imgList}></div>)});
当然如果完全没有复用价值的组件或功能点也是没必要拆分的。
13.通用组件去业务,业务组件内置业务
组件分为通用组件和业务组件,两者比较有明确的界限
通用组件更看重通用功能性和基本内容展示,组件涵盖的使用范围广 业务组件更看重业务的实现,组件的使用范围绑定具体的业务内容
1. 通用组件内部不能包含业务
组件内部如果包含了业务内容,就会大大失去他的通用性,增加开发者心智负担。
比如:有个通用的 table 组件,负责将传入的数据进行展示,内部封装了当数据值小于 0 时,还是以正数的形式展示,但是使用红色字体:
bad ❌
const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {const { data, columns, ...rest } = props;const dataRender = (item: ListItem) => {return Math.abs(item.value)}const styleRender = (item: ListItem ) => {return item.value < 0 ? { color: 'red' } : undefined}const tableColumns = useMemo(() => {return columns.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 ✅
组件内部:
const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {const { data, columns, ...rest } = props;return (<Tableref={ref}data={tableData} {...rest}>{columns.map(column => <Table.Column {...column}/>)}</Table>)});
使用:
const columns = [{title: '名称'name: 'name',},{title: '数值',name: 'value',render: item => Math.abs(item.value),style: item => item.value < 0 ? { color: 'red' } : undefined},...]const Home = (props: MyTableProps, ref: React.LegacyRef<Table>) => {const [list, setList] = useState([]);...return (<View><Tabledata={list}columns={columns} /></View>)};
2. 业务组件尽可能的在内部实现业务,降低使用者的使用负担
我们在封装业务组件的时候,切忌不可将相关复杂的业务逻辑以及运算放到组件外面由使用者去实现,在组件内部只是一些简单的封装;这很难达到业务组件的价值最大化,业务组件的目的就是聚焦某个业务尽可能的帮开发者快速完成。
比如:有个音乐 table 组件,负责将传入的数据进行一个音乐业务渲染和展示:
bad ❌
const MyMusicTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {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 展示,则上面的组件使用者只能这样使用:
const res = [...]const data = useMemo(() => {return res.map(item => ({...item,data: item.type === 1 ? item.data * 2 : item.data}))}, [res])return (<MyMusicTable data={data}/>)
显然这样的封装在使用者这边会有一些心智负担,假如一个不熟悉业务的人来开发很容易会遗漏,所以这个时候需要业务组件内置业务,降低使用者的门槛
after good ✅
const MyMusicTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {const { data, ...rest } = props;const dataRender = (item: ListItem) => {return item.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>)});
使用者无需关心业务也可以顺利圆满完成任务:
const res = [...]return (<MyMusicTabledata={res}/>)
3. 通用组件和业务组件混淆
业务开发的时候经常会出现因为样式一样,就把两个毫不相关的业务揉到一个组件里去,通过 if else 隔离,这是一个很不好的行为,这是混淆了业务组件和通用组件的概念,也没做到业务隔离。
如下例子因为光源和声音的 UI 样式差不多,用户将两个功能都封装到了一个 LightSound 业务组件内:
bad ❌
const Home = () => {return (<div>...<LightSoundisSoundvalue={sound}onChange={onSoundChange} /><LightSoundvalue={light}onChange={onLightChange} /></div>)});
LightSound
const LightSound = (props: LightSoundProps) => {const { show, title, isSound, value, onChange } = props;const showValue = useMemo(() => {if(isSound) {...} else {...}}, [value, isSound])const handleChange = () => {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 ✅
const Home = () => {return (<div>...<Soundvalue={sound}onChange={onSoundChange} /><Lightvalue={light}onChange={onLightChange} /></div>)});
Sound
const Sound = (props: SoundProps) => {const { show, value, onChange } = props;...return (<MyPopupshow={show}title={Strings.getLang('soundTitle')}><divclassName={styles.soundContent}>...</div></MyPopup>)});
Light
const Light = (props: LightProps) => {const { show, value, onChange } = props;...return (<MyPopupshow={show}title={Strings.getLang('lightTitle')}><divclassName={styles.lightContent}>...</div></MyPopup>)});
14.最大深度扩展性
当组件传入的数据可能会有树形等有深度的格式,而组件内部也会针对其渲染出有递归深度的 UI 时,需要考虑到使用者对于数据深度的不可控性,组件内部需要预留好无限深度的可能
如下渲染组件方式只有一层的深度,很有局限性
bad ❌
interface Columns extends TableColumnProps {columns: TableColumnProps[]}const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {const { data, columns = [], ...rest } = props;const renderColumn = useMemo(() => {return columns.map(item => {return item.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 ✅
interface Columns extends TableColumnProps {columns: Columns[] // 改变为继承自己}const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {const { data, columns = [], ...rest } = props;return (<Tableref={ref}data={data} {...rest}>{/* 采用外部组件 */}<MyColumncolumns={columns}/></Table>)});const MyColumn = (props: MyColumnProps) => {const { columns = [] } = propsreturn (item.columns ? (<Table.Column {...item}>{/* 递归渲染数据,实现数据的深度无限性 */}<MyColumncolumns={item.columns}/></Table.Column>) :<Table.Column {...item}/>)}
15.多语言可配制化
组件内部所有的语言都需要可以修改,兼容多语言的使用场景 默认推荐英文 内部语言变量较多时可以统一暴露一个例如 strings对象参数,其内部可以传入所有可以替换文案的 key
strings={{title: '标题',cancel: '取消',....}}
bad ❌
const prefixCls = 'my-input' // 统一的组件内部前缀export default funtion MyInput(props: MyInputProps) {const { title = '标题' } = props;return (<divclassName={`${prefixCls}-box`}><spanclassName={`${prefixCls}-title`}>{title}</span><spanclassName={`${prefixCls}-detail`}>详情</span></div>);};
after good ✅
const prefixCls = 'my-input' // 统一的组件内部前缀export default funtion MyInput(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 ❌
export default funtion MyCanvas(props: MyCanvasProps) {const { instanceId } = props;useEffect(() => {initDom(instanceId)}, [])return (<div><canvasid={instanceId} /></div>);};
after good ✅
export default funtion MyCanvas(props: MyCanvasProps) {const { instanceId } = props;useEffect(() => {if(!instanceId){console.error('missing instanceId!')return}initDom(instanceId)}, [])return (<div><canvasid={instanceId} /></div>);};
17.语义化原则
组件的命名,组件的 api,方法,包括内部的变量定义都要遵循语义化的原则,严格按照其代表的功能来命名。