如何寫出更優雅的 React 組件 - 代碼結構篇

在日常團隊開發中大家寫的組件質量參差不齊,風格千差萬別。會因爲很多需求導致組件無法擴展,或難以維護。導致很多業務組件的功能重複,使用起來相當難受。我們從代碼結構的角度來談談如何設計一個更優雅的 React 組件。

組件目錄結構

優秀的組件有着一個清晰的目錄結構。這裏的目錄結構分爲項目級結構、單組件級結構。

容器組件 / 展示組件

在項目中我們的目錄結構可以根據組件和業務耦合來劃分,和業務的耦合程度越低, 可複用性越強。展示組件只關注展示層, 可以在多個地方被複用, 它不耦合業務。容器組件主要關注業務處理,容器組件通過組合展示組件來構建完整視圖。

fn8T7a

示例:

src/
  components/ (通用組件,與業務無關,可被其他所有組件調用)
    Button/
      index.tsx
  containers/ (容器組件,與業務深度耦合,可被頁面組件調用)
    Hello/
      Kitty/ (容器組件中的特有組件,不能與其他容器組件共享)
      index.tsx
    World/
      components/
      index.tsx
  hooks/ (公共的 hooks)
  pages/ (頁面組件,特定的頁面,無複用性)
    my-app/
  store/ (狀態管理)
  services/ (接口定義)
  utils/ (工具類)

組件目錄結構

我們可以根據文件類型 / 功能 / 職責等劃分不同的目錄。

  1. 根據文件類型可以分出 images 等目錄

  2. 根據文件功能可以分出 __tests__demo 等目錄

  3. 根據文件職責可以分出 typesutilshooks 等目錄

  4. 根據組件的特點可以用目錄劃分歸類

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 }) => {};

泛型,代碼提示更智能

以下例子,可以用過泛型讓 valueonChange 回調中的類型保持一致,並做到編輯器智能類型提示。

注意:泛型組件無法使用 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';

上面 FooBar 組件就形成了一個簡單循環依賴, 儘管它不會造成什麼運行時問題. 解決方案就是將 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