feat(docs): add interactive demos and complete content for development tools
- Add Vue components for interactive demos (SSH auth, regex, env vars, ports) - Complete markdown content for SSH, regex, environment variables, and ports - Remove placeholder "待实现" sections and replace with detailed guides - Add visual explanations for key concepts like ports and localhost - Include practical examples and troubleshooting tips - Add component for showing evolution from transistors to CPU - Improve documentation structure and navigation - Add security best practices for API keys and environment variables
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="component-tree-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">组件化拆分</span>
|
||||
<span class="subtitle">一个页面如何拆成多个独立组件</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-body">
|
||||
<div class="tree-panel">
|
||||
<div class="tree-title">组件树结构</div>
|
||||
<div class="tree-list">
|
||||
<div
|
||||
v-for="comp in components"
|
||||
:key="comp.id"
|
||||
:class="['tree-item', { active: selected === comp.id }]"
|
||||
:style="{ paddingLeft: comp.depth * 1 + 'rem' }"
|
||||
@click="selected = comp.id"
|
||||
>
|
||||
<span class="tree-icon">{{ comp.icon }}</span>
|
||||
<span class="tree-name">{{ comp.name }}</span>
|
||||
<span v-if="comp.reused" class="reuse-badge">×{{ comp.reused }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-panel">
|
||||
<div class="tree-title">页面预览</div>
|
||||
<div class="page-mock">
|
||||
<div
|
||||
:class="['mock-navbar', { highlighted: selected === 'navbar' }]"
|
||||
@click="selected = 'navbar'"
|
||||
>
|
||||
<span>🏠 电商网站</span>
|
||||
<span
|
||||
:class="['mock-search', { highlighted: selected === 'search' }]"
|
||||
@click.stop="selected = 'search'"
|
||||
>🔍 搜索框</span>
|
||||
<span
|
||||
:class="['mock-cart-icon', { highlighted: selected === 'cart' }]"
|
||||
@click.stop="selected = 'cart'"
|
||||
>🛒 购物车(3)</span>
|
||||
</div>
|
||||
<div class="mock-content">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:class="['mock-product-card', { highlighted: selected === 'product' }]"
|
||||
@click="selected = 'product'"
|
||||
>
|
||||
<div class="mock-img">📦</div>
|
||||
<div class="mock-info">
|
||||
<div class="mock-product-name">商品 {{ i }}</div>
|
||||
<div class="mock-price">¥{{ i * 99 + 100 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="['mock-footer', { highlighted: selected === 'footer' }]"
|
||||
@click="selected = 'footer'"
|
||||
>
|
||||
© 2025 电商网站
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedComp" class="detail-card">
|
||||
<div class="detail-name">{{ selectedComp.icon }} {{ selectedComp.name }}</div>
|
||||
<div class="detail-desc">{{ selectedComp.desc }}</div>
|
||||
<div class="detail-tags">
|
||||
<span class="detail-tag">数据独立</span>
|
||||
<span class="detail-tag">样式隔离</span>
|
||||
<span v-if="selectedComp.reused" class="detail-tag reuse">
|
||||
复用 {{ selectedComp.reused }} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>组件化就是把一个大页面拆成多个独立的小块。每个组件管理自己的数据、界面和样式,互不干扰。同一个组件可以在不同地方复用多次,传入不同的数据就会显示不同的内容。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const selected = ref('product')
|
||||
|
||||
const components = [
|
||||
{ id: 'app', name: 'App(根组件)', icon: '📱', depth: 0, desc: '整个应用的根组件,包含所有其他组件。' },
|
||||
{ id: 'navbar', name: 'NavBar(导航栏)', icon: '🧭', depth: 1, desc: '页面顶部的导航栏,包含 Logo、搜索框和购物车入口。' },
|
||||
{ id: 'search', name: 'SearchBox(搜索框)', icon: '🔍', depth: 2, desc: '独立的搜索框组件,管理搜索关键词和搜索结果。' },
|
||||
{ id: 'cart', name: 'CartIcon(购物车图标)', icon: '🛒', depth: 2, desc: '显示购物车数量的小图标,数据来自全局购物车状态。' },
|
||||
{ id: 'product', name: 'ProductCard(商品卡片)', icon: '📦', depth: 1, reused: 3, desc: '单个商品的展示卡片。写一次代码,传入不同的商品数据就能复用多次,每次显示不同的商品信息。' },
|
||||
{ id: 'footer', name: 'Footer(页脚)', icon: '📄', depth: 1, desc: '页面底部信息,一般包含版权声明等。' }
|
||||
]
|
||||
|
||||
const selectedComp = computed(() => components.find(c => c.id === selected.value))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.component-tree-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.demo-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-panel,
|
||||
.preview-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tree-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.reuse-badge {
|
||||
margin-left: auto;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-mock {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mock-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mock-search,
|
||||
.mock-cart-icon {
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mock-content {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mock-product-card {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mock-img {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.mock-product-name {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mock-price {
|
||||
font-size: 0.65rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.mock-footer {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
outline: 2px solid var(--vp-c-brand);
|
||||
outline-offset: -1px;
|
||||
background: rgba(59, 130, 246, 0.06) !important;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-tag.reuse {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
border-color: var(--vp-c-green-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);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.demo-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mock-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="data-ui-gap-demo">
|
||||
<div class="two-panels">
|
||||
<div class="panel data-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-badge data">数据(JavaScript 变量)</span>
|
||||
</div>
|
||||
<div class="data-display">
|
||||
<div class="data-row">
|
||||
<span class="data-key">商品数量</span>
|
||||
<span class="data-val">{{ dataCount }}</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-key">总价</span>
|
||||
<span class="data-val">¥{{ dataCount * 99 }}</span>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-key">状态</span>
|
||||
<span class="data-val">{{ dataCount > 5 ? '过多' : '正常' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" @click="addItem">添加商品(修改数据)</button>
|
||||
</div>
|
||||
|
||||
<div class="gap-indicator" :class="{ desynced: isDesynced }">
|
||||
<div class="gap-line" />
|
||||
<span class="gap-label">{{ isDesynced ? '❌ 不同步' : '✅ 同步' }}</span>
|
||||
<div class="gap-line" />
|
||||
</div>
|
||||
|
||||
<div class="panel ui-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-badge ui">界面(用户看到的)</span>
|
||||
</div>
|
||||
<div class="ui-display">
|
||||
<div class="ui-row" :class="{ stale: uiCount !== dataCount }">
|
||||
<span class="ui-key">购物车</span>
|
||||
<span class="ui-val">{{ uiCount }} 件</span>
|
||||
</div>
|
||||
<div class="ui-row" :class="{ stale: uiCount !== dataCount }">
|
||||
<span class="ui-key">总价</span>
|
||||
<span class="ui-val">¥{{ uiCount * 99 }}</span>
|
||||
</div>
|
||||
<div class="ui-row" :class="{ stale: uiCount !== dataCount }">
|
||||
<span class="ui-key">状态</span>
|
||||
<span class="ui-val">{{ uiCount > 5 ? '过多' : '正常' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sync-btn" :disabled="!isDesynced" @click="syncUI">
|
||||
{{ isDesynced ? '手动同步界面' : '已同步' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-row">
|
||||
<button class="action-btn outline" @click="reset">重置</button>
|
||||
<span v-if="desyncCount > 0" class="desync-stat">
|
||||
累计不同步 {{ desyncCount }} 次
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心问题:</strong>
|
||||
<span>在没有框架的情况下,数据变了,界面不会自动跟着变。你必须自己写代码去更新界面,一旦忘了,用户看到的就是过时的、错误的信息。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const dataCount = ref(0)
|
||||
const uiCount = ref(0)
|
||||
const desyncCount = ref(0)
|
||||
|
||||
const isDesynced = computed(() => dataCount.value !== uiCount.value)
|
||||
|
||||
function addItem() {
|
||||
dataCount.value++
|
||||
if (dataCount.value > 1 && isDesynced.value) {
|
||||
desyncCount.value++
|
||||
}
|
||||
}
|
||||
|
||||
function syncUI() {
|
||||
uiCount.value = dataCount.value
|
||||
}
|
||||
|
||||
function reset() {
|
||||
dataCount.value = 0
|
||||
uiCount.value = 0
|
||||
desyncCount.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-ui-gap-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.two-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
text-align: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.panel-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-badge.data {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.panel-badge.ui {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.data-display,
|
||||
.ui-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.data-row,
|
||||
.ui-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.ui-row.stale {
|
||||
border-color: var(--vp-c-danger-1);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.data-key,
|
||||
.ui-key {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.data-val,
|
||||
.ui-val {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.gap-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.gap-line {
|
||||
width: 2px;
|
||||
height: 2rem;
|
||||
background: var(--vp-c-green-1);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.gap-indicator.desynced .gap-line {
|
||||
background: var(--vp-c-danger-1);
|
||||
animation: pulse-line 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-line {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.gap-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover { opacity: 0.85; }
|
||||
|
||||
.action-btn.outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.action-btn.outline:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sync-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.controls-row .action-btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.desync-stat {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.two-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.gap-indicator {
|
||||
flex-direction: row;
|
||||
padding-top: 0;
|
||||
}
|
||||
.gap-line {
|
||||
width: 2rem;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="declarative-formula-demo">
|
||||
<div class="formula-row">
|
||||
<div class="formula-box state-box">
|
||||
<div class="formula-label">State(数据)</div>
|
||||
</div>
|
||||
<div class="formula-arrow">→ f →</div>
|
||||
<div class="formula-box ui-box">
|
||||
<div class="formula-label">UI(界面)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-body">
|
||||
<div class="input-panel">
|
||||
<div class="panel-title">修改数据(State)</div>
|
||||
<div class="input-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="username" type="text" placeholder="输入名字" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>商品数量</label>
|
||||
<div class="stepper">
|
||||
<button @click="count = Math.max(0, count - 1)">-</button>
|
||||
<span class="stepper-value">{{ count }}</span>
|
||||
<button @click="count++">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>深色模式</label>
|
||||
<label class="toggle-switch">
|
||||
<input v-model="darkMode" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-panel" :class="{ dark: darkMode }">
|
||||
<div class="panel-title">渲染结果(UI)</div>
|
||||
<div class="preview-card">
|
||||
<div class="preview-greeting">
|
||||
{{ username ? `你好,${username}!` : '你好,访客!' }}
|
||||
</div>
|
||||
<div class="preview-cart">
|
||||
购物车:{{ count }} 件商品
|
||||
</div>
|
||||
<div class="preview-total">
|
||||
总价:¥{{ count * 99 }}
|
||||
</div>
|
||||
<div v-if="count > 5" class="preview-warning">
|
||||
商品数量较多,请确认订单
|
||||
</div>
|
||||
<div class="preview-theme">
|
||||
当前主题:{{ darkMode ? '深色' : '浅色' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state-snapshot">
|
||||
<div class="snapshot-title">当前 State 快照</div>
|
||||
<code class="snapshot-code">{{ stateSnapshot }}</code>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>你只需要修改数据(State),框架会根据数据自动渲染出对应的界面(UI)。同样的数据永远渲染出同样的界面,这就是 UI = f(State)。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const username = ref('')
|
||||
const count = ref(2)
|
||||
const darkMode = ref(false)
|
||||
|
||||
const stateSnapshot = computed(() =>
|
||||
JSON.stringify(
|
||||
{ username: username.value || '(空)', count: count.value, darkMode: darkMode.value },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.declarative-formula-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.formula-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.formula-box {
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.ui-box {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.formula-arrow {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.input-panel,
|
||||
.output-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.output-panel.dark {
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.output-panel.dark .preview-card {
|
||||
background: #16213e;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.output-panel.dark .panel-title {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-group input[type="text"] {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.input-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stepper button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stepper button:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stepper-value {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 18px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 1px;
|
||||
bottom: 1px;
|
||||
background: var(--vp-c-text-2);
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(18px);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.preview-greeting {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-warning {
|
||||
color: var(--vp-c-danger-1);
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.output-panel.dark .preview-warning {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.preview-theme {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.state-snapshot {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.snapshot-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.snapshot-code {
|
||||
display: block;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-1);
|
||||
white-space: pre;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.demo-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="dom-cost-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">DOM 操作耗时对比</span>
|
||||
<span class="subtitle">逐个操作 vs 批量操作</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="control-group">
|
||||
<label>修改次数</label>
|
||||
<div class="radio-group">
|
||||
<button
|
||||
v-for="n in counts"
|
||||
:key="n"
|
||||
:class="['radio-btn', { active: selectedCount === n }]"
|
||||
@click="selectedCount = n"
|
||||
>
|
||||
{{ n }} 次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" :disabled="isRunning" @click="runComparison">
|
||||
{{ isRunning ? '执行中...' : '开始对比' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="comparison-row">
|
||||
<div class="method-card">
|
||||
<div class="method-header">
|
||||
<span class="method-badge slow">逐个操作 DOM</span>
|
||||
</div>
|
||||
<div class="method-desc">
|
||||
每修改一次数据 → 立刻操作一次真实 DOM → 浏览器每次都要重新布局和绘制
|
||||
</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
class="progress-bar-fill slow"
|
||||
:style="{ width: slowProgress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">模拟耗时</span>
|
||||
<span class="result-value" :class="{ highlight: showResults }">
|
||||
{{ showResults ? slowTime + 'ms' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-list">
|
||||
<div class="step-item" v-for="i in Math.min(selectedCount, 4)" :key="i">
|
||||
<span class="step-num">{{ i }}</span>
|
||||
<span class="step-text">修改 → 布局 → 绘制</span>
|
||||
</div>
|
||||
<div v-if="selectedCount > 4" class="step-item ellipsis">
|
||||
<span class="step-text">... 重复 {{ selectedCount - 4 }} 次 ...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="method-card">
|
||||
<div class="method-header">
|
||||
<span class="method-badge fast">批量计算后一次性操作</span>
|
||||
</div>
|
||||
<div class="method-desc">
|
||||
所有修改先在内存中计算好 → 最后只操作一次真实 DOM → 浏览器只需要重新布局和绘制一次
|
||||
</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar-bg">
|
||||
<div
|
||||
class="progress-bar-fill fast"
|
||||
:style="{ width: fastProgress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">模拟耗时</span>
|
||||
<span class="result-value" :class="{ highlight: showResults }">
|
||||
{{ showResults ? fastTime + 'ms' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-list">
|
||||
<div class="step-item">
|
||||
<span class="step-num">1</span>
|
||||
<span class="step-text">内存中计算 {{ selectedCount }} 次变化</span>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<span class="step-num">2</span>
|
||||
<span class="step-text">一次性提交 → 布局 → 绘制</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showResults" class="savings-banner">
|
||||
批量操作节省了 <strong>{{ savingsPercent }}%</strong> 的耗时
|
||||
({{ slowTime }}ms → {{ fastTime }}ms)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>DOM 操作的真正代价不是"修改值"本身,而是每次修改后浏览器必须执行的"重新布局 + 重新绘制"。减少 DOM 操作次数,就是减少这些昂贵的计算。虚拟 DOM 的作用就是先在内存中算好所有变化,最后一次性提交。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const counts = [5, 20, 100, 500]
|
||||
const selectedCount = ref(20)
|
||||
const isRunning = ref(false)
|
||||
const slowProgress = ref(0)
|
||||
const fastProgress = ref(0)
|
||||
const showResults = ref(false)
|
||||
|
||||
const COST_PER_OP = 3
|
||||
const BATCH_OVERHEAD = 8
|
||||
|
||||
const slowTime = computed(() => selectedCount.value * COST_PER_OP)
|
||||
const fastTime = computed(() => Math.round(BATCH_OVERHEAD + selectedCount.value * 0.1))
|
||||
const savingsPercent = computed(() =>
|
||||
Math.round((1 - fastTime.value / slowTime.value) * 100)
|
||||
)
|
||||
|
||||
async function runComparison() {
|
||||
if (isRunning.value) return
|
||||
isRunning.value = true
|
||||
showResults.value = false
|
||||
slowProgress.value = 0
|
||||
fastProgress.value = 0
|
||||
|
||||
const totalSlow = slowTime.value
|
||||
const totalFast = fastTime.value
|
||||
const duration = Math.min(totalSlow * 2, 2000)
|
||||
const steps = 30
|
||||
const stepDelay = duration / steps
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await new Promise(r => setTimeout(r, stepDelay))
|
||||
slowProgress.value = Math.min((i / steps) * 100, 100)
|
||||
const fastRatio = totalFast / totalSlow
|
||||
fastProgress.value = Math.min((i / steps / fastRatio) * 100, 100)
|
||||
}
|
||||
|
||||
slowProgress.value = 100
|
||||
fastProgress.value = 100
|
||||
showResults.value = true
|
||||
isRunning.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dom-cost-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.radio-btn {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.radio-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.radio-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.comparison-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.method-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.method-header {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.method-badge.slow {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.method-badge.fast {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.method-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.6rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
height: 8px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.progress-bar-fill.slow {
|
||||
background: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.progress-bar-fill.fast {
|
||||
background: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.result-value.highlight {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-item.ellipsis {
|
||||
padding-left: 1.4rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.savings-banner {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-green-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);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.comparison-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="framework-motivation">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
问题
|
||||
</div>
|
||||
<ul>
|
||||
<li>数据变化时,手动更新 DOM 容易遗漏</li>
|
||||
<li>页面越复杂,需要同步的地方越多,越容易出 bug</li>
|
||||
<li>多人协作时,DOM 操作散落各处,维护成本高</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
根本原因
|
||||
</div>
|
||||
<ul>
|
||||
<li>浏览器不知道"数据"和"界面"的对应关系</li>
|
||||
<li>原生 DOM API 只提供底层操作,没有"数据变了就更新 UI"的能力</li>
|
||||
<li>开发者被迫充当"人肉同步器"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
框架的解法
|
||||
</div>
|
||||
<ul>
|
||||
<li>建立数据到 UI 的映射关系(UI = f(State))</li>
|
||||
<li>自动检测数据变化(响应式系统)</li>
|
||||
<li>自动计算最小 DOM 更新(虚拟 DOM / 编译优化)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style scoped>
|
||||
.framework-motivation {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.framework-motivation {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">框架光谱</span>
|
||||
<span class="subtitle">运行时 ↔ 编译时</span>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="spectrum-wrapper">
|
||||
<div class="spectrum-labels">
|
||||
<span class="spectrum-label-left">更多运行时</span>
|
||||
<span class="spectrum-label-right">更多编译时</span>
|
||||
</div>
|
||||
<div class="spectrum-bar">
|
||||
<button
|
||||
v-for="fw in frameworks"
|
||||
:key="fw.id"
|
||||
:class="['spectrum-dot', { selected: selectedId === fw.id }]"
|
||||
:style="{ left: fw.percent + '%' }"
|
||||
:title="fw.name"
|
||||
@click="selectFramework(fw.id)"
|
||||
>
|
||||
{{ fw.short }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="spectrum-dot-labels">
|
||||
<span
|
||||
v-for="fw in frameworks"
|
||||
:key="'label-' + fw.id"
|
||||
class="dot-label"
|
||||
:style="{ left: fw.percent + '%' }"
|
||||
>
|
||||
{{ fw.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-header">
|
||||
<span class="detail-emoji">{{ selected.emoji }}</span>
|
||||
<span class="detail-name">{{ selected.name }}</span>
|
||||
</div>
|
||||
<div class="detail-summary">{{ selected.summary }}</div>
|
||||
<div class="work-bars">
|
||||
<div class="work-bar-row">
|
||||
<span class="work-label">运行时工作量</span>
|
||||
<div class="work-bar-track">
|
||||
<div
|
||||
class="work-bar-fill runtime"
|
||||
:style="{ width: selected.runtimePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="work-value">{{ selected.runtimePercent }}%</span>
|
||||
</div>
|
||||
<div class="work-bar-row">
|
||||
<span class="work-label">编译时工作量</span>
|
||||
<div class="work-bar-track">
|
||||
<div
|
||||
class="work-bar-fill compile"
|
||||
:style="{ width: selected.compilePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="work-value">{{ selected.compilePercent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">打包体积</span>
|
||||
<span class="meta-value">{{ selected.bundleSize }}</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">开发体验</span>
|
||||
<span class="meta-value">{{ selected.devExperience }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>趋势:</strong>
|
||||
{{ selected.trendMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const FRAMEWORKS = {
|
||||
react: {
|
||||
id: 'react',
|
||||
name: 'React',
|
||||
short: 'R',
|
||||
emoji: '⚛️',
|
||||
percent: 20,
|
||||
runtimePercent: 80,
|
||||
compilePercent: 20,
|
||||
bundleSize: '中等',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '运行时为主:虚拟 DOM + Reconciliation',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
vue3: {
|
||||
id: 'vue3',
|
||||
name: 'Vue 3',
|
||||
short: 'V',
|
||||
emoji: '💚',
|
||||
percent: 40,
|
||||
runtimePercent: 60,
|
||||
compilePercent: 40,
|
||||
bundleSize: '中等',
|
||||
devExperience: '★★★★★',
|
||||
summary: '混合:编译优化模板 + 运行时虚拟 DOM',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
vapor: {
|
||||
id: 'vapor',
|
||||
name: 'Vue Vapor',
|
||||
short: 'Vp',
|
||||
emoji: '🌫️',
|
||||
percent: 60,
|
||||
runtimePercent: 40,
|
||||
compilePercent: 60,
|
||||
bundleSize: '较小',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '编译时为主:跳过虚拟 DOM,编译生成直接操作',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
svelte: {
|
||||
id: 'svelte',
|
||||
name: 'Svelte',
|
||||
short: 'S',
|
||||
emoji: '🔥',
|
||||
percent: 80,
|
||||
runtimePercent: 20,
|
||||
compilePercent: 80,
|
||||
bundleSize: '最小',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '编译时为主:编译时生成精确 DOM 更新代码',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
},
|
||||
solid: {
|
||||
id: 'solid',
|
||||
name: 'Solid.js',
|
||||
short: 'Sd',
|
||||
emoji: '⬆️',
|
||||
percent: 90,
|
||||
runtimePercent: 10,
|
||||
compilePercent: 90,
|
||||
bundleSize: '最小',
|
||||
devExperience: '★★★★☆',
|
||||
summary: '纯编译时:细粒度响应式,无虚拟 DOM',
|
||||
trendMessage:
|
||||
'趋势很明确:框架在不断将工作从运行时移向编译时,目标是同时实现更好的开发体验和更优的运行性能。'
|
||||
}
|
||||
}
|
||||
|
||||
const frameworks = Object.values(FRAMEWORKS)
|
||||
const selectedId = ref('vue3')
|
||||
|
||||
const selected = computed(() => FRAMEWORKS[selectedId.value] ?? FRAMEWORKS.vue3)
|
||||
|
||||
function selectFramework(id) {
|
||||
selectedId.value = id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.spectrum-wrapper {
|
||||
position: relative;
|
||||
margin: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.spectrum-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.spectrum-bar {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--vp-c-brand),
|
||||
var(--vp-c-green-1)
|
||||
);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.spectrum-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.spectrum-dot:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.spectrum-dot.selected {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 10px var(--vp-c-brand);
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.spectrum-dot-labels {
|
||||
position: relative;
|
||||
height: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dot-label {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-emoji {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.work-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.work-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.work-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.work-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.work-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.work-bar-fill.runtime {
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.work-bar-fill.compile {
|
||||
background: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.work-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dot-label {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.spectrum-dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="sync-demo">
|
||||
<div class="comparison-container">
|
||||
<div class="side manual-side">
|
||||
<div class="side-header">
|
||||
<span class="badge manual">手动同步 / jQuery 风格</span>
|
||||
</div>
|
||||
|
||||
<div class="cart-control">
|
||||
<button class="action-btn" @click="addManual">添加商品</button>
|
||||
<button class="action-btn outline" @click="resetManual">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-areas">
|
||||
<div
|
||||
v-for="area in manualAreas"
|
||||
:key="area.id"
|
||||
class="sync-area"
|
||||
:class="{ synced: area.synced, unsynced: !area.synced }"
|
||||
>
|
||||
<div class="area-header">
|
||||
<span class="area-icon">{{ area.icon }}</span>
|
||||
<span class="area-name">{{ area.name }}</span>
|
||||
<span class="sync-badge" :class="{ synced: area.synced }">
|
||||
{{ area.synced ? '已同步' : '未同步' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="area-value">{{ area.synced ? area.actual : area.stale }}</div>
|
||||
<button
|
||||
v-if="!area.synced"
|
||||
class="sync-btn"
|
||||
@click="syncArea(area)"
|
||||
>
|
||||
手动同步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="miss-counter">
|
||||
<span class="miss-label">遗漏次数:</span>
|
||||
<span class="miss-value" :class="{ danger: missCount > 0 }">{{ missCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vs-divider">
|
||||
<div class="vs-badge">VS</div>
|
||||
</div>
|
||||
|
||||
<div class="side auto-side">
|
||||
<div class="side-header">
|
||||
<span class="badge auto">自动同步 / 框架风格</span>
|
||||
</div>
|
||||
|
||||
<div class="cart-control">
|
||||
<button class="action-btn" @click="addAuto">添加商品</button>
|
||||
<button class="action-btn outline" @click="resetAuto">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-areas">
|
||||
<div
|
||||
v-for="area in autoAreas"
|
||||
:key="area.id"
|
||||
class="sync-area synced"
|
||||
>
|
||||
<div class="area-header">
|
||||
<span class="area-icon">{{ area.icon }}</span>
|
||||
<span class="area-name">{{ area.name }}</span>
|
||||
<span class="sync-badge synced">已同步</span>
|
||||
</div>
|
||||
<div class="area-value">{{ area.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="miss-counter">
|
||||
<span class="miss-label">遗漏次数:</span>
|
||||
<span class="miss-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span>前端框架的本质价值在于"自动同步"——你只需修改数据,框架保证所有依赖该数据的 UI 自动更新,不会遗漏。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
const products = ['耳机 ¥99', '键盘 ¥199', '鼠标 ¥59', '显示器 ¥1299', '摄像头 ¥149', '音箱 ¥79']
|
||||
let productIndex = ref(0)
|
||||
|
||||
const manualCount = ref(0)
|
||||
const manualItems = ref([])
|
||||
const missCount = ref(0)
|
||||
let pendingManualCount = 0
|
||||
|
||||
const manualAreas = reactive([
|
||||
{
|
||||
id: 'count',
|
||||
icon: '🔴',
|
||||
name: '购物车数量',
|
||||
synced: true,
|
||||
stale: '0 件',
|
||||
actual: '0 件'
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
icon: '📋',
|
||||
name: '商品列表',
|
||||
synced: true,
|
||||
stale: '(空)',
|
||||
actual: '(空)'
|
||||
},
|
||||
{
|
||||
id: 'total',
|
||||
icon: '💰',
|
||||
name: '总价',
|
||||
synced: true,
|
||||
stale: '¥0',
|
||||
actual: '¥0'
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
icon: '⚠️',
|
||||
name: '状态提示',
|
||||
synced: true,
|
||||
stale: '正常',
|
||||
actual: '正常'
|
||||
}
|
||||
])
|
||||
|
||||
function addManual() {
|
||||
const name = products[productIndex.value % products.length]
|
||||
productIndex.value++
|
||||
manualCount.value++
|
||||
manualItems.value.push(name)
|
||||
pendingManualCount = manualCount.value
|
||||
|
||||
const price = parseInt(name.match(/¥(\d+)/)[1])
|
||||
const totalPrice = manualItems.value.reduce((sum, item) => {
|
||||
return sum + parseInt(item.match(/¥(\d+)/)[1])
|
||||
}, 0)
|
||||
|
||||
manualAreas[0].actual = `${manualCount.value} 件`
|
||||
manualAreas[0].synced = false
|
||||
|
||||
manualAreas[1].actual = manualItems.value.join('、')
|
||||
manualAreas[1].synced = false
|
||||
|
||||
manualAreas[2].actual = `¥${totalPrice}`
|
||||
manualAreas[2].synced = false
|
||||
|
||||
manualAreas[3].actual = manualCount.value > 5 ? '⚠️ 商品过多!' : '正常'
|
||||
manualAreas[3].synced = false
|
||||
|
||||
const unsyncedBefore = manualAreas.filter(a => !a.synced).length
|
||||
if (unsyncedBefore > 0 && manualCount.value > 1) {
|
||||
missCount.value++
|
||||
}
|
||||
}
|
||||
|
||||
function syncArea(area) {
|
||||
area.synced = true
|
||||
area.stale = area.actual
|
||||
}
|
||||
|
||||
function resetManual() {
|
||||
manualCount.value = 0
|
||||
manualItems.value = []
|
||||
missCount.value = 0
|
||||
pendingManualCount = 0
|
||||
manualAreas.forEach(a => {
|
||||
a.synced = true
|
||||
a.stale = a.id === 'count' ? '0 件' : a.id === 'list' ? '(空)' : a.id === 'total' ? '¥0' : '正常'
|
||||
a.actual = a.stale
|
||||
})
|
||||
}
|
||||
|
||||
const autoCount = ref(0)
|
||||
const autoItems = ref([])
|
||||
|
||||
const autoAreas = computed(() => {
|
||||
const totalPrice = autoItems.value.reduce((sum, item) => {
|
||||
return sum + parseInt(item.match(/¥(\d+)/)[1])
|
||||
}, 0)
|
||||
return [
|
||||
{ id: 'count', icon: '🔴', name: '购物车数量', value: `${autoCount.value} 件` },
|
||||
{ id: 'list', icon: '📋', name: '商品列表', value: autoItems.value.length ? autoItems.value.join('、') : '(空)' },
|
||||
{ id: 'total', icon: '💰', name: '总价', value: `¥${totalPrice}` },
|
||||
{ id: 'status', icon: '⚠️', name: '状态提示', value: autoCount.value > 5 ? '⚠️ 商品过多!' : '正常' }
|
||||
]
|
||||
})
|
||||
|
||||
function addAuto() {
|
||||
const name = products[productIndex.value % products.length]
|
||||
productIndex.value++
|
||||
autoCount.value++
|
||||
autoItems.value.push(name)
|
||||
}
|
||||
|
||||
function resetAuto() {
|
||||
autoCount.value = 0
|
||||
autoItems.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sync-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.side-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.manual {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.badge.auto {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.cart-control {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.action-btn.outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.action-btn.outline:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.sync-areas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sync-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sync-area.synced {
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.sync-area.unsynced {
|
||||
border-color: var(--vp-c-danger-1);
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.area-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.area-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.area-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sync-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
background: var(--vp-c-danger-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sync-badge.synced {
|
||||
background: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.area-value {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
margin-top: 0.35rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
border: 1px solid var(--vp-c-danger-1);
|
||||
color: var(--vp-c-danger-1);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sync-btn:hover {
|
||||
background: var(--vp-c-danger-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.miss-counter {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.miss-label {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.miss-value {
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.miss-value.danger {
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comparison-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div
|
||||
class="reactivity-mechanism-demo"
|
||||
:style="{ '--tab-accent': currentTab?.color ?? 'var(--vp-c-brand)' }"
|
||||
>
|
||||
<div class="toggle-bar">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['toggle-btn', { active: activeTab === tab.id }]"
|
||||
:style="activeTab === tab.id ? { borderColor: tab.color, background: tab.color } : {}"
|
||||
@click="switchTab(tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="counter-row">
|
||||
<span class="counter-label">count:</span>
|
||||
<span class="counter-value">{{ count }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="modify-btn"
|
||||
:disabled="isAnimating"
|
||||
@click="modifyData"
|
||||
>
|
||||
修改数据
|
||||
</button>
|
||||
|
||||
<div class="steps-title">引擎盖下</div>
|
||||
<div class="steps-list">
|
||||
<div
|
||||
v-for="(step, idx) in currentSteps"
|
||||
:key="idx"
|
||||
:class="['step-item', stepState(idx)]"
|
||||
:style="stepStyle(idx)"
|
||||
>
|
||||
<span class="step-badge">{{ idx + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
<span v-if="stepStatus(idx) === 'done'" class="step-check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
{{ infoMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const TABS = {
|
||||
vue: {
|
||||
id: 'vue',
|
||||
label: 'Vue (Proxy)',
|
||||
color: 'var(--vp-c-green-1)',
|
||||
steps: [
|
||||
'count = 1 → Proxy 的 set 陷阱被触发',
|
||||
'通知依赖收集器:"count 变了"',
|
||||
'找到所有依赖 count 的组件',
|
||||
'自动更新 DOM'
|
||||
],
|
||||
info: 'Vue 通过 Proxy 自动拦截数据读写,开发者无需额外操作——写法最自然。'
|
||||
},
|
||||
react: {
|
||||
id: 'react',
|
||||
label: 'React (setState)',
|
||||
color: 'var(--vp-c-brand)',
|
||||
steps: [
|
||||
'调用 setCount(count + 1)',
|
||||
'React 将更新加入队列',
|
||||
'批量处理队列,触发 re-render',
|
||||
'虚拟 DOM Diff → 更新真实 DOM'
|
||||
],
|
||||
info: 'React 要求显式调用 setState,虽然多一步,但数据流更可预测。'
|
||||
},
|
||||
svelte: {
|
||||
id: 'svelte',
|
||||
label: 'Svelte (编译器)',
|
||||
color: 'var(--vp-c-warning-1)',
|
||||
steps: [
|
||||
'count += 1 被编译器识别为赋值',
|
||||
'编译时已生成 $$invalidate(count)',
|
||||
'直接更新对应的 DOM 节点(无 Diff)',
|
||||
'零运行时开销'
|
||||
],
|
||||
info: 'Svelte 在编译时完成分析,运行时零开销——但依赖编译器魔法。'
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = ref('vue')
|
||||
const count = ref(0)
|
||||
const currentStepIndex = ref(-1)
|
||||
const isAnimating = ref(false)
|
||||
|
||||
const tabs = computed(() => Object.values(TABS))
|
||||
|
||||
const currentTab = computed(() => TABS[activeTab.value])
|
||||
|
||||
const currentSteps = computed(() => currentTab.value?.steps ?? [])
|
||||
|
||||
const infoMessage = computed(() => currentTab.value?.info ?? '')
|
||||
|
||||
function stepState(idx) {
|
||||
if (currentStepIndex.value < idx) return 'pending'
|
||||
if (currentStepIndex.value === idx) return 'active'
|
||||
return 'done'
|
||||
}
|
||||
|
||||
function stepStatus(idx) {
|
||||
if (currentStepIndex.value < idx) return 'pending'
|
||||
if (currentStepIndex.value === idx) return 'active'
|
||||
return 'done'
|
||||
}
|
||||
|
||||
function stepStyle(idx) {
|
||||
if (currentStepIndex.value !== idx) return {}
|
||||
const color = currentTab.value?.color ?? 'var(--vp-c-brand)'
|
||||
return {
|
||||
borderColor: color,
|
||||
boxShadow: `0 0 8px color-mix(in srgb, ${color} 40%, transparent)`
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(id) {
|
||||
if (isAnimating.value) return
|
||||
activeTab.value = id
|
||||
currentStepIndex.value = -1
|
||||
}
|
||||
|
||||
async function modifyData() {
|
||||
if (isAnimating.value) return
|
||||
isAnimating.value = true
|
||||
count.value += 1
|
||||
currentStepIndex.value = -1
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
currentStepIndex.value = i
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
currentStepIndex.value = 4
|
||||
isAnimating.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reactivity-mechanism-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.toggle-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.counter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.counter-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.counter-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.modify-btn {
|
||||
display: block;
|
||||
margin: 0 auto 1rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modify-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.modify-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.steps-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.step-item.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-item.active .step-badge {
|
||||
background: var(--tab-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.done .step-badge {
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-check {
|
||||
color: var(--vp-c-green-1);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-item.done {
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.toggle-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">虚拟 DOM Diff 过程</span>
|
||||
<span class="subtitle">最小化 DOM 更新的核心机制</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="isModified"
|
||||
@click="modifyData"
|
||||
>
|
||||
修改数据
|
||||
</button>
|
||||
<button
|
||||
class="outline-btn"
|
||||
:disabled="!isModified"
|
||||
@click="reset"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="columns-row">
|
||||
<div class="column">
|
||||
<div class="column-title">Old VTree</div>
|
||||
<div class="tree-container">
|
||||
<div class="tree-node tree-root">div.app</div>
|
||||
<div class="tree-children">
|
||||
<div class="tree-node">h1: 待办清单</div>
|
||||
<div class="tree-node">ul.list</div>
|
||||
<div class="tree-children">
|
||||
<div class="tree-node">li: 学习 Vue</div>
|
||||
<div class="tree-node">li: 写作业</div>
|
||||
<div class="tree-node">li: 打游戏</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="column-title">Diff Result</div>
|
||||
<div v-if="isModified" class="diff-badges">
|
||||
<span class="badge badge-modified">修改: 1 个节点</span>
|
||||
<span class="badge badge-new">新增: 1 个节点</span>
|
||||
</div>
|
||||
<div class="tree-container">
|
||||
<div class="tree-node tree-root">div.app</div>
|
||||
<div class="tree-children">
|
||||
<div class="tree-node node-unchanged">h1: 待办清单</div>
|
||||
<div class="tree-node node-unchanged">ul.list</div>
|
||||
<div class="tree-children">
|
||||
<div
|
||||
:class="[
|
||||
'tree-node',
|
||||
isModified && 'node-unchanged'
|
||||
]"
|
||||
>
|
||||
li: 学习 Vue
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'tree-node',
|
||||
isModified && 'node-modified'
|
||||
]"
|
||||
>
|
||||
li: {{ isModified ? '写代码' : '写作业' }}
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'tree-node',
|
||||
isModified && 'node-unchanged'
|
||||
]"
|
||||
>
|
||||
li: 打游戏
|
||||
</div>
|
||||
<div
|
||||
v-if="isModified"
|
||||
class="tree-node node-new"
|
||||
>
|
||||
li: 看电影
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="column-title">Real DOM</div>
|
||||
<div class="real-dom-preview">
|
||||
<div class="dom-root">
|
||||
<div class="dom-node">div.app</div>
|
||||
<div class="dom-children">
|
||||
<div class="dom-node">h1: 待办清单</div>
|
||||
<ul class="dom-list">
|
||||
<li>学习 Vue</li>
|
||||
<li :class="{ 'dom-changed': isModified }">
|
||||
{{ isModified ? '写代码' : '写作业' }}
|
||||
</li>
|
||||
<li>打游戏</li>
|
||||
<li
|
||||
v-if="isModified"
|
||||
class="dom-new"
|
||||
>
|
||||
看电影
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">7</div>
|
||||
<div class="metric-label">虚拟 DOM 节点总数</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">{{ isModified ? '2' : '0' }}</div>
|
||||
<div class="metric-label">需要更新的真实 DOM</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value">{{ isModified ? '71%' : '—' }}</div>
|
||||
<div class="metric-label">节省的 DOM 操作</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
虚拟 DOM 先在内存中对比新旧两棵树,找出最小差异,然后只更新必要的真实 DOM 节点——避免了大量无效操作。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isModified = ref(false)
|
||||
|
||||
function modifyData() {
|
||||
if (isModified.value) return
|
||||
isModified.value = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
isModified.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.action-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.outline-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-1);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.outline-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.outline-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.columns-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.diff-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.badge-modified {
|
||||
background: rgba(255, 206, 86, 0.2);
|
||||
border: 1px solid var(--vp-c-warning-1);
|
||||
color: var(--vp-c-warning-1);
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
border: 1px solid var(--vp-c-green-1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.real-dom-preview {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dom-root {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dom-node {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.dom-children {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.dom-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.dom-list li {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.2rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dom-changed {
|
||||
border-color: var(--vp-c-warning-1);
|
||||
background: rgba(255, 206, 86, 0.1);
|
||||
animation: flash 0.5s ease;
|
||||
}
|
||||
|
||||
.dom-new {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0%,
|
||||
100% {
|
||||
background: rgba(255, 206, 86, 0.1);
|
||||
}
|
||||
50% {
|
||||
background: rgba(255, 206, 86, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tree-container .tree-node {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.tree-container .tree-node.node-modified {
|
||||
border-color: var(--vp-c-warning-1);
|
||||
background: rgba(255, 206, 86, 0.1);
|
||||
}
|
||||
|
||||
.tree-container .tree-node.node-new {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.tree-container .tree-node.node-unchanged {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.columns-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="what-is-dom-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">HTML → DOM 树</span>
|
||||
<span class="subtitle">浏览器如何理解你写的 HTML</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-body">
|
||||
<div class="html-panel">
|
||||
<div class="panel-title">你写的 HTML 代码</div>
|
||||
<div class="code-display">
|
||||
<div
|
||||
v-for="(line, i) in htmlLines"
|
||||
:key="i"
|
||||
:class="['code-line', { highlighted: highlightedTag === line.tag }]"
|
||||
@mouseenter="highlightedTag = line.tag"
|
||||
@mouseleave="highlightedTag = ''"
|
||||
>
|
||||
<span class="line-num">{{ i + 1 }}</span>
|
||||
<span class="line-code" :style="{ paddingLeft: line.indent * 12 + 'px' }">{{ line.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-col">
|
||||
<div class="arrow-label">浏览器解析</div>
|
||||
<div class="arrow-icon">→</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-panel">
|
||||
<div class="panel-title">浏览器生成的 DOM 树</div>
|
||||
<div class="tree-display">
|
||||
<div
|
||||
v-for="node in treeNodes"
|
||||
:key="node.id"
|
||||
:class="['tree-node', { highlighted: highlightedTag === node.tag }]"
|
||||
:style="{ marginLeft: node.depth * 20 + 'px' }"
|
||||
@mouseenter="highlightedTag = node.tag"
|
||||
@mouseleave="highlightedTag = ''"
|
||||
>
|
||||
<span class="connector" v-if="node.depth > 0">└─</span>
|
||||
<span class="node-tag">{{ node.label }}</span>
|
||||
<span v-if="node.text" class="node-text">"{{ node.text }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dom-explain">
|
||||
<div class="explain-item">
|
||||
<span class="explain-icon">📄</span>
|
||||
<div class="explain-content">
|
||||
<strong>节点(Node)</strong>
|
||||
<span>DOM 树上的每一个方块就是一个节点。每个 HTML 标签(如 <code><h1></code>、<code><p></code>)都对应一个节点。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explain-item">
|
||||
<span class="explain-icon">🌳</span>
|
||||
<div class="explain-content">
|
||||
<strong>父子关系</strong>
|
||||
<span>标签嵌套在另一个标签里面,在 DOM 树上就是父节点和子节点的关系。<code><body></code> 里包含 <code><h1></code>,所以 body 是 h1 的父节点。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explain-item">
|
||||
<span class="explain-icon">✏️</span>
|
||||
<div class="explain-content">
|
||||
<strong>DOM 操作</strong>
|
||||
<span>JavaScript 可以增加、删除、修改 DOM 树上的节点。修改节点后,浏览器会重新计算布局并重新绘制页面,这就是"DOM 操作"。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>关键概念:</strong>
|
||||
<span>DOM 是浏览器在内存中维护的一棵树,它和你写的 HTML 一一对应。JavaScript 无法直接修改 HTML 文件,它修改的是这棵 DOM 树——浏览器再根据 DOM 树的变化更新屏幕上的显示。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const highlightedTag = ref('')
|
||||
|
||||
const htmlLines = [
|
||||
{ text: '<html>', indent: 0, tag: 'html' },
|
||||
{ text: '<body>', indent: 1, tag: 'body' },
|
||||
{ text: '<h1>我的购物车</h1>', indent: 2, tag: 'h1' },
|
||||
{ text: '<p>共 3 件商品</p>', indent: 2, tag: 'p' },
|
||||
{ text: '<ul>', indent: 2, tag: 'ul' },
|
||||
{ text: '<li>耳机</li>', indent: 3, tag: 'li1' },
|
||||
{ text: '<li>键盘</li>', indent: 3, tag: 'li2' },
|
||||
{ text: '<li>鼠标</li>', indent: 3, tag: 'li3' },
|
||||
{ text: '</ul>', indent: 2, tag: 'ul' },
|
||||
{ text: '<button>结算</button>', indent: 2, tag: 'btn' },
|
||||
{ text: '</body>', indent: 1, tag: 'body' },
|
||||
{ text: '</html>', indent: 0, tag: 'html' }
|
||||
]
|
||||
|
||||
const treeNodes = [
|
||||
{ id: 1, label: 'html', depth: 0, tag: 'html' },
|
||||
{ id: 2, label: 'body', depth: 1, tag: 'body' },
|
||||
{ id: 3, label: 'h1', depth: 2, tag: 'h1', text: '我的购物车' },
|
||||
{ id: 4, label: 'p', depth: 2, tag: 'p', text: '共 3 件商品' },
|
||||
{ id: 5, label: 'ul', depth: 2, tag: 'ul' },
|
||||
{ id: 6, label: 'li', depth: 3, tag: 'li1', text: '耳机' },
|
||||
{ id: 7, label: 'li', depth: 3, tag: 'li2', text: '键盘' },
|
||||
{ id: 8, label: 'li', depth: 3, tag: 'li3', text: '鼠标' },
|
||||
{ id: 9, label: 'button', depth: 2, tag: 'btn', text: '结算' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.what-is-dom-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.demo-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.html-panel,
|
||||
.tree-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-display {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.code-line.highlighted {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.line-num {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.65rem;
|
||||
min-width: 1rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.arrow-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.arrow-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--vp-c-text-2);
|
||||
writing-mode: vertical-rl;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tree-display {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tree-node.highlighted {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.connector {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-tag {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 3px;
|
||||
padding: 0 0.3rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.tree-node.highlighted .node-tag {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.node-text {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.dom-explain {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.explain-item {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.explain-icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explain-content {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.explain-content strong {
|
||||
display: block;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.explain-content code {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.demo-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.arrow-col {
|
||||
flex-direction: row;
|
||||
padding-top: 0;
|
||||
}
|
||||
.arrow-label {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
.dom-explain {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="why-no-auto-sync-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">变量修改时发生了什么?</span>
|
||||
<span class="subtitle">原生 JavaScript vs 框架</span>
|
||||
</div>
|
||||
|
||||
<div class="toggle-bar">
|
||||
<button
|
||||
:class="['toggle-btn', { active: mode === 'native' }]"
|
||||
@click="switchMode('native')"
|
||||
>
|
||||
原生 JavaScript
|
||||
</button>
|
||||
<button
|
||||
:class="['toggle-btn', { active: mode === 'framework' }]"
|
||||
@click="switchMode('framework')"
|
||||
>
|
||||
使用框架(Vue)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="code-col">
|
||||
<div class="col-title">你写的代码</div>
|
||||
<div class="code-block">
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 点击按钮时执行</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-highlight', { executing: step >= 1 }]">
|
||||
<span class="code-text">count = count + 1</span>
|
||||
<span v-if="step >= 1" class="step-badge">{{ step >= 1 ? '✓ 执行' : '' }}</span>
|
||||
</div>
|
||||
<template v-if="mode === 'native'">
|
||||
<div class="code-line code-gap" />
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 你还要手动写下面这些:</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 2, missing: step === 1 }]">
|
||||
<span class="code-text">document.getElementById('count')</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 2, missing: step === 1 }]">
|
||||
<span class="code-text"> .textContent = count</span>
|
||||
<span v-if="step >= 2" class="step-badge">✓ 手动</span>
|
||||
<span v-else-if="step === 1" class="step-badge miss">需要你写</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 3, missing: step < 3 && step >= 1 }]">
|
||||
<span class="code-text">document.getElementById('total')</span>
|
||||
</div>
|
||||
<div :class="['code-line', 'code-manual', { executing: step >= 3, missing: step < 3 && step >= 1 }]">
|
||||
<span class="code-text"> .textContent = count * 99</span>
|
||||
<span v-if="step >= 3" class="step-badge">✓ 手动</span>
|
||||
<span v-else-if="step >= 1" class="step-badge miss">需要你写</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="code-line code-gap" />
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 不需要写别的了</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="code-comment">// 框架会自动完成后续步骤</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-col">
|
||||
<div class="col-title">执行流程</div>
|
||||
<div class="flow-steps">
|
||||
<div :class="['flow-step', { active: step >= 1, done: step > 1 }]">
|
||||
<span class="flow-num">1</span>
|
||||
<div class="flow-content">
|
||||
<div class="flow-title">JavaScript 修改变量</div>
|
||||
<div class="flow-desc">count 从 {{ count - 1 }} 变成 {{ count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow" :class="{ active: step >= 1 }">
|
||||
<span v-if="mode === 'native'">{{ step === 1 ? '❌ 到这里就停了' : '↓' }}</span>
|
||||
<span v-else>{{ step >= 1 ? '↓ 框架自动接管' : '↓' }}</span>
|
||||
</div>
|
||||
|
||||
<div :class="['flow-step', { active: step >= 2, done: step > 2, auto: mode === 'framework' }]">
|
||||
<span class="flow-num">2</span>
|
||||
<div class="flow-content">
|
||||
<div class="flow-title">
|
||||
{{ mode === 'native' ? '找到 DOM 节点' : '框架检测到变化' }}
|
||||
</div>
|
||||
<div class="flow-desc">
|
||||
{{ mode === 'native'
|
||||
? '手动调用 document.getElementById()'
|
||||
: 'Proxy 拦截了赋值操作,通知更新系统' }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="mode === 'framework' && step >= 2" class="auto-badge">自动</span>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow" :class="{ active: step >= 2 }">↓</div>
|
||||
|
||||
<div :class="['flow-step', { active: step >= 3, done: step > 3, auto: mode === 'framework' }]">
|
||||
<span class="flow-num">3</span>
|
||||
<div class="flow-content">
|
||||
<div class="flow-title">
|
||||
{{ mode === 'native' ? '修改 DOM 内容' : '框架更新所有相关 DOM' }}
|
||||
</div>
|
||||
<div class="flow-desc">
|
||||
{{ mode === 'native'
|
||||
? '手动调用 .textContent = 新值'
|
||||
: '自动找到所有使用了 count 的位置并更新' }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="mode === 'framework' && step >= 3" class="auto-badge">自动</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-col">
|
||||
<div class="col-title">界面结果</div>
|
||||
<div class="result-card">
|
||||
<div :class="['result-item', { updated: step >= (mode === 'native' ? 2 : 2) }]">
|
||||
<span class="result-label">购物车</span>
|
||||
<span class="result-value">{{ step >= (mode === 'native' ? 2 : 2) ? count : count - 1 }} 件</span>
|
||||
</div>
|
||||
<div :class="['result-item', { updated: step >= (mode === 'native' ? 3 : 2), stale: mode === 'native' && step >= 1 && step < 3 }]">
|
||||
<span class="result-label">总价</span>
|
||||
<span class="result-value">¥{{ step >= (mode === 'native' ? 3 : 2) ? count * 99 : (count - 1) * 99 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mode === 'native' && step === 1" class="stale-warning">
|
||||
变量已经改了,但界面没有任何变化
|
||||
</div>
|
||||
<div v-if="mode === 'native' && step === 2" class="stale-warning partial">
|
||||
购物车更新了,但总价还是旧的
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="action-btn" :disabled="isAnimating" @click="runStep">
|
||||
{{ step === 0 ? '执行 count = count + 1' : mode === 'native' && step < 3 ? '继续手动同步下一个' : '再执行一次' }}
|
||||
</button>
|
||||
<button class="action-btn outline" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box" v-if="mode === 'native'">
|
||||
<strong>为什么不自动?</strong>
|
||||
<span>JavaScript 的变量是"无感知"的。你执行 <code>count = 4</code> 时,JavaScript 引擎只是把内存中 count 的值从 3 改成 4,仅此而已。它不会通知任何人,不会触发任何回调,不会去检查页面上哪里显示了 count。所以界面不会有任何变化——除非你自己写代码去更新 DOM。</span>
|
||||
</div>
|
||||
<div class="info-box" v-else>
|
||||
<strong>框架怎么做到的?</strong>
|
||||
<span>框架把你的数据用特殊机制包裹起来。以 Vue 为例,它用 JavaScript 的 Proxy(代理)功能拦截你对变量的赋值操作。当你写 <code>count = 4</code> 时,Proxy 会在赋值的同时自动执行一段"通知"代码,告诉框架"count 变了",框架再去找到所有用到 count 的 DOM 节点并更新它们。整个过程你不需要写任何额外代码。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const mode = ref('native')
|
||||
const step = ref(0)
|
||||
const count = ref(1)
|
||||
const isAnimating = ref(false)
|
||||
|
||||
function switchMode(m) {
|
||||
if (isAnimating.value) return
|
||||
mode.value = m
|
||||
reset()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step.value = 0
|
||||
count.value = 1
|
||||
isAnimating.value = false
|
||||
}
|
||||
|
||||
async function runStep() {
|
||||
if (isAnimating.value) return
|
||||
|
||||
if (mode.value === 'native') {
|
||||
if (step.value === 0) {
|
||||
isAnimating.value = true
|
||||
count.value++
|
||||
step.value = 1
|
||||
isAnimating.value = false
|
||||
} else if (step.value === 1) {
|
||||
step.value = 2
|
||||
} else if (step.value === 2) {
|
||||
step.value = 3
|
||||
} else {
|
||||
reset()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
runStep()
|
||||
}
|
||||
} else {
|
||||
if (step.value === 0 || step.value >= 3) {
|
||||
if (step.value >= 3) {
|
||||
reset()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
isAnimating.value = true
|
||||
count.value++
|
||||
step.value = 1
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
step.value = 2
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
step.value = 3
|
||||
isAnimating.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.why-no-auto-sync-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: 600; }
|
||||
.demo-header .subtitle { font-size: 0.85rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.toggle-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover { border-color: var(--vp-c-brand); }
|
||||
.toggle-btn.active { background: var(--vp-c-brand); color: white; border-color: var(--vp-c-brand); }
|
||||
|
||||
.visualization-area {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 0.8fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-col, .flow-col, .result-col {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.code-gap { height: 0.3rem; }
|
||||
|
||||
.code-comment { color: var(--vp-c-text-3); }
|
||||
.code-text { color: var(--vp-c-text-1); }
|
||||
|
||||
.code-highlight.executing {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-left: 2px solid var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.code-manual {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.code-manual.executing {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.code-manual.missing {
|
||||
opacity: 0.5;
|
||||
border-left: 2px dashed var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
font-size: 0.62rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.step-badge.miss {
|
||||
background: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
transition: all 0.3s;
|
||||
opacity: 0.4;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-step.active { opacity: 1; border-color: var(--vp-c-brand); }
|
||||
.flow-step.done { opacity: 1; border-color: var(--vp-c-green-1); }
|
||||
.flow-step.auto.active { border-color: var(--vp-c-green-1); background: rgba(16, 185, 129, 0.05); }
|
||||
|
||||
.flow-num {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-step.active .flow-num { background: var(--vp-c-brand); color: white; border-color: var(--vp-c-brand); }
|
||||
.flow-step.done .flow-num { background: var(--vp-c-green-1); color: white; border-color: var(--vp-c-green-1); }
|
||||
|
||||
.flow-content { flex: 1; min-width: 0; }
|
||||
.flow-title { font-size: 0.78rem; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.flow-desc { font-size: 0.7rem; color: var(--vp-c-text-2); margin-top: 0.1rem; }
|
||||
|
||||
.auto-badge {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.35rem;
|
||||
font-size: 0.58rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
padding: 0.1rem 0;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.flow-arrow.active { color: var(--vp-c-brand); font-weight: 600; }
|
||||
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.result-item.updated { border-color: var(--vp-c-green-1); background: rgba(16, 185, 129, 0.06); }
|
||||
.result-item.stale { border-color: var(--vp-c-danger-1); background: rgba(239, 68, 68, 0.06); }
|
||||
|
||||
.result-label { color: var(--vp-c-text-2); }
|
||||
.result-value { font-weight: 700; }
|
||||
|
||||
.stale-warning {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
font-weight: 600;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stale-warning.partial { color: var(--vp-c-warning-1); background: rgba(255, 206, 86, 0.08); }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.35rem 0.8rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.action-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.action-btn.outline { background: transparent; border: 1px solid var(--vp-c-divider); color: var(--vp-c-text-1); }
|
||||
.action-btn.outline:hover { border-color: var(--vp-c-brand); color: var(--vp-c-brand); }
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; flex-shrink: 0; color: var(--vp-c-text-1); }
|
||||
|
||||
.info-box code {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.visualization-area { grid-template-columns: 1fr; }
|
||||
.toggle-bar { flex-direction: column; }
|
||||
.toggle-btn { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user