feat: update docs and components, fix DLQ demo bug

This commit is contained in:
sanbuphy
2026-01-18 12:21:49 +08:00
parent 26ed39e1eb
commit e41063a1cd
159 changed files with 54236 additions and 2525 deletions
@@ -0,0 +1,679 @@
<!--
CachingStrategyDemo.vue
缓存策略演示
-->
<template>
<div class="caching-demo">
<div class="header">
<div class="title">缓存策略速度与更新的平衡</div>
<div class="subtitle">对比不同缓存策略的效果</div>
</div>
<div class="strategy-selector">
<button
v-for="strategy in strategies"
:key="strategy.name"
@click="selectStrategy(strategy)"
:class="['strategy-btn', { active: selectedStrategy.name === strategy.name }]"
>
<span class="strategy-icon">{{ strategy.icon }}</span>
<span class="strategy-name">{{ strategy.name }}</span>
</button>
</div>
<div class="demo-area">
<div class="browser-window">
<div class="browser-header">
<div class="browser-controls">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<div class="browser-url">{{ selectedStrategy.url }}</div>
</div>
<div class="browser-content">
<div class="loading-overlay" v-if="isLoading">
<div class="spinner"></div>
<div class="loading-text">加载中... ({{ loadingProgress }}%)</div>
</div>
<div class="page-content" v-else>
<div class="page-hero">
<h2>{{ selectedStrategy.pageTitle }}</h2>
</div>
<div class="page-body">
<div class="resource-item" v-for="(resource, index) in selectedStrategy.resources" :key="index">
<div class="resource-icon">{{ resource.icon }}</div>
<div class="resource-info">
<div class="resource-name">{{ resource.name }}</div>
<div class="resource-status" :class="resource.cached ? 'cached' : 'network'">
{{ resource.cached ? '✓ 来自缓存' : '↓ 从服务器下载' }}
</div>
</div>
<div class="resource-size">{{ resource.size }}</div>
<div class="resource-time">{{ resource.time }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="metrics-panel">
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon"></span>
<span class="metric-title">加载时间</span>
</div>
<div class="metric-value" :class="selectedStrategy.performanceClass">
{{ selectedStrategy.loadTime }}
</div>
<div class="metric-change" :class="{ positive: selectedStrategy.isFast }">
{{ selectedStrategy.compared }}
</div>
</div>
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon">💾</span>
<span class="metric-title">缓存命中</span>
</div>
<div class="metric-value">
{{ selectedStrategy.cacheHit }}%
</div>
<div class="metric-bar">
<div class="metric-fill" :style="{ width: selectedStrategy.cacheHit + '%' }"></div>
</div>
</div>
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon">🌐</span>
<span class="metric-title">网络请求</span>
</div>
<div class="metric-value">
{{ selectedStrategy.requests }}
</div>
<div class="metric-desc">
{{ selectedStrategy.requestDesc }}
</div>
</div>
</div>
</div>
<div class="strategy-info">
<h3>{{ selectedStrategy.name }} 说明</h3>
<p>{{ selectedStrategy.description }}</p>
<div class="code-example">
<div class="code-header">配置示例</div>
<pre><code>{{ selectedStrategy.code }}</code></pre>
</div>
</div>
<div class="comparison-table">
<h4>策略对比</h4>
<table>
<thead>
<tr>
<th>策略</th>
<th>速度</th>
<th>更新难度</th>
<th>适用场景</th>
</tr>
</thead>
<tbody>
<tr v-for="strategy in strategies" :key="strategy.name" :class="{ highlighted: selectedStrategy.name === strategy.name }">
<td><strong>{{ strategy.name }}</strong></td>
<td>{{ strategy.speed }}</td>
<td>{{ strategy.updateDifficulty }}</td>
<td>{{ strategy.useCase }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const selectedStrategy = ref({})
const isLoading = ref(false)
const loadingProgress = ref(0)
const strategies = [
{
name: '无缓存',
icon: '🚫',
url: 'https://example.com/',
pageTitle: '页面加载缓慢',
resources: [
{ icon: '📄', name: 'index.html', size: '5 KB', time: '200ms', cached: false },
{ icon: '🎨', name: 'style.css', size: '50 KB', time: '300ms', cached: false },
{ icon: '⚙️', name: 'app.js', size: '200 KB', time: '800ms', cached: false },
{ icon: '🖼️', name: 'image.jpg', size: '150 KB', time: '500ms', cached: false }
],
loadTime: '1.8s',
performanceClass: 'poor',
isFast: false,
compared: '基准',
cacheHit: 0,
requests: 4,
requestDesc: '所有资源都从网络下载',
description: '不使用任何缓存,每次访问都要重新下载所有资源。速度最慢,但内容总是最新的。',
code: '# 禁用缓存\nCache-Control: no-cache',
speed: '慢',
updateDifficulty: '容易',
useCase: '频繁更新的内容'
},
{
name: '传统缓存',
icon: '💾',
url: 'https://example.com/',
pageTitle: '页面加载较快',
resources: [
{ icon: '📄', name: 'index.html', size: '5 KB', time: '50ms', cached: true },
{ icon: '🎨', name: 'style.css', size: '50 KB', time: '30ms', cached: true },
{ icon: '⚙️', name: 'app.js', size: '200 KB', time: '20ms', cached: true },
{ icon: '🖼️', name: 'image.jpg', size: '150 KB', time: '25ms', cached: true }
],
loadTime: '125ms',
performanceClass: 'good',
isFast: true,
compared: '快 93%',
cacheHit: 100,
requests: 0,
requestDesc: '所有资源都来自缓存',
description: '设置固定的过期时间(如 1 年)。速度极快,但更新内容需要用户清除缓存或强制刷新。',
code: '# Nginx 配置\nlocation ~* \\.(js|css|jpg|png)$ {\n expires: 1y;\n add_header: Cache-Control: public;\n}',
speed: '极快',
updateDifficulty: '困难',
useCase: '文件名带哈希的静态资源'
},
{
name: '协商缓存',
icon: '🤝',
url: 'https://example.com/',
pageTitle: '页面加载快',
resources: [
{ icon: '📄', name: 'index.html', size: '5 KB', time: '50ms', cached: true },
{ icon: '🎨', name: 'style.css', size: '50 KB', time: '30ms', cached: true },
{ icon: '⚙️', name: 'app.js', size: '200 KB', time: '350ms', cached: false },
{ icon: '🖼️', name: 'image.jpg', size: '150 KB', time: '25ms', cached: true }
],
loadTime: '455ms',
performanceClass: 'medium',
isFast: true,
compared: '快 75%',
cacheHit: 75,
requests: 1,
requestDesc: '仅下载已更新的资源',
description: '使用 ETag 或 Last-Modified 进行验证。资源未改变时返回 304,资源改变时下载新内容。',
code: '# Nginx 配置\nlocation / {\n etag on;\n add_header Cache-Control: must-revalidate;\n}',
speed: '快',
updateDifficulty: '容易',
useCase: 'HTML 文件和 API 响应'
},
{
name: 'Service Worker',
icon: '🔧',
url: 'https://example.com/',
pageTitle: '页面极速加载',
resources: [
{ icon: '📄', name: 'index.html', size: '5 KB', time: '10ms', cached: true },
{ icon: '🎨', name: 'style.css', size: '50 KB', time: '5ms', cached: true },
{ icon: '⚙️', name: 'app.js', size: '200 KB', time: '5ms', cached: true },
{ icon: '🖼️', name: 'image.jpg', size: '150 KB', time: '5ms', cached: true }
],
loadTime: '25ms',
performanceClass: 'excellent',
isFast: true,
compared: '快 98%',
cacheHit: 100,
requests: 0,
requestDesc: '完全离线可用',
description: 'Service Worker 拦截网络请求,从缓存中返回资源。可实现离线访问和即时加载。',
code: '// 注册 Service Worker\nif (\'serviceWorker\' in navigator) {\n navigator.serviceWorker.register(\'/sw.js\');\n}\n\n// sw.js\ncaches.open(\'v1\').then(cache => {\n cache.addAll([\'/\', \'/style.css\', \'/app.js\']);\n});',
speed: '极快',
updateDifficulty: '中等',
useCase: 'PWA 应用和关键资源'
}
]
function selectStrategy(strategy) {
selectedStrategy.value = strategy
simulateLoading()
}
function simulateLoading() {
isLoading.value = true
loadingProgress.value = 0
const interval = setInterval(() => {
loadingProgress.value += 10
if (loadingProgress.value >= 100) {
clearInterval(interval)
setTimeout(() => {
isLoading.value = false
}, 300)
}
}, 100)
}
onMounted(() => {
selectStrategy(strategies[1]) // 默认选中传统缓存
})
</script>
<style scoped>
.caching-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.3rem;
}
.strategy-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.strategy-btn {
flex: 1;
min-width: 120px;
padding: 0.8rem 1rem;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--vp-c-text-1);
}
.strategy-btn:hover {
border-color: var(--vp-c-brand);
}
.strategy-btn.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: #fff;
}
.strategy-icon {
font-size: 1.2rem;
}
.demo-area {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
margin-bottom: 1.5rem;
}
@media (max-width: 900px) {
.demo-area {
grid-template-columns: 1fr;
}
}
.browser-window {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
overflow: hidden;
}
.browser-header {
background: var(--vp-c-bg-soft);
padding: 0.8rem 1rem;
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.browser-controls {
display: flex;
gap: 0.4rem;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot.red {
background: #ef4444;
}
.dot.yellow {
background: #f59e0b;
}
.dot.green {
background: #22c55e;
}
.browser-url {
flex: 1;
background: var(--vp-c-bg);
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
text-align: center;
}
.browser-content {
position: relative;
min-height: 350px;
}
.loading-overlay {
position: absolute;
inset: 0;
background: var(--vp-c-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px 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); }
}
.loading-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
}
.page-content {
padding: 1.5rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
padding: 2rem;
background: linear-gradient(135deg, var(--vp-c-brand), #8b5cf6);
border-radius: 8px;
color: #fff;
}
.page-hero h2 {
margin: 0;
font-size: 1.5rem;
}
.page-body {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.resource-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.8rem;
background: var(--vp-c-bg-soft);
border-radius: 8px;
transition: all 0.3s;
}
.resource-item:hover {
background: var(--vp-c-divider);
}
.resource-icon {
font-size: 1.5rem;
}
.resource-info {
flex: 1;
}
.resource-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 0.2rem;
}
.resource-status {
font-size: 0.75rem;
}
.resource-status.cached {
color: #22c55e;
}
.resource-status.network {
color: var(--vp-c-brand);
}
.resource-size {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.resource-time {
font-size: 0.8rem;
font-weight: 600;
color: var(--vp-c-text-1);
min-width: 60px;
text-align: right;
}
.metrics-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.metric-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
}
.metric-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.metric-icon {
font-size: 1.2rem;
}
.metric-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
}
.metric-value {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 0.3rem;
color: var(--vp-c-text-1);
}
.metric-value.good {
color: #22c55e;
}
.metric-value.medium {
color: #f59e0b;
}
.metric-value.poor {
color: #ef4444;
}
.metric-value.excellent {
color: #8b5cf6;
}
.metric-change {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.metric-change.positive {
color: #22c55e;
font-weight: 600;
}
.metric-bar {
height: 6px;
background: var(--vp-c-bg-soft);
border-radius: 999px;
overflow: hidden;
margin-top: 0.5rem;
}
.metric-fill {
height: 100%;
background: linear-gradient(90deg, #22c55e, #14b8a6);
transition: width 0.3s;
}
.metric-desc {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-top: 0.3rem;
}
.strategy-info {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.strategy-info h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--vp-c-text-1);
}
.strategy-info > p {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
}
.code-example {
background: var(--vp-c-bg-soft);
border-radius: 8px;
overflow: hidden;
}
.code-header {
padding: 0.6rem 1rem;
background: var(--vp-c-divider);
font-size: 0.75rem;
font-weight: 600;
color: var(--vp-c-text-2);
}
code {
display: block;
padding: 1rem;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 0.8rem;
color: var(--vp-c-text-1);
overflow-x: auto;
line-height: 1.5;
}
.comparison-table {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.5rem;
}
.comparison-table h4 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--vp-c-text-1);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
thead {
background: var(--vp-c-bg-soft);
}
th {
padding: 0.8rem;
text-align: left;
font-weight: 600;
color: var(--vp-c-text-1);
border-bottom: 2px solid var(--vp-c-divider);
}
td {
padding: 0.8rem;
border-bottom: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
}
tr:last-child td {
border-bottom: none;
}
tr.highlighted {
background: rgba(59, 130, 246, 0.05);
}
</style>
@@ -0,0 +1,560 @@
<!--
CriticalRenderingPathDemo.vue
关键渲染路径演示
-->
<template>
<div class="crp-demo">
<div class="header">
<div class="title">关键渲染路径 (Critical Rendering Path)</div>
<div class="subtitle">浏览器如何将 HTMLCSS JavaScript 转换为像素</div>
</div>
<div class="demo-container">
<div class="input-section">
<h4>1. DOM 树构建</h4>
<div class="code-block">
<pre><code>&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;link rel="stylesheet" href="style.css"&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class="container"&gt;
&lt;h1&gt;标题&lt;/h1&gt;
&lt;p&gt;段落&lt;/p&gt;
&lt;/div&gt;
&lt;script src="app.js"&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre>
</div>
</div>
<div class="arrow-section">
<div class="arrow"></div>
</div>
<div class="process-section">
<div class="step" :class="{ active: currentStep === 'dom' }">
<div class="step-header">
<div class="step-icon">🌲</div>
<div class="step-title">DOM </div>
</div>
<div class="tree-visualization">
<div class="tree-node root">html</div>
<div class="tree-children">
<div class="tree-node">head</div>
<div class="tree-node">body</div>
<div class="tree-children">
<div class="tree-node">div.container</div>
<div class="tree-children">
<div class="tree-node">h1</div>
<div class="tree-node">p</div>
</div>
</div>
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep === 'cssom' }">
<div class="step-header">
<div class="step-icon">🎨</div>
<div class="step-title">CSSOM </div>
</div>
<div class="tree-visualization">
<div class="tree-node root">body</div>
<div class="tree-children">
<div class="tree-node">.container</div>
<div class="tree-children">
<div class="tree-node">h1</div>
<div class="tree-node">p</div>
</div>
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep === 'render' }">
<div class="step-header">
<div class="step-icon">🖼</div>
<div class="step-title">渲染树</div>
</div>
<div class="tree-visualization">
<div class="tree-node root">body</div>
<div class="tree-children">
<div class="tree-node">div.container</div>
<div class="tree-children">
<div class="tree-node">h1</div>
<div class="tree-node">p</div>
</div>
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep === 'layout' }">
<div class="step-header">
<div class="step-icon">📐</div>
<div class="step-title">布局 (Layout)</div>
</div>
<div class="layout-demo">
<div class="layout-box container">
<div class="layout-label">container</div>
<div class="layout-box h1">
<div class="layout-label">h1</div>
</div>
<div class="layout-box p">
<div class="layout-label">p</div>
</div>
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep === 'paint' }">
<div class="step-header">
<div class="step-icon">🖌</div>
<div class="step-title">绘制 (Paint)</div>
</div>
<div class="paint-demo">
<div class="paint-box container">
<div class="paint-content">
<h1>标题</h1>
<p>段落</p>
</div>
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep === 'composite' }">
<div class="step-header">
<div class="step-icon"></div>
<div class="step-title">合成 (Composite)</div>
</div>
<div class="composite-demo">
<div class="composite-layer">图层 1: 背景</div>
<div class="composite-layer">图层 2: 内容</div>
<div class="composite-layer">图层 3: 装饰</div>
<div class="composite-result">= 最终页面</div>
</div>
</div>
</div>
</div>
<div class="timeline">
<div class="timeline-header">渲染时间线</div>
<div class="timeline-bar">
<div
v-for="(step, index) in timelineSteps"
:key="step.name"
class="timeline-segment"
:class="{ active: currentStep === step.name }"
:style="{
left: step.start + '%',
width: step.width + '%',
borderColor: step.color
}"
@click="setStep(step.name)"
>
<div class="segment-label" :style="{ color: step.color }">{{ step.label }}</div>
</div>
</div>
<div class="timeline-scale">
<span>0ms</span>
<span>{{ totalDuration }}ms</span>
</div>
</div>
<div class="optimization-tips">
<div class="tip-card">
<div class="tip-icon"></div>
<div class="tip-content">
<h4>优化 DOM 构建</h4>
<p>减少 HTML 嵌套层级避免不必要的标签使用语义化 HTML</p>
</div>
</div>
<div class="tip-card">
<div class="tip-icon">🎨</div>
<div class="tip-content">
<h4>优化 CSS</h4>
<p>CSS 是渲染阻塞资源将关键 CSS 内联异步加载非关键 CSS</p>
</div>
</div>
<div class="tip-card">
<div class="tip-icon"></div>
<div class="tip-content">
<h4>优化 JavaScript</h4>
<p>JS 会阻塞 DOM 构建使用 <code>defer</code> <code>async</code> 属性</p>
</div>
</div>
<div class="tip-card">
<div class="tip-icon">📐</div>
<div class="tip-content">
<h4>减少重排</h4>
<p>批量修改样式避免逐帧操作使用 <code>transform</code> 代替位置属性</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const currentStep = ref('dom')
const timelineSteps = [
{ name: 'dom', label: 'DOM', start: 0, width: 20, color: '#3b82f6' },
{ name: 'cssom', label: 'CSSOM', start: 20, width: 15, color: '#8b5cf6' },
{ name: 'render', label: 'Render Tree', start: 35, width: 10, color: '#ec4899' },
{ name: 'layout', label: 'Layout', start: 45, width: 15, color: '#f59e0b' },
{ name: 'paint', label: 'Paint', start: 60, width: 20, color: '#10b981' },
{ name: 'composite', label: 'Composite', start: 80, width: 20, color: '#06b6d4' }
]
const totalDuration = computed(() => {
return 1000 // 假设总时长 1000ms
})
function setStep(step) {
currentStep.value = step
}
</script>
<style scoped>
.crp-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.3rem;
}
.demo-container {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
margin-bottom: 2rem;
align-items: start;
}
@media (max-width: 900px) {
.demo-container {
grid-template-columns: 1fr;
}
.arrow-section {
transform: rotate(90deg);
}
}
.input-section h4 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.8rem;
color: var(--vp-c-text-1);
}
.code-block {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
}
code {
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: var(--vp-c-text-1);
}
.arrow-section {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem 0;
}
.arrow {
font-size: 2rem;
color: var(--vp-c-text-2);
font-weight: 700;
}
.process-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.step {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
transition: all 0.3s;
opacity: 0.6;
}
.step.active {
border-color: var(--vp-c-brand);
opacity: 1;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
}
.step-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.8rem;
}
.step-icon {
font-size: 1.5rem;
}
.step-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.tree-visualization {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.5rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.tree-node {
padding: 0.3rem 0.6rem;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 0.75rem;
font-family: 'Monaco', monospace;
color: var(--vp-c-text-1);
}
.tree-node.root {
background: var(--vp-c-brand);
color: #fff;
border-color: var(--vp-c-brand);
}
.tree-children {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding-left: 1rem;
border-left: 2px dashed var(--vp-c-divider);
}
.layout-demo,
.paint-demo,
.composite-demo {
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.layout-box {
border: 2px solid var(--vp-c-brand);
border-radius: 4px;
padding: 0.5rem;
position: relative;
min-width: 80px;
min-height: 40px;
}
.layout-box.container {
background: rgba(59, 130, 246, 0.1);
}
.layout-box.h1 {
background: rgba(139, 92, 246, 0.1);
border-color: #8b5cf6;
margin-bottom: 0.3rem;
}
.layout-box.p {
background: rgba(236, 72, 153, 0.1);
border-color: #ec4899;
}
.layout-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--vp-c-text-2);
text-align: center;
}
.paint-box {
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
border: 2px solid var(--vp-c-brand);
border-radius: 8px;
padding: 1rem;
min-width: 120px;
}
.paint-content h1 {
font-size: 1rem;
font-weight: 700;
color: var(--vp-c-text-1);
margin: 0 0 0.5rem 0;
}
.paint-content p {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin: 0;
}
.composite-layer {
padding: 0.4rem 0.8rem;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.3rem;
text-align: center;
}
.composite-result {
padding: 0.5rem;
background: var(--vp-c-brand);
color: #fff;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
text-align: center;
}
.timeline {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.timeline-header {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--vp-c-text-1);
}
.timeline-bar {
position: relative;
height: 50px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.timeline-segment {
position: absolute;
height: 100%;
border-left: 3px solid;
border-right: 3px solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.timeline-segment:hover {
opacity: 0.8;
}
.timeline-segment.active {
background: rgba(59, 130, 246, 0.1);
}
.segment-label {
font-size: 0.75rem;
font-weight: 600;
text-align: center;
padding: 0 0.3rem;
}
.timeline-scale {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--vp-c-text-2);
}
.optimization-tips {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.tip-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
display: flex;
gap: 0.8rem;
}
.tip-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.tip-content {
flex: 1;
}
.tip-content h4 {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.4rem;
color: var(--vp-c-text-1);
}
.tip-content p {
font-size: 0.8rem;
color: var(--vp-c-text-2);
line-height: 1.5;
margin: 0;
}
.tip-content code {
background: var(--vp-c-bg-soft);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Monaco', monospace;
font-size: 0.75rem;
color: var(--vp-c-brand);
}
</style>
@@ -0,0 +1,475 @@
<!--
ImageOptimizationDemo.vue
图片格式对比演示
-->
<template>
<div class="image-optimization-demo">
<div class="header">
<div class="title">图片格式对比大小与质量的权衡</div>
<div class="subtitle">对比不同图片格式的大小和质量</div>
</div>
<div class="format-grid">
<div
v-for="format in formats"
:key="format.name"
class="format-card"
:class="{ selected: selectedFormat === format.name }"
@click="selectFormat(format.name)"
>
<div class="format-header">
<div class="format-name">{{ format.name }}</div>
<div class="format-badge" :class="format.badgeClass">{{ format.badge }}</div>
</div>
<div class="format-preview" :style="{ background: format.gradient }">
<div class="preview-content">
<div class="preview-image">🖼</div>
<div class="preview-size">{{ format.size }}</div>
</div>
</div>
<div class="format-metrics">
<div class="metric">
<span class="metric-label">文件大小</span>
<span class="metric-value">{{ format.fileSize }}</span>
</div>
<div class="metric">
<span class="metric-label">压缩率</span>
<span class="metric-value">{{ format.compression }}</span>
</div>
<div class="metric">
<span class="metric-label">质量</span>
<div class="quality-bar">
<div class="quality-fill" :style="{ width: format.quality + '%' }"></div>
</div>
</div>
<div class="metric">
<span class="metric-label">浏览器支持</span>
<span class="metric-value">{{ format.support }}</span>
</div>
</div>
<div class="format-use-case">
<div class="use-case-label">适用场景</div>
<div class="use-case-value">{{ format.useCase }}</div>
</div>
</div>
</div>
<div class="comparison-table">
<h4>详细对比</h4>
<table>
<thead>
<tr>
<th>格式</th>
<th>大小</th>
<th>质量</th>
<th>透明度</th>
<th>动画</th>
<th>推荐指数</th>
</tr>
</thead>
<tbody>
<tr v-for="format in formats" :key="format.name">
<td><strong>{{ format.name }}</strong></td>
<td>{{ format.sizeLevel }}</td>
<td>{{ format.qualityLevel }}</td>
<td>{{ format.transparency ? '✓' : '✗' }}</td>
<td>{{ format.animation ? '✓' : '✗' }}</td>
<td>
<div class="recommendation">
<div class="stars">{{ '★'.repeat(Math.round(format.rating)) }}{{ '☆'.repeat(5 - Math.round(format.rating)) }}</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="tips">
<div class="tip-card">
<div class="tip-icon">💡</div>
<div class="tip-content">
<h4>优化建议</h4>
<ul>
<li>优先使用 WebP 格式可减少 30-50% 的大小</li>
<li>为旧浏览器提供 JPEG/PNG 降级方案</li>
<li>使用 <code class="inline-code">&lt;picture&gt;</code> 元素实现自动降级</li>
<li>照片使用 JPEG图标使用 PNG SVG</li>
</ul>
</div>
</div>
<div class="tip-card">
<div class="tip-icon">🔧</div>
<div class="tip-content">
<h4>工具推荐</h4>
<ul>
<li><strong>Squoosh</strong>Google 开源的图片压缩工具</li>
<li><strong>ImageOptim</strong>Mac 平台的图片优化工具</li>
<li><strong>TinyPNG</strong>在线智能压缩支持 WebP</li>
<li><strong>Sharp</strong>Node.js 图片处理库适合自动化</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const selectedFormat = ref('WebP')
const formats = [
{
name: 'JPEG',
badge: '经典',
badgeClass: 'classic',
size: '500 KB',
fileSize: '500 KB',
compression: '70%',
quality: 85,
support: '100%',
useCase: '照片、复杂图像',
sizeLevel: '中等',
qualityLevel: '良好',
transparency: false,
animation: false,
rating: 4,
gradient: 'linear-gradient(135deg, #60a5fa, #3b82f6)'
},
{
name: 'PNG',
badge: '无损',
badgeClass: 'lossless',
size: '1.2 MB',
fileSize: '1.2 MB',
compression: '40%',
quality: 100,
support: '100%',
useCase: '透明图片、图标',
sizeLevel: '大',
qualityLevel: '完美',
transparency: true,
animation: false,
rating: 4.5,
gradient: 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
},
{
name: 'WebP',
badge: '推荐',
badgeClass: 'recommended',
size: '250 KB',
fileSize: '250 KB',
compression: '85%',
quality: 90,
support: '95%',
useCase: '大部分场景',
sizeLevel: '小',
qualityLevel: '优秀',
transparency: true,
animation: true,
rating: 5,
gradient: 'linear-gradient(135deg, #34d399, #10b981)'
},
{
name: 'AVIF',
badge: '最新',
badgeClass: 'latest',
size: '180 KB',
fileSize: '180 KB',
compression: '90%',
quality: 95,
support: '75%',
useCase: '追求极致性能',
sizeLevel: '最小',
qualityLevel: '卓越',
transparency: true,
animation: false,
rating: 4.5,
gradient: 'linear-gradient(135deg, #f472b6, #ec4899)'
}
]
function selectFormat(name) {
selectedFormat.value = name
}
</script>
<style scoped>
.image-optimization-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.3rem;
}
.format-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.format-card {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s;
}
.format-card:hover {
border-color: var(--vp-c-brand);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.format-card.selected {
border-color: var(--vp-c-brand);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.format-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.8rem;
}
.format-name {
font-weight: 700;
font-size: 1.1rem;
color: var(--vp-c-text-1);
}
.format-badge {
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.format-badge.classic {
background: #dbeafe;
color: #1e40af;
}
.format-badge.lossless {
background: #ede9fe;
color: #5b21b6;
}
.format-badge.recommended {
background: #d1fae5;
color: #065f46;
}
.format-badge.latest {
background: #fce7f3;
color: #9d174d;
}
.format-preview {
height: 120px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.preview-content {
text-align: center;
color: #fff;
}
.preview-image {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.preview-size {
font-size: 0.9rem;
font-weight: 600;
}
.format-metrics {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.metric {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.metric-label {
color: var(--vp-c-text-2);
}
.metric-value {
font-weight: 600;
color: var(--vp-c-text-1);
}
.quality-bar {
width: 80px;
height: 8px;
background: var(--vp-c-bg-soft);
border-radius: 999px;
overflow: hidden;
}
.quality-fill {
height: 100%;
background: linear-gradient(90deg, #22c55e, #14b8a6);
transition: width 0.3s;
}
.format-use-case {
padding-top: 0.8rem;
border-top: 1px solid var(--vp-c-divider);
}
.use-case-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.3rem;
}
.use-case-value {
font-size: 0.85rem;
color: var(--vp-c-text-1);
font-weight: 500;
}
.comparison-table {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.comparison-table h4 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--vp-c-text-1);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
thead {
background: var(--vp-c-bg-soft);
}
th {
padding: 0.8rem;
text-align: left;
font-weight: 600;
color: var(--vp-c-text-1);
border-bottom: 2px solid var(--vp-c-divider);
}
td {
padding: 0.8rem;
border-bottom: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
}
tr:last-child td {
border-bottom: none;
}
.stars {
color: #f59e0b;
}
.tips {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.tip-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.2rem;
display: flex;
gap: 1rem;
}
.tip-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.tip-content {
flex: 1;
}
.tip-content h4 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.6rem;
color: var(--vp-c-text-1);
}
.tip-content ul {
list-style: none;
padding: 0;
margin: 0;
}
.tip-content li {
font-size: 0.85rem;
color: var(--vp-c-text-2);
line-height: 1.7;
margin-bottom: 0.3rem;
}
.tip-content li:last-child {
margin-bottom: 0;
}
.inline-code {
background: var(--vp-c-bg-soft);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.8rem;
color: var(--vp-c-brand);
}
</style>
@@ -0,0 +1,423 @@
<!--
LazyLoadingDemo.vue
懒加载演示
-->
<template>
<div class="lazy-loading-demo">
<div class="header">
<div class="title">图片懒加载节省带宽提升性能</div>
<div class="subtitle">对比懒加载和立即加载的区别</div>
</div>
<div class="demo-container">
<div class="mode-selector">
<button
@click="mode = 'eager'"
:class="['mode-btn', { active: mode === 'eager' }]"
>
📦 立即加载
</button>
<button
@click="mode = 'lazy'"
:class="['mode-btn', { active: mode === 'lazy' }]"
>
懒加载
</button>
</div>
<div class="stats-bar">
<div class="stat">
<span class="stat-label">已加载图片</span>
<span class="stat-value">{{ loadedImages }} / {{ totalImages }}</span>
</div>
<div class="stat">
<span class="stat-label">节省流量</span>
<span class="stat-value" :class="{ positive: savedBandwidth > 0 }">
{{ savedBandwidth > 0 ? '-' : '' }}{{ savedBandwidth }} KB
</span>
</div>
<div class="stat">
<span class="stat-label">加载时间</span>
<span class="stat-value">{{ loadTime }} ms</span>
</div>
</div>
<div class="scroll-container" ref="scrollContainer" @scroll="handleScroll">
<div class="content-area">
<div class="placeholder">向下滚动查看更多内容</div>
<div
v-for="(image, index) in images"
:key="index"
class="image-item"
:ref="el => setImageRef(el, index)"
>
<div class="image-wrapper" :class="{ loading: image.loading, loaded: image.loaded }">
<div v-if="!image.loaded && mode === 'lazy'" class="placeholder-box">
<div class="spinner"></div>
<div class="placeholder-text">加载中...</div>
</div>
<div v-else-if="image.loaded" class="image-box">
<div class="image-icon">🖼</div>
<div class="image-info">
<div class="image-size">{{ image.size }}</div>
<div class="image-dim">{{ image.dimensions }}</div>
</div>
</div>
</div>
<div class="image-caption">图片 {{ index + 1 }}</div>
</div>
<div class="placeholder">已经到底了</div>
</div>
</div>
<div class="explanation">
<div class="explanation-item">
<h4>💡 懒加载原理</h4>
<p>只有当图片进入视口用户可见区域时才开始加载使用 Intersection Observer API 可以高效实现</p>
</div>
<div class="explanation-item">
<h4>📊 性能收益</h4>
<p>懒加载可以节省 30-60% 的带宽大幅提升首屏加载速度特别是在移动端效果显著</p>
</div>
<div class="explanation-item">
<h4>🔧 实现方式</h4>
<p><code>loading="lazy"</code> 属性是最简单的方式现代浏览器都支持需要更多控制时使用 Intersection Observer</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
const mode = ref('eager')
const scrollContainer = ref(null)
const totalImages = 12
const imageRefs = ref([])
const images = ref([])
const loadedImages = computed(() => {
return images.value.filter(img => img.loaded).length
})
const savedBandwidth = computed(() => {
if (mode.value === 'eager') return 0
const notLoaded = images.value.filter(img => !img.loaded && !imageRefs.value[images.value.indexOf(img)]?.isVisible).length
return notLoaded * 150 // 假设每张图片 150KB
})
const loadTime = computed(() => {
if (mode.value === 'eager') return 2400
return loadedImages.value * 150
})
function initializeImages() {
images.value = Array.from({ length: totalImages }, (_, i) => ({
loaded: mode.value === 'eager',
loading: false,
size: '150 KB',
dimensions: '800×600'
}))
}
function setImageRef(el, index) {
if (el) {
imageRefs.value[index] = el
}
}
function handleScroll() {
if (mode.value === 'lazy') {
checkVisibility()
}
}
function checkVisibility() {
const container = scrollContainer.value
if (!container) return
const containerRect = container.getBoundingClientRect()
const threshold = 100 // 提前 100px 开始加载
images.value.forEach((image, index) => {
if (image.loaded || image.loading) return
const ref = imageRefs.value[index]
if (!ref) return
const rect = ref.getBoundingClientRect()
const isVisible = rect.top < containerRect.bottom + threshold
if (isVisible) {
loadImage(index)
}
})
}
function loadImage(index) {
const image = images.value[index]
if (!image || image.loaded || image.loading) return
image.loading = true
// 模拟加载延迟
setTimeout(() => {
image.loaded = true
image.loading = false
}, 300 + Math.random() * 500)
}
watch(mode, () => {
initializeImages()
if (mode.value === 'lazy') {
setTimeout(() => checkVisibility(), 100)
}
})
onMounted(() => {
initializeImages()
if (mode.value === 'lazy') {
setTimeout(() => checkVisibility(), 100)
}
})
</script>
<style scoped>
.lazy-loading-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.3rem;
}
.demo-container {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.5rem;
}
.mode-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
justify-content: center;
}
.mode-btn {
padding: 0.6rem 1.2rem;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s;
color: var(--vp-c-text-1);
}
.mode-btn:hover {
border-color: var(--vp-c-brand);
}
.mode-btn.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: #fff;
}
.stats-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.stat {
flex: 1;
min-width: 120px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.8rem;
text-align: center;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.3rem;
}
.stat-value {
display: block;
font-size: 1.1rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
.stat-value.positive {
color: #22c55e;
}
.scroll-container {
height: 400px;
overflow-y: auto;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
margin-bottom: 1.5rem;
background: var(--vp-c-bg-soft);
}
.content-area {
padding: 1rem;
}
.placeholder {
text-align: center;
padding: 1.5rem;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.image-item {
margin-bottom: 1rem;
}
.image-wrapper {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
}
.image-wrapper.loading {
border-color: var(--vp-c-brand);
}
.image-wrapper.loaded {
border-color: #22c55e;
}
.placeholder-box {
height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--vp-c-bg-soft);
}
.spinner {
width: 30px;
height: 30px;
border: 3px 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); }
}
.placeholder-text {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.image-box {
height: 150px;
display: flex;
align-items: center;
padding: 1rem;
gap: 1rem;
}
.image-icon {
font-size: 3rem;
}
.image-info {
flex: 1;
}
.image-size {
font-size: 0.9rem;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 0.3rem;
}
.image-dim {
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.image-caption {
text-align: center;
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-top: 0.5rem;
}
.explanation {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.explanation-item {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 1rem;
}
.explanation-item h4 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--vp-c-text-1);
}
.explanation-item p {
font-size: 0.85rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin: 0;
}
.explanation-item code {
background: var(--vp-c-bg);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.8rem;
color: var(--vp-c-brand);
}
</style>
@@ -0,0 +1,371 @@
<!--
PerformanceMetricsDemo.vue
Core Web Vitals 性能指标演示
-->
<template>
<div class="metrics-demo">
<div class="header">
<div class="title">Core Web Vitals 核心性能指标</div>
<div class="subtitle">调整页面加载时间观察各项指标变化</div>
</div>
<div class="simulation-controls">
<label>
模拟加载时间<strong>{{ loadTime }}</strong>
</label>
<input v-model.number="loadTime" type="range" min="0.5" max="5" step="0.1" />
<button @click="startLoading" :disabled="isLoading">
{{ isLoading ? '加载中...' : '模拟加载' }}
</button>
</div>
<div class="metrics-grid">
<div class="metric-card" :class="fcpStatus.class">
<div class="metric-header">
<div class="metric-name">FCP</div>
<div class="metric-full">First Contentful Paint</div>
</div>
<div class="metric-value">{{ fcp }} s</div>
<div class="metric-desc">首次内容绘制</div>
<div class="metric-status">{{ fcpStatus.text }}</div>
<div class="indicator" :class="fcpStatus.class"></div>
</div>
<div class="metric-card" :class="lcpStatus.class">
<div class="metric-header">
<div class="metric-name">LCP</div>
<div class="metric-full">Largest Contentful Paint</div>
</div>
<div class="metric-value">{{ lcp }} s</div>
<div class="metric-desc">最大内容绘制</div>
<div class="metric-status">{{ lcpStatus.text }}</div>
<div class="indicator" :class="lcpStatus.class"></div>
</div>
<div class="metric-card" :class="fidStatus.class">
<div class="metric-header">
<div class="metric-name">FID</div>
<div class="metric-full">First Input Delay</div>
</div>
<div class="metric-value">{{ fid }} ms</div>
<div class="metric-desc">首次输入延迟</div>
<div class="metric-status">{{ fidStatus.text }}</div>
<div class="indicator" :class="fidStatus.class"></div>
</div>
<div class="metric-card" :class="clsStatus.class">
<div class="metric-header">
<div class="metric-name">CLS</div>
<div class="metric-full">Cumulative Layout Shift</div>
</div>
<div class="metric-value">{{ cls }}</div>
<div class="metric-desc">累积布局偏移</div>
<div class="metric-status">{{ clsStatus.text }}</div>
<div class="indicator" :class="clsStatus.class"></div>
</div>
</div>
<div class="explanation">
<div class="section">
<h4>指标说明</h4>
<ul>
<li><strong>FCP</strong>浏览器首次绘制内容的时间用户第一次看到页面有内容</li>
<li><strong>LCP</strong>最大内容绘制完成的时间主要内容可见</li>
<li><strong>FID</strong>用户首次交互到浏览器响应的时间页面是否可交互</li>
<li><strong>CLS</strong>页面布局在加载过程中的稳定性是否发生意外跳动</li>
</ul>
</div>
<div class="section">
<h4>评分标准</h4>
<div class="standards">
<div class="standard-item">
<span class="color-box good"></span>
<span>良好 (Good)</span>
</div>
<div class="standard-item">
<span class="color-box needs-improvement"></span>
<span>需改进 (Needs Improvement)</span>
</div>
<div class="standard-item">
<span class="color-box poor"></span>
<span> (Poor)</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const loadTime = ref(2.5)
const isLoading = ref(false)
const fcp = computed(() => (loadTime.value * 0.3).toFixed(1))
const lcp = computed(() => (loadTime.value * 0.7).toFixed(1))
const fid = computed(() => Math.round(loadTime.value * 80))
const cls = computed(() => (loadTime.value > 3 ? '0.25' : loadTime.value > 2 ? '0.15' : '0.05'))
const fcpStatus = computed(() => {
const value = parseFloat(fcp.value)
if (value <= 1.8) return { class: 'good', text: '良好' }
if (value <= 3) return { class: 'needs-improvement', text: '需改进' }
return { class: 'poor', text: '差' }
})
const lcpStatus = computed(() => {
const value = parseFloat(lcp.value)
if (value <= 2.5) return { class: 'good', text: '良好' }
if (value <= 4) return { class: 'needs-improvement', text: '需改进' }
return { class: 'poor', text: '差' }
})
const fidStatus = computed(() => {
const value = fid.value
if (value <= 100) return { class: 'good', text: '良好' }
if (value <= 300) return { class: 'needs-improvement', text: '需改进' }
return { class: 'poor', text: '差' }
})
const clsStatus = computed(() => {
const value = parseFloat(cls.value)
if (value <= 0.1) return { class: 'good', text: '良好' }
if (value <= 0.25) return { class: 'needs-improvement', text: '需改进' }
return { class: 'poor', text: '差' }
})
function startLoading() {
isLoading.value = true
setTimeout(() => {
isLoading.value = false
}, loadTime.value * 1000)
}
</script>
<style scoped>
.metrics-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.3rem;
}
.simulation-controls {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.simulation-controls label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--vp-c-text-1);
}
.simulation-controls input[type='range'] {
width: calc(100% - 120px);
margin-right: 1rem;
vertical-align: middle;
}
.simulation-controls button {
padding: 0.5rem 1rem;
background: var(--vp-c-brand);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: opacity 0.2s;
}
.simulation-controls button:hover:not(:disabled) {
opacity: 0.9;
}
.simulation-controls button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.metric-card {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1rem;
position: relative;
transition: all 0.3s;
}
.metric-card.good {
border-color: #22c55e;
background: linear-gradient(135deg, var(--vp-c-bg) 0%, rgba(34, 197, 94, 0.05) 100%);
}
.metric-card.needs-improvement {
border-color: #f59e0b;
background: linear-gradient(135deg, var(--vp-c-bg) 0%, rgba(245, 158, 11, 0.05) 100%);
}
.metric-card.poor {
border-color: #ef4444;
background: linear-gradient(135deg, var(--vp-c-bg) 0%, rgba(239, 68, 68, 0.05) 100%);
}
.metric-header {
margin-bottom: 0.5rem;
}
.metric-name {
font-weight: 700;
font-size: 1.1rem;
color: var(--vp-c-text-1);
}
.metric-full {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-top: 0.2rem;
}
.metric-value {
font-size: 1.8rem;
font-weight: 700;
margin: 0.5rem 0;
color: var(--vp-c-text-1);
}
.metric-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-bottom: 0.3rem;
}
.metric-status {
font-size: 0.85rem;
font-weight: 600;
}
.indicator {
position: absolute;
top: 1rem;
right: 1rem;
width: 12px;
height: 12px;
border-radius: 50%;
}
.indicator.good {
background: #22c55e;
box-shadow: 0 0 10px rgba(34, 197, 94, 0.5);
}
.indicator.needs-improvement {
background: #f59e0b;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.5);
}
.indicator.poor {
background: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5);
}
.explanation {
border-top: 1px solid var(--vp-c-divider);
padding-top: 1.5rem;
}
.section {
margin-bottom: 1.5rem;
}
.section h4 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 0.8rem;
color: var(--vp-c-text-1);
}
.section ul {
list-style: none;
padding: 0;
margin: 0;
}
.section li {
font-size: 0.85rem;
color: var(--vp-c-text-2);
line-height: 1.7;
padding-left: 1rem;
position: relative;
}
.section li::before {
content: '•';
position: absolute;
left: 0;
color: var(--vp-c-brand);
}
.standards {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.standard-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.color-box {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.color-box.good {
background: #22c55e;
}
.color-box.needs-improvement {
background: #f59e0b;
}
.color-box.poor {
background: #ef4444;
}
</style>
@@ -0,0 +1,467 @@
<!--
ReflowRepaintDemo.vue
重排与重绘演示
-->
<template>
<div class="reflow-demo">
<div class="header">
<div class="title">重排与重绘对比</div>
<div class="subtitle">观察不同操作对性能的影响</div>
</div>
<div class="demo-container">
<div class="canvas-area">
<div class="box-container">
<div
v-for="box in boxes"
:key="box.id"
class="box"
:class="box.selected ? 'selected' : ''"
:style="getBoxStyle(box)"
@click="selectBox(box.id)"
>
{{ box.id }}
</div>
</div>
<div class="performance-meter">
<div class="meter-label">性能影响</div>
<div class="meter-bar">
<div class="meter-fill" :class="performanceLevel.class" :style="{ width: performanceImpact + '%' }"></div>
</div>
<div class="meter-value" :class="performanceLevel.class">
{{ performanceLevel.text }}
</div>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-label">操作类型</div>
<div class="stat-value">{{ currentOperation }}</div>
</div>
<div class="stat-item">
<div class="stat-label">影响范围</div>
<div class="stat-value">{{ affectedElements }} 个元素</div>
</div>
</div>
</div>
<div class="controls">
<div class="control-section">
<h4>重排操作 (Reflow)</h4>
<p class="control-desc">改变元素尺寸或位置触发布局计算</p>
<button @click="changeWidth" class="btn reflow">改变宽度</button>
<button @click="changePosition" class="btn reflow">改变位置</button>
<button @click="addBox" class="btn reflow">添加元素</button>
</div>
<div class="control-section">
<h4>重绘操作 (Repaint)</h4>
<p class="control-desc">只改变外观不触发布局</p>
<button @click="changeColor" class="btn repaint">改变颜色</button>
<button @click="changeBackground" class="btn repaint">改变背景</button>
<button @click="toggleBorder" class="btn repaint">切换边框</button>
</div>
<div class="control-section">
<h4>合成操作 (Composite)</h4>
<p class="control-desc">只触发合成性能最佳</p>
<button @click="transformTranslate" class="btn composite">Transform 位移</button>
<button @click="transformRotate" class="btn composite">Transform 旋转</button>
<button @click="changeOpacity" class="btn composite">改变透明度</button>
</div>
</div>
</div>
<div class="info-section">
<div class="info-card">
<h4>什么是重排 (Reflow)</h4>
<p>当元素的位置尺寸发生变化时浏览器需要重新计算布局这个过程叫重排重排开销最大因为要重新计算所有受影响元素的位置</p>
</div>
<div class="info-card">
<h4>什么是重绘 (Repaint)</h4>
<p>当元素的外观颜色背景发生变化但位置不变时浏览器只需要重新绘制像素这个过程叫重绘比重排快但仍有开销</p>
</div>
<div class="info-card">
<h4>什么是合成 (Composite)</h4>
<p>使用 transform opacity 等属性浏览器可以在合成层上完成变化完全不触发布局和绘制性能最佳推荐优先使用</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const boxes = ref([
{ id: 1, x: 0, y: 0, width: 80, height: 80, color: '#3b82f6', bg: '#dbeafe', rotation: 0, opacity: 1, border: false, selected: false },
{ id: 2, x: 100, y: 0, width: 80, height: 80, color: '#8b5cf6', bg: '#ede9fe', rotation: 0, opacity: 1, border: false, selected: false },
{ id: 3, x: 0, y: 100, width: 80, height: 80, color: '#ec4899', bg: '#fce7f3', rotation: 0, opacity: 1, border: false, selected: false }
])
const currentOperation = ref('无')
const performanceImpact = ref(0)
const affectedElements = ref(0)
const performanceLevel = computed(() => {
if (performanceImpact.value <= 33) {
return { class: 'good', text: '低' }
} else if (performanceImpact.value <= 66) {
return { class: 'medium', text: '中' }
} else {
return { class: 'high', text: '高' }
}
})
function getBoxStyle(box) {
return {
left: box.x + 'px',
top: box.y + 'px',
width: box.width + 'px',
height: box.height + 'px',
backgroundColor: box.bg,
borderColor: box.color,
borderWidth: box.border ? '3px' : '0px',
color: box.color,
transform: `rotate(${box.rotation}deg)`,
opacity: box.opacity
}
}
function selectBox(id) {
boxes.value.forEach(b => b.selected = b.id === id)
}
function updateMetrics(operation, impact, affected) {
currentOperation.value = operation
performanceImpact.value = impact
affectedElements.value = affected
}
function changeWidth() {
boxes.value.forEach(box => {
box.width = 60 + Math.random() * 60
})
updateMetrics('改变宽度', 90, boxes.value.length)
}
function changePosition() {
boxes.value.forEach(box => {
box.x = Math.random() * 150
box.y = Math.random() * 150
})
updateMetrics('改变位置', 85, boxes.value.length)
}
function addBox() {
const newId = boxes.value.length + 1
boxes.value.push({
id: newId,
x: Math.random() * 100,
y: Math.random() * 100,
width: 80,
height: 80,
color: '#10b981',
bg: '#d1fae5',
rotation: 0,
opacity: 1,
border: false,
selected: false
})
updateMetrics('添加元素', 95, boxes.value.length)
}
function changeColor() {
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b']
boxes.value.forEach(box => {
box.color = colors[Math.floor(Math.random() * colors.length)]
})
updateMetrics('改变颜色', 50, boxes.value.length)
}
function changeBackground() {
const bgs = ['#dbeafe', '#ede9fe', '#fce7f3', '#d1fae5', '#fef3c7']
boxes.value.forEach(box => {
box.bg = bgs[Math.floor(Math.random() * bgs.length)]
})
updateMetrics('改变背景', 45, boxes.value.length)
}
function toggleBorder() {
boxes.value.forEach(box => {
box.border = !box.border
})
updateMetrics('切换边框', 55, boxes.value.length)
}
function transformTranslate() {
boxes.value.forEach(box => {
box.x += Math.random() * 20 - 10
})
updateMetrics('Transform 位移', 10, boxes.value.length)
}
function transformRotate() {
boxes.value.forEach(box => {
box.rotation += Math.random() * 30 - 15
})
updateMetrics('Transform 旋转', 10, boxes.value.length)
}
function changeOpacity() {
boxes.value.forEach(box => {
box.opacity = 0.5 + Math.random() * 0.5
})
updateMetrics('改变透明度', 10, boxes.value.length)
}
</script>
<style scoped>
.reflow-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.3rem;
}
.demo-container {
display: grid;
grid-template-columns: 1fr 300px;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 768px) {
.demo-container {
grid-template-columns: 1fr;
}
}
.canvas-area {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.5rem;
}
.box-container {
position: relative;
height: 250px;
margin-bottom: 1.5rem;
border: 2px dashed var(--vp-c-divider);
border-radius: 8px;
}
.box {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.2rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
}
.box:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.box.selected {
box-shadow: 0 0 0 3px var(--vp-c-brand);
}
.performance-meter {
margin-bottom: 1rem;
}
.meter-label {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
}
.meter-bar {
height: 12px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.meter-fill {
height: 100%;
transition: all 0.5s ease;
border-radius: 6px;
}
.meter-fill.good {
background: linear-gradient(90deg, #22c55e, #14b8a6);
}
.meter-fill.medium {
background: linear-gradient(90deg, #f59e0b, #f97316);
}
.meter-fill.high {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
.meter-value {
font-size: 0.9rem;
font-weight: 600;
text-align: right;
}
.meter-value.good {
color: #22c55e;
}
.meter-value.medium {
color: #f59e0b;
}
.meter-value.high {
color: #ef4444;
}
.stats {
display: flex;
gap: 1rem;
}
.stat-item {
flex: 1;
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.8rem;
}
.stat-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.3rem;
}
.stat-value {
font-size: 0.95rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.controls {
display: flex;
flex-direction: column;
gap: 1rem;
}
.control-section {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
}
.control-section h4 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.3rem;
color: var(--vp-c-text-1);
}
.control-desc {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.8rem;
}
.btn {
display: block;
width: 100%;
padding: 0.6rem;
margin-bottom: 0.5rem;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
color: #fff;
}
.btn:last-child {
margin-bottom: 0;
}
.btn.reflow {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.btn.reflow:hover {
background: linear-gradient(135deg, #dc2626, #b91c1c);
}
.btn.repaint {
background: linear-gradient(135deg, #f59e0b, #f97316);
}
.btn.repaint:hover {
background: linear-gradient(135deg, #f97316, #ea580c);
}
.btn.composite {
background: linear-gradient(135deg, #22c55e, #14b8a6);
}
.btn.composite:hover {
background: linear-gradient(135deg, #14b8a6, #0d9488);
}
.info-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.info-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
}
.info-card h4 {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--vp-c-text-1);
}
.info-card p {
font-size: 0.85rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin: 0;
}
</style>