舊專案翻新實戰:React Legacy 專案的漸進式重構策略

2026/07/31

分享如何在不中斷功能開發的情況下,逐步為 Legacy React 專案導入 TypeScript、TanStack Query、測試與模組化設計,降低修改風險並改善維護性。
Abstract React Hooks state and dependency flow banner

接手 Legacy 專案時,最理想的情況通常是:

  • 需求清楚
  • 文件完整
  • 測試齊全
  • 團隊理解既有架構
  • 有足夠時間重構

現實則往往完全相反。

你可能會遇到上千行的 Class Component、非同步 API 全部塞進全域 Store、沒有測試、資料來源混亂,以及需求隨時可能改變。

更麻煩的是,產品仍然持續運作,新功能也不能停止開發。

因此,Legacy Modernization 的重點通常不是「把舊專案全部重寫」,而是:

在持續交付功能的同時,逐步降低修改風險,建立可驗證、可維護的系統邊界。

本文將分享我在實際 React 專案中,如何逐步導入 TypeScript、TanStack Query、單元測試與模組化設計,改善一個長期累積技術債的舊系統。

Legacy 專案真正困難的不是程式碼很舊

很多人看到 Legacy 專案,第一個想法是:

把 Class Component 改成 Function Component 就好了。

但語法新舊通常不是最大的問題。

真正危險的是,你不知道修改一段程式碼後,會影響到哪些地方。

在我接手的專案中,曾經出現以下情況:

  • 單一 Component 超過 1,300 行
  • API 請求、Loading、錯誤狀態與 UI 狀態全部放在同一個 Store
  • 大量非同步 Action 寫在 Zustand 中
  • 不同頁面透過隱性規則共用資料
  • 後台設定被直接寫死在前端邏輯
  • API 與畫面欄位缺乏明確映射
  • 沒有 Unit Test 或 E2E Test
  • 測試環境與正式環境行為不一致
  • 需求方可能在準備上線時才提出修改

這類專案最大的風險並不是「程式碼不好看」,而是缺乏可預測性。

你無法確定:

  1. 修改會不會破壞其他功能
  2. 舊行為是不是 Bug,還是某種沒人記得的需求
  3. API 資料改變後,哪些畫面會受到影響
  4. 發生問題時,責任是在前端、後端還是資料來源

因此,翻新 Legacy 專案的第一步,不是馬上重寫,而是先建立系統的可觀測性。

第一步:先理解資料流,不急著重構

接到舊功能調整時,我通常不會直接開始修改 Component。

我會先確認完整資料流:

text
API Service    ↓Async Action / Store    ↓資料轉換    ↓Component Props / Selector    ↓圖表或表格

需要釐清的問題包括:

  • API 是在哪裡呼叫的?
  • Response 是否經過轉換?
  • 哪些 Component 依賴這份資料?
  • Store 是否同時存放 Server State 與 UI State?
  • 是否存在 fallback 或寫死的條件?
  • 相同 API 是否在其他頁面被重複使用?
  • 舊資料與新資料的計算方式是否一致?

這個步驟看起來沒有立刻產出功能,卻是降低風險最重要的工作。

因為在缺乏文件的 Legacy 專案裡,程式碼本身通常就是唯一的規格。

如果沒有先理解資料流就開始修改,很容易發生這種情況:

text
畫面看起來正常↓測試環境可以使用↓合併正式環境↓另一個共用頁面壞掉

Legacy 系統最喜歡用這種方式提醒工程師,它仍然活著,而且心情不太穩定。

第二步:讓 JavaScript 與 TypeScript 漸進共存

在大型 Legacy 專案中,TypeScript 通常不適合一次全面導入。

如果直接把所有 .js 與 .jsx 改成 .ts 與 .tsx,往往會立刻出現數百甚至數千個型別錯誤。團隊接下來花費大量時間處理歷史問題,原本的新功能與維運工作則全部停擺。

比較務實的方式,是先允許 JavaScript 與 TypeScript 並存,讓新程式碼開始受到型別保護,再逐步處理既有模組。

先建立最小可用的 TypeScript 設定

第一階段可以先安裝 TypeScript 與必要型別套件:

