Voby:比 Solid 更简化——无需 Babel,无需编译器

2025-06-05

Voby:比 Solid 更简化——无需 Babel,无需编译器

介绍

你好👋,我的名字是Fabio,在尝试深入了解出色的Solid框架的过程中,我最终编写了自己的独立反应库Oby,以及在其上类似 Solid 的反应框架Voby

本系列“简化 Solid”将涉及 Voby 和 Solid 做出的不同决策的约 30 点,以及我对 Voby 的决策最终简化框架和/或整体改善开发人员体验的论据。

本系列文章主要包含个人观点,如果您的观点与此不同,您很可能会认为 Solid 的某些决策是简化,而另一些决策则可能过于简化。此外,Voby 的适用范围略窄,它旨在编写功能丰富且性能卓越的客户端应用程序,例如 VS Code,而不是博客。潜在的服务器端应用程序很大程度上超出了 Voby 的适用范围(尽管可能应该能够将其与Astro或其他程序连接,并且 Voby 提供了一些基本renderToString功能),但这些应用程序不适用于 Solid。请记住这一点。

我将 Voby 与 Solid 进行比较,因为 Solid 是有效地教会我如何编写 Voby 的框架,因此它们的工作方式非常相似,而其他框架要么差异太大,要么我对它们不够了解,无法与它们进行比较。

也许,如果您有兴趣探索不同框架做出的不同选择,如果您有兴趣阅读有关 Solid 的一些细节的深入意见,或者如果您正在为您的应用程序寻找仅限客户端的框架,那么您应该阅读本系列。

让我们从第一个主要的简化开始:没有自定义的 Babel 转换或自定义的编译器。

#1 没有自定义 Babel 转换

Voby 没有针对 JSX 的自定义Babel转换,也没有任何类型的自定义编译器,它只是使用 TypeScript 附带的转换来开箱即用,或者如果你喜欢的话,根本不需要 JSX。

这是一些 Voby 示例代码:

import {$, render} from 'voby';

const Counter = () => {
    const value = $(0);
    const double = () => value() * 2;

    const increment = () => value(prev => prev + 1);
    const decrement = () => value(prev => prev - 1);

    return (
        <>
            <h1>Counter</h1>
            <p>Value: {value}</p>
            <p>Double: {double}</p>
            <p>Triple (inline): {() => value() * 3}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
        </>
    );
};

render(<Counter />, document.body);
Enter fullscreen mode Exit fullscreen mode

等效的 Solid 代码如下所示:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const Counter = () => {
    const [value, setValue] = createSignal(0);
    const double = () => value() * 2;

    const increment = () => setValue(prev => prev + 1);
    const decrement = () => setValue(prev => prev - 1);

    return (
        <>
            <h1>Counter</h1>
            <p>Value: {value()}</p>
            <p>Double: {double()}</p>
            <p>Triple (inline): {value() * 3}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
        </>
    );
};

render(() => <Counter />, document.body);
Enter fullscreen mode Exit fullscreen mode

优势

