353 lines
7.5 KiB
Vue
353 lines
7.5 KiB
Vue
|
|
<!--
|
|||
|
|
VueReactComparisonDemo.vue
|
|||
|
|
用可视化方式对比 Vue vs React:语法、状态更新、渲染心智模型
|
|||
|
|
-->
|
|||
|
|
<template>
|
|||
|
|
<div class="vr-demo">
|
|||
|
|
<div class="header">
|
|||
|
|
<div class="title">Vue vs React:它们哪里像?哪里不一样?</div>
|
|||
|
|
<div class="subtitle">
|
|||
|
|
选一个标签页,然后点“+1”,看看背后发生了什么(示意)。
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="tabs">
|
|||
|
|
<button
|
|||
|
|
v-for="t in tabs"
|
|||
|
|
:key="t.key"
|
|||
|
|
class="tab"
|
|||
|
|
:class="{ active: currentTab === t.key }"
|
|||
|
|
@click="currentTab = t.key"
|
|||
|
|
>
|
|||
|
|
{{ t.label }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="grid">
|
|||
|
|
<div class="panel">
|
|||
|
|
<div class="panel-title">Vue</div>
|
|||
|
|
<div class="preview">
|
|||
|
|
<div class="row">
|
|||
|
|
count: <strong>{{ count }}</strong>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn vue" @click="inc('vue')">+1</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="code">
|
|||
|
|
<div class="code-title">典型写法(示意)</div>
|
|||
|
|
<pre><code class="language-vue">{{ vueCode }}</code></pre>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="panel">
|
|||
|
|
<div class="panel-title">React</div>
|
|||
|
|
<div class="preview">
|
|||
|
|
<div class="row">
|
|||
|
|
count: <strong>{{ count }}</strong>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn react" @click="inc('react')">+1</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="code">
|
|||
|
|
<div class="code-title">典型写法(示意)</div>
|
|||
|
|
<pre><code class="language-jsx">{{ reactCode }}</code></pre>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="what">
|
|||
|
|
<div class="what-title">点击 “+1” 时发生了什么?</div>
|
|||
|
|
<div class="steps">
|
|||
|
|
<div
|
|||
|
|
v-for="(s, idx) in steps"
|
|||
|
|
:key="idx"
|
|||
|
|
class="step"
|
|||
|
|
:class="{ highlight: idx === lastStepIndex }"
|
|||
|
|
>
|
|||
|
|
<span class="num">{{ idx + 1 }}</span>
|
|||
|
|
<span class="text">{{ s }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="note">
|
|||
|
|
说明:这是为了建立心智模型的<strong>简化示意</strong>,真实框架内部更复杂。
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { ref, computed } from 'vue'
|
|||
|
|
|
|||
|
|
const tabs = [
|
|||
|
|
{ key: 'syntax', label: '语法(Template vs JSX)' },
|
|||
|
|
{ key: 'state', label: '状态更新(ref vs useState)' },
|
|||
|
|
{ key: 'render', label: '渲染心智模型' }
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const currentTab = ref('syntax')
|
|||
|
|
const count = ref(1)
|
|||
|
|
const lastClicked = ref('vue')
|
|||
|
|
const lastStepIndex = ref(-1)
|
|||
|
|
|
|||
|
|
const inc = (who) => {
|
|||
|
|
lastClicked.value = who
|
|||
|
|
count.value += 1
|
|||
|
|
// 简单动画:把最后一步高亮一下
|
|||
|
|
lastStepIndex.value = 2
|
|||
|
|
setTimeout(() => (lastStepIndex.value = -1), 600)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const vueCode = computed(() => {
|
|||
|
|
if (currentTab.value === 'syntax') {
|
|||
|
|
// NOTE: Avoid literal closing script tag inside a script block (HTML parser would terminate early).
|
|||
|
|
return [
|
|||
|
|
`<template>`,
|
|||
|
|
` <button @click="count++">+1</button>`,
|
|||
|
|
` <div>count: {{ count }}</div>`,
|
|||
|
|
`</template>`,
|
|||
|
|
``,
|
|||
|
|
`<script setup>`,
|
|||
|
|
`import { ref } from 'vue'`,
|
|||
|
|
`const count = ref(1)`,
|
|||
|
|
`</scr` + `ipt>`
|
|||
|
|
].join('\n')
|
|||
|
|
}
|
|||
|
|
if (currentTab.value === 'state') {
|
|||
|
|
return `import { ref } from 'vue'
|
|||
|
|
|
|||
|
|
const count = ref(1)
|
|||
|
|
|
|||
|
|
function inc() {
|
|||
|
|
count.value++
|
|||
|
|
}`
|
|||
|
|
}
|
|||
|
|
return `// Vue:响应式系统会“追踪依赖”
|
|||
|
|
// count 变了 -> 用到 count 的地方自动更新`
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const reactCode = computed(() => {
|
|||
|
|
if (currentTab.value === 'syntax') {
|
|||
|
|
return `function App() {
|
|||
|
|
const [count, setCount] = useState(1)
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<button onClick={() => setCount(count + 1)}>+1</button>
|
|||
|
|
<div>count: {count}</div>
|
|||
|
|
</>
|
|||
|
|
)
|
|||
|
|
}`
|
|||
|
|
}
|
|||
|
|
if (currentTab.value === 'state') {
|
|||
|
|
return `const [count, setCount] = useState(1)
|
|||
|
|
|
|||
|
|
function inc() {
|
|||
|
|
setCount(count + 1)
|
|||
|
|
}`
|
|||
|
|
}
|
|||
|
|
return `// React:state 变了 -> 组件函数重新执行(重新渲染)
|
|||
|
|
// 然后 React 决定哪些 DOM 需要更新`
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const steps = computed(() => {
|
|||
|
|
if (currentTab.value === 'syntax') {
|
|||
|
|
return [
|
|||
|
|
'你写 UI 的方式:Vue 常用 Template;React 常用 JSX',
|
|||
|
|
'点击按钮触发事件处理函数',
|
|||
|
|
'count 更新后,界面显示跟着变'
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
if (currentTab.value === 'state') {
|
|||
|
|
return [
|
|||
|
|
'Vue:用 ref/ reactive 保存状态;React:用 useState 保存状态',
|
|||
|
|
lastClicked.value === 'vue'
|
|||
|
|
? '你修改了 count.value'
|
|||
|
|
: '你调用 setCount(...)',
|
|||
|
|
'框架把变化反映到界面'
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
return [
|
|||
|
|
'Vue:更偏“依赖追踪”,谁用到了 count,就更新谁',
|
|||
|
|
'React:更偏“重新执行组件函数”,得到新的 UI 描述',
|
|||
|
|
'最终都会只更新需要变化的 DOM(避免全量重画)'
|
|||
|
|
]
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.vr-demo {
|
|||
|
|
border: 1px solid var(--vp-c-divider);
|
|||
|
|
background: var(--vp-c-bg-soft);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 1.5rem;
|
|||
|
|
margin: 1.5rem 0;
|
|||
|
|
font-family: var(--vp-font-family-base);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header {
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title {
|
|||
|
|
font-weight: 700;
|
|||
|
|
font-size: 1.05rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.subtitle {
|
|||
|
|
color: var(--vp-c-text-2);
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
margin-bottom: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab {
|
|||
|
|
border: 1px solid var(--vp-c-divider);
|
|||
|
|
background: var(--vp-c-bg);
|
|||
|
|
padding: 0.35rem 0.75rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tab.active {
|
|||
|
|
border-color: #3b82f6;
|
|||
|
|
color: #1d4ed8;
|
|||
|
|
background: rgba(59, 130, 246, 0.12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|||
|
|
gap: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel {
|
|||
|
|
border: 1px solid var(--vp-c-divider);
|
|||
|
|
background: var(--vp-c-bg);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel-title {
|
|||
|
|
font-weight: 800;
|
|||
|
|
margin-bottom: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.preview {
|
|||
|
|
border: 1px dashed var(--vp-c-divider);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 0.9rem;
|
|||
|
|
background: var(--vp-c-bg-soft);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.row {
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
border: none;
|
|||
|
|
padding: 0.45rem 0.8rem;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
color: #fff;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-weight: 700;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn.vue {
|
|||
|
|
background: #22c55e;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn.react {
|
|||
|
|
background: #0ea5e9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.code {
|
|||
|
|
margin-top: 0.9rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.code-title {
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
color: var(--vp-c-text-2);
|
|||
|
|
margin-bottom: 0.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pre {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0.75rem;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
border: 1px solid var(--vp-c-divider);
|
|||
|
|
background: var(--vp-c-bg-soft);
|
|||
|
|
overflow: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
code {
|
|||
|
|
font-family: var(--vp-font-family-mono);
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
color: var(--vp-c-text-1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.what {
|
|||
|
|
margin-top: 1rem;
|
|||
|
|
border-top: 1px dashed var(--vp-c-divider);
|
|||
|
|
padding-top: 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.what-title {
|
|||
|
|
font-weight: 700;
|
|||
|
|
margin-bottom: 0.6rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.steps {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.step {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.6rem;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
border: 1px solid var(--vp-c-divider);
|
|||
|
|
background: var(--vp-c-bg);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 0.55rem 0.65rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.step.highlight {
|
|||
|
|
border-color: rgba(34, 197, 94, 0.5);
|
|||
|
|
background: rgba(34, 197, 94, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.num {
|
|||
|
|
width: 1.6rem;
|
|||
|
|
height: 1.6rem;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: rgba(99, 102, 241, 0.15);
|
|||
|
|
color: #4338ca;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-weight: 800;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.text {
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
color: var(--vp-c-text-2);
|
|||
|
|
line-height: 1.35;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.note {
|
|||
|
|
margin-top: 0.7rem;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
color: var(--vp-c-text-2);
|
|||
|
|
}
|
|||
|
|
</style>
|