svelte5:一个更糟糕的 vue3

本文最后更新于:2025年3月9日 凌晨

背景

svelte5 在去年 10 月发布,据说是 svelte 发布以来最好的版本。其中,他们主要为之自豪的是 runes,这是一个基于 proxy 实现的一个反应式状态系统。但经过 vue3 composition api 和 solidjs signals,吾辈并未感到兴奋。相反,这篇博客将主要针对吾辈在实际项目中遇到的 svelte5 问题进行说明,如果你非常喜欢 svelte5,现在就可以关闭页面了。

runes 只能在 svelte 组件或者 .svelte.ts 文件中

例如,当吾辈像 react/vue 一样用 runes 编写一个 hook,例如 useCounter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export function useCounter() {
let count = $state(0)
return {
get value() {
return count
},
inc() {
count++
},
dec() {
count--
},
}
}

const counter = useCounter()
console.log(counter.value)
counter.inc()

但这个函数不能放在普通的 .ts 文件,必须使用 .svelte.ts 后缀并让 @sveltejs/vite-plugin-svelte 编译这个文件中的 runes 代码,否则你会得到 $state is not defined。这也包括 unit test,如果想要使用 runes(通常是测试 hooks/svelte component),文件名也必须以 .svelte.test.ts 结尾,真是糟糕的代码感染。

https://svelte.dev/docs/svelte/what-are-runes#:~:text=Runes%20are%20symbols%20that%20you%20use%20in%20.svelte%20and%20.svelte.js%20/%20.svelte.ts%20files%20to%20control%20the%20Svelte%20compiler

使用 runes 编写 hooks 传递或返回 runes 状态须用函数包裹

看到上面的 useCounter 中返回的值被放在 get value 里面了吗?那是因为必须这样做,例如如果尝试编写一个 hooks 并直接返回 runes 的状态,不管是 $state 还是 $derived,都必须用函数包裹传递来“保持 reaction”,否则你会得到一个错误指向 https://svelte.dev/docs/svelte/compiler-warnings#state_referenced_locally。当然这也包括函数参数,看到其中的讽刺了吗?

1
2
3
4
5
6
7
8
9
10
11
12
import { onMount } from 'svelte'

export function useTime() {
let now = $state(new Date())
onMount(() => {
const interval = setInterval(() => {
now = new Date()
}, 1000)
return () => clearInterval(interval)
})
return now
}

当然,你不能直接返回 { now } 而必须使用 get/set 包裹,svelte5 喜欢让人写更多模版代码。

1
2
3
4
5
6
7
8
9
10
11
export function useTime() {
// other code...
return {
get now() {
return now
},
set now(value) {
now = value
},
}
}

class 是 runes 一等公民,或许不是?

哦,当吾辈说必须使用函数包裹 runes 状态时,显然 svelte 团队为自己留了逃生舱口,那就是 class。检查下面这段代码,它直接返回了 class 的实例,而且正常工作!如果你去查看 sveltekit 的官方代码,他们甚至将 class 的声明和创建放在了一起:https://github.com/sveltejs/kit/blob/3bab7e3eea4dda6ec485d671803709b70852f28b/packages/kit/src/runtime/client/state.svelte.js#L31-L40

1
2
3
4
5
6
7
8
9
10
11
12
export function useClazz1() {
class Clazz1 {
count = $state(0)
inc() {
this.count++
}
dec() {
this.count--
}
}
return new Clazz1()
}

显然,它不能应用于普通的 js object 上,不需要等到运行,在编译阶段就会爆炸。

1
2
3
4
5
6
7
8
9
10
11
export function usePojo() {
return {
value: $state(0), // `$state(...)` can only be used as a variable declaration initializer or a class field https://svelte.dev/e/state_invalid_placement
inc() {
this.value++
},
dec() {
this.value--
},
}
}

最后,让我们看看 $state 是否可以将整个 class 变成响应式的?

1
2
3
4
5
6
7
8
9
10
class Clazz2 {
value = 0
inc() {
this.value++
}
dec() {
this.value--
}
}
const clazz = $state(new Clazz2())

当然不行,像 mobx 一样检测字段 class field 并将其变成响应式的显然太难了。然而,有趣的是,在这里你可以使用普通的 js 对象了。当然,当然。。。

1
2
3
4
5
6
7
8
9
const clazz = $state({
value: 0,
inc() {
this.value++
},
dec() {
this.value--
},
})

