教你写一个Vite插件,分析你的项目打包产物体积,带图表的~

模拟面试、简历指导、入职指导、项目指导、答疑解惑可私信找我~已帮助100+名同学完成改造!

前言

大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~

上一个文章我通过两个小示例,带大家了解了 Vite 插件的开发流程,这一篇文章,我带大家实现一个功能更加高大上的插件~

我们要实现一个功能,在 Vite 打包后,我们想把打包产物的大小、类型、占比,都打印给出来,便于我们分析包的体积,就像下面这种效果:

使用方式是这样的:

import VitePluginVisualizer from "./vite-plugin-visualizer";

export default defineConfig({
  base: "./",
  plugins: [
    vue(),
    VitePluginVisualizer()
  ],
});

完整源码: https://github.com/sanxin-lin/vite-plugin-visualizer/tree/main/src/vite-plugins/vite-plugin-visualizer

思路

我觉得实现这个插件需要分几个步骤:

  • 1、打包前记录开始时间
  • 1、打包后拿到产物、记录打包时间
  • 2、统计产物的类型、体积
  • 3、将统计的数据转换成 html,打印到某个 html 文件上

以下代码都是伪代码,想看完整源码可以去这里: https://github.com/sanxin-lin/vite-plugin-visualizer/tree/main/src/vite-plugins/vite-plugin-visualizer

打包前记录开始时间

上篇文章说了,Vite 有自己的钩子,但他底层是 Rollup,所以也可以使用 Rollup 的钩子,我们可以用到 buildStart这个钩子,这个钩子会开始打包时执行

// vite-plugin-visualizer.ts

import type { Plugin } from "vite";

const VitePluginVisualizer = (options: IOptions = {}): Plugin => {
  let startTime = 0;
  return {
    name: "vite-plugin-visualizer",
    buildStart() {
      // 记录开始时间
      startTime = Date.now();
    },
  }
}

打包后拿到产物、构建时间

我们需要拿到产物,才能进行计算,那我们要怎么在打包后拿到产物呢?这其实是一个时机,也就是打包后这个时机,我们可以用到 generateBundle这个钩子,它会在产物产生的时候执行,它接收到的第二个参数 outputBundle就是产物的map,是一个映射表的类型

  • key 是产物路径
  • value 是产物的一些文件信息,包括代码、名称、依赖的模块
// vite-plugin-visualizer.ts

const VitePluginVisualizer = (options: IOptions = {}): Plugin => {
  let startTime = 0;
  return {
    name: "vite-plugin-visualizer",
    buildStart() {
      // 记录开始时间
      startTime = Date.now();
    },
    generateBundle(_, outputBundle: Record<stringany>) {
      // doing
    }
  }
}

构建时间

只需要获取当前时间戳,减去开始时间即可

const VitePluginVisualizer = (options: IOptions = {}): Plugin => {
  let startTime = 0;
  return {
    name: "vite-plugin-visualizer",
    buildStart() {
      // 记录开始时间
      startTime = Date.now();
    },
    generateBundle(_, outputBundle: Record<stringany>) {
      const time = (Date.now() - startTime) / 1000 + "s"
    }
  }
}

统计产物类型、体积

需要遍历所有包,通过名称去判断后缀,判断出这个文件时什么类型,接着需要获取文件信息身上的code属性,去计算出code的长度,也就是文件的大小

其实遍历去计算大小,统计文件类型,并不难,难的是,要建立一个 tree 树结构,去将文件的依赖关系体现出来

这样在后面展示的时候,才能展示出这种效果

let assetCount = 0;
let chunkCount = 0;
let packageCount = 0;
let totalSize = 0;
let jsSize = 0;
let cssSize = 0;
let imgSize = 0;
let htmlSize = 0;
let fontSize = 0;
const tableData = [];
for (const [bundleId, bundle] of Object.entries(outputBundle)) {
  const fileType = path.extname(bundle.fileName).slice(1);
  const size = bundle?.code?.length ?? bundle?.source?.length ?? 0;
  if (fileType === "js") {
    jsSize += size;
  }
  if (fileType === "css") {
    cssSize += size;
  }
  if (["jpg""jpeg""png""gif""svg"].includes(fileType)) {
    imgSize += size;
  }
  if (fileType === "html") {
    htmlSize += size;
  }
  if (["woff""woff2""ttf""otf"].includes(fileType)) {
    fontSize += size;
  }

  const dependencyCount = Object.keys(bundle.modules ?? []).length;
  totalSize += size;
  assetCount++;
  tableData.push({
    fileName: bundle.fileName,
    fileType,
    size: Number(size / 1000).toFixed(2),
    dependencyCount,
  });
  if (bundle.type == "chunk") {
    packageCount += dependencyCount;

    const modules = Object.entries(bundle.modules).map(
      ([id, { renderedLength, code }]: any) =>
        getLength({ id, length: renderedLength, code })
    );
    const tree = buildTree(bundleId, modules, mapper);

    if (tree.children.length === 0) {
      const bundleSizes = getLength({
        id: bundleId,
        length: bundle.code.length,
        code: bundle.code,
      });
      const facadeModuleId = bundle.facadeModuleId ?? `${bundleId}-unknown`;
      const bundleUID = mapper.setNodePart(
        bundleId,
        facadeModuleId,
        bundleSizes
      );
      const leaf = { name: bundleId, uid: bundleUID };
      roots.push(leaf);
    } else {
      roots.push(tree);
    }
  }

  chunkCount = Object.keys(outputBundle).length;
}