以下是这一决定带来的一些优势。

  • Voby 可与 TypeScript/Deno/Bun/Esbuild/Whatever™ 开箱即用。
    • 对于 Solid,您通常需要某种插件,而对于像 Bun 这样的较新的工具,我不确定它是否可用。
  • Voby 能够充分利用捆绑器的性能。
    • 例如,如果您将 Solid 与 Esbuild 一起使用,则必须至少解析代码两次,一次使用 Esbuild(快速),一次使用 Babel(较慢)。
  • Voby 的 JSX 转换没有增加任何维护负担,因为 JSX 转换的实现存在于框架本身之外。
    • 对我来说, Solid 的转换似乎相当耗时且难以维护,但事实可能并非如此,我不熟悉该代码或编写 Babel 转换,你来决定。
  • 如果您熟悉 React,那么 Voby 的变换很容易理解,因为它是完全相同的变换。
    • Solid 的变换我不确定它是否在任何地方有详细记录,它有很多细微差别,比如从根本上说它有 500 多行代码我还没有读过和理解,我个人更有信心我完全理解 React 的变换而不是 Solid 的变换,因为 React 的变换非常简单。
  • Voby 的 JSX 转换基本上没有错误,因为它甚至没有错误,而是使用现有工具已经附带的经过实战检验的类似 React 的转换。
    • 自定义 Babel 转换使 Solid 中的操作变得非常复杂。例如,它存在一个根本性的问题:转换会尝试将已知的 props 编译成所需的最少代码,但这与组件上展开的 props(例如{...someProps})冲突,因为这些 props 必须在运行时才可见。例如,你可能通过常规 props 设置“class”,又通过展开 props 多次设置“class”,这该如何实现?哪种方式更合适?我不太确定。由于这个原因以及其他一些细节,Solid 转换中经常会出现 bug 或奇怪/意外的行为。也许 Solid 转换中的一些缺陷将来会得到解决?在 Voby 中,这根本不是问题,所有 props 都会在运行时解析为普通对象,并且你设置的最后一个“class”始终有效,就像在 React 中一样。
  • Voby 的发布体验非常简单,您只需使用 TypeScript 编译代码即可完成,就像平常一样。
    • 在 Solid 中你可以做同样的事情,但这会把你束缚在你正在使用的 JSX 转换的特定版本上,如果其中的某些内容发生变化或被修复,那么你要么无法从中受益,要么事情可能会中断。相反,我认为 Solid 的推荐方法是按原样发布未转换的 JSX,这在大多数情况下都没有问题,但仅举一个使事情复杂化的例子:你不能在仅 ESM 设置中导入 JSX,因为 JSX 不是 JavaScript。如果你的 JSX 恰好只是因为它利用了将来更改的转换的一些小细节而起作用,该怎么办?即使发布未转换的 JSX 也无法在这种(假设)情况下避免错误。Voby 没有这个问题,因为此时 React 的转换不能再改变,它实际上是固定的,并且可以工作。
  • Voby 所需的开发依赖项更少。实际上,完全不需要,如果您愿意,可以直接在浏览器中导入。尽管人们总是在讨论使用不同的工具更快地安装依赖项,或者依赖项会带来安全风险,但一个不容置疑的事实是,如果您什么都不做,任何工具都无法让您的工作更快或更安全,而从一开始就不安装依赖项无疑是无可厚非的。
    • Solid 需要 Babel 及其所有依赖项。我不确定这会导致多少速度下降,但在我看来,这几乎无关紧要。但如果你在意这些,我敢肯定安装 Babel 总是比不安装要慢。
  • Voby 的 API 接口可以说泄漏较少,因为没有未记录的导出函数。
    • Solid 中有一些这样的转换,只是因为自定义的 Babel 转换需要它们才能工作。Voby 不需要它们,因为没有自定义的 Babel 转换。
    • 例如,有一个“memo”函数,你可以导入它,但它没有文档记录。还有其他类似的函数。我不太清楚这些函数有多少,也不知道它们到底做什么,因为我没有深入研究过,而且据我所知,它们也没有文档记录。
  • 最后,我应该提一下,理论上你也可以不使用自定义 Babel 转换来使用 Solid,我认为你需要导入它,solid/h并且你的代码写法要稍微不同,更像 Voby 的,但这是可以实现的。如果你这样做,上面提到的大部分优势都会消失。
    • 虽然我不确定网站上是否有记录,但我不确定是否有人在使用它。如果你使用它,你将失去与使用转换的现有 Solid 生态系统的兼容性,因为生态系统可能提供了 Solid 风格的 JSX,但你没有使用它。就像,这个问题在 Voby 中不存在一样,无论你最终以何种方式编写组件,都不会改变任何东西,并且它与所有其他组件兼容。不过公平地说,Voby 本身并没有生态系统,但这是一个存在于框架本身之外的问题。
    • 另外,虽然理论上 Solid 无需自定义转换即可使用,但我认为,如果大约 0% 的用户这样使用它,如果它被视为二流功能,如果它的使用频率不够高以至于潜在的 bug 暴露出来,如果它实际上与框架的其他使用方式不兼容,那么这些都很重要。在 Voby 中,不使用自定义 Babel 转换或自定义编译器是显而易见且唯一的选择。

表现

但是性能呢?Solid 肯定比 React 快得多,因为有自定义的 transform,对吧?嗯,这个问题其实没什么特别的,答案是肯定的“不”,自定义 transform 几乎和性能无关。

看一下js-framework-benchmark,这是我所知道的比较 JavaScript UI 框架的最佳、最全面的基准。

这是过滤后的效果表:

绩效表

请注意,Solid 比 vanilla 的性能开销增加了约 6%(表格中其列底部的数字为“1.06”),而 Voby 的性能开销增加了约 8%(表格中为“1.08”),React 的性能开销增加了约 69%(表格中为“1.69”)。除非您使用并发功能,在这种情况下性能开销会增加约 86%。此外,基准测试运行期间还会出现约 2% 的噪音。

请记住,Voby 和 React 在此基准测试中使用完全相同的 JSX 转换。

这是过滤后的内存使用情况表:

内存使用情况表

