为了脱离前端鄙视链,于是自己写个框架 - React 党看完沉默了

文章来源声明: 原文作者:下家; 来源站点:掘金; 原文链接:https://juejin.cn/post/7691201978224787498; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合对响应式原理与框架设计感兴趣的进阶前端:它示范了如何用编译期手段把性能与类型安全做进架构,而不是靠开发者自觉优化。

> 前端鄙视链大家都懂:写 React 的看不起写 Vue 的,写 Vue 的看不起写 jQuery 的,写 jQuery 的看不起所有人。 本文作者,一个 React 一次都没用过的纯 Vue 党,写了个叫 **vitarx** 的框架。 结果 React 党来看:眼熟;Vue 党来看:眼熟;Solid 党来看:**报警了,有人偷家。**

序章:一段没人认得出的代码

<div v-if={isLoggedIn}>欢迎回来</div>

你以为这是 Vue? v-if 这个指令太眼熟了——Vue 十年的招牌。

但等等,Vue 的指令写在 template 的字符串属性里:

<!-- Vue -->
<div v-if="isLoggedIn">欢迎回来</div>

而这里,v-if 接的是花括号里的 TS 表达式——这是 JSX,React 的地盘。

那你以为这是 React? React 从来没有 v-if,它的信条是"一切皆 JS",条件渲染靠三元和 &&。

都不是。这是 vitarx:Vue 的指令语法,原封不动搬进 JSX 的花括号。

// vitarx:Vue 的指令 × JSX 的表达式
<div v-if={type.value === 'admin'}>管理员</div>   // 假分支不建 DOM,切换即销毁重建
被折叠的</span>     // display 切换,DOM 常驻
<SearchInput v-model={keyword} />                  // 双向绑定
<div v-html={trustedHtml} />                 // innerHTML

Vue 的指令清单几乎全数在场——只是值从字符串变成了真正的 TS 表达式,由 TypeScript 亲自做类型检查。这一点 Vue 的模板做不到(模板表达式的类型检查至今是短板),React 又没有语法可查。

形是 Vue 的形,骨是 JSX 的骨,类型是 TS 的类型。认亲游戏,从这里开始。


第一回合:React 的「形」

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 的「骨」

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() 渲染函数。

但放大看,细节全是伏笔:

// computed 的 getter 第一个参数是【旧值】——Vue 没有
const total = computed((oldValue) => {
  console.log('上一次是', oldValue)
  return items.value.reduce((s, i) => s + i.score, 0)
})

// untracked:非追踪执行,读取不建立依赖——Solid 的 untrack 改了个名
untracked(() => console.log(snapshot.value))

还有一处刁钻的边界,构成 vitarx 最著名的"防呆设计":

{count}               // ✅ 简单引用,自动等价 {count.value}
{count > 0 && <div/>} // ⚠️ 复合表达式不解包!必须显式写 count.value
                      //    否则 ref 对象恒为 truthy,条件永真且不追踪依赖

Vue 的响应式全家桶,Solid 的影子,vitarx 自己的规则。第二次扑空。


第三回合:Solid 的「魂」——这才是它真正的底色

回到第一回合埋的伏笔:这个"React 长相"的组件,体质和 React 完全不同。

Reactvitarx
组件函数执行次数每次状态变化重跑一遍**一辈子只执行一次**
更新方式重渲染 + 虚拟 DOM diff**信号级精确更新**:数据变哪,DOM 动哪
`useMemo` / `useCallback`依赖数组优化税不存在,也不需要
闭包陈旧值经典事故现场不存在,函数体没有"第二次"
依赖数组漏写`useEffect` 事故之首无依赖数组,自动追踪

组件函数只执行一次,没有任何"重渲染"概念——这不是 React 的灵魂,是 Solid 的哲学。而且 vitarx 走得更彻底:没有虚拟 DOM diff 这一层,更新由编译器直接定位到 DOM 节点。

控制流更是直接从 Solid 家里搬来的:

<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() 渲染:

{items.map(i => <li>{i.name}</li>)}   // ❌ 无 diff 架构下,整列表推倒重建
<For each={items} key="id">…</For>    // ✅ keyed 复用 + 信号级增量更新

生命周期也站在 Solid 这一侧:清理用 onDispose(Solid 的 onCleanup 换了个名),不存在 onUnmounted:

onMounted(() => { timer = setInterval(() => count.value++, 1000) })
onDispose(() => clearInterval(timer))   // 启动/清理一一配对,随组件作用域自动回收


第四回合:视图控制——Vue 的老朋友们压轴登场

<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={加载中}>
  <HeavyChart />                               // lazy 从 vitarx 导入,不是 React.lazy
</Suspense>

v-enter-from → v-enter-active → v-enter-to,Vue 的过渡类名体系原样移植,外加 12 个 JS 钩子可配 GSAP。React 开发者看到这里,已经放弃了辨认。


第五回合:路由——三个人的影子,一个人的骨架

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 的文件路由:

src/pages/
├── index.tsx          →  /
├── user/[id].tsx      →  /user/{id}     (方括号文件名,Next.js 风格)
├── _layout.tsx        →  同目录布局(Remix 风格)
└── _config.ts         →  目录级路由配置

外加编译期宏 definePage(unplugin-vue-router 的既视感):

definePage({
  name: 'userDetail',
  meta: { title: '用户详情', requiresAuth: true },
  pattern: { id: /^[0-9]+$/ }
})

vue-router 的守卫体系 + Next.js 的文件约定 + 自创的花括号参数——路由这块,它谁的都不认。


彩蛋抓包:defaultProps 不是遗风,是必然

Button.defaultProps = { type: 'default', size: 'medium' }

React 已经废弃了 defaultProps,官方替代是参数解构默认值。vitarx 为什么不跟进?

因为这条路在它的响应式模型下是死路。 React 的 props 是每次重渲染重新传入的普通对象,入口解构无所谓快照;而 vitarx 的 props 编译为惰性 accessor,派生计算依赖"在动态上下文里读 props.x"建立追踪——一旦解构 const { x } = props:

  1. 函数体只执行一次 → x 变成永久快照,后续变化丢失;
  2. 想靠编译器救也不行——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`、`` / `` / ``**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` 带旧值、``、`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,&& 左侧必须显式布尔。每一条偏差,都是模型推演的结果。

它不打算让你把任何一家的肌肉记忆原样搬进来。它只是把三家的好东西摆上同一张桌,然后告诉你:

"你以为你认识我?先写三百行再说。"