bash
npm install --save-dev typescript @types/react @types/react-dom

接著建立 tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES2020"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "allowJs": true,
    "checkJs": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}

這裡有兩個關鍵設定:

json
{
  "allowJs": true,
  "checkJs": false
}

allowJs: true 代表 TypeScript 專案可以繼續載入既有的 JavaScript 檔案,因此 .js、.jsx、.ts 與 .tsx 可以同時存在。

checkJs: false 則代表暫時不對所有 JavaScript 檔案執行完整型別檢查。

這個取捨很重要。

如果一開始就將 checkJs 設為 true,TypeScript 可能會對多年累積的 JavaScript 程式碼產生大量錯誤。這些錯誤未必與目前正在修改的功能有關,卻會讓型別檢查無法成為有效的 CI Gate。

因此,我會先採用以下策略:

設定初期選擇原因
allowJstrue允許 JavaScript 與 TypeScript 並存
checkJsfalse避免一次檢查所有歷史 JavaScript
stricttrue新增的 TypeScript 檔案直接遵守嚴格規則
noEmittrue只執行型別檢查,由原本建置工具輸出
skipLibChecktrue避免第三方型別問題阻塞初期導入

skipLibCheck 適合作為導入初期的降噪手段,但不應被誤認為能解決應用程式本身的型別問題。它只會略過宣告檔之間的檢查,不會替業務程式碼收拾殘局。

優先從系統邊界開始遷移

TypeScript 遷移不應單純按照資料夾順序進行。

比起先把所有 Button、Modal 或頁面 Component 改成 .tsx,我會優先處理資料進出系統的邊界:

  1. API Response 型別
  2. Adapter 與資料轉換函式
  3. 共用商業邏輯
  4. Query Hook
  5. 新增或正在修改的 Component
  6. 最後才處理穩定但龐大的舊 Component

原因是邊界型別能最快產生實際價值。

當 API Response、Adapter 輸出與 Hook 回傳值都有明確型別後,即使下游仍是 JavaScript,至少資料結構已經不再完全依靠猜測。

例如,可以先將 API Service 改成 TypeScript:

ts
export type StockDividendApiResponse = {
  cash_dividend: number | string | null;
  dividend_yield: number | string | null;
  release_date: string | null;
};

export async function fetchStockDividend(
  stockCode: string
): Promise<unknown> {
  const response = await fetch(`/api/stocks/${stockCode}/dividend`);

  if (!response.ok) {
    throw new Error("取得股利資料失敗");
  }

  return response.json();
}

這裡刻意讓 fetchStockDividend 回傳 Promise<unknown>,而不是直接宣稱它一定符合 StockDividendApiResponse。

因為 TypeScript 型別只存在於編譯期間,無法保證伺服器實際回傳的 JSON 符合宣告。若直接使用型別斷言:

ts
return response.json() as Promise<StockDividendApiResponse>;

只是要求 TypeScript 相信工程師,並沒有真正驗證資料。

比較穩定的做法,是在 Adapter 或 Schema 層進行 Runtime Validation,稍後會再說明。

遷移既有 Component 時採用「碰到才改善」

面對上千行的 Class Component,不一定要先完整改寫成 Function Component 才能使用 TypeScript。

可以先將 .jsx 改成 .tsx,替 Props 與 State 加上型別:

tsx
type LegacyChartProps = {
  stockCode: string;
  rows: StockDividendRow[];
  onReload?: () => void;
};

type LegacyChartState = {
  selectedYear: number | null;
  isExpanded: boolean;
};

class LegacyChart extends React.Component<
  LegacyChartProps,
  LegacyChartState
> {
  state: LegacyChartState = {
    selectedYear: null,
    isExpanded: false,
  };

  render() {
    return null;
  }
}

Class Component 本身不會因為用了 TypeScript 就突然變成技術禁區。

如果目前需求只需要調整資料來源,先補上 Props 與 State 型別,通常比同時改寫生命週期、狀態管理與渲染結構更安全。

我的原則是:

不把「語法現代化」與「商業邏輯修改」綁在同一個大型提交裡。

