基於 babel 和 postcss 查找項目中的無用模塊

背景

昊昊是業務線前端工程師(專業頁面仔),我是架構組工具鏈工程師(專業工具人),有一天昊昊和說我他維護的項目中沒用到的模塊太多了,其實能夠刪掉的,可是如今不知道哪些沒用,就不敢刪,問我是否是能夠作一個工具來找出全部沒有被引用的模塊。畢竟是專業的工具人,這種需求難不倒我,因而花了半天多實現了這個工具。javascript

這個工具是一個通用的工具,node 項目、前端項目均可以用它來查找沒有用到的模塊,並且其中模塊遍歷器的思路能夠應用到不少別的地方。因此我整理了實現思路,寫了這篇文章。css

思路分析

目標是找到項目中全部沒用到的模塊。項目中總有幾個入口模塊,代碼會從這些模塊開始打包或者運行。咱們首先要知道全部的入口模塊。前端

有了入口模塊以後,分析入口模塊的用到(依賴)了哪些模塊,而後再從用到的模塊分析依賴,這樣遞歸的進行分析,直到沒有新的依賴。這個過程當中,全部遍歷到的模塊就是用到的,而沒有被遍歷到的就是沒有用到的,就是咱們要找的能夠刪除的模塊。java

咱們能夠在遍歷的過程當中把模塊信息和模塊之間的關係以對象和對象的關係保存,構形成一個依賴圖(由於可能有一個模塊被兩個模塊依賴,甚至循環依賴,因此是圖)。以後對這個依賴圖的數據結構的分析就是對模塊之間依賴關係的分析。咱們這個需求只須要保存遍歷到的模塊路徑就能夠,能夠不生成依賴圖。node

遍歷到不一樣的模塊要找到它依賴的哪些模塊,對於不一樣的模塊有不一樣的分析依賴的方式:webpack

  • js、ts、jsx、tsx 模塊根據 es module 的 import 或者 commonjs 的 require 來肯定依賴
  • css、less、scss 模塊根據 @import 和 url() 的語法來肯定依賴

並且拿到了依賴的路徑也可能還要作一層處理,由於好比 webpack 能夠配置 alias,typescript 能夠配置 paths,還有 monorepo 的路徑也有本身的特色,這些路徑解析規則是咱們要處理的,處理以後才能找到模塊真實路徑是啥。git

通過從入口模塊開始的依賴分析,對模塊圖完成遍歷,把用到的模塊路徑保存下來,而後用全部模塊路徑過濾掉用到的,剩下的就是沒有使用的模塊。github

思路大概這樣,咱們來實現一下:web

代碼實現

模塊遍歷

咱們要寫一個模塊遍歷器,傳入當前模塊的路徑和處理模塊內容的回調函數,處理過程以下:typescript

  • 嘗試補全路徑,由於 .js、.json、.tsx 等能夠省略後綴名
  • 根據路徑得到模塊的類型
  • 若是是 js 模塊,用遍歷 js 的方式進行處理
  • 若是是 css 模塊,用遍歷 css 的方式進行處理
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 來作:

  • 讀取文件內容
  • 根據後綴名是 .jsx、.tsx 等來決定是否啓用 typescript、jsx 的 parse 插件
  • 使用 babel parser 把代碼轉成 AST
  • 使用 babel traverse 對 AST 進行遍歷
  • 處理 ImportDeclaration 和 CallExpression 的 AST,從中提取依賴路徑
  • 對依賴路徑進行處理,變成真實路徑以後,繼續遍歷該路徑的模塊

代碼以下:

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 來作:

  • 讀取文件內容
  • 根據文件路徑是 .less、.scss 來決定是否啓用 less、scss 的語法插件
  • 使用 postcss.parse 把文件內容轉成 AST
  • 遍歷 @import 節點,提取依賴路徑
  • 遍歷樣式聲明(declaration),過濾出 url() 的值,提取依賴路徑
  • 對依賴路徑進行處理,變成真實路徑以後,繼續遍歷該路徑的模塊

代碼以下:

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 模塊都要在提取了路徑以後進行處理:

  • 支持自定義路徑解析邏輯,讓用戶能夠根據須要定製路徑解析的規則
  • 過濾掉 node_modules 下的模塊,不須要分析
  • 補全路徑的後綴名
  • 若是遍歷過的模塊則跳過遍歷,避免循環依賴

代碼以下:

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;
}
複製代碼

這樣咱們就完成了分析出的依賴路徑到它真實的路徑的轉換。

路徑補全

寫代碼的時候是能夠省略掉一些文件的後綴(.js、.tsx、.json 等)的,咱們要實現補全的邏輯:

  • 若是已經有後綴名了,則跳過
  • 若是是目錄,則嘗試查找 index.xxx 的文件,找到了則返回該路徑
  • 若是是文件,則嘗試補全 .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;
}
複製代碼

