vite-bundle-analyzer插件解析

2024-02-26 43:03:47/181天之前

大概是在去年看到群友在抱怨为什么 vite 社区没有一个好用的bundle-analyzer-tool,于是在我实现了一个接近于webpack-bundle-analyzer的工具以后 对此也有一些碎碎念。

为什么需要新的 tool

正如文章开头所述 vite 社区现存的analyzer tool相比 webpack 是非常不直观的,例如 rollup-plugin-visualizer,vite-plugin-visualizer等诸如此类的工具 我们不免发现一个问题那就是华丽的图表不是必须的而相对精确以及可观的模块才是我们需要的。这里以rollup-plugin-visualizer为例 我们能看到和vite相关联的 issue 都指向了一个问题那就是stat size是不准确的以及收集到的模块数据也是错误的,当然了 这也和 rollup 的工作方式有关系。

实现 analyzer tool

根据我们使用过各种 analyzer tool 能观测到的结果来看我们注重的数据是每个 chunk 里面包含的模块他们的体积是多少,每个 chunk 是否包含了重复的模块。 好了透过这一点我们就清楚了插件应该如何实现。使用 generateBundle 我们可以轻易的获取每个chunk 所包含的基本信息。这里不会在做过多的阐述。

这里贴上所需要关注的数据签名

interface OutputAsset {
  fileName: string;
  name?: string;
  needsCodeReference: boolean;
  source: string | Uint8Array;
  type: "asset";
}

interface OutputChunk {
  code: string;
  dynamicImports: string[];
  exports: string[];
  facadeModuleId: string | null;
  fileName: string;
  implicitlyLoadedBefore: string[];
  imports: string[];
  importedBindings: { [imported: string]: string[] };
  isDynamicEntry: boolean;
  isEntry: boolean;
  isImplicitEntry: boolean;
  map: SourceMap | null;
  modules: {
    [id: string]: {
      renderedExports: string[];
      removedExports: string[];
      renderedLength: number;
      originalLength: number;
      code: string | null;
    };
  };
  moduleIds: string[];
  name: string;
  preliminaryFileName: string;
  referencedFiles: string[];
  sourcemapFileName: string | null;
  type: "chunk";
}

从这段签名来看我们只需要关注type,code,moduleIds,map 即可。至于剩下的属性我会在后面慢慢阐述为什么我们不需要这些或者说为什么我们选择了这 4 个属性。

Note

vite 在处理 suffix import 的时候会把资源当作OutputAsset 去做处理 这时候我们就需要手动去判断我们的这个chunk到底是不是 javaScript 文件。因此我们需要编写一个方法去判断这些逻辑

// 返回的结果第一个参数是判断是不是chunk,第二个参数代表sourceMap的文件名
function validateChunk(
  chunk: OutputAsset | OutputChunk,
  allChunks: OutputBundle
): [boolean, string | null] {
  const { type } = chunk;
  // 因为suffix import 在 generateBundle 阶段得到的是以js作为结尾的文件(尽管源文件是typescript)
  if (type === "asset" && path.extname(chunk.fileName) === ".js") {
    const possiblePath = chunk.fileName + ".map";
    if (possiblePath in allChunks) return [true, possiblePath];
    return [true, null];
  }
  const isChunk = type === "chunk";
  return [isChunk, isChunk ? chunk.sourcemapFileName : null];
}

有了这样的一个工具方法我们就可以在generateBundle 里面迭代我们的bundle info。但是有细心的同学可能会有疑问你要拿到sourceMap 不是需要用户指定开启这一配置才行吗?因此我们需要对这里做一个用户无感知的 hack 让我能拿到 sourcemap 的同时也不破坏用户的生成结果。