每次提交最好只有一個主要目的,例如:

  • 將 API Service 改成 TypeScript
  • 新增 Adapter 與型別
  • 將特定 Component 改成 .tsx
  • 將非同步 Store 遷移至 TanStack Query
  • 補上測試

這樣 Code Review、問題定位與 Rollback 都會容易許多。

將型別檢查納入 CI,但只檢查可控範圍

完成基本設定後,可以先加入:

json
{
  "scripts": {
    "typecheck": "tsc --noEmit"
  }
}

並在 CI 執行:

bash
npm run typecheck

由於 JavaScript 與 TypeScript 可以並存,團隊不需要等到整個專案完成遷移,才開始使用型別檢查。

比較實際的遷移路線如下:

text
允許 JavaScript 與 TypeScript 並存↓新檔案一律使用 TypeScript↓先遷移 API、Adapter 與商業邏輯↓修改既有功能時順便遷移相關檔案↓逐步加入 @ts-check 或改成 .ts/.tsx↓縮小 any、@ts-ignore 與 @ts-nocheck 的範圍↓最後再評估是否全面啟用 checkJs 或移除 allowJs

這種方式不會讓 TypeScript 導入變成一場長期停工的大型改造,而是讓型別覆蓋率隨著日常開發逐步增加。

第三步:把 Server State 與 Client State 分開

舊 React 專案常見的一個問題,是將所有狀態都放進 Redux 或 Zustand。

例如:

ts
type StockStore = {
  stockData: StockData[];
  isLoading: boolean;
  error: string | null;
  selectedTab: string;
  isTooltipOpen: boolean;
  lastFetchedAt: number | null;
  fetchStockData: (stockCode: string) => Promise<void>;
};

這裡其實混合了兩種完全不同的狀態。

Server State

由後端或外部資料來源提供:

  • 股票資料
  • 會員收藏
  • 新聞列表
  • 搜尋結果
  • API Loading
  • API Error

這類狀態通常需要處理:

  • Cache
  • Cache Key
  • Cache 失效
  • Retry
  • Refetch
  • Stale Time
  • Request Deduplication
  • 跨頁面同步

Client State

只存在於前端操作流程:

  • Modal 是否開啟
  • 選中的 Tab
  • Tooltip 狀態
  • 表格顯示模式
  • 暫存中的 UI 選項

Zustand 很適合管理 Client State,但如果拿來管理 Server State,團隊通常還得自行處理 Cache。

例如,為了避免每次進入頁面都重新呼叫 API,Store 可能逐漸加入:

  • 上次請求時間
  • 不同股票代碼的資料 Map
  • Cache 是否過期
  • 是否正在請求
  • 是否允許重新請求
  • 多個 Component 同時請求時的去重
  • 更新資料後如何同步其他頁面

原本單純的 Store 很容易演變成:

ts
type StockCacheItem = {
  data: StockData[];
  updatedAt: number;
};

type StockStore = {
  cache: Record<string, StockCacheItem>;
  loadingKeys: Record<string, boolean>;
  errors: Record<string, string | null>;
  fetchStockData: (
    stockCode: string,
    options?: {
      force?: boolean;
      staleTime?: number;
    }
  ) => Promise<void>;
};

接著每個 Action 都必須自行判斷 Cache 是否存在、是否過期,以及目前是否已有相同請求正在執行。

ts
const useStockStore = create<StockStore>((set, get) => ({
  cache: {},
  loadingKeys: {},
  errors: {},

  fetchStockData: async (
    stockCode,
    {
      force = false,
      staleTime = 5 * 60 * 1000,
    } = {}
  ) => {
    const cachedItem = get().cache[stockCode];
    const isFresh =
      cachedItem &&
      Date.now() - cachedItem.updatedAt < staleTime;

    if (!force && isFresh) {
      return;
    }

    if (get().loadingKeys[stockCode]) {
      return;
    }

    set((state) => ({
      loadingKeys: {
        ...state.loadingKeys,
        [stockCode]: true,
      },
      errors: {
        ...state.errors,
        [stockCode]: null,
      },
    }));

    try {
      const data = await fetchStockData(stockCode);

      set((state) => ({
        cache: {
          ...state.cache,
          [stockCode]: {
            data,
            updatedAt: Date.now(),
          },
        },
      }));
    } catch {
      set((state) => ({
        errors: {
          ...state.errors,
          [stockCode]: "取得股票資料失敗",
        },
      }));
    } finally {
      set((state) => ({
        loadingKeys: {
          ...state.loadingKeys,
          [stockCode]: false,
        },
      }));
    }
  },
}));

