Vue项目组件复用次数统计

一、安装依赖

webpack-stats-plugin是一个用于生成 Webpack 构建统计信息的插件,通过输出 JSON 文件帮助开发者分析模块依赖、文件大小和构建性能。

bash 复制代码
npm install --save-dev webpack-stats-plugin
# 或
yarn add --dev webpack-stats-plugin

二、基础配置

javascript 复制代码
import { StatsWriterPlugin } from 'webpack-stats-plugin'

module.exports = {
  // 其他 Webpack 配置
  plugins: [
    new StatsWriterPlugin({
      filename: 'stats.json', // 输出文件名
      transform: (data) => {
          const cleanedData = {
            modules: data.modules.map((module) => ({
              ...module,
              name: module.name.replace(/ \+ \d+ modules$/, '')
            }))
          }
          return JSON.stringify(cleanedData, null, 2)
      },
      stats: {               // 自定义统计选项
        assets: true,        // 包含资源信息
        chunkModules: true,  // 包含模块依赖
        excludeAssets: /\.(jpg|png|gif)$/, // 排除图片资源[2,4](@ref)
      }
    })
  ]
};

运行构建后会在输出目录生成 stats.json文件

三、分析 stats.json 文件信息

js 复制代码
// analyze.js
const stats = require('./dist/stats.json')
const fs = require('fs')
const path = require('path')

const vueComponents = {}
stats.modules.forEach((module) => {
  if (module.name.match(/\.vue$/)) {
    const refs = new Set()

    // 统计模块引用
    if (module.reasons) {
      module.reasons.forEach((reason) => {
        if (reason.moduleName) refs.add(reason.moduleName)
      })
    }

    vueComponents[module.name] = refs.size
  }
})

// 创建输出内容 analyze.txt 和 module.js
const outputLines = ['组件复用率统计 (含路由引用):']
const outputLineJson = []
Object.entries(vueComponents)
  .sort((a, b) => b[1] - a[1])
  .forEach(([comp, count]) => {
    const status = count >= 2 ? '✅ 高频复用' : '⚠️ 低频使用'
    outputLines.push(`${status} [${count}次] ${comp}`)
    outputLineJson.push({ path: comp, count })
  })

// 6. 同时输出到控制台和文件
const outputPath = path.resolve(__dirname, 'analyze.txt')

// 方法1:使用writeFileSync同步写入(推荐)
fs.writeFileSync(outputPath, outputLines.join('\n'), 'utf-8')
fs.writeFileSync(
  'module.js',
  `const componentData = ${JSON.stringify(outputLineJson, null, 2)}`
)

执行node analyze.js 生成 module.js 文件

四、html文件引用生成的module.js