请注意,Voby 比 Vanilla 增加了 5.1MB 的内存开销(在“运行内存 10k”测试中,Voby 增加了 21.3MB,而 Vanilla 增加了 16.2MB),而 Solid 增加了 7MB 的内存开销(总共 23.2MB)。React 增加了 18.1MB 的内存开销(总共 34.3MB),这不仅因为要为 VDOM 付费,还因为可能还需要为一些你并不需要的功能付费,这显然增加了内存使用量,它使用的内存基本上是 Voby 的三倍多,考虑到 Vanilla 使用的内存几乎全部都用于在内存中保存 DOM 节点,而我们无法减少这些节点的内存使用量。

  • 从上面的性能表来看,Solid 和 Voby (出乎意料地)很接近,在相对开销方面,它们完全靠左,而不是像 React 那样完全靠右。Voby 比 Svelte 快,比 Angular 快,比 Vue 快,比 React 快。它比不上 Solid 的框架,实际上速度差不多。这应该能说明所有关于框架性能和编译器的知识。对于一个已经优化过的框架,编译器几乎不可能优化掉太多的运行时开销。你永远不可能通过增加编译器来超越 Vanilla,而我们已经非常接近 Vanilla 了。
  • 在 JS 前端框架的语境下,编译器/转换程序与性能的真相是,编译器和转换程序实际上 95% 的作用在于提供便捷功能,而 5% 的作用则与性能相关。这意味着,从运行时性能来看,一个大量使用编译器的 JS 框架,最多只能比一个完全不使用自定义编译器或自定义 Babel 转换程序的优化框架拥有微弱的性能优势。如果你不同意这一点,那么,证据并不支持你的观点。
  • 但需要注意的是,据我所知,编译器可以帮你轻松实现一项潜在的重要优化,那就是一次性深度克隆 DOM 节点,而不是逐个创建层次结构中的每个小节点,然后再逐个添加它们。这项优化可能意义重大,而且 Solid 的转换功能会自动帮你完成这项任务,这很棒,在我看来,这也是 Solid 自定义 Babel 转换的唯一亮点。Voby 也可以进行这项优化,但需要通过可选的运行时template辅助函数来实现,不过目前这还不够通用。从用户的角度来看, Solid 的解决方案确实更好、更简单。
    • 值得一提的是,从性能角度来看,比加快新节点速度更好的方法是重用已有节点,Voby 有一个强大的组件ForValue,它也可以在 Solid 之上实现,但目前还不行,你可以使用它来重用节点。不过你需要小心处理,因为在某些情况下,你想确保重用节点中没有未重置的状态,比如动画,但只要小心就可以处理。我个人使用它来ForValue渲染虚拟化列表,这可能是我工作的应用程序中最重要的组件,任何数量的编译器优化都无法击败它,例如,它允许我的列表在滚动时创建和销毁节点,即使你可以在 0 纳秒内用魔法克隆节点,这也不可能比重用你已经拥有的相同节点更快。
    • 此外,如果您的组件主要只是调用其他组件,就像在基准测试之外的实践中经常出现的情况一样,那么 Solid 的转换所做的这种优化对您不会有太大帮助,因为可能没有任何东西可以一次性深度克隆,因为组件创建的所有各种节点都会由其他组件为其创建。
    • 所以,这种编译器优化很棒,它没有直接的缺点,但在实践中,如果你真的在意性能,你也许可以在运行时做得更好,尽管这将更加耗时,并且正确实现起来更具挑战性。而且,这种优化在基准测试之外的实用性也可能接近于零。
  • 最后再强调一下这一点:下次你听到有人说“[BLANK] 很快是因为编译器”,如果他们指的是运行时性能,那就检查一下 Voby 是否比js-framework-benchmark中的速度更快,如果是的话,你可以直接用“bullllsh*t”来代替这句话;如果不是的话,它到底快多少?大概 3%?

方便

那么便利性呢?您说便利性才是 95% 的价值所在,对吧?也许,“便利性”是个个人的事情,对某些人来说是便利的东西,对另一些人来说可能就是个陷阱。

在我们深入探讨 Solid 的变换功能之前,我想提一下,你可以将下面所有 Solid 的代码片段复制/粘贴到 Solid强大的 Playground中,它还有一个“输出”选项卡,你可以用它来亲眼看看你的代码是如何变换的。你也可以将 Voby 的代码片段粘贴到这个codesandbox中,但请记住要保留import * as React from 'voby';文件中的内容(抱歉,我没有在 CodeSandbox 中设置好它)。

现在让我们来看看我能想到的 Solid 变换能为您做的所有事情。

深度克隆节点

实体代码:

import {render} from 'solid-js/web';

