Svelet 浅析

1,882 阅读7分钟

前言

背景、本文主要内容

Svelet 是 Rich Harris 开发维护的前端框架, 在 Stackoverflow 2021 调研中被评选为最受欢迎的前端框架。它和 React 和 Vue 等类似地,以组件的形式来开发前端,核心不同点在于 Svelet 不使用 运行时和VDOM 来实现 "Reactive" 和 DOM 的更新;而Svelet 去掉了 VDOM,直接操作真实 DOM;在编译时生成高效代码来更新 DOM和实现 Reactive。

本文建介绍 Svelet 框架的基本概念,从 React 开发者视角来对比 Svelet 的使用;然后对 Svelet Reactive 实现进行解析。

什么是 Reactive

首先什么是 Svelet 强调的 Reactive,Svelet 作者以电子表格总的函数计算为例:电子表格中以函数计算出来的单元格的值是从其他单元格经过函数计算得出的;但其中一些单元格值发生变化时,依赖它的单元格就会重新计算并进行更新;同时这个更新过程不是把所有的单元格都重新计算一遍,而是根据依赖关系只计算被影响到的单元格。这个过程就是 Reactive 的理解。

1645099729831-86a46704-406f-4782-9b71-c1a4c9331acb.png

1645099744916-160b386d-ba37-457f-97c9-f672e18bb5d6.png

那在 JavaScript 中,理想的Reactive 是怎样的呢?考虑如下代码,这里声明了两个变量 a 和 b,其中 b = a * 2 就可以认为是执行了依赖关系。

a = 1
b = a * 2

当再次更新 a 的时候 a = 2,按照 Reactive 的要求,这时 b 就应该为 2 * 2 = 4 了, 即这样就达到了 Reactive 的效果。

是的,Svelet 就提供了这样的能力来表达 Reactive,在介绍这种 Reactive 使用方式前,我们先看看使用 Svelet 如何编写组件。

初识框架

组件结构

在 Svelet 中,新建 svelet 后缀文件就可以开始编写一个 Svelet 组件了,一个基本的 Svelet 组件包含三大部分:

// MyFirstComponent.svelet

<script>
  // 组件 JS 代码
  let valid = false;
</script>

<div>
  // 组件 html/模版
</div>

<p>
  // 组件允许多个 html/模版 片段
  {@if valid}
	<p>valid</p>
  {/if}
</p>


<style>
  // 组件样式
  p {
    color: red;
  }
</style>
  • 脚本

组件的 JS 逻辑,比较内部使用的变量、组件的 state、组件的事件处理函数等 JS 代码都在 script 部分

  • 模版

Svelet 采用HTML模版来编写组件的 DOM,直接在 Svelet 文件中编写HTML模版代码就可以,在HTML模版中可以使用 script 中定义的变量和函数;同时 Svelet 提供了各种指令可以在模版中使用,如 {@if } 、{@each } 等等

  • 样式

最后在 Svelet style 标签中编写 CSS 就可以,注意 Svelet 组件中的 style 样式默认就是 scope style,即文件中的样式对会影响当前组件

在引用组件的时候,直接 import 即可:

// App.svelet

<script>
  import MyFirstComponet from './MyFirstComponent.svelet';
</script>

<MyFirstComponent />

State

在 React 中,组件的 state 需要在编写代码时通过 this.state/setState 或者 useState 来使用;而在 Svelet 中,组件的 state 直接使用原始 JS 语法写在 script 标签中即可。也可以认为,在 Svelet 组件中,不需要 state,直接使用 js 变量即可;但对变量进行赋值的时候,Svelet 会标记组件需要更新,并在 loop 结束的时候,对 DOM 进行更新。

因为没有 VDOM,组件更新时候的时候,Svelet 会直接更新变量影响到的 DOM 元素,即对数据和DOM的依赖更新做到了 “Reactive”: UI = f(state)

<script>
  let count = 0;
  let label = 'count is '
  
  function handleClick() {
    count = count + 1;
  }
</script>

<div>
	<p>{label}</p>
	<p>{count}</p>
	<button on:click={handleClick}>increment</button>
</div>