這些程式碼並不是產品真正的商業邏輯,而是在手動實作一套簡化版的 Server State 管理工具。

除了程式碼量增加,還要自行處理:

  • 多個元件同時發出相同請求
  • Component Unmount 後的資料更新
  • Cache 何時回收
  • Mutation 後哪些資料需要失效

-背景重新請求

  • 網路重新連線後是否更新資料
  • 分頁與無限滾動資料如何保存

TanStack Query 已經內建這些常見能力,因此不需要再使用 Zustand 手刻 Cache。

ts
export function useStockData(stockCode: string) {
  return useQuery({
    queryKey: ["stock-data", stockCode],
    queryFn: () => fetchStockData(stockCode),
    enabled: Boolean(stockCode),
    staleTime: 5 * 60 * 1000,
    gcTime: 30 * 60 * 1000,
  });
}

這裡的 queryKey 同時也是 Cache Key:

ts
["stock-data", "2330"]
["stock-data", "2317"]

不同股票代碼會擁有各自獨立的 Cache。

當多個 Component 使用相同的 queryKey 時,TanStack Query 可以共用同一份資料,並避免重複執行相同請求。

tsx
const {
  data = [],
  isPending,
  isFetching,
  isError,
} = useStockData(stockCode);

其中:

狀態用途
isPending尚未取得第一份資料
isFetching目前正在請求,包含背景更新
isError最近一次請求發生錯誤
data目前 Cache 中可使用的資料

staleTime 則用來定義資料在多久內仍被視為新鮮:

ts
staleTime: 5 * 60 * 1000

在五分鐘內再次使用相同的 Query,通常可以直接取得 Cache,不需要重新呼叫 API。

gcTime 則控制沒有任何 Component 使用這份 Query 後,Cache 可以保留多久:

ts
gcTime: 30 * 60 * 1000

這些行為原本都需要在 Zustand 中自行設計欄位、時間判斷與清除機制,現在可以透過 Query 設定統一管理。

當資料被更新後,也不需要手動尋找所有使用這份資料的 Store 與 Component,可以直接讓相關 Cache 失效:

ts
const queryClient = useQueryClient();

await updateStockDividend(input);

await queryClient.invalidateQueries({
  queryKey: ["stock-data", input.stockCode],
});

TanStack Query 會將對應資料標記為失效,並在適當時機重新取得最新資料。

因此,我採取的策略不是一次移除所有 Zustand,而是逐步調整責任邊界:

text
TanStack Query負責 API、Cache、Cache 失效、Loading、Error、Retry、Refetch Zustand負責真正需要跨元件共享的 UI 狀態 Local State負責單一元件內部互動

這樣做的價值不只是程式碼變短。

更重要的是,團隊不需要繼續在 Zustand 中自行維護 Cache、請求去重、失效策略與資料同步機制,而是讓資料生命週期回到專門處理 Server State 的工具中。

Zustand 也能重新回到它比較擅長的位置,不必一邊控制 Modal,一邊假裝自己是資料快取框架。

第四步:建立 API 與畫面之間的轉換層

Legacy 專案很容易讓 API Response 直接進入 Component。

例如:

tsx
<TableCell>{item.field_01}</TableCell>
<TableCell>{item.field_02}</TableCell>
<TableCell>{item.field_03}</TableCell>

當後端修改欄位名稱、資料格式或 API 來源時,整個畫面就會與後端結構高度耦合。

比較穩定的方式,是建立明確的 Schema 與 Adapter。

先驗證外部資料

假設後端可能回傳數字、數字字串或 null:

ts
import { z } from "zod";