按照上面的思路,咱們實現了模塊的遍歷,找到了全部的用到的模塊。

過濾出無用模塊

上面咱們找到了全部用到的模塊,接下來只要用全部的模塊過濾掉用到的模塊,就是沒有用到的模塊。

咱們封裝一個 findUnusedModule 的方法。

傳入參數:

  • entries(入口模塊數組)
  • includes(全部模塊的 glob 表達式)
  • resolveRequirePath(自定義路徑解析邏輯)
  • cwd(解析模塊的根路徑)

返回一個對象,包含:

  • all (全部模塊)
  • used(用到的模塊)
  • unused(沒用到的模塊)

處理過程:

  • 合併參數和默認參數
  • 基於 cwd 處理 includes 的模塊路徑
  • 根據 includes 的 glob 表達式找出全部的模塊
  • 以全部 entires 爲入口進行遍歷,記錄用到的模塊
  • 過濾掉用到的模塊,求出沒有用到的模塊
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;
    }
});
複製代碼

結果以下:

成功的找出了沒有用到的模塊!(能夠把代碼拉下來跑一下試試)

思考

咱們實現了一個模塊遍歷器,它能夠對從某一個模塊開始遍歷。基於這個遍歷器咱們實現了查找無用模塊的需求,其實也能夠用它來作別的分析需求,這個遍歷的方式是通用的。

咱們知道 babel 能夠用來作兩件事情:

  • 代碼的轉譯: 從 es next、typescript 等代碼轉譯成目標環境支持的 js
  • 靜態分析: 對代碼內容作分析,好比類型檢查、lint 等,不生成代碼

這個模塊遍歷器也能夠作一樣的事情:

  • 靜態分析:分析模塊間的依賴關係,構造依賴圖,完成一些分析功能
  • 打包:把依賴圖中每個模塊用相應的代碼模版打印成目標代碼

總結

咱們先分析了需求:找出項目中沒用到的模塊。這須要實現一個模塊遍歷器。

模塊遍歷要對 js 模塊和 css 模塊作不一樣的處理:js 模塊分析 import 和 require,css 分析 url() 和 @import。

以後要對分析出的路徑作處理,變成真實路徑。要處理 node_modules、webpack alias、typescript 的 types 等狀況,咱們暴露了一個回調函數給開發者本身去擴展。

實現了模塊遍歷以後,只要指定全部的模塊、入口模塊,那麼咱們就能夠找出用到了哪些模塊,沒用到哪些模塊。

通過測試,符合咱們的需求。

這個模塊遍歷器是通用的,能夠用來作各類靜態分析,也能夠作後續的代碼打印作成一個打包器。

代碼的 github 地址在這,感興趣能夠拉下來跑跑,學會寫模塊遍歷器仍是挺有幫助的。

彩蛋

當時給昊昊介紹這個功能的時候,寫了一份實現思路的文檔,也貼在這裏吧:

昊昊: 光哥,總體的思路是什麼樣的啊,一上來就看代碼比較亂

: 模塊是一個圖的結構,指定從某個入口開始遍歷,其實這是一個 dfs 的過程,可是有循環引用,要經過記錄處理過的模塊來解決。遞歸遍歷這個圖,處理到的模塊就是用到的。

昊昊: dfs 一個模塊,怎麼肯定子模塊呢?

: 不一樣的模塊有不一樣的處理方式,好比 js 模塊,就要經過 import 或者 require 來肯定子模塊,而 css 則要經過 @import 和 url() 來肯定。 可是這些只是提取路徑,這個路徑仍是不可用的,還須要轉換成真實路徑,要有一個 resolve path 的過程。

昊昊: resolve path 都作啥啊?

: 就是處理 alias、過濾 node_modules 下的模塊,由於咱們這裏用不到,而後根據當前模塊的路徑肯定子模塊的絕對路徑。還要暴露出一個鉤子函數去讓用戶可以自定義 require path 的 resolve 邏輯。

昊昊: 就是那個 requireRequirePath 麼?

: 對的,那個就是暴露出去讓用戶自定義 path resolve 邏輯的鉤子。

昊昊: 我大致明白流程了?

: 說說看

昊昊: 項目的模塊構成依賴圖,咱們要肯定沒有用到的模塊,那就要先找出用到的模塊,以後把它們過濾掉。用到的模塊要用幾個入口模塊開始作 dfs,遍歷不一樣的模塊有不一樣的提取 require path 的方式,提取出來之後還要對 path 進行 resolve,獲得真實路徑,而後遞歸進行子模塊的處理。這樣遍歷完一遍就能肯定用到了哪些。同時還要處理循環引用問題,由於畢竟模塊是一個圖,進行 dfs 會有環在。

: 對的,棒棒的。

相關文章
相關標籤/搜索