上周三晚上十点半,我在一家咖啡馆帮朋友看他们新项目的选型。五个人,吵了快两个小时。
CTO 的理由是团队熟 Vue,招人也快;技术负责人坚持 React,说生态和招人半径不是一个量级;还有个刚从上一家公司出来的小伙子一直在推 Svelte,理由是「编译完体积小,首屏快」。最后谁也没说服谁,散场的时候我朋友问我怎么看。
我的感觉是,他们比的这几个维度——体积、性能、生态——到 2025 年基本已经被拉平得差不多了,纠结这些属于在错误的战场上消耗弹药。真正让你项目上线半年后天天加班到凌晨的,是几个很少被写进对比文章里的差异。
差异一:你的组件函数,一辈子跑几次
先看一段 React:
function Counter() {
const [n, setN] = useState(0)
console.log('组件函数又跑了一次')
return <button onClick={() => setN(n + 1)}>{n}</button>
}
这段代码每点一次按钮,console 就打印一次。组件函数会完整地从头执行到尾,包括所有的变量声明、所有的 useState 调用、所有你写在函数体里直接干的计算活。
换成 Vue 3:
<script setup>
const n = ref(0)
console.log('setup 只跑了一次')
</script>
<template><button @click="n++">{{ n }}</button></template>
这个 console 一辈子只打印一次。Svelte 的 <script> 块、Solid 的组件函数体,全是这个行为——只跑一次。
这个差异听起来很小,但它几乎是后面所有「怪规则」的总根源。
因为它反复执行,所以 Hooks 有了调用顺序要求
React 的 useState 其实是靠调用顺序来定位状态的——第一个 useState 对应第一个 state 槽,第二个对应第二个。所以官方文档和 eslint-plugin-react-hooks 会死命拦着你,不让你把它写进 if 里、写进循环里。
Vue 那边完全没有这回事,const n = ref(0) 就是一个真实存在的对象,你爱在哪创建就在哪创建(当然,把 ref 创建在 setup 外面在 SSR 下会漏内存,这是个另外的坑,Vue 官方文档专门有一页讲这个)。
闭包陷阱,和它换来的一个好处
React 每次渲染都是一个新的闭包,useEffect 里拿到的 count 就是那一帧的,所以你需要依赖数组,所以你踩过 stale closure 的坑,所以你有 useRef 这个逃生舱。
但反过来,React 因为每次都在重新执行函数,所以它反而没有「解构丢响应性」这个毛病。Solid 和 Svelte 5 才有,而且这个坑相当隐蔽:
// Solid
function Child(props) {
const { name } = props // 响应性从这里就断了
return <div>{name}</div>
}
因为 Solid 的 props 是个 Proxy,你要「访问属性」这个动作才会被追踪。你提前解构出来,只是取了个快照,之后父组件把 name 改成别的,子组件纹丝不动。官方给的解法是用 splitProps,或者干脆别解构。
Svelte 5 这边有个对照。let { count } = $props() 是官方支持的,编译器会特殊处理,没问题。但你要是解构一个 $state 对象,比如 let { x } = $state({ x: 1 }),那响应性就没了——$state 返回的是 Proxy,解构等于提前取值。Svelte 5 的文档专门用一段提醒这件事。
有意思的是 Vue 3.5(2024 年 9 月发布的那个版本)恰好把这个问题解决了。它给 const { count } = defineProps() 加了编译期的 Reactive Props Destructure 支持,解构出来还保持响应式。三个框架,一个靠 Proxy 追踪属性访问、一个靠编译期改写、一个把状态存在调用顺序里——这就是它们全部的性格。
差异二:编译时和运行时的分界线,决定了你调试时看到什么
这条没什么人写,但它对我的实际影响比性能大得多。
Svelte 和 Solid 走的是「尽可能在编译期把代码改写成直接操作 DOM 的语句」。好处是运行时小、跑得快;代价是你打开 devtools 打断点的时候,看到的变量名跟源码里的差挺远。Svelte 5 的 runes 编译完会出现 $.get()、$.set()、$.update() 这类内部函数调用,我在第一次看编译产物的时候愣了好几秒。
React 因为基本都是运行时解析,堆栈基本能对应上你的源码,但它的堆栈又长又难读,尤其是穿过 useState、useEffect、React Compiler 优化之后的那些匿名函数层,翻起来也挺费劲。
我只能说,两边都不好过,只是难受的方向不一样。
差异三:2025 年真正的战场,是水合边界
这一条是我认为最值得你花时间研究的。
React Server Components(Next.js 的 App Router 里默认用)本质上是把组件函数的执行整个挪到服务端,客户端拿到的是序列化后的结果流。但你要记住一件事:一旦你在文件顶部打了 'use client',从那个文件往下 import 的所有组件,全都变成了客户端组件,RSC 那点好处全没了。我见过有人图方便,在整个 layout 上加 'use client',等于白折腾。
Qwik 走的是另一条路,它把事件监听器的位置和 chunk 路径直接序列化进 HTML 属性里,客户端不需要重新执行整棵组件树。Astro 是 islands,只水合标了 client:load 或者 client:visible 的那些节点。Angular 19(2024 年 11 月发布)搞的增量水合也在这个方向上。
我在一台 2019 年的红米(大概是骁龙 665 那档)上量过一个中等复杂度的后台列表页:1500 行左右的表格节点,光水合阶段的主线程占用就到了 400 到 600 毫秒之间。这个数字放在旗舰机上根本看不出来,但那种机器在一些公司里还真的有人在用。
顺带说一句,React Compiler 从 2024 年 5 月的实验版本推到正式版,做的事是自动帮你插 useMemo 和 useCallback,减少子树的重渲染。但注意,它没有动 React 的根本契约——组件函数该重跑还是重跑,只是跑得更便宜了。
我自己的选型清单(带数字)
我不信那种「XX 更优雅」的说辞,我只列能被量化的东西:
| 维度 | 实际情况 |
|---|---|
| 基础包体积 | React 18 + ReactDOM 生产环境 min+gzip 大概 45KB;Vue 3 纯运行时官方数字约 34KB;Solid 约 7KB;Preact 约 3KB |
| 状态方案 | React 这边 Zustand 大概 1.2KB,Jotai 大概 3KB;Vue 的 Pinia 大概 1.5KB 上下;Solid 自带 createStore,不用额外装 |
| 数据请求 | TanStack Query 大概是 13KB 左右的量级,跨框架通用,Vue 版和 React 版都能用 |
| 表单 | React Hook Form 大概 9KB;Vue 这边一般是 VeeValidate 或 FormKit |
| 招人 | 这个没法看 GitHub star。直接去你们当地招聘网站搜一下岗位数,比任何 benchmark 都准 |
再补一句我自己的标准:如果团队不到 5 个人而且流动率高,我倾向 React,因为任何人进来都能立刻上手;如果是稳定的 2 到 4 人小队做面向 C 端的产品,我会认真考虑 SvelteKit 或者 Nuxt,前者在低端机上的首屏表现确实有可感知的差别。
最后
那天晚上散场之后,我跟朋友说了句可能有点扫兴的话:这次选型大概率不会决定你们项目的成败,后面代码写得怎么样才决定。框架换一个的成本,其实比把一段烂代码重构掉要低得多。
真要说我这几年的体会,就是别再把时间花在这张对比表上了。挑一个团队里没人反对的,先把第一版跑起来,比什么都实在。