0.Intro
这篇文章将为大家介绍前端圈“新”宠 Svelte ,以及其背后的响应式原理。对于 Svelte 你还没用过,但大概率会在一些技术周刊,社区,或者前端年度报告上听到这个名字。如果你使用掘金写文章的话,那其实已经在使用 Svelte 了,因为掘金新版的编辑器 bytemd 就是使用 Svelte 写的 👀 。
(:对于一些讯息源比较广的同学来说,Svelte 可能不算新事物,因为其早在 2016 就开始动工,是我落后了。
这篇文章发布与掘金:https://juejin.cn/post/696574...
1.Svelte 是啥?
一个前端框架,轮子哥 Rich Harris 搞的,你可能对这个人字不太熟悉,但 rollup 肯定听过,同一个作者。
新的框(轮)架(子)意味着要学习新的语法,好像每隔几个月就要学习新的“语言”,不禁让我想晒出那个旧图。
吐槽归吐槽,该学的还是要学,不然就要被淘汰了👻 。Svelte 这个框架的主要特点是:
- 用最基本的 HTML,CSS,Javascript 来写代码
- 直接编译成原生 JS,没有中间商(Virtual DOM) 赚差价
- 没有复杂的状态管理机制
2.框架对比
决定是否使用某个框架,需要有一些事实依据,下面我们将从 Star 数,下载趋势,代码体积,性能,用户满意度,等几个维度来对比一下 React、Vue、Angular、Svelte 这几个框架。
React | Vue | @angular/core | Svelte | |
---|---|---|---|---|
Star 数🌟 | 168,661 | 183,540 | 73,315 | 47,111 |
代码体积 🏋️♀️ | 42k | 22k | 89.5k | 1.6k |
Star 数上看,Svelte 只有 Vue(yyds)的四分之一(Svelte(2016) 比 Vue(2013) 慢起步三年)。不过 4.7w Star 数也不低。
代码体积(minizipped)上,Svelte 只有 1.6k !!!可别忘了轮子哥另一个作品是 rollup,打包优化很在行。不过随着项目代码增加,用到的功能多了,Svelte 编译后的代码体积增加的速度会比其他框架快,后面也会提到。
NPM 下载趋势
下载量差距非常明显,Svelte(231,262) 只有 React(10,965,933) 的百分之二。光看这边表面数据还不够,跑个分 看看。
Benchmark 跑分
越绿表示分越高,从上图可以看到 Svelte 在性能,体积,内存占用方面表现都相当不错。再看看用户满意度如何。
用户满意度
同样地,Svelte 排到了第一!!!(Interest 也是)。
初步结论
通过以上的数据对比,我们大致能得到的结论是:Svelte 代码体积小,性能爆表,未来可期,值得深入学习。
3.Svelte 基本语法
类 Vue 写法
<script>
let count = 0;
function handleClick() {
count += 1;
}
</script>
<button on:click={handleClick}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
<style>
button {
color: black;
}
</style>
以上就是一个 Svelte 组件,可以看到和 Vue 的写法基本一致,一个 .svelte
文件包含了 JS,HTML,CSS,并且使用类似的模板语法,指令绑定。
不一样的点可能是 Style 默认是 scoped
的,HTML 不需要用 <template></template>
包裹,以及没有 new Vue
,data
的初始化步骤。直接定义一个变量,直接用就行了。(背后发生了什么放到 Reactivity
章节再讲)
Vue 的写法:
var vm = new Vue({
data: {
count: 0
}
})
神奇的 $:
语法
需要在依赖数据变更时触发运算,在 Vue
中通常是使用 computed
来实现。
var vm = new Vue({
data: {
count: 0
},
computed: {
double: function () {
// `this` 指向 vm 实例
return this.count * 2
}
}
})
在 Svelte
也有类似的实现,我们使用 $:
关键字来声明 computed
变量。
<script>
let count = 0;
function handleClick() {
count += 1;
}
$: double = count * 2
</script>
<button on:click={handleClick}>
Clicked {double} times
</button>
上面的例子中,每次点击按钮,double 都会重新运算并更新到 DOM Tree 上。这是什么黑科技?是原生 JS 代码吗?
还别说,确实是,这里的使用的是 Statements and declarations 语法,冒号:
前可以是任意合法变量字符,定义一个 goto
语句。不过语义不一样,这里 Svelte 只是讨巧用了这个被废弃的语法来声明计算属性(还是原生 JS 语法,👻 没有引入黑科技。
该有的都有
作为一个前端框架,Svelte 该有的功能一样不少,例如模板语法,条件渲染,事件绑定,动画,组件生命周期,Context,甚至其他框架没有的它也有,比如自带 Store,Motion 等等非常多,由于这些 API 的学习成本并不高,用到的时候看一下代码就可以了。
接下来进入本篇文章的核心,Svelte 如何实现响应式(Reactivity) 或者说是数据驱动视图的方式和 Vue、React 有什么区别。
4.Reactivity
什么是 Reactivity?
高中化学的课堂我们接触过很多实验,例如使用紫色石蕊试液来鉴别酸碱。酸能使紫色石蕊溶液变成红色,碱能使紫色石蕊溶液变成蓝色。实验的原理是和分子结构有关,分子结构是链接,添加酸/碱是动作,而分子结构变化呈现出的结果就是反应 Reactivity。
利用好 Reactivity 往往能事半功倍,例如在 Excel/Number 里面的函数运算。
上例我们定义 E11
单元格的内容为 =SUM(D10, E10)
(建立连接),那么每次 D10
,E10
的数据发生变更时(动作),应用自动帮我们执行运算(反应),不用笨笨地手动用计算器运算。
没有 Reactivity 之前是怎么写代码的?
为了更清晰地认识 Reactvity 对编码的影响,设想一下开发一个 Todo 应用,其功能有新增任务,展示任务列表,删除任务,切换任务 DONE
状态等。
首先需要维护一个 tasks 的数据列表。
const tasks = [
{
id: 'id1',
name: 'task1',
done: false
}
]
使用 DOM 操作遍历列表,将它渲染出来。
function renderTasks() {
const frag = document.createDocumentFragment();
tasks.forEach(task => {
// 省略每个 task 的渲染细节
const item = buildTodoItemEl(task.id, task.name);
frag.appendChild(item);
});
while (todoListEl.firstChild) {
todoListEl.removeChild(todoListEl.firstChild);
}
todoListEl.appendChild(frag);
}
然后每次新增/删除/修改任务时,除了修改 tasks 数据,都需要手动触发重新渲染 tasks
(当然这样的实现并不好,每次删除/插入太多 DOM 节点性能会有问题)。
function addTask (newTask) {
tasks.push(newTask)
renderTaks()
}
function updateTask (payload) {
tasks = //...
renderTaks()
}
function deleteTask () {
tasks = //...
renderTaks()
}
注意到问题了吗,每次我们修改数据时,都需要手动更新 DOM 来实现 UI 数据同步。(在 jQuery 时代,我们确实是这么做的,开发成本高,依赖项多了以后会逐渐失控)
而有了 Reactvity,开发者只需要修改数据即可,UI 同步的事情交给 Framework 做,让开发者彻底从繁琐的 DOM 操作里面解放出来。
// vue
this.tasks.push(newTask)
在讲解 Svelte 如何实现 Reactivity
之前,先简单说说 React 和 Vue 分别是怎么做的。
React 的实现
React 开发者使用 JSX
语法来编写代码,JSX
会被编译成 ReactElement
,运行时生成抽象的 Virtual DOM。
然后在每次重新 render 时,React 会重新对比前后两次 Virtual DOM,如果不需要更新则不作任何处理;如果只是 HTML 属性变更,那反映到 DOM 节点上就是调用该节点的 setAttribute
方法;如果是 DOM 类型变更、key 变了或者是在新的 Virtual DOM 中找不到,则会执行相应的删除/新增 DOM 操作。
除此之外,抽象 Virtual DOM 的好处还有方便跨平台渲染和测试,比如 react-native, react-art。
使用 Chrome Dev Tool 的 Performance 面板,我们看看一个简单的点击计数的 DEMO 背后 React 都做了哪些事情。
import React from "react";
const Counter = () => {
const [count, setCount] = React.useState(0);
return <button onClick={() => setCount((val) => val + 1)}>{count}</button>;
};
function App() {
return <Counter />;
}
export default App;
大致可以将整个流程分为三个部分,首先是调度器,这里主要是为了处理优先级(用户点击事件属于高优先级)和合成事件。
第二个部分是 Render 阶段,这里主要是遍历节点,找到需要更新的 Fiber Node,执行 Diff 算法计算需要执行那种类型的操作,打上 effectTag,生成一条带有 effectTag 的 Fiber Node 链表。常说的异步可中断也是发生在这个阶段。
第三个阶段是 Commit,这一步要做的事情是遍历第二步生成的链表,依次执行对应的操作(是新增,还是删除,还是修改...)
所以对我们这个简单的例子,React 也有大量的前置工作需要完成,真正修改 DOM 的操作是的是红框中的部分。
前置操作完成,计算出原来是 nodeValue 需要更新,最终执行了 firstChild.nodeValue = text
。
演示使用的 React 版本是 17.0.2
,已经启用了 Concurrent Mode
。
每次 setState
React 都 Schedule Update,然后会遍历发生变更节点的所有子孙节点,所以为了避免不必要的 render,写 React 的时候需要特别注意使用 shouldComponentUpdate
,memo
,useCallback
,useMemo
等方法进行优化。
Vue 的实现
写了半天,发现还没写到重点。。。为了控制篇幅 Demo 就不写了(介绍 Vue 响应式原理的文章非常多)。
大致过程是编译过程中收集依赖,基于 Proxy(3.x) ,defineProperty(2.x) 的 getter,setter 实现在数据变更时通知 Watcher。Vue
的实现很酷,每次修改 data
上的数据都像在施魔法。
5. Svelte 降维打击
无论 React, Vue 都在达到目的(数据驱动 UI 更小)的过程中都多做了一些事情(Vue 也用了 Virtual DOM)。而 Svelte 是怎么做到减少运行时代码的呢?
秘密就藏在 Compiler 里面,大部分工作都在编译阶段都完成了。
核心 Compiler
Svelte 源代码分成 compiler 和 runtime 两部分。
那 Compiler 怎么收集依赖的呢?其实代码中的依赖关系在编译时是可以分析出来的,例如在模板中渲染一个 {name}
字段,如果发现 name 会在某些时刻修改(例如点击按钮之后),那就在每次name
被赋值之后尝试去触发更新视图。如果 name 不会被修改,那就什么也不用做。
这篇文章不会介绍 Compiler 具体如何实现,来看看经过 Compiler 之后的代码长什么样。
<script>
let name = 'world';
</script>
<h1>Hello {name}!</h1>
会被编译成如下代码,为了方便理解,我把无关的代码暂时删除了。
/* App.svelte generated by Svelte v3.38.2 */
import {
SvelteComponent,
append,
detach,
element,
init,
insert,
listen,
noop,
safe_not_equal,
set_data,
text
} from "svelte/internal";
function create_fragment(ctx) {
let h1;
return {
c() {
h1 = element("h1");
h1.textContent = `Hello ${name}!`;
},
m(target, anchor) {
insert(target, h1, anchor);
}
}
let name = "world";
create_fragment
方法是和每个组件 DOM 结果相关的方法,提供一些 DOM 的钩子方法,下一小结会介绍。
对比一下如果变量会被修改的代码
<script>
let name = 'world';
function setName () {
name = 'fesky'
}
</script>
<h1 on:click={setName}>Hello {name}!</h1>
编译后
import {
SvelteComponent,
append,
detach,
element,
init,
insert,
listen,
noop,
safe_not_equal,
set_data,
text
} from "svelte/internal";
function create_fragment(ctx) {
let h1;
let t0;
let t1;
let t2;
let dispose;
return {
c() {
h1 = element("h1");
t0 = text("Hello ");
t1 = text(/*name*/ ctx[0]);
t2 = text("!");
},
m(target, anchor) {
insert(target, h1, anchor);
append(h1, t0);
append(h1, t1);
append(h1, t2);
if (!mounted) {
// 增加了绑定事件
dispose = listen(h1, "click", /*handleClick*/ ctx[1]);
}
},
// 多一个 p (update)方法
p(ctx, [dirty]) {
if (dirty & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
}
};
}
// 多了 instance 方法
function instance($$self, $$props, $$invalidate) {
let name = "world";
function setName() {
$$invalidate(0, name = "fesky");
}
return [name, setName];
}
这种情况下编译结果的代码多了一些,简单介绍一下,首先是 fragment
中原来的 m
方法内部增加了 click
事件;多了一个 p
方法,里面调了 set_data
;新增了一个 instance
方法,这个方法返回每个组件实例中存在的属性和修改这些属性的方法(name, 和 setName),如果有其他属性和方法也是在同一个数组中返回(不要和 Hooks 搞混了)。
一些细节还不太了解没关系,后面都会介绍。重点关注赋值的代码原来的 name = 'fesky'
被编译成了 $$invalidate(0, name = "fesky")
。
还记得前面我们使用原生代码实现 Todo List 吗?我们在每次修改数据之后,都要手动重新渲染 DOM!我们不提倡这么写法,因为难以维护。
function addTask (newTask) {
tasks.push(newTask)
renderTaks()
}
而 Svelte Compile 实际上就是在代码编译阶段帮我们实现了这件事!把需要数据变更之后做的事情都分析出来生成原生 JS 代码,运行时就不需要像 Vue Proxy
那样的运行时代码了。
Selve 提供了在线的实时编译器,可以动动小手试一下。https://svelte.dev/repl/hello...
接下来的部分将是从源码角度来看看 Svelte
整体是如何 run
起来的。
Fragment——都是纯粹的 DOM 操作
每个 Svelte 组件编译后都会有一个 create_fragment
方法,这个方法返回一些 DOM 节点的声明周期钩子方法。都是单个字母不好理解,从 源码 上可以看到每个缩写的含义。
interface Fragment {
key: string|null;
first: null;
/* create */ c: () => void;
/* claim */ l: (nodes: any) => void;
/* hydrate */ h: () => void;
/* mount */ m: (target: HTMLElement, anchor: any) => void;
/* update */ p: (ctx: any, dirty: any) => void;
/* measure */ r: () => void;
/* fix */ f: () => void;
/* animate */ a: () => void;
/* intro */ i: (local: any) => void;
/* outro */ o: (local: any) => void;
/* destroy */ d: (detaching: 0|1) => void;
}
主要看以下四个钩子方法:
c(create):在这个钩子里面创建 DOM 节点,创建完之后保存在每个 fragment 的闭包内。
m(mount):挂载 DOM 节点到 target 上,在这里进行事件的板顶。
p(update):组件数据发生变更时触发,在这个方法里面检查更新。
d(destroy):移除挂载,取消事件绑定。
编译结果会从 svelte/internal
中引入 text
,element
,append
,detach
,listen
等等的方法。源码中可以看到,都是一些非常纯粹的 DOM 操作。
export function element<K extends keyof HTMLElementTagNameMap>(name: K) {
return document.createElement<K>(name);
}
export function text(data: string) {
return document.createTextNode(data);
}
export function append(target: Node, node: Node) {
if (node.parentNode !== target) {
target.appendChild(node);
}
}
export function detach(node: Node) {
node.parentNode.removeChild(node);
}
export function listen(node: EventTarget, event: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | EventListenerOptions) {
node.addEventListener(event, handler, options);
return () => node.removeEventListener(event, handler, options);
}
我们可以确信 Svelte 没有 Virtual DOM 了~
$$invalidate——Schedule Update 的开端
前面说了,Compiler 会把赋值的代码统统使用 $$invalidate
包裹起来。例如 count ++
,count += 1
,name = 'fesky'
等等。
这个方法干了什么?看看 源码,(删减了部分不重要的代码)
(i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
if (not_equal($$.ctx[i], $$.ctx[i] = value)) {
make_dirty(component, i);
}
return ret;
}
第一个参数 i
是什么?代码中运行起来赋值给 ctx 又是怎么回事 $$.ctx[i] = value
?,编译结果传入了一个 0???
$$invalidate(0, name = "fesky");
实际上,instance
方法会返回一个数组,里面包括组件实例的一些属性和方法。Svelte 会把返回 instance
方法的返回值赋到 ctx
上保存。所以这里的 i
就是 instance
返回的数组下标。
$$.ctx = instance
? instance(component, options.props || {}, (i, ret, ...rest) => {
//...
})
: [];
在编译阶段,Svelte 会按照属性在数组中的位置,生成对应的数字。例如现在有两个变量,
<script>
let firsName = '';
let lastName = '';
function handleClick () {
firsName = 'evan'
lastName = 'zhou';
}
</script>
<h1 on:click={handleClick}>Hello {firsName}{lastName}!</h1>
invalidate 部分代码编译结果就会变成:
function handleClick() {
// 对应数组下标 0
$$invalidate(0, firsName = "evan");
// 对应数组下标 1
$$invalidate(1, lastName = "zhou");
}
return [firsName, lastName, handleClick];
好了,接着往下,$$invalidate
中判断赋值之后不相等时就会调用 make_dirty
。
Dirty Check
function make_dirty(component, i) {
if (component.$$.dirty[0] === -1) {
dirty_components.push(component);
schedule_update();
component.$$.dirty.fill(0);
}
component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
}
这个方法里面的主流程是把调用 make_dirty
的组件添加到 dirty_components
中,然后调用了 schedule_update
方法。(dirty 字段的细节延后)
export function schedule_update() {
if (!update_scheduled) {
update_scheduled = true;
resolved_promise.then(flush);
}
}
schedule_update
很简单,在 Promise.resolve
(microTask) 中调用 flush
方法。
(看源码有点单调无聊,坚持住,马上结束了)
export function flush() {
for (let i = 0; i < dirty_components.length; i += 1) {
const component = dirty_components[i];
set_current_component(component);
update(component.$$);
}
}
flush
方法其实就是消费前面的 dirty_components
,调用每个需要更新组件的 update
方法。
function update($$) {
if ($$.fragment !== null) {
$$.update();
const dirty = $$.dirty;
$$.dirty = [-1];
$$.fragment && $$.fragment.p($$.ctx, dirty);
}
}
而 Update 方法呢,又回到了每个 fragment 的 p(update)
方法。这样整个链路就很清晰了。再整理以下思路:
- 修改数据,调用
$$invalidate
方法 - 判断是否相等,标记脏数据,
make_dirty
- 在 microTask 中触发更新,遍历所有
dirty_component
, 更新 DOM 节点 - 重置 Dirty
神奇的 Bitmask
上一小结中还有很重要的细节没有解释,就是 dirty
究竟是怎么标记的。
component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
看到 31
,看到 <<
右移符号,那铁定是位运算没跑了。首先我们要知道,JS 中所有的数字都是符合 IEEE-754
标准的 64
位双精度浮点类型。而所有的位运算都只会保留 32
结果的整数。
将这个语句拆解一下: (i / 31) | 0
:这里是用数组下标 i
属于 31,然后向下取整(任何整数数字和 | 0
的结果都是其本身,位运算有向下取整的功效)。 (1 << (i % 31))
:用 i
对 31
取模,然后做左移操作。
这样我们就知道了,dirty 是个数组类型,存放了多个 32
位整数,整数中的每个 bit
表示换算成 instance
数组下标的变量是否发生变更。
为了方便理解,我们用四位整数。
[1000] => [8] 表示 instance 中的第一个变量是 dirty。
[1001] => [9] 表示 instance 中的第一个变量和第四个变量是 dirty。
[1000, 0100] => [9, 4] 表示 instance 中的第一个变量和第六个变量是 dirty。
对这些基础知识不太熟悉的朋友可以翻我以前写的另外两篇文章
硬核基础二进制篇(一)0.1 + 0.2 != 0.3 和 IEEE-754 标准
硬核基础二进制篇(二)位运算
再回头看 p
方法,每次调用时都会判断依赖的数据是否发生变更,只有发生变更了,才更新 DOM。
p(ctx, [dirty]) {
if (dirty & /*firsName*/ 1) set_data(t1, /*firsName*/ ctx[0]);
if (dirty & /*lastName*/ 2) set_data(t2, /*lastName*/ ctx[1]);
}
对了,还有个约定,如果 dirty 第一个数字存储的是 -1
表示当前组件是干净的。
$$.dirty = [-1];
可以在 Github Issue 中找到相关的讨论,这样实现的好处是,编译后代码体积更小,二进制运算更快一点点。
小结
最后写个 DEMO 同样使用 Performance 面板记录代码运行信息和 React 对比一下。
<script>
let count = 1;
function handleClick () {
count += 1
}
</script>
<button on:click={handleClick}>{count}</button>
(由于实在太高效了,以至于我不得不单独为它做张放大图)💰钱都花在刀刃上。
希望看到这里你已经彻底掌握了 Svelte 响应式背后的所有逻辑。我把整个流程画了个草图,可以参考。整体看下来,Svelte 运行时的代码是非常精简,也很好理解的,有时间的话推荐看源码。
6.生态
决定是否使用某框架还有很打一个因数是框架生态怎么样,我在网上搜集了一部分,列出来供参考。
- SSR Sapper (7.1k ⭐)
- 组件库 svelte-material-ui (1.7k ⭐)
- VScode 插件 svelte-vscode (12k Usage)
- Router svelte-routing(1.4k ⭐)
- Native svelte-native(800 ⭐)
- 单元测试 svelte-testing-library (390 ⭐)
- Chrome Dev-tools Svelte Devtools (500 ⭐)
总体上看,整个生态还不太够强大,有很大空间。如果使用 Svelte 来开发管理后台,可能没有像使用 Antd 那样顺滑,而如果是开发 UI 高度自定义的 H5 活动页就完全不在话下。
7.结语
以前大家选 Vue 而不是 React 的理由,理由听到最多的是说 Vue
体积小,上手快。现在 Svelte 更小(针对小项目)更快更适合用来做活动页,你会上手吗?
Anyways,无论如何武器库又丰富了 💐💐💐,下次做技术选型的时候多了一种选择,了解了不用和没听说过所以不用还是有很大区别的。
对于我而言,Svelte 实现 Reactivity 确实特立独行,了解完实现原理也从中学到了很多知识。这篇文章花了我三天时间(找资料、看源码、写 DEMO,做大纲,写文章),如果觉得对你有收获,欢迎点赞 ❤️ + 收藏 + 评论 + 关注,这样我会更有动力产出好文章。
时间仓促,水平有限,难免会有纰漏,欢迎指正。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。