"你又在造轮子?"——当我决定不再追逐前端潮流,自己写一个框架

0 阅读14分钟

被技术迭代追着跑的日子

前端圈的节奏越来越快。

Vue 3 刚稳定没多久,React Server Components 开始铺开;Next.js 版本号一路狂飙,Remix 紧追不舍;Solid 开始崭露头角,Qwik 说自己是下一代;Svelte 5 的 Runes 语法又来了...

打开技术社区,满眼都是新框架、新工具、新范式。今天学的明天可能就过时了,上周还在讨论的最佳实践,下周就被推翻。

更让人疲惫的是生态链上的"套娃":用 Vue 就得学 Pinia + VueUse + Element Plus + VitePress;用 React 就要选 Redux/Zustand + React Query + Ant Design + Umi... 每一层都有自己的学习成本和坑。

我不是在写代码,我是在背文档。

一个"离经叛道"的决定

有一天我停下来问了自己一个问题:

如果我把花在学习这些框架上的时间,用来从零写一个自己的框架,会怎样?

这个想法一冒出来,我自己都觉得荒谬。网上到处都是这样的声音:

  • "别造轮子了,Vue/React 已经够好了"
  • "你写的能比得上团队维护了几年的项目吗?"
  • "重复造轮子是没有价值的"
  • "有这时间不如多学几个库"

这些话我都听过,甚至我自己也说过类似的话。但这一次,我想换个活法。

与其一辈子追着别人的车跑,不如自己造一辆能开的。

从虚拟 DOM 到信号驱动:我的技术探索之路

第一阶段:踩在前人的肩膀上

我没有从零发明一种全新的范式——那不现实。我参考了各种框架的公开文档和社区讨论,了解它们的设计思路和解决的问题,然后用自己的方式去实现。

虚拟 DOM 为什么流行?

它解决了一个真实问题:直接操作 DOM 太慢了,而且跨平台困难。用 JavaScript 对象描述 UI 结构,然后批量更新,这是一个优雅的抽象。

但我很快发现了一个问题:Diff 算法的开销是不可避免的

// 这是一个很常见的场景
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  )
}

todos 数组中某一项的 text 变化时,整个组件会重新执行,生成新的虚拟 DOM 树,然后 Diff 算法逐个比对 100 个 <li> 节点,最后发现只有 1 个变了。

明明只改了一个字,为什么要检查剩下 99 个?

第二阶段:从零摸索响应式系统

说实话,我并没有深入研究过 Vue 的源码——我看过的 Vue 源码加起来不超过 100 行。Vitarx 响应式系统的设计,更多是我在多次尝试、反复重构、与 AI 讨论方案的过程中逐步打磨出来的。

一开始我用的是最朴素的方式:

// 第一版:用 Set 存储依赖
const deps = new Set<Function>()

function ref(value) {
  return new Proxy({ value }, {
    get() {
      // 谁在读?记下来
      if (activeEffect) deps.add(activeEffect)
      return value
    },
    set(_, newValue) {
      value = newValue
      // 通知所有依赖者
      deps.forEach(fn => fn())
      return true
    }
  })
}

能用,但问题很多:依赖无法精确清理、重复触发、内存泄漏... 前后重构了不下十次,最终演变成了现在的双向链表依赖管理结构。这是 Vitarx 响应式引擎(@vitarx/responsive)的实际实现:

核心数据结构:DepLink 双向链表节点

/**
 * DepLink — 信号与副作用之间的双向链表节点
 *
 * 每个节点同时存在于两条链表中:
 * - Signal 链表:该信号被哪些 effect 依赖
 * - Effect 链表:该 effect 依赖了哪些信号
 */
class DepLink {
  // ── Signal 维度(该信号的所有订阅者)──
  sigPrev?: DepLink  // Signal 链表中的前驱
  sigNext?: DepLink  // Signal 链表中的后继

  // ── Effect 维度(该 effect 的所有依赖)──
  ePrev?: DepLink    // Effect 链表中的前驱
  eNext?: DepLink    // Effect 链表中的后继

  constructor(public signal: Signal, public effect: EffectRunner) {}
}