const nullableNumberSchema = z
  .union([z.number(), z.string(), z.null()])
  .transform((value, context): number | null => {
    if (value === null || value === "") {
      return null;
    }

    const parsedValue =
      typeof value === "number" ? value : Number(value);

    if (!Number.isFinite(parsedValue)) {
      context.addIssue({
        code: z.ZodIssueCode.custom,
        message: "必須是有效數值",
      });

      return z.NEVER;
    }

    return parsedValue;
  });

const stockDividendApiSchema = z.object({
  cash_dividend: nullableNumberSchema,
  dividend_yield: nullableNumberSchema,
  release_date: z.string().nullable(),
});

type StockDividendApiResponse = z.infer<
  typeof stockDividendApiSchema
>;

這裡不只是告訴 TypeScript 資料「應該長什麼樣子」,而是在執行期間實際檢查 API Response。

接著定義前端真正需要的資料模型:

ts
type StockDividendRow = {
  cashDividend: number;
  dividendYield: number | null;
  releaseDate: string | null;
};

在 Adapter 統一預設值與格式

日期處理可以集中在純函式中:

ts
function normalizeDate(
  value: string | null
): string | null {
  if (!value) {
    return null;
  }

  const date = new Date(value);

  if (Number.isNaN(date.getTime())) {
    return null;
  }

  return date.toISOString().slice(0, 10);
}

接著建立 Adapter:

ts
export function mapStockDividendRow(
  input: unknown
): StockDividendRow {
  const data = stockDividendApiSchema.parse(input);

  return {
    cashDividend: data.cash_dividend ?? 0,
    dividendYield: data.dividend_yield,
    releaseDate: normalizeDate(data.release_date),
  };
}

這段 Adapter 現在實際負責:

  • 隔離 API 與前端的欄位命名
  • 將數字字串轉換成數字
  • 將空字串與 null 正規化
  • 為現金股息設定預設值
  • 將可解析日期統一為 YYYY-MM-DD
  • 將無效日期轉換為 null
  • 拒絕不符合預期的資料格式

Component 只依賴前端定義的資料模型:

tsx
<TableCell>{row.cashDividend}</TableCell>

<TableCell>
  {row.dividendYield ?? "-"}
</TableCell>

<TableCell>
  {row.releaseDate ?? "尚未公布"}
</TableCell>

Adapter 不一定要處理所有格式

Adapter 的責任需要依專案情況明確界定。

如果 Adapter 只做以下轉換:

ts
return {
  cashDividend: data.cash_dividend,
  dividendYield: data.dividend_yield,
  releaseDate: data.release_date,
};

那麼它提供的價值就應該描述為:

隔離 API 欄位命名與前端資料模型。

不能宣稱它已經處理日期正規化、預設值與資料驗證。程式碼沒有做的事情,文件最好不要替它完成,否則規格就開始進入文學創作領域。

若要承擔更多責任,就應該像前面的範例一樣,提供對應的實作與測試。

這層 Schema 與 Adapter 可以提供以下價值:

問題Schema/Adapter 的作用
API 欄位改名隔離後端與前端的命名差異
數字與數字字串混用統一轉換成 number
null、空字串不一致集中正規化
日期格式不同統一輸出格式
API 回傳錯誤結構Runtime Validation 提前阻擋
舊 API 切換新 API將資料來源差異限制在邊界
商業預設值集中定義 fallback 行為
測試資料映射可直接撰寫 Unit Test

在 Legacy Modernization 中,Adapter 往往比重寫 Component 更有價值。

因為它建立了一條明確邊界,讓後端資料結構不再直接滲透到整個前端。

為 Adapter 補上對應測試

如果 Adapter 宣稱會處理資料格式,就應該直接測試這些行為:

