用 Node 写一个批量删除 node_modules 的工具

今天我用 npm 安装包的时候,报错说磁盘空间不够用了:

我想我也没有下什么很大的东西啊,大概是我项目比较多,node_modules 比较多。

而 node_modules 一般是比较大的。

比如我一个 nest 项目的 node_modules 就有 275 M 呢:

当然,如果你用 pnpm 安装包,可能没这个问题

因为 pnpm 是把依赖安装到全局 store,然后用的硬链接的方式从全局 store 连接到当前项目的 node_modules/.pnpm 下

node_modules 下的依赖再从这个 .pnpm 目录软链接过去。

所以同样的依赖只会全局安装一次,并且存在全局 store,根本不用担心磁盘空间占用问题。

文档里也提到了这个优势:

但问题是我很多项目用的是 yarn 和 npm,依赖保存在每个 node_modules 下,所以占用空间会很大。

所以我就想着写个自动化工具找到这些 node_modules 并删除它。

先来分析下思路:

要找到 node_modules 的目录,只要递归遍历目录和它的子目录,判断是否是 node_modules,如果是的话,就记录下来就好了。之后批量删除。

思路很清晰,但有一个要注意的点,就是软链接文件。

假设我有一个文件是 src/index.ts

想读取它的内容就用 fs.readFileSync

那我又基于它创建了一个 test/index.ts 的软链接文件呢?怎么读取?

ln -s src/index.ts ./test/index.ts

这时候如果你还是用 fs.readFileSync 就会报错了:

说是文件找不到。

软链接文件的读取要用 fs.readlinkSync

可以看到,读取出的是链接到的原文件的地址。

这样只要再 fs.readFileSync 就能读到原始内容了。

那如何判断一个文件是否是软链接文件呢?

可以通过 fs.lstatSync 拿到文件的信息,然后调用 isSymbolicLink 判断是否是符号链接,也就是软链接。

注意,这里是 lstat 不是 stat,如果用 stat 方法,依然会有文件不存在的问题。

思路理清了,我们来写下代码。

创建个项目:

mkdir node_modules_killer cd node_modules_killer npm init -y npm install typescript --save-dev

创建项目目录、package.json 、安装 typescript

然后创建这样一个 tsconfig.json

