2026/07/31
你的意見會幫助我們持續改善內容。
接手 Legacy 專案時,最理想的情況通常是:
現實則往往完全相反。
你可能會遇到上千行的 Class Component、非同步 API 全部塞進全域 Store、沒有測試、資料來源混亂,以及需求隨時可能改變。
更麻煩的是,產品仍然持續運作,新功能也不能停止開發。
因此,Legacy Modernization 的重點通常不是「把舊專案全部重寫」,而是:
在持續交付功能的同時,逐步降低修改風險,建立可驗證、可維護的系統邊界。
本文將分享我在實際 React 專案中,如何逐步導入 TypeScript、TanStack Query、單元測試與模組化設計,改善一個長期累積技術債的舊系統。
很多人看到 Legacy 專案,第一個想法是:
把 Class Component 改成 Function Component 就好了。
但語法新舊通常不是最大的問題。
真正危險的是,你不知道修改一段程式碼後,會影響到哪些地方。
在我接手的專案中,曾經出現以下情況:
這類專案最大的風險並不是「程式碼不好看」,而是缺乏可預測性。
你無法確定:
因此,翻新 Legacy 專案的第一步,不是馬上重寫,而是先建立系統的可觀測性。
接到舊功能調整時,我通常不會直接開始修改 Component。
我會先確認完整資料流:
API Service ↓Async Action / Store ↓資料轉換 ↓Component Props / Selector ↓圖表或表格需要釐清的問題包括:
這個步驟看起來沒有立刻產出功能,卻是降低風險最重要的工作。
因為在缺乏文件的 Legacy 專案裡,程式碼本身通常就是唯一的規格。
如果沒有先理解資料流就開始修改,很容易發生這種情況:
畫面看起來正常↓測試環境可以使用↓合併正式環境↓另一個共用頁面壞掉Legacy 系統最喜歡用這種方式提醒工程師,它仍然活著,而且心情不太穩定。
在大型 Legacy 專案中,TypeScript 通常不適合一次全面導入。
如果直接把所有 .js 與 .jsx 改成 .ts 與 .tsx,往往會立刻出現數百甚至數千個型別錯誤。團隊接下來花費大量時間處理歷史問題,原本的新功能與維運工作則全部停擺。
比較務實的方式,是先允許 JavaScript 與 TypeScript 並存,讓新程式碼開始受到型別保護,再逐步處理既有模組。
第一階段可以先安裝 TypeScript 與必要型別套件:
npm install --save-dev typescript @types/react @types/react-dom接著建立 tsconfig.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"]
}這裡有兩個關鍵設定:
{
"allowJs": true,
"checkJs": false
}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:
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 符合宣告。若直接使用型別斷言:
return response.json() as Promise<StockDividendApiResponse>;只是要求 TypeScript 相信工程師,並沒有真正驗證資料。
比較穩定的做法,是在 Adapter 或 Schema 層進行 Runtime Validation,稍後會再說明。
面對上千行的 Class Component,不一定要先完整改寫成 Function Component 才能使用 TypeScript。
可以先將 .jsx 改成 .tsx,替 Props 與 State 加上型別:
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 型別,通常比同時改寫生命週期、狀態管理與渲染結構更安全。
我的原則是:
不把「語法現代化」與「商業邏輯修改」綁在同一個大型提交裡。
每次提交最好只有一個主要目的,例如:
.tsx這樣 Code Review、問題定位與 Rollback 都會容易許多。
完成基本設定後,可以先加入:
{
"scripts": {
"typecheck": "tsc --noEmit"
}
}並在 CI 執行:
npm run typecheck由於 JavaScript 與 TypeScript 可以並存,團隊不需要等到整個專案完成遷移,才開始使用型別檢查。
比較實際的遷移路線如下:
允許 JavaScript 與 TypeScript 並存↓新檔案一律使用 TypeScript↓先遷移 API、Adapter 與商業邏輯↓修改既有功能時順便遷移相關檔案↓逐步加入 @ts-check 或改成 .ts/.tsx↓縮小 any、@ts-ignore 與 @ts-nocheck 的範圍↓最後再評估是否全面啟用 checkJs 或移除 allowJs這種方式不會讓 TypeScript 導入變成一場長期停工的大型改造,而是讓型別覆蓋率隨著日常開發逐步增加。
舊 React 專案常見的一個問題,是將所有狀態都放進 Redux 或 Zustand。
例如:
type StockStore = {
stockData: StockData[];
isLoading: boolean;
error: string | null;
selectedTab: string;
isTooltipOpen: boolean;
lastFetchedAt: number | null;
fetchStockData: (stockCode: string) => Promise<void>;
};這裡其實混合了兩種完全不同的狀態。
由後端或外部資料來源提供:
這類狀態通常需要處理:
只存在於前端操作流程:
Zustand 很適合管理 Client State,但如果拿來管理 Server State,團隊通常還得自行處理 Cache。
例如,為了避免每次進入頁面都重新呼叫 API,Store 可能逐漸加入:
原本單純的 Store 很容易演變成:
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 是否存在、是否過期,以及目前是否已有相同請求正在執行。
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 管理工具。
除了程式碼量增加,還要自行處理:
-背景重新請求
TanStack Query 已經內建這些常見能力,因此不需要再使用 Zustand 手刻 Cache。
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:
["stock-data", "2330"]
["stock-data", "2317"]不同股票代碼會擁有各自獨立的 Cache。
當多個 Component 使用相同的 queryKey 時,TanStack Query 可以共用同一份資料,並避免重複執行相同請求。
const {
data = [],
isPending,
isFetching,
isError,
} = useStockData(stockCode);其中:
| 狀態 | 用途 |
|---|---|
isPending | 尚未取得第一份資料 |
isFetching | 目前正在請求,包含背景更新 |
isError | 最近一次請求發生錯誤 |
data | 目前 Cache 中可使用的資料 |
staleTime 則用來定義資料在多久內仍被視為新鮮:
staleTime: 5 * 60 * 1000在五分鐘內再次使用相同的 Query,通常可以直接取得 Cache,不需要重新呼叫 API。
gcTime 則控制沒有任何 Component 使用這份 Query 後,Cache 可以保留多久:
gcTime: 30 * 60 * 1000這些行為原本都需要在 Zustand 中自行設計欄位、時間判斷與清除機制,現在可以透過 Query 設定統一管理。
當資料被更新後,也不需要手動尋找所有使用這份資料的 Store 與 Component,可以直接讓相關 Cache 失效:
const queryClient = useQueryClient();
await updateStockDividend(input);
await queryClient.invalidateQueries({
queryKey: ["stock-data", input.stockCode],
});TanStack Query 會將對應資料標記為失效,並在適當時機重新取得最新資料。
因此,我採取的策略不是一次移除所有 Zustand,而是逐步調整責任邊界:
TanStack Query負責 API、Cache、Cache 失效、Loading、Error、Retry、Refetch Zustand負責真正需要跨元件共享的 UI 狀態 Local State負責單一元件內部互動這樣做的價值不只是程式碼變短。
更重要的是,團隊不需要繼續在 Zustand 中自行維護 Cache、請求去重、失效策略與資料同步機制,而是讓資料生命週期回到專門處理 Server State 的工具中。
Zustand 也能重新回到它比較擅長的位置,不必一邊控制 Modal,一邊假裝自己是資料快取框架。
Legacy 專案很容易讓 API Response 直接進入 Component。
例如:
<TableCell>{item.field_01}</TableCell>
<TableCell>{item.field_02}</TableCell>
<TableCell>{item.field_03}</TableCell>當後端修改欄位名稱、資料格式或 API 來源時,整個畫面就會與後端結構高度耦合。
比較穩定的方式,是建立明確的 Schema 與 Adapter。
假設後端可能回傳數字、數字字串或 null:
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。
接著定義前端真正需要的資料模型:
type StockDividendRow = {
cashDividend: number;
dividendYield: number | null;
releaseDate: string | null;
};日期處理可以集中在純函式中:
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:
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 現在實際負責:
null 正規化YYYY-MM-DDnullComponent 只依賴前端定義的資料模型:
<TableCell>{row.cashDividend}</TableCell>
<TableCell>
{row.dividendYield ?? "-"}
</TableCell>
<TableCell>
{row.releaseDate ?? "尚未公布"}
</TableCell>Adapter 的責任需要依專案情況明確界定。
如果 Adapter 只做以下轉換:
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 宣稱會處理資料格式,就應該直接測試這些行為:
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 專案補齊測試通常不切實際。
更合理的策略是優先保護高風險區域:
例如,若需求規定資料必須依發布日期由新到舊排列:
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()
);
}可以先補上最核心的測試:
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 專案最常缺少的不是程式碼註解,而是決策背景。
例如:
@ts-nocheck?如果沒有記錄,幾個月後這些決策就會變成新的神祕邏輯。
我通常會在工單或 Markdown 文件中記錄:
## 修改範圍
- 將現金股息資料來源切換至新版 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 驗證主要操作流程這種文件能防止未來有人看到程式碼後,以為某段邏輯是隨便寫的,然後又進行一次充滿自信的錯誤重構。人類軟體工程最穩定的循環之一。
舊專案翻新時,技術問題常常不是唯一風險。
另一個常見問題是需求尚未確認,但工程師已經被要求準備上線。
例如:
面對這類情況,不應該透過大量相容邏輯,把所有可能需求都寫進程式碼。
更合理的方式是留下明確紀錄:
目前版本已依現有需求完成,並提供操作 Demo。 若指定時間前未收到調整意見,將依目前版本提交至測試環境。後續若 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 系統就能逐步從不可預測,變成可維護。
重構不一定要驚天動地。
很多時候,真正有效的現代化,只是讓下一位修改這段程式碼的人,不需要再次成為考古學家。