一、安装依赖
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>