对于上面的例子,当用户点击按钮的时候,Svelet 会直接更新第二个 p 元素的 text 元素;而不是重新从模版计算出 DOM 结构进行 Diff 更新。

Reactive

在上面的例子中,我们看到了 “state”,并且知道了 state 更新后依赖的 DOM 元素会被 “reactive” 地被更新。Svelet 也支持变量直接的 “reactive”,在组件中通过标记语句完成:

<script>
  let count = 0;
  $: doubled = count * 2;
  
  function handleClick() {
    count += 1;
  }
</script>

<p>count is {count}</p>
<p>doubled is {doubled}</p>
<button on:click={handleClick}>increment</button>

Svelet reactive 也支持写块语句,类似于 React 中的 useEffect, 当 reactive 语句中依赖的变量变化后,在组件更新后,就会被执行。

<script>
  let count = 0;
  $: doubled = count * 2;
  
  function handleClick() {
    count += 1;
  }
  
  $: {
    if (count > 10) {
			alert('count is dangerously high!');
      count = 0;
    }
  }
  
</script>

<p>count is {count}</p>
<p>doubled is {doubled}</p>
<button on:click={handleClick}>increment</button>

Props

同样,Svelet 组件支持 props,父组件可以通过 props 传递数据给子组件。在组件 script 中的变量加上 export 即可以建变量标记为 props,同样也可以指定默认值:

// Person.svelet
<script>
  // prop name
  export let name;
  // prop age
  export let age = 0;
</script>
  
<p>Name: {name}!</p>

{@if age}
	<p>Age: {age}></p>
{/if}
// App.svelet
<script>
  import Person from './Person.svelet';
</script>

<Person name={"Peter"} age={30} />
<Person name={"Harris"} />

生命周期函数

Svelet 组件的生命周期包括:

  • onMount:组件第一次渲染后的回调
  • onDestory:组件卸载的回调
  • beforeUpdate/afterUpdate:组件DOM元素更新前后的回调

比较常用的 onMount,类似于在 React didMount 后进行的数据请求等:

<script>
	import { onMount } from 'svelte';

	let photos = [];

	onMount(async () => {
		const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
		photos = await res.json();
	});
</script>

需要注意的是,生命周期回调函数需要在组件初始化时调用,比如不能在 setTimeout 等函数中调用;但具体在哪个地方调用则都可以,因此,可以把调用生命周期的函数抽到 utiils 等地方也是可以的:

// utils.js
import { onDestroy } from 'svelte';

export function onInterval(callback, milliseconds) {
	const interval = setInterval(callback, milliseconds);

	onDestroy(() => {
		clearInterval(interval);
	});
}
<script>
	import { onInterval } from './utils.js';

	let counter = 0;
	onInterval(() => counter += 1, 1000);
</script>

<p>count is {count}</p>

Store

在 Svelet 组件内部,直接通过赋值语句就可以完成对 state 更新,对组件外部的状态管理,Svelet 提供了 store,store 的状态可以对多个组件使用和更新;类似于 React 生态的 Redux 等状态管理库。

// store.js
import { writable } from 'svelte/store';

export const count = writable(0);
// Incrementer.svelte
<script>
	import { count } from './stores.js';

	function increment() {
    count.update(n => n + 1);
	}
</script>

<button on:click={increment}>
	+
</button>
// Decrementer.svelte
<script>
	import { count } from './stores.js';

	function decrement() {
    count.update(n => n - 1);
	}
</script>

<button on:click={decrement}>
	-
</button>
<script>
import { count } from './store.js';
  
let countValue;
  
count.subscribe(value => {
		countValue = value;
	});
</script>

<h1>The count is {countValue}</h1>

// 自动订阅
<h2>The count is ${count}</h1>

Context

Svelet 同样提供了 Context,和 React 中的Context 功能基本一致,在祖先组件中 setContext,它的所有下层组件都可以通过 getContext 获取

// App.svelte
<script>
  import { setContext } from 'svelte';
  let theme = 'light';
  function onChangeTheme(newTheme) {
    theme = newTheme;
  }
	
  setContext("theme", {
    theme,
    onChangeTheme,
  });
<script>
// Component.svelte
<script>
  import { getContext } from 'svelte'
  const { theme } = getContext('theme');
