
大家好,我是 anuoua,今天我们来讲讲基于 Signal 如何构建一个前端框架。
以 Vue 为响应式前端框架的代表,以 React 则是非响应式前端框架的代表,算是目前前端框架的稳定格局。
响应式的优点不言而喻,是高性能前端框架的选择。
而响应式也有不同的设计理念,区别于 Vue 的 reactivity,preact 的作者提出了 Signal 这种响应式的理念,和深度劫持的 reactivity 不同,Signal 更简单直观,其理念传播广泛,目前 Signal 作为 js 语言特性被提出成为 proposal。
响应式前端框架的现状
目前一些具有代表性的前端框架,基本都走向了响应式 API + 真实 DOM,例如:svelte、solid、vue,这几个前端框架在性能上有了大幅提升,但是仍然存在一些问题。
Vue 3
Vue 作为响应式框架的开创者,Vue3 仍然是虚拟 DOM,而 Vue 3 vapor 转向真实 DOM。Vue 3 版本中遇到最严重的问题是自动解包、**解构以及类型,**为了解决这些问题作者试验过很多语法,最终在数个的迭代后,还是上了编译手段,在SFC中使用宏用来解决开发体验以及 Typescript 类型问题。
markup
<script setup>
const props = defineProps({
foo: String
})
</script>
除此之外,Vue 的问题就在于官方没有引导用户到理想的开发模式上去,组件写法太多,导致社区力量分散,发力不在一处。如果统一使用 SFC 开发,统一使用 composition api,那么社区就不会陷入使用 jsx 还是 SFC,使用 options 还是 composition api 的纠结,那么社区的生态会好很多。
Svelte
Svelte 借助编译手段将视图转换成真实DOM实现,在 Svelte 5 中转向了和 Vue 类似的深度劫持的响应式API。它设计了一种叫 runes 的概念,通过编译技术追踪由特殊函数名创建的变量,将其编译成响应式代码,基本解决了类似 Vue 的困扰,无需手动解包,开发体验不错。
javascript
let message = $state('hello');
我认为 Svelte 的 runes 已经很接近完美了,开发体验很不错。
但 Svelte 本身仍然有以下几点问题:
第一 :它有自己的 DSL .svelte,我认为 JSX 更佳,Typescript 对 JSX 的支持非常好,DSL 支持 TS 总是需要付出更多的代价,而且需要支付更多的学习成本。
第二:它的响应式仍然是和 Vue 一样的默认深度劫持,如果是复杂嵌套对象,劫持内部对象会被包装带来会有隐晦的debug负担和理解成本。我认为 Signal 信号的浅劫持理念更加简单和直观。
第三:runes 还不够完美,若在大型应用中使用其创建的变量,会导致和普通变量混淆,编译器可以追踪变量,但是在多文件代码复杂组合的时候,很难区分是普通变量还是响应式变量,给debug带来困难。
Solid
Solidjs,它则是视图部分采取编译手段,API部分保持原生,让用户裸使用原生 Signal API,Solidjs 的 API 是符合 Signal 理念的,没有深度劫持。但是原生的 Signal API 看起来使用较为繁琐。
javascript
import { createSignal } from "solid-js";
const [count, setCount] = createSignal(0);
Solid 性能不错,JSX + TS 的开发体验基本拉满,唯一的问题是裸 Signal API 的使用不够优雅,略显繁琐。
例如它也不能直接解构 props,需要借助帮助函数才能维持响应性。
通病
它们在支持 Web Component 这点上,都没有做好无缝的开发体验,有额外的使用成本。
总结
以上三个框架都抛弃了虚拟DOM,配合响应式API,性能表现都非常好,但它们都或多或少都有令人在意的问题,很难找到理想中的前端框架。
| 框架 | 真实 DOM | Signal | JSX | Signal API 编译 |
|---|---|---|---|---|
| Vue | 支持(Vapor Mode) | 兼容(shallowRef) | 兼容 | 混合 |
| Svelte | 支持 | 不支持 | 不支持 | 支持 |
| Solid | 支持 | 支持 | 支持 | 不支持 |
理想的前端框架
如果我们需要一个新的前端框架,那么应该怎么设计?
根据上述总结,我认为 真实 DOM + JSX + Signal API 编译策略 + Web Component 一等支持 才是最接近完美的方案。
而 Solid 已经接近我们想要的了,给它加上剩下两个特性基本上就满足我们需要了。
所以怎么实现一个"完美"的框架呢?
从细粒度绑定到组件
signal 如何细粒度绑定 DOM 更新呢?又是怎么从基本的绑定演化为框架组件呢?
我们先从 Signal 的用法说起。
Signal 的基本用方法
javascript
// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");
// 副作用绑定
effect(() => {
// 当 name.value = "hello2";
// console => 1. "hello world" 2. "hello2 world"
console.log(displayName);
});
绑定DOM元素
javascript
// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");
const text = document.createTextNode("");
// 副作用绑定
effect(() => {
text.nodeValue= displayName.value;
});
演化成组件
一个只有 text 节点的组件:
javascript
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return (() => {
const text = document.createTextNode("");
effect(() => {
text.nodeValue= displayName.value;
});
return text;
})();
}
更复杂的组件
在 div 中添加 text 节点:
javascript
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return (() => {
const el1 = (() => {
const text = document.createTextNode("");
effect(() => {
text.nodeValue= displayName.value;
});
return text;
})();
const div = document.createElement("div");
div.append(el1);
return div;
})();
}
演化成 JSX
Solid 的编译策略和上述是类似的,视图的编译是有规律的,创建 - 绑定 - 挂载,只要是有规律的,那就可以通过 DSL 来描述,JSX 正好可以表达这个过程。
javascript
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return <div>{displayName.value}</div>;
}
可以看到复杂的视图创建流程通过 DSL 的使用配合编译手段,开发体验可以大幅提升。
同时需要指出 Solid 的编译方式未必是最好的,编译后的代码量挺大,还有各种闭包嵌套,可以稍微改进一下,编译成:
javascript
import { jsx, template } from "some/jsx-runtime"
const temp1 = template("<div>");
const App = () => {
const name = signal("hello");
const displayName = computed(() => name.value + " world");
return jsx(temp1(), {
get children() {
return displayName.value;
}
});
}
Solid 把一部分 DOM 操作过程也编译出来了,事实上创建真实 DOM 的过程很大一部分是通用的,我们把创建元素的方法抽出来 jsx,用于创建和组装元素,这样编译出来的代码也会相对直观。
同时需要注意到 template 方法,它做了一件事,内部使用 cloneNode 去创建静态节点,这样可以提升性能。
总结
这套编译策略,从演化中总结编译策略,然后完成 JSX AST 转换实现,确实是有创新思维和难度的,属于框架的创新点核心。
最先搞视图转真实 DOM 编译的是 Svelte,而 Solid 完成了更高效的实现,又最终促进了 Svelte 5 的诞生,使 Web 框架在性能得到了上大幅升级。
完整的框架要考虑的更多
只靠上面的编译策略显然是不够的,需要考虑很多细节问题。
组件的创建,事实上挺复杂的,组件是有实例的,初始化实例的过程中需要做很多工作。
比如:利用插桩来定位组件组件的边界,假设组件直接返回 <><span>1</span><span>2</span></> ,如果没有插桩框架将无法识别边界,在做列表 diff 的时候,组件内元素集合的移除、添加、移动等操作将错乱。
javascript
const App = () => {
const fragment = document.createDocumentFragment();
const instance = {
range: [
document.createTextNode(""),
document.createTextNode(""),
]
}
const span1 = document.createElement("span");
const span2 = document.createElement("span");
fragment.append(instance.range[0]);
fragment.append(span1);
fragment.append(span2);
fragment.append(instance.range[1]);
return fragment;
}
界面突变和 diff 算法
和 React 和 Vue 一样,这类编译型的前端框架仍然有 diff 过程。
界面突变的根本逻辑就是列表渲染,而列表渲染一定会涉及 diff,而 Vue 高效的 diff 算法也是可以使用的,算法和实现分离,不同的框架有不同的实现。
为什么说界面突变的根本逻辑是列表渲染?
条件渲染本质也是列表渲染,我们来看一个三目逻辑 :
javascript
// React
const List = () => {
const [toggle, setToggle] = 0;
useEffect(() => {
setToggle((toggle[0] + 1) % 2);
});
return [toggle].map(i => (<Fragment key={i}>{i}</Fragment>))
}
实际上就是列表 [0] 和 [1] 之间相互切换。
Switch Case 逻辑也类似:
javascript
// React
const List = ({ value }) => {
const [list, setList] = [1,2,3,4];
const deriveList = list.filter(i => i === value).slice(0, 1);
return [deriveList].map(i => (<Fragment key={i}>{i}</Fragment>));
}
根据 value 的值过滤列表,即可以实现 Switch Case 逻辑。
虚拟 DOM 和 真实 DOM 的 diff 实现差异
虚拟 DOM 的 diff 是从的组件节点(Vue)或者根节点(React)开始,遍历一遍,抽离出 DOM 指令以更新视图。
但是真实 DOM 的框架,列表是细粒度绑定的,当列表变化后,更新视图是在副作用内执行的,所以它需要一个特定的组件或者函数来封装这个副作用的逻辑,在 Solid 中就是 <For> 组件, Vue Vapor 和 Svelte 是在编译的时候编译成了一个特定的函数。
svelte:
html
$.each(node, 16, () => expression, $.index, ($$anchor, name, index, $$array) => {
$.next();
var text_2 = $.text('...');
$.append($$anchor, text_2);
});
diff 算法可以借鉴,但是虚拟 DOM 和 真实 DOM 框架在 diff 算法中进行的操作并不一样,理论上 Solid 也可以用 Vue 3 的算法。
开发体验升级
上面指出 Solid 体验已经很好的,但是仍有不足,裸 Signal API 的使用不够优雅,getter setter 满屏幕跑,Vue Svelte 为了解决体验问题都通过对应的编译策略来解决这个问题,而 Solid 没有,有点遗憾。
事实上开发体验这块,React 除了需要手动管理依赖这块过于逆天之外,它的开发体验真的不错。
React 的组件状态写法已经很简洁了,不用像 Vue,Solid 那样套 computed。
javascript
const App = () => {
const [name, setName] = useState("");
const displayName = "Info: " + name
return <div onClick={() => setName(name + "world")}>{displayName}</div>
}
也就是说,如果我们能改进 Solid,给它加上一组编译手段,改进 Signal 的使用体验,是不是会提升开发体验呢?
让我们尝试推演一下。
理想的组件形态
我们先提出一个理想中的组件形态,要求足够简洁,开发体验足够好:
javascript
const App = () => {
let name = "hello";
return (
<div onClick={() => {name = name + "world"}}>{name}</div>
)
}
我们希望改变 name 的时候,视图就会更新,但是这样是做不到的,改变一个变量没有任何作用。
但是如果是信号就不一样了:
javascript
const App = () => {
const name = signal("");
return (
<div onClick={() => name.value = name.value + "xxx"}>{name.value}</div>
)
}
我们根据上文所说的 JSX 编译手段,创建元素可以绑定副作用,name.value是可以被副作用收集到,并在name.value 更新的时候顺便更新视图。
javascript
import { jsx, template } from "some/jsx-runtime"
const temp1 = template("<div>");
const App = () => {
const name = signal("");
return jsx(temp1(), {
get onClick() {
return () => {
name.value = name.value + "xxx";
}
},
get children() {
return name.value;
}
});
}
这时候就需要编译来完成我们的代码转换,在这里我们把信号变量使用 **$** 标记。然后就代码如下:
javascript
const App = () => {
let $name = "hello";
return (
<div onClick={() => {$name = $name + "world"}}>{$name}</div>
)
}
这个代码和我们理想中的组件代码非常接近了,要是真的能这样写代码,那么开发体验就能得到大幅提升。
Signal 信号编译策略
前面提到使用 $ 标记信号,就是一种创新的编译策略,通过特殊命名标记变量,将变量编译成响应式信号代码。
编译策略说明
这里我们按照 preact/signals 库的 api 做示例。
编译策略一:let 搭配 $ 开头的变量,即为声明信号。
javascript
let $name = "hello"
// 编译成
import { signal } from "@preact/signal";
let $name = signal("hello");
编译策略二:读取 $ 开头的变量会默认解包
javascript
let $name = "hello";
console.log($name);
// 编译成
let $name = signal("hello");
console.log($name.value);
编译策略三:const 搭配 $ 开头的变量,为声明派生信号。
javascript
let $name = "hello";
const $display = $name + "world";
// 编译成
import { signal, computed } from "@preact/signal";
let $name = signal("hello");
const $display = computed(() => $name.value + "world");
编译策略四:$use 开头的为自定义 hooks 。
javascript
const $useName = () => {
let $name = "hello";
return {
name: $name
}
}
// 编译成
const $useName = () => {
let $name = signal("hello");
return computed(() => ({
name: $name.value
}))
}
编译策略五:解构 + 变量传递。
函数入参,入参的响应传递,解构变量需要设置$前缀
javascript
const App = ({ name: $name, ...$rest }) => {
console.log($rest);
return <div>{$name}</div>
}
// 编译为
const App = ($__0) => {
const $name = computed(() => $__0.value.name);
const $rest = computed(() => {
const { name, ...rest } = $__0.value;
return rest;
});
console.log($rest.value);
return <div>{$name.value}</div>
}
自定义 hook 返回,解构的时候为了不丢失响应,同样也要解构变量设置$前缀,这样就能触发编译。
javascript
const $useName = () => {
let $name = "hello";
return {
name: $name
}
}
// 解构后的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const { name: $name } = $useName();
// 自定义 hook 返回赋值的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const $nameSignal = $useName();
// 编译成
const $useName= () => {
let $name = signal("hello");
return computed(() => ({
name: $name.value
}))
}
const $__0 = computed(() => $useName().value);
const $name = computed(() => $__0.value.name);
const $nameSignal = $useName();
此编译策略的优点
- 无需手动导入 API,像普通变量一样使用 Signal
- 和 TS 类型的结合非常好,特别和 JSX 的类型结合非常完美
- 不怕解构
- 标记变量和常规变量一起用不会有混淆
一个简单的鼠标位置hook
javascript
const $usePosition = () => {
let $x = 0;
let $y = 0;
const $pos = {
x: $x,
y: $y
};
mounted(() => {
document.addEventListener('mousemove', (e) => {
$x = e.pageX;
$y = e.pageY;
});
})
return {
$pos,
}
}
const App = () => {
const { $pos } = $usePosition();
return <div>x: {$pos.x}; y:{$pos.y}</div>
}
是不是清爽很多,简单应用的代码量差距不是很明显,但是如果代码量增加,那么代码量的差距还是非常可观的。
同时这样的设计,甚至不需要手动导入 API ,它在编译期间自动导入,让人无需关心 Signal 本身,真正做到了无感,开发体验得到了提升。
Web Component 支持
Vue Solid Svelte 都支持封装 Web Component,但是在开发体验上并没有多好,需要额外操作才能集成到框架中使用,做不到在框架内无缝使用,这样也限制了 Web Component 的推广和使用。
所以我们希望框架能够做好以下几点来支持 Web Component:
- 和框架本身可以无缝集成,像普通组件一样方便使用
- 组件 TS 类型易用且完善
- 可以按照常规 Web Component 一样可以独立使用
- 可以供给原生 HTML 或者其他框架使用
有这样的框架吗?
有啊 J20 框架 J20

点个 Star 吧。
说在最后
这大概是我最后一个前端框架了,也算是完成了之前对前端框架的想法(中间隔了很久才想起来还有个东西没完成)。
歼20框架大量代码都是AI写的,我负责设计,它负责实现,同时帮我写测试,速度大幅提升。
AI 时代,也许框架不再重要了吧。哈哈
谢谢大家!