印象中这几种写法在 vue3 中都可以正常工作,看起来怪癖更少一点。

svelte 模板包含一些无法使用 js 实现特定功能

就像 svelte 官方文档中说明的一样,在测试中无法使用 bindable props,因为它是一个模版的专用功能,无法在 js 中使用,必须通过额外的组件将 bindable props 转换为 svelte/store writable props,因为它可以在 svelte 组件测试中使用。

1
2
3
4
5
6
7
8
9
10
<!-- input.svelte -->
<script lang="ts">
let {
value = $bindable(),
}: {
value?: string
} = $props()
</script>

<input bind:value />

当想要测试这个包含 bindable props 的组件时,必须编写一个包装组件,类似这样。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- Input.test.svelte -->
<script lang="ts">
import { type Writable } from 'svelte/store'

let {
value,
}: {
value?: Writable<string>
} = $props()
</script>

<input bind:value={$value} />

单元测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { expect, it } from 'vitest'
import { render } from 'vitest-browser-svelte'
import InputTest from './Input.test.svelte'
import { get, writable } from 'svelte/store'
import { tick } from 'svelte'

it('Input', async () => {
let value = writable('')
const screen = render(InputTest, { value })
expect(get(value)).empty
const inputEl = screen.baseElement.querySelector('input') as HTMLInputElement
inputEl.value = 'test1'
inputEl.dispatchEvent(new InputEvent('input'))
expect(get(value)).eq('test1')
value.set('test2')
await tick()
expect(inputEl.value).eq('test2')
})

是说,有办法像是 vue3 一样动态绑定多个 bindable props 吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<my-component v-bind="dynamicProps"></my-component>
</template>

<script setup>
import { reactive } from 'vue'

const dynamicProps = reactive({
title: 'Title',
description: 'Desc',
active: true,
})
</script>

不,它甚至没有提供逃生舱口,所以无法实现某种通用的 Bindable2Writable 高阶组件来将 bindable props 自动转换为 writable props,这是非常愚蠢的,尤其是 vue3 已经珠玉在前的前提下,svelte5 的实现如此糟糕简直难以理解。

https://github.com/sveltejs/svelte/discussions/15432

表单组件默认是非受控的,有时候会带来麻烦

对于下面这样一个组件,它只是一个双向绑定的 checkbox,很简单。

1
2
3
4
5
<script lang="ts">
let checked = $state(false)
</script>

<input type="checkbox" bind:checked />

那么,如果去掉 bind 呢?单向绑定?不,它只是设置了初始值,然后就由 input 的内部状态控制了,而不是预期中的不再改变。观看 3s 演示

https://x.com/rxliuli/status/1896856626050855298/video/3

当然,这不是 svelte 的问题,除了 react 之外,其他 web 框架的单向数据流似乎在遇到表单时都会出现例外

生态系统太小

这点是所有新框架都避免不了的,但在 svelte 却特别严重,包括

社区反应

每当有人抱怨 svelte5 变得更复杂时,社区总有人说你是用 svelte5 编写 hello world 的新手、或者说你可以继续锚定到 svelte4。首先,第一点,像吾辈这样曾经使用过 react/vue 的人而言,svelte4 看起来很简单,吾辈已经用 svelte4 构建了一些程序,它们并不是 hello world,事实上,可能有 10k+ 行的纯代码。其次,锚定到旧版本对于个人是不可能的,当你开始一个新项目的时候,几乎没有办法锚定到旧版本,因为生态系统中的一切都在追求新版本,旧版本的资源很难找到。


就在吾辈发布完这篇博客之后,立刻有人以 “Svelte’s reactivity doesn’t exist at runtime” 进行辩护,而在 svelte5 中,这甚至不是一个站得住脚的论点。当然,他获得了 5 个 👍,而吾辈得到了一个 👎。
https://www.reddit.com/r/sveltejs/comments/1j6ayaf/comment/mgnctgm/

总结

svelte5 变得更好了吗?显然,runes 让它与 react/vue 看起来更像了一点,但目前仍然有非常多的边界情况和怪癖,下个项目可能会考虑认真使用 solidjs,吾辈已经使用 react/vue 太久了,还是想要找点新的东西看看。


svelte5:一个更糟糕的 vue3
https://blog.rxliuli.com/p/da2dd0d86bc147778bcb0ba3c3e68149/
作者
rxliuli
发布于
2025年3月4日
许可协议