</script>

其他

此外,Svelte 还通过了

  • 事件:除了上面用到 DOM事件外,Svelet 组件支持自定义事件和dispatch,即组件可以抛出事件,父组件可以通过 on 直接响应
  • slot: 组件可以定义 slot 来配合组件的子元素使用,类似于 props.children

原理浅析

Svelte 编译流程

Svelte 区分于 React/Vue 等框架的部分在于 Svelet 提供了编译器,Svelet 将组件 svelte 文件编译成对应的组件 js 和 css,然后加上 svelte 运行时(对,Svelet 也还是有运行时,只不过它的运行时比较薄),经过打包输出最终应该程序,整个流程如下:

1645152871267-bc20ebd8-d8c5-43d6-8cac-243b0c63cbfd.svg

Svelet 编译器会对组件 svelte 文件进行编译,对每个组件输出组件 JS 代码和 CSS 样式代码,组件的 JS 代码依赖于 Svelte 运行时代码。

一个实例,对下面的 Svelete 组件

// App.svelte
<script>
  let count = 0;

  function handleClick() {
    count = count + 1;
  }
</script>

<p>count is {count}</p>
<button on:click={handleClick}>increment</button>

Svelet 编译器生成的 JS 代码如下:

/* App.svelte generated by Svelte v3.46.4 */
import {
	SvelteComponent,
	append,
	detach,
	element,
	init,
	insert,
	listen,
	noop,
	safe_not_equal,
	set_data,
	space,
	text
} from "svelte/internal";

function create_fragment(ctx) {
	let p;
	let t0;
	let t1;
	let t2;
	let button;
	let mounted;
	let dispose;

	return {
		c() {
			p = element("p");
			t0 = text("count is ");
			t1 = text(/*count*/ ctx[0]);
			t2 = space();
			button = element("button");
			button.textContent = "increment";
		},
		m(target, anchor) {
			insert(target, p, anchor);
			append(p, t0);
			append(p, t1);
			insert(target, t2, anchor);
			insert(target, button, anchor);

			if (!mounted) {
				dispose = listen(button, "click", /*handleClick*/ ctx[1]);
				mounted = true;
			}
		},
		p(ctx, [dirty]) {
			if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
		},
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(p);
			if (detaching) detach(t2);
			if (detaching) detach(button);
			mounted = false;
			dispose();
		}
	};
}

function instance($$self, $$props, $$invalidate) {
	let count = 0;

	function handleClick() {
		$$invalidate(0, count = count + 1);
	}

	return [count, handleClick];
}

class App extends SvelteComponent {
	constructor(options) {
		super();
		init(this, options, instance, create_fragment, safe_not_equal, {});
	}
}

export default App;

可以看到,对每个组件,Svelte 会生成对应一个继承于 SvelteComponet 的 class,SvelteComponet 以及其他函数如 init 都来自 Svelte 运行时 svelte/interal;最终生成的 JS 代码经过打包工具即可以输出完整的应用程序了。

SvelteComonet 内部结构

首先看看 SveleteComponent 的定义:

  • $$ 属性,记录了组件的内部状态,如组件当前的 props,组件上挂载的生命周期函数、组件更新逻辑需要相关状态等等
  • $destroy: 销毁组件
  • $set:设置 props
  • $on: 监听组件的事件
/**
 * Base class for Svelte components. Used when dev=false.
 */
export class SvelteComponent {
	$$: T$$;
	$$set?: ($$props: any) => void;

	$destroy() {
		destroy_component(this, 1);
		this.$destroy = noop;
	}

	$on(type, callback) {
		const callbacks = (this.$$.callbacks[type] || (this.$$.callbacks[type] = []));
		callbacks.push(callback);

		return () => {
			const index = callbacks.indexOf(callback);
			if (index !== -1) callbacks.splice(index, 1);
		};
	}

	$set($$props) {
		if (this.$$set && !is_empty($$props)) {
			this.$$.skip_bound = true;
			this.$$set($$props);
			this.$$.skip_bound = false;
		}
	}
}

其中 $$ 定义如下:

