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