html 复制代码
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>组件复用度分析报告</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <style>
      :root {
        --primary: #3498db;
        --secondary: #2ecc71;
        --accent: #9b59b6;
        --warning: #f39c12;
        --text: #2c3e50;
        --light-text: #7f8c8d;
        --bg: #f8f9fa;
        --card-bg: #ffffff;
        --border: #e1e4e8;
        --success: #27ae60;
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
        line-height: 1.6;
        color: var(--text);
        background-color: var(--bg);
        max-width: 1200px;
        margin: 0 auto;
        padding: 20px;
      }

      header {
        text-align: center;
        margin-bottom: 30px;
        padding-bottom: 20px;
        border-bottom: 2px solid var(--border);
      }

      h1 {
        color: var(--primary);
        margin-bottom: 10px;
        font-size: 2.2rem;
      }

      .subtitle {
        color: var(--light-text);
        font-size: 1.1rem;
      }

      .metrics {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
        gap: 15px;
        margin-bottom: 30px;
      }

      .metric-card {
        background: var(--card-bg);
        border-radius: 10px;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
        padding: 20px;
        text-align: center;
        transition: transform 0.3s;
      }

      .metric-card:hover {
        transform: translateY(-5px);
        box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
      }

      .metric-value {
        font-size: 2.5rem;
        font-weight: bold;
        margin: 10px 0;
      }

      .metric-high {
        color: var(--accent);
      }

      .metric-med {
        color: var(--primary);
      }

      .metric-low {
        color: var(--warning);
      }

      .controls {
        display: flex;
        justify-content: space-between;
        margin: 20px 0;
        flex-wrap: wrap;
        gap: 15px;
      }

      .search-box {
        flex: 1;
        min-width: 250px;
        position: relative;
      }

      .search-box input {
        width: 100%;
        padding: 10px 15px;
        border: 1px solid var(--border);
        border-radius: 4px;
        font-size: 1rem;
      }

      .filter-options {
        display: flex;
        gap: 15px;
        flex-wrap: wrap;
      }

      .filter-btn {
        padding: 8px 15px;
        background: var(--card-bg);
        border: 1px solid var(--border);
        border-radius: 4px;
        cursor: pointer;
        transition: all 0.3s;
      }

      .filter-btn.active {
        background: var(--primary);
        color: white;
        border-color: var(--primary);
      }

      .reuse-list {
        background: var(--card-bg);
        border-radius: 8px;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
        overflow: hidden;
        margin-bottom: 20px;
      }

      .component-card {
        padding: 15px;
        border-bottom: 1px solid var(--border);
        transition: background 0.2s;
      }

      .component-card:hover {
        background: #f9f9f9;
      }

      .component-card.high-reuse {
        border-left: 4px solid var(--accent);
      }

      .component-card.med-reuse {
        border-left: 4px solid var(--primary);
      }

      .component-card.low-reuse {
        border-left: 4px solid var(--warning);
      }

      .path {
        font-size: 0.85rem;
        color: var(--light-text);
        word-break: break-word;
        margin: 5px 0;
      }

      .count {
        font-weight: bold;
        font-size: 1.1rem;
        display: inline-block;
        padding: 2px 8px;
        border-radius: 12px;
        background: #f0f7ff;
      }

      .count.high {
        background: #f0e6ff;
        color: var(--accent);
      }

      .usage-bar {
        height: 8px;
        background-color: #e0e0e0;
        border-radius: 4px;
        margin: 10px 0;
        overflow: hidden;
      }

      .usage-fill {
        height: 100%;
        border-radius: 4px;
      }

      .usage-high {
        background: linear-gradient(to right, var(--accent), #8e44ad);
      }

      .usage-med {
        background: linear-gradient(to right, var(--primary), #2980b9);
      }

      .usage-low {
        background: linear-gradient(to right, var(--warning), #e67e22);
      }

      .footer {
        margin-top: 40px;
        padding-top: 20px;
        border-top: 1px solid var(--border);
      }

      .chart-container {
        position: relative;
        height: 400px;
        margin: 40px 0;
      }

      .pagination {
        display: flex;
        justify-content: center;
        margin: 20px 0;
        gap: 10px;
      }

      .pagination button {
        padding: 8px 15px;
        background: var(--primary);
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
      }

      .pagination button:disabled {
        background: #bdc3c7;
        cursor: not-allowed;
      }

      .analysis-section {
        background: var(--card-bg);
        border-radius: 8px;
        padding: 25px;
        margin: 30px 0;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
      }

      .badge {
        display: inline-block;
        padding: 3px 8px;
        border-radius: 4px;
        font-size: 0.8rem;
        font-weight: bold;
        margin-left: 10px;
      }

      .badge.high {
        background: #e8d6ff;
        color: var(--accent);
      }

      .badge.med {
        background: #d6eaff;
        color: var(--primary);
      }

      .badge.low {
        background: #ffedcc;
        color: #e67e22;
      }

      .recommendation {
        padding: 15px;
        background: #e8f4ff;
        border-left: 4px solid var(--primary);
        margin: 15px 0;
        border-radius: 0 4px 4px 0;
      }

      @media (max-width: 768px) {
        .metrics {
          grid-template-columns: 1fr;
        }

        .controls {
          flex-direction: column;
        }
      }
    </style>
  </head>
  <body>
    <header>
      <h1>组件复用度分析报告</h1>
      <p class="subtitle">最后生成时间: <span id="timestamp"></span></p>
    </header>

    <section class="metrics">
      <div class="metric-card">
        <h2>复用组件总数</h2>
        <div class="metric-value" id="totalComponents">500</div>
        <p>项目中可复用组件数量</p>
      </div>
      <div class="metric-card">
        <h2>最高复用次数</h2>
        <div class="metric-value metric-high" id="maxCount">35</div>
        <p id="maxComponentName">loading-animation 组件</p>
      </div>
      <div class="metric-card">
        <h2>平均复用率</h2>
        <div class="metric-value metric-med" id="avgRate">9.7</div>
        <p>所有组件平均复用次数</p>
      </div>
    </section>

    <section class="controls">
      <div class="search-box">
        <input
          type="text"
          id="searchInput"
          placeholder="搜索组件路径或名称..."
        />
      </div>
      <div class="filter-options">
        <div class="filter-btn active" data-filter="all">全部组件</div>
        <div class="filter-btn" data-filter="high">高频 (>10次)</div>
        <div class="filter-btn" data-filter="med">中频 (5-10次)</div>
        <div class="filter-btn" data-filter="low">低频 (<5次)</div>
      </div>
    </section>

    <section>
      <h2>
        组件复用排行榜 <span class="badge high" id="visibleCount">10</span>
      </h2>
      <div class="reuse-list" id="reuseList">
        <!-- 组件列表将通过JavaScript动态生成 -->
      </div>
      <div class="pagination">
        <button id="prevPage">上一页</button>
        <span id="pageInfo">第 1 页 / 共 50 页</span>
        <button id="nextPage">下一页</button>
      </div>
    </section>

    <section class="chart-container">
      <canvas id="reuseChart"></canvas>
    </section>

    <section class="analysis-section">
      <h2>深度分析报告</h2>

      <div class="recommendation">
        <h3>✅ 最佳实践</h3>
        <p>
          项目中已形成良好的组件复用机制,尤其基础组件(如加载动画、模态框、标签)复用率较高,符合组件化开发原则。
        </p>
      </div>

      <h3>高频组件分析 <span class="badge high">复用>10次</span></h3>
      <ul>
        <li>
          <strong>loading-animation (35次)</strong
          >:作为基础动画组件被全局引用,建议抽象为通用服务
        </li>
        <li>
          <strong>tag-label (20次)</strong
          >:标签组件在多场景复用,可扩展样式配置接口
        </li>
        <li>
          <strong>IptFeedList (11次)</strong
          >:信息流组件实现业务模块复用,符合页面深度复用原则
        </li>
      </ul>

      <h3>中频组件优化建议 <span class="badge med">复用5-10次</span></h3>
      <ul>
        <li>建立组件文档系统,提升<code>PersonalCard</code>等组件的可发现性</li>
        <li>将<code>IptModal</code>拆分为基础模态框+业务扩展层,提高灵活性</li>
        <li>对<code>show-image</code>组件增加预览功能增强</li>
      </ul>

      <h3>低频组件改进策略 <span class="badge low">复用<5次</span></h3>
      <ul>
        <li>审查<code>circle-item-card</code>等专用组件是否可合并</li>
        <li>分析<code>search-com</code>复用率低的原因(设计/需求问题)</li>
        <li>建立组件淘汰机制,对6个月无复用的组件归档处理</li>
      </ul>

      <h3>架构优化建议</h3>
      <ol>
        <li>
          建立三级组件体系:基础UI组件(15+复用)|业务组件(5-15复用)|场景组件(<5次)
        </li>
        <li>引入鸿蒙OS的<code>@Reusable</code>装饰器机制实现组件缓存</li>
        <li>使用OpenHarmony的<code>aboutToReuse</code>生命周期管理复用状态</li>
        <li>配置自动化扫描工具,每月生成复用报告</li>
      </ol>
    </section>

    <footer class="footer">
      <p>
        © 2025 前端架构组 | 组件化规范 v2.1 | 数据更新时间:
        <span id="updateTime"></span>
      </p>
    </footer>

    <script src="./module.js"></script>
    <script>
      // 页面初始化
      document.addEventListener('DOMContentLoaded', () => {
        // 设置时间信息
        const now = new Date()
        document.getElementById('timestamp').textContent = now.toLocaleString()
        document.getElementById('updateTime').textContent = now
          .toISOString()
          .split('T')[0]

        // 计算统计指标
        const totalComponents = componentData.length
        const maxComponent = componentData[0]
        const totalCount = componentData.reduce(
          (sum, item) => sum + item.count,
          0
        )
        const avgRate = (totalCount / totalComponents).toFixed(1)

        // 更新指标
        document.getElementById('totalComponents').textContent = totalComponents
        document.getElementById('maxCount').textContent = maxComponent.count
        document.getElementById('maxComponentName').textContent =
          getNameFromPath(maxComponent.path)
        document.getElementById('avgRate').textContent = avgRate

        // 初始化分页
        initPagination(componentData)

        // 初始化图表
        initChart()

        // 初始化搜索和筛选
        initSearchFilter()
      })

      // 从路径提取组件名
      function getNameFromPath(path) {
        const parts = path.split('/')
        const fileName =
          parts[parts.length - 1] === 'index.vue'
            ? parts[parts.length - 2]
            : parts[parts.length - 1].replace('.vue', '')
        return fileName || path
      }

      // 分页实现
      function initPagination(data) {
        const itemsPerPage = 10
        let currentPage = 1
        let filteredData = [...data]
        const totalPages = Math.ceil(filteredData.length / itemsPerPage)

        // 更新分页信息
        const updatePageInfo = () => {
          document.getElementById('pageInfo').textContent =
            `第 ${currentPage} 页 / 共 ${totalPages} 页`
          document.getElementById('prevPage').disabled = currentPage === 1
          document.getElementById('nextPage').disabled =
            currentPage === totalPages
          document.getElementById('visibleCount').textContent = itemsPerPage
        }

        // 渲染当前页
        const renderCurrentPage = () => {
          const startIndex = (currentPage - 1) * itemsPerPage
          const endIndex = Math.min(
            startIndex + itemsPerPage,
            filteredData.length
          )
          const pageData = filteredData.slice(startIndex, endIndex)

          const listContainer = document.getElementById('reuseList')
          listContainer.innerHTML = ''

          pageData.forEach((item) => {
            const card = createComponentCard(item)
            listContainer.appendChild(card)
          })

          updatePageInfo()
        }

        // 创建组件卡片
        const createComponentCard = (item) => {
          const card = document.createElement('div')
          card.className = 'component-card'

          // 分类复用级别
          let reuseClass = 'low-reuse'
          let usageClass = 'usage-low'
          if (item.count > 10) {
            reuseClass = 'high-reuse'
            usageClass = 'usage-high'
          } else if (item.count > 5) {
            reuseClass = 'med-reuse'
            usageClass = 'usage-med'
          }

          card.classList.add(reuseClass)

          // 计算使用条形的宽度比例
          const percentage = Math.min(100, (item.count / 35) * 100)

          // 提取组件名称
          const name = getNameFromPath(item.path)

          card.innerHTML = `
                    <h3>${name} <span class="count ${item.count > 10 ? 'high' : item.count > 5 ? 'med' : ''}">${item.count}次</span></h3>
                    <div class="path">${item.path}</div>
                    <div class="usage-bar">
                        <div class="usage-fill ${usageClass}" style="width: ${percentage}%"></div>
                    </div>
                `

          return card
        }

        // 分页按钮事件
        document.getElementById('prevPage').addEventListener('click', () => {
          if (currentPage > 1) {
            currentPage--
            renderCurrentPage()
          }
        })

        document.getElementById('nextPage').addEventListener('click', () => {
          if (currentPage < totalPages) {
            currentPage++
            renderCurrentPage()
          }
        })

        // 初始渲染
        renderCurrentPage()
      }

      // 图表初始化
      function initChart() {
        const ctx = document.getElementById('reuseChart').getContext('2d')

        // 按复用次数分组统计
        const reuseLevels = {
          high: componentData.filter((item) => item.count > 10).length,
          medium: componentData.filter(
            (item) => item.count > 5 && item.count <= 10
          ).length,
          low: componentData.filter((item) => item.count <= 5).length
        }

        // 取复用次数TOP20组件
        const topComponents = [...componentData].slice(0, 20)

        new Chart(ctx, {
          type: 'bar',
          data: {
            labels: topComponents.map((item) => getNameFromPath(item.path)),
            datasets: [
              {
                label: '组件复用次数',
                data: topComponents.map((item) => item.count),
                backgroundColor: [
                  'rgba(155, 89, 182, 0.7)', // 高频
                  'rgba(52, 152, 219, 0.7)', // 中频
                  'rgba(243, 156, 18, 0.7)' // 低频
                ],
                borderWidth: 1
              }
            ]
          },
          options: {
            responsive: true,
            maintainAspectRatio: false,
            scales: {
              y: {
                beginAtZero: true,
                title: {
                  display: true,
                  text: '复用次数'
                }
              },
              x: {
                title: {
                  display: true,
                  text: '组件名称'
                },
                ticks: {
                  autoSkip: false,
                  maxRotation: 45,
                  minRotation: 45
                }
              }
            },
            plugins: {
              legend: {
                position: 'top'
              },
              title: {
                display: true,
                text: '复用率TOP20组件分布',
                font: {
                  size: 16
                }
              },
              tooltip: {
                callbacks: {
                  footer: (tooltipItems) => {
                    const item = topComponents[tooltipItems[0].dataIndex]
                    return `路径: ${item.path}`
                  }
                }
              }
            }
          }
        })
      }

      // 搜索和筛选功能
      function initSearchFilter() {
        const searchInput = document.getElementById('searchInput')
        const filterButtons = document.querySelectorAll('.filter-btn')
        let currentFilter = 'all'

        // 搜索功能
        searchInput.addEventListener('input', (e) => {
          filterComponents(e.target.value, currentFilter)
        })

        // 筛选按钮
        filterButtons.forEach((btn) => {
          btn.addEventListener('click', () => {
            filterButtons.forEach((b) => b.classList.remove('active'))
            btn.classList.add('active')
            currentFilter = btn.dataset.filter
            filterComponents(searchInput.value, currentFilter)
          })
        })
      }

      // 组件筛选逻辑
      function filterComponents(searchTerm = '', filter = 'all') {
        const filteredData = componentData.filter((item) => {
          // 搜索过滤
          const matchSearch =
            item.path.toLowerCase().includes(searchTerm.toLowerCase()) ||
            getNameFromPath(item.path)
              .toLowerCase()
              .includes(searchTerm.toLowerCase())

          // 复用频率过滤
          let matchFilter = true
          switch (filter) {
            case 'high':
              matchFilter = item.count > 10
              break
            case 'med':
              matchFilter = item.count > 5 && item.count <= 10
              break
            case 'low':
              matchFilter = item.count <= 5
              break
          }

          return matchSearch && matchFilter
        })

        // 更新分页数据
        initPagination(filteredData)
      }
    </script>
  </body>
</html>
相关推荐
gnip5 分钟前
做个交通信号灯特效
前端·javascript
小小小小宇6 分钟前
Webpack optimization
前端
尝尝你的优乐美8 分钟前
前端查缺补漏系列(二)JS数组及其扩展
前端·javascript·面试
咕噜签名分发可爱多10 分钟前
苹果iOS应用ipa文件安装之前?为什么需要签名?不签名能用么?
前端
她说人狗殊途24 分钟前
Ajax笔记
前端·笔记·ajax
yqcoder33 分钟前
33. css 如何实现一条 0.5 像素的线
前端·css
excel1 小时前
Nuxt 3 + PWA 通知完整实现指南(Web Push)
前端·后端
yuanmenglxb20041 小时前
构建工具和脚手架:从源码到dist
前端·webpack
rit84324991 小时前
Web学习:SQL注入之联合查询注入
前端·sql·学习
啃火龙果的兔子1 小时前
Parcel 使用详解:零配置的前端打包工具
前端