前端必學之完美組件封裝原則(適用react、vue)
前言
此文總結了我多年組件封裝經(jīng)驗,以及拜讀 antd、element-plus、vant、fusion等多個知名組件庫所提煉的完美組件封裝的經(jīng)驗;是一個開發(fā)者在封裝項目組件,公共組件等場景時非常有必要遵循的一些原則,希望和大家一起探討,也希望世界上少一些半吊子組件
下面以react為例,但是思路是相通的,在vue上也適用
1. 基本屬性綁定原則
任何組件都需要繼承className, style 兩個屬性
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<HTMLDivElement>) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
return (
<div ref={ref} {...rest} className={displayClassName}>
<span></span>
</div>
);
});
export default ChcInput2. 注釋使用原則
- 原則上所有的
props和ref屬性類型都需要有注釋 - 且所有屬性(
props和ref屬性)禁用// 注釋內容語法注釋,因為此注釋不會被ts識別,也就是鼠標懸浮的時候不會出現(xiàn)對應注釋文案 - 常用的注視參數(shù)
@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 <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 MyInputindex.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. 入?yún)㈩愋图s束原則
入?yún)㈩愋捅仨氉裱唧w原則
- 確定入?yún)㈩愋偷目赡芮闆r下,切忌不可用基本類型一筆帶過
- 公共組件一般不使用枚舉作為入?yún)㈩愋停驗檫@樣在使用者需要引入此枚舉才可以不報錯
- 部分數(shù)值類型的參數(shù)需要描述最大和最小值
bad ?
interface InputProps {
status: string
}after good ?
interface InputProps {
status: 'success' | 'fail'
}bad ?
interface InputProps {
/** 總數(shù) */
count: number
}after good ?
interface InputProps {
/** 總數(shù) 0-999 */
count: number
}5. class和style定義規(guī)則
- 禁用 CSS module 因為此類寫法會讓使用者無法修改組件內部樣式;vue 的話可以用 scoped 標簽來防止樣式重復 也可以實現(xiàn)父親可修改組件內部樣式。
- 書寫組件時,內部的
class一定要加上統(tǒng)一的前綴來區(qū)分組件內外class,避免和外部的 class 類有重復。 - class 類的名稱需要語意化。
- 組件內部的所有 class 類都可以被外部使用者改變
- 禁用 important,不到萬不得已不用行內樣式
- 可以為顏色相關 CSS 屬性留好 CSS 變量,方便外部開發(fā)主題切換
bad ?
import styles from './index.module.less'
export default funtion MyInput(props: MyInputProps) {
return (
<div className={styles.input_box}>
<span className={styles.detail}>21312312</span>
</div>
);
};after good ?
import './index.less'
const prefixCls = 'my-input' // 統(tǒng)一的組件內部前綴
export default funtion MyInput(props: MyInputProps) {
return (
<div className={`${prefixCls}-box`}>
<span className={`${prefixCls}-detail`}>21312312</span>
</div>
);
};after good ?
.my-input-box {
height: 100px;
background: var(--my-input-box-background, #000);
}6. 繼承透傳原則
書寫組件時如果進行了二次封裝切忌不可將傳入的屬性一個一個提取然后綁定,這有非常大的局限性,一旦你基礎的組件更新了或者需要增加使用的參數(shù)則需要再次去修改組件代碼
bad ?
import { Input } from '某組件庫'
export interface MyInputProps {
/** 值 */
value: string
/** 限制 */
limit: number
/** 狀態(tài) */
state: string
}
const MyInput = (props: Partail<MyInputProps>) => {
const { value, limit, state } = props
// ...一些處理
return (
<Input value={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 (
<Input value={value} {...rest} />
)
}
export default MyInput7.事件配套原則
任何組件內部操作導致UI視圖改變都需要有配套的事件,來給使用者提供全量的觸發(fā)鉤子,提高組件的可用性
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 (
<div className={currClassName} style={style} onClick={onCheckOpen}>
<span onClick={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) // 實現(xiàn)組件內部open改變的事件鉤子
}
const onShowDetail = () => {
setShowDetail(!showDetail)
onShowChange?.(!showDetail) // 實現(xiàn)組件詳情展示改變的事件鉤子
}
return (
<div className={currClassName} style={style} onClick={onCheckOpen}>
<span onClick={onShowDetail}>{showDetail ? '123' : '...'}</span>
</div>
);
};8. ref綁定原則
任何書寫的組件在有可能綁定ref情況下都需要暴露有ref屬性,不然使用者一旦掛載ref則會導致控制臺報錯警告。
- 原創(chuàng)組件:useImperativeHandle 或 直接ref綁定組件根節(jié)點
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 (
<div className={displayClassName}>
...
</div>
);
});
export default ChcInputconst ChcInput = forwardRef((props: MyProps, ref: React.LegacyRef<HTMLDivElement>) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
return (
<div ref={ref} className={displayClassName}>
<span></span>
...
</div>
);
});
export default ChcInput- 二次封裝組件:則直接ref綁定在原基礎組件上 或 組件根節(jié)點
import { Input } from '某組件庫'
const ChcInput = forwardRef((props: InputProps, ref: React.LegacyRef<Input>) => {
const { className, ...rest } = props;
const displayClassName = classNames('chc-input', className);
return <Input ref={ref} className={displayClassName} {...rest} />;
});
export default ChcInput9. 自定義擴展性原則
在組件封裝時,遇到組件內部會用一些固定邏輯來渲染UI或者計算時,最好預留一個使用者可以隨意自定義的入口,而不是只能死板采用組件內部邏輯,這樣可以
- 增加組件的擴展靈活性
- 減少迭代修改
bad ?
export default funtion MyInput(props: MyInputProps) {
const { value } = props
const 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 } = props
const 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組件,我們往往都會要求組件在設計時需要包含受控和非受控兩個模式。
- 非受控: 的情況可以實現(xiàn)更加方便的使用組件
- 受控: 的情況可以實現(xiàn)更加靈活的使用組件,以增加組件的可用性
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 (
<div className={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
// 實現(xiàn)非受控模式
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 (
<div className={currClassName} style={style} onClick={onCheckOpen}>
<span>12312</span>
</div>
);
};11. 最小依賴原則
所有組件封裝都要遵循最小依賴原則,在條件允許的情況下,簡單的方法需要引入新的依賴的情況下采用手寫方式。這樣避免開發(fā)出非常依賴融于的組件或組件庫
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 <div className={displayClassName} {...rest}></div>;
});
export default ChcInputafter good ?
// hooks/index.tsx
import { 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 <div className={displayClassName} {...rest}></div>;
});
export default ChcInput當然依賴包是否引入也要參考當時的使用情況,比如如果ahooks在公司內部基本都會使用,那這個時候引入也無妨。
12. 功能拆分,單一職責原則
如果一個組件內部能力很強大,可能包含多個功能點,不建議將所有能力都只在組件內部體現(xiàn),可以將這些功能拆分成其他的公共組件, 一個組件只處理一個功能點(單一職責原則),提高功能的復用性和靈活性。
當然業(yè)務組件除外,業(yè)務組件可以在組件內實現(xiàn)多個組件的整合完成一個業(yè)務能力的單一職責。
bad ?
const MyShowPage = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {
const { data, imgList, ...rest } = props;
return (
<div>
<Table ref={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>
{/* 表格組件只處理表格內容 */}
<MyTable ref={ref} data={data} {...rest}></Table>
{/* 圖片組件只處理圖片展示能力 */}
<MyImg data={imgList}>
</div>
)
});
當然如果完全沒有復用價值的組件或功能點也是沒必要拆分的。
13. 業(yè)務組件去業(yè)務化
我們在封裝業(yè)務組件的時候,切忌不可將相關復雜的業(yè)務邏輯以及運算放到組件外面由使用者去實現(xiàn),在組件內部只是一些簡單的封裝;這很難達到業(yè)務組件的價值最大化,組件的通用性會降低,使用心智負擔也會加大。
比如:有個table組件,負責將傳入的數(shù)據(jù)進行一個業(yè)務渲染和展示:
bad ?
const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {
const { data, ...rest } = props;
return (
<Table ref={ref} data={data} {...rest}>
<Table.Column dataIndex="test1" title="標題1"/>
<Table.Column dataIndex="test2" title="標題2"/>
<Table.Column dataIndex="data" title="值"/>
</Table>
)
});但是有一個業(yè)務是當數(shù)據(jù)的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 (
<MyTable data={data}/>
)顯然這樣的封裝在使用者這邊會有一些心智負擔,假如一個不熟悉業(yè)務的人來開發(fā)很容易會遺漏,所以這個時候需要業(yè)務組件去業(yè)務化,降低使用者的門檻
after good ?
const MyTable = 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 (
<Table ref={ref} data={data} {...rest}>
<Table.Column dataIndex="test1" title="標題1"/>
<Table.Column dataIndex="test2" title="標題2"/>
<Table.Column dataIndex="data" title="值" render={dataRender}/>
</Table>
)
});使用者無需關心業(yè)務也可以順利圓滿完成任務:
const res = [...]
return (
<MyTable data={res}/>
)14. 最大深度擴展性
當組件傳入的數(shù)據(jù)可能會有樹形等有深度的格式,而組件內部也會針對其渲染出有遞歸深度的UI時,需要考慮到使用者對于數(shù)據(jù)深度的不可控性,組件內部需要預留好無限深度的可能
如下渲染組件方式只有一層的深度,很有局限性
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 (
<Table ref={ref} data={data} {...rest}>
{renderColumn}
</Table>
)
});after good ?
interface Columns extends TableColumnProps {
columns: Columns[] // 改變?yōu)槔^承自己
}
const MyTable = forwardRef((props: MyTableProps, ref: React.LegacyRef<Table>) => {
const { data, columns = [], ...rest } = props;
return (
<Table ref={ref} data={data} {...rest}>
{/* 采用外部組件 */}
<MyColumn columns={columns}/>
</Table>
)
});
const MyColumn = (props: MyColumnProps) => {
const { columns = [] } = props
return (
item.columns ? (
<Table.Column {...item}>
{/* 遞歸渲染數(shù)據(jù),實現(xiàn)數(shù)據(jù)的深度無限性 */}
<MyColumn columns={item.columns}/>
</Table.Column>
) :
<Table.Column {...item}/>
)
}15. 多語言可配制化
- 組件內部所有的語言都需要可以修改,兼容多語言的使用場景
- 默認推薦英文
- 內部語言變量較多時可以統(tǒng)一暴露一個例如
strings對象參數(shù),其內部可以傳入所有可以替換文案的key
bad ?
const prefixCls = 'my-input' // 統(tǒng)一的組件內部前綴
export default funtion MyInput(props: MyInputProps) {
const { title = '標題' } = props;
return (
<div className={`${prefixCls}-box`}>
<span className={`${prefixCls}-title`}>{title}</span>
<span className={`${prefixCls}-detail`}>詳情</span>
</div>
);
};after good ?
const prefixCls = 'my-input' // 統(tǒng)一的組件內部前綴
export default funtion MyInput(props: MyInputProps) {
const { title = 'title', detail = 'detail' } = props;
return (
<div className={`${prefixCls}-box`}>
<span className={`${prefixCls}-title`}>{title}</span>
<span className={`${prefixCls}-detail`}>{detail}</span>
</div>
);
};16.異常捕獲和提示
- 對于用戶傳入意外的參數(shù)可能帶來錯誤時要控制臺 console.error 提示
- 不要直接在組件內部 throw error,這樣會導致用戶的白屏
- 缺少某些參數(shù)或者參數(shù)不符合要求但不會導致報錯時可以使用 console.warn 提示
bad ?
export default funtion MyCanvas(props: MyCanvasProps) {
const { instanceId } = props;
useEffect(() => {
initDom(instanceId)
}, [])
return (
<div>
<canvas id={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>
<canvas id={instanceId} />
</div>
);
};17. 語義化原則
組件的命名,組件的api,方法,包括內部的變量定義都要遵循語義化的原則,嚴格按照其代表的功能來命名。
到此這篇關于前端必學之完美組件封裝原則的文章就介紹到這了,更多相關前端組件封裝原則內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
React使用Electron開發(fā)桌面端的詳細流程步驟
React是一個流行的JavaScript庫,用于構建Web應用程序,結合Electron框架,可以輕松地將React應用程序打包為桌面應用程序,本文詳細介紹了使用React和Electron開發(fā)桌面應用程序的步驟,需要的朋友可以參考下2023-06-06
詳解使用React.memo()來優(yōu)化函數(shù)組件的性能
本文講述了開發(fā)React應用時如何使用shouldComponentUpdate生命周期函數(shù)以及PureComponent去避免類組件進行無用的重渲染,以及如何使用最新的React.memo API去優(yōu)化函數(shù)組件的性能2019-03-03
React手寫簽名組件react-signature實現(xiàn)簽字demo
這篇文章主要為大家介紹了React手寫簽名組件react-signature實現(xiàn)簽字demo,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-12-12
React+Spring實現(xiàn)跨域問題的完美解決方法
這篇文章主要介紹了React+Spring實現(xiàn)跨域問題的完美解決方法,非常不錯,具有一定的參考借鑒價值,需要的朋友可以參考下2018-08-08
React如何利用Antd的Form組件實現(xiàn)表單功能詳解
這篇文章主要給大家介紹了關于React如何利用Antd的Form組件實現(xiàn)表單功能的相關資料,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2021-04-04

