任何经历数次迭代的项目,必定会留下大量无用代码。
核心思路
- 从入口文件(对应webpack配置中的entry)开始,递归查找依赖
- 遍历项目目录(通常是src目录)下所有的文件
- 对两个数组作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
遍历目录
遍历目录属于常规操作了,使用fs和path这两个模块即可完成。
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' ]