node_modules 瘦身

起因

場景一:
當前項目經歷了刀耕火種地開發, 之後接入了 cli 工具集中管理打包, 那麼項目中的依賴,
和 cli 工具中的依賴重合度是多少, 並且他的的版本是否相同, 是否有冗餘代碼

場景二:
項目中某一個庫升級了, 他依賴了 A 庫的 V3 版本, 同時當前項目依賴的是 A 庫 V2版本, 這個時候打包很明顯, 就會將這一個包的不同版本同時打入

場景三:
當前 deps 中有對應的依賴庫, 但是業務代碼中並未使用到

由於上述的場景, 我們需要一個工具來解決這些情況

思考🤔

這些場景改如何解決, 解決的方案是什麼

針對場景三來說, 現在已經有一個庫: depcheck

簡單的原理: 通過檢測項目中的文件 import 或者 require 和依賴進行對比, 最後生成依賴列表

想要一定的配置
(通過實際的調用, 發現還存在一定的問題: 在子模塊中的代碼未能被檢測, 同時關於依賴中的 babel 配置插件檢測也是同樣的)

而場景一和二就和三不太一樣了, 他是已有庫, 但是略有重複, 所有需要針對庫進行檢測

目前計劃是通過 node 腳本來運行

  • 檢查 node_modules 或者 lock 文件中, 是否存在同一庫的多個版本

  • node_modules 文件層級太多, lock 文件是他的一層映射, 考慮從這裏入手

  • 確保 lock 文件是最新的(這一層比較麻煩, 沒標識來保證, 明確就確保此文件是否存在即可)

  • 打開本地網站, 針對結果的可視化顯示(經過實際的操作, 這一場景放棄, 具體原因放下下方詳述)

開發

這裏我們首先解決場景一的問題

場景一

在上面的思考中針對此場景已經了一解決方案了, 即 depcheck 場景, 但是他的配置需要重新編寫:

check 配置更新

const options = {
    ignoreBinPackage: false, // ignore the packages with bin entry
    skipMissing: false, // skip calculation of missing dependencies
    ignorePatterns: [
        // files matching these patterns will be ignored
        'sandbox',
        'dist',
        'bower_components',
        'tsconfig.json'
    ],
    ignoreMatches: [
        // ignore dependencies that matches these globs
        'grunt-*',
    ],
    parsers: {
        // the target parsers
        '**/*.js': depcheck.parser.es6,
        '**/*.jsx': depcheck.parser.jsx,
        '**/*.ts': depcheck.parser.typescript,
        // 這裏 ts 類型可能會出問題, 但是經過實際的運行和文檔說明是沒問題的
        '**/*.tsx': [depcheck.parser.typescript, depcheck.parser.jsx],
    },
    detectors: [
        // the target detectors
        depcheck.detector.requireCallExpression,
        depcheck.detector.requireResolveCallExpression,
        depcheck.detector.importDeclaration,
        depcheck.detector.exportDeclaration,
        depcheck.detector.gruntLoadTaskCallExpression,
        depcheck.detector.importCallExpression,
        depcheck.detector.typescriptImportEqualsDeclaration,
        depcheck.detector.typescriptImportType,
    ],
    // specials: [
    //     // Depcheck API在選項中暴露了特殊屬性,它接受一個數組,以指定特殊分析器。
    // ],
    // 這裏將會覆蓋原本的 package.json 的解析
    // package: {
    // },
};

之後再調用配置:

// 默認即當前路徑
const check = (path = process.cwd()) => depcheck(path ,options)

最後加上打印結果:

console.log('Unused dependencies:')
unused.dependencies.forEach(name=>{
    console.log(chalk.greenBright(`* ${name}`))
})
console.log('Unused devDependencies:'); 
unused.devDependencies.forEach(name=>{
    console.log(chalk.greenBright(`* ${name}`))
})

調用結果的例子展示:

場景二

指令技術選型:

  1. commander

推薦最多的, 同時也是下載量最多的, 下載量 8kw+

  1. package-lock.json

針對的 lock 文件, 默認 npm 及其對應的解析, 現在還有 yarn, pnpm 比較流行, 但是
一般在服務器上打包時都用使用 npm 指令

指令的開發

計劃中的指令

  • check // 默認場景一的操作
  • check json // 解析 .lock 文件, 同時打印佔用空間的包
  • check json -d // 將結果打印成文件

第一步

指令的定義:

const main = () => {
    const program = new commander.Command();
    program.command('check')
        .description('檢查使用庫')
        .action((options) => {
            // 顯示一個 loading
            const spinner = ora('Loading check').start();
            
            // check
            check()
            
        }).command('json').description('解析 lock文件').option('-d, --doc', '解析 lock 文件, 將結果保存')
        .action(async (options) => {
            // 顯示 loading
            const spinner = ora('Loading check').start();
            // 執行腳本
            // 額外判斷 options.open
            deepCheck(spinner, options)
        })
    
    program.parse();
}

第二步 解析文件

首先我們通過 fs 來獲取文件內容:

const lockPath = path.resolve('package-lock.json')

const data = fs.readFileSync(lockPath, 'utf8')

