Solid 初探:启发 Vue Vapor 的极致框架

文章首发于个人博客

前言

坦白讲,在准备写这篇文章之前,我其实完全没看过一点 Solid 源码,也完全没有过使用经验。学习的契机其实是源于 Vue 3.6 即将引入的 Vue Vapor 无虚拟 DOM 设计 ------ 正是受 Solid 启发。

Vue 3.6 另一大更新亮点 ------ 基于 Alien Signals 的响应式性能重构 ------ 可以移步《Vue Signals 进化论(v3.6):Alien Signals 终局之战?》

初见 Solid

让我们先看一个基础的示例,展示了 Solid 如何实现响应式计数器组件。

示例代码

Counter 组件会渲染一个 button,点击 button 后文本内容会递增。

jsx 复制代码
import { render } from "solid-js/web"
import { createSignal } from "solid-js"

function Counter() {
  const [count, setCount] = createSignal(1)
  const increment = () => setCount((count) => count + 1)

  return (
    <button type="button" onClick={increment}>
      {count()}
    </button>
  )
}

render(() => <Counter />, document.getElementById("app"))

编译输出

js 复制代码
import { template as _$template } from "solid-js/web";
import { delegateEvents as _$delegateEvents } from "solid-js/web";
import { createComponent as _$createComponent } from "solid-js/web";
import { insert as _$insert } from "solid-js/web";
import { createSignal } from "solid-js";
import { render } from "solid-js/web";

var _tmpl$ = /*#__PURE__*/_$template(`<button type=button>`);

function Counter() {
  const [count, setCount] = createSignal(1);
  const increment = () => setCount(count => count + 1);
  return (() => {
    var _el$ = _tmpl$();
    _el$.$$click = increment;
    _$insert(_el$, count);
    return _el$;
  })();
}
render(() => _$createComponent(Counter, {}), document.getElementById("app"));
_$delegateEvents(["click"]);

渲染效果 (截图)

在 Solid 演练场中尝试一下

上面的示例展示了 Solid 的两个核心功能:

  • 细粒度响应 :状态变化时,Solid 不会重新执行 Counter 函数,而是直接更新依赖 count 的 DOM 节点。

  • 无虚拟 DOM:Solid 没有类似 React/Vue 中的虚拟 DOM 机制,而是通过「编译时转换模板」+「运行时响应式绑定」,直接将状态变化映射到真实 DOM。

框架设计哲学

  • React 设计哲学是「不可变数据 」+「声明式 UI」。状态更新后,重新执行组件渲染函数来生成新的虚拟 DOM 树,最后通过虚拟 DOM Diff 更新真实 DOM ------ 这一过程也称为协调(Reconciliation)。为了兼顾渲染性能与一致性,React 后来还引入了 Fiber 架构,实现了可中断的"协调"过程。

  • Vue(<3.6) 设计哲学是「响应式系统 」+「声明式模板」。编译阶段将模板编译成 render 函数(组件层级 effect 包装)。运行时状态更新后,通过响应式自动追踪依赖,仅重新执行受影响的组件 render 函数,生成新的虚拟 VNode,最后通过虚拟 DOM Diff 更新真实 DOM。

    相比 React,Vue 实现了更精确地"按需"更新,DOM Diff 范围和复杂度也小很多,而且还引入了静态提升等编译时优化手段。这也是为什么同样都是虚拟 DOM,但 Vue 不需要 React Fiber 架构。

  • Solid 设计哲学是「细粒度响应式 」+「无虚拟 DOM」。在编译阶段就建立了更精确的依赖/信号与视图节点之间的依赖关系(DOM 层级 effect 包装)。运行时状态更新后,Solid 不会重新执行整个组件函数,也不会(重新)生成虚拟 DOM 树,而是通过响应式直接更新真实 DOM。

    相比 Vue 组件层级响应式,Solid 精确到最细粒度的 DOM 层级,实现更极致的性能。

编译时转换模板

下图是 Solid 函数式组件(Counter)编译前后的代码 Diff:
Solid 组件编译前后代码 Diff

核心的变化有如下三点。

静态模板提取与复用

jsx 复制代码
<button type="button" onClick={increment}>
  // ..
</button>

// ⬇

import { template } from "solid-js/web";
var _tmpl$ = /*#__PURE__*/template(`<button type=button>`);
function Counter() {
  return (() => {
    var _el$ = _tmpl$();
    return _el$;
  })();
}

