1. 插件概述
本插件主要满足以下需求:
-
检查项目中未被引入的文件
-
检测项目中未被使用的代码块/变量名等
-
将检测结果输出到指定目录下deadcode.html
2. 功能范围
2.1 本方案实现的功能
检测vue项目中指定目录下的js,ts和vue文件,完成冗余代码的检测,输出删改建议。
2.2 本方案不实现的功能
暂未支持小程序,json和css类文件的检测。
3. 插件应用设计
首先插件必须要有vite的支持这是因为插件的运行时依赖于vite打包过程的,将插件打包后发布到npm上 然后在vite.config.js中import进来并添加这个plugin,最后配置好package.json/script 通过npm run命令来执行。关于插件的参数,需要让开发者指定一个检测目录定为inputDir,另外还需要指定一个检测结果输出目录。结合上述的内容,插件应用应该完成如下配置,配置完成后执行npm run vite:deadcode即可。
// vite.config.js
import { defineConfig } from 'vite'
import deadcodePlugins from 'vite-plugin-deadcode'
export default defineConfig({
plugins: [
deadcodePlugins({
inputDir: 'src', // 默认检测目录
outDir: 'dist' // deadcode默认输出目录
})
]
})
// package.json
{
"scripts": {
"vite:deadcode": "DEAD_CODE=true vite build"
}
}
本文后续的说明都以此设置为例。
4. 实现思路
-
第一个需求点,提前递归搜索出所有指定目录下的js/ts/vue文件,然后在解析graph.moudles的过程中把所有的module给筛选掉。这时候会发现有一些module虽然被引用了,但并不存在于graph.modules中,因此需要去遍历modules分析ast找出这些文件,同时把这些漏掉的文件也给筛选掉才能得到准确的结果。
-
检测处理js和ts文件,将其转为ast遍历,然后找出引入并使用过的变量和其对应的引入路径添加到importMaps中,再把未使用的代码块找出来写入unusedCodeMap中,最后把export出去的变量都收集起来放到exportNames中。在这个过程中处理那些漏掉的文件,先将他们的文件路径都存到一个fileQueue里。
-
遍历完成后,每个js/ts文件都会生成对应的fileVarObj={importMaps,unusedCodeMap,exportNames},先将这些fileVarObj做value,文件目录做key,保存成对象。
-
关于vue文件的解析需要有新的思路去处理,项目中同时存在vue3和vue2的代码,因此需要兼容他们也是有一定难度的。简单来说就是先把模板转成ast并找到其中所有的变量,形成一个Set结构,然后把js/ts转ast,分析setup,data,methods,computed等内容,找到所有未在js且未在template中使用的变量,最后也形成了一个fileVarObj。
-
此时可以开始处理那个遗漏文件列表fileQueue了,也就是将它们进行第2,第3和第4步处理,形成对应的fileVarObj保存起来。
-
遍历那些fileVarObj,将所有importMaps中的变量从其引入路径的exportNames中删除,最后留下exportNames中的变量则是无用的导出变量。
-
其中unusedCodeMap将会作为冗余代码被收集起来,而exportNames将会作为无用的导出变量被收集起来。
-
第三个需求点,将之前保存起来的filelist,unusedCodeMap和exportNames写入指定目录下的deadcode.html中。
5. 流程图
根据以上8个步骤,可以得到大致的流程图:
6. 技术难点
首先我们需要读到源代码,得到源代码后利用@babel/parser将代码转成ast,然后再使用@babel/traverse对ast进行遍历,以此为基础才能实现以下feature。
6.1 如何收集exportNames
/**
* 1. export const a = ''
* 2. export const { a, b } = obj
* 3. export function c() {}
* 4. export { a: 1, b: 2 }
* 5. export { abc, acd as bbb, default as iii, default } from ''
* 6. export default xxx
*
*/
const exportNames = new Set()
traverse(astree, {
ExportNamedDeclaration(path) {
if (path.node.declaration?.type === "VariableDeclaration") {
path.node.declaration.declarations.forEach((declaration) => {
if (declaration?.id?.name) {
// 处理1语法的导出情况
exportNames.add(declaration.id.name);
} else if (declaration?.id?.properties?.length) {
// 处理2语法的导出情况
declaration.id.properties.forEach(p => {
exportNames.add(p?.key?.name)
})
}
});
} else if (path.node.declaration?.type === "FunctionDeclaration") {
// 处理3语法的导出情况
exportNames.add(path.node.declaration.id.name);
}
},
ExportSpecifier(path) {
// 处理4,5语法的导出情况
exportNames.add(path.node.exported.name)
},
ExportDefaultDeclaration() {
// 处理6语法的导出情况
exportNames.add('default')
},
}
});
6.2 如何收集unusedCodeMap
对于js和ts,可以借助scope的相关api来实现,path.scope.getAllBindings()可以获取到所有的变量包括引入的和声明的。变量是否被下文引用,可以用referenced属性来判断,将未被引用的变量收集起来,并分别记录他们的type(module,const,let等)和相关源代码。
const unusedCodeMap = {}
Program: function (path) {
const binding = path.scope.getAllBindings()
for (let key in binding) {
if (!binding[key].referenced) unusedCodeMap[key] = {
type: binding[key].kind,
text: originalCode.substring(binding[key].identifier.start, binding[key].identifier.end)
};
}
},
6.3 如何收集importMaps
/**
* 1.import * as fff from ''
* 2.import ooo from ''
* 3.import { aaa as ddd, default as ccc, ttt } from ''
* 4.const abc = import('xxx1')
* 5.abc = import('xxx2')
* 6.import * from 'xxx'
* 7.export { abc, acd as bbb, default as iii, default } from 'xxx'
*/
const importMaps = {}
ImportDeclaration: function(path) {
// 适用于1,2,3这三种语法
const url = resolve(path.node.source.value)
const d = path.node.specifiers.find(v => v.type=='ImportNamespaceSpecifier')
if (d) {
importMaps[url] = t?.local?.name || 'default'
} else {
importMaps[url].push(...path.node.specifiers.map(v => {
return {
importName: v?.imported?.name || 'default',
localName: v.local.name || 'default'
}
}))
}
},
VariableDeclarator: function(path) {
// 适用于4这种语法
if (path?.node?.init?.callee?.type === 'Import') {
const url = resolve(path?.node?.init?.callee?.arguments[0]?.value)
importMaps[id].push({
importName: 'default',
localName: path.node.id.name
})
}
},
AssignmentExpression: function(path) {
// 适用于5这种语法
if (path?.node?.right?.callee?.type === 'Import') {
const url = resolve(path?.node?.right?.callee?.arguments[0]?.value)
importMaps[id].push({
importName: 'default',
localName: path.node.left.name
})
}
},
ExportAllDeclaration(path) {
// 适用于6这种语法
if (path?.node?.source?.value) {
const url = resolve(path?.node?.source?.value)
importMaps[url] = 'default'
}
},
ExportNamedDeclaration(path) {
// 适用于7这种语法
if (path.node.source) {
const url = resolve(path.node.source.value)
importMaps[id].push(...path.node.specifiers.map(v => {
return {
importName: v?.local?.name || 'default',
localName: v.exported.name
}
}))
}
},
以上代码是把所有引入的资源以及对应的变量的都收集起来了,通过unusedCodeMap,我们可以把引用过的资源给过滤掉,这样就剩下了未被引用的importNames。
6.4 如何处理vue文件(代码太多,简单讲思路)
1. 首先需要用@vue/compiler-dom中的parse方法将template分离出来;
2. 然后经过@vue/compiler-dom中compile方法转成js的ast,把其中components下所有的组件标签都收集起来;
3. 然后把compile出来的js源码用@babel/parse转为相应的格式,再用@babel/traverse进行遍历,获得其中应用到的变量,与之前的组件标签一起收集到templateVars中;
4. @vue/compiler-dom中的parse方法同样也可以分离出来js,此时又有两种情况
- <script setup>标签,可以应用之前处理js/ts的方式来分离无用代码,但要注意收集冗余代码时都要经过templateVars的筛选。
- 如果不是setup的script标签就比较困难了,先把这js转为ast,然后遍历ast找出
['props','data','setup','computed','methods','inject']中声明以及外部引入的变量和方法,收集到vueData中,这个过程当然也需要经过templateVars的筛选。然后再次遍历ast,看下整个vue对象中有没有调用过vueData中存在的变量,若有就在vueData中将其删除,遍历结束后就获得了unusedCodeMap。
5. 最后也通过unusedCodeMap,我们可以把引用过的资源给过滤掉,这样就剩下了未被引用的importNames,如此一来unusedCodeMap,exportNames和importMaps都凑齐了。
7. 关于插件
插件源码:https://github.com/Tyrion1024/vite-plugin-deadcode
npmjs:https://www.npmjs.com/package/vite-plugin-deadcode
目前已知的问题:
1. 目前还无法分析出$ref,$parent等方法调用组件内部方法。
2. 对export const a = '123'语法的解析还不够精确。