打印 html

我们需要将我们统计到的数据,渲染到 html 页面上,我们可以分为几步:

  • 1、将数据与 html 字符串拼接
  • 2、将 html 字符串打印到 html 文件中
  • 3、打开 html 文件,即可查看

我们刚刚看到了,页面上有图标、有表格,这些我们自己写起来很麻烦,所以我们可以利用 vue + echart + elementui 去写,怎么用呢?只需要在拼接 html 字符串的时候,拼接引用这些包的 script 标签即可

  • vue: https://unpkg.com/vue@2
  • elementui: https://unpkg.com/element-ui/lib/index.js
  • echart: https://cdn.jsdelivr.net/npm/echarts@5.2.2/dist/echarts.min.js
// 最终数据手收集
const outputBundlestats = {
  title,
  bundleObj: {
    root,
    time: (Date.now() - startTime) / 1000 + "s",
    startTime: new Date().toLocaleString(),
    totalSize: Number(totalSize / 1000).toFixed(2),
    assetCount,
    chunkCount,
    packageCount,
    jsSize: Number(jsSize / 1000).toFixed(2),
    cssSize: Number(cssSize / 1000).toFixed(2),
    imageSize: Number(imgSize / 1000).toFixed(2),
    htmlSize: Number(htmlSize / 1000).toFixed(2),
    fontSize: Number(fontSize / 1000).toFixed(2),
  },
  tableData,
  treeData: roots,
};
// 创建 html 页面
const html = createHtml(outputBundlestats);
// 写到 html 文件里
fs.writeFileSync(path.join("./", outputFile), html);

// 创建 html 内容

