我对 Svelte 的看法

我对Svelte的看法

Mar 09, 2021 ·
22分钟阅读

我在很早前已经听说过 Svelte,但是一直没有机会在新的项目真正地用上。最近在尝试模仿实现一个 Roam Research 的编辑器,考虑到可能会有大量和DOM交互的逻辑,所以我趁这个机会选择了Svelte,目前这个编辑器的已经完成了大部分的功能,开源在 https://github.com/djyde/plastic-editor .

在开发的过程中我对Svelte非常满意,这篇文章打算就我的体验来说说我对Svelte的一些看法。

你需要有使用过任意一个前端框架的经验才能读懂本文。这篇文章不是一篇Svelte教程,如果你想学习Svelte,请浏览 Svelte的官方教程本文试图通过告诉读者Svelte在编译阶段做了什么,来让只听说过Svelte的读者,从浅层的原理和设计的角度,了解到Svelte是一个怎么样的框架、她和其它框架的区别是什么

Svelte是什么?

简单来说Svelte是一个在编译时实现了Reactivity (反应式)的框架,所以它既是一个框架,同时也是一个Compiler.如何理解「编译时实现了Reactivity?先说说什么是Reactivity.

let a = 1
let b = 2
let c = a + b
console.log(c) //=> 3
a = 2
console.log(c) //=> 3, not 4

JavaScript没有Reactivity的能力,所以即使let c = a + b,在改变了 ab 的值之后,c 不会因此而改变。真正的Reactive与此相反。

Excel举例,Excel是天生Reactive的,一个Excel里面的单元格,可以编写简单的函数组合其它单元格的值,而组合的结果会随着依赖的单元格改变而自动改变。例如单元格 C1 如果是=SUM(A1:B1),那么 C1 值会随着 A1B1 的值而重新计算和改变。

如果你用过Vue或者MobX,你应该知道它们实现Reactivity的原理:它们使用了 defineProperty 或者Proxy,setter这一层做了一些手脚,当对象的某个成员被赋值的时候,执行更新逻辑。

const reactive = {}
Object.defineProperty(reactive, 'a', {
set(value) {
console.log('a was updated')
}
})
reactive.a = 'changed' //=> a was updated

这是一种「运行时」的手段,它需要在运行时改变了赋值行为,所以在用Vue的时候,你必需把需要Reactivity的对象包在 data 里,上文的例子用Vue需要这么写:

const yourData = {
data() {
return {
a: 1,
b: 2
}
},
computed: { // 还有 computed
c() {
return this.a + this.b
}
}
}
const reactive = new Vue(yourData)
console.log(reactive.c) //=> 3
reactive.a = 2
console.log(reactive.c) //=> 4, not 3

但是我们可以想一想,如果不用defineProperty,可以实现类似的功能吗?

当然可以:

let a = 1
let b = 2
let c = a + b
function update() {
c = a + b
}
console.log(c) //=> 3
a = 2; update()
console.log(c) //=> 4
b = 5; update()
console.log(c) //=> 6

我们只要每次在赋值的时候,手动触发一个 update 函数,那么 c 的值就会重新计算,不就实现了Reactivity的效果吗?

但是这样做未免太蠢,要写太多的代码,而且很容易漏掉。不过,我们可以借助Compiler帮我们做这些事!在编译时,每当遇到赋值语句,我们就让它在赋值语句的后面自动加一个调用 update 方法的语句。