template() 函数

template 函数源码简化如下:

js 复制代码
function template(html) {
  let node;
  const create = () => {
    const t = document.createElement("template");
    t.innerHTML = html;
    return t.content.firstChild;
  };
  const fn = () => (node || (node = create())).cloneNode(true);
  fn.cloneNode = fn;
  return fn;
}

懒加载 + 单例模式

  • 首次调用:执行 create() 创建模板。
  • 后续调用:直接使用缓存的 node,通过 cloneNode(true) 克隆。

首次:高效解析 HTML

  • <template> 是浏览器原生的惰性容器,解析 HTML 但不渲染,比 innerHTML 直接插入 DOM 更高效。

后续:高效克隆复用模板

  • 多次渲染某个组件实例时,通过浏览器原生 API cloneNode(true) 深度克隆,比重新创建或者重新解析 HTML 性能更快。

返回真实 DOM

React 组件返回的是虚拟 DOM(React.createElement),而且每次状态更新都会重新创建虚拟 DOM,最终通过 VDOM Diff 算法更新真实 DOM。Solid 组件只执行一次,直接返回真实 DOM(_tmpl$()),即使多次渲染组件实例也会通过克隆模板复用。

jsx 复制代码
// React(Virtual DOM)
function Counter() {
  // 每次重新创建 VDOM → diff → 更新真实 DOM
  return React.createElement('button', { type: 'button' }, ...);
}

// Solid(Non-Virtual DOM)
var _tmpl$ = template(`<button type=button>`);
function Counter() {
  // 返回真实 DOM,无中间层,无 diff
  return _el$ = _tmpl$();
}

事件处理

jsx 复制代码
<button type="button" onClick={increment}>

// ⬇

import { delegateEvents } from "solid-js/web";
function Counter() { 
  // ..
  return (() => { 
    var _el$ = _tmpl$(); 
    _el$.$$click = increment; 
    // ..
  })();
}
delegateEvents(["click"]);
  • 编译前:JSX 风格的事件绑定 onClick={increment}
  • 编译后:直接通过属性赋值 _el$.$$click = increment
  • 配合:插入 delegateEvents(["click"]) 实现事件委托。
  • 优势:避免每个元素单独绑定事件,减少内存占用。

全局事件委托

编译后顶层多了一行 delegateEvents(["click"])。我们先看看 delegateEvents 函数源码:

js 复制代码
function delegateEvents(eventNames, document = window.document) {
  const e = document[$$EVENTS] || (document[$$EVENTS] = new Set());
  for (let i = 0, l = eventNames.length; i < l; i++) {
    const name = eventNames[i];
    if (!e.has(name)) {
      e.add(name);
      document.addEventListener(name, eventHandler);
    }
  }
}

可见,delegateEvents(eventNames) 会在 document 层级为每个 eventNames 中的事件类型调用 addEventListener,这样全局只会注册一个 统一的事件处理器 eventHandler。相比为每个元素单独绑定事件处理器,大幅减少了监听器数量和内存开销。

eventHandler 函数源码简化如下。

js 复制代码
function eventHandler(e) {
  let node = e.target;
  const key = `$$${e.type}`;
  // 重写事件对象的 e.currentTarget 属性,指向当前处理事件的节点
  Object.defineProperty(e, "currentTarget", { get() { return node || document; }, configurable: true });
  while (node) {
    // 绑定事件处理器的属性
    const handler = node[key];
    if (handler && !node.disabled) {
      // 绑定事件处理器自定义参数的属性
      const data = node[`${key}Data`];
      data !== undefined ? handler.call(node, data, e) : handler.call(node, e);
      if (e.cancelBubble) return; // stopPropagation 效果
    }
    // 向上遍历
    node = node._$host || node.parentNode || node.host;
  }
}

核心思想:通过事件冒泡机制,从事件目标节点开始,沿着 DOM 树向上遍历查找绑定了对应事件处理器属性(例如 .$$click)的节点并执行。

事件处理器绑定

事件处理器会直接绑定到 DOM 属性上,这样在前面的 eventHandler 中就能通过 node[key] 访问到。

js 复制代码
_el$.$$click = increment;
// 如果有自定义参数,会绑定到 `.$$clickData` 属性
_el$.$$clickData = data;

