阅读 171

查找项目中的无用模块

任何经历数次迭代的项目,必定会留下大量无用代码。

核心思路

  1. 从入口文件(对应webpack配置中的entry)开始,递归查找依赖
  2. 遍历项目目录(通常是src目录)下所有的文件
  3. 对两个数组作diff,即可得到没有被使用的模块文件列表

查找依赖

在JavaScript中,模块依赖有AMD、CMD、CommonJS、ES6这四种规范,但是在webpack体系中,常用的只有CommonJS、ES6这两种规范。所以,我们只需要考虑查找JavaScript模块中,使用CommonJS和ES6引用的依赖模块即可。

在CommonJS规范中,我们使用require函数来引用其它模块:

const a = require('/path/to/a');
复制代码

在ES6规范中,我们使用import关键字来声明模块引用:

import b from '/path/to/b';
复制代码

所以,我们要做的,就是搜索代码中的/path/to/a/path/to/b。可以使用正则表达式来匹配:

const exp = /import.+?from\s*['"](.+?)['"]|require\s*\(\s*['"](.+?)['"]\s*\)/g;

const requests = [];
while (exp.exec(code)) {
  requests.push(RegExp.$1 || RegExp.$2);
}

console.log(requests);
复制代码

正则表达式是一种轻量简洁的解决方法,但是要实现更精准的匹配(想想注释或者字符串中出现了表达式能够匹配的内容),可能就不是那么好用了。因此,我们选择使用另外一种方法——基于语法树分析。

遍历语法树

现在,我们已经有很成熟的工具可以生成JavaScript的语法树了。代码压缩,代码检测,Babel转译等等工具,都离不开语法树生成工具。这里,我们使用acorn来生成语法树,使用acorn-walk来遍历语法树。

const fs = require('fs');
const acorn = require('acorn');
const walk = require('acorn-walk');

const content = fs.readFileSync(file, 'utf8');
const tree = acorn.parse(content, {
  sourceType: 'module',
});

walk.simple(tree, {
  CallExpression: (node) => {
    const { callee: { name }, arguments: [ { value: request } ] } = node;
    if (name === 'require') {
      // request: /path/to/a
    }
  },
  ImportDeclaration: (node) => {
    const { source: { value: request } } = node;
    // request: /path/to/b
  },
});
复制代码

解析表达式

通常我们引用模块时,都是使用的相对路径,基于当前模块的__dirname或者node_modules。对于webpack而言,还允许自定义路径解析别名alias。为了找到这些模块,我们需要将模块路径表达式,解析成真实的文件绝对路径。

这里,我们参考nodejs的模块查找算法,并增加别名支持,以下是伪代码。

> 点此查看完整代码

resolve(X) from module at path Y
1. If X is a core module,
    a. return X
    b. STOP
2. If X begins with '/'
    a. set Y to be the filesystem root
3. If X begins with './' or '/' or '../'
    a. RESOLVE_AS_FILE(Y + X, EXTENSIONS)
    b. RESOLVE_AS_DIRECTORY(Y + X, EXTENSIONS)
4. RESOLVE_ALIAS(X, ALIAS)
5. RESOLVE_NODE_MODULES(X, dirname(Y))
6. THROW "not found"

RESOLVE_AS_FILE(X, EXTENSIONS)
1. If X is a file.  STOP
2. let I = count of EXTENSIONS - 1
3. while I >= 0,
    a. If `${X}{EXTENSIONS[I]}` is a file.  STOP
    b. let I = I - 1

RESOLVE_AS_DIRECTORY(X, EXTENSIONS)
1. If X/package.json is a file,
    a. Parse X/package.json, and look for "main" field.
    b. If "main" is a falsy value, GOTO 2.
    c. let M = X + (json main field)
    d. RESOLVE_AS_FILE(M, EXTENSIONS)
    e. RESOLVE_INDEX(M, EXTENSIONS)
2. RESOLVE_INDEX(X, EXTENSIONS)

RESOLVE_INDEX(X, EXTENSIONS)
1. let I = count of EXTENSIONS - 1
2. while I >= 0,
    a. If `${X}/index{EXTENSIONS[I]}` is a file.  STOP
    b. let I = I - 1

RESOLVE_ALIAS(X, ALIAS, EXTENSIONS)
1. let PATHS = ALIAS_PATHS(X, ALIAS)
2. for each PATH in PATHS:
    a. RESOLVE_AS_FILE(DIR/X, EXTENSIONS)
    b. RESOLVE_AS_DIRECTORY(DIR/X, EXTENSIONS)

ALIAS_PATHS(X, START, ALIAS)
1. let PATHS = []
2. for each KEY in ALIAS:
    a. let VALUE = ALIAS[KEY]
    b. if not X starts with KEY CONTINUE
    c. let PATH = X replace KEY with VALUE
    d. PATHS = PATHS + PATH
3. return PATHS

RESOLVE_NODE_MODULES(X, START, EXTENSIONS)
1. let DIRS = NODE_MODULES_PATHS(START)
2. for each DIR in DIRS:
    a. RESOLVE_AS_FILE(DIR/X, EXTENSIONS)
    b. RESOLVE_AS_DIRECTORY(DIR/X, EXTENSIONS)

NODE_MODULES_PATHS(START)
1. let PARTS = path split(START)
2. let I = count of PARTS - 1
3. let DIRS = [GLOBAL_FOLDERS]
4. while I >= 0,
    a. if PARTS[I] = "node_modules" CONTINUE
    b. DIR = path join(PARTS[0 .. I] + "node_modules")
    c. DIRS = DIRS + DIR
    d. let I = I - 1
5. return DIRS
复制代码

遍历目录

遍历目录属于常规操作了,使用fspath这两个模块即可完成。

const fs = require('fs');
const path = require('path');

function readFileList(folder, filter, files = []) {
  if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) return files;

  fs.readdirSync(folder).forEach((file) => {
    const fullPath = path.join(folder, file);
    const stat = fs.statSync(fullPath);
    if (stat.isFile()) {
      if (typeof filter === 'function') {
        if (!filter(fullPath)) return;
      }
      files.push(fullPath);
    } else if (stat.isDirectory()) {
      readFileList(fullPath, filter, files);
    }
  });

  return files;
}
复制代码

数组diff

最简单的方式,使用Array.prototype.indexOf,即可对两个数组进行diff,并最终得到无用的模块了。

const modules = ['/path/to/a', '/path/to/b'];
const scripts = ['/path/to/a', '/path/to/b', '/path/to/c', '/path/to/d'];

const useless = scripts.filter(v => !~modules.indexOf(v));

console.log(useless);
// prints: [ '/path/to/c', '/path/to/d' ]
复制代码

参考资料