let previousSourcemapOption: boolean = false;
return {
  config(config) {
    if (config.build?.sourcemap) {
      previousSourcemapOption =
        typeof config.build.sourcemap === "boolean"
          ? config.build.sourcemap
          : // sourcemap 可以为 true | false | 'hidden' | 'inline'
            config.build.sourcemap === "hidden";
    }
    if (!config.build) {
      config.build = {};
    }
    if (typeof config.build.sourcemap === "boolean" && config.build.sourcemap) {
      // 如果为true 就保持原样
      config.build.sourcemap = true;
    } else {
      // 设置为 hidden
      config.build.sourcemap = "hidden";
    }
  },
  generateBundle(_, ouputBundle) {
    for (const bundleName in outputBundle) {
      const bundle = outputBundle[bundleName];
      const [pass, sourcemapFileName] = validateChunk(bundle, outputBundle);
      if (pass && sourcemapFileName) {
        // TODO
      }
      // 恢复结果
      if (!previousSourcemapOption) {
        if (pass && sourcemapFileName) {
          Reflect.deleteProperty(outputBundle, sourcemapFileName);
        }
      }
    }
  },
};

完成了上述的逻辑这时候我们只需要实现一个模块去记录这些chunk并生成对应的文件树即可。

实现 analyzer Module

我们需要实现一个模块去管理这些模块,应该还记得我们之前提到的code,moduleIds以及map吗?这一小节我们将会阐述他们的作用。

function wrapBundleChunk(
  bundle: OutputChunk | OutputAsset,
  chunks: OutputBundle,
  sourcemapFileName: string
) {
  const wrapped = <WrappedChunk>{};
  const isChunk = bundle.type === "chunk";
  wrapped.code = stringToByte(isChunk ? bundle.code : bundle.source);
  wrapped.map = findSourcemap(bundle.fileName, sourcemapFileName, chunks);
  wrapped.imports = isChunk ? bundle.imports : [];
  wrapped.dynamicImports = isChunk ? bundle.dynamicImports : [];
  wrapped.moduleIds = isChunk ? bundle.moduleIds : [];
  wrapped.filename = bundle.fileName;
  wrapped.isEntry = isChunk ? bundle.isEntry : false;
  return wrapped;
}

export class AnalyzerModule {
  constructor() {
    // 存放模块合集
    this.modules = [];
    // generateBundle 里面的bundles
    this.chunks = {};
  }

  // addModule 就是我们添加模块的方法
  addModule(bundle: OutputAsset | OutputChunk, sourcemapFileName: string) {
    // 生成一个统一的bundle信息抹平 suffix import 和 常规chunk的差异
    const wrapped = wrapBundleChunk(bundle, this.chunks, sourcemapFileName);
  }
}

export function createAnalyzerModule() {
  return new AnalyzerModule();
}

//  ... generateBundle
const analyzerModule = createAnalyzerModule();
analyzerMoudule.addModule(bundle, sourcemapFileName);

这样我们就有了一个简易的analyzerModule去管理我们的模块。同时我们也在添加模块的时候抹平了每个 chunk 的差异这时候我们只需要给this.modules里添加节点就行了。我们在使用webpack-bundle-analyzer 可以得知内容被分成 2 部分一部分是stats另一部分是parsed。处理stats的流程就比较容易了。 我们只需要处理moduleIds并且通过rollupContext提供的getModuleInfo去获取每个模块的内容。

Important

经过我的测试我能得出一个结论那就是通过上述方式获取的模块信息比直接从modules里面获取的 code 更为准确。modules里面的code 似乎不包含import statement 这一点会影响我们的计算结果。同时suffix import是没有moduleIds的这时候我们需要通过 sourcemap 提供的content去映射 出每个模块的code block

const KNOWN_EXT_NAME = [
  ".mjs",
  ".js",
  ".cjs",
  ".ts",
  ".tsx",
  ".vue",
  ".svelte",
  ".md",
  ".mdx",
];

const infomations = moduleIds.length
  ? moduleIds.reduce((acc, cur) => {
      const info = pluginContext.getModuleInfo(cur);
      if (info && info.code) {
        // 我们在统计stat的时候是不需要关注style的情况的
        if (
          KNOWN_EXT_NAME.includes(path.extname(info.id)) ||
          info.id.startsWith("\0")
        ) {
          acc.push({ id: info.id, code: info.code });
        }
      }
      return acc;
    }, [] as Array<ChunkMetadata>)
  : await convertSourcemapToContents(map);

