feat(docs): add interactive demo components for technical appendices
Add placeholder Vue components for visualizing technical concepts across multiple domains including frontend routing, browser rendering, cache design, queue design, database principles, API design, cloud services, and backend evolution. These components provide interactive educational content for the documentation. Update documentation structure to include new appendix sections and enhance existing content with visual components. Remove unused 'codex' dependency from package.json.
This commit is contained in:
+863
@@ -0,0 +1,863 @@
|
||||
<!--
|
||||
AssetFingerprintDemo.vue
|
||||
资源指纹(hash)演示
|
||||
|
||||
用途:
|
||||
展示前端构建中如何通过 hash 实现长期缓存策略。
|
||||
|
||||
交互功能:
|
||||
- 构建对比:对比无 hash 和带 hash 的文件名
|
||||
- 缓存演示:模拟浏览器缓存行为
|
||||
- 版本对比:展示文件变更对缓存的影响
|
||||
-->
|
||||
<template>
|
||||
<div class="asset-fingerprint-demo">
|
||||
<div class="control-panel">
|
||||
<div class="title-section">
|
||||
<span class="icon">🔖</span>
|
||||
<span class="title">资源指纹 (Hash)</span>
|
||||
<span class="subtitle">长期缓存与版本控制</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="control-btn" @click="simulateBuild">
|
||||
🔄 重新构建
|
||||
</button>
|
||||
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showHash"
|
||||
@change="updateFileNames"
|
||||
>
|
||||
<span class="toggle-text">启用 Hash</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- 文件列表 -->
|
||||
<div class="files-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📁 构建产物</span>
|
||||
<span class="panel-stats">{{ files.length }} 个文件</span>
|
||||
</div>
|
||||
|
||||
<div class="files-list">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
class="file-item"
|
||||
:class="{
|
||||
changed: file.changed,
|
||||
selected: selectedFile?.id === file.id,
|
||||
'with-hash': showHash
|
||||
}"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<div class="file-icon" :class="file.type">
|
||||
{{ getFileIcon(file.type) }}
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name-row">
|
||||
<span class="file-base">{{ file.baseName }}</span>
|
||||
<span v-if="showHash" class="file-hash">.{{ file.hash }}</span>
|
||||
<span class="file-ext">.{{ file.ext }}</span>
|
||||
<span v-if="file.changed" class="changed-badge">更新</span>
|
||||
</div>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ formatSize(file.size) }}</span>
|
||||
<span class="dot">•</span>
|
||||
<span class="file-mtime">{{ formatTime(file.mtime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浏览器缓存模拟 -->
|
||||
<div class="cache-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">🌐 浏览器缓存</span>
|
||||
<span class="cache-stats">
|
||||
命中: {{ cacheHits }} | 未命中: {{ cacheMisses }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cache-visualization">
|
||||
<div class="cache-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color hit"></span>
|
||||
<span>缓存命中 (Hash 匹配)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color miss"></span>
|
||||
<span>缓存未命中 (Hash 变化)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color new"></span>
|
||||
<span>新文件 (无缓存)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-blocks">
|
||||
<div
|
||||
v-for="(block, index) in cacheBlocks"
|
||||
:key="index"
|
||||
class="cache-block"
|
||||
:class="block.status"
|
||||
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||
>
|
||||
<div class="block-icon">{{ block.icon }}</div>
|
||||
<div class="block-name">{{ block.name }}</div>
|
||||
<div v-if="block.hash" class="block-hash">{{ block.hash }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-summary">
|
||||
<h4>📊 缓存策略效果</h4>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ cacheHitRate }}%</div>
|
||||
<div class="stat-label">缓存命中率</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ bandwidthSaved }}</div>
|
||||
<div class="stat-label">节省带宽</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ loadTime }}</div>
|
||||
<div class="stat-label">平均加载时间</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件详情 -->
|
||||
<div v-if="selectedFile" class="file-details">
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon" :class="selectedFile.type">
|
||||
{{ getFileIcon(selectedFile.type) }}
|
||||
</span>
|
||||
<div class="detail-title-wrap">
|
||||
<span class="detail-title">{{ selectedFile.name }}</span>
|
||||
<span class="detail-path">dist/{{ selectedFile.path }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="selectedFile = null">×</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<div class="detail-section">
|
||||
<h4>📋 文件信息</h4>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">大小:</span>
|
||||
<span class="info-value">{{ formatSize(selectedFile.size) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">类型:</span>
|
||||
<span class="info-value">{{ selectedFile.type.toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">修改时间:</span>
|
||||
<span class="info-value">{{ formatTime(selectedFile.mtime) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Hash:</span>
|
||||
<span class="info-value hash">{{ selectedFile.hash }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="selectedFile.dependencies?.length">
|
||||
<h4>🔗 依赖的模块 ({{ selectedFile.dependencies.length }})</h4>
|
||||
<div class="deps-tags">
|
||||
<span
|
||||
v-for="depId in selectedFile.dependencies"
|
||||
:key="depId"
|
||||
class="dep-tag"
|
||||
:style="{ background: getNode(depId)?.color || 'var(--vp-c-brand)' }"
|
||||
@click="selectFile(getNode(depId))"
|
||||
>
|
||||
{{ getNode(depId)?.name || depId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h4>💡 缓存策略</h4>
|
||||
<div class="cache-strategy">
|
||||
<p v-if="showHash">
|
||||
✅ <strong>启用 Hash</strong>:文件名包含内容哈希 ({{ selectedFile.hash }})。
|
||||
文件内容变化时,URL 会改变,浏览器会重新请求。
|
||||
适合配置 <code>Cache-Control: immutable</code> 长期缓存。
|
||||
</p>
|
||||
<p v-else>
|
||||
⚠️ <strong>无 Hash</strong>:文件名固定为 <code>{{ selectedFile.baseName }}.{{ selectedFile.ext }}</code>。
|
||||
更新文件后,需要手动刷新缓存或使用版本号查询参数。
|
||||
容易遇到"缓存不更新"的问题。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>资源指纹的作用:</strong>
|
||||
通过给文件名添加内容哈希(如 main.a3f7b2c.js),可以实现
|
||||
<strong>永久缓存</strong>策略。
|
||||
只有文件内容变化时哈希才会改变,浏览器才会重新下载。
|
||||
这样用户每次访问都能享受极速加载,同时又能及时获取最新代码。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const showHash = ref(true)
|
||||
const selectedNode = ref(null)
|
||||
const selectedFile = computed(() => selectedNode.value)
|
||||
const cacheHits = ref(42)
|
||||
const cacheMisses = ref(8)
|
||||
|
||||
// 模拟文件数据
|
||||
const generateFiles = () => {
|
||||
const files = [
|
||||
{ id: 'main', name: 'main.a3f7b2c.js', baseName: 'main', ext: 'js', type: 'js', size: 125, hash: 'a3f7b2c', mtime: Date.now() - 86400000, dependencies: ['vendor', 'utils'] },
|
||||
{ id: 'vendor', name: 'vendor.e8d9a1b.js', baseName: 'vendor', ext: 'js', type: 'js', size: 450, hash: 'e8d9a1b', mtime: Date.now() - 172800000, dependencies: [] },
|
||||
{ id: 'utils', name: 'utils.c4b5d6e.js', baseName: 'utils', ext: 'js', type: 'js', size: 28, hash: 'c4b5d6e', mtime: Date.now() - 3600000, dependencies: [], changed: true },
|
||||
{ id: 'main-css', name: 'main.f2e8d4a.css', baseName: 'main', ext: 'css', type: 'css', size: 15, hash: 'f2e8d4a', mtime: Date.now() - 86400000, dependencies: [] },
|
||||
{ id: 'logo', name: 'logo.b7c3a9f.png', baseName: 'logo', ext: 'png', type: 'image', size: 12, hash: 'b7c3a9f', mtime: Date.now() - 259200000, dependencies: [] },
|
||||
{ id: 'index', name: 'index.html', baseName: 'index', ext: 'html', type: 'html', size: 2, hash: null, mtime: Date.now(), dependencies: ['main', 'main-css'] }
|
||||
]
|
||||
return files.map(f => ({ ...f, path: f.name }))
|
||||
}
|
||||
|
||||
const files = ref(generateFiles())
|
||||
|
||||
const cacheBlocks = computed(() => {
|
||||
return files.value
|
||||
.filter(f => f.type !== 'html')
|
||||
.map((f, i) => ({
|
||||
name: f.baseName,
|
||||
icon: getFileIcon(f.type),
|
||||
hash: showHash.value ? f.hash : null,
|
||||
status: f.changed ? 'miss' : showHash.value ? 'hit' : 'new'
|
||||
}))
|
||||
})
|
||||
|
||||
const cacheHitRate = computed(() => {
|
||||
const total = cacheBlocks.value.length
|
||||
const hits = cacheBlocks.value.filter(b => b.status === 'hit').length
|
||||
return Math.round((hits / total) * 100) || 0
|
||||
})
|
||||
|
||||
const bandwidthSaved = computed(() => {
|
||||
const total = files.value.reduce((sum, f) => sum + (f.size || 0), 0)
|
||||
const saved = Math.round(total * (cacheHitRate.value / 100))
|
||||
return saved + ' KB'
|
||||
})
|
||||
|
||||
const loadTime = computed(() => {
|
||||
const base = 200
|
||||
const hitRate = cacheHitRate.value
|
||||
return Math.round(base - (hitRate * 1.5)) + 'ms'
|
||||
})
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
const icons = {
|
||||
js: '📜',
|
||||
css: '🎨',
|
||||
image: '🖼️',
|
||||
html: '📄'
|
||||
}
|
||||
return icons[type] || '📄'
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size > 1024) return (size / 1024).toFixed(1) + ' MB'
|
||||
return size + ' KB'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const simulateBuild = () => {
|
||||
files.value = generateFiles()
|
||||
// 随机标记一些文件为已更改
|
||||
files.value.forEach(f => {
|
||||
f.changed = Math.random() > 0.7
|
||||
})
|
||||
}
|
||||
|
||||
const updateFileNames = () => {
|
||||
// 更新文件名显示
|
||||
}
|
||||
|
||||
const getNode = (id) => files.value.find(f => f.id === id)
|
||||
|
||||
onMounted(() => {
|
||||
simulateBuild()
|
||||
})
|
||||
|
||||
import { watch } from 'vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-fingerprint-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title-section .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.title-section .title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.title-section .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.toggle-label input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.files-panel,
|
||||
.cache-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.panel-stats,
|
||||
.cache-stats {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.files-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-item:hover,
|
||||
.file-item.selected {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.file-item.changed {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.file-icon.js {
|
||||
background: #f7df1e;
|
||||
}
|
||||
|
||||
.file-icon.css {
|
||||
background: #264de4;
|
||||
}
|
||||
|
||||
.file-icon.image {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-base {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.file-hash {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-ext {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.changed-badge {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-family: sans-serif;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cache-visualization {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cache-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.legend-color.hit {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.legend-color.miss {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.legend-color.new {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.cache-blocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cache-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.3s ease backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.cache-block.hit {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.cache-block.miss {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.cache-block.new {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.block-icon {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.block-name {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.block-hash {
|
||||
font-size: 0.6rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.cache-summary {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.cache-summary h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-icon.js {
|
||||
background: rgba(247, 223, 30, 0.2);
|
||||
}
|
||||
|
||||
.detail-icon.css {
|
||||
background: rgba(38, 77, 228, 0.2);
|
||||
}
|
||||
|
||||
.detail-icon.image {
|
||||
background: rgba(255, 107, 107, 0.2);
|
||||
}
|
||||
|
||||
.detail-title-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-path {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--vp-c-text-3);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.info-value.hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.deps-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.dep-tag {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.dep-tag:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cache-strategy {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.cache-strategy p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.cache-strategy code {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,588 @@
|
||||
<!--
|
||||
BuildPipelineDemo.vue
|
||||
构建流水线可视化演示
|
||||
|
||||
用途:
|
||||
展示前端工程化的完整构建流程,从源代码到生产部署的全过程。
|
||||
|
||||
交互功能:
|
||||
- 步骤播放:逐步展示构建流程的每个阶段
|
||||
- 速度控制:调整演示速度
|
||||
- 阶段详情:点击每个阶段查看详细信息
|
||||
-->
|
||||
<template>
|
||||
<div class="build-pipeline-demo">
|
||||
<div class="control-panel">
|
||||
<div class="title-section">
|
||||
<span class="icon">🏭</span>
|
||||
<span class="title">构建流水线</span>
|
||||
<span class="subtitle">从代码到部署的完整旅程</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button
|
||||
class="control-btn"
|
||||
@click="togglePlay"
|
||||
:class="{ active: isPlaying }"
|
||||
>
|
||||
{{ isPlaying ? '⏸ 暂停' : '▶ 播放' }}
|
||||
</button>
|
||||
<button class="control-btn outline" @click="reset">
|
||||
↺ 重置
|
||||
</button>
|
||||
<div class="speed-control">
|
||||
<label>速度:</label>
|
||||
<select v-model="playbackSpeed">
|
||||
<option :value="0.5">0.5x</option>
|
||||
<option :value="1">1x</option>
|
||||
<option :value="2">2x</option>
|
||||
<option :value="4">4x</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pipeline-visualization">
|
||||
<div class="pipeline-track">
|
||||
<div
|
||||
v-for="(stage, index) in stages"
|
||||
:key="stage.id"
|
||||
class="stage-node"
|
||||
:class="{
|
||||
completed: currentStage > index,
|
||||
active: currentStage === index,
|
||||
pending: currentStage < index
|
||||
}"
|
||||
@click="selectStage(index)"
|
||||
>
|
||||
<div class="stage-icon">{{ stage.icon }}</div>
|
||||
<div class="stage-info">
|
||||
<div class="stage-name">{{ stage.name }}</div>
|
||||
<div class="stage-duration" v-if="stageDurations[index]">
|
||||
{{ stageDurations[index] }}ms
|
||||
</div>
|
||||
</div>
|
||||
<div class="stage-status">
|
||||
<span v-if="currentStage > index" class="status-icon success">✓</span>
|
||||
<span v-else-if="currentStage === index" class="status-icon loading">
|
||||
<span class="spinner"></span>
|
||||
</span>
|
||||
<span v-else class="status-icon pending">○</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${(currentStage / stages.length) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-details" v-if="selectedStage !== null">
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon">{{ stages[selectedStage].icon }}</span>
|
||||
<span class="detail-title">{{ stages[selectedStage].name }}</span>
|
||||
</div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-section">
|
||||
<h4>📝 阶段说明</h4>
|
||||
<p>{{ stages[selectedStage].description }}</p>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>🔧 执行的工具</h4>
|
||||
<div class="tools-list">
|
||||
<span
|
||||
v-for="tool in stages[selectedStage].tools"
|
||||
:key="tool"
|
||||
class="tool-tag"
|
||||
>
|
||||
{{ tool }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>📂 输入输出</h4>
|
||||
<div class="io-info">
|
||||
<div class="io-item">
|
||||
<span class="io-label">输入:</span>
|
||||
<span class="io-value">{{ stages[selectedStage].input }}</span>
|
||||
</div>
|
||||
<div class="io-item">
|
||||
<span class="io-label">输出:</span>
|
||||
<span class="io-value">{{ stages[selectedStage].output }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>构建流水线的作用:</strong>
|
||||
就像工厂的生产线一样,代码也需要经过一系列"加工工序"才能变成用户可以访问的网站。
|
||||
每个阶段都有特定的任务,确保最终产出的代码是优化过、无错误且性能良好的。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const stages = [
|
||||
{
|
||||
id: 'lint',
|
||||
name: '代码检查',
|
||||
icon: '🔍',
|
||||
description: '使用 ESLint、Prettier 等工具检查代码规范,确保代码风格一致,提前发现潜在问题。',
|
||||
tools: ['ESLint', 'Prettier', 'Stylelint'],
|
||||
input: '源代码 (.js, .vue, .css)',
|
||||
output: '检查报告'
|
||||
},
|
||||
{
|
||||
id: 'transform',
|
||||
name: '代码转换',
|
||||
icon: '⚙️',
|
||||
description: '将现代 JavaScript/TypeScript 转换为兼容旧浏览器的代码,处理 JSX、Vue SFC 等。',
|
||||
tools: ['Babel', 'TypeScript', 'SWC'],
|
||||
input: 'ES6+/TS/JSX 源码',
|
||||
output: 'ES5 兼容代码'
|
||||
},
|
||||
{
|
||||
id: 'dependency',
|
||||
name: '依赖解析',
|
||||
icon: '📦',
|
||||
description: '分析模块依赖关系,构建依赖图谱,确定模块加载顺序。',
|
||||
tools: ['Webpack', 'Rollup', 'esbuild'],
|
||||
input: '入口文件 (main.js)',
|
||||
output: '依赖图谱'
|
||||
},
|
||||
{
|
||||
id: 'bundle',
|
||||
name: '模块打包',
|
||||
icon: '📚',
|
||||
description: '将所有模块合并成一个或多个 bundle,优化加载性能。',
|
||||
tools: ['Webpack', 'Vite', 'Parcel'],
|
||||
input: '模块文件',
|
||||
output: 'bundle 文件'
|
||||
},
|
||||
{
|
||||
id: 'optimize',
|
||||
name: '代码优化',
|
||||
icon: '✨',
|
||||
description: '压缩代码、Tree Shaking 移除无用代码、代码分割、生成 Source Map。',
|
||||
tools: ['Terser', 'esbuild', 'Webpack'],
|
||||
input: '未优化的 bundle',
|
||||
output: '优化后的代码'
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
name: '资源处理',
|
||||
icon: '🖼️',
|
||||
description: '处理图片、字体、CSS 等资源,生成资源指纹(hash),优化缓存策略。',
|
||||
tools: ['file-loader', 'url-loader', 'ImageMagick'],
|
||||
input: '原始资源文件',
|
||||
output: '带 hash 的资源'
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
name: '部署发布',
|
||||
icon: '🚀',
|
||||
description: '将构建产物上传到 CDN 或服务器,配置缓存策略,完成发布。',
|
||||
tools: ['AWS S3', 'Vercel', 'Netlify'],
|
||||
input: 'dist 目录',
|
||||
output: '线上网站'
|
||||
}
|
||||
]
|
||||
|
||||
const currentStage = ref(0)
|
||||
const selectedStage = ref(null)
|
||||
const isPlaying = ref(false)
|
||||
const playbackSpeed = ref(1)
|
||||
const stageDurations = ref({})
|
||||
let playInterval = null
|
||||
|
||||
const togglePlay = () => {
|
||||
if (isPlaying.value) {
|
||||
pausePlay()
|
||||
} else {
|
||||
startPlay()
|
||||
}
|
||||
}
|
||||
|
||||
const startPlay = () => {
|
||||
isPlaying.value = true
|
||||
playInterval = setInterval(() => {
|
||||
if (currentStage.value < stages.length) {
|
||||
const startTime = Date.now()
|
||||
stageDurations.value[currentStage.value] = Math.floor(Math.random() * 500 + 100)
|
||||
currentStage.value++
|
||||
} else {
|
||||
pausePlay()
|
||||
}
|
||||
}, 2000 / playbackSpeed.value)
|
||||
}
|
||||
|
||||
const pausePlay = () => {
|
||||
isPlaying.value = false
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval)
|
||||
playInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
pausePlay()
|
||||
currentStage.value = 0
|
||||
selectedStage.value = null
|
||||
stageDurations.value = {}
|
||||
}
|
||||
|
||||
const selectStage = (index) => {
|
||||
selectedStage.value = index
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Auto-start for demo
|
||||
setTimeout(() => {
|
||||
if (currentStage.value === 0) {
|
||||
startPlay()
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
pausePlay()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.build-pipeline-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title-section .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.title-section .title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.title-section .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.control-btn.outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.speed-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.speed-control select {
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pipeline-visualization {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pipeline-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 2px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
width: 100%;
|
||||
background: linear-gradient(180deg, var(--vp-c-brand), var(--vp-c-brand-dark));
|
||||
border-radius: 2px;
|
||||
transition: height 0.5s ease;
|
||||
}
|
||||
|
||||
.stage-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stage-node:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.stage-node.completed {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: rgba(var(--vp-c-brand-rgb), 0.1);
|
||||
}
|
||||
|
||||
.stage-node.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg);
|
||||
box-shadow: 0 0 0 3px rgba(var(--vp-c-brand-rgb), 0.2);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.stage-node.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px rgba(var(--vp-c-brand-rgb), 0.2); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(var(--vp-c-brand-rgb), 0.1); }
|
||||
}
|
||||
|
||||
.stage-icon {
|
||||
font-size: 1.5rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stage-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stage-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.stage-duration {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.stage-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-icon.success {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-icon.pending {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-top-color: var(--vp-c-brand);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stage-details {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-section p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tool-tag {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.io-info {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.io-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.io-label {
|
||||
color: var(--vp-c-text-3);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.io-value {
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stage-node {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stage-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+760
@@ -0,0 +1,760 @@
|
||||
<!--
|
||||
BundlerComparisonDemo.vue
|
||||
打包工具对比演示 (Vite/Webpack/Rollup)
|
||||
|
||||
用途:
|
||||
直观对比三大主流打包工具的差异和适用场景。
|
||||
|
||||
交互功能:
|
||||
- 工具切换:对比 Vite、Webpack、Rollup
|
||||
- 维度对比:构建速度、配置复杂度、生态丰富度等
|
||||
- 场景推荐:根据项目类型推荐最适合的工具
|
||||
-->
|
||||
<template>
|
||||
<div class="bundler-comparison-demo">
|
||||
<div class="control-panel">
|
||||
<div class="title-section">
|
||||
<span class="icon">⚖️</span>
|
||||
<span class="title">打包工具对比</span>
|
||||
<span class="subtitle">Vite vs Webpack vs Rollup</span>
|
||||
</div>
|
||||
<div class="view-controls">
|
||||
<button
|
||||
v-for="view in viewModes"
|
||||
:key="view.id"
|
||||
class="view-btn"
|
||||
:class="{ active: currentView === view.id }"
|
||||
@click="currentView = view.id"
|
||||
>
|
||||
{{ view.icon }} {{ view.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 雷达图对比视图 -->
|
||||
<div v-if="currentView === 'radar'" class="radar-view">
|
||||
<div class="radar-container">
|
||||
<svg viewBox="0 0 400 400" class="radar-chart">
|
||||
<!-- 背景网格 -->
|
||||
<g class="grid">
|
||||
<polygon
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
:points="getGridPoints(i * 20)"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-divider)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<!-- 轴线 -->
|
||||
<line
|
||||
v-for="(dim, i) in dimensions"
|
||||
:key="i"
|
||||
:x1="200"
|
||||
:y1="200"
|
||||
:x2="getAxisEnd(i).x"
|
||||
:y2="getAxisEnd(i).y"
|
||||
stroke="var(--vp-c-divider)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 数据区域 -->
|
||||
<g class="data-areas">
|
||||
<polygon
|
||||
v-for="(tool, toolIndex) in bundlers"
|
||||
:key="tool.id"
|
||||
:points="getDataPoints(tool.scores)"
|
||||
:fill="tool.color"
|
||||
:stroke="tool.borderColor"
|
||||
fill-opacity="0.2"
|
||||
stroke-width="2"
|
||||
class="data-polygon"
|
||||
:class="{ dimmed: highlightedTool && highlightedTool !== tool.id }"
|
||||
@mouseenter="highlightedTool = tool.id"
|
||||
@mouseleave="highlightedTool = null"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 维度标签 -->
|
||||
<g class="labels">
|
||||
<text
|
||||
v-for="(dim, i) in dimensions"
|
||||
:key="i"
|
||||
:x="getLabelPos(i).x"
|
||||
:y="getLabelPos(i).y"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
fill="var(--vp-c-text-1)"
|
||||
font-size="12"
|
||||
font-weight="bold"
|
||||
>
|
||||
{{ dim.name }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 图例 -->
|
||||
<div class="legend">
|
||||
<div
|
||||
v-for="tool in bundlers"
|
||||
:key="tool.id"
|
||||
class="legend-item"
|
||||
:class="{ dimmed: highlightedTool && highlightedTool !== tool.id }"
|
||||
@mouseenter="highlightedTool = tool.id"
|
||||
@mouseleave="highlightedTool = null"
|
||||
>
|
||||
<span class="legend-color" :style="{ background: tool.borderColor }"></span>
|
||||
<span class="legend-name">{{ tool.name }}</span>
|
||||
<span class="legend-desc">{{ tool.shortDesc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格对比视图 -->
|
||||
<div v-else-if="currentView === 'table'" class="table-view">
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>对比维度</th>
|
||||
<th v-for="tool in bundlers" :key="tool.id">
|
||||
<span class="tool-header">
|
||||
<span class="tool-icon" :style="{ background: tool.borderColor }">{{ tool.icon }}</span>
|
||||
{{ tool.name }}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(dim, dimIndex) in dimensions" :key="dim.key">
|
||||
<td class="dim-name">
|
||||
<span class="dim-icon">{{ dim.icon }}</span>
|
||||
{{ dim.name }}
|
||||
</td>
|
||||
<td
|
||||
v-for="tool in bundlers"
|
||||
:key="tool.id"
|
||||
class="score-cell"
|
||||
>
|
||||
<div class="score-bar-wrapper">
|
||||
<div
|
||||
class="score-bar"
|
||||
:style="{
|
||||
width: `${tool.scores[dimIndex] * 10}%`,
|
||||
background: tool.borderColor
|
||||
}"
|
||||
></div>
|
||||
<span class="score-value">{{ tool.scores[dimIndex] }}/10</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 场景推荐视图 -->
|
||||
<div v-else-if="currentView === 'recommend'" class="recommend-view">
|
||||
<div class="scenario-list">
|
||||
<div
|
||||
v-for="scenario in scenarios"
|
||||
:key="scenario.id"
|
||||
class="scenario-card"
|
||||
:class="{ expanded: expandedScenario === scenario.id }"
|
||||
>
|
||||
<div
|
||||
class="scenario-header"
|
||||
@click="toggleScenario(scenario.id)"
|
||||
>
|
||||
<span class="scenario-icon">{{ scenario.icon }}</span>
|
||||
<div class="scenario-title-wrap">
|
||||
<span class="scenario-name">{{ scenario.name }}</span>
|
||||
<span class="scenario-desc">{{ scenario.shortDesc }}</span>
|
||||
</div>
|
||||
<span class="expand-icon">{{ expandedScenario === scenario.id ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedScenario === scenario.id" class="scenario-content">
|
||||
<div class="recommendation">
|
||||
<div class="best-choice">
|
||||
<span class="choice-label">🏆 首选推荐</span>
|
||||
<div class="choice-content">
|
||||
<span
|
||||
class="tool-badge"
|
||||
:style="{ background: getTool(scenario.bestChoice).borderColor }"
|
||||
>
|
||||
{{ getTool(scenario.bestChoice).icon }} {{ getTool(scenario.bestChoice).name }}
|
||||
</span>
|
||||
<p class="choice-reason">{{ scenario.bestReason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="scenario.alternative" class="alternative">
|
||||
<span class="choice-label">🥈 备选方案</span>
|
||||
<div class="choice-content">
|
||||
<span
|
||||
class="tool-badge alt"
|
||||
:style="{ background: getTool(scenario.alternative).borderColor }"
|
||||
>
|
||||
{{ getTool(scenario.alternative).icon }} {{ getTool(scenario.alternative).name }}
|
||||
</span>
|
||||
<p class="choice-reason">{{ scenario.altReason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>选择建议:</strong>
|
||||
{{ currentView === 'radar' ? '雷达图展示了各工具在多个维度的能力分布,面积越大代表综合能力越强。' :
|
||||
currentView === 'table' ? '表格详细对比了各工具在每个维度的具体得分,方便精确对比。' :
|
||||
'根据你的项目类型和团队情况,选择最适合的工具往往比选择"最好"的工具更重要。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentView = ref('radar')
|
||||
const highlightedTool = ref(null)
|
||||
const expandedScenario = ref(null)
|
||||
|
||||
const viewModes = [
|
||||
{ id: 'radar', name: '雷达图', icon: '📊' },
|
||||
{ id: 'table', name: '对比表', icon: '📋' },
|
||||
{ id: 'recommend', name: '场景推荐', icon: '🎯' }
|
||||
]
|
||||
|
||||
const dimensions = [
|
||||
{ key: 'speed', name: '构建速度', icon: '⚡' },
|
||||
{ key: 'config', name: '配置难度', icon: '🔧' },
|
||||
{ key: 'ecosystem', name: '生态丰富', icon: '📦' },
|
||||
{ key: 'hmr', name: '热更新速度', icon: '🔥' },
|
||||
{ key: 'output', name: '产物优化', icon: '✨' },
|
||||
{ key: 'memory', name: '内存占用', icon: '💾' }
|
||||
]
|
||||
|
||||
const bundlers = [
|
||||
{
|
||||
id: 'vite',
|
||||
name: 'Vite',
|
||||
icon: '⚡',
|
||||
shortDesc: '下一代前端构建工具',
|
||||
color: 'rgba(100, 108, 255, 0.3)',
|
||||
borderColor: '#646cff',
|
||||
scores: [10, 8, 7, 10, 8, 9],
|
||||
features: ['原生 ESM', '极速 HMR', '基于 esbuild']
|
||||
},
|
||||
{
|
||||
id: 'webpack',
|
||||
name: 'Webpack',
|
||||
icon: '📦',
|
||||
shortDesc: '老牌强大的打包工具',
|
||||
color: 'rgba(142, 214, 251, 0.3)',
|
||||
borderColor: '#8ed6fb',
|
||||
scores: [5, 5, 10, 6, 9, 5],
|
||||
features: ['生态最丰富', 'loader/plugin 多', '配置灵活']
|
||||
},
|
||||
{
|
||||
id: 'rollup',
|
||||
name: 'Rollup',
|
||||
icon: '📜',
|
||||
shortDesc: 'JavaScript 模块打包器',
|
||||
color: 'rgba(255, 107, 107, 0.3)',
|
||||
borderColor: '#ff6b6b',
|
||||
scores: [7, 7, 6, 7, 10, 8],
|
||||
features: ['Tree Shaking', '输出最优', '适合库开发']
|
||||
}
|
||||
]
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'spa',
|
||||
icon: '🚀',
|
||||
name: '中小型 SPA 项目',
|
||||
shortDesc: '单页应用,快速开发',
|
||||
bestChoice: 'vite',
|
||||
bestReason: 'Vite 的极速冷启动和热更新让开发体验极佳,配置简单,是中小型项目的首选。',
|
||||
alternative: 'webpack',
|
||||
altReason: '如果需要大量自定义配置或依赖特定的 webpack loader,webpack 仍然是可靠的选择。'
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
icon: '📚',
|
||||
name: 'JavaScript 库/组件库',
|
||||
shortDesc: '打包发布 npm 包',
|
||||
bestChoice: 'rollup',
|
||||
bestReason: 'Rollup 生成的代码最干净,Tree Shaking 效果最好,非常适合打包 JavaScript 库。',
|
||||
alternative: 'vite',
|
||||
altReason: 'Vite 使用 Rollup 进行生产构建,同时提供更好的开发体验,也是现代库开发的好选择。'
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
icon: '🏢',
|
||||
name: '大型企业级应用',
|
||||
shortDesc: '复杂业务,多人协作',
|
||||
bestChoice: 'webpack',
|
||||
bestReason: 'Webpack 生态最成熟,loader 和 plugin 最丰富,能应对各种复杂场景和定制化需求。',
|
||||
alternative: 'vite',
|
||||
altReason: '如果团队追求更好的开发体验,且项目不需要太多自定义构建逻辑,Vite 也是值得考虑的选项。'
|
||||
},
|
||||
{
|
||||
id: 'ssg',
|
||||
icon: '📝',
|
||||
name: '静态站点生成 (SSG)',
|
||||
shortDesc: '文档站、博客、营销页',
|
||||
bestChoice: 'vite',
|
||||
bestReason: 'VitePress、Astro 等现代 SSG 工具都基于 Vite,开发体验好,构建速度快。',
|
||||
alternative: 'rollup',
|
||||
altReason: '一些轻量级 SSG 工具直接使用 Rollup,如果对产物体积要求极高可以考虑。'
|
||||
}
|
||||
]
|
||||
|
||||
// 雷达图计算
|
||||
const getGridPoints = (radius) => {
|
||||
const points = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (i * 60 - 90) * Math.PI / 180
|
||||
const x = 200 + radius * Math.cos(angle)
|
||||
const y = 200 + radius * Math.sin(angle)
|
||||
points.push(`${x},${y}`)
|
||||
}
|
||||
return points.join(' ')
|
||||
}
|
||||
|
||||
const getAxisEnd = (index) => {
|
||||
const angle = (index * 60 - 90) * Math.PI / 180
|
||||
return {
|
||||
x: 200 + 100 * Math.cos(angle),
|
||||
y: 200 + 100 * Math.sin(angle)
|
||||
}
|
||||
}
|
||||
|
||||
const getLabelPos = (index) => {
|
||||
const angle = (index * 60 - 90) * Math.PI / 180
|
||||
return {
|
||||
x: 200 + 125 * Math.cos(angle),
|
||||
y: 200 + 125 * Math.sin(angle)
|
||||
}
|
||||
}
|
||||
|
||||
const getDataPoints = (scores) => {
|
||||
const points = []
|
||||
for (let i = 0; i < scores.length; i++) {
|
||||
const angle = (i * 60 - 90) * Math.PI / 180
|
||||
const radius = scores[i] * 10
|
||||
const x = 200 + radius * Math.cos(angle)
|
||||
const y = 200 + radius * Math.sin(angle)
|
||||
points.push(`${x},${y}`)
|
||||
}
|
||||
return points.join(' ')
|
||||
}
|
||||
|
||||
const getTool = (id) => bundlers.find(b => b.id === id)
|
||||
|
||||
const toggleScenario = (id) => {
|
||||
expandedScenario.value = expandedScenario.value === id ? null : id
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
// Placeholder for play functionality in this component
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
// Placeholder for reset functionality
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bundler-comparison-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title-section .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.title-section .title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.title-section .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* 雷达图视图 */
|
||||
.radar-view {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.radar-container {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radar-chart {
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.data-polygon {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-polygon:hover {
|
||||
fill-opacity: 0.4;
|
||||
}
|
||||
|
||||
.data-polygon.dimmed {
|
||||
fill-opacity: 0.1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.legend {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legend-item:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.legend-item.dimmed {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.legend-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.legend-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 表格视图 */
|
||||
.table-view {
|
||||
margin-bottom: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: var(--vp-c-bg-soft);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.comparison-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dim-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.score-cell {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.score-bar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
min-width: 20px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 推荐视图 */
|
||||
.recommend-view {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.scenario-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.scenario-card.expanded {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.scenario-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.scenario-header:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.scenario-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.scenario-title-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scenario-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-content {
|
||||
padding: 0 1rem 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.recommendation {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.best-choice,
|
||||
.alternative {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.choice-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
padding-top: 0.3rem;
|
||||
}
|
||||
|
||||
.choice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tool-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tool-badge.alt {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.choice-reason {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.radar-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,532 @@
|
||||
<!--
|
||||
CodeSplittingDemo.vue
|
||||
代码分割演示
|
||||
|
||||
用途:
|
||||
展示如何通过代码分割实现按需加载,优化首屏性能。
|
||||
-->
|
||||
<template>
|
||||
<div class="code-splitting-demo">
|
||||
<div class="demo-header">
|
||||
<h3>✂️ 代码分割演示</h3>
|
||||
<p>按需加载,提升首屏速度</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<!-- 左侧:路由配置 -->
|
||||
<div class="routes-panel">
|
||||
<div class="panel-title">🚦 路由配置</div>
|
||||
<div class="routes-list">
|
||||
<div
|
||||
v-for="route in routes"
|
||||
:key="route.path"
|
||||
class="route-item"
|
||||
:class="{ active: currentRoute === route.path, loaded: route.loaded }"
|
||||
@click="navigateTo(route)"
|
||||
>
|
||||
<div class="route-info">
|
||||
<div class="route-path">{{ route.path }}</div>
|
||||
<div class="route-name">{{ route.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="route-load-info">
|
||||
<span v-if="route.loading" class="loading-badge">加载中...</span>
|
||||
<span v-else-if="route.loaded" class="loaded-badge">已缓存</span>
|
||||
<span v-else class="lazy-badge">按需加载</span>
|
||||
</div>
|
||||
|
||||
<div class="route-size">
|
||||
{{ formatSize(route.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:加载可视化 -->
|
||||
<div class="load-panel">
|
||||
<div class="panel-title">📊 加载分析</div>
|
||||
|
||||
<div class="load-visualization">
|
||||
<!-- 初始加载 -->
|
||||
<div class="load-section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">🚀</span>
|
||||
<span class="section-title">首屏加载</span>
|
||||
<span class="section-size">{{ formatSize(initialLoadSize) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="chunk-list">
|
||||
<div
|
||||
v-for="chunk in initialChunks"
|
||||
:key="chunk.name"
|
||||
class="chunk-item initial"
|
||||
:style="{ width: getChunkWidth(chunk.size) }"
|
||||
>
|
||||
<span class="chunk-name">{{ chunk.name }}</span>
|
||||
<span class="chunk-size">{{ formatSize(chunk.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按需加载 -->
|
||||
<div class="load-section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">📦</span>
|
||||
<span class="section-title">按需加载 (Lazy Loading)</span>
|
||||
<span class="section-size">{{ formatSize(lazyLoadSize) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="chunk-list">
|
||||
<div
|
||||
v-for="chunk in lazyChunks"
|
||||
:key="chunk.name"
|
||||
class="chunk-item lazy"
|
||||
:class="{ loaded: chunk.loaded }"
|
||||
:style="{ width: getChunkWidth(chunk.size) }"
|
||||
@click="loadChunk(chunk)"
|
||||
>
|
||||
<span class="chunk-status">{{ chunk.loaded ? '✓' : '○' }}</span>
|
||||
<span class="chunk-name">{{ chunk.name }}</span>
|
||||
<span class="chunk-size">{{ formatSize(chunk.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="lazy-tip">💡 点击上方模块可模拟按需加载</p>
|
||||
</div>
|
||||
|
||||
<!-- 优化效果 -->
|
||||
<div class="optimization-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">未优化总大小</span>
|
||||
<span class="summary-value original">{{ formatSize(totalSize) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-arrow">→</div>
|
||||
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">首屏加载</span>
|
||||
<span class="summary-value optimized">{{ formatSize(initialLoadSize) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-item savings">
|
||||
<span class="summary-label">节省</span>
|
||||
<span class="summary-value">{{ savingsPercent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>代码分割的核心思想:</strong>
|
||||
不是所有代码都需要在首屏加载。通过动态导入 `import()`,
|
||||
我们可以把非核心功能延迟到真正需要时再加载。
|
||||
这就像餐厅的点餐制——不是把所有菜一次性端上来,而是按需上菜。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// 模拟路由配置
|
||||
const routes = ref([
|
||||
{ path: '/', name: '首页', size: 45, loaded: true, loading: false },
|
||||
{ path: '/about', name: '关于我们', size: 28, loaded: false, loading: false },
|
||||
{ path: '/dashboard', name: '数据面板', size: 156, loaded: false, loading: false },
|
||||
{ path: '/settings', name: '系统设置', size: 89, loaded: false, loading: false },
|
||||
{ path: '/reports', name: '报表中心', size: 234, loaded: false, loading: false }
|
||||
])
|
||||
|
||||
const currentRoute = ref('/')
|
||||
|
||||
// 模拟代码块
|
||||
const initialChunks = ref([
|
||||
{ name: 'runtime', size: 3 },
|
||||
{ name: 'core', size: 42 }
|
||||
])
|
||||
|
||||
const lazyChunks = ref([
|
||||
{ name: 'about.chunk', size: 28, loaded: false },
|
||||
{ name: 'dashboard.chunk', size: 156, loaded: false },
|
||||
{ name: 'settings.chunk', size: 89, loaded: false },
|
||||
{ name: 'reports.chunk', size: 234, loaded: false }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const initialLoadSize = computed(() =>
|
||||
initialChunks.value.reduce((sum, c) => sum + c.size, 0)
|
||||
)
|
||||
|
||||
const lazyLoadSize = computed(() =>
|
||||
lazyChunks.value.reduce((sum, c) => sum + c.size, 0)
|
||||
)
|
||||
|
||||
const totalSize = computed(() => initialLoadSize.value + lazyLoadSize.value)
|
||||
|
||||
const savingsPercent = computed(() => {
|
||||
const saved = totalSize.value - initialLoadSize.value
|
||||
return Math.round((saved / totalSize.value) * 100)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const formatSize = (size) => {
|
||||
if (size > 1024) return (size / 1024).toFixed(1) + ' MB'
|
||||
return size + ' KB'
|
||||
}
|
||||
|
||||
const getChunkWidth = (size) => {
|
||||
const maxSize = Math.max(...initialChunks.value.map(c => c.size), ...lazyChunks.value.map(c => c.size))
|
||||
const percent = (size / maxSize) * 100
|
||||
return `${Math.max(percent, 20)}%`
|
||||
}
|
||||
|
||||
const navigateTo = (route) => {
|
||||
currentRoute.value = route.path
|
||||
|
||||
if (!route.loaded && !route.loading) {
|
||||
route.loading = true
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
route.loaded = true
|
||||
route.loading = false
|
||||
// 同步更新 chunk 状态
|
||||
const chunkName = route.path.slice(1) || 'index'
|
||||
const chunk = lazyChunks.value.find(c => c.name.includes(chunkName))
|
||||
if (chunk) chunk.loaded = true
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
|
||||
const loadChunk = (chunk) => {
|
||||
if (chunk.loaded) return
|
||||
chunk.loaded = true
|
||||
}
|
||||
|
||||
const selectFile = (file) => {
|
||||
// 简化处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-splitting-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-header h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.demo-header p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.routes-panel,
|
||||
.load-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.routes-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.route-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.route-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.route-item:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.route-item.active {
|
||||
background: rgba(100, 108, 255, 0.1);
|
||||
border-left: 3px solid #646cff;
|
||||
}
|
||||
|
||||
.route-item.loaded .route-path {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.route-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.route-path {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.route-name {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.route-load-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-badge,
|
||||
.loaded-badge,
|
||||
.lazy-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading-badge {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.loaded-badge {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lazy-badge {
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.route-size {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.load-visualization {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.load-section {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.section-size {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.chunk-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chunk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chunk-item.initial {
|
||||
background: rgba(100, 108, 255, 0.15);
|
||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.chunk-item.lazy {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chunk-item.lazy:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.chunk-item.lazy.loaded {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.chunk-status {
|
||||
font-size: 0.75rem;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chunk-name {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.chunk-size {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.lazy-tip {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.optimization-summary {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.optimization-summary h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.6rem 0.4rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stat-value.original {
|
||||
color: var(--vp-c-text-2);
|
||||
text-decoration: line-through;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-value.optimized {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.stat-value.savings {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.route-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.route-size {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+840
@@ -0,0 +1,840 @@
|
||||
<!--
|
||||
DependencyGraphDemo.vue
|
||||
依赖图谱可视化演示
|
||||
|
||||
用途:
|
||||
展示前端项目的依赖关系图,帮助理解模块如何相互引用。
|
||||
|
||||
交互功能:
|
||||
- 图谱可视化:以力导向图展示模块依赖关系
|
||||
- 节点交互:悬停/点击节点查看详情
|
||||
- 路径追踪:高亮显示两个模块间的依赖路径
|
||||
- 布局切换:切换不同的图谱布局方式
|
||||
-->
|
||||
<template>
|
||||
<div class="dependency-graph-demo">
|
||||
<div class="control-panel">
|
||||
<div class="title-section">
|
||||
<span class="icon">🕸️</span>
|
||||
<span class="title">依赖图谱</span>
|
||||
<span class="subtitle">模块依赖关系可视化</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="layout-control">
|
||||
<label>布局:</label>
|
||||
<select v-model="currentLayout">
|
||||
<option value="force">力导向图</option>
|
||||
<option value="circular">环形布局</option>
|
||||
<option value="hierarchical">层次布局</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="control-btn" @click="resetGraph">
|
||||
↺ 重置视图
|
||||
</button>
|
||||
|
||||
<button class="control-btn outline" @click="toggleAnimation">
|
||||
{{ isAnimating ? '⏸ 暂停' : '▶ 动画' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="graph-container" ref="graphContainer">
|
||||
<svg
|
||||
class="graph-svg"
|
||||
:viewBox="`0 0 ${width} ${height}`"
|
||||
@wheel.prevent="handleZoom"
|
||||
@mousedown="startDrag"
|
||||
@mousemove="handleDrag"
|
||||
@mouseup="endDrag"
|
||||
@mouseleave="endDrag"
|
||||
>
|
||||
<!-- 网格背景 -->
|
||||
<defs>
|
||||
<pattern
|
||||
id="grid"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d="M 40 0 L 0 0 0 40"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-divider)"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</pattern>
|
||||
|
||||
<!-- 箭头标记 -->
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="20"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon
|
||||
points="0 0, 10 3.5, 0 7"
|
||||
fill="var(--vp-c-text-3)"
|
||||
/>
|
||||
</marker>
|
||||
|
||||
<!-- 高亮箭头 -->
|
||||
<marker
|
||||
id="arrowhead-highlight"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="20"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon
|
||||
points="0 0, 10 3.5, 0 7"
|
||||
fill="var(--vp-c-brand)"
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- 连线 -->
|
||||
<g class="edges">
|
||||
<line
|
||||
v-for="edge in edges"
|
||||
:key="`${edge.source}-${edge.target}`"
|
||||
:x1="getNode(edge.source).x"
|
||||
:y1="getNode(edge.source).y"
|
||||
:x2="getNode(edge.target).x"
|
||||
:y2="getNode(edge.target).y"
|
||||
:stroke="edge.highlighted ? 'var(--vp-c-brand)' : 'var(--vp-c-text-3)'"
|
||||
:stroke-width="edge.highlighted ? 3 : 1.5"
|
||||
:marker-end="edge.highlighted ? 'url(#arrowhead-highlight)' : 'url(#arrowhead)'"
|
||||
class="edge-line"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 节点 -->
|
||||
<g class="nodes">
|
||||
<g
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
:transform="`translate(${node.x}, ${node.y})`"
|
||||
class="node"
|
||||
:class="{
|
||||
selected: selectedNode === node.id,
|
||||
highlighted: node.highlighted,
|
||||
entry: node.type === 'entry'
|
||||
}"
|
||||
@mouseenter="highlightNode(node.id)"
|
||||
@mouseleave="unhighlightNode"
|
||||
@click="selectNode(node.id)"
|
||||
>
|
||||
<!-- 节点外圈 -->
|
||||
<circle
|
||||
:r="node.size + 4"
|
||||
:fill="node.color"
|
||||
opacity="0.2"
|
||||
class="node-glow"
|
||||
/>
|
||||
|
||||
<!-- 节点主体 -->
|
||||
<circle
|
||||
:r="node.size"
|
||||
:fill="node.color"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
class="node-circle"
|
||||
/>
|
||||
|
||||
<!-- 节点图标 -->
|
||||
<text
|
||||
y="4"
|
||||
text-anchor="middle"
|
||||
fill="white"
|
||||
font-size="14"
|
||||
class="node-icon"
|
||||
>
|
||||
{{ node.icon }}
|
||||
</text>
|
||||
|
||||
<!-- 节点标签 -->
|
||||
<text
|
||||
:y="node.size + 18"
|
||||
text-anchor="middle"
|
||||
:fill="selectedNode === node.id ? 'var(--vp-c-brand)' : 'var(--vp-c-text-1)'"
|
||||
font-size="11"
|
||||
font-weight="500"
|
||||
class="node-label"
|
||||
>
|
||||
{{ node.name }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" @click="zoomIn">+</button>
|
||||
<span class="zoom-level">{{ Math.round(zoom * 100) }}%</span>
|
||||
<button class="zoom-btn" @click="zoomOut">-</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点详情面板 -->
|
||||
<div v-if="selectedNodeData" class="node-details">
|
||||
<div class="detail-header">
|
||||
<span
|
||||
class="detail-icon"
|
||||
:style="{ background: selectedNodeData.color }"
|
||||
>{{ selectedNodeData.icon }}</span>
|
||||
<div class="detail-title-wrap">
|
||||
<span class="detail-title">{{ selectedNodeData.name }}</span>
|
||||
<span class="detail-type">{{ selectedNodeData.type === 'entry' ? '入口文件' : '模块' }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="selectedNode = null">×</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<div class="detail-section">
|
||||
<h4>📦 依赖信息</h4>
|
||||
<div class="deps-info">
|
||||
<div class="deps-count">
|
||||
<span class="count-label">引入:</span>
|
||||
<span class="count-value">{{ selectedNodeData.dependencies?.length || 0 }} 个模块</span>
|
||||
</div>
|
||||
<div class="deps-count">
|
||||
<span class="count-label">被引用:</span>
|
||||
<span class="count-value">{{ getIncomingDeps(selectedNodeData.id).length }} 个模块</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="selectedNodeData.dependencies?.length">
|
||||
<h4>🔗 引用的模块</h4>
|
||||
<div class="deps-list">
|
||||
<span
|
||||
v-for="depId in selectedNodeData.dependencies"
|
||||
:key="depId"
|
||||
class="dep-tag"
|
||||
:style="{ background: getNode(depId)?.color || 'var(--vp-c-brand)' }"
|
||||
@click="selectNode(depId)"
|
||||
>
|
||||
{{ getNode(depId)?.name || depId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h4>📊 模块大小</h4>
|
||||
<div class="size-bar">
|
||||
<div
|
||||
class="size-fill"
|
||||
:style="{
|
||||
width: `${Math.min((selectedNodeData.size || 0) / 10, 100)}%`,
|
||||
background: selectedNodeData.color
|
||||
}"
|
||||
></div>
|
||||
<span class="size-text">{{ selectedNodeData.size || 0 }} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>依赖图谱的作用:</strong>
|
||||
就像地图一样,依赖图谱帮助你理解项目中的模块是如何相互关联的。
|
||||
你可以快速找到某个模块被哪些地方引用,或者发现循环依赖等问题。
|
||||
在大型项目中,良好的依赖结构是维护性的关键。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const width = 600
|
||||
const height = 400
|
||||
const currentView = ref('radar')
|
||||
const highlightedTool = ref(null)
|
||||
const selectedNode = ref(null)
|
||||
const zoom = ref(1)
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ x: 0, y: 0 })
|
||||
const panOffset = ref({ x: 0, y: 0 })
|
||||
const currentLayout = ref('force')
|
||||
const isAnimating = ref(true)
|
||||
|
||||
// 模拟项目依赖数据
|
||||
const nodes = ref([
|
||||
{ id: 'main', name: 'main.js', type: 'entry', size: 5, icon: '🚀', color: '#646cff', x: 300, y: 200, dependencies: ['utils', 'components', 'api'] },
|
||||
{ id: 'utils', name: 'utils.js', type: 'module', size: 12, icon: '🛠️', color: '#ff6b6b', x: 150, y: 100, dependencies: ['helpers'] },
|
||||
{ id: 'components', name: 'components/', type: 'module', size: 45, icon: '🧩', color: '#4ecdc4', x: 450, y: 120, dependencies: ['utils', 'hooks'] },
|
||||
{ id: 'api', name: 'api.js', type: 'module', size: 8, icon: '🔌', color: '#ffe66d', x: 200, y: 300, dependencies: ['config'] },
|
||||
{ id: 'helpers', name: 'helpers.js', type: 'module', size: 6, icon: '🔧', color: '#a8e6cf', x: 80, y: 50, dependencies: [] },
|
||||
{ id: 'hooks', name: 'hooks.js', type: 'module', size: 15, icon: '⚓', color: '#ff8b94', x: 520, y: 200, dependencies: ['utils'] },
|
||||
{ id: 'config', name: 'config.js', type: 'module', size: 3, icon: '⚙️', color: '#c7ceea', x: 120, y: 350, dependencies: [] }
|
||||
])
|
||||
|
||||
const edges = computed(() => {
|
||||
const edgeList = []
|
||||
nodes.value.forEach(node => {
|
||||
if (node.dependencies) {
|
||||
node.dependencies.forEach(depId => {
|
||||
edgeList.push({
|
||||
source: node.id,
|
||||
target: depId,
|
||||
highlighted: false
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
return edgeList
|
||||
})
|
||||
|
||||
const selectedNodeData = computed(() => {
|
||||
if (!selectedNode.value) return null
|
||||
return nodes.value.find(n => n.id === selectedNode.value)
|
||||
})
|
||||
|
||||
const getNode = (id) => nodes.value.find(n => n.id === id)
|
||||
|
||||
const getIncomingDeps = (nodeId) => {
|
||||
return nodes.value.filter(n => n.dependencies?.includes(nodeId))
|
||||
}
|
||||
|
||||
const selectNode = (id) => {
|
||||
selectedNode.value = id
|
||||
// 高亮相关边
|
||||
edges.value.forEach(edge => {
|
||||
edge.highlighted = edge.source === id || edge.target === id
|
||||
})
|
||||
}
|
||||
|
||||
const highlightNode = (id) => {
|
||||
// 悬停效果
|
||||
}
|
||||
|
||||
const unhighlightNode = () => {
|
||||
// 清除悬停效果
|
||||
}
|
||||
|
||||
const zoomIn = () => {
|
||||
zoom.value = Math.min(zoom.value * 1.2, 3)
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
zoom.value = Math.max(zoom.value / 1.2, 0.3)
|
||||
}
|
||||
|
||||
const handleZoom = (e) => {
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
zoom.value = Math.max(0.3, Math.min(3, zoom.value * delta))
|
||||
}
|
||||
|
||||
const startDrag = (e) => {
|
||||
isDragging.value = true
|
||||
dragStart.value = { x: e.clientX - panOffset.value.x, y: e.clientY - panOffset.value.y }
|
||||
}
|
||||
|
||||
const handleDrag = (e) => {
|
||||
if (!isDragging.value) return
|
||||
panOffset.value = {
|
||||
x: e.clientX - dragStart.value.x,
|
||||
y: e.clientY - dragStart.value.y
|
||||
}
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const resetGraph = () => {
|
||||
zoom.value = 1
|
||||
panOffset.value = { x: 0, y: 0 }
|
||||
selectedNode.value = null
|
||||
// 重置节点位置
|
||||
nodes.value.forEach((node, i) => {
|
||||
const angle = (i / nodes.value.length) * 2 * Math.PI
|
||||
node.x = 300 + 150 * Math.cos(angle)
|
||||
node.y = 200 + 100 * Math.sin(angle)
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAnimation = () => {
|
||||
isAnimating.value = !isAnimating.value
|
||||
}
|
||||
|
||||
const toggleScenario = (id) => {
|
||||
expandedScenario.value = expandedScenario.value === id ? null : id
|
||||
}
|
||||
|
||||
// 简化的力导向布局模拟
|
||||
let animationFrame
|
||||
const simulateForceLayout = () => {
|
||||
if (!isAnimating.value) return
|
||||
|
||||
const centerX = width / 2
|
||||
const centerY = height / 2
|
||||
const k = 50 // 弹簧常数
|
||||
const repulsion = 500 // 斥力
|
||||
|
||||
nodes.value.forEach((node, i) => {
|
||||
let fx = 0, fy = 0
|
||||
|
||||
// 向中心的引力
|
||||
fx += (centerX - node.x) * 0.01
|
||||
fy += (centerY - node.y) * 0.01
|
||||
|
||||
// 节点间的斥力
|
||||
nodes.value.forEach((other, j) => {
|
||||
if (i === j) return
|
||||
const dx = node.x - other.x
|
||||
const dy = node.y - other.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const force = repulsion / (dist * dist)
|
||||
fx += (dx / dist) * force
|
||||
fy += (dy / dist) * force
|
||||
})
|
||||
|
||||
// 依赖的弹簧力
|
||||
if (node.dependencies) {
|
||||
node.dependencies.forEach(depId => {
|
||||
const target = nodes.value.find(n => n.id === depId)
|
||||
if (target) {
|
||||
const dx = target.x - node.x
|
||||
const dy = target.y - node.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const force = (dist - k) * 0.01
|
||||
fx += (dx / dist) * force
|
||||
fy += (dy / dist) * force
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 应用力
|
||||
node.x += fx * 0.1
|
||||
node.y += fy * 0.1
|
||||
|
||||
// 边界限制
|
||||
node.x = Math.max(30, Math.min(width - 30, node.x))
|
||||
node.y = Math.max(30, Math.min(height - 30, node.y))
|
||||
})
|
||||
|
||||
animationFrame = requestAnimationFrame(simulateForceLayout)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
simulateForceLayout()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationFrame) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
})
|
||||
|
||||
// 场景推荐数据
|
||||
const expandedScenario = ref(null)
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'spa',
|
||||
icon: '🚀',
|
||||
name: '中小型 SPA 项目',
|
||||
shortDesc: '单页应用,快速开发',
|
||||
bestChoice: 'vite',
|
||||
bestReason: 'Vite 的极速冷启动和热更新让开发体验极佳,配置简单,是中小型项目的首选。',
|
||||
alternative: 'webpack',
|
||||
altReason: '如果需要大量自定义配置或依赖特定的 webpack loader,webpack 仍然是可靠的选择。'
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
icon: '📚',
|
||||
name: 'JavaScript 库/组件库',
|
||||
shortDesc: '打包发布 npm 包',
|
||||
bestChoice: 'rollup',
|
||||
bestReason: 'Rollup 生成的代码最干净,Tree Shaking 效果最好,非常适合打包 JavaScript 库。',
|
||||
alternative: 'vite',
|
||||
altReason: 'Vite 使用 Rollup 进行生产构建,同时提供更好的开发体验,也是现代库开发的好选择。'
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
icon: '🏢',
|
||||
name: '大型企业级应用',
|
||||
shortDesc: '复杂业务,多人协作',
|
||||
bestChoice: 'webpack',
|
||||
bestReason: 'Webpack 生态最成熟,loader 和 plugin 最丰富,能应对各种复杂场景和定制化需求。',
|
||||
alternative: 'vite',
|
||||
altReason: '如果团队追求更好的开发体验,且项目不需要太多自定义构建逻辑,Vite 也是值得考虑的选项。'
|
||||
},
|
||||
{
|
||||
id: 'ssg',
|
||||
icon: '📝',
|
||||
name: '静态站点生成 (SSG)',
|
||||
shortDesc: '文档站、博客、营销页',
|
||||
bestChoice: 'vite',
|
||||
bestReason: 'VitePress、Astro 等现代 SSG 工具都基于 Vite,开发体验好,构建速度快。',
|
||||
alternative: 'rollup',
|
||||
altReason: '一些轻量级 SSG 工具直接使用 Rollup,如果对产物体积要求极高可以考虑。'
|
||||
}
|
||||
]
|
||||
|
||||
const getTool = (id) => bundlers.find(b => b.id === id)
|
||||
|
||||
// 简化的布局切换
|
||||
watch(currentLayout, (newLayout) => {
|
||||
// 重置节点位置以演示不同布局
|
||||
nodes.value.forEach((node, i) => {
|
||||
if (newLayout === 'circular') {
|
||||
const angle = (i / nodes.value.length) * 2 * Math.PI
|
||||
node.x = 300 + 150 * Math.cos(angle)
|
||||
node.y = 200 + 120 * Math.sin(angle)
|
||||
} else if (newLayout === 'hierarchical') {
|
||||
const level = node.type === 'entry' ? 0 : node.dependencies?.length > 2 ? 1 : 2
|
||||
const siblings = nodes.value.filter(n => {
|
||||
const nl = n.type === 'entry' ? 0 : n.dependencies?.length > 2 ? 1 : 2
|
||||
return nl === level
|
||||
})
|
||||
const index = siblings.indexOf(node)
|
||||
const total = siblings.length
|
||||
node.x = 100 + (index + 1) * (400 / (total + 1))
|
||||
node.y = 80 + level * 120
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dependency-graph-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title-section .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.title-section .title {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.title-section .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.layout-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.layout-control select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.control-btn.outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
position: relative;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.graph-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.edge-line {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.node.selected .node-circle {
|
||||
stroke: var(--vp-c-brand);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.node.highlighted .node-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.node-glow {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-details {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-title-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.deps-info {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.deps-count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.deps-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.dep-tag {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.dep-tag:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.size-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.size-fill {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.size-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.radar-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,429 @@
|
||||
<!--
|
||||
HotReloadDemo.vue
|
||||
热更新机制演示
|
||||
|
||||
用途:
|
||||
展示HMR(热模块替换)的工作原理。
|
||||
-->
|
||||
<template>
|
||||
<div class="hot-reload-demo">
|
||||
<div class="demo-header">
|
||||
<h3>🔥 热更新 (HMR) 演示</h3>
|
||||
<p>修改代码无需刷新页面,即时生效</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<!-- 对比图 -->
|
||||
<div class="comparison">
|
||||
<div class="method-card no-hmr">
|
||||
<div class="card-header">
|
||||
<span class="icon">🔄</span>
|
||||
<span class="title">传统刷新</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="step" v-for="(step, i) in noHmrSteps" :key="i">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="time">⏱️ 5-10秒</span>
|
||||
<span class="state">页面闪烁、状态丢失</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vs-divider">VS</div>
|
||||
|
||||
<div class="method-card hmr">
|
||||
<div class="card-header">
|
||||
<span class="icon">⚡</span>
|
||||
<span class="title">HMR 热更新</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="step" v-for="(step, i) in hmrSteps" :key="i">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="time">⏱️ 50-200ms</span>
|
||||
<span class="state">无刷新、状态保持</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流程图 -->
|
||||
<div class="flow-diagram">
|
||||
<h4>HMR 工作流程</h4>
|
||||
<div class="flow-steps">
|
||||
<div class="flow-step" v-for="(step, i) in flowSteps" :key="i">
|
||||
<div class="step-box">
|
||||
<span class="step-icon">{{ step.icon }}</span>
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
</div>
|
||||
<div v-if="i < flowSteps.length - 1" class="step-arrow">→</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持情况 -->
|
||||
<div class="support-table">
|
||||
<h4>各构建工具 HMR 支持</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>构建工具</th>
|
||||
<th>HMR 支持</th>
|
||||
<th>更新速度</th>
|
||||
<th>特点</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tool in hmrTools" :key="tool.name">
|
||||
<td><strong>{{ tool.name }}</strong></td>
|
||||
<td>
|
||||
<span class="badge" :class="tool.supportClass">{{ tool.support }}</span>
|
||||
</td>
|
||||
<td>{{ tool.speed }}</td>
|
||||
<td>{{ tool.feature }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>HMR 的核心原理:</strong>
|
||||
构建工具通过 WebSocket 与浏览器保持连接。当文件修改后,工具编译变更模块,通过 WebSocket 通知浏览器。
|
||||
浏览器中的 HMR Runtime 接收更新,替换旧模块,同时保持应用状态不变。
|
||||
这就像是给飞行中的飞机换引擎——不停机就能完成更新。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const noHmrSteps = [
|
||||
'修改代码并保存',
|
||||
'手动刷新浏览器',
|
||||
'页面重新加载所有资源',
|
||||
'应用状态重置(登录丢失)'
|
||||
]
|
||||
|
||||
const hmrSteps = [
|
||||
'修改代码并保存',
|
||||
'构建工具检测变更并编译',
|
||||
'WebSocket 推送更新到浏览器',
|
||||
'局部替换模块,状态保持'
|
||||
]
|
||||
|
||||
const flowSteps = [
|
||||
{ icon: '👨💻', label: '开发者修改代码' },
|
||||
{ icon: '🛠️', label: '构建工具编译' },
|
||||
{ icon: '📡', label: 'WebSocket推送' },
|
||||
{ icon: '🔄', label: '浏览器替换模块' },
|
||||
{ icon: '✨', label: '页面即时更新' }
|
||||
]
|
||||
|
||||
const hmrTools = [
|
||||
{
|
||||
name: 'Vite',
|
||||
support: '原生支持',
|
||||
supportClass: 'excellent',
|
||||
speed: '极快 (<100ms)',
|
||||
feature: '基于 ESM,HMR 速度最快'
|
||||
},
|
||||
{
|
||||
name: 'Webpack',
|
||||
support: '完全支持',
|
||||
supportClass: 'good',
|
||||
speed: '较快 (1-3s)',
|
||||
feature: '最成熟的 HMR 实现'
|
||||
},
|
||||
{
|
||||
name: 'Parcel',
|
||||
support: '自动支持',
|
||||
supportClass: 'good',
|
||||
speed: '快 (500ms-1s)',
|
||||
feature: '零配置,自动 HMR'
|
||||
},
|
||||
{
|
||||
name: 'Rollup',
|
||||
support: '插件支持',
|
||||
supportClass: 'fair',
|
||||
speed: '开发时较慢',
|
||||
feature: '主要用于生产构建'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hot-reload-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-header h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.demo-header p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comparison {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.method-card {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.method-card.hmr {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.card-header .icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.card-header .title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider-light);
|
||||
}
|
||||
|
||||
.step:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flow-diagram {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.flow-diagram h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.step-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--vp-c-text-2);
|
||||
text-align: center;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.step-arrow {
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.support-table {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.support-table h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.support-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.support-table th,
|
||||
.support-table td {
|
||||
padding: 0.5rem 0.6rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.support-table th {
|
||||
background: var(--vp-c-bg-soft);
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge.excellent {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.badge.good {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.badge.fair {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.flow-steps {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<!--
|
||||
SourceMapDemo.vue
|
||||
SourceMap原理演示
|
||||
|
||||
用途:
|
||||
展示SourceMap如何将压缩后的代码映射回源代码。
|
||||
-->
|
||||
<template>
|
||||
<div class="source-map-demo">
|
||||
<div class="demo-header">
|
||||
<h3>🗺️ SourceMap 原理演示</h3>
|
||||
<p>调试压缩代码的秘密武器</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="code-comparison">
|
||||
<div class="code-panel source">
|
||||
<div class="panel-title">📄 源代码 (Source)</div>
|
||||
<pre class="code-block"><code>function calculateSum(a, b) {
|
||||
// 计算两个数的和
|
||||
const result = a + b;
|
||||
console.log('结果:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const sum = calculateSum(10, 20);
|
||||
console.log('总和:', sum);</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mapping-arrows">
|
||||
<div class="arrow" v-for="i in 5" :key="i">
|
||||
<span class="line"></span>
|
||||
<span class="point">→</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-panel minified">
|
||||
<div class="panel-title">🔧 压缩后 (Minified)</div>
|
||||
<pre class="code-block"><code>function n(n,r){var t=n+r;return console.log("结果:",t),t}var r=n(10,20);console.log("总和:",r);
|
||||
//# sourceMappingURL=app.js.map</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sourcemap-explanation">
|
||||
<div class="explanation-section">
|
||||
<h4>📦 SourceMap 文件内容示例</h4>
|
||||
<pre class="json-block"><code>{
|
||||
"version": 3,
|
||||
"sources": ["src/utils.js", "src/main.js"],
|
||||
"names": ["calculateSum", "a", "b", "result"],
|
||||
"mappings": "AAAA,SAASA...",
|
||||
"file": "app.min.js"
|
||||
}</code></pre>
|
||||
<ul class="field-explanation">
|
||||
<li><strong>version</strong>: SourceMap 规范版本(当前是 3)</li>
|
||||
<li><strong>sources</strong>: 原始源文件列表</li>
|
||||
<li><strong>names</strong>: 压缩前后的变量名映射</li>
|
||||
<li><strong>mappings</strong>: 位置映射信息(VLQ 编码)</li>
|
||||
<li><strong>file</strong>: 对应的压缩文件名</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tips-section">
|
||||
<h4>💡 使用建议</h4>
|
||||
<div class="tips-grid">
|
||||
<div class="tip-item">
|
||||
<span class="tip-icon">🚀</span>
|
||||
<div class="tip-content">
|
||||
<strong>开发环境</strong>
|
||||
<p>开启 SourceMap,方便调试</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<span class="tip-icon">🔒</span>
|
||||
<div class="tip-content">
|
||||
<strong>生产环境</strong>
|
||||
<p>不部署 .map 文件,防止源码泄露</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<span class="tip-icon">🗂️</span>
|
||||
<div class="tip-content">
|
||||
<strong>单独存放</strong>
|
||||
<p>使用 `sourceMappingURL` 指向独立服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>SourceMap 工作原理:</strong>
|
||||
压缩代码时,构建工具会记录每个字符在源代码中的位置,生成 .map 文件。
|
||||
浏览器调试时,通过映射关系把压缩后的代码"还原"成源代码显示。
|
||||
注意:生产环境不要暴露 .map 文件,防止源码泄露!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-map-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-header h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.demo-header p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.code-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.code-comparison {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mapping-arrows {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-panel .panel-title {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.mapping-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.arrow .line {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.arrow .point {
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 0.8rem;
|
||||
margin-left: -2px;
|
||||
}
|
||||
|
||||
.sourcemap-explanation {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sourcemap-explanation {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.explanation-section,
|
||||
.tips-section {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.explanation-section h4,
|
||||
.tips-section h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.json-block {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.field-explanation {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.field-explanation li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.field-explanation strong {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tips-grid {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tip-content strong {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tip-content p {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,336 @@
|
||||
<!--
|
||||
TreeShakingDemo.vue
|
||||
摇树优化演示
|
||||
|
||||
用途:
|
||||
直观展示 Tree Shaking 如何移除未使用的代码。
|
||||
|
||||
交互功能:
|
||||
- 代码选择:选择使用哪些导出
|
||||
- 实时计算:显示包体积变化
|
||||
- 对比视图:对比 Tree Shaking 前后
|
||||
-->
|
||||
<template>
|
||||
<div class="tree-shaking-demo">
|
||||
<div class="demo-header">
|
||||
<h3>🌳 Tree Shaking 演示</h3>
|
||||
<p>选择你需要的功能,观察包体积变化</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<!-- 源代码面板 -->
|
||||
<div class="source-panel">
|
||||
<div class="panel-title">📦 utils.js (源代码)</div>
|
||||
<div class="code-block">
|
||||
<div
|
||||
v-for="(func, index) in functions"
|
||||
:key="index"
|
||||
class="code-line"
|
||||
:class="{ used: func.used, unused: !func.used && hasSelection }"
|
||||
>
|
||||
<span class="line-number">{{ index + 1 }}</span>
|
||||
<span class="line-content">{{ func.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="control-panel">
|
||||
<div class="panel-title">🎛️ 选择需要的功能</div>
|
||||
<div class="function-toggles">
|
||||
<label
|
||||
v-for="(func, index) in functions"
|
||||
:key="index"
|
||||
class="toggle-item"
|
||||
:class="{ active: func.used }"
|
||||
>
|
||||
<input type="checkbox" v-model="func.used" />
|
||||
<span class="toggle-name">{{ func.name }}</span>
|
||||
<span class="toggle-size">{{ func.size }}B</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="stats-box">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">原始大小</span>
|
||||
<span class="stat-value original">{{ originalSize }}B</span>
|
||||
</div>
|
||||
<div class="stat-arrow">→</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Tree Shaking 后</span>
|
||||
<span class="stat-value optimized">{{ optimizedSize }}B</span>
|
||||
</div>
|
||||
<div class="stat-item savings">
|
||||
<span class="stat-label">节省</span>
|
||||
<span class="stat-value">{{ savingsPercent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>Tree Shaking 原理:</strong>
|
||||
现代打包工具会分析 ES 模块的导出/导入关系,自动移除未被使用的代码。
|
||||
前提条件:1) 使用 ES 模块 (import/export);2) 代码无副作用;3) 打包工具支持(Webpack、Rollup 等)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const functions = ref([
|
||||
{
|
||||
name: 'debounce',
|
||||
code: 'export function debounce(fn, delay) { ... }',
|
||||
size: 156,
|
||||
used: true
|
||||
},
|
||||
{
|
||||
name: 'throttle',
|
||||
code: 'export function throttle(fn, limit) { ... }',
|
||||
size: 142,
|
||||
used: false
|
||||
},
|
||||
{
|
||||
name: 'deepClone',
|
||||
code: 'export function deepClone(obj) { ... }',
|
||||
size: 234,
|
||||
used: true
|
||||
},
|
||||
{
|
||||
name: 'formatDate',
|
||||
code: 'export function formatDate(date, fmt) { ... }',
|
||||
size: 189,
|
||||
used: false
|
||||
},
|
||||
{
|
||||
name: 'randomString',
|
||||
code: 'export function randomString(len) { ... }',
|
||||
size: 98,
|
||||
used: false
|
||||
}
|
||||
])
|
||||
|
||||
const originalSize = computed(() =>
|
||||
functions.value.reduce((sum, f) => sum + f.size, 0)
|
||||
)
|
||||
|
||||
const optimizedSize = computed(() =>
|
||||
functions.value.filter(f => f.used).reduce((sum, f) => sum + f.size, 0)
|
||||
)
|
||||
|
||||
const savingsPercent = computed(() => {
|
||||
const saved = originalSize.value - optimizedSize.value
|
||||
return Math.round((saved / originalSize.value) * 100)
|
||||
})
|
||||
|
||||
const hasSelection = computed(() =>
|
||||
functions.value.some(f => f.used)
|
||||
)
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
const icons = { js: '📜', css: '🎨', image: '🖼️', html: '📄' }
|
||||
return icons[type] || '📄'
|
||||
}
|
||||
|
||||
const formatSize = (size) => size > 1024 ? (size / 1024).toFixed(1) + ' MB' : size + ' KB'
|
||||
const formatTime = (timestamp) => new Date(timestamp).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const selectedNode = ref(null)
|
||||
const selectedFile = computed(() => selectedNode.value)
|
||||
const cacheHits = ref(42)
|
||||
const cacheMisses = ref(8)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-shaking-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-header h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.demo-header p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.source-panel,
|
||||
.control-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.code-line:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.code-line.used {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.code-line.unused {
|
||||
opacity: 0.4;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
color: var(--vp-c-text-3);
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.line-content {
|
||||
color: var(--vp-c-text-1);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.function-toggles {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.toggle-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.toggle-item:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.toggle-item.active {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.toggle-item input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-name {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toggle-size {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.stats-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
margin: 0 0.75rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stat-value.original {
|
||||
color: var(--vp-c-text-2);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.stat-value.optimized {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.stat-item.savings .stat-value {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user