紀錄工作經驗、相關知識,解決技術相關問題。

Javascript, React, 工作雜談, 新手 Jamp Holes, 網站相關

解決 React 元件 defaultProps 錯誤:TypeScript Props 預設參數、HOC 與重構踩坑排查

解決 React 元件 defaultProps 錯誤:TypeScript Props 預設參數、HOC 與重構踩坑排查 特色圖片

當你在升級 React 18.3 或 React 19 專案並將函式元件(Function Component)的 defaultProps 改寫為 JavaScript 預設參數(Default Parameters)時,若只是單純替換語法,常會在 TypeScript Props 型別定義、後端回傳 null 值導致預設值失效,或是 物件預設值引發 useEffect 重複觸發 等場景踩坑。本文將針對 React + TypeScript 專案廢除 defaultProps 後的型別重構、高階元件(HOC)傳遞與三大常見重構陷阱進行完整排查。

💡 延伸閱讀(基礎警告排查):若你想先了解瀏覽器 Console 跳出這道英文警告的底層背景與純 JavaScript 基礎改法,可先參考另一篇專文:React Console Warning:Support for defaultProps will be removed from function components 原因與解決方法。

一、 快速回顧:從 defaultProps 轉換為預設參數的基本寫法

在舊版 React 專案中,我們常在函式元件外部掛載 defaultProps 來設定預設屬性:

// ❌ 舊版寫法:在 React 18.3+ 會觸發警告,React 19 函式元件已不再支援
function Text({ fontSize }) {
  return <p style={{ fontSize }}>範例文字內容</p>;
}

Text.defaultProps = {
  fontSize: '20px',
};

最直接的遷移方式,是改用 ES6 的 函式解構預設參數(Default Parameters),直接在元件接收 Props 的入口處宣告預設值:

// ✅ 現代標準寫法:直接在參數解構時設定預設值
const Text = ({ fontSize = '20px' }) => {
  return <p style={{ fontSize }}>範例文字內容</p>;
};

雖然基本語法替換非常直覺,但在實務生產環境(特別是結合 TypeScript、API 資料串接 與 效能最佳化 的專案)中,直接把 defaultProps 刪掉改寫成解構預設參數,往往會遇到以下三個隱藏陷阱。


二、 React + TypeScript 廢除 defaultProps 後的 Props 型別定義規範

過去在 TypeScript 中搭配 defaultProps 是一大痛點:因為編譯器很難同時明白「這個屬性在外部呼叫元件時可以省略(Optional ?:),但在元件內部因為有 defaultProps 所以一定有值(Non-nullable)」。

改用 ES6 預設參數後,TypeScript 的控制流分析(Control Flow Analysis)終於能自動完成型別收斂。以下是舊制與新制的規格對照:

對照維度舊制 Component.defaultProps新制 ES6 解構預設參數對 TypeScript 開發的影響
Interface 宣告方式常需額外搭配 Partial 或 typeof defaultProps直接將有預設值的屬性加上 ?(Optional)大幅減少樣板程式碼(Boilerplate)
元件外部呼叫檢查容易發生必填與選填型別衝突標記 ? 的屬性可自由省略不傳IDE 自動補全與 Props 提示更精準
元件內部型別推導常被推導為 T 或 undefined,需寫 ! 斷言解構賦予預設值後,自動收斂為非空型別 T元件內部可直接安全呼叫屬性方法
React 19 相容性函式元件完全忽略 defaultProps100% 原生支援,零執行期額外開銷符合未來版本與標準 ES 規範

範本一:TypeScript 函式元件與 Props 型別標準定義

在定義 interface 或 type 時,請將具備預設值的屬性加上 ? 設為可選屬性(Optional Property),並在解構時賦予預設值,切勿再使用已棄用的 React.FC 內建 defaultProps 掛載:

import React from 'react';

interface AlertBannerProps {
  /** 必填屬性:提示訊息內容 */
  message: string;
  /** 選填屬性:視覺狀態等級(預設為 'info') */
  variant?: 'info' | 'warning' | 'error';
  /** 選填屬性:是否顯示關閉按鈕(預設為 true) */
  closable?: boolean;
  /** 選填回呼:點擊關閉時觸發 */
  onClose?: () => void;
}

export const AlertBanner = ({
  message,
  variant = 'info',
  closable = true,
  onClose,
}: AlertBannerProps) => {
  // 在元件內部,TypeScript 會自動將 variant 推導為 'info' | 'warning' | 'error'(不含 undefined)
  // closable 也會自動推導為確定的 boolean 型別
  return (
    <div className={`alert-box alert-${variant}`} role="alert">
      <span>{message}</span>
      {closable && (
        <button type="button" onClick={onClose} aria-label="關閉提示">
          ✕
        </button>
      )}
    </div>
  );
};

三、 重構常見的三大踩坑場景與排查解法

踩坑 1:API 回傳 null 時,ES6 預設參數不會生效!

這是從後端 API 串接資料時最常發生的執行期 Bug(如 Uncaught TypeError: Cannot read properties of null)。

  • 問題原因:在 JavaScript ES6 規範中,解構預設參數(例如 { items = [] })只有在屬性值嚴格等於 undefined(prop === undefined)時才會觸發預設值。如果後端資料庫欄位為空並回傳了 null(即 <UserList items={data.items} /> 傳入 null),預設的 [] 將不會生效,items 依然會是 null!
  • 解決思路:對於可能收到 null 的 API 資料欄位,請結合 空值合併運算子(Nullish Coalescing Operator ??) 進行雙重防護:
