> 技术文档 > 前端必学-完美组件封装原则

前端必学-完美组件封装原则

此文总结了我多年组件封装经验,以及拜读 antdelement-plusvantfusion等多个知名组件库所提炼的完美组件封装的经验;是一个开发者在封装项目组件,公共组件等场景时非常有必要遵循的一些原则,希望和大家一起探讨,也希望世界上少一些半吊子组件

下面以react为例,但是思路是相通的,在vue上也适用

1. 基本属性绑定原则

任何组件都需要继承classNamestyle 两个属性

import 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) => { const { className, ...rest } = props; const displayClassName = classNames(\'chc-input\', className); return ( 
);});export default ChcInput

2. 注释使用原则

  • 原则上所有的propsref属性类型都需要有注释
  • 且所有属性(propsref属性)禁用 // 注释内容 语法注释,因为此注释不会被ts识别,也就是鼠标悬浮的时候不会出现对应注释文案
  • 常用的注视参数 @description 描述, @version 新属性的起始版本, @deprecated 废弃的版本, @default 默认值
  • 面向国际化使用的组件一般描述语言推荐使用英文

bad ❌

interface MyInputsProps { // 自定义class className?: string}const test: MyInputsProps = {}test.className

应该使用如下注释方法

after good ✅

interface MyInputsProps { /** 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 
;};

after good ✅

// 暴露 MyInputProps 类型export interface MyInputProps { ....}funtion MyInput(props: MyInputProps) { return 
;};// 也可以自己挂载一个组件名称if (process.env.NODE_ENV !== \'production\') { MyInput.displayName = \'MyInput\';}export default MyInput

index.ts

export * from \'./input\'export { default as MyInput } from \'./input\';

当然如果目标组件没有暴露相关的类型,可以通过ComponentPropsComponentRef来分别获取组件的propsref属性

type DialogProps = ComponentProps type DialogRef = ComponentRef 

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 ( 
21312312
);};

after good ✅

import \'./index.less\'const prefixCls = \'my-input\' // 统一的组件内部前缀export default funtion MyInput(props: MyInputProps) { return ( 
21312312
);};

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) => { const { value, limit, state } = props // ...一些处理 return (  )}export default MyInput

extends继承基础组件的所有属性,并用...rest 承接所有传入的属性,并绑定到我们的基准组件上。

after good ✅

import { Input, InputProps } from \'某组件库\'export interface MyInputProps extends InputProps { /** 值 */ value: string}const MyInput = (props: Partial) => { const { value, ...rest } = props // ...一些处理 return (  )}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 ( 
{showDetail ? \'123\' : \'...\'}
);};

所有组件内部会影响外部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 ( 
{showDetail ? \'123\' : \'...\'}
);};

8. ref绑定原则

任何书写的组件在有可能绑定ref情况下都需要暴露有ref属性,不然使用者一旦挂载ref则会导致控制台报错警告。

  • 原创组件:useImperativeHandle 或 直接ref绑定组件根节点
interface ChcInputRef { /** 值 */ setValidView: (isShow?: boolean) => void, /** 值 */ field: Field}const ChcInput = forwardRef((props, ref) => { const { className, ...rest } = props; useImperativeHandle(ref, () => ({ setValidView(isShow = false) { setIsCheckBalloonVisible(isShow); }, field }), []); return ( 
...
);});export default ChcInput

const ChcInput = forwardRef((props: MyProps, ref: React.LegacyRef) => { const { className, ...rest } = props; const displayClassName = classNames(\'chc-input\', className); return ( 
...
);});export default ChcInput
  • 二次封装组件:则直接ref绑定在原基础组件上 或 组件根节点
import { Input } from \'某组件库\'const ChcInput = forwardRef((props: InputProps, ref: React.LegacyRef) => { const { className, ...rest } = props; const displayClassName = classNames(\'chc-input\', className); return ;});export default ChcInput

9. 自定义扩展性原则

在组件封装时,遇到组件内部会用一些固定逻辑来渲染UI或者计算时,最好预留一个使用者可以随意自定义的入口,而不是只能死板采用组件内部逻辑,这样可以

  1. 增加组件的扩展灵活性
  2. 减少迭代修改

bad ❌

export default funtion MyInput(props: MyInputProps) { const { value } = props const detailText = useMemo(() => { return value.split(\',\').map(item => `组件内部复杂的逻辑:${item}`).join(\'\\n\') }, [value]) return ( 
{detailText}
);};

after good ✅

export default funtion MyInput(props: MyInputProps) { const { value, render } = props const detailText = useMemo(() => { // render 用户自定义渲染 return render ? render(value) : value.split(\',\').map(item => `组件内部复杂的逻辑:${item}`).join(\'\\n\') }, [value]) return ( 
{detailText}
);};

同理复杂的ui渲染也可以采用用户自定义传入render方法的方式进行扩展

10. 受控与非受控模式原则

对于react组件,我们往往都会要求组件在设计时需要包含受控非受控两个模式。
非受控: 的情况可以实现更加方便的使用组件
受控: 的情况可以实现更加灵活的使用组件,以增加组件的可用性

bad ❌(只有一种受控模式)

import classNames from \'classnames\';const prefixCls = \'my-input\' export default funtion MyInput(props: MyInputProps) { const { value, className, style, onChange } = props const currClassName = classNames(className, { `${prefixCls}-box`: true, `${prefixCls}-open`: value, // 是否采用打开样式 }) const onCheckOpen = () => { onChange?.(!value) } return ( 
12312
);};

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\') return setOpen(value) }, [value]) const currClassName = classNames(className, { `${prefixCls}-box`: true, `${prefixCls}-open`: open, // 是否采用打开样式 }) const onCheckOpen = () => { onChange?.(!open) // 非受控模式下 组件内部自身处理 if(typeof value !== \'boolean\') { setOpen(!open) } } return ( 
12312
);};

