我第一次看到 AngularJS 的 ng-model 时,是真的觉得网页活过来了。

以前写页面,数据和 DOM 像住在两栋楼里,改完这边还得跑去通知那边。双向绑定一出来,输入框里的字和代码里的状态突然有了联系。那种感觉很像框架替我接走了一件麻烦事。

后来麻烦又从别的地方长了回来。$scope、Digest Cycle、复杂指令和偶尔不得不手动调用的 $apply(),每一样都有道理,凑在一起却让人心里没底。页面越大,我越分不清到底是我在控制框架,还是我在背框架的脾气。

Vue 1 出现以后,我很喜欢它。不是因为它发明了完全不同的东西,而是 AngularJS 里那些让我兴奋的能力,第一次没有带着同等分量的别扭一起过来。

这些年继续写 Vue 和 React,也一直看着 SolidJS、Svelte 往前走,我判断框架的方式慢慢变了。以前看到一个漂亮 API,我会先想「这写起来真爽」;现在我更想知道,项目写到第三年以后,这份爽是谁付的钱。

框架当然不可能消灭复杂度。它只能把复杂度放到某个地方。可能是运行时,可能是编译器,可能是框架作者,也可能是每天写业务的开发者。

我在意的是最后一种。

React 的笨办法,反而给了我一种确定感

早期 React 并不好写。Class 组件里的 this、分散的生命周期、HOC 和 Render Props,再加上那几年颇有仪式感的 Redux,谁要说那套体验简单,我是不太信的。

Hooks 把很多能力收进了函数组件,却又带来了另一套需要认真学习的规则。useEffect 的依赖、闭包里的旧值、状态更新的时机,都不是看一眼 JavaScript 语法就能自然推出来的。

不过 React 有一个我一直很看重的特点。它愿意把一部分不精确留在系统内部,换取业务代码里相对稳定的读取方式。

const [count, setCount] = useState(0)

console.log(count)
save(count)
const doubled = count * 2

我在原来的文章里写过「count 就是一个数字」。重写时再看,这句话不够严谨。React 的官方文档说得更准确,State 是当前这次渲染的一份快照,调用 setter 请求的是下一次渲染,不会立刻改变手里的这个值。

但在一次渲染内部,count 的读取确实保留了普通值的感觉。它可以直接比较、传参和计算,不需要开发者一路记着哪里要解包,哪里拿到的是响应式容器。React 可能因此多做一些工作,也可能让更新范围比理想状态更大,可普通代码通常先保证语义成立,再讨论哪里值得优化。

这不是说 React 更简单。它只是把账记在了一个我更容易看见的位置。闭包和 Effect 的账仍然要还,但我不必在每一次属性读取时重新确认响应性有没有断掉。

Vue 3 真正累人的,不只是多写一个 .value

我对 Vue 3 的不满,过去很容易被缩成一句「我讨厌 .value」。这么说很痛快,但也有点冤枉 Vue。

ref 把响应式引用显式包装起来,本身是一个合理设计。Vue 的文档现在也明确推荐用 ref() 作为声明响应式状态的主要 API。真正增加认知成本的,是同一个值会随着所在位置不同,呈现出不同的访问规则。

const count = ref(0)

count.value++        // 脚本里需要 .value
// 模板里通常直接写 count

再加上 reactive 以后,问题就不只是多了几个字符。开发者得知道模板如何解包、数组和集合里为什么又不解包、解构会不会切断追踪、第三方库拿到的是 Proxy 还是原始对象。原本只描述业务关系的数据路径,中间开始夹着框架容器。

const state = reactive({ count: 0 })
const { count } = state

count++ // 改的是局部变量,已经不再连接 state.count

这段代码不难,难的是它看起来太正常了。语法没错,类型也未必报警,页面只是从某个时刻开始不再按预期更新。

所以我并不觉得 Proxy 是坏技术。复杂对象的属性读写交给 Proxy 追踪,写起来非常自然,state.user.profile.name = '新的名字' 也比层层 setter 舒服。问题出在 Proxy、Ref、自动解包和普通值的边界进入了高频业务代码。toReftoRefstoRawmarkRaw、各种 shallow API 都有自己的使用场景,可它们也在提醒我们,框架把多少边界管理交给了使用者。

Vue 3 的能力很强。我担心的从来不是它做不到,而是团队里的每个人都要长期维持同一套正确姿势。小项目里这可能只是习惯,大项目里它会慢慢变成代码审查、团队规范和排错时间。

SolidJS 和 Svelte 都要细粒度,却给了两种答案

SolidJS 的 Signal 很聪明,也很诚实。

const [count, setCount] = createSignal(0)

count()
setCount(count() + 1)

按照 SolidJS 的定义createSignal 返回的就是 getter 和 setter。读取时调用 getter,写入时调用 setter,依赖关系在追踪作用域里建立。规则没有藏起来,也没有今天解包、明天不解包的猜测。

代价同样摆在脸上。一个原本像数字的东西变成了函数调用,复杂对象也可能出现 user().profile.name。Solid Store 可以用 Proxy 提供更自然的属性访问,但这也说明一个现实,细粒度响应式总要找到一个能被追踪的入口。Solid 选择让这个入口尽量明确。

Svelte 5 的选择更大胆一些。