interface T$$ {
	dirty: number[];
	ctx: null | any;
	bound: any;
	update: () => void;
	callbacks: any;
	after_update: any[];
	props: Record<string, 0 | string>;
	fragment: null | false | Fragment;
	not_equal: any;
	before_update: any[];
	context: Map<any, any>;
	on_mount: any[];
	on_destroy: any[];
	skip_bound: boolean;
	on_disconnect: any[];
	root:Element | ShadowRoot
}

其中一些主要的:

  • ctx数组类型,是生成和更新 DOM 的输入,Svelte 模版中使用到的变量和函数都会按顺序保存在 ctx 数组中
  • dirty 数字bitmap 数组,用来标记 ctx 中的那些值发生了变化,dirty 使用 bitmap 来标记 cxt ,dirty 数组中每一项即可以标记 32 个 ctx 值
  • update 组件更新后组件内部的回调函数
  • props:父组件传递的 props
  • context:通过 setContext/getContext 设置的 context
  • on_mounton_destroybefore_updateafter_udpate:保存组件注册的生命周期回调函数

组件初始化 init

从 Sevlte 编译器生成的代码可以看到组件初始化主要有 init 函数完成,接受的参数中其中 instancecreate_fragment 是组件代码:

init(this, options, instance, create_fragment, safe_not_equal, {});

这里 instance 即为生成 $$.ctx 的函数,对上面的组件最终ctx为 [count, handleClick],即 count 和 handleClick 是在 html 模版中使用到的变量和函数

function instance($$self, $$props, $$invalidate) {
	let count = 0;

	function handleClick() {
		$$invalidate(0, count = count + 1);
	}

	return [count, handleClick];
}

在 Svelet 内部,组件init 函数执行的时候,主要完成以下几件事:

  • 生成一个默认的 componet.$$ 对象
const $$: T$$ = (component.$$ = {
  fragment: null,
  ctx: null,

  // state
  props,
  update: noop,
  not_equal,
  bound: blank_object(),

  // lifecycle
  on_mount: [],
  on_destroy: [],
  on_disconnect: [],
  before_update: [],
  after_update: [],
  context: new Map(
    options.context || (parent_component ? parent_component.$$.context : [])
  ),

  // everything else
  callbacks: blank_object(),
  dirty,
  skip_bound: false,
  root: options.target || parent_component.$$.root,
});

append_styles && append_styles($$.root);

  • 调用组件的 instance 函数,将返回值保存到 $$.ctx 即完成 ctx 的初始化,此外 instance 还负责处理 reactive 语句,即 $: {...} 代码
$$.ctx = instance
? instance(component, options.props || {}, (i, ret, ...rest) => {
  const value = rest.length ? rest[0] : ret;
  if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
    if (!$$.skip_bound && $$.bound[i]) $$.bound[i](value);
    if (ready) make_dirty(component, i);
  }
  return ret;
})
: [];

这里可以看到了,在上面代码中的 $$invalidate 函数的实现了,即 instance 的第3个参数,这里 invlidate函数的逻辑是判断值是否发生了变化,如果变化了,则将invlidate 函数的逻辑是判断值是否发生了变化,如果变化了,则将 .dirty 对应的位上进行标记 ctx 脏

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));
}
  • 如果组件有 target,则挂载组件;并执行生命周期函数
export function mount_component(component, target, anchor, customElement) {
	const { fragment, on_mount, on_destroy, after_update } = component.$$;

	fragment && fragment.m(target, anchor);

	if (!customElement) {
		// onMount happens before the initial afterUpdate
		add_render_callback(() => {

			const new_on_destroy = on_mount.map(run).filter(is_function);
			if (on_destroy) {
				on_destroy.push(...new_on_destroy);
			} else {
				// Edge case - component was destroyed immediately,
				// most likely as a result of a binding initialising
				run_all(new_on_destroy);
			}
			component.$$.on_mount = [];
		});
	}

	after_update.forEach(add_render_callback);
}

组件的 mount 主要是 fragement 的 mount,即组件代码中的 create_fragment

fragement

每个组件编译的代码出了上面的 instance 函数用来初始化 ctx 和执行 reactive 语句外,还有个 create_fragment 来负责 DOM 的创建、更新和销毁

