歼20居然是个框架-基于 Signals 信号的前端框架设计

大家好,我是 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();

此编译策略的优点

  1. 无需手动导入 API,像普通变量一样使用 Signal
  2. 和 TS 类型的结合非常好,特别和 JSX 的类型结合非常完美
  3. 不怕解构
  4. 标记变量和常规变量一起用不会有混淆

一个简单的鼠标位置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 时代,也许框架不再重要了吧。哈哈

谢谢大家!

相关推荐
梵得儿SHI1 小时前
Vue 核心语法深度解析:生命周期与响应式之计算属性(computed)与侦听器(watch/watchEffect)
前端·javascript·vue.js·计算属性·侦听器·缓存机制·数据派生
秋天的一阵风1 小时前
翻掘金看到停更的前辈们,突然想聊两句 🤔
前端·vue.js·程序员
中杯可乐多加冰1 小时前
openEuler软件生态体验:快速部署Nginx Web服务器
服务器·前端·nginx
拾忆,想起1 小时前
Dubbo服务降级全攻略:构建韧性微服务系统的守护盾
java·前端·网络·微服务·架构·dubbo
我爱学习_zwj1 小时前
Node.js模块管理:CommonJS vs ESModules
开发语言·前端·javascript
咬人喵喵1 小时前
网页开发的“三剑客”:HTML、CSS 和 JavaScript
javascript·css·html
顾安r1 小时前
12.8 脚本网页 井字棋
前端·stm32·django·html
心本无晴.1 小时前
深入剖析Vue3中Axios的实战应用与最佳实践
前端·javascript·vue.js
冬男zdn1 小时前
优雅的React表单状态管理
前端·javascript·react.js