既然我们获取了引用的模块信息那么我们只需要迭代他们并且推送到 stat 里面记录大小即可。parsed 我们也可以依葫芦画瓢的方式去获取(getModuleInfo) 获取的内容是没有经过terser这些压缩器去压缩的因此我们需要通过sourcemap 去反映射压缩后的产物。

function getStringFromSerializeMappings(
  bytes: Uint8Array[],
  mappings: Array<Loc>,
  decoder: TextDecoder
) {
  const mappingsWithLine: Record<number, Array<Loc>> = {};
  let parsedString = "";
  for (const mapping of mappings) {
    const { generatedLine } = mapping;
    if (!(generatedLine in mappingsWithLine)) {
      mappingsWithLine[generatedLine] = [];
    }
    mappingsWithLine[generatedLine].push(mapping);
  }
  for (const line in mappingsWithLine) {
    const l = parseInt(line) - 1;
    if (bytes[l]) {
      const runes = decoder.decode(bytes[l]);
      const mappings = mappingsWithLine[line];
      const [first, ...rest] = mappings;
      const end = rest[rest.length - 1];
      // 如果没有结束符 证明整个source只有一个内容
      if (!end) {
        parsedString += runes.substring(first.generatedColumn);
      } else {
        // 如果没有结束符 证明整个source只有一个内容
        if (typeof end.lastGeneratedColumn !== "number") {
          parsedString += runes.substring(first.generatedColumn);
        } else {
          // 截取字符 range
          parsedString += runes.substring(
            first.generatedColumn,
            end.lastGeneratedColumn ?? end.generatedColumn
          );
        }
      }
    }
  }
  return parsedString;
}

export async function getSourceMappings(
  code: Uint8Array,
  rawSourceMap: string,
  formatter: (id: string) => string
) {
  const hints: Record<string, string> = {};
  const bytes = splitBytesByNewLine(code);
  const promises: Array<[() => string, MappingItem]> = [];
  const decoder = new TextDecoder();
  const consumer = await new SourceMapConsumer(rawSourceMap);
  consumer.eachMapping(
    (mapping) => {
      if (mapping.source)
        promises.push([() => formatter(mapping.source), mapping]);
    },
    null,
    SourceMapConsumer.ORIGINAL_ORDER
  );

  const mappings = promises.map(([fn, mapping]) => {
    const id = fn();
    return { mapping, id };
  });

  const sortedMappings = mappings.reduce((acc, cur) => {
    if (!acc[cur.id]) {
      acc[cur.id] = {
        mappings: [],
      };
    }
    acc[cur.id].mappings.push(cur.mapping as any);
    return acc;
  }, {} as Record<string, { mappings: Array<Loc> }>);

  for (const key in sortedMappings) {
    sortedMappings[key].mappings.sort(
      (a, b) => a.generatedColumn - b.generatedColumn
    );
    const { mappings } = sortedMappings[key];
    if (mappings.length > 0) {
      const s = getStringFromSerializeMappings(bytes, mappings, decoder);
      hints[key] = s;
    }
  }
  consumer.destroy();
  return hints;
}

值得注意的是在 js 世界中字符串的存储是以UTF-16的方式进行的而我们的 code 是以UTF-8去表示的我们在获range的时候应该以字符偏移量而不是字节偏移量。(一个小插曲 terser似乎并不能处理No-BMP字符以及没有处理正确的generateColumn会导致我们的解析结果错误。) 在完成了上述了工作我们就能获取到每个module 包含的stat,parsed信息只需要把他们分别组装成一个 tree 就能得到我们想要的结果。

结语

至此插件的主体逻辑已经剖析完,这一篇文章也仅仅只是我的碎碎念如果您对项目感兴趣可以查看vite-bundle-analyzer 获取更多细节。

CC BY-NC-SA 4.02024-PRESENT © Kanno