針對 lock 數據解析:

    const allPacks = new Map();
    
    Object.keys(allDeps).forEach(name => {
        const item = allDeps[name]
        if (item.dev) {
            // dev 的暫時忽略掉
            return
        }
        
        if (item.requires) {
            // 和item.dependencies中的操作類似
            setCommonPack(item.requires, name, item.dependencies)
        }
        
        if (item.dependencies) {
            Object.keys(item.dependencies).forEach(depsName => {
                const depsItem = item.dependencies[depsName]
                if (!allPacks.has(depsName)) {
                    allPacks.set(depsName, [])
                }
                const packArr = allPacks.get(depsName);
                
                packArr.push({
                    location: `${name}/node_modules/${depsName}`,
                    version: depsItem.version,
                    label: 'reDeps', // 標識爲重複的依賴
                    size: getFileSize(`./node_modules/${name}/node_modules/${depsName}`)
                })
                allPacks.set(depsName, packArr)
            })
        }
    })

最後通過一個循環來計算出暫用空間最大的包:

    // 創建一個排序數據, push 之後自動根據 size 排序
    let topSizeIns = createTopSize()
    
    allPacks.forEach((arr, name, index) => {
        if(arr.length <= 1){
            return
        }
        let localSize = 0
        arr.forEach((item, itemIndex) => {
            const size = Number(item.size)
            localSize += size
        })
        
        topSizeIns.push({items: arr, size: localSize})
    })

    // 最後打印結果, 輸出可選擇文檔
    if (options.doc) {
        fs.writeFileSync(`deepCheck.json`, `${JSON.stringify(mapChangeObj(allPacks), null, 2)}`, {encoding: 'utf-8'})
    }
    
    // 打印 top5
    console.log(chalk.yellow('佔用空間最大的 5 個重複庫:'))
    topSizeIns.arr.forEach(itemObj => {
        const common = itemObj.items.find(it => it.label === 'common')
        console.log(chalk.cyan(`${common.location}--${itemObj.size.toFixed(2)}KB`));
        itemObj.items.forEach(it => {
            console.log(`* ${it.location}@${it.version}--size:${it.size}KB`)
        })
    })

第三步

圖形化方案(已經棄用)

先說說實現方案:

  1. 轉換json 生成的數據至圖表需要的數據
  2. 啓動本地服務, 引用 echart 和數據

數據轉換:

let nodes = []
let edges = []
packs.forEach((arr, name, index) => {
    let localSize = 0
    arr.forEach((item, itemIndex) => {
        const size = Number(item.size)
        nodes.push({
            x: Math.random() * 1000,
            y: Math.random() * 1000,
            id: item.location,
            name: item.location,
            symbolSize: size > max ? max : size,
            itemStyle: {
                color: getRandomColor(),
            },
        })
        localSize += size
    })
    
    topSizeIns.push({items: arr, size: localSize})
    
    const common = arr.find(it => it.label === 'common')
    if (common) {
        arr.forEach(item => {
            if (item.label === 'common') {
                return
            }
            edges.push({
                attributes: {},
                size: 1,
                source: common.location,
                target: item.location,
            })
        })
    }
})

啓動服務:

服務並沒有使用三方庫, 而是添加了一個node http 服務:


var mineTypeMap = {
    html: 'text/html;charset=utf-8',
    htm: 'text/html;charset=utf-8',
    xml: "text/xml;charset=utf-8",
    // 省略其他
}

const createServer = () => {
    const chartData = fs.readFileSync(getFile('deepCheck.json'), 'utf8')

    http.createServer(function (request, response) {
        // 解析請求,包括文件名
        // request.url
        if (request.url === '/') {
            // 從文件系統中讀取請求的文件內容
            const data = fs.readFileSync(getFile('tools.html'))
            response.writeHead(200, {'Content-Type': 'text/html'});
            // 這裏是使用的類似服務端數據的方案, 當然也可以使用引入 json 的方案來解決
            const _data = data.toString().replace(new RegExp('<%chartData%>'), chartData)
            // 響應文件內容
            response.write(_data);
            response.end();
        } else {
            const targetPath = decodeURIComponent(getFile(request.url)); //目標地址是基準路徑和文件相對路徑的拼接,decodeURIComponent()是將路徑中的漢字進行解碼
            console.log(request.method, request.url, baseDir, targetPath)

            const extName = path.extname(targetPath).substr(1);
            if (fs.existsSync(targetPath)) { //判斷本地文件是否存在
                if (mineTypeMap[extName]) {
                    response.setHeader('Content-Type', mineTypeMap[extName]);
                }
                var stream = fs.createReadStream(targetPath);
                stream.pipe(response);
            } else {
                response.writeHead(404, {'Content-Type': 'text/html'});
                response.end();
            }
        }
    }).listen(8080);

    console.log('Server running at http://127.0.0.1:8080/');

    opener(`http://127.0.0.1:8080/`);
}

export default createServer

效果圖:

通過此圖, 可以看到大概問題點所在:

  1. 依賴包太多, 導致數據顯示雜亂
  2. 根據包真實尺寸大小顯示圓圈, 其中的差距過大, 大的有幾萬 kb, 小的有幾十kb
    圖中暫時閒置了最大 size 200

所以暫時不開啓此功能

總結

當前已構建出包: @grewer/deps-check 可嘗試使用

針對文章一開始提出的三種常見場景, 此包基本上能夠解決了

之後還能提出一些優化點, 比如有些包的替換(moment 替換 dayjs, lodashlodash.xx 包不能同時存在等等)
這些就需要長期維護管理了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章