Babel和Postcss查找项目中无用文件

背景

因为业务需求迭代,项目中存在废弃的.vue、.js、.css等文件,通过Babel和Postcss查找出项目中未被引用的文件,作为精简项目代码文件的参考。

思路分析

从入口文件开始,递归分析文件之间的引用关系,项目中未被遍历的文件,为未被使用的文件。文件引用关系的递归分即为图的遍历,因一个项目可能存在多个入口文件,即为多图的遍历。



vue文件需要先解析出文件中的js代码和css代码,分别分析其中的文件引用关系
js的引用方式主要有import和require
css的引用主要有@import和src
文件遍历流程如下


2.png
  1. 文件类型分析:补全文件路径,分析文件类型
    路径补全



    代码示例


    4.png

    文件类型(使用path中extname获取路径文件的拓展名)
    5.png

    代码示例
    6.png
  2. Vue文件使用VueTempiler中parseComponent解析,分别提取出script中的js代码和style中的css代码
    vue文件分析


    7.png

    代码示例


    8.png

    compiler.parseComponent数据结构
    9.png

    对于一个vue文件,可以多个style,所以解析出styles字段是个Array
  3. js文件或js代码使用Babel的parser转为ast,并使用Babel traverse遍历ast, 分别获取ImportDeclaration和CallExpression中的import和require路径信息
    js分析


    10.png

    代码示例


    11.png

    ImportDeclaration获取import的路径关系,CallExpression获取require和回调中import的路径关系
    回调中的import
    12.png

    ast数据结构



    回调中的import路径信息存储在callee中arguments中
  4. css文件或css代码使用Postcss的parse转为ast,分别获取walkAtRules和walkDecls路径信息
    css分析


    14.png

    代码示例


    15.png

    Postcss生成ast的类型和遍历方法
    16.png

    使用walkAtRules获取@import中路径关系,walkDecls获取url()中的路径关系
  5. 判断获取的路径信息是否已遍历,已遍历则跳过,未遍历则保存

17.png
完整代码
/**
 * 目标: 寻找项目中没有用到的模块
 * 从入口模块开始,分析模块间依赖,直到遍历结束(图遍历)
 */

// 常量定义
const MODULE_TYPES = {
    JS: 1 << 0, // 1
    CSS: 1 << 1, // 2
    JSON: 1 << 2, // 4
    VUE: 1 << 3, // 8
    IMAGE: 1 << 4, // 16
}
const JS_EXTS = ['.js', '.jsx', '.ts', '.tsx']
const JSON_EXTS = ['json']
const CSS_EXTS = ['.css', '.scss', '.less']
const VUE_EXTS = ['.vue']
const IMAGE_EXTS = ['.svg', '.png', '.jpeg', '.jpg']
const fs = require('fs')
const fastGlob = require('fast-glob');
const { join, normalize, resolve, extname, dirname } = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const chalk = require('chalk');
const postcss = require('postcss');
const postcssLess = require('postcss-less');
const postcssScss = require('postcss-scss');
const compiler = require('vue-template-compiler')
let requirePathResolver = () => {}
// 1. 模块遍历-根据模块路径,补全路径
function getModuleType(modulePath) {
    const moduleExt = extname(modulePath) // path.extname 返回路径文件的拓展名
    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
    }
    else if (VUE_EXTS.some(ext => ext === moduleExt)) {
        return MODULE_TYPES.VUE
    }
    else if (IMAGE_EXTS.some(ext => ext === moduleExt)) {
        return MODULE_TYPES.IMAGE
    }
}
function traverseModule(curModulePath, callback) {
    
    curModulePath = completeModulePath(curModulePath) // 补全路径

    const moduleType = getModuleType(curModulePath)
    
    if (moduleType & MODULE_TYPES.JS) {
        traverseJsContent(curModulePath, 'JsModule',  callback)
    }
    else if (moduleType & MODULE_TYPES.CSS) {
        traverseCssContent(curModulePath, 'CssModule', callback)
    }
    else if (moduleType & MODULE_TYPES.VUE){
        const source = fs.readFileSync(curModulePath, { encoding: 'utf-8' })
        const content = compiler.parseComponent(source)
        content.script && traverseJsContent(curModulePath, content.script.content, callback)
        if ((content.styles || []).length > 0) {
            content.styles.forEach(item => {
                traverseCssContent(curModulePath, item, callback)
            })
        }
    }
    else if (moduleType & MODULE_TYPES.IMAGE) {
        // 得到src引入的图像
        // const res = source.match(/[\.@]+\/[a-zA-Z\/]+\.(png|svg|jpeg|jpg)/ug)
    }
}