interface TagListProps {
  // 允許外部或 API 傳入 string[]、null 或 undefined
  tags?: string[] | null;
}

export const TagList = ({ tags }: TagListProps) => {
  // ✅ 同時防禦 undefined 與 null:當 tags 為 null 或 undefined 時皆安全退回空陣列 []
  const safeTags = tags ?? [];

  return (
    <ul>
      {safeTags.map((tag) => (
        <li key={tag}>{tag}</li>
      ))}
    </ul>
  );
};

踩坑 2:在解構參數中直接寫 { config = {} } 或 { list = [] } 導致無限重渲染

  • 問題原因:如果你在函式參數中直接寫 ({ style = {}, items = [] }) => ...,每次父元件重新渲染且沒有傳入該屬性時,JavaScript 都會在記憶體中重新建立一個全新的 {} 物件或 [] 陣列實例(新的記憶體位址)。如果這個屬性剛好被放進 useEffect 的依賴陣列(Dependency Array)或傳給 React.memo 子元件,就會因為每次渲染的 Reference 不同而導致 React.memo 破功,甚至引發 useEffect 無限迴圈!
  • 解決思路:將預設的空物件、空陣列或預設設定抽離到元件外部宣告為模組級常數(Module-level Constant),確保每次渲染都指向同一個記憶體參考:
import React, { useEffect } from 'react';

interface ChartConfig {
  animate: boolean;
  theme: 'light' | 'dark';
}

// ✅ 將預設物件與空陣列抽離至元件外部,固定記憶體參考位址(Reference)
const DEFAULT_CHART_CONFIG: ChartConfig = {
  animate: true,
  theme: 'light',
};
const EMPTY_DATA_LIST: number[] = [];

interface MetricsChartProps {
  data?: number[];
  config?: ChartConfig;
}

export const MetricsChart = ({
  data = EMPTY_DATA_LIST,
  config = DEFAULT_CHART_CONFIG,
}: MetricsChartProps) => {
  useEffect(() => {
    // 因為 DEFAULT_CHART_CONFIG 參考位址固定,不會在每次重渲染時無故重複觸發 useEffect
    console.log('套用圖表設定:', config.theme, '資料筆數:', data.length);
  }, [config, data]);

  return <div className={`chart-${config.theme}`}>共 {data.length} 筆數據</div>;
};

踩坑 3:搭配 forwardRef 或高階元件(HOC)時的預設參數寫法

當元件需要搭配 React.forwardRef 或包裹在高階元件(Higher-Order Component, HOC)中時,請將預設參數直接寫在內層渲染函式的第一個 props 解構參數上:

import React, { forwardRef } from 'react';

interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
   variant?: 'outlined' | 'filled';
}

// ✅ 在 forwardRef 內層渲染函式的第一個參數直接解構並給定預設值
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
  ({ label = '請輸入內容', variant = 'outlined', type = 'text', ...restProps }, ref) => {
    return (
      <label className={`input-field input-${variant}`}>
        <span>{label}</span>
        <input ref={ref} type={type} {...restProps} />
      </label>
    );
  }
);

TextInput.displayName = 'TextInput';

四、 開發者高頻 Q&A 彙整

Q1:為什麼把 defaultProps 改成預設參數後,傳入 null 會報錯?

  • 問題原因:ES6 預設參數僅在參數值為 undefined 時才會套用預設值;而 null 在 JavaScript 中被視為「有明確傳入的空值」,因此不會觸發預設參數。
  • 解法:若屬性資料來自後端 API 且可能為 null,請在元件內部搭配空值合併運算子 const safeVal = propVal ?? '預設值' 處理。

Q2:使用 ...restProps 展開其餘屬性時,預設參數順序有影響嗎?

  • 問題原因:在解構賦值 ({ variant = 'primary', ...restProps }) 中,已被明確解構出來的 variant 不會包含在 restProps 內。
  • 解法:若需要將套用預設值後的屬性一併傳遞給底層原生元素(如 <button type={type} {...restProps}>),請確保在 JSX 中明確綁定該屬性變數。

Q3:為什麼 ESLint 出現 react/require-default-props 規則警告?

  • 問題原因:部分舊專案的 eslint-plugin-react 預設要求所有可選 Props 必須寫在 Component.defaultProps 中,與 React 18.3+ 的棄用方向衝突。
  • 解法:在 .eslintrc 設定檔中,將 react/require-default-props 的 functions 選項設為 "defaultArguments"(即 "react/require-default-props": ["error", { "functions": "defaultArguments" }]),讓 ESLint 改為檢查函式預設參數。

Q4:Class Component(類別元件)也需要把 static defaultProps 移掉嗎?

  • 問題原因:React 19 僅移除「函式元件(Function Component)」對 defaultProps 的支援。因為類別元件是透過 this.props 存取屬性,無法使用函式參數解構。
  • 解法:現有的 Class Component 仍可保留 static defaultProps,不影響 React 19 升級。

Q5:如果我想看基礎的 Console Warning 觸發原因與純 JS 範例該看哪篇?

參考資料與延伸閱讀

發表迴響