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>
相关推荐
Ratten2 分钟前
解决 error when starting dev server TypeError crypto$2.getRandomValues
前端
coding随想5 分钟前
深入浅出DOM3合成事件(Composition Events):如何处理输入法编辑器(IME)的复杂输入流程
前端
六月的雨在掘金5 分钟前
狼人杀法官版,EdgeOne 带你轻松上手狼人杀
前端·后端
Ratten10 分钟前
【npm 解决】---- TypeError: crypto.hash is not a function
前端
前端小大白10 分钟前
JavaScript 循环三巨头:for vs forEach vs map 终极指南
前端·javascript·面试
晴空雨12 分钟前
面试题:如何判断一个对象是否为可迭代对象?
前端·javascript·面试
嘻嘻__13 分钟前
掘金沸点屏蔽脚本分享
前端·掘金社区
用户479492835691514 分钟前
🎨 Prettier 深度解析:从历史演进到内部格式化引擎的完整拆解
前端
Man14 分钟前
uniapp中使用unocss适配多端
前端·css
阿虎儿15 分钟前
React 事件类型完全指南:深入理解合成事件系统
前端·javascript·react.js