輕量級(jí)項(xiàng)目結(jié)構(gòu)分析工具的設(shè)計(jì)與實(shí)現(xiàn))
rea這個(gè)項(xiàng)目名乍一看確實(shí)容易讓人一頭霧水三個(gè)字母沒(méi)有官方解釋也沒(méi)有配套文檔。我最早接觸它是在某次接手一個(gè)遺留系統(tǒng)的時(shí)候——代碼倉(cāng)庫(kù)里躺著一個(gè)叫rea的目錄里面全是一堆以rea_開(kāi)頭的腳本文件。當(dāng)時(shí)的第一反應(yīng)是“這是什么鬼”翻了兩天之后才明白它是一個(gè)被團(tuán)隊(duì)內(nèi)部用慣了但從來(lái)沒(méi)正式命名過(guò)的代碼結(jié)構(gòu)梳理工具。后來(lái)我花了兩周時(shí)間把它重寫(xiě)、補(bǔ)全、做成一個(gè)可復(fù)用的命令行工具再后來(lái)它就成了我自己日常開(kāi)發(fā)中離不開(kāi)的一個(gè)小工具箱。我在博客里分享過(guò)不少項(xiàng)目但這是第一次認(rèn)真復(fù)盤(pán)rea本身的來(lái)龍去脈。如果你正在做一個(gè)代碼量失控的項(xiàng)目、需要一個(gè)能快速看清全局的工具或者你也想打造一個(gè)屬于自己、貼合自己工作習(xí)慣的開(kāi)發(fā)者工具這篇文章應(yīng)該對(duì)你有用。我會(huì)把從最初的需求分析、到核心模塊實(shí)現(xiàn)、再到上線后踩過(guò)的一堆坑全部串起來(lái)講盡量給你一份能直接照著落地的參考方案。1. 項(xiàng)目整體設(shè)計(jì)與思路拆解1.1 它到底解決什么問(wèn)題先說(shuō)最核心的那個(gè)問(wèn)題為什么需要一個(gè)叫rea的東西。當(dāng)時(shí)那個(gè)遺留系統(tǒng)技術(shù)棧不算復(fù)雜Spring Boot 后端加一個(gè) Vue 前端代碼規(guī)模也不到十萬(wàn)行。但問(wèn)題在于這個(gè)項(xiàng)目經(jīng)歷了多輪維護(hù)前后好幾個(gè)團(tuán)隊(duì)都往里加過(guò)東西目錄結(jié)構(gòu)已經(jīng)變得非?;靵y。具體表現(xiàn)有這么幾種同一個(gè)功能模塊的代碼分散在四五個(gè)不同的目錄里彼此之間通過(guò)相對(duì)路徑互相引用。有些類文件明明已經(jīng)沒(méi)人在用了但因?yàn)闆](méi)人敢刪就一直躺在deprecated目錄里占據(jù)空間。前端的資源文件命名完全沒(méi)有規(guī)范新建文檔(最終版).js這種名字都能出現(xiàn)。最讓人頭疼的是沒(méi)有人能說(shuō)清楚整個(gè)項(xiàng)目的依賴關(guān)系到底長(zhǎng)什么樣。我當(dāng)時(shí)的任務(wù)是在這個(gè)系統(tǒng)上加一個(gè)新功能。按理說(shuō)這是一個(gè)很常規(guī)的開(kāi)發(fā)任務(wù)但實(shí)際花費(fèi)的時(shí)間里有一半以上都用在了“找代碼”而不是“寫(xiě)代碼”上。每改一個(gè)地方都要先在 IDE 里搜半天上下游調(diào)用生怕漏掉哪個(gè)隱式依賴導(dǎo)致上線出問(wèn)題。rea最初的形態(tài)就是為解決“理清項(xiàng)目結(jié)構(gòu)”這個(gè)痛點(diǎn)而寫(xiě)的一個(gè)命令行工具。它的作用很簡(jiǎn)單遞歸掃描項(xiàng)目的所有源文件提取每一個(gè)文件中定義的函數(shù)、類、變量同時(shí)分析文件之間的引用關(guān)系最后生成一份結(jié)構(gòu)化的報(bào)告告訴你項(xiàng)目里有哪些關(guān)鍵節(jié)點(diǎn)、哪些模塊被依賴得最多、哪些文件是無(wú)人問(wèn)津的死代碼候選對(duì)象。1.2 為什么不直接用現(xiàn)成工具聊到這里可能有人會(huì)問(wèn)代碼覆蓋率工具、依賴分析工具、SonarQube 這些,市面上不是有大把現(xiàn)成的嗎為什么還要自己造輪子這個(gè)問(wèn)題的答案我認(rèn)為恰恰是rea這個(gè)項(xiàng)目最有價(jià)值的地方?,F(xiàn)成的工具確實(shí)功能強(qiáng)大但它們普遍存在兩個(gè)問(wèn)題。第一個(gè)問(wèn)題是重量級(jí)很多分析工具需要你把整個(gè)項(xiàng)目導(dǎo)入、配置數(shù)據(jù)庫(kù)、等待后臺(tái)任務(wù)跑完才能看到結(jié)果。對(duì)于一個(gè)只想快速搞清楚一個(gè)項(xiàng)目大概結(jié)構(gòu)的開(kāi)發(fā)者來(lái)說(shuō)這個(gè)流程太笨重了。第二個(gè)問(wèn)題是不靈活工具輸出的是它定義好的指標(biāo)體系而不是你當(dāng)下最關(guān)心的那幾個(gè)問(wèn)題。我當(dāng)時(shí)需要的其實(shí)是一個(gè)非常輕量、能按需擴(kuò)展、結(jié)果一眼能看懂的東西。rea的設(shè)計(jì)哲學(xué)由此確定單命令、無(wú)配置、純靜態(tài)掃描、輸出可讀性優(yōu)先。它不需要在項(xiàng)目里安裝任何插件不需要建立索引數(shù)據(jù)庫(kù)也不需要聯(lián)網(wǎng)。你在命令行里對(duì)項(xiàng)目根目錄執(zhí)行一條命令幾秒鐘之后就能拿到一份簡(jiǎn)潔的報(bào)告。這個(gè)設(shè)計(jì)思路就像你家里工具箱里那把最常用的螺絲刀——不需要電動(dòng)不需要一堆替換頭就是一把趁手、順手、永遠(yuǎn)不會(huì)壞的工具。1.3 技術(shù)選型背后的思考技術(shù)選型上我選擇了 Node.js 作為運(yùn)行環(huán)境用純 JavaScript 實(shí)現(xiàn)不依賴任何第三方庫(kù)。現(xiàn)在回頭看這個(gè)決定大致是合理的但中間也走過(guò)一些彎路。一開(kāi)始我其實(shí)考慮過(guò)用 Python 寫(xiě)因?yàn)?Python 處理文本和正則表達(dá)式確實(shí)方便而且我本人也更熟悉 Python。但后來(lái)想清楚了兩個(gè)問(wèn)題目標(biāo)用戶包括我自己日常主要工作在 Node 生態(tài)里用 Node 寫(xiě)出來(lái)的工具后續(xù)如果要擴(kuò)展成 IDE 插件或者對(duì)接前端構(gòu)建流程會(huì)順暢得多。Node 在文件系統(tǒng)操作方面擁有非常完善的 API對(duì)讀取目錄樹(shù)這類需求支持得很好。至于為什么不依賴第三方庫(kù)理由就更實(shí)際了這個(gè)工具的核心功能就是讀文件、做匹配、統(tǒng)計(jì)計(jì)數(shù)、輸出結(jié)果這些用 Node 自帶模塊完全可以搞定。每引入一個(gè)依賴都意味著未來(lái)的維護(hù)成本、安全風(fēng)險(xiǎn)、版本兼容問(wèn)題。為了這么一個(gè)小工具去引入依賴樹(shù)實(shí)在劃不來(lái)。這里補(bǔ)充一個(gè)我后來(lái)才意識(shí)到的問(wèn)題。純 JavaScript 腳本在遇到大項(xiàng)目時(shí)性能確實(shí)會(huì)成為一個(gè)瓶頸。印象最深的一次是在一個(gè)大型電商系統(tǒng)上跑rea源文件總數(shù)超過(guò)五萬(wàn)個(gè)直接跑崩了。后來(lái)我用 Worker 線程池做了并發(fā)掃描改造才把耗時(shí)控制在一個(gè)可接受的范圍內(nèi)。這個(gè)優(yōu)化過(guò)程我在后面的實(shí)操部分詳細(xì)講。2. 核心功能拆解與實(shí)操要點(diǎn)rea的功能并不花哨核心就是三件事掃描、分析、報(bào)告。下面一個(gè)一個(gè)拆開(kāi)說(shuō)。2.1 文件掃描模塊的設(shè)計(jì)文件掃描是整個(gè)工具的基石。這一步做不好后面所有的分析都是空中樓閣。掃描模塊的核心需求有三個(gè)能遞歸遍歷目錄、能按規(guī)則過(guò)濾掉不需要分析的文件、能采集到足夠多的文件元數(shù)據(jù)。第一版的實(shí)現(xiàn)非常簡(jiǎn)單就是用fs.readdirSync配合fs.statSync做一個(gè)深度優(yōu)先遍歷。核心邏輯說(shuō)出來(lái)大家都能懂const fs require(fs); const path require(path); class Scanner { constructor(projectRoot, ignoredPatterns) { this.projectRoot projectRoot; this.ignoredPatterns ignoredPatterns; this.fileList []; } walk(dir) { const entries fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (this.shouldIgnore(entry.name)) continue; const fullPath path.join(dir, entry.name); if (entry.isDirectory()) { this.walk(fullPath); } else if (entry.isFile()) { this.fileList.push({ path: fullPath, ext: path.extname(entry.name), name: entry.name, size: fs.statSync(fullPath).size }); } } } shouldIgnore(name) { return this.ignoredPatterns.some((pattern) pattern.test(name)); } }這套實(shí)現(xiàn)足夠應(yīng)對(duì)大多數(shù)中小型項(xiàng)目。但在這里我要提一個(gè)實(shí)際踩過(guò)的坑把同步遍歷寫(xiě)進(jìn)一個(gè)遞歸函數(shù)里一旦項(xiàng)目文件層級(jí)特別深很容易爆棧。我們當(dāng)時(shí)那個(gè)項(xiàng)目里有一個(gè)測(cè)試目錄嵌套層級(jí)最深的地方超過(guò)了四十層遞歸調(diào)用的時(shí)候棧幀積累得非常快。雖然一開(kāi)始沒(méi)有直接崩潰但性能明顯下降掃描耗時(shí)達(dá)到了二十多秒。后來(lái)我改成了顯式使用棧數(shù)據(jù)結(jié)構(gòu)來(lái)遍歷而不是依賴函數(shù)調(diào)用棧walk(root) { const stack [root]; while (stack.length 0) { const currentDir stack.pop(); const entries fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { if (this.shouldIgnore(entry.name)) continue; const fullPath path.join(currentDir, entry.name); if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile()) { this.fileList.push({ path: fullPath, ext: path.extname(entry.name), name: entry.name, size: fs.statSync(fullPath).size }); } } } }這里我想多啰嗦一句很多新手容易忽略withFileTypes: true這個(gè)參數(shù)。如果不用這個(gè)參數(shù)readdirSync返回的是字符串?dāng)?shù)組你得對(duì)每個(gè)路徑單獨(dú)調(diào)用fs.statSync來(lái)判斷它是文件還是目錄。這相當(dāng)于多了一次系統(tǒng)調(diào)用在文件數(shù)量龐大的時(shí)候損耗非??捎^。用了withFileTypes之后Dirent對(duì)象直接提供了isFile()、isDirectory()方法節(jié)省了額外一次stat的開(kāi)銷。2.2 依賴分析模塊的算法選擇掃描到文件列表之后緊接著要做的事情是分析依賴關(guān)系。這里所謂的依賴不單指代碼里的import或require語(yǔ)句還包括自定義函數(shù)之間的調(diào)用。對(duì)于 JavaScript 項(xiàng)目要精準(zhǔn)分析函數(shù)級(jí)調(diào)用關(guān)系難度非常大,因?yàn)?JavaScript 是動(dòng)態(tài)語(yǔ)言運(yùn)行時(shí)綁定、回調(diào)函數(shù)、閉包這些特性都讓靜態(tài)分析變得很費(fèi)勁。所以我在第一版設(shè)計(jì)時(shí)做了一個(gè)戰(zhàn)略取舍不做精確的函數(shù)調(diào)用分析只做模塊級(jí)的依賴分析。具體來(lái)說(shuō)就是用正則表達(dá)式把每個(gè)文件中出現(xiàn)的import ... from ...、require(...)、const xxx require(...)、import(...)這幾種常見(jiàn)的引用形式全部提取出來(lái)再對(duì)引用路徑做規(guī)范化處理最終生成一張以文件為節(jié)點(diǎn)、以引用關(guān)系為邊的依賴圖。這個(gè)思路的好處用一個(gè)詞概括就是“夠用”。我需要的不是一張完美的代碼架構(gòu)圖而是一個(gè)能告訴我在改動(dòng)某個(gè)文件時(shí)哪些地方可能需要聯(lián)動(dòng)調(diào)整的清單。這個(gè)場(chǎng)景下解析到模塊級(jí)別足夠了。提取引用關(guān)系的邏輯大致是這樣const IMPORT_PATTERN /(?:import\s[\s\S]*?from\s*[]([^])[]|require\s*\(\s*[]([^])[]\s*\))/g; function extractDependencies(sourceCode) { const deps []; let match; while ((match IMPORT_PATTERN.exec(sourceCode)) ! null) { const rawPath match[1] || match[2]; if (rawPath !rawPath.startsWith(.) !rawPath.startsWith(/)) { continue; // 第三方包不做深入分析 } deps.push(normalizePath(rawPath)); } return deps; }這里有一個(gè)細(xì)節(jié)值得注意我在匹配第三方包時(shí)直接跳過(guò)了。因?yàn)閞ea關(guān)注的永遠(yuǎn)是項(xiàng)目?jī)?nèi)部模塊之間的關(guān)系node_modules里的依賴再?gòu)?fù)雜也不是這個(gè)工具要管的范疇。強(qiáng)行分析第三方包既浪費(fèi)性能又會(huì)讓報(bào)告變得嘈雜難讀。規(guī)范化路徑這步也很關(guān)鍵。假設(shè)一個(gè)文件位于src/utils/helpers.js它引用了../constants.js那么我在報(bào)告里應(yīng)該展示的是src/constants.js而不是../constants.js否則后面做聚合統(tǒng)計(jì)的時(shí)候就無(wú)從下手了。路徑規(guī)范化的實(shí)現(xiàn)const path require(path); function normalizePath(rawPath, currentFilePath) { if (rawPath.startsWith(/)) { return path.normalize(rawPath); } const absolutePath path.resolve(path.dirname(currentFilePath), rawPath); return path.relative(process.cwd(), absolutePath); }用path.resolve先把相對(duì)路徑轉(zhuǎn)為絕對(duì)路徑再用path.relative把絕對(duì)路徑轉(zhuǎn)為相對(duì)于項(xiàng)目根目錄的路徑。這樣整理出來(lái)的依賴關(guān)系每一行都是可追溯、可點(diǎn)擊的。2.3 報(bào)告生成的人性化設(shè)計(jì)分析做完以后如何呈現(xiàn)結(jié)果是一個(gè)非常值得琢磨的問(wèn)題。我見(jiàn)過(guò)不少工具分析做得很深入但輸出結(jié)果是一大堆 JSON開(kāi)發(fā)者看完之后依舊一頭霧水。rea從一開(kāi)始就明確了一件事最終交付物必須是人類可讀的純文本報(bào)告。第一版報(bào)告長(zhǎng)這樣Project: demo-shop Generated at: 2025-06-14 10:23:01 規(guī)模概況 Total files: 1,234 Total lines of code: 78,942 Total dependencies (internal): 3,215 高風(fēng)險(xiǎn)模塊 Top 10 1. src/order/service.js (127 inward refs) 2. src/user/session.js (98 inward refs) 3. src/payment/gateway.js (76 inward refs) ... 孤立文件候選 - src/legacy/migrate_old.js (no inward refs, 0 outward refs) - src/pages/UnusedPage.vue (no inward refs, 0 outward refs) ...這個(gè)報(bào)告看起來(lái)不復(fù)雜但需要在背后完成兩件很重要的事。第一件事是統(tǒng)計(jì)每個(gè)文件的被引用次數(shù)。這就需要一個(gè)從目標(biāo)路徑到引用來(lái)源列表的反向映射表構(gòu)建方式很簡(jiǎn)單在收集依賴關(guān)系的時(shí)候每發(fā)現(xiàn)一條引用就在目標(biāo)文件對(duì)應(yīng)的集合里加一個(gè)來(lái)源。第二件事是判斷“孤立文件”。如果某個(gè)文件在內(nèi)部分析中既沒(méi)有被其他文件引用它本身也沒(méi)有引用任何內(nèi)部文件那么它極大概率是從某個(gè)版本的迭代中殘留下來(lái)的廢棄代碼。注意這只是“候選”——因?yàn)閯?dòng)態(tài)加載、反射調(diào)用、配置文件引用這些情況靜態(tài)分析是捕捉不到的。所以報(bào)告里會(huì)用“候選”這個(gè)詞提醒使用者不要直接照單全收。2.4 并發(fā)掃描的優(yōu)化方案如果你只在小項(xiàng)目上用過(guò)rea那你可能永遠(yuǎn)不會(huì)遇到這一節(jié)要講的問(wèn)題。但一旦跑大型項(xiàng)目性能問(wèn)題就會(huì)立刻浮現(xiàn)。我前面提到在一個(gè)五萬(wàn)文件的電商項(xiàng)目上第一版rea直接跑崩了。這里所謂的“崩”不是內(nèi)存溢出而是耗時(shí)長(zhǎng)得不可接受。當(dāng)時(shí)我用計(jì)時(shí)器量了一下掃描耗時(shí)就占了兩分多鐘而理想狀況下一個(gè)命令行工具是不應(yīng)該在交互時(shí)讓人等待這么久的。性能瓶頸主要出在fs.statSync這個(gè)調(diào)用上。當(dāng)年我在遍歷目錄的時(shí)候每遇到一個(gè)文件都會(huì)調(diào)用一次fs.statSync獲取文件大小。五萬(wàn)個(gè)文件就意味著五萬(wàn)次同步的系統(tǒng)調(diào)用這個(gè)開(kāi)銷非??捎^。優(yōu)化方案有兩個(gè)方向可以選擇一個(gè)是改用fs.promises做異步 IO另一個(gè)是用 Worker 線程池做并行掃描。我最終把兩個(gè)方向結(jié)合了。用 Worker 線程池改造的思路是主線程把項(xiàng)目根目錄下的頂層子目錄分發(fā)給若干個(gè) Worker每個(gè) Worker 負(fù)責(zé)掃描自己分到的子目錄把結(jié)果序列化后傳回主線程。這個(gè)過(guò)程有點(diǎn)像把一個(gè)大倉(cāng)庫(kù)的盤(pán)點(diǎn)工作分給幾組人同時(shí)做每組只負(fù)責(zé)自己的片區(qū)最后再把匯總結(jié)果合起來(lái)。這里我給出一個(gè)簡(jiǎn)化版的線程池實(shí)現(xiàn)思路const { Worker } require(worker_threads); function parallelScan(roots) { const workers roots.map((root, index) new Promise((resolve, reject) { const worker new Worker(TASK_FILE, { workerData: { root, index } }); worker.on(message, resolve); worker.on(error, reject); })); return Promise.all(workers); }不過(guò)要提醒大家Worker 線程用起來(lái)隨手就能寫(xiě)但序列化和反序列化fileList的開(kāi)銷也不小。當(dāng)時(shí)測(cè)試下來(lái)文件數(shù)量在三千以下時(shí)單線程和線程池的耗時(shí)幾乎沒(méi)有區(qū)別甚至線程池還更慢一些。只有文件數(shù)量超過(guò)一萬(wàn)時(shí)線程池的收益才明顯體現(xiàn)出來(lái)。所以如果你的項(xiàng)目規(guī)模沒(méi)到這個(gè)量級(jí)沒(méi)必要盲目上并發(fā)先看看是不是被其他低效操作拖了后腿。3. 實(shí)操過(guò)程與核心環(huán)節(jié)實(shí)現(xiàn)理論部分說(shuō)完了接下來(lái)是實(shí)戰(zhàn)環(huán)節(jié)。我的經(jīng)驗(yàn)是做工具類項(xiàng)目先搭一個(gè)可以用起來(lái)的骨架再逐步加功能比一開(kāi)始就追求大而全要高效得多。3.1 環(huán)境準(zhǔn)備與項(xiàng)目初始化rea的運(yùn)行環(huán)境要求是 Node.js 14 及以上原因很簡(jiǎn)單舊版本不支持fs.readdirSync的withFileTypes參數(shù)其實(shí)這個(gè)參數(shù)在 10.10 就引入 了但我建議直接給個(gè)更寬松的底線。項(xiàng)目初始化用的是npm init -y它會(huì)自動(dòng)生成一個(gè)package.json。我習(xí)慣在項(xiàng)目里加上bin字段這樣就能通過(guò)npm link把命令安裝成全局命令{ name: rea, version: 0.1.0, description: A lightweight static analysis tool for project structure, main: src/index.js, bin: { rea: src/index.js }, engines: { node: 14.0.0 } }文件目錄結(jié)構(gòu)我采用了很常見(jiàn)的模式rea/ ├── src/ │ ├── index.js # 入口文件命令分發(fā) │ ├── scanner.js # 目錄掃描模塊 │ ├── analyzer.js # 依賴分析模塊 │ ├── reporter.js # 報(bào)告生成模塊 │ └── utils.js # 通用工具 ├── test/ │ └── fixtures/ # 測(cè)試用的臨時(shí)項(xiàng)目 └── package.json入口文件的寫(xiě)法非常簡(jiǎn)單就是解析命令行參數(shù)然后按指令調(diào)用不同模塊#!/usr/bin/env node const { scanner } require(./scanner); const { analyzer } require(./analyzer); const { reporter } require(./reporter); async function main() { const projectRoot process.argv[2] || .; const scanStart Date.now(); const fileList await scanner.scan(projectRoot); const analysis analyzer.analyze(fileList); const elapsed Date.now() - scanStart; console.log(reporter.render(projectRoot, analysis, elapsed)); } main().catch((err) { console.error([rea] Error:, err.message); process.exit(1); });這一版沒(méi)有引入 commander 之類的參數(shù)解析庫(kù)。因?yàn)樗壳爸恍枰粋€(gè)參數(shù)項(xiàng)目路徑。將來(lái)如果增加了更多命令再考慮引入?yún)?shù)解析框架也不遲。我一直堅(jiān)持一個(gè)原則不過(guò)早引入復(fù)雜度。3.2 依賴分析模塊的完整實(shí)現(xiàn)依賴分析模塊是這個(gè)工具的核心大腦。這里我貼出完整實(shí)現(xiàn)并逐行解釋關(guān)鍵邏輯。class Analyzer { constructor(fileList) { this.fileList fileList; this.nodeMap new Map(); // 存儲(chǔ)每個(gè)文件節(jié)點(diǎn) this.edgeList []; } analyze() { for (const file of this.fileList) { const source this._readFile(file.path); const deps extractDependencies(source, file.path); file.deps deps; this.nodeMap.set(file.path, file); } this._buildReverseMap(); return { nodes: this.nodeMap, edges: this.edgeList }; } _readFile(filePath) { try { return fs.readFileSync(filePath, utf-8); } catch (err) { return ; } } _buildReverseMap() { for (const [targetPath] of this.nodeMap) { this.nodeMap.get(targetPath).inwardRefs []; } for (const file of this.fileList) { for (const dep of file.deps) { // 只統(tǒng)計(jì)存在對(duì)應(yīng)文件節(jié)點(diǎn)的依賴 if (this.nodeMap.has(dep)) { this.nodeMap.get(dep).inwardRefs.push(file.path); this.edgeList.push({ from: file.path, to: dep }); } } } } }這里有幾個(gè)需要特別留意的點(diǎn)。第一個(gè)_buildReverseMap里的inwardRefs初始化放到了外層循環(huán)里。如果你直接把inwardRefs初始化寫(xiě)進(jìn)_readFile那一步那么每個(gè)文件被訪問(wèn)的先后順序就會(huì)影響最終結(jié)果這是典型的初始化順序陷阱。第二個(gè)import解析用的是正則表達(dá)式必然存在誤報(bào)和漏報(bào)的可能。比如代碼中有被注釋掉的require語(yǔ)句或者寫(xiě)在字符串里的import字樣正則都會(huì)把它們誤判為真實(shí)依賴。如果是做嚴(yán)謹(jǐn)?shù)纳暇€檢查這顯然不合格但如果目標(biāo)是快速生成一份“值得人工查看的候選清單”這個(gè)精度足夠了。當(dāng)工具的目標(biāo)定位是提供線索而不是給出裁決時(shí)不完美是可以接受的。第三個(gè)我在正則里特別處理了動(dòng)態(tài)導(dǎo)入import()因?yàn)楝F(xiàn)代代碼中按需加載的場(chǎng)景太常見(jiàn)了忽略它會(huì)漏掉很多真實(shí)的運(yùn)行時(shí)依賴。3.3 報(bào)告渲染引擎的實(shí)現(xiàn)報(bào)告部分的目標(biāo)是盡量保持簡(jiǎn)潔同時(shí)信息密度足夠高。我不建議直接輸出一個(gè)超大表格開(kāi)發(fā)者在終端里滾動(dòng)幾百行體驗(yàn)很差。所以我做了一套“摘要 重點(diǎn)列表”的結(jié)構(gòu)。渲染邏輯的核心其實(shí)是一個(gè)排序與截?cái)嗟倪^(guò)程function renderReport(projectRoot, analysis, elapsed) { const nodes [...analysis.nodes.values()]; const totalFiles nodes.length; let totalLines 0; for (const node of nodes) { totalLines countLines(node); } const lines []; lines.push(Project: ${projectRoot}); lines.push(Generated at: ${new Date().toLocaleString()}); lines.push(); lines.push( 規(guī)模概況 ); lines.push(Total files: ${totalFiles.toLocaleString()}); lines.push(Total lines of code: ${totalLines.toLocaleString()}); lines.push(Total internal edges: ${analysis.edges.length.toLocaleString()}); lines.push(); const sortedByInward nodes .sort((a, b) b.inwardRefs.length - a.inwardRefs.length); lines.push( 高風(fēng)險(xiǎn)模塊 Top 10 ); for (const file of sortedByInward.slice(0, 10)) { lines.push(${file.path} (${file.inwardRefs.length} inward refs)); } lines.push(); const isolatedFiles nodes.filter( (file) file.inwardRefs.length 0 file.deps.length 0 ); lines.push( 孤立文件候選 ); if (isolatedFiles.length 0) { lines.push((none)); } else { for (const file of isolatedFiles.slice(0, 20)) { lines.push(- ${file.path}); } } lines.push(); return lines.join(\n); }這個(gè)渲染函數(shù)雖然看起來(lái)很長(zhǎng)但邏輯非常簡(jiǎn)單聚合數(shù)據(jù)、排序、取前 N 個(gè)、拼接字符串。它的設(shè)計(jì)目標(biāo)就是讓使用者在十秒鐘之內(nèi)抓住項(xiàng)目的核心風(fēng)險(xiǎn)點(diǎn)。3.4 從第一版到可發(fā)布版本的過(guò)程寫(xiě)第一版只用了兩三個(gè)小時(shí)但rea真正變成可用的工具是經(jīng)歷了比較長(zhǎng)的迭代之后。第一版跑通后我遇到的最大問(wèn)題其實(shí)不是技術(shù)難點(diǎn)而是“輸出結(jié)果讓人看不明白”。比如報(bào)告里出現(xiàn)的孤立文件候選有接近一半其實(shí)是前端項(xiàng)目在構(gòu)建時(shí)期引用的less變量文件——它們雖然不被任何 JS 文件直接引用但是在編譯期起著非常重要的作用。如果我只簡(jiǎn)單地輸出一個(gè)候選列表使用者大概率會(huì)誤判。為了解決這個(gè)問(wèn)題我在分析階段加入了一個(gè)“文件類型分組”的維度。針對(duì)特定的文件擴(kuò)展名如.less、.scss、.md、.json打上特殊標(biāo)簽在報(bào)告中用不同的標(biāo)記提示使用者這些文件不適用“孤立”判斷。這個(gè)改動(dòng)雖然不復(fù)雜卻極大地提高了報(bào)告的實(shí)用價(jià)值。另一個(gè)重要的迭代是支持.reaignore文件類似于.gitignore的機(jī)制。因?yàn)橛行┠夸洷热缱詣?dòng)生成的 mock 數(shù)據(jù)目錄根本不應(yīng)該參與分析強(qiáng)行分析只會(huì)制造噪音。這個(gè)功能寫(xiě)起來(lái)很簡(jiǎn)單就是多讀一個(gè)配置文件把其中的每個(gè)模式追加到忽略列表里。4. 實(shí)操過(guò)程記錄一次真實(shí)的使用案例光講原理不落地總感覺(jué)少了點(diǎn)什么。這里我復(fù)盤(pán)一次用rea分析一個(gè)實(shí)際項(xiàng)目以下代稱為模擬項(xiàng)目X的完整過(guò)程包括中間遇到的小狀況。4.1 準(zhǔn)備階段搭建一個(gè)棘手項(xiàng)目模擬項(xiàng)目X 是一個(gè)典型的早期 Web 應(yīng)用體量中等偏下但結(jié)構(gòu)非常雜亂。它的源代碼目錄長(zhǎng)這樣project-x/ ├── src/ │ ├── api/ │ ├── components/ │ │ ├── core/ │ │ └── legacy/ │ ├── pages/ │ ├── styles/ │ ├── utils/ │ └── vendor-patches/ # 放了一些從網(wǎng)上復(fù)制來(lái)的第三方補(bǔ)丁 ├── scripts/ │ ├── build.js │ └── deploy.sh ├── mock/ └── docs/我預(yù)想中的難點(diǎn)有兩個(gè)vendor-patches目錄里的補(bǔ)丁代碼引用了很多古老的文件路徑mock目錄里有一大堆自動(dòng)生成的數(shù)據(jù)文件它們不參與運(yùn)行但會(huì)干擾依賴分析。我在項(xiàng)目根目錄創(chuàng)建了.reaignore文件mock/ vendor-patches/然后把rea工具放到 PATH 中準(zhǔn)備執(zhí)行掃描。4.2 第一次運(yùn)行結(jié)果與分析執(zhí)行命令rea ./project-x輸出的報(bào)告節(jié)選如下Project: ./project-x Generated at: 2025-06-14 10:23:01 規(guī)模概況 Total files: 846 Total lines of code: 51,239 Total internal edges: 1,082 高風(fēng)險(xiǎn)模塊 Top 10 1. src/api/request.js (124 inward refs) 2. src/components/core/Table.js (78 inward refs) 3. src/pages/dashboard/index.js (61 inward refs) 4. src/styles/theme.js (54 inward refs) 5. src/components/core/Form.js (50 inward refs) ...看到這個(gè)結(jié)果我基本可以判斷模擬項(xiàng)目X的核心問(wèn)題在哪了src/api/request.js被一百多個(gè)文件直接引用這意味著任何對(duì)這個(gè)文件的小改動(dòng)都可能波及全項(xiàng)目所有的 API 調(diào)用。用《重構(gòu)》里的術(shù)語(yǔ)來(lái)說(shuō)這就是典型的“霰彈式修改”高危點(diǎn)。順著這個(gè)線索我抽查了幾個(gè)引用request.js的頁(yè)面組件發(fā)現(xiàn)它們確實(shí)共用了一個(gè)全局的請(qǐng)求實(shí)例而且這個(gè)實(shí)例硬編碼了一些業(yè)務(wù)參數(shù)。后面如果要改造項(xiàng)目這個(gè)文件必然是第一優(yōu)先級(jí)。4.3 深入排查孤立文件候選的甄別接著往下看報(bào)告中的孤立文件候選 孤立文件候選 - src/styles/dark-theme.css - src/pages/settings/profile_v2.js - src/pages/settings/profile_v1.js - src/utils/legacy_format.js注意最后這兩個(gè)文件profile_v1.js和profile_v2.js。二者都不被引用但顯然存在一個(gè)版本切換關(guān)系。通過(guò)查看git log我發(fā)現(xiàn)profile_v1.js確實(shí)已經(jīng)被替代而profile_v2.js是通過(guò)路由懶加載方式引用的所以靜態(tài)分析掃不到。這個(gè)案例很好地說(shuō)明了一個(gè)問(wèn)題孤立文件候選不是終點(diǎn)而是下一步人工排查的起點(diǎn)。如果自動(dòng)化工具能直接告訴你哪些代碼可以被刪除那它就不再是輔助工具而是代碼庫(kù)的替代者了。4.4 針對(duì)模擬項(xiàng)目X的優(yōu)化建議基于rea生成的報(bào)告我很快就整理出了幾條優(yōu)化建議將request.js中的基礎(chǔ) API 封裝拆分為獨(dú)立的小函數(shù)同時(shí)增加對(duì)全局請(qǐng)求實(shí)例的注入方式。刪除profile_v1.js以及與其相關(guān)的樣式文件。把utils/legacy_format.js中有價(jià)值的方法合并到utils/format.js確保沒(méi)有邏輯重復(fù)。在mock數(shù)據(jù)入口處增加統(tǒng)一的出口配置避免散落各處導(dǎo)致的認(rèn)知負(fù)擔(dān)。這些建議談不上高大上但他們建立在完整掃描結(jié)果之上因此每一處改動(dòng)都有明確的依據(jù)減少了憑空猜測(cè)帶來(lái)的風(fēng)險(xiǎn)。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄工具開(kāi)發(fā)到后期我陸陸續(xù)續(xù)遇到并解決了一些典型的工程問(wèn)題。這里挑幾個(gè)最有代表性的問(wèn)題做個(gè)速查表也方便你將來(lái)使用或仿寫(xiě)類似工具時(shí)能夠繞開(kāi)這些坑。問(wèn)題原因排查技巧解決方案掃描大目錄耗時(shí)過(guò)長(zhǎng)同步 IO 操作過(guò)多用perf_hooks分階段計(jì)時(shí)定位耗時(shí)集中在掃描還是分析切換到異步 IO 或 Worker 線程池報(bào)告中有大量誤報(bào)的孤立文件靜態(tài)分析無(wú)法覆蓋動(dòng)態(tài)引用抽樣核查個(gè)別文件檢查是否存在import()或配置式引用引入文件類型分組和.reaignore機(jī)制require路徑解析 alias 導(dǎo)致依賴匹配不上構(gòu)建工具里配置了模塊別名在報(bào)告里輸出一條“未匹配 dependency”匯總在工具里增加別名映射配置文件運(yùn)行環(huán)境 Node 版本過(guò)低導(dǎo)致崩潰使用了較新的 API查看堆棧信息定位具體 API明確最低環(huán)境要求啟動(dòng)時(shí)做版本檢查文件編碼不統(tǒng)一導(dǎo)致亂碼GBK 編碼的文件被當(dāng) UTF-8 讀取觀察到輸出中文亂碼時(shí)不應(yīng)盲目跳過(guò)讀取時(shí)嘗試多種編碼解碼并在報(bào)告中標(biāo)記編碼識(shí)別結(jié)果Worker 線程數(shù)據(jù)序列化開(kāi)銷大傳輸海量文件列表對(duì)比單線程與線程池在閾值前后的表現(xiàn)低于閾值時(shí)自動(dòng)退化為單線程模式除了這些常見(jiàn)問(wèn)題之外再分享幾個(gè)我平時(shí)用得比較多的小技巧。第一個(gè)技巧在調(diào)試rea時(shí)我喜歡先用一個(gè)小型 fixture 項(xiàng)目做驗(yàn)證而不是直接跑真實(shí)大項(xiàng)目。fixture 項(xiàng)目里就放三五個(gè)人為制造的依賴關(guān)系比如 a 引用 bb 引用 cc 又引用 a用來(lái)驗(yàn)證循環(huán)依賴是否能被正確識(shí)別。用這種微型案例調(diào)試效率遠(yuǎn)高于在大項(xiàng)目里糊里糊涂找 bug。第二個(gè)技巧輸出中間態(tài)調(diào)試信息。我在工具中加了一個(gè)--debug參數(shù)它會(huì)額外打印出掃描階段的文件數(shù)量、每個(gè)階段耗時(shí)、依賴解析失敗的路徑列表。這些信息在定位問(wèn)題時(shí)價(jià)值極高。第三個(gè)技巧把工具的源碼納入自己的日常代碼習(xí)慣之中。rea雖然是一個(gè)工具項(xiàng)目但它同樣需要版本管理、測(cè)試用例、代碼風(fēng)格統(tǒng)一。我把它推到一個(gè)私有倉(cāng)庫(kù)里每次修復(fù)完 bug 都加一個(gè)針對(duì)性的測(cè)試防止后續(xù)改其他地方時(shí)把修好的問(wèn)題又帶出來(lái)。第四個(gè)技巧也是我認(rèn)為最重要的不要把工具的定位搞錯(cuò)。rea的產(chǎn)出是線索不是結(jié)論。我在最初幾版的使用中曾因?yàn)檫^(guò)度信任“孤立文件候選”列表差點(diǎn)把一個(gè)動(dòng)態(tài)組件給刪了。從那以后我堅(jiān)持在報(bào)告首頁(yè)頂部加一句提示所有分析結(jié)果均需人工復(fù)核。這句話雖然簡(jiǎn)單卻在很大程度上避免了使用者過(guò)度依賴工具帶來(lái)的災(zāi)難。6. 后續(xù)擴(kuò)展方向與個(gè)人經(jīng)驗(yàn)總結(jié)rea目前能做的事情已經(jīng)足夠解決我百分之八十的結(jié)構(gòu)梳理需求但它的擴(kuò)展空間依然很大。如果時(shí)間允許我大概率會(huì)在三個(gè)方向繼續(xù)打磨。第一個(gè)方向是支持更多語(yǔ)言。目前依賴解析主要針對(duì) JavaScript 和 TypeScript 生態(tài)輸出報(bào)告的時(shí)候也會(huì)刻意區(qū)分.js、.jsx、.ts、.tsx。但實(shí)際項(xiàng)目里很可能一個(gè)倉(cāng)庫(kù)同時(shí)包含 Java、Python、甚至 Go 代碼。只要針對(duì)每種語(yǔ)言補(bǔ)充對(duì)應(yīng)的解析規(guī)則rea就可以變成一種真正意義上的多語(yǔ)言項(xiàng)目結(jié)構(gòu)分析工具。第二個(gè)方向是輸出可視化的依賴圖。現(xiàn)在終端文本報(bào)告雖然好讀但面對(duì)大型項(xiàng)目時(shí)一張能夠讓用戶拖動(dòng)、縮放、聚焦的交互式圖表會(huì)帶來(lái)更直觀的體驗(yàn)。我計(jì)劃在未來(lái)版本中把分析結(jié)果同時(shí)輸出一份 JSON 或 DOT 格式方便接入其他可視化工具。第三個(gè)方向是提供增量掃描能力。每次全量掃描雖然已經(jīng)足夠快但如果在 git 提交后只想分析本次變更涉及了多少上下游文件增量模式會(huì)實(shí)用得多。最后說(shuō)一點(diǎn)個(gè)人體會(huì)。我做過(guò)不少工具rea不是技術(shù)含量最高的一個(gè)但它是我認(rèn)為“性價(jià)比”最高的一個(gè)。它解決的問(wèn)題非常具體非常貼近日常開(kāi)發(fā)的真實(shí)痛點(diǎn)而且它本身并不復(fù)雜——一個(gè)會(huì)點(diǎn) Node.js 的開(kāi)發(fā)者幾天時(shí)間就能寫(xiě)出類似的工具。如果你正在為“看不懂項(xiàng)目結(jié)構(gòu)”而頭疼我的建議是先別急著重構(gòu)或換框架花點(diǎn)時(shí)間寫(xiě)一個(gè)像rea這樣的小工具把項(xiàng)目的真實(shí)面貌摸清楚。很多所謂架構(gòu)混亂的問(wèn)題在理清依賴關(guān)系之后往往會(huì)自動(dòng)暴露出清晰的解決路徑。你自己寫(xiě)出來(lái)的工具以后改起來(lái)也特別順手因?yàn)樗拿恳恍写a都貼合你自己的腦回路。還有一個(gè)我始終忘不掉的經(jīng)驗(yàn)。在我第一次重寫(xiě)rea的那個(gè)周期里我曾因?yàn)橄爰右粋€(gè)“自動(dòng)重構(gòu)建議”功能而差點(diǎn)把工具本身給拖垮。后來(lái)我把它砍掉了因?yàn)檫@個(gè)功能需要的智能程度遠(yuǎn)超一個(gè)命令行工具的范疇。做工具最忌貪多一個(gè)工具能專注地把一件事做好就已經(jīng)非常出色了。這句話我現(xiàn)在送給所有在造輪子的朋友也包括未來(lái)的我自己。