如何寫出更優雅的 React 組件 - 代碼結構篇
在日常團隊開發中大家寫的組件質量參差不齊,風格千差萬別。會因爲很多需求導致組件無法擴展,或難以維護。導致很多業務組件的功能重複,使用起來相當難受。我們從代碼結構的角度來談談如何設計一個更優雅的 React
組件。
組件目錄結構
優秀的組件有着一個清晰的目錄結構。這裏的目錄結構分爲項目級結構、單組件級結構。
容器組件 / 展示組件
在項目中我們的目錄結構可以根據組件和業務耦合來劃分,和業務的耦合程度越低, 可複用性越強。展示組件只關注展示層, 可以在多個地方被複用, 它不耦合業務。容器組件主要關注業務處理,容器組件通過組合展示組件來構建完整視圖。
示例:
src/
components/ (通用組件,與業務無關,可被其他所有組件調用)
Button/
index.tsx
containers/ (容器組件,與業務深度耦合,可被頁面組件調用)
Hello/
Kitty/ (容器組件中的特有組件,不能與其他容器組件共享)
index.tsx
World/
components/
index.tsx
hooks/ (公共的 hooks)
pages/ (頁面組件,特定的頁面,無複用性)
my-app/
store/ (狀態管理)
services/ (接口定義)
utils/ (工具類)
組件目錄結構
我們可以根據文件類型 / 功能 / 職責等劃分不同的目錄。
-
根據文件類型可以分出
images
等目錄 -
根據文件功能可以分出
__tests__
、demo
等目錄 -
根據文件職責可以分出
types
、utils
、hooks
等目錄 -
根據組件的特點可以用目錄劃分歸類
HelloWorld/ (普通的業務組件)
__tests__/ (測試用例)
demo/ (組件示例)
Bar/ (特有組件分類)
Kitty.tsx (特有組件)
Kitty.module.less
Foo/
hooks/ (自定義 hooks)
images/ (圖片目錄)
types/ (類型定義)
utils/ (工具類方法)
index.tsx (出口文件)
比如我最近寫的一個表格組件的目錄結構:
├─SheetTable
│ ├─Cell
│ ├─Header
│ ├─Layer
│ ├─Main
│ ├─Row
│ ├─Store
│ ├─types
│ └─utils
組件內部結構
組件內部需要保持良好的順序邏輯,統一團隊規範。約定俗成後,這樣一目瞭然定義可以讓我們更清晰地去 Review。
導入順序
導入順序爲 node_modules
-> @/ 開頭文件
-> 相對路徑文件
-> 當前組件樣式文件
// 導入 node_modules 依賴
import React from'react';
// 導入公共組件
import Button from'@/components/Button';
// 導入相對路徑組件
import Foo from'./Foo';
// 導入對應同名的 .less 文件,命名爲 styles
import styles from'./Kitty.module.less';
使用 組件名 + Props
形式命名 Props
類型並導出。
類型與參數書寫的順序保持一致,一般以 [a-z] 的順序定義。變量的註釋禁止放末尾,原因是會導致編輯器識別錯位,無法正確提示
/**
* 類型定義(命名:組件名 + Props)
*/
export interface KittyProps {
/**
* 多行註釋(建議)
*/
email: string;
// 單行註釋(不推薦)
mobile: string;
username: string; // 末尾註釋(禁止)
}
使用 React.FC
定義
const Kitty: React.FC<KittyProps> = ({ email, mobile, usename }) => {};
泛型,代碼提示更智能
以下例子,可以用過泛型讓 value
和 onChange
回調中的類型保持一致,並做到編輯器智能類型提示。
注意:泛型組件無法使用 React.FC
類型
export interface FooProps<Value> {
value: Value;
onChange: (value: Value) =>void;
}
exportfunction Foo<Value extends React.Key>(props: FooProps<Value>) {}
禁止直接使用 any
類型
無論隱式和顯式的方式,都不推薦使用 any
類型。定義了 any
的參數會讓使用該組件的人產生極度困惑,無法明確地知道其中的類型。我們可以通過泛型的方式去聲明。
// 隱式 any (禁止)
let foo;
function bar(param) {}
// 顯式 any (禁止)
let hello: any;
function world(param: any) {}
// 使用泛型繼承,縮小類型範圍 (推薦)
function Tom<P extends Record<string, any>>(param: P) {}
一個組件對應一個樣式文件
我們以組件的顆粒度大小爲抽象單元,樣式文件則應與組件本身保持一致。不推薦交叉引入樣式文件的做法,這樣會導致重構混亂,無法明確當前這個樣式被多少個組件使用。
- Tom.tsx
- Tom.module.less
- Kitty.tsx
- Kitty.module.less
內聯樣式
避免偷懶,要時刻保持優雅,隨手一個 style={}
是極爲不推薦的。這樣不僅每次渲染都有重新創建的消耗,而且是清晰的 JSX 上的噪點,影響閱讀。
組件行數限制
組件需要明確的註釋,並保持 300 行以內的代碼行數。代碼行數可以通過配置 eslint
來做到限制(可以跳過註釋 / 空行的的統計):
'max-lines-per-function': [2, { max: 320, skipComments: true, skipBlankLines: true }],
組件內部編寫代碼的順序
組件內部的順序爲 state
-> custom Hooks
-> effects
-> 內部 function
-> 其他邏輯
-> JSX
/**
* 組件註釋(簡明概要)
*/
const Kitty: React.FC<KittyProps> = ({ email }) => {
// 1. state
// 2. custom Hooks
// 3. effects
// 4. 內部 function
// 5. 其他邏輯...
return (
<div className={styles.wrapper}>
{email}
<Child />
</div>
);
};
事件函數命名區分
內部方法按照 handle{Type}{Event}
命名,例如 handleNameChange
。暴露外部的方法按照 on{Type}{Event}
,例如 onNameChange
。這樣做的好處可以直接通過函數名區分是否爲外部參數。
例如 antd/Button 組件片段:
const handleClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement, MouseEvent>) => {
const { onClick, disabled } = props;
if (innerLoading || disabled) {
e.preventDefault();
return;
}
(onClick as React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>)?.(e);
};
繼承原生元素 props
定義
原生元素 props
都繼承了 React.HTMLAttributes
。某些特殊元素也會擴展自己的屬性,例如 InputHTMLAttributes
。
我們定義一個自定義組件則可以通過繼承 React.InputHTMLAttributes<HTMLInputElement>
,讓其類型具有所有 input
的特性。
export interface KittyProps extends React.InputHTMLAttributes<HTMLInputElement> {
/**
* 新增支持回車鍵事件
*/
onPressEnter?: React.KeyboardEventHandler<HTMLInputElement>;
}
function Kitty({ onPressEnter, onKeyUp, ...restProps }: KittyProps) {
function handleKeyUp(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.code.includes('Enter') && onPressEnter) {
onPressEnter(e);
}
if (onKeyUp) {
onKeyUp(e);
}
}
return<input onKeyUp={handleKeyUp} {...restProps} />;
}
避免循環依賴
如果你寫的組件包含了循環依賴, 這時候你需要考慮拆分和設計模塊文件
// --- Foo.tsx ---
import Bar from'./Bar';
export interface FooProps {}
exportconst Foo: React.FC<FooProps> = () => {};
Foo.Bar = Bar;
// --- Bar.tsx ----
import { FooProps } from'./Foo';
上面 Foo
和 Bar
組件就形成了一個簡單循環依賴, 儘管它不會造成什麼運行時問題. 解決方案就是將 FooProps
抽取到單獨的文件:
// --- types.ts ---
export interface FooProps {}
// --- Foo.tsx ---
import Bar from'./Bar';
import { FooProps } from'./types';
exportconst Foo: React.FC<FooProps> = () => {};
Foo.Bar = Bar;
// --- Bar.tsx ----
import { FooProps } from'./types';
相對路徑不要超過兩級
當項目複雜的情況下,目錄結構會越來越深,文件會有很長的 ../
路徑,這樣看起來很不優雅:
import { ButtonProps } from'../../../components/Button';
我們可以通過在 tsconfig.json
中配置
"paths": {
"@/*": ["src/*"]
}
和 vite
中配置
alias: {
'@/': `${path.resolve(process.cwd(), 'src')}/`,
}
現在我們可以導入相對於 src
的模塊:
import { ButtonProps } from'@/components/Button';
當然更徹底一點,可以使用 monorepo
的項目管理方式來解耦各個組件。只要搭建一套腳手架,就能管理(構建、測試、發佈)多個 package
不要直接使用 export default
導出未命名的組件
這種方式導出的組件在 React Inspector
查看時會顯示爲 Unknown
// 錯誤做法
exportdefault () => {};
// 正確做法
exportdefaultfunction Kitty() {}
// 正確做法:先聲明後導出
function Kitty() {}
exportdefault Kitty;
結語
以上是寫 React
組件在目錄結構以及編碼規則上需要注意的點,後續我們講解如何在思維上保持優雅。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/H3bUP0cMpknGcCbiTpQryg