先看看 create_fragement 返回对象 fragment 的类型定义:

export 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;
}

其中比较重要的是 fragemnt.c 负责 DOM 的创建、fragment.m 负责 DOM 的挂载、fragment.p 负责 DOM 的更新、和 fragment.d 负责 DOM 的销毁。对上午例子的 create_fragment 函数如下:

function create_fragment(ctx) {
	let p;
	let t0;
	let t1;
	let t2;
	let button;
	let mounted;
	let dispose;

	return {
		c() {
			p = element("p");
			t0 = text("count is ");
			t1 = text(/*count*/ ctx[0]);
			t2 = space();
			button = element("button");
			button.textContent = "increment";
		},
		m(target, anchor) {
			insert(target, p, anchor);
			append(p, t0);
			append(p, t1);
			insert(target, t2, anchor);
			insert(target, button, anchor);

			if (!mounted) {
				dispose = listen(button, "click", /*handleClick*/ ctx[1]);
				mounted = true;
			}
		},
		p(ctx, [dirty]) {
			if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
		},
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(p);
			if (detaching) detach(t2);
			if (detaching) detach(button);
			mounted = false;
			dispose();
		}
	};
}

这里 create_fragment 函数的参数 ctx 即为 compoent.$$.ctx,有 instance 函数的 ctx

可以看到 fragment.c 函数通过 DOM API 创建了一些 DOM 元素, fragment.m 函数会将这些元素挂载到 target 上;fragment.p 函数则根据 dirty 状态来直接操作对应的 DOM 元素。

组件更新

最后,来看看但 handleClick 执行后,组件是如何更新的,首先 handleClick 执行后会执行上文 $$invalidate函数,然后调度更新,更新过程主要由 flush 函数负责:

export function flush() {
	const saved_component = current_component;

	do {
		// first, call beforeUpdate functions
		// and update components
		while (flushidx < dirty_components.length) {
			const component = dirty_components[flushidx];
			flushidx++;
			set_current_component(component);
			update(component.$$);
		}
		set_current_component(null);

		dirty_components.length = 0;
		flushidx = 0;

		while (binding_callbacks.length) binding_callbacks.pop()();

		// then, once components are updated, call
		// afterUpdate functions. This may cause
		// subsequent updates...
		for (let i = 0; i < render_callbacks.length; i += 1) {
			const callback = render_callbacks[i];

			if (!seen_callbacks.has(callback)) {
				// ...so guard against infinite loops
				seen_callbacks.add(callback);

				callback();
			}
		}

		render_callbacks.length = 0;
	} while (dirty_components.length);

	while (flush_callbacks.length) {
		flush_callbacks.pop()();
	}

	update_scheduled = false;
	seen_callbacks.clear();
	set_current_component(saved_component);
}

flush 函数对对脏组件依次进行更新,并执行 beforeAfter/afterUpdate 生命周期函数。单个组件的更新逻辑也非常简单,即直接调用组件的 fragment 的 update 函数来更新 DOM,这里在invalidate 的过程中已经对 ctx 进行了更新,并标记了 dirty 字段,所以在 fragment.p 的过程中就可以完成的 DOM 的更新了。

function update($$) {
	if ($$.fragment !== null) {
		$$.update();
		run_all($$.before_update);
		const dirty = $$.dirty;
		$$.dirty = [-1];
		$$.fragment && $$.fragment.p($$.ctx, dirty);

		$$.after_update.forEach(add_render_callback);
	}
}

总结

到这里,已经对 Svelte 的用法和基本概念都有了了解,同时对原理也有了一定的理解了。回都本文最开始,为什么 Svelte 会被评选为最受喜爱的前端框架呢,我觉得可能有以下原因:

  • 去掉 VDOM 和相关的运行时可能会带来的性能提升
  • 原生简单的 Reactive,比 setState/useState 等简单直接
  • 因为有编译时的代码转换,Svelte 没有繁琐的范式代码,比如 PureComponent、 shoudComponentUpdate、useCallback、useMemo等

但同时可能也有一些缺点:

  • 去掉 VDOM 意味着基于 VDOM 的测试等便利性都没有了
  • Svelte 相关的生态距 React 等还有一段距离。