这个设计的精妙之处在于:通过一个节点同时维护两个维度的链表,实现了 O(1) 的依赖建立和 O(n) 的精确触发。

Track(依赖收集):建立链表关联

当副作用函数执行并访问响应式数据时:

export function trackSignal(signal: Signal): void {
  const activeEffect = currentActiveEffect  // 当前正在执行的 effect
  if (!activeEffect) return                  // 没有活跃 effect,不追踪

  // 创建或复用链表节点,挂到两条链表的尾部
  let link = activeEffect[DEP_INDEX_MAP]?.get(signal)
  if (!link) {
    link = createDepLink(activeEffect, signal)
    activeEffect[DEP_INDEX_MAP]?.set(signal, link)
  }
  link[DEP_VERSION] = activeEffect[DEP_VERSION]  // 标记为"本次仍访问"
}

// createDepLink 内部:
// 1. 挂到 signal 的 dep 链表尾部(signal → sigPrev ↔ link ↔ sigNext)
// 2. 挂到 effect 的 dep 链表尾部(effect → ePrev ↔ link ↔ eNext)

执行过程可视化:

执行 effect(() => { count.value + name.value })

步骤1: 访问 count → trackSignal(count)
  count 链表:  HEAD ←→ [linkA] ←→ TAIL   (新增 linkA)
  effect链表:  HEAD ←→ [linkA] ←→ TAIL

步骤2: 访问 name → trackSignal(name)
  name  链表:  HEAD ←→ [linkB] ←→ TAIL   (新增 linkB)
  effect链表:  HEAD ←→ [linkA] ←→ [linkB] ←→ TAIL

Trigger(触发更新):沿链表精确通知

当数据变化时,只需遍历 Signal 链表即可通知所有依赖者——不需要 Diff,不需要重渲染:

export function triggerSignal(signal: Signal): void {
  // 从 Signal 链表头部开始,逐个通知依赖它的 effect
  for (let link = signal[SIGNAL_DEP_HEAD]; link; ) {
    const next = link.sigNext  // 先保存下一个(effect 执行可能修改链表)
    link.effect()              // 直接调度 effect 执行
    link = next                // 移动到下一个
  }
}

关于 API 命名:refreactivecomputedwatcheffect 这些名字确实借鉴了 Vue 的风格——这不是因为 Vitarx 的实现和 Vue 相同,而是为了降低开发者的迁移成本和学习门槛。如果你熟悉 Vue,上手 Vitarx 几乎零成本。但底层实现完全是独立设计的。

第三阶段:Vitarx 的诞生

基于这些理解,我开始构建 Vitarx。核心理念可以概括为三点:

  1. 信号级精确更新 — 数据变化时,只更新关联的 DOM 节点
  2. 运行时视图树 — 保留完整的运行时结构,而非编译成静态代码
  3. 万物皆组件 — 所有内置组件都用公开 API 构建,无黑盒
import { ref } from 'vitarx'

function Counter() {
  const count = ref(0)

  return (
    <div>
      <p>Count: {count}</p>
      {/* count 变化时,只有这个 <p> 的文本节点会被更新 */}
      <button onClick={() => count.value++}>+1</button>
    </div>
  )
}

没有虚拟 DOM,没有 Diff,没有组件级重渲染。count 变化 → 沿双向链表定位到依赖它的文本节点 → 更新文本。三步完成。

"造轮子"给我带来了什么?

1. 对技术的真正掌控感

以前用框架时,遇到奇怪的行为我只能去搜 Issue、看文档、问社区。现在我遇到问题可以直接去看底层实现——因为那就是我自己写的。

案例:Teleport 传送门组件

在很多框架中,传送门(Portal)是底层运行时的特殊能力,开发者只能调用无法定制。但在 Vitarx 中,Teleport 就是一个普通函数组件,核心源码如下:

/**
 * Teleport 组件 - 将子组件渲染到 DOM 树的其他位置
 *
 * 全部使用 vitarx 公开 API,没有任何黑盒操作
 */
function Teleport({ children, to, defer, disabled }) {
  const child = useFastChild(children)

  if (!child) return createCommentView('Teleport:empty')
  if (disabled) return child

  // 锚点占位符留在原位
  const placeholder = createCommentView(`Teleport:${to}`)
  const instance = getInstance()

  onInit(() => { child.init(instance.subViewContext) })

  const mount = () => {
    const target = getTarget(to, instance.view.location)
    if (target) child.mount(target)  // 挂载到目标容器
  }

  defer ? onMounted(mount) : onBeforeMount(mount)

  onShow(() => { if (child.isRuntime && !child.isActive) child.activate() })
  onHide(() => { if (child.isRuntime && !child.isActive) child.deactivate() })
  onDispose(() => child.dispose())

  return placeholder
}

整个组件用到的全部是 Vitarx 公开 API:

API来源包用途
useFastChildruntime-core获取并缓存子视图
createCommentViewruntime-core创建注释节点作为锚点
getInstanceruntime-core获取当前组件实例
onInit/onMounted/onBeforeMount/onDispose/onShow/onHideruntime-core生命周期钩子

案例:自定义 Canvas 渲染器

万物皆组件的理念不仅限于内置组件的重写——你甚至可以替换整个渲染后端。Vitarx 通过 ViewRenderer 接口抽象了所有 DOM 操作,只需实现这个接口就能把 JSX 渲染到任何目标:

import { setRenderer, type ViewRenderer } from 'vitarx'

// 自定义 Canvas 渲染器 —— 实现 ViewRenderer 接口
const canvasRenderer: ViewRenderer = {
  // ── 创建节点:用轻量对象代替 DOM 元素 ──
  createElement(tag, parent)       { /* 创建元素节点 */ },
  createText(text)                 { /* 创建文本节点 */ },
  createComment(text)              { /* 创建注释节点(锚点占位)*/ },
  createFragment(view)             { /* 创建片段(For/Suspense 等容器)*/ },

  // ── 类型判断 ──
  isElement(node)                  { return !!node?.__canvas__ },
  isSVGElement()                   { return false },
  isMathMLElement()                { return false },
  isFragment(node)                 { return !!node?.__fragment__ },

  // ── 节点操作 ──
  append(child, parent)            { /* 挂载子节点到父容器 */ },
  insert(child, anchor)            { /* 插入到锚点之前 */ },
  replace(newNode, oldNode)        { /* 替换旧节点 */ },
  remove(node)                     { /* 从文档中移除节点 */ },

  // ── 内容与属性 ──
  setText(node, text)              { /* 更新文本内容 */ },
  setAttribute(el, key, next, prev){ /* 设置属性 / 绑定事件 */ }
}

// 注册后,所有 JSX 节点的创建、挂载、更新、销毁都走这个渲染器
setRenderer(canvasRenderer)

14 个方法,覆盖了框架与平台交互的全部能力。 实现它,你就能让 Vitarx 把 JSX 渲染到 Canvas、WebGL、终端 UI——任何你能想到的目标。

ViewRenderer 接口定义了 14 个方法,覆盖了创建、类型判断、结构操作、内容更新、属性修改等所有平台交互能力:

方法分类方法用途
创建createElement / createText / createComment / createFragment创建各类型的视图节点
类型判断isElement / isSVGElement / isMathMLElement / isFragment运行时节点类型识别
结构操作append / insert / replace / remove节点的增删改
内容setText文本内容更新
属性setAttribute属性设置与事件绑定

这意味着 Vitarx 的渲染层是完全可插拔的——DOM 只是默认选项,你可以渲染到 Canvas、WebGL、甚至终端 UI。

没有任何隐藏的特殊处理,没有编译器的魔法指令。

2. 不再被 API 变动绑架

React Hooks 的依赖数组规则变过好几次。每次升级都要检查 useEffectuseMemouseCallback 的依赖是否正确。Vue 3 的 <script setup> 语法糖也经历过多轮调整。

但当你理解了底层的响应式原理后,这些 API 层面的变动不再是威胁。你知道它们在解决什么问题,也知道如果换一种方式该怎么实现。

3. 极高的可扩展性

Vitarx 的所有内置组件——ForSuspenseLazyFreezeTransitionTeleport——全部是用公开 API 构建的普通组件。

这意味着你可以:

  • 查看源码理解每一行是如何工作的
  • 复制一份修改成符合你业务需求的版本
  • 从头写一个同样能力的组件,因为你有相同的 API
  • 换掉渲染器把 JSX 渲染到你想要的任何目标上

这不是理论上的可能性,而是设计层面的保证。

性能实测:数据说话

我不打算吹嘘 Vitarx 性能天下第一——那是骗人的。但客观的 Benchmark 数据说明它在高频更新场景下确实有优势。

以下数据来自 JS Framework Benchmark,数值越小越好(单位:毫秒):

高频更新场景(Vitarx 的主战场)

部分更新(每10行修改1行)
Vue 3.6  ████████████████████ 19.1ms
React 19 ████████████          11.2ms
Vitarx   ██████████            9.2ms  ✅ 最快

选中行高亮(点击一行变色)
Vue 3.6  ██████████            9.8ms
React 19 ███████               5.8ms
Vitarx   ████                  4.5ms  ✅ 最快

交换两行位置(拖拽排序)
Vue 3.6  ██████████           10.7ms  🥇 并列最优
React 19 ████████████████████████████████████████████ 67.6ms
Vitarx   ██████████           10.4ms  🥇 并列最优

综合指标对比

测试场景Vue 3.6React 19Vitarx 4.0结论
部分更新(每10行)19.1ms11.2ms9.2msVitarx 最快
选中行高亮9.8ms5.8ms4.5msVitarx 最快
交换两行10.7ms67.6ms10.4msVitarx ≈ Vue
删除一行20.9ms18.1ms17.2msVitarx 最快
包体积(压缩)22.9KB51.4KB17.0KBVitarx 最小
首次绘制134.4ms359.6ms137.7msVitarx 接近原生
创建1000行81.9ms82.4ms112.2msVue/Vue 最快
创建10000行880.2ms1143.5ms1104.1msVue 最快

结论很清晰:在高频状态更新场景下(选中、交换、部分修改),Vitarx 的细粒度更新策略优势明显。而在大规模静态创建场景下,成熟框架的优化更到位。

性能很重要,但它不是唯一重要的东西。架构的可理解性、可扩展性、可控性,同样是衡量一个框架价值的关键维度。

回应质疑:"造轮子"到底有没有价值?

质疑一:"你写的能比得上 Vue/React 吗?"

坦白说,在很多方面确实比不上。Vue 有庞大的团队和多年的打磨,React 有 Meta 的工程体系支撑。Vitarx 在成熟度、生态丰富度、工具链完善程度上都有差距。

但这不是重点。

重点是:通过亲手实现,我对前端框架的理解达到了一个以前无法企及的高度。这种理解力是通用的——即使以后我用回 Vue 或 React,我也是带着"知其所以然"的心态在使用,而不是照着文档抄代码。

质疑二:"重复造轮子是浪费时间"

如果你只是复制粘贴一份已有的东西,那确实是浪费时间。但如果你的目标是深入理解,那么"造轮子"可能是最高效的学习方式。

我在这个过程中学到的东西包括但不限于:

  • 响应式系统的双向链表依赖管理 — track/trigger 的链表操作、版本号增量清理
  • 视图树的设计与生命周期管理 — init/mount/update/dispose 完整流转
  • JSX 转换原理与自定义 JSX 运行时 — 不依赖官方 jsx-runtime
  • SSR 的流式渲染与客户端水合 — renderToString / renderToStream
  • 插件系统的设计模式 — App.use() 的链式调用与依赖注入
  • TypeScript 类型体操的实战应用 — 条件类型、模板字面量、infer

这些东西,靠读文档是学不到这么深的。

质疑三:"没人会用你自己写的框架"

这话有一定道理。让开发者切换框架是一个很高的门槛,需要足够的理由。

但 Vitarx 不是为了让所有人放弃 Vue/React 而存在的。它是为那些有特殊需求的人准备的:

  • 需要深度定制框架行为的项目
  • 对性能有极致要求的场景
  • 想要学习和研究框架原理的开发者
  • 追求架构透明度和可控性的团队

只要能帮助到这些人,这个框架就有存在的意义。

Vitarx 技术架构一览

┌─────────────────────────────────────────────┐
│                   vitarx                    │  ← 聚合包
├────────────┬─────────────┬──────────────────┤
│runtime-dom │ runtime-ssr │                  │  ← 平台适配层
├────────────┴─────────────┼──────────────────┤
│        runtime-core      │     utils        │  ← 运行时核心
├──────────────────────────┼──────────────────┤
│        responsive        │                  │  ← 响应式系统
└──────────────────────────┴──────────────────┘
包名职责核心 API
@vitarx/responsive响应式引擎(双向链表依赖管理)ref, reactive, computed, watch, effect
@vitarx/runtime-core组件与视图系统App, View, 生命周期, provide/inject, setRenderer
@vitarx/runtime-dom浏览器渲染DOM 渲染器, Transition, Teleport, 指令系统
@vitarx/runtime-ssr服务端渲染renderToString, renderToStream

快速体验

# 安装
npm install vitarx

# 或使用脚手架
pnpm create vitarx
import { ref, createApp } from 'vitarx'

function App() {
  const count = ref(0)

  return (
    <div className="app">
      <h1>Hello Vitarx</h1>
      <p>Count: {count}</p>
      <button onClick={() => count.value++}>+1</button>
      <button onClick={() => count.value--}>-1</button>
    </div>
  )
}

createApp(App).mount('#app')

从零开始:给你的第一个迷你框架建议

如果你也被这篇文章触动了,想自己动手试试,这里有一条从易到难的实践路线:

第一步:实现一个最小响应式系统(1-2天)

这是所有现代前端框架的基石。你需要实现:

// 目标API
const count = ref(0)

effect(() => {
  console.log('count is:', count.value)  // 打印: count is: 0
})

count.value = 1  // 自动触发上面的 effect,打印: count is: 1

核心要点

  • 用 Proxy 或 Object.defineProperty 拦截读写
  • 维护一个"当前活跃的 effect"全局变量
  • 读时收集依赖(track),写时触发更新(trigger)
  • 可以先用 Set 存储依赖,后续优化为双向链表

第二步:实现一个简易虚拟 DOM(2-3天)

// 目标API
const vdom = h('div', { class: 'app' },
  h('p', null, 'Hello'),
  h('button', { onClick: () => console.log('click') }, 'Click me')
)

render(vdom, document.getElementById('app')!)

核心要点

  • 实现 h() 函数创建虚拟节点
  • 实现 render() 递归创建真实 DOM
  • 实现 patch() 做简单的同层级 Diff
  • 处理属性更新和事件绑定

第三步:把响应式和视图结合起来(3-5天)

ref 的变化自动触发视图更新——恭喜你,你已经拥有了一个最原始的前端框架雏形!

后续方向(按兴趣选择)

  • 加 JSX 支持:配置 Babel/TypeScript 的 jsx 运行时
  • 加组件系统:函数组件 + 生命周期 + Props
  • 加内置组件:For(列表)、If(条件)、Portal(传送门)
  • 加 SSR 能力:renderToString 服务端渲染
  • 换渲染目标:Canvas/WebGL/终端 UI

每完成一步,你对前端框架的理解就会深一层。 不需要一步到位,先跑起来再说。

写在最后

做 Vitarx 这两年,是我作为开发者成长最快的两年。

不是因为写了多少行代码,而是因为我终于跳出了"使用者"的思维局限,开始以"创造者"的视角看待技术。每一个设计决策背后的权衡,每一个 bug 背后的根因,每一种架构模式的优劣——这些都是被动使用框架时无法获得的体验。

如果你也厌倦了无休止地追逐技术潮流,如果你想真正理解手中的工具而不仅仅是使用它,不妨试试从零开始写点什么。

重要的是开始。

至于 Vitarx,它就在这里,开源、透明、欢迎审视。无论你是想用它来做项目,还是想通过阅读它的源码来学习框架原理,都欢迎:

  • 🌟 GitHub — 给个 Star 是最大的支持
  • 📖 官方文档 — 完整的使用指南与 API 文档

造轮子的意义不在于轮子本身,而在于你在造的过程中所获得的一切。