起因
团队主要写 React,但接手一个 Vue 2 老项目。Vue 2 的 Options API(data/methods/
computed 分块)跟 React hooks 风格完全不一样,写起来不顺手。
升级到 Vue 3 + Composition API 后语义跟 React 接近多了,迁移
成本下降。下面记录我的"React 视角看 Vue 3"。
script setup 语法
Vue 3.2+ 的 <script setup> 是 Composition API 的语法糖:
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
watch(count, (newVal, oldVal) => {
console.log(`count ${oldVal} → ${newVal}`)
})
onMounted(() => {
console.log('mounted')
})
</script>
<template>
<button @click="increment">{{ count }} (doubled: {{ doubled }})</button>
</template>
对比 React:
const [count, setCount] = useState(0)
const doubled = useMemo(() => count * 2, [count])
useEffect(() => {
console.log(`count: ${count}`)
}, [count])
useEffect(() => {
console.log('mounted')
}, [])
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
Vue 的 ref(0) ≈ React 的 useState。访问值要 .value(template 里
自动 unwrap)。computed ≈ useMemo。watch ≈ useEffect with deps。
ref vs reactive
import { ref, reactive } from 'vue'
const counter = ref(0)
console.log(counter.value)
counter.value = 10
const state = reactive({ count: 0, name: '' })
console.log(state.count)
state.count = 10
ref 适合基础类型;reactive 适合对象。
重要:reactive 不能解构(会失去响应性):
const state = reactive({ count: 0 })
const { count } = state // ❌ count 不再响应
要解构必须 toRefs:
import { toRefs } from 'vue'
const { count } = toRefs(state) // ✅
为了避免这个坑,我直接全用 ref,复杂状态用 ref({ count: 0, name: '' })。
组合函数(custom hook 等价物)
抽象逻辑成 useXxx:
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
const inc = () => count.value++
const dec = () => count.value--
return { count, doubled, inc, dec }
}
用:
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, doubled, inc } = useCounter(10)
</script>
完全等价于 React custom hook。
defineProps / defineEmits(类型化 props / events)
<script setup lang="ts">
const props = defineProps<{
user: { id: string; name: string }
count?: number
}>()
const emit = defineEmits<{
(e: 'increment', value: number): void
(e: 'reset'): void
}>()
function bump() {
emit('increment', (props.count ?? 0) + 1)
}
</script>
<template>
<button @click="bump">{{ user.name }}: {{ count }}</button>
</template>
父组件:
<MyButton :user="me" :count="5" @increment="handleInc" @reset="handleReset" />
类型完全跟着传。
v-model 双向绑定
React 强迫你手写 value={x} onChange={...}。Vue 的 v-model 是糖:
<!-- 父 -->
<MyInput v-model="email" />
<!-- 子 -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', $event.target.value)">
</template>
简化版用 defineModel(Vue 3.4+):
<script setup>
const email = defineModel<string>()
</script>
<template>
<input v-model="email">
</template>
3 行干完。React 里至少 8-10 行。
Pinia:Vue 的状态管理
npm i pinia
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isLoggedIn = computed(() => user.value !== null)
async function login(email: string, pw: string) {
const r = await fetch('/api/login', { method: 'POST', body: ... })
user.value = await r.json()
}
function logout() {
user.value = null
}
return { user, isLoggedIn, login, logout }
})
<script setup>
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
function onSubmit() {
auth.login(email.value, password.value)
}
</script>
跟 Zustand 类似(但写法是 composition function 而非 set/get)。
路由
npm i vue-router@4
// router.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/posts/:id', component: () => import('./views/Post.vue') },
],
})
export default router
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute() // /posts/42 → route.params.id = '42'
const router = useRouter()
function goHome() { router.push('/') }
</script>
完全镜像 React Router 的 useParams / useNavigate。
SSR / SSG: Nuxt
类似 React 的 Next.js,Vue 的元框架是 Nuxt:
npx nuxi@latest init my-app
文件系统路由、自动 import、SSR、API routes 等都是 Nuxt 的事,开箱即用。
Vue 比 React 强的几点
- template + script + style 三段式:scoped CSS 内置,
<style scoped>自动加 hash,组件样式不互相污染 - 响应式不需要写依赖数组:
watch自动追踪。ReactuseEffect
写错 deps 是常见 bug。 - v-for, v-if, v-show:模板里写就够,不像 JSX 强
.map()/&& - defineModel 简化双向绑定
React 比 Vue 强的几点
- 更大生态:Next.js、Remix、库选择更多
- TypeScript 更原生:Vue 也支持但偶尔有些边角不丝滑
- 更广泛的工作机会
效果
我个人混着写下来:
- Vue 项目代码量比同等 React 少 20-30%
- 模板比 JSX 更适合"以视图为中心"的页面(marketing / 表单密集)
- 复杂状态 / 复杂交互 React 生态成熟度高
- 个人选择:marketing / 后台管理 → Vue + Nuxt;
复杂 web app → React
踩过的坑
-
reactive 解构丢响应性:上面说过。新人最容易踩。
-
template 里 ref 自动 unwrap:
```vue
const count = ref(0)
{{ count }}
``
习惯之后没事,刚转过来时反复{{ count.value }}多写.value`。
-
v-html直接 innerHTML:XSS 隐患。任何用户输入永远不用 v-html。 -
Vue 3 跟 Vue 2 写法不通用:老 Vue 2 项目升 3 是 breaking change,
不是 React 16 → 17 那种平滑升级。 -
Pinia store 在 setup() 外用要 createPinia 先注入:
main.ts必须app.use(createPinia()),否则报 "no active pinia"。
登录后参与评论。