【實戰(zhàn)】基于 babel 和 postcss 查找項目中的無用模塊
背景
昊昊是業(yè)務(wù)線前端工程師(專業(yè)頁面仔),我是架構(gòu)組工具鏈工程師(專業(yè)工具人),有一天昊昊和說我他維護(hù)的項目中沒用到的模塊太多了,其實可以刪掉的,但是現(xiàn)在不知道哪些沒用,就不敢刪,問我是不是可以做一個工具來找出所有沒有被引用的模塊。畢竟是專業(yè)的工具人,這種需求難不倒我,于是花了半天多實現(xiàn)了這個工具。
這個工具是一個通用的工具,node 項目、前端項目都可以用它來查找沒有用到的模塊,而且其中模塊遍歷器的思路可以應(yīng)用到很多別的地方。所以我整理了實現(xiàn)思路,寫了這篇文章。
思路分析
目標(biāo)是找到項目中所有沒用到的模塊。項目中總有幾個入口模塊,代碼會從這些模塊開始打包或者運(yùn)行。我們首先要知道所有的入口模塊。
有了入口模塊之后,分析入口模塊的用到(依賴)了哪些模塊,然后再從用到的模塊分析依賴,這樣遞歸的進(jìn)行分析,直到?jīng)]有新的依賴。這個過程中,所有遍歷到的模塊就是用到的,而沒有被遍歷到的就是沒有用到的,就是我們要找的可以刪除的模塊。