11. 最小依赖原则

所有组件封装都要遵循最小依赖原则,在条件允许的情况下,简单的方法需要引入新的依赖的情况下采用手写方式。这样避免开发出非常依赖融于的组件或组件库

bad ❌

import { useLatest } from \'ahooks\' // 之前组件库无ahooks, 会引入新的依赖!import classNames from \'classnames\';const ChcInput = forwardRef((props: InputProps, ref: React.LegacyRef) => { const { className, ...rest } = props; const displayClassName = classNames(\'chc-input\', className); const funcRef = useLatest(func); // 解决回调内无法获取最新state问题 return 
;});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) => { const { className, ...rest } = props; const displayClassName = classNames(\'chc-input\', className); const funcRef = useLatest(func); // 解决回调内无法获取最新state问题 return 
;});export default ChcInput

当然依赖包是否引入也要参考当时的使用情况,比如如果ahooks在公司内部基本都会使用,那这个时候引入也无妨。

12. 功能拆分,单一职责原则

如果一个组件内部能力很强大,可能包含多个功能点,不建议将所有能力都只在组件内部体现,可以将这些功能拆分成其他的公共组件, 一个组件只处理一个功能点(单一职责原则),提高功能的复用性和灵活性。
当然业务组件除外,业务组件可以在组件内实现多个组件的整合完成一个业务能力的单一职责。

bad ❌

const MyShowPage = forwardRef((props: MyTableProps, ref: React.LegacyRef) => { const { data, imgList, ...rest } = props; return ( 
{/* 表格显示相关功能封装 ...省略一堆代码 */}
{/* 图例相关功能封装 ...省略一堆代码 */}
)});

表格图例两个功能点拆分成单独的两个公共组件

after good ✅

const MyShowPage = forwardRef((props: MyTableProps, ref: React.LegacyRef) => { const { data, imgList, ...rest } = props; return ( 
{/* 表格组件只处理表格内容 */}
{/* 图片组件只处理图片展示能力 */}
)});

当然如果完全没有复用价值的组件或功能点也是没必要拆分的。

13. 业务组件去业务化

我们在封装业务组件的时候,切忌不可将相关复杂的业务逻辑以及运算放到组件外面由使用者去实现,在组件内部只是一些简单的封装;这很难达到业务组件的价值最大化,组件的通用性会降低,使用心智负担也会加大。

比如:有个table组件,负责将传入的数据进行一个业务渲染和展示:

bad ❌

const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef) => { const { data, ...rest } = props; return ( 
)});

但是有一个业务是当数据的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 ( )

显然这样的封装在使用者这边会有一些心智负担,假如一个不熟悉业务的人来开发很容易会遗漏,所以这个时候需要业务组件去业务化,降低使用者的门槛

after good ✅

const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef) => { const { data, ...rest } = props; const dataRender = (item: ListItem) => { return item.type === 1 ? item.data*2 : item.data } return ( 
)});

使用者无需关心业务也可以顺利圆满完成任务:

const res = [...]return ( )

14. 最大深度扩展性

当组件传入的数据可能会有树形等有深度的格式,而组件内部也会针对其渲染出有递归深度的UI时,需要考虑到使用者对于数据深度的不可控性,组件内部需要预留好无限深度的可能
如下渲染组件方式只有一层的深度,很有局限性

bad ❌

interface Columns extends TableColumnProps { columns: TableColumnProps[]}const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef) => { const { data, columns = [], ...rest } = props; const renderColumn = useMemo(() => { return columns.map(item => { return item.columns ? ( 
{item.columns.map(column =>
)}
) : }) }, [columns]) return (
{renderColumn}
)});

after good ✅

interface Columns extends TableColumnProps { columns: Columns[] // 改变为继承自己}const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef) => { const { data, columns = [], ...rest } = props; return ( 
{/* 采用外部组件 */}
)});const MyColumn = (props: MyColumnProps) => { const { columns = [] } = props return ( item.columns ? ( {/* 递归渲染数据,实现数据的深度无限性 */}
) : )}

15. 多语言可配制化

  • 组件内部所有的语言都需要可以修改,兼容多语言的使用场景
  • 默认推荐英文
  • 内部语言变量较多时可以统一暴露一个例如 strings 对象参数,其内部可以传入所有可以替换文案的key

bad ❌

const prefixCls = \'my-input\' // 统一的组件内部前缀export default funtion MyInput(props: MyInputProps) { const { title = \'标题\' } = props; return ( 
{title} 详情
);};

after good ✅

const prefixCls = \'my-input\' // 统一的组件内部前缀export default funtion MyInput(props: MyInputProps) { const { title = \'title\', detail = \'detail\' } = props; return ( 
{title} {detail}
);};

16.异常捕获和提示

  • 对于用户传入意外的参数可能带来错误时要控制台 console.error 提示
  • 不要直接在组件内部 throw error,这样会导致用户的白屏
  • 缺少某些参数或者参数不符合要求但不会导致报错时可以使用 console.warn 提示

bad ❌

export default funtion MyCanvas(props: MyCanvasProps) { const { instanceId } = props; useEffect(() => { initDom(instanceId) }, []) return ( 
);};

after good ✅

export default funtion MyCanvas(props: MyCanvasProps) { const { instanceId } = props; useEffect(() => { if(!instanceId){ console.error(\'missing instanceId!\') return } initDom(instanceId) }, []) return ( 
);};

17. 语义化原则

组件的命名,组件的api,方法,包括内部的变量定义都要遵循语义化的原则,严格按照其代表的功能来命名。