let count = $state(0)
let doubled = $derived(count * 2)

count++

count 的使用方式重新接近普通局部变量,依赖关系交给编译器处理。$state 遇到数组和简单对象时仍会创建深层 Proxy,$derived 也有明确的表达式与依赖规则。它并没有让 JavaScript 原生获得响应性,而是承认这段代码属于 Svelte 的语言层,由编译器负责赋予额外语义。

这点我很喜欢。不是因为 Svelte 消灭了边界,而是它把最常见的局部状态写得自然,让边界更多出现在编译、跨模块传递和对象身份这些相对低频的位置。

SolidJS 更像在说「响应式值就是一种特殊值,请明确地读写它」。Svelte 更像在说「你先按变量来写,特殊性由编译器接走」。两套方案都比模糊边界强,只是我个人更愿意把这份负担交给工具链。

有意思的是,大家都开始把更多工作交给编译器

如果还用「React 靠运行时,Svelte 靠编译器」来概括今天的前端,已经不太够了。

React Compiler 1.0 已经稳定发布。它会在构建时分析数据流和可变性,自动为组件与 Hook 做记忆化。React 没有因此改变 State 快照、组件重渲染和 Effect 的基本语义,但一部分过去由开发者手写 useMemouseCallback 承担的性能工作,正在被编译器收回去。

Vue 也在往这个方向走。截至我重写这篇文章时,Vue 3.6 已进入 RC,Vapor Mode 以完全可选的方式为 SFC 提供新的编译模式。它绕开 Virtual DOM,为更小的基础包和更直接的更新路径服务,同时允许在现有应用里局部采用。

不过这里要把期待放准。Vapor Mode 解决的重点是渲染路径和运行时成本,不是把 Vue 的 ref.value 和解构语义重新设计一遍。它支持的也是现有 Vue API 的一个子集,和传统 VDOM 组件混用仍有边界。

这反而让我更确定,编译器不是某个框架的风格标签,而是大家都在争取的一块复杂度预算。能在构建阶段可靠推导的东西,就没有必要永远让运行时猜,更没有必要让每个业务开发者手工维护。

只是编译器也不是免费的。它会带来构建链、诊断信息、版本兼容和调试上的新问题。差别在于,这些问题通常可以由少数工具作者集中解决,再让大量业务代码受益。相比把同一条规则分发给团队里的每个人反复记忆,我更愿意付这笔钱。

核心 API 之外,还有一笔生态账

只比较 useStateref、Signal 和 Rune,还是会漏掉真实项目里很大一部分成本。

Vue 让我纠结的地方恰好也在这里。它的响应式边界会渗进业务代码,但 Vue Router、Pinia、Vite、Nuxt 和 DevTools 又给出了一条相对集中的默认路线。很多选择不用在每个项目里重做一遍,团队招来一个熟悉 Vue 的开发者,也更容易对工程的大致形状形成共同预期。

React 的核心更克制,JSX 的组合能力也非常强,可路由、状态、数据请求、缓存和服务端渲染往往有多条成熟路线。选择多当然是自由,选择本身却也是工作。它会变成技术调研、团队争论、迁移方案,以及几年后某个库换方向时的维护成本。

所以「核心小」不一定等于「项目简单」,「官方能力多」也不一定等于「开发者负担重」。框架如果在核心之外提供了一套稳定生态,也是在替开发者承担复杂度。反过来,一个漂亮得像数学公式的响应式核心,如果把应用架构全部留给每个团队重新拼装,账并没有消失,只是换了会计科目。

这也是为什么我很难简单地说 Vue 不如 React,或者 Svelte 一定会赢。Vue 在状态边界上收了更多认知成本,却在生态一致性上还回来不少;React 让组件里的值更容易理解,却把更多架构选择留给项目;SolidJS 用显式规则换来精确;Svelte 把很大希望放在编译器上,也就必须持续证明工具链能守住这些语义。

我现在更关心的,是三年后谁还在背规则

写回开头那个 AngularJS 的瞬间,我才意识到,自己喜欢框架的理由其实一直没怎么变。

我喜欢的不是魔法。我喜欢的是原本需要我反复处理的事情,被一个更合适的层接走了。

当年的 ng-model 接走了数据同步,后来 AngularJS 又把 Digest Cycle 的脾气还给了开发者。今天的几个框架换了名字和实现,仍然在做同一道分配题。

React 接受一定程度的重渲染,再用 Compiler 收回部分手工优化;Vue 提供完整的响应式与官方生态,也让开发者理解 Ref 和 Proxy 的边界;SolidJS 把细粒度规则明确写进 getter 与 setter;Svelte 则让编译器尽量保住业务代码的自然形状。

我现在不会只问哪个框架更快,或者哪个 API 第一眼更漂亮。我会多问几句。正常业务代码是否默认正确?最容易踩的坑能不能被工具发现?跨模块以后语义会不会突然变化?当性能需要优化时,是改一个集中层,还是让整个团队到处补标记?

一个框架真正昂贵的地方,往往不是下载体积,也不是某次基准测试慢了几毫秒,而是那些所有开发者都要记住、又很难被机器验证的规则。

能由框架作者解决的,就别反复考业务开发者。

这是我现在对「简单」最朴素的理解。