我們可以在遍歷的過程中把模塊信息和模塊之間的關(guān)系以對象和對象的關(guān)系保存,構(gòu)造成一個依賴圖(因為可能有一個模塊被兩個模塊依賴,甚至循環(huán)依賴,所以是圖)。之后對這個依賴圖的數(shù)據(jù)結(jié)構(gòu)的分析就是對模塊之間依賴關(guān)系的分析。我們這個需求只需要保存遍歷到的模塊路徑就可以,可以不生成依賴圖。
遍歷到不同的模塊要找到它依賴的哪些模塊,對于不同的模塊有不同的分析依賴的方式:
js、ts、jsx、tsx 模塊根據(jù) es module 的 import 或者 commonjs 的 require 來確定依賴 css、less、scss 模塊根據(jù) @import 和 url() 的語法來確定依賴
而且拿到了依賴的路徑也可能還要做一層處理,因為比如 webpack 可以配置 alias,typescript 可以配置 paths,還有 monorepo 的路徑也有自己的特點,這些路徑解析規(guī)則是我們要處理的,處理之后才能找到模塊真實路徑是啥。
經(jīng)過從入口模塊開始的依賴分析,對模塊圖完成遍歷,把用到的模塊路徑保存下來,然后用所有模塊路徑過濾掉用到的,剩下的就是沒有使用的模塊。
思路大概這樣,我們來實現(xiàn)一下:
代碼實現(xiàn)
模塊遍歷
我們要寫一個模塊遍歷器,傳入當(dāng)前模塊的路徑和處理模塊內(nèi)容的回調(diào)函數(shù),處理過程如下:
嘗試補(bǔ)全路徑,因為 .js、.json、.tsx 等可以省略后綴名 根據(jù)路徑獲得模塊的類型 如果是 js 模塊,用遍歷 js 的方式進(jìn)行處理 如果是 css 模塊,用遍歷 css 的方式進(jìn)行處理
const MODULE_TYPES = {
JS: 1 << 0,
CSS: 1 << 1,
JSON: 1 << 2
};
function getModuleType(modulePath) {
const moduleExt = extname(modulePath);
if (JS_EXTS.some(ext => ext === moduleExt)) {
return MODULE_TYPES.JS;
} else if (CSS_EXTS.some(ext => ext === moduleExt)) {
return MODULE_TYPES.CSS;
} else if (JSON_EXTS.some(ext => ext === moduleExt)) {
return MODULE_TYPES.JSON;
}
}
function traverseModule (curModulePath, callback) {
curModulePath = completeModulePath(curModulePath);
const moduleType = getModuleType(curModulePath);
if (moduleType & MODULE_TYPES.JS) {
traverseJsModule(curModulePath, callback);
} else if (moduleType & MODULE_TYPES.CSS) {
traverseCssModule(curModulePath, callback);
}
}
js 模塊遍歷
遍歷 js 模塊需要分析其中的 import 和 require 依賴。我們使用 babel 來做:
讀取文件內(nèi)容 根據(jù)后綴名是 .jsx、.tsx 等來決定是否啟用 typescript、jsx 的 parse 插件 使用 babel parser 把代碼轉(zhuǎn)成 AST 使用 babel traverse 對 AST 進(jìn)行遍歷 處理 ImportDeclaration 和 CallExpression 的 AST,從中提取依賴路徑 對依賴路徑進(jìn)行處理,變成真實路徑之后,繼續(xù)遍歷該路徑的模塊
代碼如下:
function traverseJsModule(curModulePath, callback) {
const moduleFileContent = fs.readFileSync(curModulePath, {
encoding: 'utf-8'
});
const ast = parser.parse(moduleFileContent, {
sourceType: 'unambiguous',
plugins: resolveBabelSyntaxtPlugins(curModulePath)
});
traverse(ast, {
ImportDeclaration(path) {
const subModulePath = moduleResolver(curModulePath, path.get('source.value').node);
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
traverseModule(subModulePath, callback);
},
CallExpression(path) {
if (path.get('callee').toString() === 'require') {
const subModulePath = moduleResolver(curModulePath, path.get('arguments.0').toString().replace(/['"]/g, ''));
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
traverseModule(subModulePath, callback);
}
}
})
}
css 模塊遍歷
遍歷 css 模塊需要分析 @import 和 url()。我們使用 postcss 來做:
讀取文件內(nèi)容 根據(jù)文件路徑是 .less、.scss 來決定是否啟用 less、scss 的語法插件 使用 postcss.parse 把文件內(nèi)容轉(zhuǎn)成 AST 遍歷 @import 節(jié)點,提取依賴路徑 遍歷樣式聲明(declaration),過濾出 url() 的值,提取依賴路徑 對依賴路徑進(jìn)行處理,變成真實路徑之后,繼續(xù)遍歷該路徑的模塊
代碼如下:
function traverseCssModule(curModulePath, callback) {
const moduleFileConent = fs.readFileSync(curModulePath, {
encoding: 'utf-8'
});
const ast = postcss.parse(moduleFileConent, {
syntaxt: resolvePostcssSyntaxtPlugin(curModulePath)
});
ast.walkAtRules('import', rule => {
const subModulePath = moduleResolver(curModulePath, rule.params.replace(/['"]/g, ''));
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
traverseModule(subModulePath, callback);
});
ast.walkDecls(decl => {
if (decl.value.includes('url(')) {
const url = /.*url\((.+)\).*/.exec(decl.value)[1].replace(/['"]/g, '');
const subModulePath = moduleResolver(curModulePath, url);
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
}
} )
}
模塊路徑處理
不管是 css 還是 js 模塊都要在提取了路徑之后進(jìn)行處理:
支持自定義路徑解析邏輯,讓用戶可以根據(jù)需要定制路徑解析的規(guī)則 過濾掉 node_modules 下的模塊,不需要分析 補(bǔ)全路徑的后綴名 如果遍歷過的模塊則跳過遍歷,避免循環(huán)依賴
代碼如下:
const visitedModules = new Set();
function moduleResolver (curModulePath, requirePath) {
if (typeof requirePathResolver === 'function') {// requirePathResolver 是用戶自定義的路徑解析邏輯
const res = requirePathResolver(dirname(curModulePath), requirePath);
if (typeof res === 'string') {
requirePath = res;
}
}
requirePath = resolve(dirname(curModulePath), requirePath);
// 過濾掉第三方模塊
if (requirePath.includes('node_modules')) {
return '';
}
requirePath = completeModulePath(requirePath);
if (visitedModules.has(requirePath)) {
return '';
} else {
visitedModules.add(requirePath);
}
return requirePath;
}
這樣我們就完成了分析出的依賴路徑到它真實的路徑的轉(zhuǎn)換。
路徑補(bǔ)全
寫代碼的時候是可以省略掉一些文件的后綴(.js、.tsx、.json 等)的,我們要實現(xiàn)補(bǔ)全的邏輯:
如果已經(jīng)有后綴名了,則跳過 如果是目錄,則嘗試查找 index.xxx 的文件,找到了則返回該路徑 如果是文件,則嘗試補(bǔ)全 .xxx 的后綴,找到了則返回該路徑 沒有找到則報錯:module not found
const JS_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
const JSON_EXTS = ['.json'];
function completeModulePath (modulePath) {
const EXTS = [...JSON_EXTS, ...JS_EXTS];
if (modulePath.match(/\.[a-zA-Z]+$/)) {
return modulePath;
}
function tryCompletePath (resolvePath) {
for (let i = 0; i < EXTS.length; i ++) {
let tryPath = resolvePath(EXTS[i]);
if (fs.existsSync(tryPath)) {
return tryPath;
}
}
}
function reportModuleNotFoundError (modulePath) {
throw chalk.red('module not found: ' + modulePath);
}
if (isDirectory(modulePath)) {
const tryModulePath = tryCompletePath((ext) => join(modulePath, 'index' + ext));
if (!tryModulePath) {
reportModuleNotFoundError(modulePath);
} else {
return tryModulePath;
}
} else if (!EXTS.some(ext => modulePath.endsWith(ext))) {
const tryModulePath = tryCompletePath((ext) => modulePath + ext);
if (!tryModulePath) {
reportModuleNotFoundError(modulePath);
} else {
return tryModulePath;
}
}
return modulePath;
}
按照上面的思路,我們實現(xiàn)了模塊的遍歷,找到了所有的用到的模塊。
過濾出無用模塊
上面我們找到了所有用到的模塊,接下來只要用所有的模塊過濾掉用到的模塊,就是沒有用到的模塊。
我們封裝一個 findUnusedModule 的方法。
傳入?yún)?shù):
entries(入口模塊數(shù)組) includes(所有模塊的 glob 表達(dá)式) resolveRequirePath(自定義路徑解析邏輯) cwd(解析模塊的根路徑)
返回一個對象,包含:
all (所有模塊) used(用到的模塊) unused(沒用到的模塊)
處理過程:
合并參數(shù)和默認(rèn)參數(shù) 基于 cwd 處理 includes 的模塊路徑 根據(jù) includes 的 glob 表達(dá)式找出所有的模塊 以所有 entires 為入口進(jìn)行遍歷,記錄用到的模塊 過濾掉用到的模塊,求出沒有用到的模塊
const defaultOptions = {
cwd: '',
entries: [],
includes: ['**/*', '!node_modules'],
resolveRequirePath: () => {}
}
function findUnusedModule (options) {
let {
cwd,
entries,
includes,
resolveRequirePath
} = Object.assign(defaultOptions, options);
includes = includes.map(includePath => (cwd ? `${cwd}/${includePath}` : includePath));
const allFiles = fastGlob.sync(includes).map(item => normalize(item));
const entryModules = [];
const usedModules = [];
setRequirePathResolver(resolveRequirePath);
entries.forEach(entry => {
const entryPath = resolve(cwd, entry);
entryModules.push(entryPath);
traverseModule(entryPath, (modulePath) => {
usedModules.push(modulePath);
});
});
const unusedModules = allFiles.filter(filePath => {
const resolvedFilePath = resolve(filePath);
return !entryModules.includes(resolvedFilePath) && !usedModules.includes(resolvedFilePath);
});
return {
all: allFiles,
used: usedModules,
unused: unusedModules
}
}
這樣,我們封裝的 findUnusedModule 能夠完成最初的需求:查找項目下沒有用到的模塊。
測試功能
我們來測試一下效果,用這個目錄作為測試項目:
const { all, used, unused } = findUnusedModule({
cwd: process.cwd(),
entries: ['./demo-project/fre.js', './demo-project/suzhe2.js'],
includes: ['./demo-project/**/*'],
resolveRequirePath (curDir, requirePath) {
if (requirePath === 'b') {
return path.resolve(curDir, './lib/ssh.js');
}
return requirePath;
}
});
結(jié)果如下:

成功的找出了沒有用到的模塊!(可以把代碼拉下來跑一下試試)
思考
我們實現(xiàn)了一個模塊遍歷器,它可以對從某一個模塊開始遍歷?;谶@個遍歷器我們實現(xiàn)了查找無用模塊的需求,其實也可以用它來做別的分析需求,這個遍歷的方式是通用的。
我們知道 babel 可以用來做兩件事情:
代碼的轉(zhuǎn)譯:從 es next、typescript 等代碼轉(zhuǎn)譯成目標(biāo)環(huán)境支持的 js 靜態(tài)分析:對代碼內(nèi)容做分析,比如類型檢查、lint 等,不生成代碼
這個模塊遍歷器也可以做同樣的事情:
靜態(tài)分析:分析模塊間的依賴關(guān)系,構(gòu)造依賴圖,完成一些分析功能 打包:把依賴圖中每一個模塊用相應(yīng)的代碼模版打印成目標(biāo)代碼
總結(jié)
我們先分析了需求:找出項目中沒用到的模塊。這需要實現(xiàn)一個模塊遍歷器。
模塊遍歷要對 js 模塊和 css 模塊做不同的處理:js 模塊分析 import 和 require,css 分析 url() 和 @import。
之后要對分析出的路徑做處理,變成真實路徑。要處理 node_modules、webpack alias、typescript 的 types 等情況,我們暴露了一個回調(diào)函數(shù)給開發(fā)者自己去擴(kuò)展。
實現(xiàn)了模塊遍歷之后,只要指定所有的模塊、入口模塊,那么我們就可以找出用到了哪些模塊,沒用到哪些模塊。
經(jīng)過測試,符合我們的需求。
這個模塊遍歷器是通用的,可以用來做各種靜態(tài)分析,也可以做后續(xù)的代碼打印做成一個打包器。
代碼的 github 地址在這,感興趣可以拉下來跑跑,學(xué)會寫模塊遍歷器還是挺有幫助的。
彩蛋
當(dāng)時給昊昊介紹這個功能的時候,寫了一份實現(xiàn)思路的文檔,也貼在這里吧:
昊昊: 光哥,整體的思路是什么樣的啊,一上來就看代碼比較亂
我:模塊是一個圖的結(jié)構(gòu),指定從某個入口開始遍歷,其實這是一個 dfs 的過程,但是有循環(huán)引用,要通過記錄處理過的模塊來解決。遞歸遍歷這個圖,處理到的模塊就是用到的。
昊昊:dfs 一個模塊,怎么確定子模塊呢?
我:不同的模塊有不同的處理方式,比如 js 模塊,就要通過 import 或者 require 來確定子模塊,而 css 則要通過 @import 和 url() 來確定。但是這些只是提取路徑,這個路徑還是不可用的,還需要轉(zhuǎn)換成真實路徑,要有一個 resolve path 的過程。
昊昊:resolve path 都做啥?。?/p>
我:就是處理 alias、過濾 node_modules 下的模塊,因為我們這里用不到,然后根據(jù)當(dāng)前模塊的路徑確定子模塊的絕對路徑。還要暴露出一個鉤子函數(shù)去讓用戶能夠自定義 require path 的 resolve 邏輯。
昊昊:就是那個 requireRequirePath 么?
我:對的,那個就是暴露出去讓用戶自定義 path resolve 邏輯的鉤子。
昊昊:我大體明白流程了?
我:說說看
昊昊:項目的模塊構(gòu)成依賴圖,我們要確定沒有用到的模塊,那就要先找出用到的模塊,之后把它們過濾掉。用到的模塊要用幾個入口模塊開始做 dfs,遍歷不同的模塊有不同的提取 require path 的方式,提取出來以后還要對 path 進(jìn)行 resolve,得到真實路徑,然后遞歸進(jìn)行子模塊的處理。這樣遍歷完一遍就能確定用到了哪些。同時還要處理循環(huán)引用問題,因為畢竟模塊是一個圖,進(jìn)行 dfs 會有環(huán)在。
我:對的,棒棒的。
