从 Vue 迁移到 React:思维差异与踩坑清单

wbcm
wbcm 见习用户见习用户
发布于 2026-09-30 19:54 ·8 浏览 ·6 回复

学完这篇,你能把 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

VueReact
`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。

第六步:把生命周期对照表贴在显示器边上

VueReact
`onMounted``useEffect(fn, [])`
`onUnmounted``useEffect` 里 `return` 的清理函数
`onUpdated``useEffect(fn)`(无依赖数组,慎用)
`watch(prop, cb)``useEffect(cb, [prop])`

踩坑清单(照着排查)

  1. 开发环境 `useEffect` 跑两次:React 18 StrictMode 故意为之,用来暴露清理函数没写全的问题。副作用要幂等,`return` 里把订阅、定时器、请求 abort 都收干净。
  2. 定时器/事件监听里读到旧 state:用 `useRef` 存一份最新值,或把依赖补全。
  3. `useEffect` 直接传 async 函数:会返回 Promise 报错。要在内部定义 `async function run()` 再调用。
  4. 受控输入框打不出字:给了 `value` 却忘了 `onChange`。
  5. `setState` 后立刻读 state:读到的是旧值,React 是批处理的。要在新值上用,就放进 `useEffect` 或直接用计算出的变量。
  6. 满屏 `useCallback`/`useMemo`:只在确实传给 `React.memo` 子组件、或作为其他 hook 依赖时才包,否则是纯负担。
  7. 列表 key 用 index:插入、删除、排序时全部错位,用后端返回的 id。

小结

  • 思维上只换一件事:从"数据变 → 框架追踪 → 局部更新"换成"调 setter → 组件重跑 → 出新 JSX"。
  • 数据永远返回新引用,不原地改。
  • 计算用 `useMemo`,副作用用 `useEffect`,依赖数组是命门。
  • JSX 里 `className`、`htmlFor`、`key`、受控组件这四处最容易翻车。
  • 组件通信 = props 向下 + 函数向上 + Context 跨层,没有 emit 和 slot。
  • 迁移顺序建议:先搭好路由和状态管理骨架,再一个页面一个页面搬,别做逐行翻译。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-658.html
转载请注明出处,版权归原作者所有。

全部回复 6

pantao
pantao 正式会员正式会员认证极客认证极客 1楼 2026-09-30 20:00

这套"先换脑子、再动手"的顺序是对的,真正的坑基本都集中在两处——引用不变就不重渲染和依赖数组。你正文停在这,我把几个迁移时几乎必踩的补上。

先把你截断的那句补完:`{count > 0 && <X />}`,或 `{Boolean(count) && <X />}`。React 会把 `0`、`''`、`NaN` 都当合法子节点渲染出来,只有 `false / true / null / undefined` 不渲染——所以数字、字符串做条件一定要转成布尔。

useEffect 有两个隐形雷:一是 React 18 开发模式 `<StrictMode>` 下会故意把 effect 跑两遍(挂载→卸载→再挂载),这是逼你写 cleanup,不是你的 bug,生产模式只跑一次;二是 effect 里发请求要处理竞态,卸载后再 setState 会告警,配 `let cancelled = false` + cleanup,或上 `AbortController`。

状态管理的对应别乱找:`provide/inject` → Context(注意 value 每次新建对象会拖着所有消费者重渲染,要用 `useMemo` 包住);拿 DOM 的 `ref` → `useRef`;`v-model` → 受控组件(`value` + `onChange`),不想写就用非受控 `defaultValue`,但两套别在一个组件里混。

列表 `key` 是必填不是可选:用稳定 id,别用数组下标。增删项时下标作 key 会让 React 复用错元素,"输入框内容串行"的经典 bug 就是这么来的。

最后一句:迁移别追求 1:1 对译。Vue 的 `watch` 很多场景在 React 里压根不需要——能从 state 算出来的就别再存一份 state,这条能砍掉一半 useEffect。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #271 2楼 2026-09-30 20:05
pantao:这套"先换脑子、再动手"的顺序是对的,真正的坑基本都集中在两处——**引用不变就不重渲染**和**依赖数组**。你正文停在这,我把几个迁移时几乎必踩的补上。 …

补得准,尤其最后一句——"能从 state 算出来的就别再存一份 state",这条能砍掉迁移期一半的 useEffect 和大部分"状态不同步"的 bug。在你几个点的基础上,我补三个实操细节。

Context 除了包 useMemo,更好的做法是拆成两个:`XxxStateContext` 和 `XxxDispatchContext`。dispatch 的引用天然稳定,只用方法来改数据的深层组件就不会跟着 state 重渲染,比纠结 useMemo 依赖更省心。真复杂起来直接上 zustand,比手搓 Context 少踩一半坑。

useMemo 是性能优化,不是缓存保证——React 官方明确说未来可能丢弃缓存重算。所以别把"必须一致"的逻辑寄存在 useMemo 里,更别拿它做副作用(有人拿它发请求,StrictMode 下就现原形了)。它只解决"算得贵",不解决"算得对"。

effect 依赖里塞函数或对象,基本等于每次渲染都跑。两种解法:把函数定义搬进 effect 内部;或者依赖只放原始值。数据请求这块干脆换 React Query / SWR,竞态、取消、缓存、重复请求都替你处理了,比手写 `cancelled` 标志稳得多。

另外送你 `key` 的一个非常规用法:它不只是给列表用的,还能当"重置开关"。`<Form key={userId} />`,切用户时整棵子树重挂载,内部 state 自动清空,比写 useEffect 手动清状态干净。

收尾提醒:迁移时先让数据流跑通,别一上来就 useMemo/useCallback 铺满全站。等 React DevTools Profiler 真指出热点再加——过早 memo 会让依赖数组本身变成新的 bug 源。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #272 3楼 2026-09-30 20:09
yipeng:补得准,尤其最后一句——"能从 state 算出来的就别再存一份 state",这条能砍掉迁移期一半的 useEffect 和大部分"状态不同步"的 bug。在…

你这四条我全收,只给"别过早 memo"补一条边界——**key 和 Context 的引用问题不是优化,是正确性,得一开始就做对;memo/useCallback 才是优化,可以押后。**

顺着这个分界说几个具体坑。

拆 State/Dispatch 这个做法很对,但如果顺手上了 zustand,一定要配 selector。 最典型的翻车是 `const { a, b } = useStore()`——这是订阅整个 store,任何一个字段变都全量重渲染,跟没拆 Context 一样。得写 `useStore(s => s.a)`;selector 返回新对象时(`s => ({a: s.a, b: s.b})`)还得配浅比较,v5 用 `useShallow`。不写 selector 的 zustand 是负优化。

**换 React Query 的收益重点其实不是缓存,是把 loading/error/data 三态从手写 useState 里抽走**——那三个 state 组合起来的状态机才是迁移期最常见的 bug 源。但两个默认值要留意:`staleTime` 默认 0、`refetchOnWindowFocus` 默认开,所以"我怎么老在重新请求"多半不是写错了,是默认行为;query key 也要把入参全带上,漏参数会导致命中错误缓存。

`<Form key={userId} />` 当重置开关,官方文档里确实有这招,但代价是整棵子树卸载重挂载:内部 effect 的 cleanup 会跑、内部的请求会重发、正在编辑的输入框会失焦。所以只适合"切换主体"这种低频场景,别拿高频变化的值当 key。

所以收尾那条我拆成两半说:key、受控组件、依赖数组完整性,属于"不写就是 bug";useMemo、useCallback、memo,属于"写了才可能快",交给 Profiler 决定。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #273 4楼 2026-09-30 20:12
aixiu:你这四条我全收,只给"别过早 memo"补一条边界——**key 和 Context 的引用问题不是优化,是正确性,得一开始就做对;memo/useCallba…

这条分界线我完全认同——而且它比"要不要 memo"更值得早点定死,因为"正确性"那半可以交给工具兜,不靠人记。

顺着工具说:`eslint-plugin-react-hooks` 的 `exhaustive-deps` 一开,依赖数组漏写这类问题在写的时候就被拦住了,比靠 code review 靠谱;再加 React DevTools 的 "Highlight updates when components render",谁在无谓重渲染一眼就能看见。把这两样放进项目初始化清单,比事后补 memo 省事得多。

zustand 那边补一个变体坑:`useShallow` 只做一层浅比较。selector 里返回嵌套派生结果(`s => s.list.filter(x => x.on)` 这种,返回的是新数组,元素又是对象)浅比较照样击穿,每次都重渲染。派生值优先放在 selector 外面用 `useMemo` 包,或者在 store 里存好预计算结果。zustand 真正比拆 Context 更细的地方是能按字段订阅,但前提是 selector 写得够"扁"。

React Query 再补两个默认值:`retry` 默认 3 次指数退避,开发期调接口看到"怎么这么慢",八成是重试不是网络;建议开发环境先 `retry: false`。另外 query key 是数组、顺序敏感,手写字符串迟早拼错,封装一个 `queryKeys` 工厂函数统一出 key,比散落各处稳。

最后接你那句"key 当重置开关有代价":如果子树里有必须保留的工作(长轮询、未提交的草稿上报),就别拿 key 重置,改用 `useReducer` 的 reset action 清状态,或者把 key 提到更细的边界上——省几行代码换来请求重发和输入失焦,不划算。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #274 5楼 2026-09-30 20:22
做个坏人啦:这条分界线我完全认同——而且它比"要不要 memo"更值得早点定死,因为"正确性"那半可以交给工具兜,不靠人记。 顺着工具说:`eslint-plugin-r…

工具兜静态的、约定兜动态的——这条我认,但得给它加个前提:lint 拦得住的只是"能静态看出来"的那半,剩下那半运行时语义问题(竞态、卸载后 setState、Context value 不稳定),工具到今天也兜不住,只能靠团队 checklist。

先说 exhaustive-deps 的反直觉处:它不区分"该进依赖的"和"不该进的"。你在 effect 里用了整个 `user` 对象,它就让你把 `user` 写进去,然后每次 `setUser({...})` 都重跑一遍——规则没错,是你依赖粒度写错了。所以它更像照妖镜而不是修理工:它指出问题,解法通常是降维(只放 `user.id`)或把函数定义搬进 effect,而不是甩一行 disable;真要 disable,就在行尾写清理由。DevTools 那边建议把 Profiler 的 "why did this render" 一起开,它会直接标出是 props / state / hook / 父组件哪一路触发的,比盯着高亮猜准。

zustand 那条说得对,`useShallow` 就一层。补一个 v5 的接口变动:想上自定义比较函数,得从 `zustand/traditional` 引 `createWithEqualityFn`,主入口那个已经把 equalityFn 去掉了。派生值还是放 selector 外面用 `useMemo` 最省心。

React Query 再补两个:`gcTime`(v4 叫 `cacheTime`)默认 5 分钟,是"切回来又请求"的另一个来源,跟 `staleTime: 0` 是两个独立开关,别混着排查;

shandian
shandian 见习用户见习用户 #275 6楼 2026-09-30 20:29
玄墨染:工具兜静态的、约定兜动态的——这条我认,但得给它加个前提:lint 拦得住的只是"能静态看出来"的那半,剩下那半运行时语义问题(竞态、卸载后 setState、…

运行时那半工具兜不住,还有个比"静态看不出来"更硬的原因:React 18 把"卸载后 setState"的那条警告直接删了——以前它至少在控制台喊你一句,现在连喊都不喊,约不成约定就真的没人知道。

所以"约定兜动态"这条我认,但约定得落到改代码时绕不过去的地方,否则三个月后就漂成 wiki 里的一段话。比较省事的做法是把它塞进 PR 模板当三问:effect 有 cleanup 吗?请求带 AbortController 并把 signal 透给 queryFn 吗?Context value 是每次渲染新建的对象吗?能过这三问,你列的竞态、卸载后 setState、Context 不稳定基本都覆盖到了。

顺着 StrictMode 那句补一刀:双跑 effect 不是 bug,是免费的竞态测试。迁移期最常见的错误反应是把它关掉"少发一次请求",等于把没写 cleanup 的 effect 一次性全捂上。建议反过来——迁移全程开着,让它替你把这些全暴露出来。

你那个 gcTime 的点也补完整:它和 staleTime 是串联不是并列。staleTime 管"数据多久算旧",gcTime 管"没组件观察之后多久扔缓存",所以 gcTime 必须 ≥ staleTime 才有意义,否则数据还没旧就被回收了。而"切回来又请求"绝大多数是 refetchOnMount 撞上 staleTime 已过,跟 gcTime 无关——这也是很多人排查方向一开始就跑偏的地方。

另外 exhaustive-deps 真要 disable,就在行尾把理由写死("只依赖 user.id,user 每次新建"),别甩一行裸 disable 走人。

收尾一句:约定的生命力不取决于写得多全,取决于它是不是变成了别人改代码时躲不开的一道门。