通过「事件处理器绑定」+「全局事件委托」,Solid 实现了高效的事件处理机制,减少运行时监听器和开销。

响应式内容插入

jsx 复制代码
{{ count() }}

// ⬇
import { insert } from "solid-js/web";
function Counter() {
  // ..
  return (() => { 
    // ..
    insert(_el$, count); 
    // ..
  })();
}

insert() 函数

如上所示新增了 insert 方法,并且传入的 count 是通过 createSignal(1) 创建的 signal(getter 函数),而不是调用结果。

js 复制代码
import { createSignal } from "solid-js";

const [count, setCount] = createSignal(1);
//       ^ getter  ^ setter

console.log(count()); // prints "1"

setCount(0); // changes count to 0

console.log(count()); // prints "0"

insert 方法实现如下,运行时内部会为这个 signalaccessor)创建一个响应式的 effect,自动追踪依赖并更新。

js 复制代码
function insert(parent, accessor, marker, initial) {
  if (marker !== undefined && !initial) initial = [];
  if (typeof accessor !== "function") {
    return insertExpression(parent, accessor, initial, marker);
  }
  // 关键:为函数类型的 accessor 创建响应式 effect
  createRenderEffect(
    current => insertExpression(parent, accessor(), current, marker), 
    initial
  );
}

运行时原理

Solid Signals

Solid 的 signaleffect 实现原理其实和 Vue 等其他框架差不多,在 API 设计风格以及实现细节上有所差异。比如 Solid 的 createSignal() API 设计强调了读/写隔离。信号通过一个只读的 getter 和另一个单独的 setter 暴露,通过函数调用而非 Proxy 实现读取拦截。

js 复制代码
import { createSignal } from "solid-js"

const [count, setCount] = createSignal(0)

count() // 访问值
setCount(1) // 更新值

正如 Vue 官方文档所述,Vue 同样可以复刻类似 API:

js 复制代码
import { shallowRef, triggerRef } from 'vue'

export function createSignal(value, options) {
  const r = shallowRef(value)
  const get = () => r.value
  const set = (v) => {
    r.value = typeof v === 'function' ? v(r.value) : v
    if (options?.equals === false) triggerRef(r)
  }
  return [get, set]
}

关于更多实现细节,感兴趣可直接看源码或者之前发过的博客,这里不展开赘述。

细粒度响应式 Fine-Grained Reactivity

前面我们提到在编译时模板的动态内容 count() 会被转换成 insert(_el$, count)。在运行时,insert 方法会为这个 signal 创建一个响应式的 effect,自动追踪依赖并更新。

js 复制代码
<button type="button">
  {count()}
</button>

// ⬇
insert(_el$, count);

// ⬇
function insert(parent, count) {
  createRenderEffect(current => insertExpression(parent, count(), current))
}

当点击 button 后,count 的 setter(setCount)方法被调用时,Solid 会触发 effect 重新执行 insertExpression

insertExpression()

insertExpression 源码其实比较复杂,需要满足现实可能的各种渲染场景,这里为方便理解,针对 Counter 组件渲染场景简化后的代码如下:

js 复制代码
function insertExpression(parent, value, current) {
  if (value === current) {
    // 相等性检查
    return current
  }

  const t = typeof value
  if (t === "string" || t === "number") {
    if (t === "number") {
      value = value.toString()
      if (value === current) return current
    }
    if (current !== "" && typeof current === "string") {
      // 点击更新
      current = parent.firstChild.data = value
    } else {
      // 初次渲染
      current = parent.textContent = value
    }
  }
  // ..

  return current
}

初次渲染

初次渲染时,current 为空,执行 parent.textContent = value,button 内创建一个文本节点,内容为 "1"。没有重建任何外层 DOM,只是设置节点 textContent。并且在 effect 内部会将 current 更新为字符串 "1"

点击更新

点击 button 后,setCount 被调用,count() 变为 2,触发 effect 重新执行 insertExpression。此时 current 已经是字符串 "1",执行 parent.firstChild.data = value,直接将 button 内部文本节点的内容更新为 "2"。没有替换节点和 diff,也不需要重新渲染 Counter 函数。

一点思考

在我看来 Solid 真正"独领风骚"的地方并不在于它引入了响应式,因为 Vue、Preact、Svelte 等等也都有,而且 Solid 响应式性能也并不是最好的那一档。真正出色的其实是"细粒度响应式"中前半部分的"细粒度"三字。

响应式固然重要,但只有结合无虚拟 DOM 的设计和模板编译转换,才能够实现 DOM 级别的细粒度更新。前者更偏通用技术的实现,而后者更多是框架层面的设计哲学。这种能站在架构层面的优化设计是更加难能可贵的。

Vue 3.6+

受 Solid 启发的,Vue 其实很早就开始探索一种类似的编译策略 ------ Vapor Mode ------ 不依赖于虚拟 DOM 而是更多地利用 Vue 的内置响应性系统,即将在 Vue 3.6 中落地。引入 Vapor Mode 之后,Vue 并没有完全摒弃虚拟 DOM,而是提供了用户可选的渲染模式,"双引擎"模式也更具灵活性。

目前已经可以在 Vue Playground 中尝试 Vapor 模式(选择 Vue 3.6-alpha 版本),在 script setup 中使用 vapor 语法糖开启 Vapor Mode。同样一个简单的计数器组件示例代码如下所示:

html 复制代码
<script setup vapor>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">
    {{ count }}
  </button>
</template>

VDOM Mode

这段代码如果不加 vapor 语法糖,也就是常规的虚拟 DOM 渲染模式,编译输出的代码会是这样的:

js 复制代码
function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock(
      "button",
      {
        onClick: _cache[0] || (_cache[0] = ($event) => $setup.count++),
      },
      _toDisplayString($setup.count),
      1 /* TEXT */
    )
  );
}

_createElementBlock 函数会创建一个 VNode(虚拟节点)。

Vapor Mode

而如果加上 vapor 语法糖,编译输出的代码会是这样的:

js 复制代码
import {
  txt as _txt,
  toDisplayString as _toDisplayString,
  setText as _setText,
  renderEffect as _renderEffect,
  delegateEvents as _delegateEvents,
  template as _template,
} from "vue";

const t0 = _template("<button> </button>", true);
_delegateEvents("click");

function render(_ctx, $props, $emit, $attrs, $slots) {
  const n0 = t0();
  const x0 = _txt(n0);
  n0.$evtclick = () => _ctx.count++;
  _renderEffect(() => _setText(x0, _toDisplayString(_ctx.count)));
  return n0;
}

在 Vue 演练场中尝试一下

Vapor Mode 编译结果和 Solid 十分相似,同样是引入 template 函数创建真实 DOM 节点,新增事件委托 delegateEvents 方法,并且通过 renderEffect 创建细粒度的响应式更新。后面有机会我们再研究 Vue Vapor 源码(挖坑)。

结语

近期有一款全新的前端框架面世 ------ Ripple ------ 主打融合 React + Solid + Svelte,老外评论也纷纷表示"学不动了"("Why are we still here? Just to suffer?")。从前端框架的宏观趋势来看,响应式基本成为标配,重心慢慢从"运行时"向"编译时"倾斜。

作为前端开发者,我对于前端框架还是喜闻乐见的。但在 AI 时代,React 凭借生态累积几乎成为"事实标准"。未来前端框架又将何去何从呢?!

参考

相关推荐
小二·2 小时前
从零到上线:Spring Boot 3 + Spring Cloud Alibaba + Vue 3 构建高可用 RBAC 微服务系统(超详细实战)
vue.js·spring boot·微服务
xiaohe06013 小时前
🥳 Uni ECharts 2.1 发布:正式支持鸿蒙,零成本迁移、全平台兼容、跨端开发零负担!
vue.js·uni-app·echarts
RAY_CHEN.3 小时前
vue递归组件-笔记
前端·javascript·vue.js
WenGyyyL3 小时前
GMNER多模态实体识别任务——ReAct结合
前端·react.js·前端框架
辻戋8 小时前
从零实现React Scheduler调度器
前端·react.js·前端框架
徐同保8 小时前
使用yarn@4.6.0装包,项目是react+vite搭建的,项目无法启动,报错:
前端·react.js·前端框架
毕设十刻15 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
王同学要变强18 小时前
【深入学习Vue丨第二篇】构建动态Web应用的基础
前端·vue.js·学习
程序定小飞19 小时前
基于springboot的web的音乐网站开发与设计
java·前端·数据库·vue.js·spring boot·后端·spring