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:
+253
-158
@@ -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>
|
||||
|
||||
+156
-458
@@ -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>
|
||||
|
||||
+161
-475
@@ -1,121 +1,61 @@
|
||||
<template>
|
||||
<div class="mobx-reactivity-demo">
|
||||
<div class="demo-header">
|
||||
<h4>MobX 响应式原理演示</h4>
|
||||
<p class="hint">体验 MobX 的自动依赖追踪机制,理解 Observable、Action 和 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>
|
||||
|
||||
+194
-445
@@ -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>
|
||||
|
||||
+169
-407
@@ -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>
|
||||
|
||||
+249
-355
@@ -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 新项目推荐 Pinia,React 中小型项目推荐 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>
|
||||
|
||||
+163
-435
@@ -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><template>
|
||||
<div>
|
||||
<p>Count: {{ count }}</p>
|
||||
<p>Double: {{ doubleCount }}</p>
|
||||
<button @click="increment">+</button>
|
||||
<button @click="incrementAsync">+ (async)</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapState(['count']),
|
||||
...mapGetters(['doubleCount'])
|
||||
},
|
||||
methods: {
|
||||
...mapMutations(['INCREMENT']),
|
||||
...mapActions(['incrementAsync']),
|
||||
|
||||
increment() {
|
||||
this.INCREMENT()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script></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><template>
|
||||
<div>
|
||||
<p>Count: {{ counter.count }}</p>
|
||||
<p>Double: {{ counter.doubleCount }}</p>
|
||||
<button @click="counter.increment()">+</button>
|
||||
<button @click="counter.incrementAsync()">+ (async)</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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
|
||||
</script></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>
|
||||
|
||||
+186
-701
@@ -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 (
|
||||
<div>
|
||||
<span>{count}</span>
|
||||
<span>{double}</span>
|
||||
</div>
|
||||
)
|
||||
}</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>
|
||||
|
||||
Reference in New Issue
Block a user