:類型約束、受控組件Bug與測試工程化)
這期的學(xué)習周報第三十二期說實話和之前三十一期都不太一樣。往常我會東學(xué)一個技巧、西看一個框架但這一周大約80%的精力都投在了一件很“重”的事情上——把維護了大半年的某個前端組件項目從JavaScript整體遷移到TypeScript。遷移過程中又牽出了一個拖了兩周的輸入框聯(lián)動bug順手補了一批組件測試最后還抽了半天把構(gòu)建流程里攢下的雜活清理了一遍。這期內(nèi)容沒有什么花哨的新技術(shù)全是實打?qū)嵉墓こ袒顑簩懡o同樣在維護老項目、想引入類型約束或者正在被各種邊界問題折磨的前端開發(fā)者??赐昴阒辽倌軒ё邘讞l可以直接落地的操作思路。1. 類型遷移不是“加類型”而是把隱式約定翻到臺面上1.1 為什么拖了那么久才動手我手上的這個組件項目前前后后維護了大半年代碼量大概在一萬行左右組件數(shù)量不算多但相互之間的調(diào)用關(guān)系相當繞。早期用JavaScript寫得很爽因為根本不用考慮類型函數(shù)簽名隨便改組件props隨手加。但問題恰恰出在“隨手”上——連續(xù)三周出現(xiàn)了同類型的線上小故障某個業(yè)務(wù)方調(diào)用組件時以為傳的是字符串數(shù)組實際傳的是對象數(shù)組另一個調(diào)用方把回調(diào)函數(shù)和普通值搞混了。這類問題在JavaScript里幾乎無法提前暴露只能靠運行時日志去反推每次排查都要花掉大量時間。真正讓我下決心的不是“TypeScript更好”這個抽象的理由而是三個實打?qū)嵉耐袋c第一組件庫的API正在擴大靠人腦記憶所有props的類型已經(jīng)不現(xiàn)實第二每次改動公共組件都擔心破壞調(diào)用方的行為但沒有任何機制能告訴我“哪里可能會掛”第三團隊里新接手的人讀代碼時不得不沿著調(diào)用鏈去猜數(shù)據(jù)結(jié)構(gòu)。于是這周下定決心來一次徹底遷移。1.2 遷移順序從工具函數(shù)層向視圖層推進最初我想直接改組件文件但動手前又猶豫了。組件的props邏輯比較復(fù)雜如果一上來就改組件很可能陷入“改一步、報一堆錯”的泥潭。我最后定的遷移順序是先遷移utils和類型定義層再遷移組件的props聲明最后處理組件內(nèi)部的實現(xiàn)細節(jié)。這個順序有明確的理由。工具函數(shù)層通常不依賴DOM不涉及生命周期遷移風險最低而且很多工具函數(shù)是被多個組件共用的先把它們的類型定住后面的組件遷移就有了“地基”。我第一個遷移的是一個數(shù)字格式化函數(shù)原來長這樣function formatNumber(value, digits) { return Number(value).toFixed(digits); }遷移后變成了帶泛型和約束的版本function formatNumberN extends string | number( value: N, digits?: number ): string { const num typeof value string ? parseFloat(value) : value; return num.toFixed(digits ?? 2); }別看這個函數(shù)很小它暴露了一個重要問題原來調(diào)用方可能傳入字符串、數(shù)字甚至null沒人說得清。加上類型約束之后至少調(diào)用意圖變得可檢查了。類似的工具函數(shù)我遷移了十多個每遷移完一個就立刻把對應(yīng)的調(diào)用點批量修正保持倉庫隨時處于可運行狀態(tài)。1.3 組件泛型讓調(diào)用方的數(shù)據(jù)類型自動“穿”進去遷移中最有價值的體驗發(fā)生在給某個Table列表組件定義類型的時候。這個組件接收一個data數(shù)組作為數(shù)據(jù)源列配置里有一個render回調(diào)函數(shù)用來自定義單元格的渲染內(nèi)容。在JavaScript時代render函數(shù)的參數(shù)是any調(diào)用方怎么寫都行但也因此沒有任何提示。遷移之后我希望data的數(shù)據(jù)類型能夠自動“穿”到render的參數(shù)里——也就是調(diào)用方傳了一個User[]數(shù)組列配置里render的第一個參數(shù)就被推導(dǎo)成User寫錯屬性名就會直接報錯。這需要用到泛型interface ColumnConfigT { title: string; dataIndex?: keyof T; render?: (value: T, record: T, index: number) React.ReactNode; } interface TablePropsT { data: T[]; columns: ColumnConfigT[]; } function TableT(props: TablePropsT) { // 組件內(nèi)部實現(xiàn) }這段代碼看起來簡單但它帶來的改變是巨大的。以前業(yè)務(wù)方在render里取字段靠記憶現(xiàn)在編譯器會告訴他們這個對象上到底有哪些屬性。我自己的體會是遷移過程中很多曾經(jīng)寫在注釋里的潛規(guī)則“這里的value其實是xxx格式”現(xiàn)在被類型系統(tǒng)強制“說清楚”了注釋可以刪掉約束卻留了下來。1.4 遷移暴露出的壞味道遷移的過程也是重新審視代碼的過程我順手記錄了三個最典型的壞味道隱式any很多函數(shù)參數(shù)沒有標注遷移時編譯器大面積報錯這其實是好事說明以前代碼的約定幾乎都藏在人腦里。用as any強行過關(guān)有一處代碼為了省事直接把接口返回值斷言成某個類型遷到一半我就發(fā)現(xiàn)這里其實是數(shù)據(jù)格式不一致的源頭真正該做的是修正數(shù)據(jù)解析邏輯而不是跳過檢查。組件內(nèi)對props二次賦值有組件會把props.value復(fù)制到內(nèi)部變量再修改這是最危險的一種壞味道我的那個輸入框bug就和它直接相關(guān)。我自己總結(jié)的經(jīng)驗是遷移TypeScript本質(zhì)上是一場“顯式化”工程把原來靠默契和文檔維持的約定全部變成編譯器可校驗的規(guī)則。寫代碼的速度可能會暫時變慢但排查問題的時間會大幅減少這筆賬值得算。2. 受控組件與異步初始值“打架”一個拖了兩周的輸入框bug2.1 問題現(xiàn)場遷移做了一半我被迫停下來處理一個遺留了很久的bug。場景是一個表單里的聯(lián)動下拉有一級分類和二級分類兩個選擇器二級選項依賴一級選擇的結(jié)果通過異步接口請求。當時的現(xiàn)象是首次進入頁面時如果默認值已經(jīng)存在二級選擇器能正常顯示但只要切換一級分類二級的值就“賴著不走”——界面上還顯示著舊值表單提交時也能提交出臟數(shù)據(jù)。更詭異的是某些操作路徑下連onChange事件都不觸發(fā)。這個問題在線上掛了兩周期間陸續(xù)有人反饋但我一直沒找到穩(wěn)定的復(fù)現(xiàn)步驟。這周因為要遷移組件類型必須把它的行為徹底搞明白才真正沉下心來排查。2.2 完整排查鏈路從最小復(fù)現(xiàn)到根因定位排查這類問題我最大的教訓(xùn)是不要直接在完整頁面里斷點調(diào)試場景越復(fù)雜越難鎖定范圍。我做的第一步是剝離依賴把聯(lián)動邏輯單獨抽到一個十幾行的demo組件里只保留一級選擇、二級選項請求、二級回顯這三個核心動作。抽出來之后問題立刻穩(wěn)定復(fù)現(xiàn)了說明問題確實出在組件交互本身而不是接口數(shù)據(jù)異常。接下來我在自定義二級選擇組件里逐層打印props。從父組件傳入的value開始看一直追蹤到組件內(nèi)部。打印到第二層時發(fā)現(xiàn)了端倪父組件在切換一級分類后確實把value重置成了空但自定義組件內(nèi)部自己維護了一個innerValue狀態(tài)并在useEffect里用這個內(nèi)部狀態(tài)覆蓋了props傳進來的值。換句話說外部數(shù)據(jù)流已經(jīng)明確說“清空”組件內(nèi)部卻堅持“我不管我用自己的舊值”。這其實暴露了這類bug的典型套路組件內(nèi)部對props做了“本地拷貝”然后在生命周期里回寫??雌饋斫M件很“靈活”實際卻打破了單一數(shù)據(jù)流原則。最致命的是當外部value和內(nèi)部state同時存在時優(yōu)先級的混亂會在不同操作順序下產(chǎn)生完全不同的行為——有些路徑內(nèi)部state生效有些路徑外部value生效于是bug表現(xiàn)時有時無。2.3 修復(fù)方案要么受控要么非受控不能兩頭都要修復(fù)思路并不復(fù)雜核心就是讓組件回到純受控模式value和onChange完全由父組件驅(qū)動組件內(nèi)部不持有“真實值”狀態(tài)只允許存在必要的展示中間態(tài)。改動后的邏輯大概是這樣的function CategorySelect({ value, onChange, category }) { // 不設(shè)置內(nèi)部value狀態(tài) // 二級選項列表可以存內(nèi)部因為這是展示數(shù)據(jù) const [options, setOptions] useState([]); useEffect(() { fetchOptions(category).then(setOptions); }, [category]); return ( Select value{value} onChange{(next) onChange(next)} options{options} / ); }修復(fù)的關(guān)鍵不是代碼量而是思路上的取舍。我當時用一個類比給自己理清了受控組件就像遙控器電視機顯示什么頻道由遙控器說了算非受控組件就像電視機自帶的旋鈕自己管自己。最怕的是遙控器和旋鈕同時存在誰都有權(quán)改頻道那觀眾看到的畫面就變成隨機結(jié)果了。放到組件設(shè)計里也是同樣的道理凡是對外暴露value的組件就必須同步提供onChange并且內(nèi)部不得再私自修改這個值。2.4 這個bug帶來的長期啟示修復(fù)完這個bug之后我給自己定了一條組件設(shè)計紅線任何對外暴露的value屬性組件內(nèi)部一律不得復(fù)制、不得緩存、不得在effect里回寫。如果確實需要展示中間態(tài)就把中間態(tài)視為一種完全獨立的UI狀態(tài)比如輸入過程中的草稿內(nèi)容而不是去篡改業(yè)務(wù)值。這條紅線在后續(xù)遷移TypeScript時幫了大忙。因為類型系統(tǒng)雖然能約束參數(shù)類型卻約束不了這種“行為類型”只有把設(shè)計原則定下來才能避免同類問題換一個殼再出現(xiàn)。3. 這周重新理解了“防御性編程”測試不是湊覆蓋率3.1 為什么突然開始補測試嚴格來說這個組件項目之前的測試覆蓋率幾乎是零。我一度覺得組件測試性價比低因為要搭渲染環(huán)境、模擬交互寫起來又慢又啰嗦。但這周遷移TypeScript時我改了多個函數(shù)的返回邏輯有的還動了組件內(nèi)部的渲染分支心里非常沒底我不知道哪次改動會悄悄破壞某個邊界場景。于是決定把測試補起來。補測試的過程讓我改變了看法——測試的價值不在覆蓋率數(shù)字本身而在于它能幫我把“用戶/調(diào)用方踩過的坑”固化成用例讓每次改動都能快速回歸驗證。3.2 測試重點不是“正常流程”而是“誰會弄壞它”我剛準備寫用例的時候腦子里冒出來的全是“渲染一個列表斷言有幾個元素”這類正常流程。但寫了兩三個之后我發(fā)現(xiàn)這類用例對真實工程質(zhì)量幫助有限因為正常流程手動點兩遍也能測出來。真正需要測試固化的是那些容易出問題的邊界場景。我整理了五類高危場景空值兜底數(shù)據(jù)為空數(shù)組、字段為undefined、整個請求結(jié)果為null時組件是否崩潰。異步競態(tài)快速切換篩選條件前一個請求后返回并覆蓋了后一個正確結(jié)果界面是否顯示混亂。極端輸入超長文本、特殊字符、超大的數(shù)值邊界。連續(xù)觸發(fā)按鈕快速連點、onChange高頻調(diào)用是否產(chǎn)生重復(fù)提交或狀態(tài)錯亂。狀態(tài)切換從“有數(shù)據(jù)”切換到“無數(shù)據(jù)”再切換回“有數(shù)據(jù)”中間狀態(tài)是否正確。這五類場景幾乎覆蓋了我這半年遇到的大部分線上問題。把它們的公共規(guī)律總結(jié)出來之后我驚喜地發(fā)現(xiàn)同一個原則可以反向指導(dǎo)組件設(shè)計假設(shè)所有傳進來的props都不可信那組件內(nèi)部就必須做兜底但兜底不能讓外部對真實狀態(tài)產(chǎn)生誤解。3.3 一個可以直接參考的用例示例以一個二級聯(lián)動組件為例我補了這樣一個用例邏輯很簡單卻很能說明問題describe(CategorySelect 空數(shù)據(jù)兜底, () { it(當二級選項請求返回空數(shù)組時不展示異常狀態(tài), async () { mockFetchOptions.mockResolvedValueOnce([]); render( CategorySelect categorya value{null} onChange{jest.fn()} / ); await waitFor(() { expect(screen.queryByText(加載失敗)).not.toBeInTheDocument(); expect(screen.getByRole(combobox)).toBeInTheDocument(); }); }); it(當一級分類切換但二級選項接口尚未返回時不保留舊選項, async () { // 具體渲染與觸發(fā)邏輯省略 }); });這種用例表面上測試的是“空數(shù)據(jù)不崩潰”實際上是把一個業(yè)務(wù)血淚教訓(xùn)固化了下來曾經(jīng)有某個版本在選項為空時直接渲染了一個錯誤占位符導(dǎo)致大量用戶誤以為系統(tǒng)故障。這比測試一個“正常列表渲染”有用得多。3.4 測試反過來推動了API設(shè)計補測試還有一個意外的收獲——它反向暴露了兩個API設(shè)計問題。第一個是我原本讓調(diào)用方自行傳入空數(shù)組和加載狀態(tài)但寫用例時發(fā)現(xiàn)如果調(diào)用方忘了處理就會直接崩潰這是把風險推給了調(diào)用方。于是我改了設(shè)計在組件內(nèi)部對空數(shù)組做默認處理調(diào)用方不傳也能正常工作第二個是發(fā)現(xiàn)某個回調(diào)函數(shù)的參數(shù)在不同調(diào)用時機下形態(tài)不一致類型完全沒法描述清楚。最終我沒有選擇給它標注一個復(fù)雜的聯(lián)合類型而是直接簡化了回調(diào)簽名讓它在任何時機下形態(tài)統(tǒng)一。這讓我體會到類型系統(tǒng)和測試用例都會迫使你把API設(shè)計得更簡潔因為它們不接受模糊的中間態(tài)。4. 順手做的工程化清理構(gòu)建腳本、冗余依賴與CI提速4.1 清理的三件“雜活”遷移和測試占據(jù)了大部分時間但我還是抽了半天做了一件看著不大、收益卻很明顯的事情給項目做了一次工程化瘦身。瘦身對象有三個第一是npm scripts的統(tǒng)一第二是依賴清理第三是提交前的自動檢查。npm scripts這個問題看起來很不起眼但實際影響很大。我翻開package.json一看里面攢了十幾個腳本名稱五花八門有dev、dev:client、serve、build:stage啟動時根本分不清該用哪個。我統(tǒng)一做了一次規(guī)范所有構(gòu)建類命令統(tǒng)一叫build本地開發(fā)統(tǒng)一叫dev測試統(tǒng)一叫test帶環(huán)境的腳本統(tǒng)一加--mode參數(shù)。做完之后本項目的新人上手只需要記住三個命令。4.2 刪除依賴前用工具核對別憑感覺依賴清理是最容易踩坑的部分。我一開始憑感覺想刪兩個“看起來不用”的包但轉(zhuǎn)頭一想這跟手動改代碼一樣不靠譜——有些包是某個構(gòu)建工具的隱式依賴直接刪可能引發(fā)連鎖報錯。于是我用依賴分析工具先掃了一遍未使用列表把結(jié)果分成了“確定無用”和“需要人工核對”兩批然后再動手。實測下來清理了大概二十個間接依賴之后CI時長從四分鐘降到了兩分半左右本地冷啟動也快了差不多20%。但這次清理真正讓我記住的教訓(xùn)是不要純手動刪依賴。有一次我圖省事直接刪了一個體積很大的“冗余”包結(jié)果某個構(gòu)建階段立刻報錯——它在幕后被一個插件引用著。后來我養(yǎng)成了習慣大版本變更前先全量鎖定依賴再用分析工具對比引用關(guān)系寧可慢一點也不靠猜。4.3 用lint-staged減少“review噪音”順手還接入了lint-staged和husky的提交前檢查。配置很簡單核心就這一段{ husky: { hooks: { pre-commit: lint-staged } }, lint-staged: { *.{ts,tsx,js}: [eslint --fix, prettier --write], *.{json,css,md}: [prettier --write] } }這個配置的意義在于提交代碼時自動幫你做格式修復(fù)和基礎(chǔ)檢查不讓Lint錯誤和格式問題流到代碼評審環(huán)節(jié)。過去每次評審都要花不少時間評論“這里缺分號、那里縮進不對”接入之后這類噪音大幅減少review能更專注在邏輯本身。4.4 工程化的本質(zhì)減少“人肉記憶”做完這些清理之后我自己復(fù)盤了一下工程化折騰了半天本質(zhì)只有一個——減少人肉記憶。統(tǒng)一腳本是為了不用記命令自動格式化是為了不用記風格依賴清理是為了不讓心智負擔堆積在過時的工具鏈上。我的衡量標準很樸素一個新人把倉庫克隆到本地不看任何內(nèi)部文檔能不能在十分鐘內(nèi)跑起開發(fā)環(huán)境如果能說明工程化是健康的如果需要看一堆說明才能啟動那就說明工具鏈在拖后腿。這也是我每次做工程化調(diào)整之后都要問自己的問題。最后再分享一點關(guān)于持續(xù)記錄的小體會。寫了三十二期周報單看每一期都覺得內(nèi)容平平不成體系但拉長到一年的跨度就能清晰地看到一條學(xué)習脈絡(luò)從零散地學(xué)工具到系統(tǒng)地做遷移再到回過頭來補齊測試和工程化。這種“回頭看”的價值比任何一期單獨的內(nèi)容都大。也建議你試試按周記錄自己的項目進展哪怕每次只有幾百字積累一段時間之后你會發(fā)現(xiàn)自己走過的路比想象中要長得多。