export const createHtml = (allData: any) => {
  const chartScript = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- 引入样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<style>
body {
  background-color: rgb(246, 247, 251);
}
.app {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}
.container {
  width: 1280px;
  margin: 0px auto;
  background-color: rgb(255, 255, 255);
  border: 1px solid rgb(225, 223, 221);
  border-radius: 2px;
  padding: 0 20px 20px 20px;
}
.header-wrap {
  min-height: 56px;
  display: flex;
  -webkit-box-align: center;
  align-items: center;
  padding: 12px 20px 0px;
  font-size: 16px;
  line-height: 24px;
  font-weight: 600;
  color: rgb(27, 26, 25);
}
.bundle-info {
  display: flex;
  border-radius: 4px;
  border: 1px solid rgb(225, 223, 221);
  padding: 12px 16px;
  justify-content: space-between;
  margin-bottom: 20px;
}
.bundle-info-title {
  font-size: 14px;
  font-weight: 600;
}
.bundle-overview {
  flex-wrap: wrap;
  width: auto;
  overflow: visible;
  height: auto;
  display: flex;
  justify-content: space-between;
}
.bundle-left {
  width: 60%;
  height: 300px;
  border-radius: 4px;
  display: flex;
  justify-content: space-between;
  align-content: space-between;
  flex-wrap: wrap;
}
.bundle-left-item {
  width: calc(30% - 20px);
  height: calc(45% - 20px);
  border-radius: 4px;
  border: 1px solid rgb(225, 223, 221);
  padding: 12px 16px;
  text-align: left;
}
.kb {
  font-size: 14px;
  font-weight: 400;
}
.bundle-left-item-title {
  text-align: left;
  font-size: 14px;
  font-weight: 600;
  line-height: 22px;
}
.bundle-left-item-value {
  font-size: 28px;
  font-weight: 600;
  line-height: 100px;
}
.bundle-right {
  width: calc(40% - 20px);
  border-radius: 4px;
  height: 300px;
  border: 1px solid rgb(225, 223, 221);
}
.pie {
  position: relative;
  width: 100%;
  height: 100%;
  padding: 20px;
}
.tablist {
  margin-top: 20px;
}
.visualization {
    width: 1248px;
    height: 500px;
    background: #eebe77;
  }
</style>
<script src="https://unpkg.com/vue@2"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.2.2/dist/echarts.min.js"></script>
</head>
<body>
<div class="app" id="app">
<div class="container">
  <div class="header-wrap">${allData.title}</div>
  <div class="bundle-info">
    <div>
      <span class="bundle-info-title">project URL:</span
      >{{ bundleObj.root }}
    </div>
    <div>
      <span class="bundle-info-title">built in:</span> {{ bundleObj.time }}
    </div>
    <div>
      <span class="bundle-info-title">built Date:</span>
      {{ bundleObj.startTime }}
    </div>
  </div>
  <div class="bundle-overview">
    <div class="bundle-left">
      <div
        class="bundle-left-item"
        v-for="item in bundleList"
        :key="item.key"
      >
        <div class="bundle-left-item-title" v-if="item.content">
          <el-popover
            :ref="item.key"
            :title="item.content.title"
            width="200"
            trigger="hover"
            :content="item.content.content"
          >
          </el-popover>
          <span v-popover="item.key">{{ item.title }}</span>
        </div>
        <div v-else class="bundle-left-item-title">{{ item.title }}</div>
        <div class="bundle-left-item-value">
          {{ bundleObj[item.key] }}
          <span v-if="!item.content" class="kb">kb</span>
        </div>
      </div>
    </div>
    <div class="bundle-right">
      <div class="pie" id="pie"></div>
    </div>
  </div>
  <el-tabs type="border-card" class="tablist">
    <el-tab-pane label="Visualization">
      <div id="visualization" class="visualization"></div>
    </el-tab-pane>
    <el-tab-pane label="Assets Statistics">
      <el-table :data="tableData" height="400" stripe style="width: 100%">
        <el-table-column prop="fileName" sortable label="file" width="500px">
        </el-table-column>
        <el-table-column prop="fileType" sortable label="type"> </el-table-column>
        <el-table-column prop="size" sortable label="size(kb)"> </el-table-column>
        <el-table-column prop="dependencyCount" sortable label="dependencyCount">
        </el-table-column>
      </el-table>
    </el-tab-pane>
  </el-tabs>
</div>
</div>
</body>


<script>
var app = new Vue({
  el: '#app',
  data: {
    bundleObj:${JSON.stringify(allData.bundleObj)},
    tableData:${JSON.stringify(allData.tableData)},
    bundleList:[
        {
          key: 'totalSize',
          title: 'Bundle Size',
          content: '',
        },
        {
          key: 'jsSize',
          title: 'Initial JS Size',
          content: '',
        },
        {
          key: 'cssSize',
          title: 'Initial CSS Size',
          content: '',
        },
        {
          key: 'assetCount',
          title: 'Assets Count',
          content: {
            title: 'Webpack ouput assets',
            content:
              'Files emitted by webpack.<br/> Including any JavaScript CSS font Image files which processed by webpack.',
          },
        },
        {
          key: 'chunkCount',
          title: 'Chunks Count',
          content: {
            title: 'Chunks',
            content:
              '(1)initial is the main chunk for the entry point. This chunk contains all the modules and its dependencies that you specify for an entry point.initial is the main chunk for the entry point. This chunk contains all the modules and its dependencies that you specify for an entry point.non-initial is a chunk that may be lazy-loaded. It may appear when dynamic import or SplitChunksPlugin is being used.',
          },
        },
        {
          key: 'packageCount',
          title: 'Packages Count',
          content: {
            title: 'node_modules',
            content: 'Third part packages count in node_modules.',
          },
        },
      ]
  },
  methods: {
    setPieChart(){
        // 基于准备好的dom,初始化echarts实例
        var myChart = echarts.init(document.getElementById('pie'))
        // 绘制图表
        myChart.setOption({
          title: {
            text: 'Bundle Overview',
          },
          tooltip: {
            trigger: 'item',
          },
          legend: {
            orient: 'vertical',
            left: 'left',
            top: '30%',
          },
          series: [
            {
              name: 'Bundle Overview',
              type: 'pie',
              radius: '50%',
              data: [
                { value: ${allData.bundleObj.jsSize}, name: 'JS' },
                { value: ${allData.bundleObj.cssSize}, name: 'CSS' },
                { value: ${allData.bundleObj.imageSize}, name: 'Image' },
                { value: ${allData.bundleObj.htmlSize}, name: 'Font' },
                { value: ${allData.bundleObj.fontSize}, name: 'Html' },
              ],
              emphasis: {
                itemStyle: {
                  shadowBlur: 10,
                  shadowOffsetX: 0,
                  shadowColor: 'rgba(0, 0, 0, 0.5)',
                },
              },
            },
          ],
        })
    },
    getLevelOption() {
        return [
          {
            itemStyle: {
              borderColor: '#777',
              borderWidth: 0,
              gapWidth: 1
            },
            upperLabel: {
              show: false
            }
          },
          {
            itemStyle: {
              borderColor: '#555',
              borderWidth: 5,
              gapWidth: 1
            },
            emphasis: {
              itemStyle: {
                borderColor: '#ddd'
              }
            }
          },
          {
            colorSaturation: [0.35, 0.5],
            itemStyle: {
              borderWidth: 5,
              gapWidth: 1,
              borderColorSaturation: 0.6
            }
          }
        ];
      },
    setTreeChart(){
        // 基于准备好的dom,初始化echarts实例
        var myChart = echarts.init(document.getElementById('visualization'))
        // 绘制图表
        myChart.setOption({
          title: {
              text: 'visualization',
              left: 'center'
          },
          tooltip: {
              formatter: function(info) {
                  var value = info.value;
                  var treePathInfo = info.treePathInfo;
                  var treePath = [];
                  for (var i = 1; i < treePathInfo.length; i++) {
                      treePath.push(treePathInfo[i].name);
                  }
                  return [
                      '<div class="tooltip-title">' +
                      echarts.format.encodeHTML(treePath.join('/')) +
                      '</div>',
                      'size: ' + echarts.format.addCommas(value) + ' KB'
                  ].join('');
              }
          },
          series: [{
              name: 'root',
              type: 'treemap',
              visibleMin: 300,
              label: {
                  show: true,
                  formatter: '{b}'
              },
              upperLabel: {
                  show: true,
                  height: 30
              },
              itemStyle: {
                  borderColor: '#fff'
              },
              levels: this.getLevelOption(),
              data: ${JSON.stringify(allData.treeData)},
          }]
      })
    }
  },
  mounted(){
    this.setPieChart()
    this.setTreeChart()
  }
})
</script>
</html>
  `
;

  return chartScript;
};

打包、查看

最后运行打包命令,然后打开生成的 html 文件即可查看

完整源码: https://github.com/sanxin-lin/vite-plugin-visualizer/tree/main/src/vite-plugins/vite-plugin-visualizer

结语

我是林三心

  • 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;
  • 一个偏前端的全干工程师;
  • 一个不正经的掘金作者;
  • 逗比的B站up主;
  • 不帅的小红书博主;
  • 喜欢打铁的篮球菜鸟;
  • 喜欢历史的乏味少年;
  • 喜欢rap的五音不全弱鸡

如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 

广州的兄弟可以约饭哦,或者约球~我负责打铁,你负责进球,谢谢~

相关推荐

  • 大模型「进化手册」:英伟达这次终于放大招了!
  • SpringCloud 微服务迁移到 Kubernetes 容器化完整流程
  • 不要小看Redis,真的强!!
  • ChatGPT入门指南:一文了解如何获取GPT4账号及AI绘图应用
  • 如何给application.yml文件的敏感信息加密?
  • 浅谈前端出现率高的设计模式
  • 华为全面完成5G-A技术性能测试;苹果将在iOS 18上推出生成式AI功能;Mojo编程语言发布 Mac 版本|极客头条
  • 看这里!!1024个程序员关于AI PC 的联想
  • 面试官:如何判断两个数组的内容是否相等???
  • 强化学习的一周「GitHub 热点速览」
  • 这些过时的前端技术请不要再继续学了!
  • 如何使用 Pinia ORM 管理 Vue 中的状态
  • 知乎热帖:为什么很多人在一家公司工作 2-3 年就会跳槽?
  • 简单有效!Direct Inversion: 三行代码提升基于Diffusion的图像编辑效果
  • 复旦大学自然语言处理实验室:如何构建和训练ChatGPT
  • 提升图神经网络性能方法综述
  • 前端程序员是怎么做物联网开发的
  • 8 城联动,1024 程序员节技术嘉年华成功举办!
  • 聚力谋发展,开源耀星城,2023 CCF中国开源大会圆满落幕
  • 一个实验性的开源项目DB-GPT:使用本地大模型与数据和环境进行交互