冗余代码检测插件 vite-plugin-deadcode

1,609 阅读6分钟

1. 插件功能描述

  • 检查项目中未被引入的文件
  • 检测项目中未被使用的代码块/变量名等
  • 将检测结果输出到指定目录下deadcode.html
  • 针对的文件为js/ts/vue

2. 插件应用设计

首先插件必须要有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"
  }
}

3. 实现思路

  1. 第一个需求点,提前递归搜索出所有指定目录下的js/ts/vue文件,然后在解析graph.moudles的过程中把所有的module给筛选掉。这时候会发现有一些module虽然被引用了,但并不存在于graph.modules中,因此需要去遍历modules分析ast找出这些文件,同时把这些漏掉的文件也给筛选掉才能得到准确的结果。

  2. 检测处理js和ts文件,将其转为ast遍历,然后找出引入并使用过的变量和其对应的引入路径添加到importMaps中,再把未使用的代码块找出来写入unusedCodeMap中,最后把export出去的变量都收集起来放到exportNames中。在这个过程中处理那些漏掉的文件,先将他们的文件路径都存到一个fileQueue里。

  3. 遍历完成后,每个js/ts文件都会生成对应的fileVarObj={importMaps,unusedCodeMap,exportNames},先将这些fileVarObj做value,文件目录做key,保存成对象。

  4. 关于vue文件的解析需要有新的思路去处理,项目中同时存在vue3和vue2的代码,因此需要兼容他们也是有一定难度的。简单来说就是先把模板转成ast并找到其中所有的变量,形成一个Set结构,然后把js/ts转ast,分析setup,data,methods,computed等内容,找到所有未在js且未在template中使用的变量,最后也形成了一个fileVarObj。

  5. 此时可以开始处理那个遗漏文件列表fileQueue了,也就是将它们进行第2,第3和第4步处理,形成对应的fileVarObj保存起来。

  6. 遍历那些fileVarObj,将所有importMaps中的变量从其引入路径的exportNames中删除,最后留下exportNames中的变量则是无用的导出变量。

  7. 其中unusedCodeMap将会作为冗余代码被收集起来,而exportNames将会作为无用的导出变量被收集起来。

  8. 第三个需求点,将之前保存起来的filelist,unusedCodeMap和exportNames写入指定目录下的deadcode.html中。

4. 流程图

根据以上8个步骤,可以得到大致的流程图 image.png

5. 技术难点

首先我们需要读到源代码,得到源代码后利用@babel/parser将代码转成ast,然后再使用@babel/traverse对ast进行遍历,以此为基础才能实现以下feature。

  • 如何收集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')
    },
   }
  });
  • 如何收集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)
        };
      }
    },
  • 如何收集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。

  • 如何处理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。

6. 其他

插件源码:github.com/Tyrion1024/… npmjs:www.npmjs.com/package/vit…

目前还存在的问题:

  1. 目前还无法在vue文件中 分析出$ref,$parent等方法调用组件内部方法。
  2. 对export const a = '123'语法的解析还不够精确。 如果a没有被使用到,也只能输出“a是无用export”。
  3. deadcode.html 样式实在太丑...