const App = () => {
    return <div><span><b>Hello</b></span></div>;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

节点会尽可能地深度克隆,而不是逐个创建并附加。我们已经讨论过这一点,这很棒,但在实际应用中效果会有所不同,可以通过在运行时重用节点来解决这个问题。其实没什么好说的,这是一个潜在的、重要的锦上添花的功能。

Voby 的等效代码基本相同,但如果不使用template辅助组件,并且需要挂载多个组件,<App />速度会更慢,大概慢 15% 左右。 一次性克隆的节点层级越大,Solid 的速度相对于 Voby 来说就越快。

相反,尽管看起来非常相似,但即使不使用该template函数,在 Voby 中也会同样快,因为这无法通过 Solid 的变换进行优化:

import {render} from 'solid-js/web';

const Div = props => {
    return <div>{props.children}</div>;
};

const Span = props => {
    return <span>{props.children}</span>;
};

const B = props => {
    return <b>{props.children}</b>;
};

const App = () => {
    return <Div><Span><B>Hello</B></Span></Div>;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Voby 的等效代码基本相同,但如果您愿意,也可以解构 props。如果您在 Solid 中解构 props,通常会导致程序崩溃。

自动将 props 包裹在函数调用中

实体代码:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const Count = (props: {value: number}) => {
    return <p>{props.value}</p>;
};

const App = () => {
    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    return <Count value={count()} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

如果您的某个 props(包括“children”)的值包含一个函数调用,count()如上所示,那么它将自动为您包装在一个函数中,因为否则使用类似 React 的转换您只会传递一个原语,而原语永远不会改变,所以您的子组件将无法对任何变化做出反应。

本质上,如果你以 React 的思维方式来看待这个问题,看起来我们只是传递一个数字,因此你会期望p收到以下 props 对象:

{ value: 0 }
Enter fullscreen mode Exit fullscreen mode

但实际的 props 对象看起来更像这样:

{ get value() { return count(); } }
Enter fullscreen mode Exit fullscreen mode

这样,只需访问 prop 即可调用信号。这样,prop 就具有了响应式,但是:你无法知道哪些 prop 是响应的,而且现在你无法再解构 prop,因为如果你解构 prop,你就会在现场读取信号,而不是在 effect 或 memo 中读取,而后者在信号发生变化时可以重新执行。

另外,在我看来,有趣的是,你无法让 Solid 组件在类型层面明确表示某些 props 不会被响应,比如某个组件的某些 prop 只接受“数字”。这根本无法表达,因为上面的代码对 TypeScript 来说,它看起来像是一个数字被传递,它无法感知 Solid 的转换,TypeScript 认为会被执行的代码并不是真正会被执行的代码。对于任何接受原语的组件,你总是可以直接向它传递信号,这样你不会看到任何编译时错误,甚至可能也不会看到运行时错误。如果出现问题,你只会看到组件没有按照预期进行更新。

Voby 对此的看法截然不同,他强制接收组件始终指定它是否可以处理原语或带有类型的信号,从而更难出现错误。这样,如果您将信号传递给一个声明无法处理信号的组件,则会在编译时收到错误。甚至编辑器也可能会立即提示您有错误。

Voby 绝对更喜欢显式地使用类型,而不是隐式地使用。Solid 则更倾向于为你解开信号,这会导致各种负面后果,比如我们之前提到的无法解构 props。

在 Voby 中,信号或函数解包方式如下$$(count),其中$$是 Voby 导出的函数,在 Solid 中,它们解包方式如下props.count,最终$$()比 少 2 个字符props.,它也不需要在您的组件中添加任何额外的分支,而且它是明确的,而且如果您只是将它们传递给其他东西,您通常甚至不需要解包 props,所以 Voby 采用了这种方式,在我看来,这种方式更简单,更强大。

这是等效的 Voby 代码:

import {$, render} from 'voby';

const Count = ({value}: {value: number | (() => number)}) => {
    return <p>{value}</p>;
};

const App = () => {
    const count = $(0);
    setInterval(() => count(count() + 1), 1000);
    return <Count value={count} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

这里要注意的主要一点是,我们写的value={count}是 而不是value={count()},因为我们的Count组件表示它也接受一个函数值(如果它读取自身内部的信号,或者它本身就是一个信号,就像在这种情况下一样,它可能是反应性的),我们希望它对它做出反应,所以我们只需将信号传递给它,就是这样。

请注意,Voby 导出的函数在任何地方都不需要解包$$,因为我们只是将 prop 传递给本机<p>组件,它会在内部为您解包。

注意 props 是如何被解构的。在 Voby 中,是否解构 props 并不重要。解构更节省内存,因为包装器对象可以被垃圾回收。如果你尝试在 Solid 中执行同样的操作,你会发现组件Count会停止自我更新。这是因为信号是在响应式上下文之外读取的,因为仅仅通过解构访问 prop 就会调用信号。

还要注意,我们函数的类型Count非常明确,而且只有一个 prop,可以说已经很复杂了。但实际上,这可以大大简化,你可以将逻辑别名化为FunctionMaybe如下类型:

type FunctionMaybe<T> = T | (() => T);

// Then our props become...

type Props = { value: FunctionMaybe<number> };
Enter fullscreen mode Exit fullscreen mode

但更好的是,您可以从 Voby 导入此类型并为其编写一个全局别名,如果这是您喜欢的,而这实际上是我自己在做的事情:

import type {FunctionMaybe} from 'voby';

// Convenient alias

type $<T> = FunctionMaybe<T>;

// Then our props become...

type Props = { value: $<number> };
Enter fullscreen mode Exit fullscreen mode

这更合理吧?如果你的组件支持对每个 prop 做出响应,那么你还可以构造一个适用FunctionMaybe于每个 prop 的类型,这样你就可以编写类似下面的代码,即使添加更多 props,也不需要任何额外的包装器:

type Props = $<{ value: number }>;
Enter fullscreen mode Exit fullscreen mode

这里还需要考虑读/写隔离,但我们改天再讨论。我只想简单地说,如果你编写 JavaScript,这非常有用,但如果你编写 TypeScript,这没什么用。在上面的例子中,尽管我们将 setter 传递给CountVoby 中的组件,但因为默认情况下 getter 和 setter 在 Voby 中是一个函数,我们的Count组件实际上没有干净的方式来使用它,首先你必须断言它value实际上有一个 setter,然后没有办法真正检查它是否也是一个 setter,我们Count实际上可以只传递一个 getter,那么你能做什么呢?将它包装在中try..catch,调用它,然后希望获得最好的结果?在实践中,你永远不会编写这样的代码,就像,你必须明确地让自己陷入困境,因为 TypeScript 会在整个过程中对你大喊大叫,这不会发生。如果这还不能说服您,您还可以通过createSignal为 Voby 编写自己的 3 行来实现类似 Solid 的硬读/写隔离。

基本上,Solid 可能会认为这种 props 转换是为了方便用户使用。但在我看来,实际上不进行这种转换几乎总是更方便,而且也更安全,因为它允许你在类型层面上表达某些 props 是否不会被响应,而这在 Solid 中是无法实现的。

此外,Voby 的处理方式更加一致,就像你可以将原语或信号传递给组件一样,你也可以将它们传递给钩子(Solid 称之为“原语”)。但 Solid 的 transform 无法检测你的钩子,它们只是函数,而且钩子通常不会只接受一个 props 对象,所以你该怎么办?你必须自己解包接收到的参数。Voby 中不存在这种特殊情况,它只有一种处理方式,而且平均而言,这种方式也更方便。

顺便说一句,Solid 设计上没有检查某个值是否是信号的函数,所以如果你想解包一个可能是回调函数或回调信号的值,你该怎么做?Solid 没有与 Voby 直接对应的函数来isObservable检查某个值是否是信号(在 Voby 中我们称之为“可观察对象”,因为它们可以被“观察者”观察到,也就是效果和备忘录),也没有方便的$$解包函数。

自动包装带有属性访问的 props。

实体代码:

import {render} from 'solid-js/web';
import {createMutable} from 'solid-js/store';

const Count = (props: {value: number}) => {
    return <p>{props.value}</p>;
};

const App = () => {
    const state = createMutable({ value: 0 });
    setInterval(() => state.value++, 1000);
    return <Count value={state.value} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

我们之前看到的包含函数调用的 props 的自动包装,store.value在这个例子中,也适用于包含属性访问的 props。原因和问题都是一样的。

在 Voby 中你需要明确,如果你传递的是一个根本不可能做出反应的数字,那么如果你想让组件对其做出反应,你就必须传递一个函数,所以在这种情况下:

import {render, store} from 'voby';

const Count = ({value}: {value: number | (() => number)}) => {
    return <p>{value}</p>;
};

const App = () => {
    const state = store({ value: 0 });
    setInterval(() => state.value++, 1000);
    return <Count value={() => state.value} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

代码稍微多一点,但是更明确,TypeScript 可以更好地理解我们的代码的作用。

魔术裁判

实体代码:

import {render} from 'solid-js/web';
import {onMount} from 'solid-js';

const App = () => {
    let ref: HTMLDivElement;
    onMount(() => {
        ref.textContent = Date.now();
    });
    return <div ref={ref!} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

转换启用了这种定义 ref 的替代方法,看起来您只是创建一个变量并将其作为 ref 传递,如果您来自 React 思维模式,则这根本没有任何意义:您正在创建一个变量,该变量从未初始化,该变量作为 ref 传递,所以您实际上已经写了ref={undefined},并且不知何故这有效?

诀窍在于,变换基本上变成ref={ref}ref={element => ref = element}

它看起来就像一个便利功能,尽管它的类型很棘手,因为 TypeScript 在这里会感到困惑,这是可以理解的。

实际上,我认为这个功能永远不应该使用,而且通常情况下根本就不能用,因为在某些情况下它会导致一些 bug,而且这些 bug 一开始很难被发现。我们来看看另一个 Solid 代码片段:

import {render} from 'solid-js/web';
import {createSignal, createEffect, Show} from 'solid-js';

const App = () => {
    let ref: HTMLDivElement;

    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    const [mounted, setMounted] = createSignal(true);
    setInterval(() => setMounted(mounted => !mounted), 1000);

    createEffect(() => {
        ref.textContent = count();
    });

    return (
        <Show when={mounted()} fallback={<div>Unmounted!</div>}>
            <div ref={ref!} />
        </Show>
    );
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

如果您不熟悉 Solid,这可能看起来非常复杂,但基本上我们将代码片段更改为如下形式:有一个每秒递增的“count”信号,有一个每秒切换的“mounted”信号,我们的信号被包裹<div>在一个中ShowShow基本上一秒钟安装 div,下一秒卸载它,然后再次安装回来等等。就是这样,我们的 ref 以相同的方式定义,我们现在只是在其中写入“count”的值。

你能发现问题所在吗?

问题从根本上来说在于,这些魔法引用仅仅是变量而已,它们无法通知我们任何更新,因为只有信号才能做到这一点。所以这里发生的事情是,我们的 sdiv实际上发生了变化,每 2 秒就会创建一个新的 s,但我们的ref变量却没有告诉我们发生了什么,这怎么可能呢?我们只是在这里更新了错误的divs 而已!

基本上,如果魔法引用所附加的节点不是始终立即渲染,那么魔法引用就完全失效了。如果节点延迟渲染,或者有条件地渲染,魔法引用就根本无法使用。

请注意,如果我们将其中一个魔术引用传递给自定义组件,我们甚至无法立即看到它将附加到什么,您需要查看该组件的代码,并了解这个问题,才能判断在这种情况下使用魔术引用是否是安全的。

因此,在我看来,这是一个明显需要避免的功能,除非你喜欢你的代码成为一个充满细微错误的雷区。

进行 ref 的正确方法就是使用信号,就像这个 Solid 代码片段中那样:

import {render} from 'solid-js/web';
import {createSignal, createEffect, Show} from 'solid-js';

const App = () => {
    const [ref, setRef] = createSignal<HTMLDivElement>();

    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    const [mounted, setMounted] = createSignal(true);
    setInterval(() => setMounted(mounted => !mounted), 1000);

    createEffect(() => {
        const node = ref();
        if(!node) return;
        node.textContent = count();
    });

    return (
        <Show when={mounted()} fallback={<div>Unmounted!</div>}>
            <div ref={setRef} />
        </Show>
    );
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

这确实有效。另外请注意,TypeScript 强制我们检查这里是否真的有一个节点。使用魔法引用,通常情况下,让 TypeScript 感到满意的方法是进行类型断言,但如果你期望连接的节点尚未连接,那么在运行时,这种方法可能会让你大吃一惊。

在 Voby 中,制作 ref 的明显且最简单的方法是正确的,即制作信号(或者我们在 Voby 中称之为“可观察量”,但它们是同一件事):

import {$, render, If, useEffect} from 'voby';

const App = () => {
    const ref = $<HTMLDivElement>();

    const count = $(0);
    setInterval(() => count(count() + 1), 1000);
    const mounted = $(true);
    setInterval(() => mounted(mounted => !mounted), 1000);

    useEffect(() => {
        const node = ref();
        if(!node) return;
        node.textContent = count();
    });

    return (
        <If when={mounted} fallback={<div>Unmounted!</div>}>
            <div ref={ref} />
        </If>
    );
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

在编译时解析已知 props

实体代码:

import {render} from 'solid-js/web';

const App = () => {
    const [color, setColor] = createSignal('red');
    setInterval(() => setColor(color() === 'red' ? 'blue' : 'red'), 1000);
    return <div class={color()} {...{ class: "bar"}} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

在这个代码片段中,我们有一个“颜色”信号,它在“红色”和“蓝色”之间来回切换,我们将其设置为 的一个类<div>。但我们在元素上也有一个展开,实际上,元素上展开的 props 很可能来自父组件,而展开的内容只有在运行时才会知道,为了简单起见,我在上面的代码片段中内联了一个简单的对象。

让我们看看该代码在操场上编译成什么:

import { render, spread, effect, className, template } from 'solid-js/web';

const _tmpl$ = /*#__PURE__*/template(`<div></div>`, 2);

const App = () => {
    const [color, setColor] = createSignal('red');
    setInterval(() => setColor(color() === 'red' ? 'blue' : 'red'), 1000);
    return (() => {
        const _el$ = _tmpl$.cloneNode(true);

        spread(_el$, {
            class: "bar"
        }, false, false);

        effect(() => className(_el$, color()));

        return _el$;
    })();
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

看来这里发生了很多事情。

我想要强调的主要一点是,由于我们编写了class={color()},Solid 已经知道它必须在节点上设置一个类,因此它直接输出必要的代码:effect(() => className(_el$, color()));

这很棒,但真的好到哪里去呢?Voby 会在运行时彻底检查每个 prop 应该做什么,而且在编译时这样做实际上也不会带来太多性能提升。如果 Solid 能做到,它在基准测试中会比 Voby 有很大优势,但正如我们已经看到的,它不可能在基准测试中比 Voby 有太大优势,因为它已经非常接近 Vanilla 了。

所以我们完成了这个小小的优化,但现在也生成了一些处理扩展的代码。那么我们的“div”到底应该属于哪个类呢?看代码我不太确定,看起来好像先设置“bar”,然后立即将其替换为“red”,然后在“blue”和“red”之间来回切换。除非我读错了代码。

以上只是单个散布,包含静态值。想象一下,如果散布也设置了一个依赖于信号的类,或者有多个这样的散布,会怎么样?我不确定每种情况下会发生什么。

在 Voby 中,就像在 React 中一样,首先在运行时生成一个单一的普通对象 props 对象,并且在其上设置的最后一个属性始终获胜。

因此在实践中,我们在这种情况下的 props 对象将是{ class: color, ...{ class: "bar" } },它相当于{ class: "bar" },处理起来很简单,而且处理方式也很明显。

那么这种优化真的值得吗?在我看来,即使你从不做点差,它也只会增加太多的复杂性,而且几乎没有任何好处。

还要注意,截至目前,如果您使用扩展,这可能实际上甚至不是一种优化,而是一种反优化,因为现在您有多个效果,每个效果都认为它们完全管理了您的“类”属性。如果一开始就不存在这种冲突,那么您就只会有 1 个效果,而不是 N 个。

通用渲染

Solid 的转换支持渲染到 DOM 以外的其他对象,基本上它将所有 DOM API 调用替换为您提供的 API 调用,因此如果您的 API 支持渲染到<canvas>、终端或其他对象,那么您只需编写普通的 Solid 代码,但将其渲染到与 DOM 不同的目标。

这很酷。但是除非你需要同时使用多个渲染目标,否则框架可以直接导出一个API对象供你覆盖,无需进行转换。

就我个人而言,我并不真正需要这个功能,不需要同时使用多个不同的目标,也不需要单个非 DOM 目标,我只是简单地使用 Voby 来渲染到 DOM。

从“简化”的角度来看,这是一个小众功能,会给框架带来复杂性,尤其是在需要同时支持多个渲染目标的情况下。我不需要这个功能,几乎每个人都不需要,所以我还没有实现它。

更小的捆绑尺寸

如果您使用 SSR,Solid 的自定义转换可以向浏览器发送稍微少一点的代码,因为某些代码可以在您的计算机中执行,而不是在用户的计算机上执行。

这可能很好,但我们谈论的是 3kb 或类似的数字,甚至可能更少。

如果你写的是一些小程序,或者像电商这样有特定限制的程序,这可能对你来说很有吸引力,但对于我感兴趣的富客户端应用程序来说,这几乎微不足道。例如,在 VS Code 中,仅编辑器组件Monaco就占用了 1MB 以上的 JavaScript 代码。

本质上,对于富客户端应用来说,SSR 几乎毫无用处。我电脑上现在运行的 VS Code 实例大约 10 天前启动的,作为用户,我一点也不介意启动时间多花 2 毫秒,因为使用 SSR 可以减少 3kb 的代码。在这种情况下,SSR 的意义不大。

使逻辑运算符具有反应性

实体代码:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const App = () => {
    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);

    return (
        <>
            <p>Even (Ternary): {count() % 2 ? 'no' : 'yes'}</p>
            <p>Even (Binary): {count() % 2 && 'yes'}</p>
        </>
    )
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

这太酷了!我们只是写了一段看似普通的 JavaScript 代码,但不知何故,我们看到的页面却像那些逻辑运算符一样被更新了?没错,转换基本上就是把它转换成等效的 JavaScript 代码,而这些代码是你必须编写的,才能使其具有响应性。

在我看来,这里有一个陷阱,那就是你应该把这段代码看作不是 JavaScript 代码。比如,一个三元运算符在 JavaScript 中不能被设置为响应式,但在 Solid(JSX 内部)中是响应式的,所以它实际上不能算作 JavaScript 三元运算符。换句话说,同样的行为在 Voby 中无法用 React 的 transform 实现。

更重要的细节是,对于任何进行分支的东西,比如这些逻辑运算符,有两种方式可以更新:当条件发生变化时,在仍然保持真值或假值的同时,您的组件会被刷新或缓存,它们有时分别被称为“键控”更新和“非键控”更新。

这里需要理解的重要一点是,有时你需要带键的行为,因为它可能更正确;有时你需要非带键的行为,因为它可能同样正确,但速度更快。这部分转换的问题在于,我不太清楚这些操作符在 Solid 中使用了哪种行为,因为我认为没有文档记录。更重要的是,我们无法在不同情况下在两者之间切换并选择正确的行为。因此,对于分支逻辑,最好使用 Solid 的Show组件或 Voby 的等效If组件之类的东西,因为使用自定义组件,你可以选择所需的行为。

这将是 Voby 中的等效代码,带有显式包装器:

import {$, render} from 'voby';

const App = () => {
    const count = $(0);
    setInterval(() => count(count() + 1), 1000);

    return (
        <>
            <p>Even (Ternary): {() => count() % 2 ? 'no' : 'yes'}</p>
            <p>Even (Binary): {() => count() % 2 && 'yes'}</p>
        </>
    )
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

提供原始 DOM 节点

实体代码:

const node = <div class="foo" />;
console.log(node instanceof HTMLDivElement); // true
Enter fullscreen mode Exit fullscreen mode

Solid 的 transform 可以直接提供原始 DOM 节点。无需 VDOM,无需中间函数,直接提供 DOM 节点,非常酷。

Voby代码:

const node = <div class="foo" />;
console.log(node instanceof HTMLDivElement); // false
console.log(typeof node === 'function'); // true
Enter fullscreen mode Exit fullscreen mode

相反,在 Voby 中,虽然它也没有 VDOM,但你总是会得到一个函数,一旦调用它就会给你结果,在这种情况下,一旦调用它就会给你<div>

这是 Voby 的一个要求,因为它使用了 React 的 transform,它无法直接通过该 transform 提供原始 DOM 节点(除非你不想遇到 bug)。这实际上触及了 Solid transform 最重要​​的特性:先调用父组件,再调用子组件。为了做到这一点,Voby 必须返回函数,以便能够按正确的顺序调用组件。Solid 不需要这样做,因为它使用了不同的 transform。

总的来说,这是 Solid 中一个非常酷的功能,特别是在直观层面上,你编写一个<div />然后就得到实际的<div />返回,感觉很对。

在我目前使用 Voby 的约 2 万行组件库中,我觉得这个功能本来只需要用一次就够了。挺酷的,但实际操作起来我觉得它并不特别重要,我可不想为此付出自定义转换的代价。或许在其他用例中,这个功能可以简化更多代码,你自己决定吧。

/* @once */评论

实体代码:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const Count = (props: {value: number}) => {
    return <p>{props.value}</p>;
};

const App = () => {
    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    return <Count value={/* @once */ count()} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

这与我们在上面的几点看到的片段相同,但count()我们写的不是/* @once */ count()

Solid 有一条特殊的注释,原因如下:假设你想将一个原语传递给组件,如果写成count(),解开信号,实际上会变成 getter ,从而将信号作为 prop 传递,你该怎么做呢?这条注释解决了这个问题,它只是告诉 transform 不要将其变成 getter。

在我看来,那条注释很奇怪。首先,我不希望我使用的任何库在我使用的语言中引入任何特殊注释。仔细想想,这根本不可能,JavaScript 中根本没法把注释变成特殊注释。但这条注释在 Solid 中是特殊的,所以它不是 JavaScript。事实上,JSX 从来就不是 JavaScript,但 React 的转换是 TypeScript 自带的,所以从某种意义上说,它现在就是普通的 TypeScript,而且这种转换本身就没有什么神奇之处。所以,照这个道理,Solid 代码就不是普通的 TypeScript,因为它的语义不同。

但从根本上来说,Voby 中的等效代码对我来说感觉更自然:

import {$, render} from 'voby';

const Count = ({value}: {value: number | (() => number)}) => {
    return <p>{value}</p>;
};

const App = () => {
    const count = $(0);
    setInterval(() => count(count() + 1), 1000);
    return <Count value={count()} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

这和我们上面几个点看到的代码几乎一样,只是count={count}现在我们不是写成 ,而是写成count{count()}。看起来很正常吧?之前我们想传递一个信号,所以就传递一个信号;现在我们想传递一个数字,所以就传递一个数字。在 Solid 中,这基本上是反过来的,解包信号时传递的是信号,而不是解包后的值。

总而言之,我觉得大家应该很快就能习惯 SolidScript(我喜欢这么叫它)的编写方式,但我仍然觉得很奇怪,count={count()}Voby 的编写方式不是响应式的,而 Solid 的编写方式却是响应式的。我理解 Solid 中为什么会出现这种情况,但我不喜欢,感觉很不对劲。

指令

实体代码:

import {render} from 'solid-js/web';
import {createSignal, createEffect} from 'solid-js';

const model = (el, value) => {
    const [field, setField] = value();
    createEffect(() => el.value = field());
    el.addEventListener("input", (e) => setField(e.target.value));
};

const App = () => {
    const [name, setName] = createSignal('');
    return <input type="text" use:model={[name, setName]} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

指令太棒了,我爱死它们了。它们本质上是 JSX 的一种扩展机制。本质上,它们允许你在原生元素上定义自定义的“use:*”属性。

换句话说,指令就像钩子,你不需要传递引用,因为它们已经从框架中传递了一个。

它们在 Solid 和 Voby 中的使用方式非常相似,区别在于它们的定义方式。

在 Solid 中,如果你把这段代码粘贴到编辑器中,你会立刻发现问题已经存在。TypeScript 认为那个“model”函数永远不会被使用,因为它不可能知道 Solid 的变换函数处于活动状态。但如果你在 Solid 中写“use:model”,那么它的变换函数会期望找到一个名为“model”的函数。我们该如何解决这个问题呢?

没有干净的解决方案,您必须编写一些虚拟代码来欺骗 TypeScript 相信它确实被使用了。

我们还应该扩展JSX.Directives接口,以便将我们创建的新属性告知 TypeScript,这没问题,我们应该这样做。但这里有一个小概念问题:我们的类型定义实际上是全局的,每个 Solid 文件都会使用相同的JSX类型,但我们指令的定义是局部的,不同的文件可能有不同的、不兼容的实现。转换和 TypeScript 都无法在编译时理解这个问题,而且公平地说,你自己很可能永远不会遇到这个问题,因为为什么要使用同名的两个不兼容的指令呢?但这值得强调。

在 Voby 中,指令是通过createDirective函数创建的,并像上下文一样附加到全局,如果你遵循建议的话。它们实际上只是被附加到上下文中的一个特殊符号下。

等效 Voby 代码:

import {$, createDirective, render, useEffect} from 'voby';
import type {Observable} from 'voby';

const ModelDirective = createDirective( 'model', (target: HTMLInputElement, value: Observable<string>) => {
    useEffect(() => {
        target.value = value();
    });
    target.addEventListener("input", (e) => value(e.target.value));
});

ModelDirective.register();

const App = () => {
    const name = $('');
    return <input type="text" use:model={name} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

就是这样。指令是一个实际的对象,你可以使用 来注册它ModalDirective.Provider,在本例中,就像上下文对象一样,或者使用.register函数,建议使用 函数来代替指令。

现在 TypeScript 不会认为我们的指令从未被使用过,因为通过调用.register它,它知道它可能会产生副作用。

此外,我们的指令现在已在框架中注册,如果我们使用它 100 次,我们不必导入ModelDirective100 次,我们只需要注册一次,它现在基本上只是 JSX 的一部分。

在我看来,使用上下文注册指令而不是要求对它们进行转换要好得多,而且这可能是一个简单的改变,也可以在 Solid 中实现,我不知道为什么要使用转换,它只是看起来更复杂,而且对我来说不太实用。

您可能认为 Solid 中将指令注册到变换器是为了提高性能,因为在 Solid 中,变换后的代码直接引用了指令,而在 Voby 中,这需要上下文查找。但事实并非如此,如果您在 Voby 中只全局注册指令(您应该这样做),则无需上下文查找,因为框架知道指令只是全局注册的,因此可以立即知道在哪里查找,这种方法不会带来可衡量的性能开销。


Solid 的变换可能还有其他值得注意的功能。目前我实在想不出其他的了。

总结

我爱 Solid

在这一点上,我可能应该明确地说我喜欢Solid,使用反应性原语对我来说感觉很好,Solid 让我大开眼界。

我只是不喜欢它的一些细节,而且有些功能对于我的用例来说是多余的。

在不了解你的情况下,我猜 Solid 可能更适合你,你绝对应该试试。Voby 不太可能更适合你的用例,如果不是因为缺乏生态系统或缺乏完善的文档(可能是因为它不太关心 SSR)。但如果你在开发客户端应用,或者你喜欢为 Astro 👀 做绑定,你可能也需要研究一下。

我喜欢简单

将来很难让复杂的代码库变得更简单,如果可以的话,最好从一开始就押注简单性,并尽可能长时间地坚持下去。

在经历了编写自己的框架的艰辛之后,我也尝试这样做,尽可能长时间地保持简单易用。同时不牺牲性能,因为我也很在意性能。

我认为 Solid 的一个功能对我来说不值得,那就是自定义 Babel 变换。我不需要它,也不想要它,你呢?

我们将在其他时间讨论剩下的约 29 点。

感谢阅读,祝您有美好的一天👋

文章来源:https://dev.to/fabiospampinato/voby-simplifications-over-solid-no-babel-no-compiler-5epg
PREV
构建可扩展的电子商务数据模型
NEXT
使用 Netlify 函数隐藏您的 API 密钥