{ "compilerOptions": { "lib": ["ES2015"], "types": ["node"], "target": "es2016", "outDir": "./dist", "module": "commonjs", "sourceMap": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*.ts"] }

lib 是引入 ts 内置的类型,这里引入 es2015 的 api 的类型。

types 是引入第三方类型,这里引入 node api 的类型。

并且安装 @types/node

npm install @types/node --save-dev

指定 target 和 module 也就是编译后的语言的版本和模块类型。

指定 outDir,也就是输出目录。

指定 include 包含编译的文件。

生成 sourcemap,待会我们调试用。

我们加一个 src/index.ts 试一下:

现在直接这样引入 node 模块会报错,要这样才可以:

如果你还是想用上面的方式,可以加一个 ts 编译选项:

这样就好了:

因为 node 的 内置模块是 commonjs 的,默认需要 import * as xxx from,这个选项会生成一些额外的代码来让模块可以 import 引入:

然后我们先加一段代码用来测试:

import os from 'os'; console.log(os.homedir());

在 package.json 里添加 dev 的 scripts

执行 npm run dev

可以看到 dist 下有了编译后的文件和 sourcemap。

node 跑一下:

没啥问题。

然后再来试下调试:

在 debug 面板点击 create a launch.json file

创建一个调试配置文件:

新建调试 node 的配置:

创建这样一个调试配置:

在代码里打个断点:

然后点击调试启动:

代码就会在断点处断住:

可以单步调试。

左边可以看到作用域,调用栈。

编译和调试都搞定了,我们来写下具体的逻辑:

import fs from 'fs/promises'; import os from 'os'; import path from 'path'; const homedir = os.homedir(); const foundDirs = []; async function searchDir(dirPath: string, searchName: string) { } async function main() { await searchDir(homedir, 'node_modules'); await fs.writeFile('./found', foundDirs.join(os.EOL)); console.log('done'); } main();

框架大概是这样的:

从 home 目录开始递归查找 node_modules,然后把查到的路径放到 foundDirs 里,最后按照每行一个路径写入文件。

这里用的 fs/promises 是 promise 版本的 fs api。

os.EOL 是 end of line,也就是换行符,不同操作系统的换行符不同,所以从 os 模块拿。

而 searchDir 的逻辑如下:

async function searchDir(dirPath: string, searchName: string) { const children = await fs.readdir(dirPath); for(let i = 0; i< children.length; i++) { const child = children[i]; const childPath = path.join(dirPath, child); const res = await fs.lstat(childPath); if(await res.isSymbolicLink()) { break; } if(res.isDirectory() && !child.startsWith('.')) { if(child === 'node_modules') { console.log(childPath) foundDirs.push(childPath); } else { await searchDir(childPath, searchName); } } } }

用 readdir 读取目录的内容,依次判断每个文件是否软链接文件。

如果是软链接,直接 return,不然读取它会报错。

再判断下是否是目录,如果是 node_modules 目录就把路径放入 foundDirs,否则递归查找。

这里排除掉 . 开头的目录,这些一般是隐藏目录,不需要查找。

跑一下:

确实查找到了一些 node_modules 目录。

但是一些目录提示没有权限。

这种目录直接跳过就好了,没有权限的目录一般都不是项目目录。

也就是这样:

读取目录的时候没有权限直接跳过。

这样,就会打印出所有的 node_modules 目录:

并且会写入这个 found 文件:

好家伙,21648 个 node_modules

这样,第一个阶段的任务就完成了,也就是找到所有 node_modules:

import fs from 'fs/promises'; import os from 'os'; import path from 'path'; const homedir = os.homedir(); const foundDirs = []; async function searchDir(dirPath: string, searchName: string) { let children; try { children = await fs.readdir(dirPath); } catch(e) { return; } for(let i = 0; i< children.length; i++) { const child = children[i]; const childPath = path.join(dirPath, child); const res = await fs.lstat(childPath); if(await res.isSymbolicLink()) { break; } if(res.isDirectory() && !child.startsWith('.')) { if(child === 'node_modules') { console.log(childPath) foundDirs.push(childPath); } else { await searchDir(childPath, searchName); } } } } async function main() { await searchDir(homedir, 'node_modules'); await fs.writeFile('./found', foundDirs.join(os.EOL)); console.log('done'); } main();

然后,我们如何知道一个 node_modules 的大小呢?

其实和递归查找是一样的,只不过现在是递归累加文件大小了:

import fs from 'fs/promises'; import path from 'path'; async function dirSize(dirPath) { let totalSize = 0; let children; try { children = await fs.readdir(dirPath); } catch(e) { return; } for(let i = 0; i< children.length; i++) { const child = children[i]; const childPath = path.join(dirPath, child); const res = await fs.lstat(childPath); if(await res.isSymbolicLink()) { break; } if(res.isDirectory()) { totalSize += await dirSize(childPath); } else { totalSize += res.size; } } return totalSize; } async function main() { const size = await dirSize('./node_modules'); console.log(size); } main();

其余的文件权限、软链接的判断逻辑一样,只不过现在会拿到 size 累加起来。

测试下:

基本是一样的。

这样,就可以在找到 node_modules 目录之后,用这个 dirSize 来计算下大小。

然后我们实现最终的目的,删除。

这个就比较简单了,我们可以从 found 目录读取文件路径,然后依次删除。

import fs from 'fs/promises'; import os from 'os'; async function fileExists(filePath) { try { await fs.access(filePath); return true; } catch(e){ return false; } } async function removeFileOrDir(dirs: string[]) { for (let i = 0; i < dirs.length; i++) { if(await fileExists(dirs[i])) { await fs.rm(dirs[i], { recursive: true }); console.log(dirs[i], 'removed') } } } async function main() { const str = await fs.readFile('./found', {encoding: 'utf-8'}); const dirs = str.split(os.EOL); await removeFileOrDir(dirs); } main();

这里要先判断目录是否存在,因为如果已经不存在了,rm 会报错:

判断文件是否存在,用 access 的 api,如果访问报错就是不存在,否则就是存在。

在 found 里放两个目录试试:

执行 cd 不报错,说明目录存在:

然后执行下 node 脚本删除它们:

之后再 cd 就报错了,说明目录已经被删除了:

这样,我们就完成了扫描出所有 node_modules、计算大小、批量删除的功能。

总结

用 npm 或者 yarn 安装依赖,依赖直接保存在 node_modules 下,会占用很大的磁盘空间。

如果是 pnpm,因为用的是从全局 store 硬链接过来的方式,全局只会保存一份。

今天我磁盘空间满了,所以想批量清理下 node_modules,于是用 node + ts 写了一个小工具。

首先,递归遍历目录,查找出所有的 node_modules 的路径,写入文件中

然后遍历目录,累加计算 fileSize。

之后读取文件,根据其中的路径批量删除 node_modules。

用到了这些 node api:

  1. os.homedir 拿到 home 目录
  2. os.EOL 拿到当前系统的换行符
  3. path.join 拼接文件路径
  4. fs.readdir 读取目录
  5. fs.lstat 读取文件或者目录的信息,同时支持 link 文件,建议只用 lstat 不用 stat
  6. fs.lstat(xxx).isSymbolicLink 判断软链接文件
  7. fs.lstat(xxx).isDirectory 是否是目录
  8. fs.writeFile 写文件
  9. fs.readFile 读文件
  10. fs.access 判断文件或者目录是否存在,如果不存在,会抛出异常
  11. fs.rm 删除文件或目录

要注意的是链接文件直接 readdir 会提示文件或者目录不存在,要用 fs.lstat(xxx).isSymbolicLink 的方式判断下,如果是软链接就跳过。

有了这个工具,就可以批量查找、删除 node_module 以及计算它们的大小了,是释放磁盘空间的利器。

#前端#

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP