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

wbcm
wbcm 见习用户见习用户
发布于 2026-09-30 19:54 ·92 浏览 ·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
onMounteduseEffect(fn, [])
onUnmounteduseEffect 里 return 的清理函数
onUpdateduseEffect(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轻量论坛系统,原文地址:https://www.leleweb.cn/thread-658.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 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 走人。

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