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

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. 实现思路

  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中。

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'语法的解析还不够精确。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值