ts
describe("mapStockDividendRow", () => {
  it("應轉換欄位名稱與數字字串", () => {
    expect(
      mapStockDividendRow({
        cash_dividend: "3.5",
        dividend_yield: "4.2",
        release_date: "2026-03-01T08:00:00+08:00",
      })
    ).toEqual({
      cashDividend: 3.5,
      dividendYield: 4.2,
      releaseDate: "2026-03-01",
    });
  });

  it("現金股息為 null 時應使用預設值", () => {
    expect(
      mapStockDividendRow({
        cash_dividend: null,
        dividend_yield: null,
        release_date: null,
      })
    ).toEqual({
      cashDividend: 0,
      dividendYield: null,
      releaseDate: null,
    });
  });

  it("無效日期應轉換為 null", () => {
    expect(
      mapStockDividendRow({
        cash_dividend: 2,
        dividend_yield: 3,
        release_date: "invalid-date",
      })
    ).toEqual({
      cashDividend: 2,
      dividendYield: 3,
      releaseDate: null,
    });
  });

  it("無效數值格式應拋出錯誤", () => {
    expect(() =>
      mapStockDividendRow({
        cash_dividend: "not-a-number",
        dividend_yield: 3,
        release_date: "2026-03-01",
      })
    ).toThrow();
  });
});

這些測試除了保護 Adapter,也把資料處理規則變成可以執行的規格。

未來若後端將空值從 null 改成 "--",測試會立即指出現有 Schema 無法接受新格式,而不是等到表格上線後顯示出一串神祕內容。

第五步:先替高風險邏輯補測試

替整個 Legacy 專案補齊測試通常不切實際。

更合理的策略是優先保護高風險區域:

  • 資料驗證與映射
  • 排序
  • 金額計算
  • 年份欄位生成
  • 欄位 fallback
  • API 參數組合
  • 權限判斷
  • 新舊資料來源切換

例如,若需求規定資料必須依發布日期由新到舊排列:

ts
export function sortByReleaseDate<
  T extends { releaseDate: string }
>(rows: T[]): T[] {
  return [...rows].sort(
    (a, b) =>
      new Date(b.releaseDate).getTime() -
      new Date(a.releaseDate).getTime()
  );
}

可以先補上最核心的測試:

ts
describe("sortByReleaseDate", () => {
  it("應依發布日期由新到舊排序", () => {
    const rows = [
      { id: 1, releaseDate: "2026-01-01" },
      { id: 2, releaseDate: "2026-03-01" },
    ];

    expect(sortByReleaseDate(rows)).toEqual([
      { id: 2, releaseDate: "2026-03-01" },
      { id: 1, releaseDate: "2026-01-01" },
    ]);
  });

  it("不應修改原始陣列", () => {
    const rows = [
      { id: 1, releaseDate: "2026-01-01" },
      { id: 2, releaseDate: "2026-03-01" },
    ];

    sortByReleaseDate(rows);

    expect(rows[0].id).toBe(1);
  });
});

這種測試的成本不高,卻可以保護最容易因需求變更而出錯的行為。

測試的目的也不是證明程式碼永遠不會出錯,而是讓未來修改時,工程師能快速知道自己破壞了什麼。

第六步:用文件保存「為什麼」,而不只是「做了什麼」

Legacy 專案最常缺少的不是程式碼註解,而是決策背景。

例如:

  • 為什麼某個 Model 必須走特定 API?
  • 為什麼某些表格使用 Client-side Pagination?
  • 為什麼舊資料與新資料無法直接 QA 比對?
  • 為什麼某個欄位不能沿用舊公式?
  • 為什麼這次不順便重構整個 Store?
  • 為什麼部分 JavaScript 檔案暫時沒有遷移?
  • 為什麼特定模組仍然保留 @ts-nocheck?

如果沒有記錄,幾個月後這些決策就會變成新的神祕邏輯。

我通常會在工單或 Markdown 文件中記錄:

md
## 修改範圍

- 將現金股息資料來源切換至新版 API
- 移除舊 CashDVDYield_byYear 資料依賴
- 新增 API Schema、Adapter 與型別
- 保留既有圖表 UI 與互動方式

## TypeScript 遷移範圍

- API Service、Adapter 與 Query Hook 已改為 TypeScript
- 舊圖表 Component 暫時維持 JavaScript
- 舊 Component 透過 Adapter 接收已正規化資料
- 後續修改圖表功能時再逐步遷移為 TSX

## 已知限制

- 新舊 API 計算公式不同,無法直接以數值完全比對
- 部分年度資料由後端決定是否提供
- 本次不調整圖表共用 Component
- 舊圖表模組缺少完整型別,暫時由包裝層隔離

