横向对比 / 闯关模式
React vs Vue 真实开发任务对比
按列表、搜索、弹窗、请求、路由权限、状态管理和工程化逐项横向对比。
一句话:真正有用的 React vs Vue 对比,不是只说“哪个更好”,而是把同一个用户管理后台里的每个语法、每个开发要点都摆在一起看。
第 9 篇 / 共 12 篇。
本篇学完你会:按“语法 -> 组件 -> 状态 -> 请求 -> 路由 -> 权限 -> 工程化”的顺序,系统对比 React 19 和 Vue 3.5 在真实业务里的写法差异。
1. 对比规则
我们只用一个案例:用户管理后台。
功能包含:
用户列表
搜索筛选
新增/编辑弹窗
删除确认
表单校验
API 请求
Loading / Error 状态
路由跳转
全局状态
权限控制
工程化目录组织比较标准不是“谁语法少”,而是:
代码是否清楚
状态是否好追踪
业务是否好拆分
多人协作是否稳定
TypeScript 是否能帮忙
后续扩展是否方便
2. 一张总表先看全局
| 要点 | React 19 | Vue 3.5 |
|---|---|---|
| 核心心智 | 用 state 重新计算 UI | 模板绑定响应式数据 |
| 页面写法 | TSX / JSX | .vue 单文件组件 |
| 组件声明 | 函数组件 | script setup + template |
| 文本插值 | {value} | {{ value }} |
| 属性绑定 | prop={value} | :prop="value" |
| 事件绑定 | onClick={fn} | @click="fn" |
| 条件渲染 | 三元 / && | v-if / v-else |
| 列表渲染 | array.map() | v-for |
| 表单双绑 | value + onChange | v-model |
| 本地状态 | useState | ref / reactive |
| 派生数据 | useMemo | computed |
| 副作用 | useEffect | onMounted / watch |
| DOM 引用 | useRef | template ref |
| 逻辑复用 | custom Hook | composable |
| 父传子 | props | props |
| 子传父 | callback prop | emit |
| 内容插槽 | children | slot |
| 全局状态 | Context / Zustand / Redux | Pinia |
| 路由 | React Router | Vue Router |
| 权限 | 组件包裹 / loader / guard | route meta + guard |
| 样式 | CSS Modules / CSS-in-JS / Tailwind | scoped CSS / CSS Modules / Tailwind |
这张表先建立地图,下面逐项展开。
3. 页面入口和组件声明
React 里,一个页面通常就是一个函数:
export function UserManagementPage() {
return <section>用户管理</section>;
}Vue 里,一个页面通常是 .vue 单文件组件:
<script setup lang="ts">
const title = '用户管理';
</script>
<template>
<section>{{ title }}</section>
</template>| 对比点 | React | Vue |
|---|---|---|
| 文件 | .tsx | .vue |
| 结构 | JS 函数返回 JSX | script、template、style 分区 |
| 优点 | JS/TS 能力完整 | 页面结构接近 HTML |
| 初学难点 | JSX 不是 HTML | 要理解 SFC 和响应式 |
4. 模板语法:JSX vs template
React 的 JSX 是“JavaScript 里写 UI”:
const title = '用户管理';
return <h1>{title}</h1>;Vue 的 template 是“HTML 模板里绑定数据”:
<h1>{{ title }}</h1>核心区别:
React:语法更接近 JS 表达式。
Vue:语法更接近 HTML + 指令。如果页面逻辑很动态,React 的 JSX 会很灵活;如果页面结构比较标准,Vue 的模板会更直观。
5. 文本、属性、class、style
文本插值
React:
<span>{user.name}</span>Vue:
<span>{{ user.name }}</span>属性绑定
React:
<input value={keyword} placeholder="搜索用户名" />Vue:
<input :value="keyword" placeholder="搜索用户名" />class 绑定
React:
<span className={user.status === 'active' ? 'badge active' : 'badge disabled'}>
{user.status}
</span>Vue:
<span :class="['badge', user.status === 'active' ? 'active' : 'disabled']">
{{ user.status }}
</span>style 绑定
React:
<div style={{ color: user.status === 'active' ? '#16a34a' : '#64748b' }} />Vue:
<div :style="{ color: user.status === 'active' ? '#16a34a' : '#64748b' }" />| 要点 | React | Vue |
|---|---|---|
| class 名 | className | class |
| 动态 class | JS 表达式 | :class |
| 动态 style | 对象 | :style 对象 |
| 文本 | {} | {{}} |
6. 条件渲染
用户状态为空时显示“暂无用户”。
React:
return users.length > 0 ? <UserTable users={users} /> : <p>暂无用户</p>;或:
{error && <p className="error">{error}</p>}Vue:
<UserTable v-if="users.length > 0" :users="users" />
<p v-else>暂无用户</p>或:
<p v-if="error" class="error">{{ error }}</p>| 对比点 | React | Vue |
|---|---|---|
| if/else | 三元表达式 | v-if / v-else |
| 简单显示 | condition && <Comp /> | v-if="condition" |
| 适合 | JS 逻辑强的场景 | 模板结构清楚的场景 |
7. 列表渲染
用户列表是最常见的对比点。
React:
<tbody>
{users.map((user) => (
<UserRow key={user.id} user={user} />
))}
</tbody>Vue:
<tbody>
<UserRow v-for="user in users" :key="user.id" :user="user" />
</tbody>两边都必须有稳定 key。
| 要点 | React | Vue |
|---|---|---|
| 循环方式 | map | v-for |
| key | key={user.id} | :key="user.id" |
| 常见坑 | 用 index 做 key | 用 index 做 key |
8. 事件绑定
点击编辑用户。
React:
<button onClick={() => openEditModal(user)}>编辑</button>Vue:
<button @click="openEditModal(user)">编辑</button>事件对象:
React:
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setKeyword(event.target.value);
}Vue:
<input @input="handleInput" />function handleInput(event: Event) {
keyword.value = (event.target as HTMLInputElement).value;
}Vue 也常用修饰符:
<form @submit.prevent="submitForm">
<button type="submit">保存</button>
</form>React 里要手动写:
function submitForm(event: React.FormEvent) {
event.preventDefault();
}9. 表单双向输入
搜索框写法最能体现差异。
React:
const [keyword, setKeyword] = useState('');
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} />;Vue:
<script setup lang="ts">
const keyword = ref('');
</script>
<template>
<input v-model="keyword" />
</template>表单对象:
React:
const [form, setForm] = useState<UserForm>({
name: '',
email: '',
role: 'member'
});
<input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
/>;Vue:
const form = reactive<UserForm>({
name: '',
email: '',
role: 'member'
});<input v-model="form.name" />| 对比点 | React | Vue |
|---|---|---|
| 单字段 | value + onChange | v-model |
| 对象字段 | setForm + 展开旧对象 | 直接绑定 form.name |
| 优点 | 数据变化路径显式 | 表单代码少 |
| 注意 | 不要直接改 state | 注意 reactive 解构 |
10. Props:父传子
父组件把用户传给行组件。
React:
type UserRowProps = {
user: User;
};
function UserRow({ user }: UserRowProps) {
return <span>{user.name}</span>;
}Vue:
<script setup lang="ts">
defineProps<{
user: User;
}>();
</script>
<template>
<span>{{ user.name }}</span>
</template>使用:
React:
<UserRow user={user} />Vue:
<UserRow :user="user" />共同原则:子组件不要直接修改父组件传来的对象。
11. 子传父:回调 vs emit
用户点击编辑,子组件要通知父组件。
React 用回调 props:
type UserRowProps = {
user: User;
onEdit: (user: User) => void;
};
function UserRow({ user, onEdit }: UserRowProps) {
return <button onClick={() => onEdit(user)}>编辑</button>;
}Vue 用 emit:
<script setup lang="ts">
const props = defineProps<{ user: User }>();
const emit = defineEmits<{
edit: [user: User];
}>();
</script>
<template>
<button @click="emit('edit', props.user)">编辑</button>
</template>父组件接收:
React:
<UserRow user={user} onEdit={openEditModal} />Vue:
<UserRow :user="user" @edit="openEditModal" />| 对比点 | React | Vue |
|---|---|---|
| 子传父方式 | 函数 props | emit 事件 |
| 语义 | 普通 JS 函数 | 组件事件 |
| 优点 | 灵活直接 | 父子边界清楚 |
12. children vs slot
如果我们做一个通用弹窗。
React:
type ModalProps = {
title: string;
children: React.ReactNode;
};
function Modal({ title, children }: ModalProps) {
return (
<section className="modal">
<h2>{title}</h2>
{children}
</section>
);
}使用:
<Modal title="编辑用户">
<UserForm />
</Modal>Vue:
<template>
<section class="modal">
<h2>{{ title }}</h2>
<slot />
</section>
</template>使用:
<Modal title="编辑用户">
<UserForm />
</Modal>命名插槽:
Vue:
<slot name="footer" />React 通常用 props:
<Modal title="编辑用户" footer={<button>保存</button>}>
<UserForm />
</Modal>13. 本地状态:useState vs ref/reactive
React:
const [open, setOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);Vue:
const open = ref(false);
const editingUser = ref<User | null>(null);对象状态:
React:
setQuery((current) => ({
...current,
keyword: nextKeyword
}));Vue:
query.keyword = nextKeyword;大白话:
React:给 React 一份新状态。
Vue:修改响应式数据,Vue 自动追踪。14. 派生数据:useMemo vs computed
统计正常用户数量。
React:
const activeCount = useMemo(
() => users.filter((user) => user.status === 'active').length,
[users]
);Vue:
const activeCount = computed(() => users.value.filter((user) => user.status === 'active').length);| 对比点 | React | Vue |
|---|---|---|
| API | useMemo | computed |
| 依赖 | 手动写依赖数组 | 自动追踪响应式依赖 |
| 常见坑 | 依赖漏写 | computed 里做副作用 |
不要把请求接口放进 useMemo 或 computed。它们适合“算值”,不适合“做事”。
15. 副作用:useEffect vs onMounted/watch
页面首次加载用户列表。
React:
useEffect(() => {
loadUsers();
}, []);Vue:
onMounted(() => {
loadUsers();
});搜索条件变化后重新加载。
React:
useEffect(() => {
loadUsers(query);
}, [query]);Vue:
watch(
() => ({ ...query }),
() => {
loadUsers();
}
);| 要点 | React | Vue |
|---|---|---|
| 首次加载 | useEffect(..., []) | onMounted |
| 监听变化 | effect 依赖数组 | watch |
| 清理副作用 | effect return 函数 | onUnmounted 或 watch cleanup |
| 常见坑 | 依赖数组漏写 | watch 过深、触发太频繁 |
16. DOM 引用:useRef vs template ref
让搜索框自动聚焦。
React:
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;Vue:
<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => {
inputRef.value?.focus();
});
</script>
<template>
<input ref="inputRef" />
</template>React 的 useRef 还常用来保存不会触发渲染的值;Vue 的 ref 既能做 DOM 引用,也能做响应式值,区别要靠上下文判断。
17. 接口请求和 loading/error
React:
const [state, setState] = useState({
data: [] as User[],
loading: false,
error: null as string | null
});
async function loadUsers() {
setState((current) => ({ ...current, loading: true, error: null }));
try {
const data = await fetchUsers(query);
setState({ data, loading: false, error: null });
} catch {
setState({ data: [], loading: false, error: '加载失败' });
}
}Vue:
const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function loadUsers() {
loading.value = true;
error.value = null;
try {
users.value = await fetchUsers(query);
} catch {
error.value = '加载失败';
} finally {
loading.value = false;
}
}共同原则:
不要只写成功状态。
真实页面至少要有 loading、error、empty、success。18. 业务逻辑复用:custom Hook vs composable
React 抽 useUsers:
export function useUsers() {
const [query, setQuery] = useState<UserQuery>({ keyword: '', status: 'all' });
const [state, setState] = useState<UserState>({ data: [], loading: false, error: null });
const reload = useCallback(() => {
return loadUsers(query);
}, [query]);
return { query, setQuery, state, reload };
}Vue 抽 useUsers:
export function useUsers() {
const query = reactive<UserQuery>({ keyword: '', status: 'all' });
const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function reload() {
users.value = await fetchUsers(query);
}
return { query, users, loading, error, reload };
}| 对比点 | React custom Hook | Vue composable |
|---|---|---|
| 命名 | 通常 useXxx | 通常 useXxx |
| 调用位置 | 组件或 Hook 顶层 | 常在 setup/composable 里 |
| 状态 | useState 等 Hook | ref/reactive |
| 注意 | Hook 调用顺序不能变 | 注意响应式返回和解构 |
19. 全局状态:Context/Zustand vs Pinia
当前登录用户和权限适合全局。
React Context 简版:
const AuthContext = createContext<AuthState | null>(null);
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error('AuthProvider missing');
return value;
}React Zustand 简版:
export const useAuthStore = create<AuthState>((set) => ({
user: null,
permissions: [],
setUser: (user) => set({ user, permissions: user.permissions })
}));Vue Pinia:
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as CurrentUser | null,
permissions: [] as string[]
}),
actions: {
setUser(user: CurrentUser) {
this.user = user;
this.permissions = user.permissions;
}
}
});| 状态 | React 建议 | Vue 建议 |
|---|---|---|
| 当前用户 | Context 或 Zustand | Pinia |
| 权限列表 | Context 或 Zustand | Pinia |
| 表单草稿 | 本地 state | 组件本地 ref/reactive |
| 用户列表 | 本地 Hook 或请求缓存 | composable 或请求缓存 |
| 主题语言 | Context/Zustand | Pinia |
20. 路由和页面跳转
React Router:
{
path: '/admin/users',
element: <UserManagementPage />
}跳转:
const navigate = useNavigate();
navigate('/admin/users');读参数:
const { id } = useParams();Vue Router:
{
path: '/admin/users',
component: () => import('./UserManagementPage.vue')
}跳转:
const router = useRouter();
router.push('/admin/users');读参数:
const route = useRoute();
const id = route.params.id;21. 权限控制
按钮权限。
React:
function PermissionButton({ code, children }: { code: PermissionCode; children: React.ReactNode }) {
const can = useAuthStore((state) => state.permissions.includes(code));
return can ? <>{children}</> : null;
}Vue:
<button v-if="authStore.permissions.includes('user:create')">
新增用户
</button>路由权限。
React:
<RequirePermission code="user:read">
<UserManagementPage />
</RequirePermission>Vue:
{
path: '/admin/users',
component: UserManagementPage,
meta: { permission: 'user:read' }
}router.beforeEach((to) => {
const authStore = useAuthStore();
const permission = to.meta.permission as PermissionCode | undefined;
if (permission && !authStore.permissions.includes(permission)) {
return '/403';
}
});共同底线:前端权限只是体验,后端接口必须再次校验。
22. TypeScript 写法
共享类型:
type UserStatus = 'active' | 'disabled';
type User = {
id: number;
name: string;
email: string;
role: 'admin' | 'member';
status: UserStatus;
};React props:
type UserTableProps = {
users: User[];
onEdit: (user: User) => void;
};Vue props:
defineProps<{
users: User[];
}>();Vue emits:
defineEmits<{
edit: [user: User];
delete: [id: number];
}>();React event:
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
}Vue event:
function handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
}| 对比点 | React | Vue |
|---|---|---|
| props 类型 | 普通 TS type | defineProps<T>() |
| 事件类型 | React 事件类型 | DOM Event 类型 |
| 组件返回 | JSX.Element 通常可推导 | SFC 模板推导 |
| 路由 meta | 自己封装或声明 | 扩展 vue-router 类型 |
23. 样式组织
React 常见:
import styles from './user-table.module.css';
<table className={styles.table} />Vue 常见:
<style scoped>
.table {
width: 100%;
}
</style>两边都可以用 Tailwind、UnoCSS、CSS Modules。
| 方式 | React | Vue |
|---|---|---|
| 局部 CSS | CSS Modules | scoped CSS |
| 原子类 | Tailwind / UnoCSS | Tailwind / UnoCSS |
| 动态样式 | JS 对象或 className | :class / :style |
24. 性能优化
React 常见优化:
const filteredUsers = useMemo(() => filterUsers(users, query), [users, query]);
const handleEdit = useCallback((user: User) => openEditModal(user), []);
export const UserRow = memo(function UserRow({ user }: UserRowProps) {
return <tr>{user.name}</tr>;
});Vue 常见优化:
const filteredUsers = computed(() => filterUsers(users.value, query));<UserRow v-for="user in filteredUsers" :key="user.id" :user="user" />共同优化:
| 问题 | 处理 |
|---|---|
| 搜索太频繁 | debounce |
| 表格太大 | 分页 / 虚拟滚动 |
| 图片太大 | 压缩 / 懒加载 |
| 首屏太慢 | 路由懒加载 |
| 重复请求 | 请求缓存 |
不要为了“看起来高级”提前堆优化。先把数据流写清楚。
25. 测试思路
React 组件测试:
render(<UserTable users={users} onEdit={vi.fn()} />);
expect(screen.getByText('小明')).toBeInTheDocument();Vue 组件测试:
const wrapper = mount(UserTable, {
props: { users }
});
expect(wrapper.text()).toContain('小明');端到端测试两边类似:
await page.goto('/admin/users');
await page.getByPlaceholder('搜索用户名').fill('小明');
await expect(page.getByText('小明')).toBeVisible();测试重点不是框架语法,而是业务结果:用户能不能搜、能不能保存、错误时有没有提示。
26. 工程化目录
React:
features/users/
api.ts
types.ts
hooks/
use-users.ts
components/
user-filter.tsx
user-form.tsx
user-table.tsx
pages/
user-management-page.tsxVue:
features/users/
api.ts
types.ts
composables/
use-users.ts
components/
UserFilter.vue
UserForm.vue
UserTable.vue
pages/
UserManagementPage.vue真正到项目里,两边的工程思想很接近:
按业务模块聚合
接口层独立
类型独立
页面只负责组装
复杂逻辑抽到 hook/composable
权限和路由集中处理27. 选型建议
| 情况 | 更推荐 |
|---|---|
| 团队 JS/TS 能力强,喜欢函数式组合 | React |
| 团队希望模板清晰,上手快 | Vue |
| 后台管理系统、国内生态 | Vue 很常见 |
| 海外生态、复杂前端平台、跨端生态 | React 很常见 |
| 你要做 AI 全栈、个人产品 | 两个都值得会 |
最实际的建议:
先精通一个,再能看懂另一个。
用同一个用户管理后台案例练两遍,比看十篇争论文章有用。28. 下一步:进入生态和架构
学完这篇后,不要停在“我知道 React 和 Vue 语法差异”的层面。真实项目还需要继续理解生态和架构:
React 生态怎么选?
Vue 生态怎么选?
两边路由、状态、请求、表单、组件库、SSR、测试怎么横向比较?最终你要带走的不是“React 胜”或“Vue 胜”,而是:
同一个业务问题,我能用清楚的数据流、组件结构和工程规范解决。