計(jì)與實(shí)踐:從代碼重復(fù)到統(tǒng)一基礎(chǔ)庫)
簡(jiǎn)介ztools是一個(gè)面向JavaScript開發(fā)者的輕量前端工具集圍繞異步編程、模板渲染與依賴管理三個(gè)方向提供實(shí)用能力內(nèi)置兼容IE舊版本的ES6 Promise方案便于在老舊瀏覽器中編寫現(xiàn)代異步代碼Plato模板引擎以簡(jiǎn)潔的方式完成數(shù)據(jù)與DOM的綁定適合快速搭建視圖層Eidos依賴注入封裝則有助于降低模塊耦合提升代碼可測(cè)試性與復(fù)用性。壓縮包共17個(gè)文件以js源碼為主同時(shí)包含html示例、README說明、package.json配置等結(jié)構(gòu)清晰便于按模塊閱讀與調(diào)試整體僅13KB適合學(xué)習(xí)或直接引入項(xiàng)目。目前已有308人學(xué)習(xí)下載。通過閱讀源碼與示例讀者可以了解Promise polyfill的實(shí)現(xiàn)思路、簡(jiǎn)易模板引擎的解析過程以及依賴注入容器的設(shè)計(jì)方式對(duì)于想深入前端工程化與工具封裝的開發(fā)者是份不錯(cuò)的參考資料。 接手團(tuán)隊(duì)那會(huì)兒我翻了一遍現(xiàn)有代碼庫發(fā)現(xiàn)一個(gè)很真實(shí)的現(xiàn)象deepClone至少有三個(gè)版本在兩個(gè)模塊里各寫各的防抖函數(shù)有四個(gè)人用自己的實(shí)現(xiàn)日期格式化更是五花八門有的返回字符串、有的返回?cái)?shù)組還有的干脆直接報(bào)錯(cuò)。這些代碼本身沒問題但維護(hù)的人換了一茬又一茬風(fēng)格已經(jīng)割裂到?jīng)]法看了。所以就有了ztools這個(gè)前端工具集項(xiàng)目。它不是要做一個(gè)“什么都有”的大雜燴包而是把團(tuán)隊(duì)里反復(fù)出現(xiàn)、已經(jīng)驗(yàn)證過的工具函數(shù)和邏輯沉淀成一套統(tǒng)一、可測(cè)試、可按需引入的基礎(chǔ)庫。如果你也需要把散落的公共代碼整合起來或者是想搭建自己的第一個(gè)前端工具庫這篇內(nèi)容應(yīng)該能給你一些可以直接抄作業(yè)的思路。1. 為什么需要一套前端工具集1.1 團(tuán)隊(duì)代碼里那些“復(fù)制粘貼”之痛一個(gè)中大型前端項(xiàng)目跑兩三年之后公共邏輯的重復(fù)率會(huì)高得嚇人。最典型的癥狀就是每個(gè)新同學(xué)入職第一個(gè)任務(wù)大概率是“把這里的請(qǐng)求封裝改成統(tǒng)一的”然后你會(huì)發(fā)現(xiàn)項(xiàng)目里已經(jīng)有四套request封裝三份localStorage讀寫工具還有兩個(gè)行為互相矛盾的 cookie 操作函數(shù)。重復(fù)代碼的問題不只是浪費(fèi)幾行字節(jié)真正可怕的是“改不動(dòng)”和“不敢刪”。當(dāng)你發(fā)現(xiàn)線上有個(gè)日期格式化的 bug你要在所有用到格式化的地方逐個(gè)排查因?yàn)槊恳粋€(gè)實(shí)現(xiàn)的行為都可能略有不同。而當(dāng)你試圖刪掉其中一個(gè)工具函數(shù)時(shí)又怕某個(gè)隱晦的調(diào)用點(diǎn)突然報(bào)錯(cuò)。這種狀態(tài)下任何重構(gòu)都是在走鋼絲。我建ztools的初衷就是把這些重復(fù)邏輯撈出來給它們一個(gè)統(tǒng)一的歸宿。它解決的問題不是“代碼少寫幾行”而是讓團(tuán)隊(duì)對(duì)“公共能力”只有一個(gè)認(rèn)知來源、一份測(cè)試用例、一個(gè)維護(hù)入口。1.2 工具集的設(shè)計(jì)目標(biāo)與邊界工具集不是框架它的定位要非??酥?。我在項(xiàng)目規(guī)劃階段就定下了幾條設(shè)計(jì)原則只做基礎(chǔ)能力不做業(yè)務(wù)邏輯。通用的函數(shù)、hooks、類型定義可以收進(jìn)來但跟具體業(yè)務(wù)綁定的數(shù)據(jù)解析、權(quán)限判斷、接口封裝一律不進(jìn)。按需引入不能拖累主包體積。用戶引一個(gè)debounce不能被迫加載整個(gè)工具集。類型完整用法統(tǒng)一。所有函數(shù)都要有精確的 TypeScript 類型所有命名都要符合一套規(guī)范調(diào)用方式保持一致。必須經(jīng)過測(cè)試。工具函數(shù)是最容易被大家依賴的底層代碼沒有測(cè)試覆蓋出了問題就是全線崩潰。這些邊界約束了工具集的發(fā)展方向也幫我在后續(xù)無數(shù)次“要不要把這個(gè)也放進(jìn)來”的討論中快速做出判斷。工具集的價(jià)值不在于大而在于清晰。2. 技術(shù)選型與整體架構(gòu)2.1 為什么用 TypeScript 加雙格式構(gòu)建ztools的技術(shù)棧選擇不算激進(jìn)但都是經(jīng)過實(shí)際驗(yàn)證的。整個(gè)工具集用 TypeScript 編寫構(gòu)建產(chǎn)物同時(shí)輸出 ESMES Module和 CJSCommonJS兩種格式部分工具還附帶瀏覽器直接可用的 IIFE 版本。TypeScript 的核心收益不是“有類型”而是“讓使用方在編譯期就拿到提示”。工具函數(shù)一旦在團(tuán)隊(duì)內(nèi)廣泛使用類型定義就是隱形的文檔。比如debounce函數(shù)如果沒有類型約束調(diào)用方很容易搞混wait和immediate參數(shù)的順序而有了類型提示這種問題幾乎不可能發(fā)生。雙格式構(gòu)建的原因更務(wù)實(shí)現(xiàn)在前端項(xiàng)目基本都跑在 Vite 這類現(xiàn)代構(gòu)建工具上ESM 是主流但還有一些老項(xiàng)目用的是 webpack 4 甚至直接是 Node 端的 CommonJS 引用如果只有 ESM 產(chǎn)物它們?cè)趓equire(ztools)的時(shí)候會(huì)直接報(bào)錯(cuò)。所以我在package.json里用exports字段做了條件導(dǎo)出{ name: ztools, version: 0.3.2, type: module, main: ./dist/index.cjs, module: ./dist/index.js, types: ./dist/index.d.ts, exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.js, require: ./dist/index.cjs }, ./utils/*: { types: ./dist/utils/*.d.ts, import: ./dist/utils/*.js, require: ./dist/utils/*.cjs } } }同樣地很多場(chǎng)景下按需引入也依賴 ESM 的tree-shaking。如果你只用了ztools里的formatDate構(gòu)建工具應(yīng)該有能力把其他函數(shù)全部搖掉讓最終的包體積增加量幾乎可以忽略。這一點(diǎn)我在第 5 節(jié)會(huì)展開講。2.2 目錄結(jié)構(gòu)與模塊劃分ztools的目錄結(jié)構(gòu)從一開始就是按“領(lǐng)域”劃分的而不是按“類型”堆在一起。這樣做的好處是使用方一看到路徑就能猜到功能歸屬維護(hù)的人也知道該往哪里加代碼。ztools/ ├── src/ │ ├── utils/ # 基礎(chǔ)函數(shù)工具 │ │ ├── debounce.ts │ │ ├── throttle.ts │ │ ├── deepClone.ts │ │ ├── formatDate.ts │ │ ├── formatNumber.ts │ │ ├── getUrlParam.ts │ │ └── storage.ts │ ├── hooks/ # React Hooks │ │ ├── useDebounce.ts │ │ ├── useThrottle.ts │ │ ├── useLocalStorage.ts │ │ └── usePrevious.ts │ ├── dom/ # 瀏覽器 DOM 操作 │ │ ├── scrollToBottom.ts │ │ └── copyToClipboard.ts │ ├── types/ # 公共類型定義 │ │ └── index.ts │ └── index.ts # 入口統(tǒng)一導(dǎo)出 ├── tests/ ├── docs/ ├── package.json └── tsup.config.ts入口文件index.ts會(huì)統(tǒng)一導(dǎo)出所有公共 API但每個(gè)子目錄也支持單獨(dú)路徑引用這樣既能兼顧“一次性引入全部”的方便也能滿足“只引一個(gè)函數(shù)”的精準(zhǔn)訴求。子路徑導(dǎo)出在package.json的exports里已經(jīng)做了映射不需要額外配置。2.3 tree-shaking 與按需引入的設(shè)計(jì)很多工具庫明明功能很少但打出來的包卻有幾百 KB核心原因就是沒有做按需設(shè)計(jì)。ztools在這個(gè)問題上做了幾個(gè)層級(jí)的控制內(nèi)部模塊拆分除入口文件外每個(gè)工具函數(shù)一個(gè)文件互不依賴。這樣任何構(gòu)建工具在分析依賴圖時(shí)都能把未用到的模塊隔離掉。保持較少的內(nèi)部依賴每個(gè)函數(shù)盡可能不依賴工具集內(nèi)其他函數(shù)避免“引一個(gè)函數(shù)拖進(jìn)來一串”的連鎖效應(yīng)。聲明sideEffects: false在package.json中明確告知構(gòu)建工具這個(gè)包里的文件不會(huì)在 import 時(shí)產(chǎn)生副作用可以放心刪除未使用的導(dǎo)出。這一點(diǎn)經(jīng)常有人漏掉但少了它 tree-shaking 可能就失效了。{ sideEffects: false }3. 核心實(shí)現(xiàn)與漸進(jìn)搭建過程3.1 已實(shí)現(xiàn)的工具類目與典型實(shí)現(xiàn)ztools目前積累了幾十種工具按使用頻率分為三類。第一類是高頻基礎(chǔ)函數(shù)比如debounce、throttle、deepClone、formatDate、getUrlParam第二類是 React Hooks比如useDebounce、useThrottle、useLocalStorage第三類是瀏覽器環(huán)境下的輔助函數(shù)比如copyToClipboard、scrollToBottom。以debounce為例拋開各種邊界情況不談它的核心實(shí)現(xiàn)其實(shí)只有十幾行。但真正的難點(diǎn)在于類型定義、參數(shù)兼容、以及this上下文的保持。我參考了業(yè)界常見的實(shí)現(xiàn)最后寫出來是這個(gè)樣子export function debounceA extends unknown[], R( fn: (...args: A) R, wait 300, immediate false ) { let timer: ReturnTypetypeof setTimeout | null null; let result: R | undefined; const debounced function(this: unknown, ...args: A) { const later () { timer null; if (!immediate) { result fn.apply(this, args); } }; const callNow immediate timer null; if (timer ! null) { clearTimeout(timer); } timer setTimeout(later, wait); if (callNow) { result fn.apply(this, args); } return result as R; }; debounced.cancel function() { if (timer ! null) { clearTimeout(timer); timer null; } }; return debounced; }實(shí)現(xiàn)完之后還有兩件事必須做一個(gè)是讓copyToClipboard這類函數(shù)兼容瀏覽器對(duì)剪貼板權(quán)限的限制——在不支持navigator.clipboard的環(huán)境下自動(dòng)降級(jí)到document.execCommand(copy)另一個(gè)是給所有函數(shù)補(bǔ)充 JSDoc 注釋明確參數(shù)含義、返回值、使用示例和注意事項(xiàng)。后面這一件事當(dāng)時(shí)覺得耽誤時(shí)間后來發(fā)現(xiàn)文檔的價(jià)值比代碼本身還大。3.2 工具函數(shù)的單元測(cè)試與質(zhì)量保障一個(gè)工具函數(shù)如果沒有測(cè)試那它和臨時(shí)腳本沒有本質(zhì)區(qū)別。ztools的測(cè)試選的是 Vitest理由很直接它跟 Vite 的配置天然打通跑起來快而且對(duì) TypeScript 的支持不需要額外配置。測(cè)試用例的覆蓋范圍我一般會(huì)遵循“正常值 邊界值 異常值”的思路。拿formatDate來說正常值就是傳一個(gè)時(shí)間戳或 Date 對(duì)象期望返回格式化的字符串邊界值要覆蓋0時(shí)間戳、跨年的日期、閏年 2 月 29 日異常值要覆蓋undefined、null、非法字符串等這個(gè)時(shí)候最好能讓函數(shù)拋出一個(gè)明確的錯(cuò)誤而不是靜默返回一個(gè)詭異結(jié)果。import { describe, expect, it } from vitest; import { formatDate } from ../src/utils/formatDate; describe(formatDate, () { it(formats timestamp correctly, () { const timestamp new Date(2024-03-15T08:30:00).getTime(); expect(formatDate(timestamp, YYYY-MM-DD HH:mm)).toBe(2024-03-15 08:30); }); it(handles invalid input by throwing, () { expect(() formatDate(not-a-date, YYYY-MM-DD)).toThrow(); }); });剛開始補(bǔ)測(cè)試的時(shí)候我會(huì)覺得進(jìn)度變慢了但后來發(fā)現(xiàn)測(cè)試真正發(fā)揮作用的時(shí)刻是“別人來改你的函數(shù)”。沒有測(cè)試罩著別人動(dòng)代碼你心里是懸的有測(cè)試罩著他改壞了 CI 第一個(gè)跳出來比你在代碼 review 里耳提面命一百遍都管用。3.3 文檔站點(diǎn)與 npm 發(fā)布工具集的另一半價(jià)值在于“讓人愿意用、用得明白”。我一開始只在 README 里寫了幾個(gè)示例后來被同事反復(fù)問“這個(gè)函數(shù)怎么用、參數(shù)是什么”才意識(shí)到文檔必須跟上。ztools的文檔方案沒有搞得很重。我選了一個(gè)輕量的靜態(tài)文檔生成器把函數(shù)說明、示例代碼、參數(shù)表、變更記錄集中在一個(gè)站點(diǎn)上。每個(gè)函數(shù)都配一個(gè)可折疊的示例區(qū)塊方便讀者直接復(fù)制。文檔的源碼放在docs/目錄下和代碼庫同步維護(hù)提交代碼時(shí)如果改了公共 APICI 會(huì)檢查對(duì)應(yīng)文檔是否更新避免出現(xiàn)“代碼改了文檔沒改”的脫節(jié)。npm 發(fā)布流程則完全交給 GitHub Actions。每次打v*標(biāo)簽自動(dòng)觸發(fā)構(gòu)建、跑測(cè)試、生成類型聲明然后發(fā)布到配置好的 registry。發(fā)布之后還會(huì)同步生成一份最新版 CHANGELOG日志里的版本號(hào)、feature、fix 全部從 Git 提交記錄里提取不需要手寫。這個(gè)流程一開始搭的時(shí)候花了半天但之后每次發(fā)版都是推個(gè) tag 的事省心很多。4. 使用場(chǎng)景與接入方式4.1 在業(yè)務(wù)項(xiàng)目中接入業(yè)務(wù)項(xiàng)目接入ztools的方式取決于它使用的模塊體系。新項(xiàng)目基本走 ESM 按需引入import { debounce } from ztools; import { useDebounce } from ztools/hooks; const onSearch debounce((keyword: string) { // 搜索請(qǐng)求 }, 500);老項(xiàng)目如果還在用 CommonJS也可以直接const { debounce } require(ztools)因?yàn)槲仪懊嫣岬降碾p格式構(gòu)建已經(jīng)做了兼容。這樣團(tuán)隊(duì)在做技術(shù)棧升級(jí)遷移期間不需要等所有項(xiàng)目都切到 ESM 才能開始復(fù)用工具集。4.2 團(tuán)隊(duì)協(xié)作與版本管理工具集既然是給團(tuán)隊(duì)用的版本管理和發(fā)布策略就得有章法。我的做法是采用語義化版本SemVer新增工具函數(shù)加minor版本修復(fù) bug 或優(yōu)化實(shí)現(xiàn)加patch版本發(fā)生 breaking change 才升major版本。breaking change盡量少出如果非要出必須提前一個(gè)版本在文檔和 CHANGELOG 里標(biāo)注棄用信息給使用方留出遷移時(shí)間。比較重要的是要建立“工具集不是某個(gè)人的私有物”的共識(shí)。任何人想往里面加?xùn)|西都要發(fā)起 MR說明用途、實(shí)現(xiàn)方案、調(diào)研過哪些已有方案并附上測(cè)試用例。我自己作為維護(hù)者一開始會(huì)花比較多精力在 review 這些 MR 上但等大家習(xí)慣了這套流程工具集會(huì)越滾越健康。4.3 后續(xù)擴(kuò)展與生態(tài)方向ztools目前的規(guī)劃是繼續(xù)往更細(xì)分的場(chǎng)景做擴(kuò)展。一個(gè)方向是增加更多 React Hooks比如useEventListener、useMediaQuery、useAsync這些都是業(yè)務(wù)里反復(fù)出現(xiàn)的需求。另一個(gè)方向是提供一些輕量的“配置化”能力比如統(tǒng)一的錯(cuò)誤捕獲上報(bào)入口、統(tǒng)一的日志格式但這類能力要謹(jǐn)慎因?yàn)樗菀谆驑I(yè)務(wù)邏輯。還有一個(gè)想法是把工具集按領(lǐng)域拆成獨(dú)立包比如ztools-utils、ztools-hooks、ztools-dom由同一個(gè) monorepo 管理、統(tǒng)一發(fā)布。這樣團(tuán)隊(duì)里某個(gè)項(xiàng)目如果只需要 Hooks可以只裝ztools-hooks。不過這個(gè)分解動(dòng)作會(huì)帶來不小的維護(hù)成本目前看必要性不高先記在規(guī)劃里。5. 常見問題與排查技巧實(shí)錄5.1 tree-shaking 失效打包體積沒降下來這是我被問過最多的問題。癥狀是業(yè)務(wù)項(xiàng)目明明只引了一個(gè)函數(shù)打包產(chǎn)物體積卻增大了幾百 KB。絕大多數(shù)情況下原因有三個(gè)package.json缺sideEffects: false聲明構(gòu)建工具不敢動(dòng)任何模塊。入口文件把所有工具都export出去了雖然理論上 ESM 可以 tree-shaking但如果構(gòu)建工具配置不當(dāng)或產(chǎn)物格式不理想還是會(huì)失效。使用方可能用了import * as ztools from ztools。這種寫法會(huì)保留整個(gè)模塊對(duì)象導(dǎo)致所有函數(shù)都被打包進(jìn)去。排查思路是先用vite --debug或webpack-bundle-analyzer看產(chǎn)物結(jié)構(gòu)確認(rèn)哪些模塊被打進(jìn)去了再一個(gè)個(gè)排除原因。我實(shí)際處理過的一個(gè)案例就是某業(yè)務(wù)項(xiàng)目把import * as ztools改成具名導(dǎo)入后體積直接少了近 200 KB。5.2 類型聲明丟失或和實(shí)際 API 不匹配發(fā)布之后發(fā)現(xiàn)使用方在 TypeScript 里拿不到類型提示或者提示的老類型和實(shí)際函數(shù)不匹配。這個(gè)問題的根源通常是我在發(fā)版時(shí)沒有成功生成最新的.d.ts文件或者exports字段里的types路徑指向不對(duì)。我的處理方式是構(gòu)建腳本里顯式用tsc --emitDeclarationOnly生成類型聲明而不是依賴打包工具的附帶產(chǎn)物發(fā)布前在本地用npm pack打一次 tarball檢查里面的文件結(jié)構(gòu)是否符合預(yù)期再用一個(gè)模擬業(yè)務(wù)項(xiàng)目通過npm link做一次真實(shí)引用測(cè)試。這套檢查做完基本就不會(huì)再出現(xiàn)“發(fā)出去之后發(fā)現(xiàn)類型不對(duì)”的尷尬了。5.3 瀏覽器兼容性問題集中爆發(fā)部分工具函數(shù)在不同瀏覽器里表現(xiàn)不一致比如Intl.DateTimeFormat在部分舊瀏覽器里對(duì)中文 locale 支持不完整structuredClone在更早期環(huán)境里根本不存在。工具集必須在代碼里做兼容降級(jí)而不能默認(rèn)使用方瀏覽器都是最新版。我給的策略是在函數(shù)實(shí)現(xiàn)層面做能力檢測(cè)如果環(huán)境不支持基準(zhǔn) API就降級(jí)為簡(jiǎn)單的模擬實(shí)現(xiàn)或拋出明確的警告。同時(shí)一定要在文檔里寫清楚每個(gè)函數(shù)的瀏覽器支持范圍避免業(yè)務(wù)在低版本瀏覽器上排查問題到頭來發(fā)現(xiàn)是工具集的問題。5.4 發(fā)布到內(nèi)部 registry 后安裝失敗這個(gè)坑我踩過一次。當(dāng)時(shí)配置了私有 npm registry但發(fā)布流程里沒有正確處理 registry 的認(rèn)證信息結(jié)果 CI 構(gòu)建能過業(yè)務(wù)項(xiàng)目卻怎么都拉不到包。后來我在發(fā)布腳本里顯式指定了 registry 地址和認(rèn)證環(huán)境變量并在 CI 里加了“安裝驗(yàn)證”這一步驟發(fā)布完成后立刻在一個(gè)臨時(shí)目錄里執(zhí)行一次npm install ztools確保安裝鏈路完全暢通再通知團(tuán)隊(duì)使用。經(jīng)驗(yàn)就是凡是自動(dòng)化發(fā)布的流程都要在流程末尾加一個(gè)“自檢”環(huán)節(jié)機(jī)器不會(huì)“覺得沒問題”只有驗(yàn)證過才是真的沒問題。5.5 工具函數(shù)行為不統(tǒng)一導(dǎo)致線上問題有時(shí)候不同函數(shù)對(duì)同一類參數(shù)的解析方式不一致比如formatDate會(huì)用本地時(shí)區(qū)解析時(shí)間字符串而getUrlParam里的時(shí)間處理用了 UTC 時(shí)區(qū)兩個(gè)函數(shù)聯(lián)動(dòng)時(shí)就會(huì)出現(xiàn)幾小時(shí)的偏差。所以我后來在ztools里立了一條規(guī)矩所有時(shí)間相關(guān)的函數(shù)必須在文檔里明確寫清楚默認(rèn)時(shí)區(qū)并且提供統(tǒng)一的時(shí)區(qū)參數(shù)入口。這類隱性約定靠代碼 review 很難發(fā)現(xiàn)靠測(cè)試用例才能把行為固定下來。最后再說一點(diǎn)維護(hù)心得工具集這件事做起來容易堅(jiān)持維護(hù)下去難。我見過不少團(tuán)隊(duì)的工具庫熱度過了之后沒人維護(hù)新需求各寫各的慢慢又退化成“歷史遺留代碼”。我的經(jīng)驗(yàn)是工具集的生命力不在于代碼多炫而在于邊界清晰、文檔完整、測(cè)試覆蓋到位。每次有人提“再加一個(gè)函數(shù)”的時(shí)候先問三個(gè)問題——這個(gè)邏輯真的通用嗎團(tuán)隊(duì)里有沒有已經(jīng)在寫的重復(fù)實(shí)現(xiàn)它能不能配齊測(cè)試和文檔如果答案都是肯定的再收進(jìn)來如果有一個(gè)是否定的就先緩一緩。如果你也在規(guī)劃自己的前端工具集建議不用一上來就追求大而全先從業(yè)務(wù)項(xiàng)目里撈兩個(gè)高頻復(fù)用的函數(shù)配好測(cè)試、寫好文檔、發(fā)布一版讓團(tuán)隊(duì)先“用起來”。跑順了流程再慢慢迭代你會(huì)發(fā)現(xiàn)工具集這東西真的是越早做越劃算。本文還有配套的精品資源點(diǎn)擊獲取