2026/07/31
接手 Legacy 專案時,最理想的情況通常是:
現實則往往完全相反。
你可能會遇到上千行的 Class Component、非同步 API 全部塞進全域 Store、沒有測試、資料來源混亂,以及需求隨時可能改變。
更麻煩的是,產品仍然持續運作,新功能也不能停止開發。
因此,Legacy Modernization 的重點通常不是「把舊專案全部重寫」,而是:
在持續交付功能的同時,逐步降低修改風險,建立可驗證、可維護的系統邊界。
本文將分享我在實際 React 專案中,如何逐步導入 TypeScript、TanStack Query、單元測試與模組化設計,改善一個長期累積技術債的舊系統。
很多人看到 Legacy 專案,第一個想法是:
把 Class Component 改成 Function Component 就好了。
但語法新舊通常不是最大的問題。
真正危險的是,你不知道修改一段程式碼後,會影響到哪些地方。
在我接手的專案中,曾經出現以下情況:
這類專案最大的風險並不是「程式碼不好看」,而是缺乏可預測性。
你無法確定:
因此,翻新 Legacy 專案的第一步,不是馬上重寫,而是先建立系統的可觀測性。
接到舊功能調整時,我通常不會直接開始修改 Component。
我會先確認完整資料流:
1API Service
2 ↓
3Async Action / Store
4 ↓
5資料轉換
6 ↓
7Component Props / Selector
8 ↓
9圖表或表格需要釐清的問題包括:
這個步驟看起來沒有立刻產出功能,卻是降低風險最重要的工作。
因為在缺乏文件的 Legacy 專案裡,程式碼本身通常就是唯一的規格。
如果沒有先理解資料流就開始修改,很容易發生這種情況:
1畫面看起來正常
2↓
3測試環境可以使用
4↓
5合併正式環境
6↓
7另一個共用頁面壞掉Legacy 系統最喜歡用這種方式提醒工程師,它仍然活著,而且心情不太穩定。
在大型 Legacy 專案中,TypeScript 通常不適合一次全面導入。
如果直接把所有 .js 與 .jsx 改成 .ts 與 .tsx,往往會立刻出現數百甚至數千個型別錯誤。團隊接下來花費大量時間處理歷史問題,原本的新功能與維運工作則全部停擺。
比較務實的方式,是先允許 JavaScript 與 TypeScript 並存,讓新程式碼開始受到型別保護,再逐步處理既有模組。
第一階段可以先安裝 TypeScript 與必要型別套件:
1npm install --save-dev typescript @types/react @types/react-dom接著建立 tsconfig.json:
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "lib": ["DOM", "DOM.Iterable", "ES2020"],
5 "module": "ESNext",
6 "moduleResolution": "Bundler",
7 "jsx": "react-jsx",
8 "strict": true,
9 "noEmit": true,
10 "allowJs": true,
11 "checkJs": false,
12 "esModuleInterop": true,
13 "skipLibCheck": true,
14 "resolveJsonModule": true
15 },
16 "include": ["src"]
17}這裡有兩個關鍵設定:
1{
2 "allowJs": true,
3 "checkJs": false
4}allowJs: true 代表 TypeScript 專案可以繼續載入既有的 JavaScript 檔案,因此 .js、.jsx、.ts 與 .tsx 可以同時存在。
checkJs: false 則代表暫時不對所有 JavaScript 檔案執行完整型別檢查。
這個取捨很重要。
如果一開始就將 checkJs 設為 true,TypeScript 可能會對多年累積的 JavaScript 程式碼產生大量錯誤。這些錯誤未必與目前正在修改的功能有關,卻會讓型別檢查無法成為有效的 CI Gate。
因此,我會先採用以下策略:
| 設定 | 初期選擇 | 原因 |
|---|---|---|
allowJs | true | 允許 JavaScript 與 TypeScript 並存 |
checkJs | false | 避免一次檢查所有歷史 JavaScript |
strict | true | 新增的 TypeScript 檔案直接遵守嚴格規則 |
noEmit | true | 只執行型別檢查,由原本建置工具輸出 |
skipLibCheck | true | 避免第三方型別問題阻塞初期導入 |
skipLibCheck 適合作為導入初期的降噪手段,但不應被誤認為能解決應用程式本身的型別問題。它只會略過宣告檔之間的檢查,不會替業務程式碼收拾殘局。
TypeScript 遷移不應單純按照資料夾順序進行。
比起先把所有 Button、Modal 或頁面 Component 改成 .tsx,我會優先處理資料進出系統的邊界:
原因是邊界型別能最快產生實際價值。
當 API Response、Adapter 輸出與 Hook 回傳值都有明確型別後,即使下游仍是 JavaScript,至少資料結構已經不再完全依靠猜測。
例如,可以先將 API Service 改成 TypeScript:
1export type StockDividendApiResponse = {
2 cash_dividend: number | string | null;
3 dividend_yield: number | string | null;
4 release_date: string | null;
5};
6
7export async function fetchStockDividend(
8 stockCode: string
9): Promise<unknown> {
10 const response = await fetch(`/api/stocks/${stockCode}/dividend`);
11
12 if (!response.ok) {
13 throw new Error("取得股利資料失敗");
14 }
15
16 return response.json();
17}這裡刻意讓 fetchStockDividend 回傳 Promise<unknown>,而不是直接宣稱它一定符合 StockDividendApiResponse。
因為 TypeScript 型別只存在於編譯期間,無法保證伺服器實際回傳的 JSON 符合宣告。若直接使用型別斷言:
1return response.json() as Promise<StockDividendApiResponse>;只是要求 TypeScript 相信工程師,並沒有真正驗證資料。
比較穩定的做法,是在 Adapter 或 Schema 層進行 Runtime Validation,稍後會再說明。
面對上千行的 Class Component,不一定要先完整改寫成 Function Component 才能使用 TypeScript。
可以先將 .jsx 改成 .tsx,替 Props 與 State 加上型別:
1type LegacyChartProps = {
2 stockCode: string;
3 rows: StockDividendRow[];
4 onReload?: () => void;
5};
6
7type LegacyChartState = {
8 selectedYear: number | null;
9 isExpanded: boolean;
10};
11
12class LegacyChart extends React.Component<
13 LegacyChartProps,
14 LegacyChartState
15> {
16 state: LegacyChartState = {
17 selectedYear: null,
18 isExpanded: false,
19 };
20
21 render() {
22 return null;
23 }
24}Class Component 本身不會因為用了 TypeScript 就突然變成技術禁區。
如果目前需求只需要調整資料來源,先補上 Props 與 State 型別,通常比同時改寫生命週期、狀態管理與渲染結構更安全。
我的原則是:
不把「語法現代化」與「商業邏輯修改」綁在同一個大型提交裡。
每次提交最好只有一個主要目的,例如:
.tsx這樣 Code Review、問題定位與 Rollback 都會容易許多。
完成基本設定後,可以先加入:
1{
2 "scripts": {
3 "typecheck": "tsc --noEmit"
4 }
5}並在 CI 執行:
1npm run typecheck由於 JavaScript 與 TypeScript 可以並存,團隊不需要等到整個專案完成遷移,才開始使用型別檢查。
比較實際的遷移路線如下:
1允許 JavaScript 與 TypeScript 並存
2↓
3新檔案一律使用 TypeScript
4↓
5先遷移 API、Adapter 與商業邏輯
6↓
7修改既有功能時順便遷移相關檔案
8↓
9逐步加入 @ts-check 或改成 .ts/.tsx
10↓
11縮小 any、@ts-ignore 與 @ts-nocheck 的範圍
12↓
13最後再評估是否全面啟用 checkJs 或移除 allowJs這種方式不會讓 TypeScript 導入變成一場長期停工的大型改造,而是讓型別覆蓋率隨著日常開發逐步增加。
舊 React 專案常見的一個問題,是將所有狀態都放進 Redux 或 Zustand。
例如:
1type StockStore = {
2 stockData: StockData[];
3 isLoading: boolean;
4 error: string | null;
5 selectedTab: string;
6 isTooltipOpen: boolean;
7 lastFetchedAt: number | null;
8 fetchStockData: (stockCode: string) => Promise<void>;
9};這裡其實混合了兩種完全不同的狀態。
由後端或外部資料來源提供:
這類狀態通常需要處理:
只存在於前端操作流程:
Zustand 很適合管理 Client State,但如果拿來管理 Server State,團隊通常還得自行處理 Cache。
例如,為了避免每次進入頁面都重新呼叫 API,Store 可能逐漸加入:
原本單純的 Store 很容易演變成:
1type StockCacheItem = {
2 data: StockData[];
3 updatedAt: number;
4};
5
6type StockStore = {
7 cache: Record<string, StockCacheItem>;
8 loadingKeys: Record<string, boolean>;
9 errors: Record<string, string | null>;
10 fetchStockData: (
11 stockCode: string,
12 options?: {
13 force?: boolean;
14 staleTime?: number;
15 }
16 ) => Promise<void>;
17};接著每個 Action 都必須自行判斷 Cache 是否存在、是否過期,以及目前是否已有相同請求正在執行。
1const useStockStore = create<StockStore>((set, get) => ({
2 cache: {},
3 loadingKeys: {},
4 errors: {},
5
6 fetchStockData: async (
7 stockCode,
8 {
9 force = false,
10 staleTime = 5 * 60 * 1000,
11 } = {}
12 ) => {
13 const cachedItem = get().cache[stockCode];
14 const isFresh =
15 cachedItem &&
16 Date.now() - cachedItem.updatedAt < staleTime;
17
18 if (!force && isFresh) {
19 return;
20 }
21
22 if (get().loadingKeys[stockCode]) {
23 return;
24 }
25
26 set((state) => ({
27 loadingKeys: {
28 ...state.loadingKeys,
29 [stockCode]: true,
30 },
31 errors: {
32 ...state.errors,
33 [stockCode]: null,
34 },
35 }));
36
37 try {
38 const data = await fetchStockData(stockCode);
39
40 set((state) => ({
41 cache: {
42 ...state.cache,
43 [stockCode]: {
44 data,
45 updatedAt: Date.now(),
46 },
47 },
48 }));
49 } catch {
50 set((state) => ({
51 errors: {
52 ...state.errors,
53 [stockCode]: "取得股票資料失敗",
54 },
55 }));
56 } finally {
57 set((state) => ({
58 loadingKeys: {
59 ...state.loadingKeys,
60 [stockCode]: false,
61 },
62 }));
63 }
64 },
65}));這些程式碼並不是產品真正的商業邏輯,而是在手動實作一套簡化版的 Server State 管理工具。
除了程式碼量增加,還要自行處理:
-背景重新請求
TanStack Query 已經內建這些常見能力,因此不需要再使用 Zustand 手刻 Cache。
1export function useStockData(stockCode: string) {
2 return useQuery({
3 queryKey: ["stock-data", stockCode],
4 queryFn: () => fetchStockData(stockCode),
5 enabled: Boolean(stockCode),
6 staleTime: 5 * 60 * 1000,
7 gcTime: 30 * 60 * 1000,
8 });
9}這裡的 queryKey 同時也是 Cache Key:
1["stock-data", "2330"]
2["stock-data", "2317"]不同股票代碼會擁有各自獨立的 Cache。
當多個 Component 使用相同的 queryKey 時,TanStack Query 可以共用同一份資料,並避免重複執行相同請求。
1const {
2 data = [],
3 isPending,
4 isFetching,
5 isError,
6} = useStockData(stockCode);其中:
| 狀態 | 用途 |
|---|---|
isPending | 尚未取得第一份資料 |
isFetching | 目前正在請求,包含背景更新 |
isError | 最近一次請求發生錯誤 |
data | 目前 Cache 中可使用的資料 |
staleTime 則用來定義資料在多久內仍被視為新鮮:
1staleTime: 5 * 60 * 1000在五分鐘內再次使用相同的 Query,通常可以直接取得 Cache,不需要重新呼叫 API。
gcTime 則控制沒有任何 Component 使用這份 Query 後,Cache 可以保留多久:
1gcTime: 30 * 60 * 1000這些行為原本都需要在 Zustand 中自行設計欄位、時間判斷與清除機制,現在可以透過 Query 設定統一管理。
當資料被更新後,也不需要手動尋找所有使用這份資料的 Store 與 Component,可以直接讓相關 Cache 失效:
1const queryClient = useQueryClient();
2
3await updateStockDividend(input);
4
5await queryClient.invalidateQueries({
6 queryKey: ["stock-data", input.stockCode],
7});TanStack Query 會將對應資料標記為失效,並在適當時機重新取得最新資料。
因此,我採取的策略不是一次移除所有 Zustand,而是逐步調整責任邊界:
1TanStack Query
2負責 API、Cache、Cache 失效、Loading、Error、Retry、Refetch
3
4Zustand
5負責真正需要跨元件共享的 UI 狀態
6
7Local State
8負責單一元件內部互動這樣做的價值不只是程式碼變短。
更重要的是,團隊不需要繼續在 Zustand 中自行維護 Cache、請求去重、失效策略與資料同步機制,而是讓資料生命週期回到專門處理 Server State 的工具中。
Zustand 也能重新回到它比較擅長的位置,不必一邊控制 Modal,一邊假裝自己是資料快取框架。
Legacy 專案很容易讓 API Response 直接進入 Component。
例如:
1<TableCell>{item.field_01}</TableCell>
2<TableCell>{item.field_02}</TableCell>
3<TableCell>{item.field_03}</TableCell>當後端修改欄位名稱、資料格式或 API 來源時,整個畫面就會與後端結構高度耦合。
比較穩定的方式,是建立明確的 Schema 與 Adapter。
假設後端可能回傳數字、數字字串或 null:
1import { z } from "zod";
2
3const nullableNumberSchema = z
4 .union([z.number(), z.string(), z.null()])
5 .transform((value, context): number | null => {
6 if (value === null || value === "") {
7 return null;
8 }
9
10 const parsedValue =
11 typeof value === "number" ? value : Number(value);
12
13 if (!Number.isFinite(parsedValue)) {
14 context.addIssue({
15 code: z.ZodIssueCode.custom,
16 message: "必須是有效數值",
17 });
18
19 return z.NEVER;
20 }
21
22 return parsedValue;
23 });
24
25const stockDividendApiSchema = z.object({
26 cash_dividend: nullableNumberSchema,
27 dividend_yield: nullableNumberSchema,
28 release_date: z.string().nullable(),
29});
30
31type StockDividendApiResponse = z.infer<
32 typeof stockDividendApiSchema
33>;這裡不只是告訴 TypeScript 資料「應該長什麼樣子」,而是在執行期間實際檢查 API Response。
接著定義前端真正需要的資料模型:
1type StockDividendRow = {
2 cashDividend: number;
3 dividendYield: number | null;
4 releaseDate: string | null;
5};日期處理可以集中在純函式中:
1function normalizeDate(
2 value: string | null
3): string | null {
4 if (!value) {
5 return null;
6 }
7
8 const date = new Date(value);
9
10 if (Number.isNaN(date.getTime())) {
11 return null;
12 }
13
14 return date.toISOString().slice(0, 10);
15}接著建立 Adapter:
1export function mapStockDividendRow(
2 input: unknown
3): StockDividendRow {
4 const data = stockDividendApiSchema.parse(input);
5
6 return {
7 cashDividend: data.cash_dividend ?? 0,
8 dividendYield: data.dividend_yield,
9 releaseDate: normalizeDate(data.release_date),
10 };
11}這段 Adapter 現在實際負責:
null 正規化YYYY-MM-DDnullComponent 只依賴前端定義的資料模型:
1<TableCell>{row.cashDividend}</TableCell>
2
3<TableCell>
4 {row.dividendYield ?? "-"}
5</TableCell>
6
7<TableCell>
8 {row.releaseDate ?? "尚未公布"}
9</TableCell>Adapter 的責任需要依專案情況明確界定。
如果 Adapter 只做以下轉換:
1return {
2 cashDividend: data.cash_dividend,
3 dividendYield: data.dividend_yield,
4 releaseDate: data.release_date,
5};那麼它提供的價值就應該描述為:
隔離 API 欄位命名與前端資料模型。
不能宣稱它已經處理日期正規化、預設值與資料驗證。程式碼沒有做的事情,文件最好不要替它完成,否則規格就開始進入文學創作領域。
若要承擔更多責任,就應該像前面的範例一樣,提供對應的實作與測試。
這層 Schema 與 Adapter 可以提供以下價值:
| 問題 | Schema/Adapter 的作用 |
|---|---|
| API 欄位改名 | 隔離後端與前端的命名差異 |
| 數字與數字字串混用 | 統一轉換成 number |
null、空字串不一致 | 集中正規化 |
| 日期格式不同 | 統一輸出格式 |
| API 回傳錯誤結構 | Runtime Validation 提前阻擋 |
| 舊 API 切換新 API | 將資料來源差異限制在邊界 |
| 商業預設值 | 集中定義 fallback 行為 |
| 測試資料映射 | 可直接撰寫 Unit Test |
在 Legacy Modernization 中,Adapter 往往比重寫 Component 更有價值。
因為它建立了一條明確邊界,讓後端資料結構不再直接滲透到整個前端。
如果 Adapter 宣稱會處理資料格式,就應該直接測試這些行為:
1describe("mapStockDividendRow", () => {
2 it("應轉換欄位名稱與數字字串", () => {
3 expect(
4 mapStockDividendRow({
5 cash_dividend: "3.5",
6 dividend_yield: "4.2",
7 release_date: "2026-03-01T08:00:00+08:00",
8 })
9 ).toEqual({
10 cashDividend: 3.5,
11 dividendYield: 4.2,
12 releaseDate: "2026-03-01",
13 });
14 });
15
16 it("現金股息為 null 時應使用預設值", () => {
17 expect(
18 mapStockDividendRow({
19 cash_dividend: null,
20 dividend_yield: null,
21 release_date: null,
22 })
23 ).toEqual({
24 cashDividend: 0,
25 dividendYield: null,
26 releaseDate: null,
27 });
28 });
29
30 it("無效日期應轉換為 null", () => {
31 expect(
32 mapStockDividendRow({
33 cash_dividend: 2,
34 dividend_yield: 3,
35 release_date: "invalid-date",
36 })
37 ).toEqual({
38 cashDividend: 2,
39 dividendYield: 3,
40 releaseDate: null,
41 });
42 });
43
44 it("無效數值格式應拋出錯誤", () => {
45 expect(() =>
46 mapStockDividendRow({
47 cash_dividend: "not-a-number",
48 dividend_yield: 3,
49 release_date: "2026-03-01",
50 })
51 ).toThrow();
52 });
53});這些測試除了保護 Adapter,也把資料處理規則變成可以執行的規格。
未來若後端將空值從 null 改成 "--",測試會立即指出現有 Schema 無法接受新格式,而不是等到表格上線後顯示出一串神祕內容。
替整個 Legacy 專案補齊測試通常不切實際。
更合理的策略是優先保護高風險區域:
例如,若需求規定資料必須依發布日期由新到舊排列:
1export function sortByReleaseDate<
2 T extends { releaseDate: string }
3>(rows: T[]): T[] {
4 return [...rows].sort(
5 (a, b) =>
6 new Date(b.releaseDate).getTime() -
7 new Date(a.releaseDate).getTime()
8 );
9}可以先補上最核心的測試:
1describe("sortByReleaseDate", () => {
2 it("應依發布日期由新到舊排序", () => {
3 const rows = [
4 { id: 1, releaseDate: "2026-01-01" },
5 { id: 2, releaseDate: "2026-03-01" },
6 ];
7
8 expect(sortByReleaseDate(rows)).toEqual([
9 { id: 2, releaseDate: "2026-03-01" },
10 { id: 1, releaseDate: "2026-01-01" },
11 ]);
12 });
13
14 it("不應修改原始陣列", () => {
15 const rows = [
16 { id: 1, releaseDate: "2026-01-01" },
17 { id: 2, releaseDate: "2026-03-01" },
18 ];
19
20 sortByReleaseDate(rows);
21
22 expect(rows[0].id).toBe(1);
23 });
24});這種測試的成本不高,卻可以保護最容易因需求變更而出錯的行為。
測試的目的也不是證明程式碼永遠不會出錯,而是讓未來修改時,工程師能快速知道自己破壞了什麼。
Legacy 專案最常缺少的不是程式碼註解,而是決策背景。
例如:
@ts-nocheck?如果沒有記錄,幾個月後這些決策就會變成新的神祕邏輯。
我通常會在工單或 Markdown 文件中記錄:
1## 修改範圍
2
3- 將現金股息資料來源切換至新版 API
4- 移除舊 CashDVDYield_byYear 資料依賴
5- 新增 API Schema、Adapter 與型別
6- 保留既有圖表 UI 與互動方式
7
8## TypeScript 遷移範圍
9
10- API Service、Adapter 與 Query Hook 已改為 TypeScript
11- 舊圖表 Component 暫時維持 JavaScript
12- 舊 Component 透過 Adapter 接收已正規化資料
13- 後續修改圖表功能時再逐步遷移為 TSX
14
15## 已知限制
16
17- 新舊 API 計算公式不同,無法直接以數值完全比對
18- 部分年度資料由後端決定是否提供
19- 本次不調整圖表共用 Component
20- 舊圖表模組缺少完整型別,暫時由包裝層隔離
21
22## 驗證方式
23
24- 驗證 API 參數
25- 驗證 Runtime Schema
26- 驗證 Response Mapping
27- 驗證 Null 與日期處理
28- 驗證年份欄位
29- 驗證排序
30- 使用 Playwright 驗證主要操作流程這種文件能防止未來有人看到程式碼後,以為某段邏輯是隨便寫的,然後又進行一次充滿自信的錯誤重構。人類軟體工程最穩定的循環之一。
舊專案翻新時,技術問題常常不是唯一風險。
另一個常見問題是需求尚未確認,但工程師已經被要求準備上線。
例如:
面對這類情況,不應該透過大量相容邏輯,把所有可能需求都寫進程式碼。
更合理的方式是留下明確紀錄:
1目前版本已依現有需求完成,並提供操作 Demo。
2
3若指定時間前未收到調整意見,將依目前版本提交至測試環境。
4後續若 Prototype 或需求內容變更,將另行評估修改範圍與時程。這不是推卸責任,而是讓需求決策可追蹤。
工程師可以處理已知的複雜度,但不應該被要求替尚未做出的產品決策負責。
經過這些實際調整後,我認為 Legacy Modernization 可以整理成以下幾個原則。
| 原則 | 說明 |
|---|---|
| 先理解,再修改 | 先畫出資料流與依賴關係 |
| 允許新舊技術並存 | 不要求一次完成全面遷移 |
| 從系統邊界開始 | 優先處理 API、Schema 與 Adapter |
| 建立狀態責任邊界 | 分開 Server State、Client State 與 Local State |
| 小步提交 | 避免一次重寫整個模組 |
| 優先保護高風險邏輯 | 為驗證、計算、排序與映射補測試 |
| 限制未型別程式碼 | 透過 .d.ts 或包裝層隔離 |
| 保留決策背景 | 文件記錄限制與修改原因 |
| 區分需求與技術問題 | 不用程式碼掩蓋需求不確定性 |
| 保持可回復性 | 每次修改都應容易 Rollback |
全面重寫並非完全不可行,但需要非常嚴格的條件。
比較適合重寫的情況包括:
如果缺乏以上條件,直接重寫通常只是把已知的 Legacy 問題,換成一套尚未被發現的新問題。
而且新系統只要開始承接需求,通常很快也會累積技術債。
真正決定系統品質的,往往不是使用 React Class Component 還是 React Hooks,也不是副檔名是否全部變成 .tsx,而是團隊是否具有:
Legacy 專案翻新不是一次性的重構任務,而是一個持續降低風險的過程。
在實務上,我不會以「把所有舊程式碼改成最新寫法」作為目標,而是優先思考:
TypeScript 的價值也不在於讓整個 Repository 看起來比較現代,而是逐步減少系統中依賴猜測的地方。
只要每次開發新功能時,都順手改善一小部分型別邊界、資料驗證、狀態責任、測試與文件,Legacy 系統就能逐步從不可預測,變成可維護。
重構不一定要驚天動地。
很多時候,真正有效的現代化,只是讓下一位修改這段程式碼的人,不需要再次成為考古學家。