feat(docs): enhance interactive demos and improve documentation

- Add new interactive components for frontend routing, browser rendering pipeline, and database transactions
- Improve existing demos with better visuals, explanations, and examples
- Update documentation structure and content for better clarity
- Add new utility scripts and update package.json with new commands
- Fix formatting and alignment in documentation tables
This commit is contained in:
sanbuphy
2026-02-13 22:10:03 +08:00
parent 599052b2e0
commit d174ceea32
88 changed files with 26273 additions and 15539 deletions
@@ -1,93 +1,126 @@
<template>
<div class="component-hierarchy-demo">
<div class="demo-header">
<h4>组件层级可视化</h4>
<p class="hint">点击组件查看详情观察组件树如何组织</p>
<span class="icon">🌳</span>
<span class="title">组件层级结构</span>
<span class="subtitle">像家谱树一样的组件关系</span>
</div>
<div class="tree-container">
<div class="tree-node root-node" :class="{ active: selectedNode === 'app' }" @click="selectNode('app')">
<div class="node-icon">🌳</div>
<div class="node-label">App (根组件)</div>
<div class="node-desc">管理全局状态</div>
</div>
<div class="intro-text">
想象你在<span class="highlight">公司组织架构</span>工作CEO根组件在顶层下面是各个部门父组件每个部门里还有员工子组件这就是组件树
</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'header' }" @click="selectNode('header')">
<div class="node-icon">📌</div>
<div class="node-label">Header</div>
<div class="node-desc">导航 + 用户信息</div>
<div class="demo-content">
<div class="tree-container">
<div class="tree-node root-node" :class="{ active: selectedNode === 'app' }" @click="selectNode('app')">
<div class="node-icon">👑</div>
<div class="node-info">
<div class="node-label">App (根组件)</div>
<div class="node-desc">CEO - 管理全局</div>
</div>
</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'main' }" @click="selectNode('main')">
<div class="node-icon">📄</div>
<div class="node-label">Main Content</div>
<div class="node-desc">页面主要内容</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'header' }" @click="selectNode('header')">
<div class="node-icon">📌</div>
<div class="node-info">
<div class="node-label">Header</div>
<div class="node-desc">导航栏部门</div>
</div>
</div>
</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'sidebar' }" @click="selectNode('sidebar')">
<div class="node-icon">📑</div>
<div class="node-label">Sidebar</div>
<div class="node-desc">侧边栏菜单</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'main' }" @click="selectNode('main')">
<div class="node-icon">📄</div>
<div class="node-info">
<div class="node-label">Main Content</div>
<div class="node-desc">主内容部门</div>
</div>
</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'productlist' }" @click="selectNode('productlist')">
<div class="node-icon">🛍</div>
<div class="node-label">ProductList</div>
<div class="node-desc">商品列表展示</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'sidebar' }" @click="selectNode('sidebar')">
<div class="node-icon">📑</div>
<div class="node-info">
<div class="node-label">Sidebar</div>
<div class="node-desc">侧边栏小组</div>
</div>
</div>
</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node leaf" :class="{ active: selectedNode === 'productcard' }" @click="selectNode('productcard')">
<div class="node-icon">🏷</div>
<div class="node-label">ProductCard</div>
<div class="node-desc">单个商品卡片</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'productlist' }" @click="selectNode('productlist')">
<div class="node-icon">🛍</div>
<div class="node-info">
<div class="node-label">ProductList</div>
<div class="node-desc">商品列表组</div>
</div>
</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node leaf" :class="{ active: selectedNode === 'productcard' }" @click="selectNode('productcard')">
<div class="node-icon">🏷</div>
<div class="node-info">
<div class="node-label">ProductCard</div>
<div class="node-desc">商品卡片员工</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'footer' }" @click="selectNode('footer')">
<div class="node-icon">🔻</div>
<div class="node-label">Footer</div>
<div class="node-desc">页脚信息</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'footer' }" @click="selectNode('footer')">
<div class="node-icon">🔻</div>
<div class="node-info">
<div class="node-label">Footer</div>
<div class="node-desc">页脚部门</div>
</div>
</div>
</div>
</div>
</div>
<Transition name="fade">
<div v-if="selectedNodeInfo" class="node-details">
<div class="detail-header">
<span class="detail-icon">{{ selectedNodeInfo.icon }}</span>
<span class="detail-title">{{ selectedNodeInfo.title }}</span>
</div>
<p class="detail-desc">{{ selectedNodeInfo.description }}</p>
<div v-if="selectedNodeInfo.props || selectedNodeInfo.events" class="detail-info">
<div v-if="selectedNodeInfo.props" class="info-section">
<strong>📥 接收:</strong>
<span class="prop-tags">{{ selectedNodeInfo.props.join(', ') }}</span>
</div>
<div v-if="selectedNodeInfo.events" class="info-section">
<strong>📤 触发:</strong>
<span class="prop-tags">{{ selectedNodeInfo.events.join(', ') }}</span>
</div>
</div>
</div>
</Transition>
<div v-if="!selectedNode" class="hint-text">
👆 点击上方任意节点查看职责说明
</div>
</div>
<div v-if="selectedNodeInfo" class="node-details">
<h5>{{ selectedNodeInfo.title }}</h5>
<p>{{ selectedNodeInfo.description }}</p>
<div class="props-list" v-if="selectedNodeInfo.props">
<strong>接收的 Props:</strong>
<ul>
<li v-for="prop in selectedNodeInfo.props" :key="prop">{{ prop }}</li>
</ul>
</div>
<div class="events-list" v-if="selectedNodeInfo.events">
<strong>触发的事件:</strong>
<ul>
<li v-for="event in selectedNodeInfo.events" :key="event">{{ event }}</li>
</ul>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>核心思想</strong>组件像组织架构父组件管理整体子组件负责具体功能数据从上往下传事件从下往上报
</div>
</div>
</template>
@@ -99,44 +132,51 @@ const selectedNode = ref(null)
const nodeInfoMap = {
app: {
title: 'App (根组件)',
description: '应用的入口组件,负责初始化全局状态、路由配置和全局样式。通常包含 RouterView 来渲染页面级组件',
icon: '👑',
title: 'App 根组件',
description: '就像公司的CEO,负责整个应用的初始化和全局管理。包含路由、全局状态、主题配置等大方向决策。',
props: [],
events: []
},
header: {
title: 'Header (导航栏)',
description: '顶部导航组件,显示 Logo、主导航菜单、用户信息、购物车入口等。通常是全局组件,在大多数页面都显示。',
icon: '📌',
title: 'Header 导航栏',
description: '公司的前台部门,负责展示Logo、导航菜单、用户信息和购物车等。大部分页面都会用到它。',
props: ['user', 'cartCount'],
events: ['logout', 'search']
},
main: {
title: 'Main Content (主内容区)',
description: '页面的主要内容区域,包含侧边栏和具体内容。使用 flex 或 grid 布局来组织内容',
icon: '📄',
title: 'Main Content 主内容',
description: '公司的核心业务部门,管理页面的主要内容区域。用flex或grid布局组织侧边栏和内容。',
props: [],
events: []
},
sidebar: {
title: 'Sidebar (侧边栏)',
description: '左侧导航菜单,通常用于后台管理系统或分类浏览页面。包含可折叠的菜单组。',
icon: '📑',
title: 'Sidebar 侧边栏',
description: '公司的导航小组,提供可折叠的菜单。常见于后台管理系统或分类浏览页面。',
props: ['menuItems', 'collapsed'],
events: ['select', 'toggle']
},
productlist: {
title: 'ProductList (商品列表)',
description: '展示商品列表的容器组件,负责数据获取、分页、排序和筛选逻辑。包含多个 ProductCard 组件。',
icon: '🛍️',
title: 'ProductList 商品列表',
description: '商品展示团队,负责数据获取、分页、排序和筛选。包含多个ProductCard成员。',
props: ['products', 'loading', 'total'],
events: ['loadMore', 'sort', 'filter']
},
productcard: {
title: 'ProductCard (商品卡片)',
description: '单个商品的展示卡片,显示商品图片、名称、价格、评分等信息。是最基础的 UI 组件之一。',
icon: '🏷️',
title: 'ProductCard 商品卡片',
description: '最基层的员工,负责展示单个商品的信息(图片、名称、价格、评分)。专注于UI展示。',
props: ['product', 'showAddToCart'],
events: ['addToCart', 'click']
},
footer: {
title: 'Footer (页脚)',
description: '页面底部的信息区域,包含版权信息、友情链接、联系方式、社交媒体链接等。',
icon: '🔻',
title: 'Footer 页脚',
description: '公司的后勤部门,展示版权信息、友情链接、联系方式、社交媒体链接等辅助信息。',
props: [],
events: []
}
@@ -147,7 +187,7 @@ const selectedNodeInfo = computed(() => {
})
const selectNode = (nodeId) => {
selectedNode.value = nodeId
selectedNode.value = selectedNode.value === nodeId ? null : nodeId
}
</script>
@@ -155,50 +195,81 @@ const selectNode = (nodeId) => {
.component-hierarchy-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
.demo-header .icon {
font-size: 1.25rem;
}
.hint {
margin: 0;
font-size: 14px;
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 6px;
}
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.demo-content {
background: var(--vp-c-bg);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.tree-container {
overflow-x: auto;
padding: 10px 0;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
margin-left: 28px;
gap: 0.75rem;
margin-top: 0.75rem;
margin-left: 1.5rem;
}
.tree-branch {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
gap: 0.75rem;
}
.connector {
width: 20px;
width: 16px;
height: 2px;
background: var(--vp-c-divider);
margin-top: 24px;
margin-top: 18px;
position: relative;
}
@@ -206,22 +277,22 @@ const selectNode = (nodeId) => {
content: '';
position: absolute;
left: 0;
top: -10px;
top: -8px;
width: 2px;
height: 12px;
height: 10px;
background: var(--vp-c-divider);
}
.tree-node {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--vp-c-bg);
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
transition: all 0.2s ease;
min-width: 180px;
}
@@ -233,7 +304,7 @@ const selectNode = (nodeId) => {
.tree-node.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
box-shadow: 0 0 0 3px var(--vp-c-brand-delta);
}
.root-node {
@@ -243,105 +314,129 @@ const selectNode = (nodeId) => {
.leaf .node-icon {
opacity: 0.8;
transform: scale(0.9);
}
.node-icon {
font-size: 20px;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--vp-c-bg-soft);
background: var(--vp-c-bg);
border-radius: 6px;
}
.node-info {
display: flex;
flex-direction: column;
}
.node-label {
font-weight: 600;
font-size: 14px;
font-size: 0.875rem;
color: var(--vp-c-text-1);
}
.node-desc {
font-size: 12px;
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-left: auto;
padding-left: 12px;
border-left: 1px solid var(--vp-c-divider);
margin-top: 0.15rem;
}
.node-details {
margin-top: 20px;
padding: 16px;
background: var(--vp-c-bg);
margin-top: 1rem;
padding: 1rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
.detail-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.node-details h5 {
margin: 0 0 8px 0;
color: var(--vp-c-brand);
font-size: 16px;
.detail-icon {
font-size: 1.25rem;
}
.node-details p {
margin: 0 0 12px 0;
color: var(--vp-c-text-2);
font-size: 14px;
line-height: 1.6;
}
.props-list, .events-list {
margin-top: 12px;
}
.props-list strong, .events-list strong {
display: block;
margin-bottom: 4px;
.detail-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--vp-c-text-1);
font-size: 13px;
}
.props-list ul, .events-list ul {
margin: 0;
padding-left: 20px;
}
.props-list li, .events-list li {
.detail-desc {
font-size: 0.875rem;
color: var(--vp-c-text-2);
font-size: 13px;
line-height: 1.5;
margin-bottom: 0.75rem;
}
.detail-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-section {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
}
.info-section strong {
color: var(--vp-c-text-1);
flex-shrink: 0;
}
.prop-tags {
color: var(--vp-c-brand);
font-family: monospace;
margin: 2px 0;
font-size: 0.75rem;
}
.hint-text {
text-align: center;
font-size: 0.85rem;
color: var(--vp-c-text-3);
margin-top: 0.75rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.info-box {
background: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.info-box .icon {
margin-right: 0.25rem;
}
@media (max-width: 768px) {
.tree-node {
min-width: auto;
flex-wrap: wrap;
}
.node-desc {
width: 100%;
margin-top: 4px;
padding-left: 42px;
border-left: none;
}
.tree-children {
margin-left: 16px;
margin-left: 1rem;
}
}
</style>
@@ -1,330 +1,191 @@
<template>
<div class="event-bus-demo">
<div class="demo-header">
<h4>事件总线通信演示</h4>
<p class="hint">观察多个独立组件如何通过事件总线进行通信注意内存管理的重要性</p>
<span class="icon">📡</span>
<span class="title">Event Bus 事件总线</span>
<span class="subtitle">像广播站一样的消息传递</span>
</div>
<div class="architecture-view">
<div class="central-hub" :class="{ active: isTransmitting }">
<div class="hub-core">
<div class="hub-icon">🔌</div>
<div class="hub-label">Event Bus</div>
<div class="hub-status">{{ isTransmitting ? '传输中...' : '待机' }}</div>
</div>
<div class="hub-rings">
<div class="ring ring-1"></div>
<div class="ring ring-2"></div>
<div class="ring ring-3"></div>
</div>
<div class="intro-text">
想象你在<span class="highlight">广播电台</span>工作任何部门组件都可以通过广播站Event Bus发布消息所有收音机监听器都能收到广播不需要知道对方是谁
</div>
<div class="demo-content">
<div class="bus-center">
<div class="bus-icon">📻</div>
<div class="bus-label">广播站 (Event Bus)</div>
</div>
<div class="connected-components">
<div class="components-grid">
<div
v-for="comp in components"
:key="comp.id"
class="component-node"
:class="{ active: comp.isActive, emitting: comp.isEmitting, listening: comp.isListening }"
:style="{ top: comp.y + '%', left: comp.x + '%' }"
:class="{ active: comp.isActive }"
@click="sendEvent(comp)"
>
<div class="node-header">
<span class="node-icon">{{ comp.icon }}</span>
<span class="node-name">{{ comp.name }}</span>
<div class="comp-icon">{{ comp.icon }}</div>
<div class="comp-name">{{ comp.name }}</div>
<div class="comp-status" :class="{ listening: comp.isListening }">
{{ comp.isListening ? '📻 收音中' : '🔇 未开机' }}
</div>
<div class="node-status">
<span class="status-dot" :class="{ active: comp.isListening }"></span>
{{ comp.isListening ? '监听中' : '未监听' }}
</div>
<button class="emit-btn" @click="emitEvent(comp)">
发送事件
</button>
</div>
</div>
<svg class="connection-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<marker id="arrowhead" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
<polygon points="0 0, 6 3, 0 6" :fill="isTransmitting ? 'var(--vp-c-brand)' : 'var(--vp-c-divider)'" />
</marker>
</defs>
<line
v-for="(comp, index) in components"
:key="index"
class="connection-line"
:class="{ active: comp.isEmitting || isTransmitting }"
:x1="comp.x"
:y1="comp.y"
x2="50"
y2="50"
marker-end="url(#arrowhead)"
/>
</svg>
<Transition name="fade">
<div v-if="logs.length > 0" class="event-log">
<div class="log-title">📨 消息记录</div>
<div class="log-list">
<div v-for="(log, index) in logs.slice(0, 5)" :key="index" class="log-item" :class="log.type">
<span class="log-type">{{ log.type === 'emit' ? '🎤 广播' : '📻 收听' }}</span>
<span class="log-text">{{ log.text }}</span>
</div>
</div>
</div>
</Transition>
</div>
<div class="event-log">
<div class="log-header">
<h5>📨 事件日志</h5>
<button class="clear-btn" @click="clearLogs">清空</button>
</div>
<div class="log-content">
<div v-if="logs.length === 0" class="empty-log">
暂无事件记录点击组件上的"发送事件"按钮开始测试
</div>
<div
v-for="(log, index) in logs"
:key="index"
class="log-item"
:class="log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-type">{{ log.type === 'emit' ? '发送' : '接收' }}</span>
<span class="log-from">{{ log.from }}</span>
<span class="log-arrow"></span>
<span class="log-to">{{ log.to }}</span>
<span class="log-data">{{ log.data }}</span>
</div>
</div>
<div class="hint-text">
👆 点击上方任意部门模拟发送广播消息其他开机的部门会收到
</div>
<div class="memory-warning">
<div class="warning-icon"></div>
<div class="warning-content">
<h6>内存泄漏风险提醒</h6>
<p>使用 Event Bus 如果组件销毁前没有取消订阅$off会导致内存泄漏推荐在 beforeUnmount 钩子中清理订阅</p>
<pre><code>// 正确做法
export default {
created() {
this.$bus.$on('event', this.handler)
},
beforeUnmount() {
this.$bus.$off('event', this.handler) // 必须取消订阅
}
}</code></pre>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>核心思想</strong>Event Bus 像广播站任何组件都可以发送和接收消息不需要知道对方存在适合简单的跨组件通信但要记得组件销毁时关闭收音机取消监听
</div>
</div>
</template>
<script setup>
import { ref, reactive, onBeforeUnmount } from 'vue'
import { reactive, ref } from 'vue'
// 组件定义
const components = reactive([
{ id: 1, name: 'Header', icon: '📌', x: 15, y: 15, isActive: false, isEmitting: false, isListening: true },
{ id: 2, name: 'Sidebar', icon: '📑', x: 85, y: 15, isActive: false, isEmitting: false, isListening: true },
{ id: 3, name: 'ProductList', icon: '🛍️', x: 15, y: 85, isActive: false, isEmitting: false, isListening: true },
{ id: 4, name: 'Cart', icon: '🛒', x: 85, y: 85, isActive: false, isEmitting: false, isListening: true }
{ id: 1, name: 'Header', icon: '📌', isActive: false, isListening: true },
{ id: 2, name: 'Sidebar', icon: '📑', isActive: false, isListening: true },
{ id: 3, name: 'ProductList', icon: '🛍️', isActive: false, isListening: true },
{ id: 4, name: 'Cart', icon: '🛒', isActive: false, isListening: true }
])
const isTransmitting = ref(false)
const logs = ref([])
// 格式化时间
const formatTime = () => {
const now = new Date()
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}.${now.getMilliseconds().toString().padStart(3, '0')}`
}
// 添加日志
const addLog = (type, from, to, data) => {
const sendEvent = (comp) => {
// 发送动画
comp.isActive = true
logs.value.unshift({
type,
time: formatTime(),
from,
to,
data: JSON.stringify(data)
type: 'emit',
text: `${comp.name} 发布广播: 有新消息!`
})
if (logs.value.length > 50) {
logs.value = logs.value.slice(0, 50)
}
}
// 发送事件
const emitEvent = (comp) => {
// 触发发送动画
comp.isEmitting = true
isTransmitting.value = true
// 记录发送日志
addLog('emit', comp.name, 'Event Bus', { event: 'user:action', payload: { from: comp.name } })
// 模拟其他组件接收
// 其他组件接收
components.forEach(target => {
if (target.id !== comp.id && target.isListening) {
setTimeout(() => {
target.isActive = true
addLog('receive', 'Event Bus', target.name, { event: 'user:action', payload: { from: comp.name } })
logs.value.unshift({
type: 'receive',
text: `${target.name} 收到广播`
})
setTimeout(() => {
target.isActive = false
}, 500)
}, 300 + Math.random() * 200)
}, 100)
}
})
// 清理动画状态
setTimeout(() => {
comp.isEmitting = false
isTransmitting.value = false
}, 1000)
comp.isActive = false
}, 500)
}
// 清空日志
const clearLogs = () => {
logs.value = []
}
onBeforeUnmount(() => {
// 模拟清理订阅
components.forEach(comp => {
comp.isListening = false
})
})
</script>
<style scoped>
.event-bus-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
.demo-header .icon {
font-size: 1.25rem;
}
.hint {
margin: 0;
font-size: 14px;
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.architecture-view {
position: relative;
min-height: 400px;
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
overflow: hidden;
border-radius: 6px;
}
.central-hub {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.hub-core {
position: relative;
z-index: 2;
width: 100px;
height: 100px;
background: linear-gradient(135deg, var(--vp-c-brand), var(--vp-c-brand-light));
border-radius: 50%;
.demo-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
gap: 1rem;
margin-bottom: 1rem;
}
.central-hub.active .hub-core {
transform: scale(1.1);
box-shadow: 0 0 30px var(--vp-c-brand);
}
.hub-icon {
font-size: 28px;
margin-bottom: 2px;
}
.hub-label {
font-size: 11px;
font-weight: 600;
color: white;
.bus-center {
align-self: center;
text-align: center;
}
.hub-status {
font-size: 9px;
color: rgba(255, 255, 255, 0.8);
margin-top: 2px;
}
.hub-rings {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 2px solid var(--vp-c-divider);
padding: 1rem 2rem;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-brand);
border-radius: 50%;
opacity: 0.3;
transition: all 0.3s ease;
}
.central-hub.active .ring {
border-color: var(--vp-c-brand);
opacity: 0.6;
.bus-icon {
font-size: 2rem;
}
.ring-1 { width: 140px; height: 140px; }
.ring-2 { width: 180px; height: 180px; }
.ring-3 { width: 220px; height: 220px; }
.central-hub.active .ring-1 { animation: pulse1 1s ease infinite; }
.central-hub.active .ring-2 { animation: pulse2 1s ease infinite 0.2s; }
.central-hub.active .ring-3 { animation: pulse3 1s ease infinite 0.4s; }
@keyframes pulse1 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.3; }
.bus-label {
font-weight: 600;
color: var(--vp-c-brand);
font-size: 0.9rem;
}
@keyframes pulse2 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.4; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.2; }
}
@keyframes pulse3 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.2; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.1; }
}
.connected-components {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
.components-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.component-node {
position: absolute;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 12px;
min-width: 120px;
pointer-events: auto;
transition: all 0.3s ease;
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.component-node:hover {
@@ -333,267 +194,104 @@ onBeforeUnmount(() => {
}
.component-node.active {
border-color: #22c55e;
background: #f0fdf4;
box-shadow: 0 0 0 3px #bbf7d0;
}
.component-node.emitting {
border-color: var(--vp-c-brand);
animation: emitPulse 0.5s ease;
background: var(--vp-c-brand-soft);
box-shadow: 0 0 0 3px var(--vp-c-brand-delta);
}
@keyframes emitPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
.comp-icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.node-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.node-icon {
font-size: 16px;
}
.node-name {
.comp-name {
font-weight: 600;
font-size: 12px;
color: var(--vp-c-text-1);
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.node-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--vp-c-text-2);
margin-bottom: 8px;
.comp-status {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--vp-c-divider);
transition: all 0.3s ease;
}
.status-dot.active {
background: #22c55e;
box-shadow: 0 0 4px #22c55e;
animation: blink 1.5s ease infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.emit-btn {
width: 100%;
padding: 6px 10px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.emit-btn:hover {
background: var(--vp-c-brand-dark);
}
.connection-lines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.connection-line {
stroke: var(--vp-c-divider);
stroke-width: 2;
fill: none;
transition: all 0.3s ease;
}
.connection-line.active {
stroke: var(--vp-c-brand);
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--vp-c-brand));
.comp-status.listening {
color: var(--vp-c-brand);
font-weight: 500;
}
.event-log {
margin-top: 20px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
padding: 1rem;
}
.log-header {
.log-title {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
.log-list {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.log-header h5 {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.clear-btn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 12px;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
}
.clear-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.log-content {
max-height: 200px;
overflow-y: auto;
padding: 8px;
}
.empty-log {
text-align: center;
padding: 40px;
color: var(--vp-c-text-3);
font-size: 13px;
flex-direction: column;
gap: 0.5rem;
}
.log-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 4px;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 12px;
font-size: 0.85rem;
font-family: monospace;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.log-item.emit {
background: #eff6ff;
border-left: 3px solid #3b82f6;
background: var(--vp-c-brand-soft);
border-left: 3px solid var(--vp-c-brand);
}
.log-item.receive {
background: #f0fdf4;
border-left: 3px solid #22c55e;
}
.log-time {
color: var(--vp-c-text-3);
font-size: 11px;
background: var(--vp-c-bg-soft);
border-left: 3px solid var(--vp-c-text-2);
}
.log-type {
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
.log-item.emit .log-type {
background: #dbeafe;
color: #1d4ed8;
}
.log-item.receive .log-type {
background: #bbf7d0;
color: #15803d;
}
.log-from,
.log-to {
color: var(--vp-c-text-2);
}
.log-arrow {
color: var(--vp-c-divider);
}
.log-data {
color: var(--vp-c-brand);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memory-warning {
margin-top: 20px;
padding: 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
display: flex;
gap: 12px;
}
.warning-icon {
font-size: 24px;
flex-shrink: 0;
}
.warning-content h6 {
margin: 0 0 4px 0;
color: #92400e;
font-size: 14px;
.hint-text {
text-align: center;
font-size: 0.85rem;
color: var(--vp-c-text-3);
margin-bottom: 0.75rem;
background: var(--vp-c-bg);
padding: 0.75rem;
border-radius: 6px;
}
.warning-content p {
margin: 0 0 8px 0;
color: #a16207;
font-size: 13px;
line-height: 1.5;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.warning-content pre {
margin: 0;
padding: 12px;
background: #fef3c7;
border-radius: 4px;
font-size: 12px;
overflow-x: auto;
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.warning-content code {
color: #92400e;
font-family: monospace;
.info-box {
background: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.info-box .icon {
margin-right: 0.25rem;
}
</style>
@@ -1,121 +1,61 @@
<template>
<div class="mobx-reactivity-demo">
<div class="demo-header">
<h4>MobX 响应式原理演示</h4>
<p class="hint">体验 MobX 的自动依赖追踪机制理解 ObservableAction Reaction 的关系</p>
<span class="icon"></span>
<span class="title">MobX 响应式原理</span>
<span class="subtitle">自动追踪依赖的魔法</span>
</div>
<!-- 可视化图表 -->
<div class="visualization-area">
<div class="flow-diagram">
<!-- Observable -->
<div class="box-container observable-side">
<div class="box-title">Observable (可观察状态)</div>
<div class="state-boxes">
<div
v-for="item in todos"
:key="item.id"
class="state-item"
:class="{ completed: item.completed, changed: recentlyChanged === item.id }"
>
<span class="item-text">{{ item.text }}</span>
<span class="item-status">{{ item.completed ? '✓' : '○' }}</span>
</div>
</div>
<div class="intro-text">
想象你在<span class="highlight">魔术表演</span>现场魔术师Observable改变物品所有盯着看的观众Reaction都会自动注意到变化不需要一个个去通知他们
</div>
<div class="demo-content">
<div class="state-display">
<div class="state-header">
<span class="state-icon">📦</span>
<span class="state-title">Observable 状态</span>
</div>
<!-- 连接箭头 -->
<div class="connection-area">
<div class="arrow-bidirectional">
<div class="arrow-label top">追踪依赖</div>
<div class="arrow-line">
<div class="particles">
<span v-for="i in 3" :key="i" class="particle"></span>
</div>
</div>
<div class="arrow-label bottom">触发更新</div>
</div>
</div>
<!-- Reaction -->
<div class="box-container reaction-side">
<div class="box-title">Reaction (响应/副作用)</div>
<div class="reactions-list">
<div class="reaction-item computed">
<div class="reaction-header">
<span class="reaction-icon">🧮</span>
<span class="reaction-name">Computed: 待办统计</span>
</div>
<div class="reaction-value">
{{ todos.length }} 已完成 {{ completedCount }}
</div>
</div>
<div class="reaction-item autorun">
<div class="reaction-header">
<span class="reaction-icon">🔄</span>
<span class="reaction-name">Autorun: 自动保存</span>
</div>
<div class="reaction-status" :class="{ active: autoSaveActive }">
{{ autoSaveActive ? '💾 已自动保存到 localStorage' : '⏸️ 等待变更...' }}
</div>
</div>
<div class="reaction-item reaction">
<div class="reaction-header">
<span class="reaction-icon">👀</span>
<span class="reaction-name">Reaction: 变更日志</span>
</div>
<div class="reaction-log">
<div v-for="(log, index) in changeLogs" :key="index" class="log-entry">
<span class="log-time">{{ log.time }}</span>
<span class="log-action">{{ log.action }}</span>
</div>
</div>
</div>
<div class="todo-list">
<div
v-for="todo in todos"
:key="todo.id"
class="todo-item"
:class="{ completed: todo.completed, changed: recentlyChanged === todo.id }"
@click="toggleTodo(todo.id)"
>
<span class="todo-status">{{ todo.completed ? '✓' : '○' }}</span>
<span class="todo-text">{{ todo.text }}</span>
</div>
</div>
</div>
</div>
<!-- Action 区域 -->
<div class="action-area">
<div class="action-title">🎮 交互控制台 (Action)</div>
<div class="action-controls">
<div class="input-group">
<input v-model="newTodoText" placeholder="输入待办事项..." @keyup.enter="addTodo" />
<button @click="addTodo">添加</button>
<div class="reaction-display">
<div class="reaction-header">
<span class="reaction-icon">🔄</span>
<span class="reaction-title">自动响应</span>
</div>
<div class="quick-actions">
<button @click="completeAll">全部完成</button>
<button @click="clearCompleted">清除已完成</button>
<button @click="reset">重置</button>
<div class="reaction-stats">
<div class="stat-item">
<span class="stat-label">总计</span>
<span class="stat-value">{{ todos.length }} </span>
</div>
<div class="stat-item">
<span class="stat-label">已完成</span>
<span class="stat-value completed">{{ completedCount }} </span>
</div>
</div>
</div>
<div class="interaction-area">
<input v-model="newTodoText" placeholder="输入待办事项..." @keyup.enter="addTodo" class="todo-input" />
<button @click="addTodo" class="add-btn"> 添加</button>
</div>
</div>
<!-- 说明区域 -->
<div class="explanation-area">
<div class="explanation-card">
<h5>📦 Observable (可观察状态)</h5>
<p>使用 <code>observable</code> 或类属性装饰器 <code>@observable</code> 定义的状态当状态变化时所有依赖它的 Reaction 会自动重新执行</p>
</div>
<div class="explanation-card">
<h5> Action (动作)</h5>
<p>使用 <code>action</code> <code>@action</code> 装饰器标记的方法用于修改 Observable 状态Action 会批量处理变更通知避免中间状态的重复渲染</p>
</div>
<div class="explanation-card"
>
<h5>🔄 Reaction (响应)</h5>
<p> Observable 状态变化时自动执行的副作用包括</p>
<ul>
<li><code>autorun</code>: 自动追踪依赖并执行</li>
<li><code>reaction</code>: 对特定数据变化作出反应</li>
<li><code>when</code>: 条件满足时执行一次</li>
</ul>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>核心思想</strong>MobX 自动追踪状态和响应的关系状态变化时自动触发相关更新就像魔术你只管改变数据UI 会自动更新
</div>
</div>
</template>
@@ -123,7 +63,6 @@
<script setup>
import { ref, computed, watch } from 'vue'
// 状态
const todos = ref([
{ id: 1, text: '学习 MobX', completed: false },
{ id: 2, text: '理解响应式原理', completed: true }
@@ -131,15 +70,11 @@ const todos = ref([
const newTodoText = ref('')
const recentlyChanged = ref(null)
const autoSaveActive = ref(false)
const changeLogs = ref([])
// 计算属性(模拟 MobX 的 computed
const completedCount = computed(() => {
return todos.value.filter(t => t.completed).length
})
// 方法(模拟 MobX 的 action
const addTodo = () => {
if (!newTodoText.value.trim()) return
@@ -156,8 +91,6 @@ const addTodo = () => {
setTimeout(() => {
recentlyChanged.value = null
}, 500)
addLog('添加待办', newTodo.text)
}
const toggleTodo = (id) => {
@@ -168,472 +101,225 @@ const toggleTodo = (id) => {
setTimeout(() => {
recentlyChanged.value = null
}, 500)
addLog(todo.completed ? '完成待办' : '取消完成', todo.text)
}
}
const completeAll = () => {
todos.value.forEach(t => t.completed = true)
addLog('全部完成', `${todos.value.length}`)
}
const clearCompleted = () => {
const count = todos.value.filter(t => t.completed).length
todos.value = todos.value.filter(t => !t.completed)
addLog('清除已完成', `${count}`)
}
const reset = () => {
todos.value = [
{ id: 1, text: '学习 MobX', completed: false },
{ id: 2, text: '理解响应式原理', completed: true }
]
changeLogs.value = []
addLog('重置', '恢复初始状态')
}
const addLog = (action, detail) => {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
changeLogs.value.unshift({ time, action, detail })
if (changeLogs.value.length > 10) {
changeLogs.value = changeLogs.value.slice(0, 10)
}
}
// 模拟 autorun - 自动保存
watch(todos, () => {
autoSaveActive.value = true
setTimeout(() => {
autoSaveActive.value = false
}, 1000)
}, { deep: true })
</script>
<style scoped>
.mobx-reactivity-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
.demo-header .icon {
font-size: 1.25rem;
}
.hint {
margin: 0;
font-size: 14px;
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.visualization-area {
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border-radius: 6px;
}
.flow-diagram {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 20px;
align-items: start;
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
@media (max-width: 968px) {
.flow-diagram {
grid-template-columns: 1fr;
}
.connection-area {
transform: rotate(90deg);
padding: 40px 0 !important;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.box-container {
background: var(--vp-c-bg-soft);
.state-display,
.reaction-display {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
padding: 1rem;
}
.box-container.observable-side {
border-color: #ff6b6b;
}
.box-container.reaction-side {
border-color: #4ecdc4;
}
.box-title {
font-weight: 600;
font-size: 14px;
color: var(--vp-c-text-1);
margin-bottom: 12px;
padding-bottom: 8px;
.state-header,
.reaction-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.state-boxes {
display: flex;
flex-direction: column;
gap: 8px;
.state-icon,
.reaction-icon {
font-size: 1.25rem;
}
.state-item {
.state-title,
.reaction-title {
font-weight: 600;
font-size: 0.9rem;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
gap: 0.75rem;
padding: 0.6rem 0.75rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.state-item:hover {
border-color: var(--vp-c-brand);
.todo-item:hover {
background: var(--vp-c-bg);
transform: translateX(4px);
}
.state-item.completed {
.todo-item.completed {
background: #f0fdf4;
border-color: #86efac;
}
.state-item.changed {
animation: highlight 0.5s ease;
}
@keyframes highlight {
0%, 100% { background: var(--vp-c-bg); }
50% { background: #fef3c7; }
}
.item-text {
font-size: 13px;
color: var(--vp-c-text-1);
}
.state-item.completed .item-text {
.todo-item.completed .todo-text {
text-decoration: line-through;
color: var(--vp-c-text-3);
}
.item-status {
font-size: 14px;
color: #22c55e;
.todo-item.changed {
animation: highlight 0.5s ease;
}
.connection-area {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
@keyframes highlight {
0%, 100% { background: var(--vp-c-bg-soft); }
50% { background: #fef3c7; }
}
.arrow-bidirectional {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.arrow-label {
font-size: 11px;
color: var(--vp-c-text-3);
padding: 4px 8px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
white-space: nowrap;
}
.arrow-label.top {
margin-bottom: 4px;
}
.arrow-label.bottom {
margin-top: 4px;
}
.arrow-line {
position: relative;
width: 3px;
height: 80px;
background: linear-gradient(to bottom, #ff6b6b, #4ecdc4);
border-radius: 2px;
}
.particles {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 10px 0;
}
.particle {
font-size: 8px;
.todo-status {
font-size: 1.25rem;
color: var(--vp-c-brand);
animation: flow 1.5s linear infinite;
opacity: 0;
}
.particle:nth-child(1) { animation-delay: 0s; }
.particle:nth-child(2) { animation-delay: 0.5s; }
.particle:nth-child(3) { animation-delay: 1s; }
@keyframes flow {
0% {
opacity: 0;
transform: translateY(0);
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(60px);
}
}
.reactions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.reaction-item {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 12px;
transition: all 0.3s ease;
}
.reaction-item.computed {
border-left: 4px solid #8b5cf6;
}
.reaction-item.autorun {
border-left: 4px solid #f59e0b;
}
.reaction-item.reaction {
border-left: 4px solid #ec4899;
}
.reaction-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.reaction-icon {
font-size: 14px;
}
.reaction-name {
font-weight: 600;
font-size: 12px;
.todo-text {
font-size: 0.9rem;
color: var(--vp-c-text-1);
}
.reaction-value {
font-size: 13px;
.reaction-stats {
display: flex;
gap: 1.5rem;
}
.stat-item {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.stat-label {
color: var(--vp-c-text-2);
font-family: monospace;
padding: 4px 8px;
background: var(--vp-c-bg-soft);
border-radius: 4px;
}
.reaction-status {
font-size: 12px;
color: var(--vp-c-text-3);
padding: 4px 8px;
background: var(--vp-c-bg-soft);
border-radius: 4px;
.stat-value {
font-weight: 600;
color: var(--vp-c-brand);
}
.reaction-status.active {
.stat-value.completed {
color: #22c55e;
background: #dcfce7;
}
.reaction-log {
max-height: 100px;
overflow-y: auto;
}
.log-entry {
.interaction-area {
display: flex;
gap: 8px;
font-size: 11px;
padding: 3px 0;
border-bottom: 1px solid var(--vp-c-divider);
gap: 0.75rem;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: var(--vp-c-text-3);
font-family: monospace;
}
.log-action {
color: var(--vp-c-text-2);
}
.action-area {
margin-top: 20px;
padding: 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.action-title {
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
font-size: 14px;
}
.action-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group {
display: flex;
gap: 8px;
}
.input-group input {
.todo-input {
flex: 1;
padding: 8px 12px;
padding: 0.6rem 1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 14px;
font-size: 0.9rem;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
}
.input-group input:focus {
.todo-input:focus {
outline: none;
border-color: var(--vp-c-brand);
}
.input-group button {
padding: 8px 16px;
.add-btn {
padding: 0.6rem 1.5rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.input-group button:hover {
background: var(--vp-c-brand-dark);
.add-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.quick-actions button {
padding: 6px 12px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
.info-box {
background: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
font-size: 12px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
}
.quick-actions button:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
.info-box .icon {
margin-right: 0.25rem;
}
.explanation-area {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
@media (max-width: 768px) {
.interaction-area {
flex-direction: column;
}
.explanation-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
}
.explanation-card h5 {
margin: 0 0 10px 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.explanation-card p {
margin: 0;
font-size: 13px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
.explanation-card code {
background: var(--vp-c-bg-soft);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
color: var(--vp-c-brand);
}
.explanation-card ul {
margin: 8px 0 0 0;
padding-left: 18px;
}
.explanation-card li {
font-size: 12px;
color: var(--vp-c-text-2);
margin: 4px 0;
}
@media (max-width: 640px) {
.quick-actions {
justify-content: center;
.reaction-stats {
flex-direction: column;
gap: 0.5rem;
}
}
</style>
@@ -1,163 +1,103 @@
<template>
<div class="props-flow-demo">
<div class="demo-header">
<h4>Props 数据流演示</h4>
<p class="hint">观察父组件如何通过 props 向子组件传递数据以及子组件如何通过事件向父组件通信</p>
<span class="icon">📦</span>
<span class="title">Props 数据传递</span>
<span class="subtitle">父亲给儿子送礼物的单向流动</span>
</div>
<div class="flow-container">
<!-- 父组件可视化 -->
<div class="parent-component">
<div class="component-header">
<span class="tag">Parent.vue</span>
<span class="badge blue">数据持有者</span>
</div>
<div class="intro-text">
想象你在<span class="highlight">快递公司</span>工作包裹数据只能从寄件人父组件发往收件人子组件收件人不能直接修改包裹内容只能通过电话事件让寄件人修改
</div>
<div class="data-box">
<div class="data-title">data() {</div>
<div class="data-item" :class="{ changed: hasChanged }">
<span class="key">user:</span>
<span class="value">{ name: '{{ userName }}', age: {{ userAge }} }</span>
<div class="demo-content">
<div class="component-box parent">
<div class="component-label">👨 父组件 (寄件人)</div>
<div class="data-display">
<div class="data-row">
<span class="key">包裹内容:</span>
<span class="value">{{ user.name }} ({{ user.age }})</span>
</div>
<div class="data-item">
<span class="key">theme:</span>
<span class="value">'{{ theme }}'</span>
<div class="data-row">
<span class="key">包装颜色:</span>
<span class="value" :class="theme">{{ theme === 'light' ? '亮色' : '暗色' }}</span>
</div>
<div class="data-title">}</div>
</div>
<div class="props-config">
<div class="config-title">传递给子组件的 Props:</div>
<div class="prop-tag" v-for="prop in activeProps" :key="prop">
:{{ prop }}="{{ prop }}"
<div class="props-output">
<span class="label">📮 发送包裹:</span>
<div class="prop-tags">
<span class="prop-tag">:user</span>
<span class="prop-tag">:theme</span>
</div>
</div>
</div>
<!-- 流动动画 -->
<div class="flow-animation">
<div class="flow-line" :class="{ active: isFlowing }">
<div class="flow-particles" v-if="isFlowing">
<span v-for="i in 5" :key="i" class="particle"></span>
</div>
</div>
<div class="flow-label" :class="{ active: isFlowing }">
{{ isFlowing ? '传递 Props' : '等待交互' }}
</div>
<div class="flow-arrow" :class="{ active: isFlowing }">
<div class="arrow-body"></div>
<div class="flow-text">{{ isFlowing ? '快递派送中...' : 'Props 单向传递' }}</div>
</div>
<!-- 子组件可视化 -->
<div class="child-component">
<div class="component-header">
<span class="tag">Child.vue</span>
<span class="badge green">数据展示</span>
</div>
<div class="props-box">
<div class="props-title">props: {</div>
<div class="prop-item" v-for="prop in activeProps" :key="prop" :class="{ receiving: isFlowing }">
<span class="prop-name">{{ prop }}</span>
<span class="prop-type">{ type: {{ getPropType(prop) }} }</span>
<div class="component-box child">
<div class="component-label">👦 子组件 (收件人)</div>
<div class="props-display">
<div class="label">📬 接收包裹:</div>
<div class="prop-item">
<span class="prop-name">user</span>
<span class="prop-value">{{ user.name }} ({{ user.age }})</span>
</div>
<div class="props-title">}</div>
</div>
<div class="render-preview">
<div class="preview-title">渲染预览:</div>
<div class="preview-content">
<div class="user-card">
<div class="avatar">👤</div>
<div class="user-info">
<div class="user-name">{{ userName || '未知用户' }}</div>
<div class="user-meta">
<span class="age">{{ userAge }}</span>
<span class="theme-badge" :class="theme">{{ theme }}</span>
</div>
</div>
</div>
<div class="prop-item">
<span class="prop-name">theme</span>
<span class="prop-value" :class="theme">{{ theme === 'light' ? '亮色' : '暗色' }}</span>
</div>
</div>
<div class="emit-section">
<div class="emit-title">向父组件通信:</div>
<button class="emit-btn" @click="emitUpdate">$emit('update', { name: '王五' })</button>
</div>
<button class="emit-btn" @click="handleEmit">
📞 打电话给爸爸改名字
</button>
</div>
</div>
<div class="interaction-panel">
<div class="panel-title">🎮 交互控制台</div>
<div class="interaction-area">
<div class="control-group">
<label>修改父组件数据:</label>
<input v-model="userName" placeholder="用户名" @input="triggerFlow" />
<input v-model.number="userAge" type="number" placeholder="年龄" @input="triggerFlow" />
<label>📝 修改包裹内容</label>
<input v-model="user.name" placeholder="收件人姓名" @input="triggerFlow" />
<input v-model.number="user.age" type="number" placeholder="年龄" @input="triggerFlow" />
<select v-model="theme" @change="triggerFlow">
<option value="light">Light 主题</option>
<option value="dark">Dark 主题</option>
<option value="light">亮色包装</option>
<option value="dark">暗色包装</option>
</select>
</div>
<div class="control-group">
<label>选择传递的 Props:</label>
<label class="checkbox"><input type="checkbox" v-model="propSelection.user" /> user</label>
<label class="checkbox"><input type="checkbox" v-model="propSelection.theme" /> theme</label>
</div>
<div class="flow-status" :class="{ active: isFlowing }">
{{ isFlowing ? '⬇️ 数据正在流动...' : '⏸️ 等待数据变化' }}
</div>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>核心思想</strong>Props 是单向数据流父组件像寄件人子组件像收件人子组件不能直接修改 props只能通过 emit 事件通知父组件修改
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, reactive } from 'vue'
const user = reactive({
name: '小明',
age: 25
})
// 父组件数据
const userName = ref('张三')
const userAge = ref(25)
const theme = ref('light')
const hasChanged = ref(false)
// Props 选择
const propSelection = ref({
user: true,
theme: true
})
const activeProps = computed(() => {
return Object.entries(propSelection.value)
.filter(([, v]) => v)
.map(([k]) => k)
})
// 流动动画控制
const isFlowing = ref(false)
let flowTimeout = null
const triggerFlow = () => {
hasChanged.value = true
isFlowing.value = true
clearTimeout(flowTimeout)
flowTimeout = setTimeout(() => {
isFlowing.value = false
hasChanged.value = false
}, 1500)
}, 1000)
}
// 监听数据变化
watch([userName, userAge, theme], () => {
triggerFlow()
}, { deep: true })
const getPropType = (prop) => {
const types = {
user: 'Object',
theme: 'String'
}
return types[prop] || 'Any'
}
const emitUpdate = () => {
userName.value = '王五'
const handleEmit = () => {
user.name = '小红'
triggerFlow()
}
</script>
@@ -166,388 +106,214 @@ const emitUpdate = () => {
.props-flow-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.flow-container {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
margin-bottom: 20px;
}
@media (max-width: 968px) {
.flow-container {
grid-template-columns: 1fr;
}
.flow-animation {
flex-direction: row !important;
padding: 12px !important;
}
.flow-line {
width: 100% !important;
height: 2px !important;
}
}
.parent-component,
.child-component {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
}
.component-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--vp-c-divider);
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.tag {
font-family: monospace;
font-size: 13px;
padding: 4px 8px;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
border-radius: 4px;
.demo-header .icon {
font-size: 1.25rem;
}
.badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.badge.blue {
background: #dbeafe;
color: #1e40af;
}
.badge.green {
background: #dcfce7;
color: #166534;
}
.data-box,
.props-box {
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
font-family: monospace;
font-size: 13px;
}
.data-title,
.props-title {
color: var(--vp-c-text-3);
margin-bottom: 4px;
}
.data-item,
.prop-item {
padding: 4px 8px;
margin: 2px 0;
border-radius: 3px;
transition: all 0.3s ease;
}
.data-item.changed {
background: #fef3c7;
animation: pulse 0.5s ease;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
.data-item .key {
color: var(--vp-c-brand);
}
.data-item .value {
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.prop-item.receiving {
background: #dcfce7;
animation: receive 0.5s ease;
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 6px;
}
@keyframes receive {
0% { transform: translateX(-10px); opacity: 0.5; }
100% { transform: translateX(0); opacity: 1; }
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.component-box {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.75rem;
}
.component-label {
font-weight: 600;
color: var(--vp-c-brand);
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--vp-c-divider);
font-size: 0.85rem;
}
.data-display,
.props-display {
margin-bottom: 0.5rem;
}
.data-row,
.prop-item {
display: flex;
gap: 0.5rem;
padding: 0.2rem 0;
font-family: monospace;
font-size: 0.85rem;
}
.key,
.prop-name {
color: var(--vp-c-brand);
font-weight: 500;
}
.prop-type {
color: var(--vp-c-text-3);
font-size: 11px;
margin-left: 8px;
}
.props-config {
margin-bottom: 12px;
}
.config-title {
font-size: 12px;
.value,
.prop-value {
color: var(--vp-c-text-2);
margin-bottom: 6px;
}
.value.light,
.prop-value.light {
background: #fef3c7;
padding: 2px 6px;
border-radius: 3px;
}
.value.dark,
.prop-value.dark {
background: #374151;
color: #f3f4f6;
padding: 2px 6px;
border-radius: 3px;
}
.props-output {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.prop-tags {
display: flex;
gap: 0.25rem;
}
.prop-tag {
display: inline-block;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
padding: 2px 8px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
padding: 4px 8px;
margin: 2px;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
border-radius: 4px;
font-size: 0.8rem;
}
.flow-animation {
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.flow-line {
width: 2px;
height: 80px;
background: var(--vp-c-divider);
position: relative;
gap: 0.25rem;
padding: 0.4rem;
transition: all 0.3s ease;
}
.flow-line.active {
background: var(--vp-c-brand);
box-shadow: 0 0 10px var(--vp-c-brand);
}
.flow-particles {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.particle {
.flow-arrow.active {
color: var(--vp-c-brand);
font-size: 8px;
animation: flowDown 1s linear infinite;
opacity: 0;
}
.particle:nth-child(1) { animation-delay: 0s; }
.particle:nth-child(2) { animation-delay: 0.2s; }
.particle:nth-child(3) { animation-delay: 0.4s; }
.particle:nth-child(4) { animation-delay: 0.6s; }
.particle:nth-child(5) { animation-delay: 0.8s; }
@keyframes flowDown {
0% {
opacity: 0;
transform: translateY(0);
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(60px);
}
}
.flow-label {
margin-top: 12px;
font-size: 13px;
.arrow-body {
font-size: 1.3rem;
color: var(--vp-c-text-3);
text-align: center;
transition: all 0.3s ease;
}
.flow-label.active {
.flow-arrow.active .arrow-body {
color: var(--vp-c-brand);
transform: scale(1.2);
}
.flow-text {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.flow-arrow.active .flow-text {
color: var(--vp-c-brand);
font-weight: 600;
}
.render-preview {
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.preview-title {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
.preview-content {
background: var(--vp-c-bg);
border-radius: 4px;
padding: 12px;
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
font-size: 32px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-brand-soft);
border-radius: 50%;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 600;
color: var(--vp-c-text-1);
font-size: 14px;
}
.user-meta {
display: flex;
gap: 8px;
margin-top: 4px;
font-size: 12px;
}
.age {
color: var(--vp-c-text-2);
}
.theme-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
text-transform: uppercase;
}
.theme-badge.light {
background: #fef3c7;
color: #92400e;
}
.theme-badge.dark {
background: #374151;
color: #f3f4f6;
}
.emit-section {
padding: 12px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.emit-title {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
.emit-btn {
width: 100%;
padding: 8px 12px;
padding: 0.5rem 1rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
}
.emit-btn:hover {
background: var(--vp-c-brand-dark);
opacity: 0.9;
transform: translateY(-1px);
}
.interaction-panel {
margin-top: 20px;
padding: 16px;
.interaction-area {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.panel-title {
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
font-size: 14px;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.control-group {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-group label {
display: block;
font-size: 13px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 8px;
font-weight: 500;
}
.control-group input,
.control-group select {
width: 100%;
padding: 8px 12px;
padding: 0.4rem 0.6rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 14px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
margin-bottom: 8px;
font-size: 0.85rem;
}
.control-group input:focus,
@@ -556,32 +322,15 @@ const emitUpdate = () => {
border-color: var(--vp-c-brand);
}
.checkbox {
display: inline-flex !important;
align-items: center;
gap: 6px;
margin-right: 16px;
cursor: pointer;
}
.checkbox input {
width: auto !important;
margin: 0 !important;
}
.flow-status {
padding: 10px 16px;
background: var(--vp-c-bg-soft);
.info-box {
background: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
text-align: center;
font-size: 14px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
transition: all 0.3s ease;
}
.flow-status.active {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
font-weight: 600;
.info-box .icon {
margin-right: 0.25rem;
}
</style>
@@ -1,165 +1,60 @@
<template>
<div class="redux-flow-demo">
<div class="demo-header">
<h4>Redux 数据流演示</h4>
<p class="hint">理解 Redux 的单向数据流Action Reducer Store View</p>
<span class="icon">🔄</span>
<span class="title">Redux 数据流</span>
<span class="subtitle">单向循环的数据管道</span>
</div>
<div class="flow-diagram">
<!-- 视图层 -->
<div class="flow-layer view-layer">
<div class="layer-header">
<span class="layer-icon">👁</span>
<span class="layer-title">View (视图层)</span>
</div>
<div class="view-content">
<div class="counter-display">
<span class="counter-label">当前计数:</span>
<span class="counter-value" :class="{ changed: countChanged }">{{ count }}</span>
</div>
<div class="action-buttons">
<button class="action-btn" @click="dispatchAction('INCREMENT')">
<span class="btn-icon"></span>
增加
</button>
<button class="action-btn" @click="dispatchAction('DECREMENT')">
<span class="btn-icon"></span>
减少
</button>
<button class="action-btn" @click="dispatchAction('RESET')">
<span class="btn-icon">🔄</span>
重置
</button>
</div>
</div>
</div>
<!-- 箭头View -> Action -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'action' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'action' }">
dispatch(action)
</div>
</div>
<!-- Action -->
<div class="flow-layer action-layer" :class="{ active: flowStage === 'action' }">
<div class="layer-header">
<span class="layer-icon">📨</span>
<span class="layer-title">Action (动作)</span>
</div>
<div class="action-content">
<div class="action-object">
<div class="code-block">
<pre><code>{
type: "{{ currentAction.type }}",
payload: {{ currentAction.payload || 'undefined' }}
}</code></pre>
</div>
</div>
</div>
</div>
<!-- 箭头Action -> Reducer -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'reducer' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'reducer' }">
reduce(state, action)
</div>
</div>
<!-- Reducer -->
<div class="flow-layer reducer-layer" :class="{ active: flowStage === 'reducer' }">
<div class="layer-header">
<span class="layer-icon"></span>
<span class="layer-title">Reducer (纯函数)</span>
</div>
<div class="reducer-content">
<div class="reducer-function">
<div class="code-block">
<pre><code>function reducer(state, action) {
switch (action.type) {
case "{{ currentAction.type }}":
return {
...state,
count: (state?.count ?? 0) {{ currentAction.operator }} {{ currentAction.step || 1 }}
};
default:
return state;
}
}</code></pre>
</div>
</div>
</div>
</div>
<!-- 箭头Reducer -> Store -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'store' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'store' }">
update store
</div>
</div>
<!-- Store -->
<div class="flow-layer store-layer" :class="{ active: flowStage === 'store' }">
<div class="layer-header">
<span class="layer-icon">🏪</span>
<span class="layer-title">Store (单一数据源)</span>
</div>
<div class="store-content">
<div class="store-state">
<div class="state-label">Current State:</div>
<div class="code-block">
<pre><code>{
count: {{ count }}
}</code></pre>
</div>
</div>
</div>
</div>
<!-- 箭头Store -> View -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'view' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'view' }">
notify subscribers
</div>
</div>
<div class="intro-text">
想象你在<span class="highlight">图书馆</span>工作读者View填写借书单Action管理员Reducer审核后更新库存记录Store新通知View更新就会显示在公告栏
</div>
<!-- 底部说明 -->
<div class="redux-principles">
<h5>📋 Redux 三大原则</h5>
<div class="principles-grid">
<div class="principle-card">
<div class="principle-number">1</div>
<h6>单一数据源</h6>
<p>整个应用的 state 储存在唯一的 store </p>
</div>
<div class="principle-card">
<div class="principle-number">2</div>
<h6>State 只读</h6>
<p>唯一改变 state 的方法是触发 action</p>
</div>
<div class="principle-card">
<div class="principle-number">3</div>
<h6>纯函数修改</h6>
<p>Reducer 必须是纯函数接收旧 state 返回新 state</p>
</div>
<div class="demo-content">
<div class="counter-display">
<span class="counter-label">当前库存</span>
<span class="counter-value" :class="{ changed: countChanged }">{{ count }}</span>
<span class="counter-unit">本书</span>
</div>
<div class="action-buttons">
<button class="action-btn" @click="dispatchAction('INCREMENT')">
<span class="btn-icon"></span>
进货 (+1)
</button>
<button class="action-btn" @click="dispatchAction('DECREMENT')">
<span class="btn-icon"></span>
出货 (-1)
</button>
<button class="action-btn reset" @click="dispatchAction('RESET')">
<span class="btn-icon">🔄</span>
重置库存
</button>
</div>
<Transition name="fade">
<div v-if="flowStage" class="flow-stages">
<div class="flow-stage" :class="{ active: flowStage === 'action' }">
<span class="stage-icon">📝</span>
<span class="stage-text">Action: {{ currentAction.type }}</span>
</div>
<div class="flow-arrow"></div>
<div class="flow-stage" :class="{ active: flowStage === 'reducer' }">
<span class="stage-icon"></span>
<span class="stage-text">Reducer 处理中...</span>
</div>
<div class="flow-arrow"></div>
<div class="flow-stage" :class="{ active: flowStage === 'store' }">
<span class="stage-icon">📦</span>
<span class="stage-text">Store 已更新</span>
</div>
</div>
</Transition>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>核心思想</strong>Redux 是单向数据流循环View 触发 Action Reducer 纯函数处理 更新 Store 通知 View 重新渲染状态可预测易于调试
</div>
</div>
</template>
@@ -167,53 +62,23 @@
<script setup>
import { ref, reactive } from 'vue'
// 状态
const count = ref(0)
const countChanged = ref(false)
const flowStage = ref('')
// 当前 action
const currentAction = reactive({
type: '',
payload: null,
operator: '+',
step: 1
type: ''
})
// 调度 action
const dispatchAction = async (actionType) => {
flowStage.value = 'action'
// 设置当前 action
currentAction.type = actionType
switch (actionType) {
case 'INCREMENT':
currentAction.payload = undefined
currentAction.operator = '+'
currentAction.step = 1
break
case 'DECREMENT':
currentAction.payload = undefined
currentAction.operator = '-'
currentAction.step = 1
break
case 'RESET':
currentAction.payload = undefined
currentAction.operator = '='
currentAction.step = 0
break
}
// 模拟流程
await wait(600)
await wait(500)
flowStage.value = 'reducer'
await wait(800)
await wait(500)
flowStage.value = 'store'
await wait(600)
flowStage.value = 'view'
// 更新状态
switch (actionType) {
case 'INCREMENT':
count.value++
@@ -231,7 +96,7 @@ const dispatchAction = async (actionType) => {
countChanged.value = false
}, 300)
await wait(400)
await wait(300)
flowStage.value = ''
}
@@ -242,86 +107,75 @@ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
.redux-flow-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.flow-diagram {
display: flex;
flex-direction: column;
gap: 16px;
}
.flow-layer {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
transition: all 0.3s ease;
}
.flow-layer.active {
border-color: var(--vp-c-brand);
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
transform: scale(1.02);
}
.layer-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--vp-c-divider);
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.layer-icon {
font-size: 20px;
.demo-header .icon {
font-size: 1.25rem;
}
.layer-title {
font-weight: 600;
color: var(--vp-c-text-1);
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.view-content {
display: flex;
flex-direction: column;
gap: 16px;
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 6px;
}
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.demo-content {
background: var(--vp-c-bg);
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 0.75rem;
}
.counter-display {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 20px;
gap: 0.75rem;
padding: 2rem;
background: var(--vp-c-bg-soft);
border-radius: 8px;
margin-bottom: 1rem;
}
.counter-label {
font-size: 14px;
font-size: 1rem;
color: var(--vp-c-text-2);
}
.counter-value {
font-size: 36px;
font-size: 3rem;
font-weight: 700;
color: var(--vp-c-brand);
transition: all 0.3s ease;
@@ -332,209 +186,117 @@ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
color: #22c55e;
}
.counter-unit {
font-size: 1rem;
color: var(--vp-c-text-2);
}
.action-buttons {
display: flex;
gap: 8px;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--vp-c-brand-dark);
transform: translateY(-1px);
opacity: 0.9;
transform: translateY(-2px);
}
.action-btn.reset {
background: var(--vp-c-text-2);
}
.btn-icon {
font-size: 14px;
font-size: 1rem;
}
.flow-arrow-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
opacity: 0.5;
transition: all 0.3s ease;
}
.flow-arrow.active {
opacity: 1;
}
.arrow-line {
width: 2px;
height: 20px;
background: var(--vp-c-divider);
transition: all 0.3s ease;
}
.flow-arrow.active .arrow-line {
background: var(--vp-c-brand);
box-shadow: 0 0 8px var(--vp-c-brand);
}
.arrow-head {
color: var(--vp-c-divider);
font-size: 12px;
line-height: 1;
transition: all 0.3s ease;
}
.flow-arrow.active .arrow-head {
color: var(--vp-c-brand);
animation: bounce 0.5s ease infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(2px); }
}
.arrow-label {
margin-top: 4px;
font-size: 11px;
color: var(--vp-c-text-3);
font-family: monospace;
transition: all 0.3s ease;
}
.arrow-label.active {
color: var(--vp-c-brand);
font-weight: 600;
}
.action-content,
.reducer-content,
.store-content {
padding: 12px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.action-object {
display: flex;
flex-direction: column;
gap: 8px;
}
.code-block {
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
}
.code-block pre {
margin: 0;
padding: 12px;
overflow-x: auto;
}
.code-block code {
font-family: 'Fira Code', 'Monaco', monospace;
font-size: 12px;
line-height: 1.5;
color: #d4d4d4;
}
.reducer-function {
display: flex;
flex-direction: column;
gap: 8px;
}
.store-state {
display: flex;
flex-direction: column;
gap: 8px;
}
.state-label {
font-size: 12px;
color: var(--vp-c-text-2);
}
.redux-principles {
margin-top: 24px;
padding: 20px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.redux-principles h5 {
margin: 0 0 16px 0;
color: var(--vp-c-text-1);
font-size: 16px;
}
.principles-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.principle-card {
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
border-left: 4px solid var(--vp-c-brand);
}
.principle-number {
width: 28px;
height: 28px;
background: var(--vp-c-brand);
color: white;
border-radius: 50%;
.flow-stages {
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
gap: 0.5rem;
padding: 1rem;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.principle-card h6 {
margin: 0 0 6px 0;
color: var(--vp-c-text-1);
font-size: 14px;
.flow-stage {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
font-size: 0.85rem;
transition: all 0.3s ease;
}
.principle-card p {
margin: 0;
.flow-stage.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
box-shadow: 0 0 0 3px var(--vp-c-brand-delta);
}
.stage-icon {
font-size: 1.25rem;
}
.stage-text {
font-weight: 500;
}
.flow-arrow {
font-size: 1.5rem;
color: var(--vp-c-text-3);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 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);
font-size: 12px;
line-height: 1.5;
}
.info-box .icon {
margin-right: 0.25rem;
}
@media (max-width: 768px) {
.action-buttons {
.flow-stages {
flex-direction: column;
}
.principles-grid {
grid-template-columns: 1fr;
.flow-arrow {
transform: rotate(90deg);
}
}
</style>
@@ -1,121 +1,84 @@
<template>
<div class="state-management-comparison">
<div class="demo-header">
<h4>状态管理库全景对比</h4>
<p class="hint">全面对比主流状态管理方案的特性适用场景和学习曲线</p>
<span class="icon">📊</span>
<span class="title">状态管理方案对比</span>
<span class="subtitle">不同工具的适用场景</span>
</div>
<!-- 简化版对比表格 -->
<div class="comparison-table-wrapper">
<table class="comparison-table">
<thead>
<tr>
<th class="feature-col">特性</th>
<th v-for="lib in libraries" :key="lib.id" class="lib-col">
<div class="lib-header">
<span class="lib-icon">{{ lib.icon }}</span>
<span class="lib-name">{{ lib.name }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="feature-name">学习曲线</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<div class="intro-text">
想象你在<span class="highlight">超市</span>采购小买小卖用购物篮Zustand大采购用手推车Pinia企业级采购用专业物流Redux根据需求选对工具
</div>
<div class="demo-content">
<div class="comparison-table">
<div class="table-header">
<div class="header-col first">工具</div>
<div class="header-col">难度</div>
<div class="header-col">大小</div>
<div class="header-col">框架</div>
</div>
<div class="table-body">
<div
v-for="lib in libraries"
:key="lib.id"
class="table-row"
:class="{ selected: selectedLib === lib.id }"
@click="selectedLib = lib.id"
>
<div class="row-col first">
<span class="lib-icon">{{ lib.icon }}</span>
<span class="lib-name">{{ lib.name }}</span>
</div>
<div class="row-col">
<div class="curve-bar">
<div class="curve-fill" :style="{ width: lib.learningCurve + '%', background: getCurveColor(lib.learningCurve) }"></div>
</div>
<span class="curve-label">{{ getCurveLabel(lib.learningCurve) }}</span>
</td>
</tr>
<tr>
<td class="feature-name">包大小</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
</div>
<div class="row-col">
<span class="size-badge" :class="getSizeClass(lib.bundleSize)">{{ lib.bundleSize }}</span>
</td>
</tr>
<tr>
<td class="feature-name">TypeScript</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="boolean-badge" :class="{ yes: lib.typescript, no: !lib.typescript }">
{{ lib.typescript ? '✓' : '✗' }}
</span>
</td>
</tr>
<tr>
<td class="feature-name">开发工具</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="boolean-badge" :class="{ yes: lib.devtools, no: !lib.devtools }">
{{ lib.devtools ? '✓' : '✗' }}
</span>
</td>
</tr>
<tr>
<td class="feature-name">SSR 支持</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="boolean-badge" :class="{ yes: lib.ssr, no: !lib.ssr }">
{{ lib.ssr ? '✓' : '✗' }}
</span>
</td>
</tr>
<tr>
<td class="feature-name">适用框架</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="text-value">{{ lib.framework }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="row-col">
<span class="framework-text">{{ lib.framework }}</span>
</div>
</div>
</div>
</div>
<Transition name="fade">
<div v-if="selectedLibrary" class="library-detail">
<div class="detail-header">
<span class="detail-icon">{{ selectedLibrary.icon }}</span>
<div class="detail-title">
<h5>{{ selectedLibrary.name }}</h5>
<p class="tagline">{{ selectedLibrary.tagline }}</p>
</div>
</div>
<div class="detail-grid">
<div class="detail-section compact">
<div class="section-title">🎯 适用场景</div>
<div class="section-content">{{ selectedLibrary.scenarios.join('、') }}</div>
</div>
<div class="detail-section compact">
<div class="section-title green"> 优点</div>
<div class="section-content">{{ selectedLibrary.pros.slice(0, 2).join('') }}</div>
</div>
<div class="detail-section compact">
<div class="section-title red"> 缺点</div>
<div class="section-content">{{ selectedLibrary.cons.slice(0, 2).join('') }}</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- 选中库的详细信息 -->
<div v-if="selectedLibrary" class="library-detail">
<div class="detail-header">
<span class="detail-icon">{{ selectedLibrary.icon }}</span>
<div class="detail-title">
<h5>{{ selectedLibrary.name }}</h5>
<span class="detail-tagline">{{ selectedLibrary.tagline }}</span>
</div>
<a :href="selectedLibrary.docsUrl" target="_blank" class="docs-link">
官方文档
</a>
</div>
<div class="detail-grid">
<div class="detail-section">
<h6>🎯 适用场景</h6>
<ul>
<li v-for="(scenario, index) in selectedLibrary.scenarios" :key="index">{{ scenario }}</li>
</ul>
</div>
<div class="detail-section">
<h6> 优势</h6>
<ul class="advantages">
<li v-for="(pro, index) in selectedLibrary.pros" :key="index">{{ pro }}</li>
</ul>
</div>
<div class="detail-section">
<h6> 劣势</h6>
<ul class="disadvantages">
<li v-for="(con, index) in selectedLibrary.cons" :key="index">{{ con }}</li>
</ul>
</div>
</div>
</div>
<!-- 决策流程图 -->
<div class="decision-flow">
<h5>🤔 如何选择</h5>
<div class="flow-chart">
<div class="flow-node start">开始</div>
<div class="flow-arrow"></div>
<div class="flow-node question">需要跨框架支持</div>
<div class="flow-arrow"> </div>
<div class="flow-node result">考虑 Pinia / Vuex</div>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>选择建议</strong>Vue 3 新项目推荐 PiniaReact 中小型项目推荐 Zustand大型企业级应用推荐 Redux Toolkit根据项目规模选择最合适的工具
</div>
</div>
</template>
@@ -123,22 +86,19 @@
<script setup>
import { ref, computed } from 'vue'
const selectedLib = ref('pinia')
const libraries = [
{
id: 'redux',
name: 'Redux',
icon: '🔄',
tagline: 'JavaScript 应用的可预测状态容器',
docsUrl: 'https://redux.js.org/',
scenarios: ['大型企业级应用', '需要严格数据流控制', '复杂的状态逻辑'],
pros: ['严格的数据流,易于调试', '强大的中间件生态', '时间旅行调试', '可预测的状态更新'],
cons: ['学习曲线陡峭', '样板代码较多', '小型项目可能过于复杂'],
codeExample: '// Redux 示例代码',
pros: ['严格的数据流,易于调试', '强大的中间件生态'],
cons: ['学习曲线陡峭', '样板代码较多'],
learningCurve: 80,
bundleSize: '7KB',
typescript: true,
devtools: true,
ssr: true,
framework: 'React/Vue/Angular'
},
{
@@ -146,16 +106,11 @@ const libraries = [
name: 'Vuex',
icon: '🌿',
tagline: 'Vue.js 的官方状态管理库',
docsUrl: 'https://vuex.vuejs.org/',
scenarios: ['Vue 2/3 中大型项目', '需要模块化管理状态', '团队成员熟悉 Vue 生态'],
pros: ['与 Vue 深度集成', '响应式系统', '模块化管理', '优秀的开发工具'],
cons: ['仅适用于 Vue', 'Vue 3 中被 Pinia 取代', '相对冗余的 API'],
codeExample: '// Vuex 示例代码',
pros: ['与 Vue 深度集成', '响应式系统'],
cons: ['仅适用于 Vue', 'Vue 3 中被 Pinia 取代'],
learningCurve: 60,
bundleSize: '4KB',
typescript: true,
devtools: true,
ssr: true,
framework: 'Vue Only'
},
{
@@ -163,54 +118,41 @@ const libraries = [
name: 'Pinia',
icon: '🍍',
tagline: '直观、类型安全、灵活的 Vue Store',
docsUrl: 'https://pinia.vuejs.org/',
scenarios: ['Vue 3 新项目首选', '重视 TypeScript 支持', '希望简化状态管理'],
pros: ['轻量级设计', '原生 TypeScript 支持', '组合式 API 风格', '代码更简洁'],
cons: ['Vue 3 专属', '生态系统相对年轻', '大型项目需自定义规范'],
codeExample: '// Pinia 示例代码',
pros: ['轻量级设计', '原生 TypeScript 支持'],
cons: ['Vue 3 专属', '生态系统相对年轻'],
learningCurve: 30,
bundleSize: '2KB',
typescript: true,
devtools: true,
ssr: true,
framework: 'Vue 3 Only'
},
{
id: 'zustand',
name: 'Zustand',
icon: '🐻',
tagline: '极简的 React 状态管理',
scenarios: ['React 中小型项目', '追求简洁 API', '不需要复杂中间件'],
pros: ['极简 API', '无需 Provider'],
cons: ['生态相对较小', '调试工具不如 Redux'],
learningCurve: 25,
bundleSize: '1KB',
framework: 'React Only'
}
]
const features = [
{ key: 'learningCurve', label: '学习曲线', icon: '📈' },
{ key: 'bundleSize', label: '包大小', icon: '📦' },
{ key: 'typescript', label: 'TypeScript', icon: '🔷' },
{ key: 'devtools', label: '开发工具', icon: '🛠️' },
{ key: 'ssr', label: 'SSR 支持', icon: '🚀' },
{ key: 'framework', label: '适用框架', icon: '🔧' }
]
const selectedLib = ref(null)
const selectedLibrary = computed(() => {
if (!selectedLib.value) return null
return libraries.find(lib => lib.id === selectedLib.value)
})
function selectLib(id) {
selectedLib.value = id
}
function getValue(lib, key) {
return lib[key]
}
function getCurveColor(value) {
if (value <= 30) return '#22c55e'
if (value <= 60) return '#f59e0b'
return '#ef4444'
if (value <= 30) return 'var(--vp-c-brand-1)'
if (value <= 60) return 'var(--vp-c-warning-1)'
return 'var(--vp-c-danger-1)'
}
function getCurveLabel(value) {
if (value <= 30) return '简单'
if (value <= 60) return '中等'
return '陡峭'
return '复杂'
}
function getSizeClass(size) {
@@ -223,98 +165,139 @@ function getSizeClass(size) {
<style scoped>
.state-management-comparison {
padding: 1rem;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 1.5rem;
text-align: center;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.demo-header h4 {
margin: 0 0 0.5rem;
color: var(--vp-c-text-1);
.demo-header .icon {
font-size: 1.25rem;
}
.hint {
margin: 0;
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
font-size: 0.85rem;
margin-left: 0.5rem;
}
.comparison-table-wrapper {
overflow-x: auto;
margin-bottom: 1.5rem;
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 6px;
}
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.demo-content {
margin-bottom: 1rem;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.comparison-table th,
.comparison-table td {
padding: 0.75rem;
border: 1px solid var(--vp-c-divider);
text-align: left;
}
.comparison-table th {
background: var(--vp-c-bg);
font-weight: 600;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.feature-col {
width: 120px;
.table-header {
display: grid;
grid-template-columns: 1.8fr 1.2fr 0.8fr 1.2fr;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.lib-col {
min-width: 120px;
.header-col {
padding: 0.5rem 0.75rem;
font-weight: 600;
font-size: 0.8rem;
border-right: 1px solid var(--vp-c-divider);
}
.header-col:last-child {
border-right: none;
}
.table-body {
display: flex;
flex-direction: column;
}
.table-row {
display: grid;
grid-template-columns: 1.8fr 1.2fr 0.8fr 1.2fr;
border-bottom: 1px solid var(--vp-c-divider);
cursor: pointer;
transition: background 0.2s;
}
.lib-col:hover,
.lib-col.selected {
background: rgba(102, 126, 234, 0.1);
.table-row:last-child {
border-bottom: none;
}
.lib-header {
.table-row:hover {
background: var(--vp-c-bg-soft);
}
.table-row.selected {
background: var(--vp-c-brand-soft);
}
.row-col {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
border-right: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
}
.row-col:last-child {
border-right: none;
}
.row-col.first {
font-weight: 500;
}
.lib-icon {
font-size: 1.2rem;
font-size: 1rem;
}
.lib-name {
font-weight: 500;
}
.feature-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
}
.feature-value {
text-align: center;
color: var(--vp-c-text-1);
}
.curve-bar {
width: 100%;
height: 6px;
flex: 1;
height: 5px;
background: var(--vp-c-divider);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.25rem;
min-width: 50px;
}
.curve-fill {
@@ -324,15 +307,15 @@ function getSizeClass(size) {
}
.curve-label {
font-size: 0.75rem;
font-size: 0.7rem;
color: var(--vp-c-text-2);
white-space: nowrap;
}
.size-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
@@ -351,194 +334,105 @@ function getSizeClass(size) {
color: #ef4444;
}
.boolean-badge {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
font-size: 0.8rem;
font-weight: 600;
}
.boolean-badge.yes {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.boolean-badge.no {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.text-value {
font-size: 0.85rem;
.framework-text {
color: var(--vp-c-text-2);
font-size: 0.75rem;
}
.library-detail {
margin-top: 1.5rem;
padding: 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.75rem;
}
.detail-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.detail-icon {
font-size: 2rem;
}
.detail-title {
flex: 1;
font-size: 1.5rem;
}
.detail-title h5 {
margin: 0 0 0.25rem;
font-size: 1.2rem;
margin: 0 0 0.2rem;
font-size: 1rem;
}
.detail-tagline {
.tagline {
margin: 0;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.docs-link {
padding: 0.5rem 1rem;
background: var(--vp-c-brand);
color: white;
border-radius: 6px;
text-decoration: none;
font-size: 0.85rem;
transition: opacity 0.2s;
}
.docs-link:hover {
opacity: 0.9;
font-size: 0.75rem;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.detail-section {
padding: 1rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.detail-section h6 {
margin: 0 0 0.75rem;
font-size: 1rem;
color: var(--vp-c-text-1);
}
.detail-section ul {
margin: 0;
padding-left: 1.2rem;
}
.detail-section li {
margin: 0.5rem 0;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.code-block {
background: var(--vp-c-bg);
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
}
.code-block pre {
margin: 0;
font-size: 0.85rem;
line-height: 1.6;
}
.code-block code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: var(--vp-c-text-1);
}
.decision-flow {
margin-top: 1.5rem;
padding: 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
.decision-flow h5 {
margin: 0 0 1rem;
text-align: center;
font-size: 1.1rem;
}
.flow-chart {
display: flex;
flex-direction: column;
align-items: center;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.flow-node {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
text-align: center;
}
.flow-node.start {
background: var(--vp-c-brand);
color: white;
}
.flow-node.question {
.detail-section.compact {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
padding: 0.5rem;
border-radius: 4px;
}
.flow-node.result {
background: rgba(34, 197, 94, 0.1);
.section-title {
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.3rem;
color: var(--vp-c-text-1);
}
.section-title.green {
color: #22c55e;
border: 2px solid #22c55e;
}
.flow-arrow {
font-size: 1.2rem;
.section-title.red {
color: #ef4444;
}
.section-content {
font-size: 0.75rem;
color: var(--vp-c-text-2);
line-height: 1.4;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 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);
}
.info-box .icon {
margin-right: 0.25rem;
}
@media (max-width: 768px) {
.comparison-table {
font-size: 0.8rem;
}
.comparison-table th,
.comparison-table td {
padding: 0.5rem;
}
.lib-icon {
font-size: 1rem;
.table-header,
.table-row {
grid-template-columns: 1.5fr 1fr 0.7fr 1fr;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
</style>
</style>
@@ -1,254 +1,88 @@
<template>
<div class="vuex-pinia-demo">
<div class="demo-header">
<h4>Vuex vs Pinia 深度对比</h4>
<p class="hint">体验 Vue 生态两种主流状态管理方案在语法类型支持和开发体验上的差异</p>
<span class="icon">🍍</span>
<span class="title">Vuex vs Pinia</span>
<span class="subtitle">Vue 状态管理的新老方案</span>
</div>
<div class="comparison-container">
<!-- Vuex 面板 -->
<div class="panel vuex-panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-icon">🌿</span>
<span>Vuex</span>
<div class="intro-text">
想象你在<span class="highlight">餐厅</span>点餐Vuex 就像传统餐厅需要分部门state/mutations/actions填写单据Pinia 就像快餐店直接在一个柜台组合式 API搞定所有流程
</div>
<div class="demo-content">
<div class="comparison-cards">
<div class="card vuex-card" :class="{ active: activeTab === 'vuex' }" @click="activeTab = 'vuex'">
<div class="card-header">
<span class="card-icon">🌿</span>
<span class="card-title">Vuex</span>
<span class="card-badge">经典</span>
</div>
<div class="card-body">
<div class="feature-list">
<div class="feature-item"> 选项式 API</div>
<div class="feature-item"> State / Mutations / Actions 分离</div>
<div class="feature-item"> 样板代码较多</div>
<div class="feature-item"> TypeScript 支持较弱</div>
</div>
</div>
<span class="panel-badge legacy">经典</span>
</div>
<div class="panel-content">
<!-- Store 定义 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">store/index.js</span>
<div class="card pinia-card" :class="{ active: activeTab === 'pinia' }" @click="activeTab = 'pinia'">
<div class="card-header">
<span class="card-icon">🍍</span>
<span class="card-title">Pinia</span>
<span class="card-badge recommended">推荐</span>
</div>
<div class="card-body">
<div class="feature-list">
<div class="feature-item"> 组合式 API</div>
<div class="feature-item"> 去除 Mutations简化代码</div>
<div class="feature-item"> 完美 TypeScript 支持</div>
<div class="feature-item"> 自动代码分割</div>
</div>
<div class="code-block" v-pre>
<pre><code>import { createStore } from 'vuex'
</div>
</div>
</div>
<Transition name="fade" mode="out-in">
<div v-if="activeTab === 'vuex'" key="vuex" class="code-example">
<div class="code-title">Vuex 代码示例</div>
<pre class="code-block"><code>// store/index.js
export default createStore({
// State
state: {
count: 0,
user: null
},
// Getters
getters: {
doubleCount: state => {
return (state?.count ?? 0) * 2
},
isLoggedIn: state => !!(state?.user)
},
// Mutations (同步)
state: { count: 0 },
mutations: {
INCREMENT(state) {
state.count = (state?.count ?? 0) + 1
},
SET_USER(state, user) {
state.user = user
state.count++
}
},
// Actions (可异步)
actions: {
incrementAsync({ commit }) {
setTimeout(() => {
commit('INCREMENT')
}, 1000)
},
async fetchUser({ commit }, userId) {
const response =
await fetch(\`/api/users/\${userId}\`)
const user = await response.json()
commit('SET_USER', user)
increment({ commit }) {
commit('INCREMENT')
}
}
})</code></pre>
</div>
</div>
<!-- 组件中使用 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">Counter.vue</span>
</div>
<div class="code-block" v-pre>
<pre><code>&lt;template&gt;
&lt;div&gt;
&lt;p&gt;Count: {{ count }}&lt;/p&gt;
&lt;p&gt;Double: {{ doubleCount }}&lt;/p&gt;
&lt;button @click="increment"&gt;+&lt;/button&gt;
&lt;button @click="incrementAsync"&gt;+ (async)&lt;/button&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;script&gt;
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'
export default {
computed: {
...mapState(['count']),
...mapGetters(['doubleCount'])
},
methods: {
...mapMutations(['INCREMENT']),
...mapActions(['incrementAsync']),
increment() {
this.INCREMENT()
}
}
}
&lt;/script&gt;</code></pre>
</div>
</div>
</div>
</div>
<!-- 中间对比区 -->
<div class="comparison-divider">
<div class="vs-badge">VS</div>
<div class="comparison-points">
<div class="point">
<span class="point-icon">📝</span>
<span class="point-text">Vuex 样板代码较多</span>
</div>
<div class="point">
<span class="point-icon">🔷</span>
<span class="point-text">TS 类型需额外定义</span>
</div>
<div class="point">
<span class="point-icon">⚙️</span>
<span class="point-text">选项式 API 风格</span>
</div>
</div>
</div>
<!-- Pinia 面板 -->
<div class="panel pinia-panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-icon">🍍</span>
<span>Pinia</span>
</div>
<span class="panel-badge modern">推荐</span>
</div>
<div class="panel-content">
<!-- Store 定义 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">stores/counter.js</span>
</div>
<div class="code-block" v-pre>
<pre><code>import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 方式1: 组合式 API (推荐)
<div v-else-if="activeTab === 'pinia'" key="pinia" class="code-example">
<div class="code-title">Pinia 代码示例</div>
<pre class="code-block"><code>// stores/counter.js
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0)
const user = ref(null)
// Getters
const doubleCount = computed(() => (count.value ?? 0) * 2)
const isLoggedIn = computed(() => !!user.value)
// Actions
function increment() {
count.value = (count.value ?? 0) + 1
count.value++
}
async function incrementAsync() {
await new Promise(r => setTimeout(r, 1000))
increment()
}
async function fetchUser(userId) {
const response = await fetch(\`/api/users/\${userId}\`)
user.value = await response.json()
}
return {
count, user,
doubleCount, isLoggedIn,
increment, incrementAsync, fetchUser
}
})
// 方式2: 选项式 API
export const useCounterStoreOld = defineStore('counter', {
state: () => ({
count: 0,
user: null
}),
getters: {
doubleCount: (state) => (state?.count ?? 0) * 2
},
actions: {
increment() {
this.count = (this?.count ?? 0) + 1
}
}
return { count, increment }
})</code></pre>
</div>
</div>
<!-- 组件中使用 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">Counter.vue</span>
</div>
<div class="code-block" v-pre>
<pre><code>&lt;template&gt;
&lt;div&gt;
&lt;p&gt;Count: {{ counter.count }}&lt;/p&gt;
&lt;p&gt;Double: {{ counter.doubleCount }}&lt;/p&gt;
&lt;button @click="counter.increment()"&gt;+&lt;/button&gt;
&lt;button @click="counter.incrementAsync()"&gt;+ (async)&lt;/button&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;script setup&gt;
import { useCounterStore } from '@/stores/counter'
// 直接获取 store 实例
const counter = useCounterStore()
// 或者直接解构(但会失去响应式!)
// const { count, increment } = useCounterStore() // ❌ 错误
// 正确解构方式:使用 storeToRefs
// import { storeToRefs } from 'pinia'
// const { count, doubleCount } = storeToRefs(counter)
// const { increment } = counter
&lt;/script&gt;</code></pre>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- 特性对比表格 -->
<div class="features-comparison">
<h5>🔄 核心特性对比</h5>
<div class="features-table">
<div class="feature-row header">
<div class="feature-name">特性</div>
<div class="feature-vuex">Vuex</div>
<div class="feature-pinia">Pinia</div>
</div>
<div v-for="feature in comparisonFeatures" :key="feature.name" class="feature-row">
<div class="feature-name">{{ feature.name }}</div>
<div class="feature-vuex" :class="{ check: feature.vuex === '✓', cross: feature.vuex === '✗' }">{{ feature.vuex }}</div>
<div class="feature-pinia" :class="{ check: feature.pinia === '✓', cross: feature.pinia === '✗' }">{{ feature.pinia }}</div>
</div>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>选择建议</strong>Vue 3 新项目直接用 Pinia语法更简洁TypeScript 支持更好老项目用 Vuex 也没问题但推荐逐步迁移到 Pinia
</div>
</div>
</template>
@@ -256,291 +90,185 @@ const counter = useCounterStore()
<script setup>
import { ref } from 'vue'
const comparisonFeatures = [
{ name: '组合式 API 支持', vuex: '✗', pinia: '✓' },
{ name: 'TypeScript 支持', vuex: '△', pinia: '✓' },
{ name: '无需 mutations', vuex: '✗', pinia: '✓' },
{ name: '自动模块化', vuex: '✗', pinia: '✓' },
{ name: '更轻量的体积', vuex: '✗', pinia: '✓' },
{ name: 'Vue 2 支持', vuex: '✓', pinia: '△' },
{ name: '开发工具支持', vuex: '✓', pinia: '✓' },
{ name: 'SSR 支持', vuex: '✓', pinia: '✓' }
]
const activeTab = ref('pinia')
</script>
<style scoped>
.vuex-pinia-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
.demo-header .icon {
font-size: 1.25rem;
}
.hint {
margin: 0;
font-size: 14px;
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.comparison-container {
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 6px;
}
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.demo-content {
margin-bottom: 1rem;
}
.comparison-cards {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
margin-bottom: 24px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 968px) {
.comparison-container {
grid-template-columns: 1fr;
}
.comparison-divider {
flex-direction: row !important;
padding: 12px !important;
}
.comparison-points {
flex-direction: row !important;
flex-wrap: wrap;
}
}
.panel {
.card {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.vuex-panel {
border-color: #42b883;
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.pinia-panel {
border-color: #ffd859;
.card.active {
border-color: var(--vp-c-brand);
box-shadow: 0 0 0 3px var(--vp-c-brand-delta);
}
.panel-header {
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--vp-c-bg-soft);
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
.card-icon {
font-size: 1.5rem;
}
.card-title {
font-weight: 600;
color: var(--vp-c-text-1);
font-size: 1rem;
flex: 1;
}
.panel-icon {
font-size: 24px;
}
.panel-badge {
padding: 4px 10px;
.card-badge {
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
font-size: 0.75rem;
font-weight: 500;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-2);
}
.panel-badge.legacy {
background: #e0f2fe;
color: #0369a1;
.card-badge.recommended {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
}
.panel-badge.modern {
background: #fef3c7;
color: #92400e;
.card-body {
padding: 0.5rem 0;
}
.panel-content {
padding: 16px;
}
.code-section {
margin-bottom: 16px;
}
.code-section:last-child {
margin-bottom: 0;
}
.code-header {
.feature-list {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 6px 10px;
flex-direction: column;
gap: 0.5rem;
}
.feature-item {
font-size: 0.85rem;
color: var(--vp-c-text-2);
padding: 0.4rem 0.6rem;
background: var(--vp-c-bg-soft);
border-radius: 4px;
}
.file-icon {
font-size: 14px;
.code-example {
background: var(--vp-c-bg);
border-radius: 6px;
padding: 1rem;
}
.file-name {
font-size: 12px;
color: var(--vp-c-text-2);
font-family: monospace;
.code-title {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.75rem;
color: var(--vp-c-text-1);
}
.code-block {
margin: 0;
padding: 1rem;
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
}
.code-block pre {
margin: 0;
padding: 12px;
overflow-x: auto;
}
.code-block code {
font-family: 'Fira Code', 'Monaco', monospace;
font-size: 11px;
line-height: 1.5;
font-family: monospace;
font-size: 0.8rem;
line-height: 1.6;
color: #d4d4d4;
}
.comparison-divider {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 0;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.vs-badge {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #42b883, #ffd859);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
color: white;
margin-bottom: 16px;
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.comparison-points {
display: flex;
flex-direction: column;
gap: 8px;
}
.point {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
.info-box {
background: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
font-size: 12px;
}
.point-icon {
font-size: 14px;
}
.point-text {
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.features-comparison {
margin-top: 24px;
padding: 20px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.features-comparison h5 {
margin: 0 0 16px 0;
color: var(--vp-c-text-1);
font-size: 16px;
}
.features-table {
display: flex;
flex-direction: column;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
overflow: hidden;
}
.feature-row {
display: grid;
grid-template-columns: 1fr 100px 100px;
border-bottom: 1px solid var(--vp-c-divider);
}
.feature-row:last-child {
border-bottom: none;
}
.feature-row.header {
background: var(--vp-c-bg-soft);
font-weight: 600;
}
.feature-name,
.feature-vuex,
.feature-pinia {
padding: 10px 12px;
font-size: 13px;
}
.feature-vuex,
.feature-pinia {
text-align: center;
border-left: 1px solid var(--vp-c-divider);
}
.feature-vuex.check,
.feature-pinia.check {
color: #22c55e;
font-weight: 600;
}
.feature-vuex.cross,
.feature-pinia.cross {
color: #ef4444;
}
.feature-vuex:not(.check):not(.cross),
.feature-pinia:not(.check):not(.cross) {
color: #f59e0b;
}
@media (max-width: 640px) {
.feature-row {
grid-template-columns: 1fr 60px 60px;
}
.feature-name,
.feature-vuex,
.feature-pinia {
padding: 8px;
font-size: 11px;
}
.info-box .icon {
margin-right: 0.25rem;
}
</style>
@@ -1,461 +1,198 @@
<template>
<div class="zustand-jotai-demo">
<div class="demo-header">
<h4>Zustand & Jotai轻量级状态管理</h4>
<p class="hint">探索现代 React 生态中最简洁的状态管理方案体验"钩子即状态"的开发模式</p>
<span class="icon">🐻</span>
<span class="title">Zustand & Jotai</span>
<span class="subtitle">React 轻量级状态管理</span>
</div>
<div class="demo-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-button"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<span class="tab-icon">{{ tab.icon }}</span>
<span class="tab-name">{{ tab.name }}</span>
</button>
<div class="intro-text">
想象你在<span class="highlight">便利店</span>工作Zustand 就像整个仓库统一管理Jotai 就像把商品拆成一个个小格子Atom每个格子独立管理按需取用
</div>
<!-- Zustand 演示 -->
<div v-if="activeTab === 'zustand'" class="tab-content">
<div class="split-view">
<!-- 左侧代码 -->
<div class="code-panel">
<div class="code-tabs">
<button
v-for="file in zustandFiles"
:key="file.name"
class="code-tab"
:class="{ active: activeZustandFile === file.name }"
@click="activeZustandFile = file.name"
>
{{ file.name }}
</button>
</div>
<div class="code-content">
<pre><code>{{ getZustandFileContent() }}</code></pre>
</div>
</div>
<!-- 右侧演示 -->
<div class="demo-panel">
<div class="panel-header">
<span class="header-icon">🐻</span>
<span class="header-title">Zustand Store Demo</span>
</div>
<div class="bear-counter">
<div class="bear-display">
<span v-for="n in Math.min(bears, 10)" :key="n" class="bear-icon">🐻</span>
<span v-if="bears > 10" class="more-bears">+{{ bears - 10 }}</span>
</div>
<div class="count-display">
<span class="count-number">{{ bears }}</span>
<span class="count-label">bears around here</span>
</div>
</div>
<div class="fish-tank">
<div class="tank-header">
<span>🐟 Fish Tank</span>
<span class="fish-count">{{ fishes }} fishes</span>
</div>
<div class="tank-content">
<div v-for="fish in Math.min(fishes, 8)" :key="fish" class="fish">
🐠
</div>
</div>
</div>
<div class="action-buttons">
<button class="action-btn primary" @click="addBear">
<span class="btn-icon"></span>
Add Bear
</button>
<button class="action-btn secondary" @click="addFish">
<span class="btn-icon">🐟</span>
Add Fish
</button>
<button class="action-btn danger" @click="eatFish">
<span class="btn-icon">🍽</span>
Eat Fish ({{ fishes }})
</button>
<button class="action-btn warning" @click="removeAllBears">
<span class="btn-icon">🗑</span>
Remove All
</button>
</div>
<div class="async-demo">
<div class="async-header">Async Action Demo</div>
<div class="async-controls">
<button class="async-btn" :disabled="loading" @click="fetchBears">
{{ loading ? ' Fetching...' : '🌐 Fetch Bears from API' }}
</button>
</div>
<div v-if="apiResponse" class="api-response">
<pre>{{ apiResponse }}</pre>
</div>
</div>
</div>
<div class="demo-content">
<div class="demo-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-button"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<span class="tab-icon">{{ tab.icon }}</span>
<span class="tab-name">{{ tab.name }}</span>
</button>
</div>
</div>
<!-- Jotai 演示 -->
<div v-if="activeTab === 'jotai'" class="tab-content">
<!-- Jotai 内容类似结构 -->
<div class="split-view">
<div class="code-panel">
<!-- Jotai 代码示例 -->
<div class="code-tabs">
<button class="code-tab active">atoms/counter.js</button>
<Transition name="fade" mode="out-in">
<div :key="activeTab" class="tab-content">
<div v-if="activeTab === 'zustand'" class="feature-showcase">
<div class="feature-card">
<span class="feature-icon">📦</span>
<span class="feature-title">单一 Store</span>
<span class="feature-desc">所有状态集中管理</span>
</div>
<div class="feature-card">
<span class="feature-icon"></span>
<span class="feature-title">极简 API</span>
<span class="feature-desc">无需 Provider 包裹</span>
</div>
<div class="feature-card">
<span class="feature-icon">🎯</span>
<span class="feature-title">细粒度订阅</span>
<span class="feature-desc">只重渲染需要的组件</span>
</div>
</div>
<div class="code-content">
<pre><code>import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
// 基础原子
<div v-if="activeTab === 'zustand'" class="code-example">
<pre class="code-block"><code>// Zustand Store
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({
bears: state.bears + 1
}))
}))
// 在组件中使用
function BearCounter() {
const bears = useStore((state) => state.bears)
return <div>{bears} bears around here</div>
}</code></pre>
</div>
<div v-if="activeTab === 'jotai'" class="feature-showcase">
<div class="feature-card">
<span class="feature-icon"></span>
<span class="feature-title">原子化</span>
<span class="feature-desc">状态拆分成独立 Atom</span>
</div>
<div class="feature-card">
<span class="feature-icon">🔗</span>
<span class="feature-title">自动依赖</span>
<span class="feature-desc">派生状态自动追踪</span>
</div>
<div class="feature-card">
<span class="feature-icon">📝</span>
<span class="feature-title">TypeScript</span>
<span class="feature-desc">原生类型支持</span>
</div>
</div>
<div v-if="activeTab === 'jotai'" class="code-example">
<pre class="code-block"><code>// Jotai Atom
import { atom } from 'jotai'
// 基础 Atom
const countAtom = atom(0)
// 派生原子 - 自动追踪依赖
// 派生 Atom
const doubleAtom = atom((get) => get(countAtom) * 2)
const isEvenAtom = atom((get) => get(countAtom) % 2 === 0)
// 可写派生原子
const countAndDoubleAtom = atom(
(get) => ({
count: get(countAtom),
double: get(doubleAtom)
}),
(get, set, newCount) => {
set(countAtom, newCount)
}
)
// 原子家族 - 动态创建原子
const todoAtomFamily = atomFamily((id) =>
atom({ id, text: '', completed: false })
)
// 异步原子
const userAtom = atom(null)
const fetchUserAtom = atom(
(get) => get(userAtom),
async (get, set, userId) => {
const response = await fetch(\`/api/users/\${userId}\`)
const user = await response.json()
set(userAtom, user)
}
)
export {
countAtom,
doubleAtom,
isEvenAtom,
countAndDoubleAtom,
todoAtomFamily,
fetchUserAtom
// 在组件中使用
function Counter() {
const [count, setCount] = useAtom(countAtom)
const [double] = useAtom(doubleAtom)
return (
&lt;div&gt;
&lt;span&gt;{count}&lt;/span&gt;
&lt;span&gt;{double}&lt;/span&gt;
&lt;/div&gt;
)
}</code></pre>
</div>
</div>
<div class="demo-panel">
<div class="panel-header">
<span class="header-icon">⚛️</span>
<span class="header-title">Jotai Atom Demo</span>
</div>
<div class="atom-demo">
<div class="atom-visualization">
<div class="atom-core" :class="{ active: count > 0 }">
<div class="atom-nucleus">
<span class="nucleus-label">countAtom</span>
<span class="nucleus-value">{{ count }}</span>
</div>
<div class="electron-orbits">
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="orbit orbit-3"></div>
</div>
</div>
<div class="derived-atoms">
<div class="derived-atom" :class="{ active: double > 0 }">
<div class="atom-label">doubleAtom</div>
<div class="atom-value">{{ double }}</div>
<div class="atom-formula">count × 2</div>
</div>
<div class="derived-atom" :class="{ active: true }">
<div class="atom-label">isEvenAtom</div>
<div class="atom-value" :class="{ even: isEven }">{{ isEven ? 'YES' : 'NO' }}</div>
<div class="atom-formula">count % 2 === 0</div>
</div>
</div>
</div>
<div class="atom-controls">
<div class="control-group">
<button class="control-btn" @click="increment">
<span class="btn-icon"></span>
increment()
</button>
<button class="control-btn" @click="decrement">
<span class="btn-icon"></span>
decrement()
</button>
<button class="control-btn" @click="reset">
<span class="btn-icon">🔄</span>
reset()
</button>
</div>
<div class="control-group">
<button class="control-btn async" :disabled="loading" @click="fetchRandom">
<span class="btn-icon">{{ loading ? '⏳' : '🎲' }}</span>
{{ loading ? 'Fetching...' : 'Fetch Random (Async)' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- 底部说明 -->
<div class="info-section">
<div class="info-card">
<h5>🎯 核心概念</h5>
<ul>
<li><strong>Atom</strong>: 状态的基本单元,可读写</li>
<li><strong>Derived Atom</strong>: 派生状态,自动追踪依赖</li>
<li><strong>Atom Family</strong>: 动态创建原子集合</li>
</ul>
</div>
<div class="info-card">
<h5>⚡ 与 Redux/MobX 对比</h5>
<ul>
<li>更细粒度的状态管理</li>
<li>天然支持 TypeScript</li>
<li>不需要 Provider 包裹</li>
<li>与 React Suspense 配合良好</li>
</ul>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>选择建议</strong>Zustand 适合中小项目API 简洁直观Jotai 适合需要细粒度控制的场景状态更模块化两个都支持 TypeScript不需要 Provider
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref } from 'vue'
// Tab 切换
const activeTab = ref('zustand')
const tabs = [
{ id: 'zustand', name: 'Zustand', icon: '🐻' },
{ id: 'jotai', name: 'Jotai', icon: '⚛️' }
]
// Zustand 状态
const bears = ref(0)
const fishes = ref(5)
const loading = ref(false)
const apiResponse = ref('')
// 计算属性(模拟派生状态)
const double = computed(() => bears.value * 2)
const isEven = computed(() => bears.value % 2 === 0)
// 方法
const increment = () => {
bears.value++
}
const decrement = () => {
if (bears.value > 0) bears.value--
}
const reset = () => {
bears.value = 0
fishes.value = 5
}
const addBear = () => {
bears.value++
}
const addFish = () => {
fishes.value++
}
const eatFish = () => {
if (fishes.value > 0) {
fishes.value--
bears.value += 0.1
}
}
const removeAllBears = () => {
bears.value = 0
}
const fetchBears = async () => {
loading.value = true
apiResponse.value = ''
await new Promise(resolve => setTimeout(resolve, 1500))
bears.value = Math.floor(Math.random() * 10) + 1
apiResponse.value = `{\n "status": "success",\n "data": {\n "bears": ${bears.value},\n "message": "Bears fetched successfully"\n }\n}`
loading.value = false
}
const fetchRandom = async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
bears.value = Math.floor(Math.random() * 100)
loading.value = false
}
// 文件切换
const zustandFiles = [
{ name: 'store.js', content: `import { create } from 'zustand'
const useStore = create((set, get) => ({
// State
bears: 0,
fishes: 5,
// Computed (in component using selectors)
// const doubleBears = useStore(state => state.bears * 2)
// Actions
increasePopulation: () =>
set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
eatFish: () => set((state) => ({
fishes: state.fishes - 1,
bears: state.bears + 0.1
})),
// Async action
fetchBears: async () => {
try {
const response = await fetch('/api/bears')
const data = await response.json()
if (data && typeof data.count === 'number') {
set({ bears: data.count })
}
} catch (error) {
console.error('Failed to fetch bears:', error)
}
},
// Get current state
getState: () => get()
}))
export default useStore` },
{ name: 'Component.jsx', content: `import useStore from './store'
// Component using Zustand
function BearCounter() {
// Select only what you need - fine-grained updates
const bears = useStore((state) => state.bears)
const increase = useStore((state) => state.increasePopulation)
return (
<div>
<h1>{bears} bears around here...</h1>
<button onClick={increase}>Add bear</button>
</div>
)
}
// Async action usage
function BearFetcher() {
const fetchBears = useStore((state) => state.fetchBears)
const bears = useStore((state) => state.bears)
return (
<div>
<p>Bears: {bears}</p>
<button onClick={fetchBears}>
Fetch Bears
</button>
</div>
)
}
// Multiple state selections
function FishTank() {
const { fishes, bears, eatFish } = useStore(
(state) => ({
fishes: state.fishes,
bears: state.bears,
eatFish: state.eatFish
})
)
return (
<div>
<p>Fishes: {fishes}</p>
<p>Bears: {bears}</p>
<button onClick={eatFish}>Eat Fish</button>
</div>
)
}` }
]
const activeZustandFile = ref('store.js')
const getZustandFileContent = () => {
return zustandFiles.find(f => f.name === activeZustandFile.value)?.content || ''
}
</script>
<style scoped>
.zustand-jotai-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
max-height: 600px;
overflow-y: auto;
}
.demo-header {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
.demo-header .icon {
font-size: 1.25rem;
}
.hint {
margin: 0;
font-size: 14px;
.demo-header .title {
font-weight: bold;
font-size: 1rem;
}
.demo-header .subtitle {
color: var(--vp-c-text-2);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.intro-text {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 6px;
}
.intro-text .highlight {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.demo-content {
margin-bottom: 1rem;
}
.demo-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
gap: 0.75rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--vp-c-divider);
padding-bottom: 12px;
padding-bottom: 0.75rem;
}
.tab-button {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
font-size: 14px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
@@ -473,351 +210,99 @@ const getZustandFileContent = () => {
}
.tab-icon {
font-size: 20px;
font-size: 1rem;
}
.tab-name {
font-weight: 500;
}
.split-view {
.tab-content {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 1rem;
}
.feature-showcase {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 968px) {
.split-view {
grid-template-columns: 1fr;
}
}
.code-panel {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.code-tabs {
display: flex;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.code-tab {
padding: 10px 16px;
background: transparent;
border: none;
font-size: 13px;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
}
.code-tab:hover {
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.code-tab.active {
color: var(--vp-c-brand);
background: var(--vp-c-bg);
border-bottom: 2px solid var(--vp-c-brand);
}
.code-content {
max-height: 600px;
overflow: auto;
}
.code-content pre {
margin: 0;
padding: 16px;
font-family: 'Fira Code', monospace;
font-size: 12px;
line-height: 1.6;
color: #d4d4d4;
background: #1e1e1e;
}
.demo-panel {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
}
.panel-header {
.feature-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--vp-c-divider);
}
.header-icon {
font-size: 24px;
}
.header-title {
font-weight: 600;
font-size: 16px;
color: var(--vp-c-text-1);
}
.bear-counter {
gap: 0.5rem;
padding: 1rem;
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.bear-display {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
margin-bottom: 12px;
min-height: 40px;
}
.bear-icon {
font-size: 24px;
}
.more-bears {
font-size: 14px;
color: var(--vp-c-text-2);
align-self: center;
}
.count-display {
border-radius: 8px;
text-align: center;
}
.count-number {
font-size: 36px;
font-weight: 700;
color: var(--vp-c-brand);
.feature-icon {
font-size: 2rem;
}
.count-label {
display: block;
font-size: 14px;
color: var(--vp-c-text-2);
margin-top: 4px;
}
.fish-tank {
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 100%);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.tank-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.feature-title {
font-weight: 600;
color: #0369a1;
font-size: 0.9rem;
color: var(--vp-c-text-1);
}
.tank-content {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
min-height: 50px;
}
.fish {
font-size: 28px;
animation: swim 2s ease-in-out infinite;
}
.fish:nth-child(2) { animation-delay: 0.3s; }
.fish:nth-child(3) { animation-delay: 0.6s; }
.fish:nth-child(4) { animation-delay: 0.9s; }
@keyframes swim {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-3px) rotate(-2deg); }
75% { transform: translateY(3px) rotate(2deg); }
}
.action-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.action-btn.primary {
background: var(--vp-c-brand);
color: white;
}
.action-btn.primary:hover {
background: var(--vp-c-brand-dark);
}
.action-btn.secondary {
background: #0ea5e9;
color: white;
}
.action-btn.secondary:hover {
background: #0284c7;
}
.action-btn.danger {
background: #ef4444;
color: white;
}
.action-btn.danger:hover {
background: #dc2626;
}
.action-btn.warning {
background: #f59e0b;
color: white;
}
.action-btn.warning:hover {
background: #d97706;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-icon {
font-size: 14px;
}
.async-demo {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 12px;
}
.async-header {
font-size: 12px;
font-weight: 600;
.feature-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-bottom: 8px;
}
.async-controls {
margin-bottom: 8px;
}
.async-btn {
width: 100%;
padding: 10px;
background: #8b5cf6;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.async-btn:hover:not(:disabled) {
background: #7c3aed;
}
.async-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.api-response {
.code-example {
background: #1e1e1e;
border-radius: 6px;
padding: 10px;
overflow: auto;
padding: 1rem;
overflow-x: auto;
}
.api-response pre {
.code-block {
margin: 0;
font-family: 'Fira Code', monospace;
font-size: 11px;
line-height: 1.5;
}
.code-block code {
font-family: monospace;
font-size: 0.75rem;
line-height: 1.6;
color: #d4d4d4;
}
.info-section {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.info-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.info-card h5 {
margin: 0 0 10px 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.info-card ul {
margin: 0;
padding-left: 18px;
}
.info-card li {
font-size: 12px;
.info-box {
background: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin: 5px 0;
line-height: 1.5;
}
.info-card li strong {
color: var(--vp-c-text-1);
.info-box .icon {
margin-right: 0.25rem;
}
@media (max-width: 768px) {
.action-buttons {
grid-template-columns: 1fr;
.demo-tabs {
flex-direction: column;
}
.atom-core {
transform: scale(0.9);
.feature-showcase {
grid-template-columns: 1fr;
}
}
</style>