如何寫出更優(yōu)雅的 React 組件 - 代碼結構篇
在日常團隊開發(fā)中大家寫的組件質量參差不齊,風格千差萬別。會因為很多需求導致組件無法擴展,或難以維護。導致很多業(yè)務組件的功能重復,使用起來相當難受。我們從代碼結構的角度來談談如何設計一個更優(yōu)雅的 React 組件。
組件目錄結構
優(yōu)秀的組件有著一個清晰的目錄結構。這里的目錄結構分為項目級結構、單組件級結構。
容器組件/展示組件
在項目中我們的目錄結構可以根據組件和業(yè)務耦合來劃分,和業(yè)務的耦合程度越低, 可復用性越強。展示組件只關注展示層, 可以在多個地方被復用, 它不耦合業(yè)務。容器組件主要關注業(yè)務處理,容器組件通過組合展示組件來構建完整視圖。
示例:
- src/
 - components/ (通用組件,與業(yè)務無關,可被其他所有組件調用)
 - Button/
 - index.tsx
 - containers/ (容器組件,與業(yè)務深度耦合,可被頁面組件調用)
 - Hello/
 - Kitty/ (容器組件中的特有組件,不能與其他容器組件共享)
 - index.tsx
 - World/
 - components/
 - index.tsx
 - hooks/ (公共的 hooks)
 - pages/ (頁面組件,特定的頁面,無復用性)
 - my-app/
 - store/ (狀態(tài)管理)
 - services/ (接口定義)
 - utils/ (工具類)
 
組件目錄結構
我們可以根據文件類型/功能/職責等劃分不同的目錄。
- 根據文件類型可以分出 images 等目錄
 - 根據文件功能可以分出 __tests__ 、demo 等目錄
 - 根據文件職責可以分出 types 、utils 、hooks 等目錄
 - 根據組件的特點可以用目錄劃分歸類
 
- HelloWorld/ (普通的業(yè)務組件)
 - __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
 
組件內部結構
組件內部需要保持良好的順序邏輯,統(tǒng)一團隊規(guī)范。約定俗成后,這樣一目了然定義可以讓我們更清晰地去 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 類型并導出。
類型與參數(shù)書寫的順序保持一致,一般以 [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 的參數(shù)會讓使用該組件的人產生極度困惑,無法明確地知道其中的類型。我們可以通過泛型的方式去聲明。
- // 隱式 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
 
內聯(lián)樣式
避免偷懶,要時刻保持優(yōu)雅,隨手一個 style={} 是極為不推薦的。這樣不僅每次渲染都有重新創(chuàng)建的消耗,而且是清晰的 JSX 上的噪點,影響閱讀。
組件行數(shù)限制
組件需要明確的注釋,并保持 300 行以內的代碼行數(shù)。代碼行數(shù)可以通過配置 eslint 來做到限制(可以跳過注釋/空行的的統(tǒng)計):
- '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>
 - );
 - };
 
事件函數(shù)命名區(qū)分
內部方法按照 handle{Type}{Event} 命名,例如 handleNameChange。暴露外部的方法按照 on{Type}{Event},例如 onNameChange。這樣做的好處可以直接通過函數(shù)名區(qū)分是否為外部參數(shù)。
例如 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
- 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} />;
 - }
 
避免循環(huán)依賴
如果你寫的組件包含了循環(huán)依賴, 這時候你需要考慮拆分和設計模塊文件
- // --- 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 組件就形成了一個簡單循環(huán)依賴, 盡管它不會造成什么運行時問題. 解決方案就是將 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';
 
相對路徑不要超過兩級
當項目復雜的情況下,目錄結構會越來越深,文件會有很長的 ../ 路徑,這樣看起來很不優(yōu)雅:
- import { ButtonProps } from'../../../components/Button';
 
我們可以通過在 tsconfig.json 中配置
- "paths": {
 - "@/*": ["src/*"]
 - }
 
和 vite 中配置
- alias: {
 - '@/': `${path.resolve(process.cwd(), 'src')}/`,
 - }
 
現(xiàn)在我們可以導入相對于 src 的模塊:
- import { ButtonProps } from'@/components/Button';
 
當然更徹底一點,可以使用 monorepo 的項目管理方式來解耦各個組件。只要搭建一套腳手架,就能管理(構建、測試、發(fā)布)多個 package
不要直接使用 export default 導出未命名的組件
這種方式導出的組件在 React Inspector 查看時會顯示為 Unknown
- // 錯誤做法
 - exportdefault () => {};
 - // 正確做法
 - exportdefaultfunction Kitty() {}
 - // 正確做法:先聲明后導出
 - function Kitty() {}
 - exportdefault Kitty;
 
結語
以上是寫 React 組件在目錄結構以及編碼規(guī)則上需要注意的點,后續(xù)我們講解如何在思維上保持優(yōu)雅。
















 
 
 







 
 
 
 