Files
test-repo/docs/.vitepress/theme/components/appendix/frontend-engineering/AssetFingerprintDemo.vue
T
sanbuphy 0eba9e87e9 fix(eslint): reduce warnings in GitHub Actions deployment
- Disable formatting rules (handled by Prettier)
- Relaxed strict Vue/JS rules for demo code compatibility
- Fix syntax errors in ApiPlayground and VoiceCloningDemo
- Fix duplicate else-if condition in ApiPlayground
- Fix Promise executor async pattern in AutoregressiveAudioDemo
- Add TypeScript file support to ESLint config

Warnings reduced from 295 to 251 problems.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 17:38:10 +08:00

909 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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
v-model="showHash"
type="checkbox"
@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>缓存命中 (Hash 匹配)</span>
</div>
<div class="legend-item">
<span class="legend-color miss" />
<span>缓存未命中 (Hash 变化)</span>
</div>
<div class="legend-item">
<span class="legend-color new" />
<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
v-if="selectedFile.dependencies?.length"
class="detail-section"
>
<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, onMounted } 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()
})
</script>
<style scoped>
.asset-fingerprint-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background-color: var(--vp-c-bg-soft);
padding: 0.75rem;
margin: 0.5rem 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: 6px;
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;
}
.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: 0.75rem;
}
.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: 6px;
padding: 0.75rem;
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: 6px;
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>