学完这篇,你能把 Vue 项目迁到 React 时不靠"翻译语法"硬搬,而是先换脑子、再动手,并绕开那批最常见的坑。
第一步:先把"响应式"这条腿换掉
Vue 用 `ref(0)`,改的时候 `.value++`,视图自己跟着变,因为 Proxy 在替你追踪依赖。React 没有追踪这回事,它只有一条规则:调用 setter → 组件函数整体重跑 → 生成新的 JSX。
// Vue
const count = ref(0); count.value++;
// React
const [count, setCount] = useState(0);
setCount(count + 1);
由此推出第一个大坑:不要原地改数据。数组 `push`、对象改属性,引用没变,React 认为没变化,不重渲染。
// ❌ list.push(item); setList(list);
// ✅ setList([...list, item]);
注意:`setCount(count + 1)` 连写两次只会加 1,因为闭包读的是同一次渲染的旧值。要写成 `setCount(c => c + 1)`。
第二步:computed / watch → useMemo / useEffect
Vue 的 `computed` 自动收集依赖,React 要你手写依赖数组。判断标准很简单:
- 渲染期间算出来的值 → `useMemo`
- 出了渲染之外的副作用(请求、订阅、定时器、改 DOM)→ `useEffect`
const total = useMemo(
() => list.reduce((sum, i) => sum + i.price, 0),
[list]
);
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // 相当于 onUnmounted
}, []); // 空数组 = 只跑一次(约等于 onMounted)
注意:依赖数组漏写会读到旧值(stale closure),依赖里塞整个对象又会导致每次都重跑。依赖只放"真正用到的原始值"。
第三步:模板语法 → JSX
| Vue | React |
|---|
| `v-if="ok"` | `{ok && <X />}` 或三元 |
| `v-for="i in list"` | `{list.map(i => <Row key={i.id} />)}` |
| `:class="{active: on}"` | `className={on ? 'active' : ''}` |
| `@click="fn"` | `onClick={fn}` |
| `v-model="v"` | `value={v} onChange={e => setV(e.target.value)}` |
| `:style="{color:'red'}"` | `style={{ color: 'red' }}` |
注意:`{count && <X />}` 当 `count` 为 `0` 时页面上会渲染出一个孤零零的 `0`。数字做条件判断请写 `count > 0 && ...`。
注意:`class` 必须写 `className`,`for` 必须写 `htmlFor`;忘了给 `key` 会警告,用数组下标当 key 在可排序列表里会导致状态错位。
第四步:组件通信换个名字
Vue 的 `emit`,在 React 里就是往子组件传一个函数;`slot` 对应 `props.children`;`provide/inject` 对应 `createContext` + `useContext`;`defineExpose` 基本没有等价物,非要用就 `useImperativeHandle`。
// 子组件
function Child({ onPick }) {
return <button onClick={() => onPick(1)}>选 1</button>;
}
第五步:状态管理挑个对味的
Pinia 用户迁过来最顺手的是 Zustand,都是 hook 式、几乎没有样板代码:
const useStore = create(set => ({
count: 0,
inc: () => set(s => ({ count: s.count + 1 })),
}));
如果你原来重度依赖 Vuex 的 mutation/action 分层,那就上 Redux Toolkit,别硬套 Zustand。
第六步:把生命周期对照表贴在显示器边上
| Vue | React |
|---|
| `onMounted` | `useEffect(fn, [])` |
| `onUnmounted` | `useEffect` 里 `return` 的清理函数 |
| `onUpdated` | `useEffect(fn)`(无依赖数组,慎用) |
| `watch(prop, cb)` | `useEffect(cb, [prop])` |
踩坑清单(照着排查)
- 开发环境 `useEffect` 跑两次:React 18 StrictMode 故意为之,用来暴露清理函数没写全的问题。副作用要幂等,`return` 里把订阅、定时器、请求 abort 都收干净。
- 定时器/事件监听里读到旧 state:用 `useRef` 存一份最新值,或把依赖补全。
- `useEffect` 直接传 async 函数:会返回 Promise 报错。要在内部定义 `async function run()` 再调用。
- 受控输入框打不出字:给了 `value` 却忘了 `onChange`。
- `setState` 后立刻读 state:读到的是旧值,React 是批处理的。要在新值上用,就放进 `useEffect` 或直接用计算出的变量。
- 满屏 `useCallback`/`useMemo`:只在确实传给 `React.memo` 子组件、或作为其他 hook 依赖时才包,否则是纯负担。
- 列表 key 用 index:插入、删除、排序时全部错位,用后端返回的 id。
小结
- 思维上只换一件事:从"数据变 → 框架追踪 → 局部更新"换成"调 setter → 组件重跑 → 出新 JSX"。
- 数据永远返回新引用,不原地改。
- 计算用 `useMemo`,副作用用 `useEffect`,依赖数组是命门。
- JSX 里 `className`、`htmlFor`、`key`、受控组件这四处最容易翻车。
- 组件通信 = props 向下 + 函数向上 + Context 跨层,没有 emit 和 slot。
- 迁移顺序建议:先搭好路由和状态管理骨架,再一个页面一个页面搬,别做逐行翻译。