// 2. JS模块遍历-分析import和require依赖 
function resolveBabelSyntaxtPlugins(modulePath) {
    const plugins = [];
    if (['.tsx', '.jsx'].some(ext => modulePath.endsWith(ext))) {
        plugins.push('jsx');
    }
    if (['.ts', '.tsx'].some(ext => modulePath.endsWith(ext))) {
        plugins.push('typescript');
    }
    return plugins;
}
function traverseJsContent(curModulePath, moduleFileContent, callback) {
    try {
        moduleFileContent === 'JsModule' && 
        (moduleFileContent = fs.readFileSync(curModulePath, { encoding: 'utf-8' }))
        const ast = parser.parse(moduleFileContent, {
            sourceType: 'unambiguous',
            plugins: resolveBabelSyntaxtPlugins(curModulePath)
        })
        traverse(ast, {
            ImportDeclaration(path) {
                const url = path.get('source.value').node
                const subModulePath = moduleResolver(curModulePath, url, 'js') // 获取子模块路径
                if (!subModulePath) return
                callback && callback(subModulePath)
                traverseModule(subModulePath, callback)
            },
            CallExpression(path) {
                if (path.get('callee').toString() === 'require') {
                    const url = path.get('arguments.0').toString().replace(/['"]/g, '')
                    const subModulePath = moduleResolver(curModulePath, url, 'js')
                    if (!subModulePath) return
                    callback && callback(subModulePath)
                    traverseModule(subModulePath, callback)
                }
                if (path.get('callee').toString() === 'import') {
                    const url = path.get('arguments.0').toString().replace(/['"\n\r]/g, '')
                    const subModulePath = moduleResolver(curModulePath, url, 'js')
                    if (!subModulePath) return
                    callback && callback(subModulePath)
                    traverseModule(subModulePath, callback)
                }
            }
        })
    }
    catch(e) {
        console.log(curModulePath, '解析JS内容失败!')
    }  
}

// 3. CSS模块遍历-分析@import和url(),使用postcss
function resolvePostcssSyntaxtPlugin(modulePath) {
    if (modulePath.endsWith('.scss')) {
        return 'scss'
        // return postcssScss;
    }
    if (modulePath.endsWith('.less')) {
        return 'less'
        // return postcssLess
    }
    return 'css'
}
function traverseCssContent(curModulePath, cssContent, callback) {
    try {
        let moduleFileContent = ''
        let type = 'css'
        let ast = ''
        if (cssContent == 'CssModule') {
            moduleFileContent = fs.readFileSync(curModulePath, { encoding: 'utf-8' })
            type = resolvePostcssSyntaxtPlugin(curModulePath)
        }
        else {
            if (cssContent.src) {
                const subModulePath = moduleResolver(curModulePath, cssContent.src, 'css')
                if (!subModulePath) return
                callback && callback(subModulePath)
                traverseModule(subModulePath, callback)
            }
            moduleFileContent = cssContent.content
            type = cssContent.lang || 'css'
        }
        if (!moduleFileContent) { // 内容为空
            return
        }
        switch (type) {
            case 'css':
                ast = postcss.parse(moduleFileContent)
                break
            case 'scss':
                ast = postcssScss.parse(moduleFileContent)
                break
            case 'less':
                ast = postcssLess.parse(moduleFileContent)
                break
        }
        ast.walkAtRules('import', rule => {
            let target = rule.params.replace(/['"]/g, '')
            !target.startsWith('./') && (target = './' + target)
            const subModulePath = moduleResolver(curModulePath, target, 'css')
            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, 'css')
                if (!subModulePath) return
                callback && callback(subModulePath)
                traverseModule(subModulePath, callback)
            }
        })
    }
   catch(e) {
    console.log( '解析CSS内容失败!')
   }
}
// 4. 模块路径处理-路径不全,跳过已处理的模块
const visitedModules = new Set() // 用于存储已遍历的模块

function moduleResolver(curModulePath, requirePath, type = 'js') {
    if (typeof requirePathResolver === 'function') { // 可自定义的路径解析逻辑
        const res = requirePathResolver(dirname(curModulePath), requirePath)
        typeof res === 'string' && (requirePath = res)
    }
    /* requirePath 路径矫正 */
    requirePath = requirePath.replace(/\/\*[.\s\S]*?\*\//g, '').trim() // 矫正动态import url存在注释问题,并删除字符串头尾空格
    /*
        requirePath 正确性校验
        js: import、require项目文件路径以'./'或'@/'或'../'开头
        css: @import项目文件以'~'或'./'或'../'或'@/'或''开头
        src: 使用项目中图像以'./'或'@/'或'../'或''开头
    */
    switch(type) {
        case 'js':
            if (!/^(\.\/|@\/|\.\.\/)/.test(requirePath)) return ''
            break
        case 'css':
            break
        case 'src':
            break
    }
    if (requirePath.includes('@jzt') || requirePath.includes('@jadfe')) return ''
    if (curModulePath.indexOf('node_modules') != -1 || requirePath.indexOf('node_modules') != -1) return '' // 过滤第三方模块
    if (requirePath.includes('common2.0') || curModulePath.includes('common2.0')) {
        return ''
    } // 过滤公共组件
    if (requirePath.includes('jadfe')) return '' // 过滤jadpc
    if (requirePath.includes('localFonts')) return '' // 过滤字体库
    if (requirePath.includes('jzt')) return '' // 过滤业务组件
    if (requirePath.includes('@')) {
        let temp1 = curModulePath.replace(/src.*$/g, '')
        let temp2 = requirePath.replace('@', '')
        requirePath = temp1 + 'src' + temp2
    }
    else {
        requirePath = resolve(dirname(curModulePath), requirePath)
    }
    requirePath = completeModulePath(requirePath)
    if (visitedModules.has(requirePath)) return ''
    else visitedModules.add(requirePath)
    return requirePath
}
// 5. 路径不全-补全文件后缀
function isDirectory(filePath) {
    try {
        return fs.statSync(filePath).isDirectory()
    }catch(e) {}
    return false;
}
function completeModulePath(modulePath) {
    const EXTS = [...JSON_EXTS, ...JS_EXTS, ...CSS_EXTS, ...VUE_EXTS, ...IMAGE_EXTS]
    if (modulePath.match(/\.[a-zA-Z]+$/) && !modulePath.endsWith('.sl') ) return modulePath
    function tryCompletePath (resolvePath) {
        for (let i = 0; i < EXTS.length; i++) { // 依次尝试文件后缀名,直到fs文件系统存在相应文件
            let tryPath = resolvePath(EXTS[i])
            if (fs.existsSync(tryPath)) return tryPath
        }
    }
    function reportModuleNotFoundError (modulePath) {
        console.log(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
}

// 6. 保存未使用模块的路径
const defaultOptions = {
    cwd: '',
    entries: [],
    includes: ['**/*', '!node_modules'],
    resolveRequirePath: () => {}
}
function setRequirePathResolver(resolver) {
    requirePathResolver = resolver;
};
function findUnusedModule (options) {
    let {
        cwd,
        entries,
        includes,
        resolveRequirePath
    } = Object.assign(defaultOptions, options)
    includes = includes.map(path => (cwd ? `${cwd}/${path}` : path))
    let allFiles = fastGlob.sync(includes).map(item => normalize(item))
    allFiles = allFiles.filter(item => {
        const EXTS = [...JS_EXTS, ...JSON_EXTS, ...CSS_EXTS, ...VUE_EXTS]
        const ext = (item.match(/\.[a-zA-Z]+$/g) || [])[0]
        return !item.includes('common2.0') && EXTS.includes(ext)
    })
    const entryModules = []
    const useModules = []
    setRequirePathResolver(resolveRequirePath)
    entries.forEach(entry => {
        const entryPath = resolve(cwd, entry)
        entryModules.push(entryPath)
        traverseModule(entryPath, (modulePath) => {useModules.push(modulePath)})
    })
    const unusedModules = allFiles.filter(filePath => {
        const resolvedFilePath = resolve(filePath)
        return !entryModules.includes(resolvedFilePath) && !useModules.includes(resolvedFilePath)
    })
    return {
        all: allFiles,
        used: useModules,
        unused: unusedModules
    }
}
module.exports = findUnusedModule


局限
  1. 动态import文件路径无法获取 => 根据接口字段动态加载的组件无法获取
  2. 项目中图像png、svg等文件的引用关系未处理 => 可拓展
  3. 查找过程中无法区分出路由import但未使用的文件路径
参考

基于 babel 和 postcss 查找项目中的无用模块

vue-loader&vue-template-compiler详解

Babel Ast详解

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,812评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,626评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,144评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,052评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,925评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,035评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,461评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,150评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,413评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,501评论 2 307
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,277评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,159评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,528评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,868评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,143评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,407评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,615评论 2 335

推荐阅读更多精彩内容