背景
因为业务需求迭代,项目中存在废弃的.vue、.js、.css等文件,通过Babel和Postcss查找出项目中未被引用的文件,作为精简项目代码文件的参考。
思路分析
从入口文件开始,递归分析文件之间的引用关系,项目中未被遍历的文件,为未被使用的文件。文件引用关系的递归分即为图的遍历,因一个项目可能存在多个入口文件,即为多图的遍历。
vue文件需要先解析出文件中的js代码和css代码,分别分析其中的文件引用关系
js的引用方式主要有import和require
css的引用主要有@import和src
文件遍历流程如下
-
文件类型分析:补全文件路径,分析文件类型
路径补全
代码示例
文件类型(使用path中extname获取路径文件的拓展名)
代码示例
-
Vue文件使用VueTempiler中parseComponent解析,分别提取出script中的js代码和style中的css代码
vue文件分析
代码示例
compiler.parseComponent数据结构
对于一个vue文件,可以多个style,所以解析出styles字段是个Array -
js文件或js代码使用Babel的parser转为ast,并使用Babel traverse遍历ast, 分别获取ImportDeclaration和CallExpression中的import和require路径信息
js分析
代码示例
ImportDeclaration获取import的路径关系,CallExpression获取require和回调中import的路径关系
回调中的import
ast数据结构
回调中的import路径信息存储在callee中arguments中 -
css文件或css代码使用Postcss的parse转为ast,分别获取walkAtRules和walkDecls路径信息
css分析
代码示例
Postcss生成ast的类型和遍历方法
使用walkAtRules获取@import中路径关系,walkDecls获取url()中的路径关系 判断获取的路径信息是否已遍历,已遍历则跳过,未遍历则保存
完整代码
/**
* 目标: 寻找项目中没有用到的模块
* 从入口模块开始,分析模块间依赖,直到遍历结束(图遍历)
*/
// 常量定义
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
局限
- 动态import文件路径无法获取 => 根据接口字段动态加载的组件无法获取
- 项目中图像png、svg等文件的引用关系未处理 => 可拓展
- 查找过程中无法区分出路由import但未使用的文件路径