## 驗證方式

- 驗證 API 參數
- 驗證 Runtime Schema
- 驗證 Response Mapping
- 驗證 Null 與日期處理
- 驗證年份欄位
- 驗證排序
- 使用 Playwright 驗證主要操作流程

這種文件能防止未來有人看到程式碼後,以為某段邏輯是隨便寫的,然後又進行一次充滿自信的錯誤重構。人類軟體工程最穩定的循環之一。

第七步:不要把需求不確定性藏在程式碼裡

舊專案翻新時,技術問題常常不是唯一風險。

另一個常見問題是需求尚未確認,但工程師已經被要求準備上線。

例如:

  • Prototype 尚未完成
  • Demo 已提供,但需求方沒有回覆
  • 上線前一天才提出新方向
  • 沒有設計稿,工程師只能自行推測
  • 驗收標準沒有明確定義

面對這類情況,不應該透過大量相容邏輯,把所有可能需求都寫進程式碼。

更合理的方式是留下明確紀錄:

text
目前版本已依現有需求完成,並提供操作 Demo。 若指定時間前未收到調整意見,將依目前版本提交至測試環境。後續若 Prototype 或需求內容變更,將另行評估修改範圍與時程。

這不是推卸責任,而是讓需求決策可追蹤。

工程師可以處理已知的複雜度,但不應該被要求替尚未做出的產品決策負責。

漸進式重構的核心原則

經過這些實際調整後,我認為 Legacy Modernization 可以整理成以下幾個原則。

原則說明
先理解,再修改先畫出資料流與依賴關係
允許新舊技術並存不要求一次完成全面遷移
從系統邊界開始優先處理 API、Schema 與 Adapter
建立狀態責任邊界分開 Server State、Client State 與 Local State
小步提交避免一次重寫整個模組
優先保護高風險邏輯為驗證、計算、排序與映射補測試
限制未型別程式碼透過 .d.ts 或包裝層隔離
保留決策背景文件記錄限制與修改原因
區分需求與技術問題不用程式碼掩蓋需求不確定性
保持可回復性每次修改都應容易 Rollback

哪些情況不適合直接重寫?

全面重寫並非完全不可行,但需要非常嚴格的條件。

比較適合重寫的情況包括:

  • 舊系統已經無法支援新需求
  • 商業邏輯已經重新定義
  • 有完整測試或可驗證規格
  • 新舊系統能夠並行一段時間
  • 團隊有足夠人力維護兩套系統
  • 有明確的遷移與回退方案

如果缺乏以上條件,直接重寫通常只是把已知的 Legacy 問題,換成一套尚未被發現的新問題。

而且新系統只要開始承接需求,通常很快也會累積技術債。

真正決定系統品質的,往往不是使用 React Class Component 還是 React Hooks,也不是副檔名是否全部變成 .tsx,而是團隊是否具有:

  • 清楚的責任邊界
  • 可驗證的需求
  • 穩定的開發流程
  • Code Review
  • 型別檢查
  • 測試策略
  • 部署與回退機制

結語

Legacy 專案翻新不是一次性的重構任務,而是一個持續降低風險的過程。

在實務上,我不會以「把所有舊程式碼改成最新寫法」作為目標,而是優先思考:

  • 下一次修改能不能更容易?
  • 問題發生時能不能更快定位?
  • API 改動會不會影響整個畫面?
  • 外部資料是否經過實際驗證?
  • 商業邏輯是否能獨立測試?
  • TypeScript 的型別是否被限制在可信邊界?
  • 新加入的工程師能不能理解目前設計?

TypeScript 的價值也不在於讓整個 Repository 看起來比較現代,而是逐步減少系統中依賴猜測的地方。

只要每次開發新功能時,都順手改善一小部分型別邊界、資料驗證、狀態責任、測試與文件,Legacy 系統就能逐步從不可預測,變成可維護。

重構不一定要驚天動地。

很多時候,真正有效的現代化,只是讓下一位修改這段程式碼的人,不需要再次成為考古學家。


這篇文章有幫助嗎?