说实话,刚开始把 Vue 项目搬到 React 的时候,我脑子里全是问号。Vue 那种“模板写写、数据绑绑、组件一用”的顺畅感,到了 React 里怎么突然变得这么别扭?特别是当你习惯了 Vue 的 CDN 引入方式(就是那个简单的 <script src="vue.js">),结果发现 React 根本没有那种“拿来即用”的单文件直连方案时,真的有点懵。
今天咱们就坐下来,像老朋友聊天一样,把这些坑一个个扒开看看,顺便把对应的 React 方案也讲透。别担心,咱们不整那些虚头巴脑的理论,直接看代码、看问题、看解法。
先说说“CDN 思维”这个根本性的差异
很多从 Vue CDN 转过来的朋友,第一个困惑就是:“React 不是也有 CDN 吗?怎么用不了?”
确实,React 在 unpkg 或者 cdnjs 上有 react.development.js 和 react-dom.development.js,你能直接引用。但问题在于,Vue 的 CDN 版本自带全局注册、全局插件系统和相对直观的 API,而 React 的 CDN 版本只是纯粹的库函数,没有任何“框架感”。
举个例子,你在 Vue 里可以这样写:
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">反转消息</button>
</div>
<script>
new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
</script>
</body>
</html>
你看,多清爽?一个 HTML 文件,几行 JS,跑起来了。
但如果你试图用同样的方式写 React,你会发现:
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- 还差一个 Babel,不然 JSX 跑不了 -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="text/babel">
function App() {
const [message, setMessage] = React.useState('Hello React!');
const reverseMessage = () => {
setMessage(message.split('').reverse().join(''));
};
return React.createElement('div', null,
React.createElement('p', null, message),
React.createElement('button', { onClick: reverseMessage }, '反转消息')
);
}
ReactDOM.render(
React.createElement(App),
document.getElementById('app')
);
</script>
</body>
</html>
哇,是不是感觉瞬间复杂了十倍?即使你加了 Babel 支持,写成 JSX 也还是有一堆 React.useState、ReactDOM.render 这种显式调用。
这其实就是第一个大坑:Vue 的 CDN 方案是“框架级”的,而 React 的 CDN 方案只是“库级”的。
很多人以为换个框架只是语法变变,实际上整个心智模型都要转。Vue 是命令式的、模板驱动的;React 是声明式的、JSX 驱动的。这个根本差异,会导致后面一堆“为什么我这样写不行”的问题。
坑一:状态管理的“响应式” vs “不可变”
在 Vue 里,data 里的任何变化,视图自动更新。这是 Vue 的响应式系统魔法——它通过 Object.defineProperty(Vue 2)或者 Proxy(Vue 3)自动追踪依赖。
但在 React 里,你不能直接修改状态。你必须用 setState 或者 useState 返回的 setter 函数。
我见过最多的错误代码长这样:
// 错误示范!Vue 程序员常犯的错
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
count += 1; // ❌ 直接修改状态,React 不会重新渲染!
};
return <button onClick={increment}>{count}</button>;
}
这段代码看着没啥问题,对吧?count += 1 多直观啊,跟 Vue 里的 this.count++ 一个意思。但在 React 里,这完全没用。你修改了一个局部变量的副本,useState 根本不知道数据变了,视图自然不会更新。
正确的写法是:
// 正确写法
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1); // ✅ 通知 React 状态变了
};
return <button onClick={increment}>{count}</button>;
}
理解这一点很重要:React 的状态是不可变的。每次更新状态,你都要创建一个新的值,然后告诉 React:“嘿,用这个新值替换旧值”。React 会比较新旧值,决定是否重新渲染。
这里有个小陷阱,就是对象和数组的修改。在 Vue 里,你可以直接推入数组、修改对象属性,响应式系统会自动捕捉。但在 React 里:
// 错误示范
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
todos.push({ text, id: Date.now() }); // ❌ 直接修改原数组
setTodos(todos); // React 看到的是同一个引用,可能不会重渲染
};
return (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
);
}
正确的做法是创建新数组:
// 正确写法
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos([...todos, { text, id: Date.now() }]); // ✅ 创建新数组
};
return (
<ul>
{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}
</ul>
);
}
数组用展开运算符,对象用对象展开运算符,总之就是不要修改原数据,创建新数据。
坑二:组件通信的“props 透传”vs “props drilling”
Vue 的组件通信方式很丰富:props、emit、provide/inject、Vuex/Pinia。但在 CDN 场景下,很多人只用了 props 和 $emit。
转到 React 后,props 的概念类似,但 $emit 没了。React 用的是回调函数来实现子组件向父组件通信。
来看个例子。在 Vue 里,子组件触发事件:
<!-- Vue 子组件 -->
<template>
<button @click="$emit('increment')">点击+</button>
</template>
父组件接收:
<!-- Vue 父组件 -->
<template>
<Child @increment="handleIncrement" />
</template>
在 React 里,你得这样写:
// React 子组件
function Child({ onIncrement }) {
return <button onClick={onIncrement}>点击+</button>;
}
// React 父组件
function Parent() {
const handleIncrement = () => {
console.log('子组件触发了点击');
};
return <Child onIncrement={handleIncrement} />;
}
看着简单,但当层级深的时候,就麻烦了。比如 GrandParent → Parent → Child → GrandChild,你想让 GrandChild 告诉 GrandParent 某件事,你得把回调函数一层层传下去,这就是所谓的 “props drilling”(Props 钻孔)。
在 Vue 里,你可以用 provide/inject 或者 Pinia 状态管理来解决。在 React 里,你有几个选择:
- Context API(React 内置)
- 状态管理库(Redux、Zustand、Jotai 等)
- 回调函数逐层传递(小项目勉强能用)
对于从 Vue CDN 转过来的项目,如果你不想引入复杂的构建工具,Context API 是个不错的过渡方案:
// 创建 Context
const ThemeContext = React.createContext('light');
// 提供 Context 的组件
function ThemeProvider({ children }) {
const [theme, setTheme] = React.useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 使用 Context 的组件(任意深度)
function DeepChild() {
const { theme, setTheme } = React.useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题(当前:{theme})
</button>
);
}
// 根组件
function App() {
return (
<ThemeProvider>
<DeepChild />
</ThemeProvider>
);
}
这跟 Vue 的 provide/inject 异曲同工,只是写法上更繁琐一些。
坑三:生命周期钩子的“名称不同”vs “概念相似”
Vue 的生命周期钩子大家应该很熟:created、mounted、updated、unmounted 等等。
React 的功能组件里没有这些钩子,只有 useEffect。这让很多 Vue 程序员一开始完全懵圈:“我该怎么在组件挂载时发送请求?怎么在组件卸载时清理定时器?”
其实 useEffect 可以覆盖 Vue 的大部分生命周期需求,只是写法不同:
| Vue 钩子 | React 等价写法 |
|---|---|
created / setup() 初始化 |
函数体直接执行 |
mounted |
useEffect(() => { ... }, []) |
updated |
useEffect(() => { ... }, [依赖项]) |
unmounted |
useEffect(() => { return cleanup; }, []) |
举个例子,在 Vue 里发送 API 请求:
<script>
export default {
async created() {
const response = await fetch('/api/data');
this.data = await response.json();
}
}
</script>
在 React 里:
function DataComponent() {
const [data, setData] = useState(null);
useEffect(() => {
async function fetchData() {
const response = await fetch('/api/data');
const json = await response.json();
setData(json);
}
fetchData();
}, []); // 空依赖数组 = 只在挂载时执行
return <div>{data ? JSON.stringify(data) : '加载中...'}</div>;
}
注意那个 [],它告诉 React:“这个 effect 只在组件挂载时执行一次,之后不再执行”。这相当于 Vue 的 created 或 mounted。
如果你想监听某个状态的变化(相当于 Vue 的 updated),就把依赖项写进数组:
useEffect(() => {
console.log('userId 变了,重新获取用户信息');
fetchUser(userId);
}, [userId]); // 当 userId 变化时执行
这里有个大坑:很多人会忘记依赖数组,写成这样:
useEffect(() => {
// 忘记写依赖数组!
fetchUser(userId);
});
这会导致 effect 在每次渲染时都执行,包括状态变化、父组件重渲染等,性能灾难就来了。
正确的做法是:
- 空数组
[]= 只在挂载时执行(相当于mounted) - 有依赖项
[a, b]= 当 a 或 b 变化时执行(相当于updated) - 没有数组 = 每次渲染都执行(通常是 bug)
还有一个常见的误区:在 useEffect 里直接修改状态导致无限循环:
// 危险代码
useEffect(() => {
setData(getNewData()); // ❌ 如果 getNewData 每次都返回新对象,会无限触发
});
正确的做法是确保依赖项稳定,或者使用 useMemo 缓存:
const stableData = useMemo(() => getNewData(), []);
useEffect(() => {
setData(stableData);
}, [stableData]);
坑四:条件渲染和列表渲染的语法差异
Vue 的 v-if、v-else、v-for 在 React 里变成了三元运算符和 map()。
在 Vue 里:
<template>
<div v-if="isLoggedIn">
<p>欢迎回来!</p>
</div>
<div v-else>
<p>请先登录</p>
</div>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
在 React 里:
function App({ isLoggedIn, items }) {
return (
<div>
{isLoggedIn ? (
<p>欢迎回来!</p>
) : (
<p>请先登录</p>
)}
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
看着差不多,但有几个细节要注意:
key的重要性:在 Vue 里,v-for的:key是必须的,React 里也是。但 React 对key的要求更严格——它必须是稳定的、唯一的值。用数组索引当key是个坏习惯,除非列表是静态的。条件渲染的性能:Vue 的
v-if是“真渲染,假不渲染”,React 的三元运算符也是这样。但有时候你会看到这样的写法:
// 不推荐:即使 isLoggedIn 为 false,Button 组件仍然会创建
<div>
{isLoggedIn && <Button />}
</div>
这种写法在 isLoggedIn 为 false 时,false 会被渲染成文本 "false"!虽然 React 18 对这种情况做了优化,但还是建议用括号包裹:
// 推荐
<div>
{isLoggedIn && (<Button />)}
</div>
或者用三元运算符更明确:
<div>
{isLoggedIn ? <Button /> : null}
</div>
v-show的等价物:Vue 的v-show是通过display: none控制显隐,不会销毁组件。在 React 里没有直接等价物,你可以用内联样式:
<div style={{ display: isVisible ? 'block' : 'none' }}>
内容
</div>
但要注意,这种方式组件依然存在,只是隐藏了。如果你只是想控制可见性,这没问题;如果你想根据条件动态加载/卸载组件,还是用 v-if 对应的三元运算符。
坑五:事件处理的“语法糖”vs “绑定方式”
Vue 的事件处理非常简洁:@click="handler"、@keyup.enter="submit"。
React 的事件处理有几点不同:
- 命名规范:React 用驼峰命名,如
onClick、onChange,而不是click、change。 - 传递函数:React 里你传递的是函数引用,不是函数调用。
- 合成事件:React 使用合成事件系统,事件委托到根节点。
看个例子:
// Vue 风格(错误)
<button onClick={handleSubmit()}>提交</button> // ❌ 立即执行
// React 正确写法
<button onClick={handleSubmit}>提交</button> // ✅ 传递函数引用
// 如果需要传参
<button onClick={() => handleSubmit(id)}>提交</button> // ✅ 用箭头函数包裹
另一个常见坑是 onChange 事件。在 Vue 里,v-model 自动处理输入框的 change 事件。在 React 里,你需要手动处理:
// Vue
<input v-model="message" />
// React
function InputComponent() {
const [message, setMessage] = useState('');
return (
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
);
}
这里 value 是受控组件的概念。在 Vue 里,你可以用非受控组件(直接读 DOM),但在 React 的最佳实践中,推荐使用受控组件。
非受控组件的替代方案:如果你确实想用 refs 直接操作 DOM(类似 Vue 的 ref),可以这样:
function InputComponent() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value); // 直接读取 DOM
};
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>读取输入</button>
</>
);
}
但记住,除非有特殊情况,否则优先使用受控组件。
