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:
sanbuphy
2026-02-21 10:04:47 +08:00
parent 399913d3ff
commit 6098908eee
52 changed files with 17782 additions and 2725 deletions
@@ -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>
@@ -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>
@@ -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>
@@ -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>&lt;h1&gt;</code><code>&lt;p&gt;</code>都对应一个节点</span>
</div>
</div>
<div class="explain-item">
<span class="explain-icon">🌳</span>
<div class="explain-content">
<strong>父子关系</strong>
<span>标签嵌套在另一个标签里面 DOM 树上就是父节点和子节点的关系<code>&lt;body&gt;</code> 里包含 <code>&lt;h1&gt;</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>