vite 合并小文件插件

229 阅读1分钟

每次打包发小小文件很多,小到几百B、几kB等,网上也没找到好用的插件,自己就写了一个,大家参考学习,欢迎提取建议

import path from 'path';
import { PluginOption } from 'vite';
import fs from 'fs';
import esbuild from 'esbuild';

/**
 * 合并小文件插件
 */
export function mergeSmallFilePlugin() {
  const plugin: PluginOption = {
    name: 'vite:yu-bundle-plugin',
    enforce: 'post',
    apply: 'build',
    async writeBundle(options, bundle) {
      // 小于 10kB 的文件进行合并
      const minChunkSize = 10 * 1000; // 10kB
      const chunks = Object.values(bundle).filter(chunk => chunk.type === 'chunk');
      // 只合并到动态导入入口
      const dynChunks = chunks.filter(chunk => chunk.isDynamicEntry);
      // 记录文件引用次数,后面删除替换 __vite__mapDeps 使用
      const chunkCountMap: Record<string, number> = {};
      for (const dynChunk of dynChunks) {
        // 仅非动态导入文件才能合并
        const impChunks = dynChunk.imports
          .map(s => bundle[s])
          .filter(chunk => chunk && chunk.type === 'chunk')
          .filter(chunk => !chunk.isDynamicEntry)
          .filter(chunk => {
            chunkCountMap[chunk.fileName] ||= 0;
            chunkCountMap[chunk.fileName]++;
            if (chunk.code.length < minChunkSize) {
              // 合并后计数减1
              chunkCountMap[chunk.fileName]--;
              return true;
            }
            return false;
          });
        if (!impChunks.length) {
          continue;
        }
        // esbuild 需要合并的路径列表
        const internal = impChunks.map(chunk => chunk.fileName.replace(/^assets/g, '.'));
        console.log(`esbuild:\x1B[32m dist/${dynChunk.fileName}\x1B[0m`);
        for (const impChunk of impChunks) {
          console.log(
            `\x1B[33m${(impChunk.code.length / 1024).toFixed(2)}kB\x1B[0m`.padEnd(8, ' '),
            `dist/${impChunk.fileName}`
          );
        }
        // 合并到 dynChunk
        await esbuild.build({
          stdin: {
            contents: dynChunk.code,
            loader: 'js',
            resolveDir: 'dist/assets',
          },
          outfile: `dist/${dynChunk.fileName}`,
          charset: 'utf8',
          format: 'esm',
          platform: 'browser',
          bundle: true,
          minify: false,
          minifyWhitespace: true,
          packages: 'external',
          allowOverwrite: true,
          plugins: [
            {
              name: 'myexternal',
              setup(build) {
                build.onResolve({ filter: /.*/ }, async args => {
                  if (internal.includes(args.path)) {
                    // 需要合并的路径
                    return {
                      path: path.resolve('dist/assets', args.path),
                      external: false,
                    };
                  }
                  return {
                    path: args.path,
                    external: true,
                  };
                });
              },
            },
          ],
        });
      }
      // 找到 __vite__mapDeps 进行替换
      const indexChunks = chunks.filter(chunk => chunk.code.includes('const __vite__mapDeps'));
      // 没有被引用的才需要替换
      const internal = Object.entries(chunkCountMap)
        .filter(([_, count]) => count === 0)
        .map(([file]) => file.replace(/^assets/g, '.'));
      if (indexChunks.length && internal.length) {
        const internalToken = new RegExp(`(${internal.join('|')})`, 'g');
        for (const indexChunk of indexChunks) {
          // 替换 __vite__mapDeps
          const code = indexChunk.code.replace(internalToken, '').toString();
          console.log(`replace __vite__mapDeps: dist/${indexChunk.fileName}`);
          await fs.promises.writeFile(path.resolve(`dist/${indexChunk.fileName}`), code);
        }
        for (const file of internal) {
          // 删除未引用的文件
          console.log(`rm dist/assets${file.substring(1)}`);
          await fs.promises.rm(path.resolve(`dist/assets`, file));
        }
      }
    },
  };
  return [plugin];
}