这就是Svelte做的事情。当然实际上的实现要更加复杂一些(比如需要把更新放在同一个microtask,但原理是一样的。

Reactivity是现代的前端框架的标配,因为从前MVC的开发模式对UI开发来说,要在Controller手动操作DOM这个步骤显得有点枯燥和多余,我们希望UI是自动随着数据的变化而自动更新的。因此不同的框架除了写法不尽相同外,最主要的区别还是在于框架在检测到数据更新后,如何处理UI的更新

以一个Counter为例,这是React的写法:

function Counter () {
const [ count, setCount ] = React.useState(0)
return (
<>
<div>{count}</div>
<button onClick={_ => setCount(count + 1) }>+</button>
<button onClick={_ => setCount(count - 1) }>-</button>
</>
)
}

我在之前的文章已经谈过React的思想,在这里稍作重复。React主张「视图」是「状态」的函数:

Terminal window
UI = f(state)

setState 的时候,这个函数会重新执行,因为是新的state,因此UI是变化的。在React里,UIVirtual DOM,用算法对比两个DOM树,来算出哪个真实的DOM需要被更新。

React的实现非常「粗暴,因为它是真的会重新执行这个函数,在上面的例子中,Counter 会在每次状态变化的时候被重新执行。这使得写React组件要多留心性能问题,因为你要避免在函数内部进行不必要的计算:

function Counter () {
const [ count, setCount ] = React.useState(0)
doSomethingHeavy()
return (
<>
<div>{count}</div>
<button onClick={_ => setCount(count + 1) }>+</button>
<button onClick={_ => setCount(count - 1) }>-</button>
</>
)
}

在「古典」React里,你不得不写shouldComponentUpdate,在现代React,你同样需要引入 useCallbackuseMemo,手动地缓存函数,来避免性能问题。

同样是使用Virtual DOMVue却没有这个问题,因为Vue的机制(依赖收集)决定了它不必重新执行整个UI函数来换取新的Virutal DOM树,当某个状态更新的时候,它明确地知道应该diff哪些节点。

如果你是Vue用户,而不太清楚React的机制,你可以把一个React组件函数想象成是一个Vuecomputed 里的成员函数,你一定知道在 computed 的成员函数里做耗时计算的后果是什么。

现代前端框架倾向于使用Virtual DOM,我认为主要出于两点:

Virtual DOM的本质就是找出需要被修改的真实DOM节点,难道不用Virtual DOM就不能实现吗?当然不是。回到上面的Counter的例子,我们如何用Vanilla JS (原生JavaScript)来实现:

const target = document.querySelector('#app')
// state
let count = 0
// view
const div = document.createElement('div')
const countText = document.createTextNode(`${count}`)
div.appendChild(countText)
const button1 = document.createElement('button')
const button1Text = document.createTextNode(`+`)
button1.appendChild(button1Text)
const button2 = document.createElement('button')
const button2Text = document.createTextNode(`-`)
button2.appendChild(button2Text)
target.appendChild(div)
target.appendChild(button1)
target.appendChild(button2)
// event
button1.addEventListener('click', () => {
count += 1
})
button2.addEventListener('click', () => {
count -= 1
})

上面的程序生成了UI,绑定了点击事件,改变了状态 count 的值。但是显然UI是不会随之改变的,所以我们需要写一个 update 函数,让状态在变化的时候,触发特定的UI更新逻辑:

const target = document.querySelector('#app')
// state
let state = {
count: 0
}
// view
const div = document.createElement('div')
const countText = document.createTextNode(`${state.count}`)
div.appendChild(countText)
const button1 = document.createElement('button')
const button1Text = document.createTextNode(`+`)
button1.appendChild(button1Text)
const button2 = document.createElement('button')
const button2Text = document.createTextNode(`-`)
button2.appendChild(button2Text)
target.appendChild(div)
target.appendChild(button1)
target.appendChild(button2)
// event
button1.addEventListener('click', () => {
update('count', state.count + 1)
})
button2.addEventListener('click', () => {
update('count', state.count - 1)
})
// update
function update(key, value) {
state[key] = value
countText.nodeValue = state[key]
}

现在点击按钮,div 显示的 count 就会变化了,因为我们在 update 函数指明了UI更新的逻辑。

我敢保证上面的程序性能一定比React版本的更好(当然在这个例子可能只相差0.0000002ms,因为DOM diff再快还是要算,原生JavaScript是不需要算的。

但没人愿意这样写程序:

  1. 这样的代码完全丧失了可读性,无法一眼看出UI树的结构。
  2. UI只要一调整,就需要写大量的代码。
  3. 每当有元素依赖一个状态值,就要手动在 update 函数中加上UI更新的逻辑。和传统的MVC没区别。

Svelte是一个Compiler,帮助你在编译时生成这些Vanilla JS的代码,同时收集依赖,生成UI更新的逻辑。

<div>hello world</div>

会被编译成:

const div = document.createElement('div')
const text = document.createTextNode('hello world')
div.appendChild(text)

这并不是Svelte编译出来的代码,真实的代码经过了封装。这里只是为了方便讲解,但本质上是一致的。

加一个变量:

<script>
let count = 0
</script>
<div>
{count}
</div>

会被编译成:

let count = 0
const div = document.createElement('div')
const text = document.createTextNode(`${count}`)
div.appendChild(text)
update() {
text.nodeValue = count
}

再次强调,这并非Svelte编译出来的真实代码。如果你对Svelte真实编译出来的代码有兴趣,可以在官方的REPL https://svelte.dev/repl 写一个简单的Svelte组件然后看JS output.然后推荐进一步阅读 https://lihautan.com/compile-svelte-in-your-head/

编译器在遇到 {count} 的时候,就可以收集到在count变化的时候需要更新哪些元素。也就是说,像Vue那样通过getter实现的依赖收集,Svelte通过编译阶段实现了。

一个完整的Counter:

<script>
let count = 0
</script>
<div>
{count}
</div>
<button on:click={_ => { count += 1 }}>+</button>
<button on:click={_ => { count -= 1 }}>-</button>

你可以在这里打开这个程序 https://svelte.dev/repl/cfd45cdafb8a48a88edab6921c69ac0c?version=3

在编译的阶段,只要遇到赋值语句,就可以插入一个语句来安排UI update (schedule update).就像本文最初提到的方法一样。

到这里,已经解释了什么是「在编译时实现了Reactivity

Svelte的特殊语法

Svelte里有一个比较特殊的语法,值得在这里介绍一下。

回到最初的例子:

let a = 1
let b = 2
let c = a + b

c 依赖了其它变量,如果其中的依赖发生了改变,它应该会被重新计算。在Vue里可以通过 computed 实现:

const reactive = new Vue({
data() {
return {
a: 1,
b: 2
}
},
computed: {
c() {
return this.a + this.b
}
}
})

Svelte用了一个特殊的语法实现了类似computed的功能:

<script>
let a = 1
let b = 2
$: c = a + b
</script>
<span>{c}</span>

: 其实是一个合法的JavaScript语法,https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/label

当然背后同样是在编译时实现的,它在更新视图的函数前会加入执行 a + b 并赋值给c的语句。

Svelte的跨组件通讯

状态管理和组件通讯是前端框架设计比较重要的一块,通常的做法是 Lifting State Up.也就是两个同级组件如果想要共享状态,那就把这个状态上升到共同的父组件上面去。Svelte也可以这么做。不过Svelte里有store,你可以定义一个writable store,然后在不同的组件之间进行读取和更新:

store.js
import { wrtiable } from 'svelte/store'
export let count = writable(0)
// A.svelte
<script>
import { count } from './store.js'
let count_value
const unsub = count.subscribe((newValue) => {
count_value = newValue
})
</script>
<sapn>{count_value}</sapn>
// B.svelte
<script>
import { count } from './store.js'
</script>
<button on:click={_ => count.set(2) }>mutate</button>

每个writable store其实是一个object,在需要用到这个值的组件里可以 subscribe 他的变化,然后更新到自己组件里的状态。在另一个组件里可以调用 set 更新这个状态的值。

敏锐的读者可能已经发现,上面的代码没有处理组件销毁时unsubscribe.当然我可以在 onDestroy() 里调用unsub(),但是在Svelte,有个更便捷的语法:

store.js
import { wrtiable } from 'svelte/store'
export let count = writable(0)
// A.svelte
<script>
import { count } from './store.js'
</script>
<sapn>{$count}</sapn>
// B.svelte
<script>
import { count } from './store.js'
</script>
<button on:click={_ => $count = 2 }>mutate</button>

Svelte规定了在store前面加一个$,会自动subscribe,得到它的值,并且在组件被销毁的时候自动帮你unsubscribe它。对它进行赋值的时候,其实相当于执行了 .set() 的方法。

不要以为 svelte/storewritable返回的对象是一个黑魔法,其实他不过是一个普通的对象而已,只是Svelte在编译的时候对 $ 为首的变量做了一些特殊处理。比如:

<script>
console.log($name)
</script>

会编译成:

let name_value
const unsub = name.subscribe((newValue) => {
name_value = newValue
})
console.log(name_value)
onDestroy(() =>{
unsub()
})

同样这不是Svelte实际生成的代码,这里是为了讲解,但本质和Svelte的逻辑一致

对一个store赋值:

<script>
$name = 'new'
</script>

会被编译成:

name_value.set('name')

看到这里,你可能已经知道了,其实store只不过是一个普通的对象,只是如果你在Svelte里通过$符号进行操作的时候,会调用它的一些方法。这些方法就是:

只要任何对象有实现两个方法,就可以用$进行这样的便捷使用。这是一种Svelte约定的 store协议.writable是一个创建符合store协议的对象的捷径,不是什么黑魔法。

如果你用RxJS,你会发现RxJS天生就兼容store协议

我对Svelte的看法

我用React太久了,在用回不需要关心rerender问题的框架,我觉得非常舒服😄。当然Vue也不需要,我觉得在某个程度上SvelteVue很像,它和Vue的区别是:

因为所有的功能都是在编译时实现的,所以用Svelte写的代码非常直白 —— 像正常定义变量一样定义变量,在HTML里使用这个变量,修改这个变量(而且没有 this。运行时的框架无论如何简单,至少需要你写一层Wrapper,例如在Vue里你必须把状态包在data函数中返回。

我认为 Write less code 是重要的,在前端开发的领域,我们花了太多精力在处理像immutable, reactivity这些UI开发标配的特性上面, 我一直认为需要有一门天生带了这些特性的语言用来写UI应用,而不是引入第三方包、写一些boilerplate code.

Svelte非常接近,而且它做到了不需要你学习新的语法,在JavaScript里面就实现了这样的效果(目前只是Reactivity

所以无论是对比哪个框架,我个人觉得Svelte对我来说最大的吸引力是可以写更少的代码,而且在写代码的时候感觉是符合直觉的,这一点非常重要,我认为React并没有做好这一点。我碰到过太多用React的朋友同样遇到过这样的问题:

function Timer () {
const [ time, setTime ] = React.useState(0)
React.useEffect(() => {
const interval = setInterval(() => {
setTime(time + 1)
}, 1000)
return () => clearInterval(interval)
}, [])
return (
<div>{time}</div>
)
}

这个 time 在视图里一直是1,为什么?谁会在第一次写这样的逻辑的时候就能意识到问题出现在哪里呢?

同样的逻辑在Svelte里,就不会有这样的问题(当然在Vue里也不会有

<script>
let time = 0
const interval = setInterval(() => {
time = time + 1
}, 1000)
onDestroy(() => {
clearInterval(interval)
})
</script>
<div>
{time}
</div>

当然可能有人会说,Randy,你连在hooks里面怎么用setInterval都不知道,你一定是JavaScript基础不行,回去补补基础吧!对于这样的想法,我只能一笑了之😆。

我没有办法提出一个杀手级的功能吸引没有用过Svelte的人来用Svelte,我只能说我喜欢Svelte是因为她是一个简单的框架,我在用她的时候不用想太多Why.

Vue的缺点和优点基本就是Svelte的缺点和优点,但Svelte从用法上比Vue更简单。

至于有人说,Svelte生成的代码体积小,我认为这确实是一个优势,不过要注意的是用Svelte生成的代码,体积是线性增长的。这是我大概画的一个图,表示项目规模和代码体积的关系:

这只是一个大概的趋势,图中的斜率不是一个准确的值。详细在Github看相关的讨论 https://github.com/sveltejs/svelte/issues/2546

Svelte的适用场景

生态是技术选型一个很重要的考虑因素,Svelte显然不是数一数二的选择,基本不用期望Svelte有什么现成的组件库。

我认为Svelte非常适合用来做活动页。活动页没有很复杂的交互,以渲染和事件绑定为主。我常常想做简单的活动页还用React也太委屈自己了吧。

另外一个很好的用法是用Svelte写的UI组件,可以包装成给不同框架用的组件。Svelte暴露了足够的 API 可以适配到其它框架,比如写一个日历组件,然后分别包装成ReactVue的版本。

结论

我很喜欢Rich Harris (Svelte的作者)的很多想法,在YouTube看他的演讲有很大的收获。你可以在下面的延伸链接找到更多有关Svelte的我认为不错的视频,十分推荐Rethinking Reactivity这个演讲(我在Bilibili没有找到搬运,所以只能贴上YouTube链接了

最后想说的是,学习一个框架或者一个语言,不一定是非要把它用到生产环境才算是有用。我很喜欢看新的技术和学不同的语言,更多地是因为想看看在面对同一个问题的时候,不同的人解决问题的思路是怎么样的,这才是框架和语言真正的魅力。比如说你不一定非要用 Elm,但是你一定能从Elm的设计学到点什么。Svelte也一样:)

延伸链接


sponsor
sponsor
通过支付宝[email protected]或赞赏码赞助此文