前端鄙视链大家都懂:写 React 的看不起写 Vue 的,写 Vue 的看不起写 jQuery 的,写 jQuery 的看不起所有人。 本文作者,一个 React 一次都没用过的纯 Vue 党,写了个叫 vitarx 的框架。 结果 React 党来看:眼熟;Vue 党来看:眼熟;Solid 党来看:报警了,有人偷家。
序章:一段没人认得出的代码
tsx
<div v-if={isLoggedIn}>欢迎回来</div>
你以为这是 Vue? v-if 这个指令太眼熟了------Vue 十年的招牌。
但等等,Vue 的指令写在 template 的字符串属性里:
html
<!-- Vue -->
<div v-if="isLoggedIn">欢迎回来</div>
而这里,v-if 接的是花括号里的 TS 表达式------这是 JSX,React 的地盘。
那你以为这是 React? React 从来没有 v-if,它的信条是"一切皆 JS",条件渲染靠三元和 &&。
都不是。这是 vitarx:Vue 的指令语法,原封不动搬进 JSX 的花括号。
tsx
// vitarx:Vue 的指令 × JSX 的表达式
<div v-if={type.value === 'admin'}>管理员</div> // 假分支不建 DOM,切换即销毁重建
<span v-show={collapsed}>被折叠的</span> // display 切换,DOM 常驻
<SearchInput v-model={keyword} /> // 双向绑定
<div v-html={trustedHtml} /> // innerHTML
Vue 的指令清单几乎全数在场------只是值从字符串变成了真正的 TS 表达式,由 TypeScript 亲自做类型检查。这一点 Vue 的模板做不到(模板表达式的类型检查至今是短板),React 又没有语法可查。
形是 Vue 的形,骨是 JSX 的骨,类型是 TS 的类型。认亲游戏,从这里开始。
第一回合:React 的「形」
tsx
export default function Counter(props: { initial?: number }) {
const count = ref(props.initial ?? 0)
const inc = () => count.value++
return <button onClick={inc}>{count}</button>
}
函数组件、props 进、JSX 出------React 的组件形态,像素级复刻 。useState 没了,换成 .value 的 ref,这是后话。
真正的反转藏在框架的"体质"里,先按下不表------因为它牵出的,是第三回合的主角。
第二回合:Vue 的「骨」
ts
import { ref, reactive, computed, watch, watchEffect, nextTick,
toRefs, provide, inject, untracked, createScope } from 'vitarx'
这份导入清单,Vue 开发者可以闭眼上手 :ref、reactive、computed、watch、watchEffect、toRefs、nextTick、provide/inject 一个不少;createScope 对应 Vue 3.2 的 EffectScope;组件写法也是熟悉的 defineExpose、mergeProps、h() 渲染函数。
但放大看,细节全是伏笔:
ts
// computed 的 getter 第一个参数是【旧值】------Vue 没有
const total = computed((oldValue) => {
console.log('上一次是', oldValue)
return items.value.reduce((s, i) => s + i.score, 0)
})
ts
// untracked:非追踪执行,读取不建立依赖------Solid 的 untrack 改了个名
untracked(() => console.log(snapshot.value))
还有一处刁钻的边界,构成 vitarx 最著名的"防呆设计":
tsx
{count} // ✅ 简单引用,自动等价 {count.value}
{count > 0 && <div/>} // ⚠️ 复合表达式不解包!必须显式写 count.value
// 否则 ref 对象恒为 truthy,条件永真且不追踪依赖
Vue 的响应式全家桶,Solid 的影子,vitarx 自己的规则。第二次扑空。
第三回合:Solid 的「魂」------这才是它真正的底色
回到第一回合埋的伏笔:这个"React 长相"的组件,体质和 React 完全不同。
| React | vitarx | |
|---|---|---|
| 组件函数执行次数 | 每次状态变化重跑一遍 | 一辈子只执行一次 |
| 更新方式 | 重渲染 + 虚拟 DOM diff | 信号级精确更新:数据变哪,DOM 动哪 |
useMemo / useCallback |
依赖数组优化税 | 不存在,也不需要 |
| 闭包陈旧值 | 经典事故现场 | 不存在,函数体没有"第二次" |
| 依赖数组漏写 | useEffect 事故之首 |
无依赖数组,自动追踪 |
组件函数只执行一次,没有任何"重渲染"概念------这不是 React 的灵魂,是 Solid 的哲学。而且 vitarx 走得更彻底:没有虚拟 DOM diff 这一层,更新由编译器直接定位到 DOM 节点。
控制流更是直接从 Solid 家里搬来的:
tsx
<For each={items} key="id">
{(item, index) => <li>{item.name}</li>}
</For>
<Switch fallback={<div>未知状态</div>}>
<Match when={status === 'loading'}>加载中...</Match>
<Match when={status === 'error'}>出错了</Match>
<Match when={status === 'success'}>加载成功</Match>
</Switch>
对照 Solid,连 fallback 的命名都一样。但 vitarx 有自己的铁律------响应式列表禁止 .map() 渲染:
tsx
{items.map(i => <li>{i.name}</li>)} // ❌ 无 diff 架构下,整列表推倒重建
<For each={items} key="id">...</For> // ✅ keyed 复用 + 信号级增量更新
生命周期也站在 Solid 这一侧:清理用 onDispose(Solid 的 onCleanup 换了个名),不存在 onUnmounted:
tsx
onMounted(() => { timer = setInterval(() => count.value++, 1000) })
onDispose(() => clearInterval(timer)) // 启动/清理一一配对,随组件作用域自动回收
第四回合:视图控制------Vue 的老朋友们压轴登场
tsx
<Teleport to="body"><Modal /></Teleport> // Vue 的 Teleport
<Transition name="fade" mode="out-in" appear>
{show.value && <div class="box" />}
</Transition> // Vue 的 Transition,6 个 CSS 类名一个不少
<Freeze is={currentTab} max={5} /> // KeepAlive 换了个名字,include/exclude/max 齐全
<Suspense fallback={<span>加载中</span>}>
<HeavyChart /> // lazy 从 vitarx 导入,不是 React.lazy
</Suspense>
v-enter-from → v-enter-active → v-enter-to,Vue 的过渡类名体系原样移植,外加 12 个 JS 钩子可配 GSAP。React 开发者看到这里,已经放弃了辨认。
第五回合:路由------三个人的影子,一个人的骨架
ts
const router = createRouter({
routes: defineRoutes(
{ path: '/', component: Home },
{ path: '/user/{id}', name: 'user', component: User, pattern: { id: /^\d+$/ } },
{ path: '/admin', component: AdminLayout, children: [
{ path: '', component: Dashboard }
] }
),
mode: 'path',
beforeEach: guard
})
配置式路由表、嵌套 children、全局守卫 ------vue-router 的骨架。但参数语法是 {id} 花括号,React Router 和 vue-router 都写 :id,没人这么写。它还玩了一手 Next.js 的文件路由:
bash
src/pages/
├── index.tsx → /
├── user/[id].tsx → /user/{id} (方括号文件名,Next.js 风格)
├── _layout.tsx → 同目录布局(Remix 风格)
└── _config.ts → 目录级路由配置
外加编译期宏 definePage(unplugin-vue-router 的既视感):
tsx
definePage({
name: 'userDetail',
meta: { title: '用户详情', requiresAuth: true },
pattern: { id: /^[0-9]+$/ }
})
vue-router 的守卫体系 + Next.js 的文件约定 + 自创的花括号参数------路由这块,它谁的都不认。
彩蛋抓包:defaultProps 不是遗风,是必然
tsx
Button.defaultProps = { type: 'default', size: 'medium' }
React 已经废弃了 defaultProps,官方替代是参数解构默认值。vitarx 为什么不跟进?
因为这条路在它的响应式模型下是死路。 React 的 props 是每次重渲染重新传入的普通对象,入口解构无所谓快照;而 vitarx 的 props 编译为惰性 accessor,派生计算依赖"在动态上下文里读 props.x"建立追踪------一旦解构 const { x } = props:
- 函数体只执行一次 →
x变成永久快照,后续变化丢失; - 想靠编译器救也不行------TSX 里的解构是任意位置的任意模式(别名、嵌套、rest、对象整体外传),编译器无法可靠重写回访问器。Vue 3.5 试过(reactive props destructure),也只有在 SFC 编译器全权接管模板的前提下才能成立,至今仍是实验特性。
所以 defaultProps 在 vitarx 反而是最自洽的设计 :它在视图构建阶段合并一次,props 依旧走 accessor,响应式链路完全不受污染。一个由响应式模型反向约束语法设计的典型案例------框架的每个"不合时宜",背后都是一条走不通的路。
类似的"倔强"还有:
- 无 Vue 式隐式属性透传 :给组件传
class,不在 props 里显式声明就被静默丢弃------把"组件 API"钉死在类型上; v-model只认modelValue:自定义属性走useModel(props, 'xxx')+WithModelEvent类型,双向绑定的边界清清楚楚;class与className同用直接编译报错(E016):不留运行时玄学。
终章之一:基因检测报告
| 特征 | 认亲结果 |
|---|---|
(props) => JSX 函数组件、defaultProps |
React 的形态 |
只执行一次、信号级细粒度更新、无 vdom diff、untracked / onDispose、<For> / <Switch> / <Match> |
Solid 的灵魂 |
ref / reactive / computed / watch / provide / inject / nextTick / h() / mergeProps / defineExpose / EffectScope |
Vue 的响应式与组件 API |
v-if / v-show / v-model / v-html / v-bind 指令、Transition / Teleport |
Vue 的模板语法与视图控制 |
Suspense + lazy、onError 错误边界 |
React / Vue 联名 |
路由 {id} 参数、[id].tsx 文件路由、definePage 宏 |
Next / Remix / unplugin-vue-router 的影子,骨架是 vue-router |
computed 带旧值、<Freeze>、dynamic()、builder()、ref 解包边界、无隐式透传、编译期校验错误码(E006-E016) |
vitarx 独家 |
终章之二:它真正想做的事
认亲游戏到此结束。如果只把它当成"缝合怪",就错过了重点------那些熟悉的 API 之下,是五个一以贯之的设计主张:
一、把更新成本从 O(组件) 压到 O(节点)。 没有重渲染、没有虚拟 DOM diff,状态变化由信号直接驱动对应 DOM 节点。性能不靠开发者优化(useMemo/memo),靠架构本身。
二、"只执行一次"消灭一整类 bug。 没有第二次执行,就没有闭包陈旧值、没有 stale props、没有依赖数组焦虑。函数组件写过三年的人,都为 useEffect 的依赖数组付出过代价------这里的答案是:不存在。
三、类型安全贯穿到模板层。 指令的值是真正的 TS 表达式(Vue 模板做不到),IfBlock 让 v-if 链正确收窄类型,WithModelEvent 让双向绑定可推导,路由参数有 RouteIndexMap 类型映射。
四、编译器不只是转译,还是守门员。 branch / expr / accessor 三件套在编译期做静态优化;<Switch> 里混入非 <Match> 子元素、<For> 丢 key、class 撞 className------这些错误在编译期报错,而不是运行时才发现。
五、反直觉之处皆有出处。 无 fallthrough、v-model 收窄、ref 解包边界、保留 defaultProps------每一条"为什么不一样"背后,都是响应式模型推演后的必然,而不是标新立异。
结语
第一次写 vitarx,你会不断经历这样的时刻:
"咦,这不是 Vue 吗?"------写到一半发现组件函数只执行一次。 "那这肯定是 React。"------结果它没有重渲染,没有虚拟 DOM diff。 "......Solid?"------可它的状态明明是
ref(0),它的指令明明是v-if。
它确实"缝合"了三家:React 给了它组件的形态,Vue 给了它响应式的骨架和模板的语法,Solid 给了它更新的哲学 。但缝合不等于拼凑------computed 比 Vue 多一个旧值参数,v-model 只认 modelValue,<For> 强制 keyed,&& 左侧必须显式布尔。每一条偏差,都是模型推演的结果。
它不打算让你把任何一家的肌肉记忆原样搬进来。它只是把三家的好东西摆上同一张桌,然后告诉你:
"你以为你认识我?先写三百行再说。"