feat(docs): enhance interactive demos and improve documentation
- Add new interactive components for frontend routing, browser rendering pipeline, and database transactions - Improve existing demos with better visuals, explanations, and examples - Update documentation structure and content for better clarity - Add new utility scripts and update package.json with new commands - Fix formatting and alignment in documentation tables
This commit is contained in:
+84
-180
@@ -4,9 +4,10 @@
|
||||
-->
|
||||
<template>
|
||||
<div class="metrics-demo">
|
||||
<div class="header">
|
||||
<div class="title">Core Web Vitals 核心性能指标</div>
|
||||
<div class="subtitle">调整页面加载时间,观察各项指标变化</div>
|
||||
<div class="demo-header">
|
||||
<span class="icon">📊</span>
|
||||
<span class="title">Core Web Vitals</span>
|
||||
<span class="subtitle">调整加载时间,观察性能指标变化</span>
|
||||
</div>
|
||||
|
||||
<div class="simulation-controls">
|
||||
@@ -20,9 +21,6 @@
|
||||
max="5"
|
||||
step="0.1"
|
||||
/>
|
||||
<button @click="startLoading" :disabled="isLoading">
|
||||
{{ isLoading ? '加载中...' : '模拟加载' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="metrics-grid">
|
||||
@@ -71,43 +69,24 @@
|
||||
</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 class="standards">
|
||||
<div class="standard-item">
|
||||
<span class="color-box good"></span>
|
||||
<span>良好</span>
|
||||
</div>
|
||||
<div class="standard-item">
|
||||
<span class="color-box needs-improvement"></span>
|
||||
<span>需改进</span>
|
||||
</div>
|
||||
<div class="standard-item">
|
||||
<span class="color-box poor"></span>
|
||||
<span>差</span>
|
||||
</div>
|
||||
</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 class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心指标:</strong>FCP(首次绘制)≤1.8s,LCP(最大内容绘制)≤2.5s,FID(输入延迟)≤100ms,CLS(布局偏移)≤0.1。目标是让所有指标都达到"良好"标准。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -116,7 +95,6 @@
|
||||
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))
|
||||
@@ -152,249 +130,175 @@ const clsStatus = computed(() => {
|
||||
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);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.simulation-controls {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.simulation-controls label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
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;
|
||||
width: 100%;
|
||||
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;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem;
|
||||
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%
|
||||
);
|
||||
border-color: var(--vp-c-success-1);
|
||||
}
|
||||
|
||||
.metric-card.needs-improvement {
|
||||
border-color: #f59e0b;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--vp-c-bg) 0%,
|
||||
rgba(245, 158, 11, 0.05) 100%
|
||||
);
|
||||
border-color: var(--vp-c-warning-1);
|
||||
}
|
||||
|
||||
.metric-card.poor {
|
||||
border-color: #ef4444;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--vp-c-bg) 0%,
|
||||
rgba(239, 68, 68, 0.05) 100%
|
||||
);
|
||||
border-color: var(--vp-c-error-1);
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.metric-full {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0.5rem 0;
|
||||
margin: 0.4rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.metric-desc {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-status {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.indicator.good {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.5);
|
||||
background: var(--vp-c-success-1);
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.indicator.needs-improvement {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 10px rgba(245, 158, 11, 0.5);
|
||||
background: var(--vp-c-warning-1);
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.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);
|
||||
background: var(--vp-c-error-1);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.standards {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.standard-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.color-box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.color-box.good {
|
||||
background: #22c55e;
|
||||
background: var(--vp-c-success-1);
|
||||
}
|
||||
|
||||
.color-box.needs-improvement {
|
||||
background: #f59e0b;
|
||||
background: var(--vp-c-warning-1);
|
||||
}
|
||||
|
||||
.color-box.poor {
|
||||
background: #ef4444;
|
||||
background: var(--vp-c-error-1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
</style>
|
||||
|
||||
+191
-263
@@ -4,21 +4,33 @@
|
||||
-->
|
||||
<template>
|
||||
<div class="reflow-demo">
|
||||
<div class="header">
|
||||
<div class="title">重排与重绘对比</div>
|
||||
<div class="subtitle">观察不同操作对性能的影响</div>
|
||||
<div class="demo-header">
|
||||
<span class="icon">⚡</span>
|
||||
<span class="title">重排与重绘</span>
|
||||
<span class="subtitle">观察不同操作对性能的影响</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<span class="tab-icon">{{ tab.icon }}</span>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-area">
|
||||
<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>
|
||||
@@ -51,62 +63,57 @@
|
||||
</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 v-if="activeTab === 'reflow'" class="control-group">
|
||||
<button @click="changeWidth" class="btn high-impact">改变宽度</button>
|
||||
<button @click="changePosition" class="btn high-impact">改变位置</button>
|
||||
<button @click="addBox" class="btn high-impact">添加元素</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">
|
||||
<div v-if="activeTab === 'repaint'" class="control-group">
|
||||
<button @click="changeColor" class="btn medium-impact">改变颜色</button>
|
||||
<button @click="changeBackground" class="btn medium-impact">
|
||||
改变背景
|
||||
</button>
|
||||
<button @click="toggleBorder" class="btn repaint">切换边框</button>
|
||||
<button @click="toggleBorder" class="btn medium-impact">切换边框</button>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h4>合成操作 (Composite)</h4>
|
||||
<p class="control-desc">只触发合成,性能最佳</p>
|
||||
<button @click="transformTranslate" class="btn composite">
|
||||
<div v-if="activeTab === 'composite'" class="control-group">
|
||||
<button @click="transformTranslate" class="btn low-impact">
|
||||
Transform 位移
|
||||
</button>
|
||||
<button @click="transformRotate" class="btn composite">
|
||||
<button @click="transformRotate" class="btn low-impact">
|
||||
Transform 旋转
|
||||
</button>
|
||||
<button @click="changeOpacity" class="btn composite">
|
||||
<button @click="changeOpacity" class="btn low-impact">
|
||||
改变透明度
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="info-card">
|
||||
<h4>什么是重排 (Reflow)?</h4>
|
||||
<p>
|
||||
当元素的位置、尺寸发生变化时,浏览器需要重新计算布局,这个过程叫重排。重排开销最大,因为要重新计算所有受影响元素的位置。
|
||||
</p>
|
||||
<Transition name="fade">
|
||||
<div v-if="activeTab" class="tab-info">
|
||||
<div v-if="activeTab === 'reflow'" class="info-content">
|
||||
<p>
|
||||
<strong>重排 (Reflow)</strong>:当元素的位置、尺寸发生变化时,浏览器需要重新计算布局。重排开销最大,因为要重新计算所有受影响元素的位置。
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="activeTab === 'repaint'" class="info-content">
|
||||
<p>
|
||||
<strong>重绘 (Repaint)</strong>:当元素的外观(颜色、背景)发生变化,但位置不变时,浏览器只需要重新绘制像素。比重排快,但仍有开销。
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="activeTab === 'composite'" class="info-content">
|
||||
<p>
|
||||
<strong>合成 (Composite)</strong>:使用 transform 和 opacity 等属性,浏览器可以在合成层上完成变化,完全不触发布局和绘制。性能最佳,推荐优先使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="info-card">
|
||||
<h4>什么是重绘 (Repaint)?</h4>
|
||||
<p>
|
||||
当元素的外观(颜色、背景)发生变化,但位置不变时,浏览器只需要重新绘制像素,这个过程叫重绘。比重排快,但仍有开销。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h4>什么是合成 (Composite)?</h4>
|
||||
<p>
|
||||
使用 transform 和 opacity
|
||||
等属性,浏览器可以在合成层上完成变化,完全不触发布局和绘制。性能最佳,推荐优先使用。
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>性能优化原则:</strong>优先使用 transform 和 opacity 进行动画,避免频繁触发布局计算(如 width、height、top、left),可以大幅提升页面性能。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -114,46 +121,18 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeTab = ref('reflow')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'reflow', icon: '🔴', label: '重排' },
|
||||
{ id: 'repaint', icon: '🟡', label: '重绘' },
|
||||
{ id: 'composite', icon: '🟢', label: '合成' }
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
{ id: 1, x: 20, y: 20, width: 80, height: 80, bg: 'var(--vp-c-brand-1)', rotation: 0, opacity: 1 },
|
||||
{ id: 2, x: 120, y: 20, width: 80, height: 80, bg: 'var(--vp-c-brand-2)', rotation: 0, opacity: 1 },
|
||||
{ id: 3, x: 20, y: 120, width: 80, height: 80, bg: 'var(--vp-c-brand-3)', rotation: 0, opacity: 1 }
|
||||
])
|
||||
|
||||
const currentOperation = ref('无')
|
||||
@@ -161,13 +140,9 @@ 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: '高' }
|
||||
}
|
||||
if (performanceImpact.value <= 33) return { class: 'good', text: '低' }
|
||||
if (performanceImpact.value <= 66) return { class: 'medium', text: '中' }
|
||||
return { class: 'high', text: '高' }
|
||||
})
|
||||
|
||||
function getBoxStyle(box) {
|
||||
@@ -177,18 +152,11 @@ function getBoxStyle(box) {
|
||||
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
|
||||
@@ -196,9 +164,7 @@ function updateMetrics(operation, impact, affected) {
|
||||
}
|
||||
|
||||
function changeWidth() {
|
||||
boxes.value.forEach((box) => {
|
||||
box.width = 60 + Math.random() * 60
|
||||
})
|
||||
boxes.value.forEach((box) => { box.width = 60 + Math.random() * 60 })
|
||||
updateMetrics('改变宽度', 90, boxes.value.length)
|
||||
}
|
||||
|
||||
@@ -218,57 +184,41 @@ function addBox() {
|
||||
y: Math.random() * 100,
|
||||
width: 80,
|
||||
height: 80,
|
||||
color: '#10b981',
|
||||
bg: '#d1fae5',
|
||||
bg: 'var(--vp-c-brand)',
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
border: false,
|
||||
selected: false
|
||||
opacity: 1
|
||||
})
|
||||
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)]
|
||||
})
|
||||
const colors = ['var(--vp-c-brand-1)', 'var(--vp-c-brand-2)', 'var(--vp-c-brand-3)']
|
||||
boxes.value.forEach((box) => { box.bg = 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)]
|
||||
})
|
||||
const bgs = ['var(--vp-c-brand-1)', 'var(--vp-c-brand-2)', 'var(--vp-c-brand-3)']
|
||||
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
|
||||
})
|
||||
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
|
||||
})
|
||||
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
|
||||
})
|
||||
boxes.value.forEach((box) => { box.opacity = 0.5 + Math.random() * 0.5 })
|
||||
updateMetrics('改变透明度', 10, boxes.value.length)
|
||||
}
|
||||
</script>
|
||||
@@ -276,54 +226,78 @@ function changeOpacity() {
|
||||
<style scoped>
|
||||
.reflow-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-family: var(--vp-font-family-base);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.3rem;
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
.tab-btn:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: var(--vp-c-bg-inverse);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.tab-icon { font-size: 1rem; }
|
||||
.tab-label { font-weight: 500; }
|
||||
|
||||
.demo-area {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
grid-template-columns: 1fr 200px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.demo-area { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.box-container {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
margin-bottom: 1.5rem;
|
||||
height: 200px;
|
||||
margin-bottom: 1rem;
|
||||
border: 2px dashed var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.box {
|
||||
@@ -332,95 +306,72 @@ function changeOpacity() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
color: var(--vp-c-bg-inverse);
|
||||
border-radius: 6px;
|
||||
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;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.meter-bar {
|
||||
height: 12px;
|
||||
height: 10px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
transition: all 0.5s ease;
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.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-fill.good { background: var(--vp-c-success-1); }
|
||||
.meter-fill.medium { background: var(--vp-c-warning-1); }
|
||||
.meter-fill.high { background: var(--vp-c-error-1); }
|
||||
|
||||
.meter-value {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.meter-value.good {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.meter-value.medium {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.meter-value.high {
|
||||
color: #ef4444;
|
||||
}
|
||||
.meter-value.good { color: var(--vp-c-success-1); }
|
||||
.meter-value.medium { color: var(--vp-c-warning-1); }
|
||||
.meter-value.high { color: var(--vp-c-error-1); }
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.3rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
@@ -428,95 +379,72 @@ function changeOpacity() {
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--vp-c-bg-inverse);
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.btn.high-impact { background: var(--vp-c-error-1); }
|
||||
.btn.high-impact:hover { opacity: 0.9; }
|
||||
|
||||
.btn.reflow {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
}
|
||||
.btn.medium-impact { background: var(--vp-c-warning-1); }
|
||||
.btn.medium-impact:hover { opacity: 0.9; }
|
||||
|
||||
.btn.reflow:hover {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
}
|
||||
.btn.low-impact { background: var(--vp-c-success-1); }
|
||||
.btn.low-impact:hover { opacity: 0.9; }
|
||||
|
||||
.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 {
|
||||
.tab-info {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
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 {
|
||||
.info-content {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-content strong {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
</style>
|
||||
|
||||
+56
-33
@@ -1,14 +1,18 @@
|
||||
<!--
|
||||
VirtualScrollingDemo.vue
|
||||
虚拟滚动演示
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const TOTAL_ITEMS = 10000
|
||||
const ITEM_HEIGHT = 50
|
||||
const CONTAINER_HEIGHT = 400
|
||||
const CONTAINER_HEIGHT = 280
|
||||
|
||||
// Generate mock data
|
||||
const items = Array.from({ length: TOTAL_ITEMS }, (_, i) => ({
|
||||
id: i,
|
||||
content: `Item #${i + 1} - This is a long list item content to simulate real data.`
|
||||
content: `Item #${i + 1} - 虚拟滚动列表项内容`
|
||||
}))
|
||||
|
||||
const scrollTop = ref(0)
|
||||
@@ -41,17 +45,23 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
|
||||
<template>
|
||||
<div class="demo-container">
|
||||
<div class="demo-header">
|
||||
<span class="icon">📜</span>
|
||||
<span class="title">虚拟滚动</span>
|
||||
<span class="subtitle">只渲染可见区域的列表项</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Total Items</div>
|
||||
<div class="stat-label">总数据量</div>
|
||||
<div class="stat-value">{{ TOTAL_ITEMS.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="stat-box highlight">
|
||||
<div class="stat-label">Rendered DOM Nodes</div>
|
||||
<div class="stat-label">实际渲染</div>
|
||||
<div class="stat-value">{{ renderedCount }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">Memory Saved</div>
|
||||
<div class="stat-label">节省内存</div>
|
||||
<div class="stat-value">
|
||||
~{{ ((1 - renderedCount / TOTAL_ITEMS) * 100).toFixed(1) }}%
|
||||
</div>
|
||||
@@ -81,14 +91,9 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<p>
|
||||
<strong>How it works:</strong> Instead of rendering all
|
||||
{{ TOTAL_ITEMS }} items at once, we only render the items currently
|
||||
visible in the viewport (plus a small buffer). As you scroll, we
|
||||
calculate which items should be visible and position them absolutely to
|
||||
create the illusion of a full list.
|
||||
</p>
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>工作原理:</strong>不渲染全部 {{ TOTAL_ITEMS }} 项,只渲染视口中可见的项(加上少量缓冲)。滚动时计算应该显示哪些项,并使用绝对定位创建完整列表的错觉。性能从 O(n) 优化到 O(1)。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,40 +102,52 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
.demo-container {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background-color: var(--vp-c-bg);
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.6rem;
|
||||
border-radius: 6px;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.stat-box.highlight {
|
||||
border-color: var(--vp-c-brand);
|
||||
background-color: var(--vp-c-brand-dimm);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
@@ -138,9 +155,10 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
.scroll-container {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
background-color: var(--vp-c-bg);
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.scroll-phantom {
|
||||
@@ -157,7 +175,7 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
pointer-events: none; /* Let scroll events pass through to container */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
@@ -167,10 +185,10 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
box-sizing: border-box;
|
||||
background-color: var(--vp-c-bg); /* Ensure background covers phantom */
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.item-index {
|
||||
@@ -178,6 +196,7 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
color: var(--vp-c-brand);
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
@@ -185,12 +204,16 @@ const renderedCount = computed(() => visibleItems.value.length)
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.explanation {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user