半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

Svelte — 是什麼讓我遇見這樣的你

前言

第一次看到 Svelte 時,心中想著「恩...又是一個新的前端框架了嗎?」,抱持著這樣的心情沒有在意太多。直到後來看到越來越多部落格文章介紹以及很多網站都在用之後,開始引起了我的好奇心。

看了官方網站的教學跟文件,才發現這跟一般的「前端框架」做法不太一樣,是從語法層面下手,搭配 compiler 來寫出簡潔又高效的程式碼。

Svelte/_2020-04-19_17.53.03.png

這句話吸引了我:

Svelte compiles your code to tiny, framework-less vanilla JS — your app starts fast and stays fast.

實際寫過一遍之後,雖然習慣 React 的我有些地方還在思考怎樣做會比較好,但整體來說這是一個讓我非常喜歡,而且走的路和其他(React, Vue 等)框架完全不一樣的概念。

我很推薦大家看看 Svelte 作者在 YGLF 的演講 — Rethinking reactivity,重新思考一下到底前端框架的本質是什麼以及我們可以做到哪些事情。以下是我對這場演講的筆記跟反思。

當我們說 Reactivity,我們在談什麼?

The essence of functional reactive programming is to specify the dynamic behavior of a value completely at the time of declaration — Heinrich Apfelmus

當我們在使用前端框架的時候,往往會考慮兩件事情:

  • Virtual DOM — 確保頁面渲染時的效能
  • Reactivity — 追蹤數值的變化

前端框架最核心的部分就是 data flow 跟資料變化的追蹤。

使用 Virtual DOM 的原因

主要的原因在於資料更新把整個畫面重新更新的話對效能會有很大的影響,也因此 Virtual DOM 通常會實作一套有效率的 diff 演算法,來確保每次畫面更新時只會重新渲染有必要的更動。

不過問題來了,要實作一套穩定的 diff 演算法搭配更新機制,其實需要非常大的功夫,還需要兼顧效能考量,才不會讓 tree 的深度過深的時候造成效能瓶頸。

Reactivity

為了確保 React 可以追蹤資料的更新(變化),React 使用了 setState 以及 useState 來追蹤值的變化;而 Vue 則採用了 Proxy 的方式,讓開發者在存取值的時候可以觸發 Reactivity 的機制進而追蹤資料變化。

在 React 當中,我們使用了 useState 或是 this.setState 的方式來確保 React 可以感知到值的變化,還有一套機制來確保沒有多餘的更新(batch updates)。我們看看下面的程式碼:

const Counter = () => {
  const [counter, setCounter] = useState(0);
	const handleClick = () => {
    setCounter(c => c + 1)
  }
	return <div onClick={handleClick}>{counter}</div>
}

每次 Component 更新的時候都會再重新跑一次 useState 跟重新 evalute handleClick 這個函式,React 內部會保存並追蹤這些 state 變化並作出適當的更新。

為了解決這個問題又有了 useMemo 還有各種優化手段:

  • shouldComponentUpdate
  • React.PureComponent
  • useMemo
  • useCallback

這些機制的實作讓我們花了非常大的力氣,只是為了當畫面更新時不要全部重新 render,還有為了確保 Virtual DOM 的效能而提供給開發者優化的選項。這兩者結合造成 react, react-dom 有非常大的 bundle size。

甚至作者本人還在官方部落格發布一篇 Virtual DOM is pure overhead ,來說明 Virtual DOM 帶來的取捨有哪些。摘錄文章結尾:

It's important to understand that virtual DOMisn't a feature. It's a means to an end, the end being declarative, state-driven UI development. Virtual DOM is valuable because it allows you to build apps without thinking about state transitions, with performance that isgenerally good enough. That means less buggy code, and more time spent on creative tasks instead of tedious ones.

But it turns out that we can achieve a similar programming model without using virtual DOM — and that's where Svelte comes in.

另外文章中有個 Twitter 的推文是讓我覺得蠻有趣的觀點:

整個 Virtual DOM 的機制是因為 framework 想要讓你不用擔心狀態的管理,但是為什麼這樣的機制不是由 Platform 來提供?我覺得這個觀點蠻有趣的,但不知道實際做起來需要多大的工夫,而且如果由瀏覽器來實作...,恐怕又要遇到跨瀏覽器的問題跟規範,進化的速度可能遠遠不及框架本身。

Svelte/_2020-04-19_19.52.12.png

雖然也不是真的那麼大,但再加上產品本身的程式碼,這個 bundle size 還是有點驚人。

總結一下以上的想法,現代的前端框架缺點在於:

  • runtime 的 reactivity + Virtual DOM diff 機制
  • 肥大的 bundle size

或許會有人覺得:何必什麼事都要計較 bundle size 跟 performance?

Rethink Performance

我自己以前也常常這樣想,大家隨便 CPU 就是 i5 i7 起跳,手機掏出來就是 iPhone 11 Pro 跟旗艦機,網路隨便吃到飽,真的有必要在意這些嗎?

最近我有新的想法,來自於我在日本工作、生活後的感受:

  • 不是所有地方都像台灣一樣可以用很便宜的價格網路吃到飽,在日本通常沒有網路吃到飽的選項,網路有限之外還貴得要命。 這是 LINE Mobile 的手機方案,雖然一般的 SNS 不算流量,但身為 YouTube 跟 Netflix 愛好者還是不怎麼夠用。

LINE Mobile

  • 很多使用者會使用格安(方案比較便宜的業者),基地台跟速度都不是那麼快跟穩定
  • 在地下鐵的時候網路明顯變慢,不像台灣那麼穩定。

這幾點加起來,如果是在搭地鐵的時候開啟網頁,或是用那種格安的爛網路,真的會感受到速度的差距。我也深深感受到,並不是每個國家都理所當然地跟台灣一樣有那麼幸福的網路速度。

還有就是我用樹莓派的時候,也可以感受到比較低階的 CPU 在跑網頁時會有明顯的卡頓。

在 IoT 逐漸崛起的時代,並不是每個人都會用淫威的 CPU 跟 GPU 跑網頁的。這也是讓我逐漸思考效能以及框架選擇的原因。難道 Svelte 就沒有這個問題嗎?我們繼續看下去。

作者在 19:15 處用之前 Dan 的 React time slicing 的例子,並且用 Svelte 實作了一遍,發現 Svelte 完全不用開啟 Async mode 或是 debounced,效能完完全全超越了 React 一大截。

擁抱原生 API

在看教學文件的時候,看到他們的事件傳遞機制,後面的實作完完全全就是 addEventListenerCustomEvent 而已,沒有什麼 Synthetic Event(React),什麼 Event pool,就是 CustomEvent。

// https://github.com/sveltejs/svelte/blob/master/src/runtime/internal/dom.ts#L60-L63
export function listen(node: Node, event: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | EventListenerOptions) {
	node.addEventListener(event, handler, options);
	return () => node.removeEventListener(event, handler, options);
}

// https://github.com/sveltejs/svelte/blob/master/src/runtime/internal/dom.ts#L275-L279
export function custom_event<T=any>(type: string, detail?: T) {
	const e: CustomEvent<T> = document.createEvent('CustomEvent');
	e.initCustomEvent(type, false, false, detail);
	return e;
}

雖然也因為如此,或許之後各種瀏覽器的差異造成的 bug,或是框架裡頭可以做到的優化,可能沒辦法很好地在 Svelte 解決,而是轉交給開發者身上,像是 Event DelegationReact Event Pool,是好是壞就根據使用場景而定了。

無法抗拒的語法

我是個很喜歡用語法以及語言層面解決問題的人,這些方式讓開發者不用再花那麼多時間去了解 useEffect, useMemo, useCallback 後面是怎麼運作以及為什麼要用。

Svelte 提供了一套簡單的語法(大部分也都還是 JavaScript)跟 teamplate syntax,透過 compiler 編譯之後會變成一個比較 low level 的 JavaScript 程式碼。例如:

<script>
  import { createEventDispatcher } from 'svelte';
  export let label;
	let a = 1;
	
	$: b = a * 2
  const dispatch = createEventDispatcher();
  function handleClick() {
    dispatch('toggle', {
      value
    });
  }
</script>

<button class="button" class:active on:click={handleClick}>{label}</button>

這個 Svelte 的程式碼,編譯出來後會長成像這樣子:

import { createEventDispatcher } from "svelte";

function create_fragment(ctx) {
	let button;
	let t;
	let dispose;

	return {
		c() {
			button = element("button");
			t = text(/*label*/ ctx[0]);
			attr(button, "class", "button");
		},
		m(target, anchor, remount) {
			insert(target, button, anchor);
			append(button, t);
			if (remount) dispose();
			dispose = listen(button, "click", /*handleClick*/ ctx[1]);
		},
		p(ctx, [dirty]) {
			if (dirty & /*label*/ 1) set_data(t, /*label*/ ctx[0]);
		},
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(button);
			dispose();
		}
	};
}

let a = 1;

function instance($$self, $$props, $$invalidate) {
	let { label } = $$props;
	const dispatch = createEventDispatcher();

	function handleClick() {
		dispatch("toggle", { value });
	}

	$$self.$set = $$props => {
		if ("label" in $$props) $$invalidate(0, label = $$props.label);
	};

	let b;
	$: b = a * 2;
	return [label, handleClick];
}

class App extends SvelteComponent {...}

注意一下這邊的 element 跟 text function,其實只是 document.createElementelement.textContent 的包裝而已,沒有什麼 Virtual DOM 的概念。

那麼 Svelte 是怎麼達到 reactivity 機制的?

Compiler 可以做到什麼事?

let a = '' 宣告的時候,實際上 compiler 可以知道 a 被宣告,所以當你做類似 a = a + 1 的時候,compiler 會在後面幫你改成類似 $$invalidate('a', a + 1) 的方式,通知 Svelte 這個值有變化,這時 Svelte 就會去標記這個變數為 dirty,並且 schedule update,更新畫面上有用到 a 的部分。

如果 a 沒有實際在畫面上(tag 裡頭)使用到,Svelte 甚至不會特別把他 compile 出來,例如:

<script>
	let name = 'world';
	
	function handleClick() {
		name += '?'
	}
</script>

<h1 on:click={handleClick}>Hello </h1>

function create_fragment(ctx) {
  //
}

function instance($$self) {
	let name = "world";

	function handleClick() {
		name += "?"; // << 注意這行,因為在 tag 當中沒有用到,所以沒有用 $$invalidate 取代
	}

	return [handleClick];
}
// ...

如果在 tag 當中有用到:

<script>
	let name = 'world';
	
	function handleClick() {
		name += '?'
	}
</script>
// 原本的 world 用變數 name 取代
<h1 on:click={handleClick}>Hello {name}</h1>

function create_fragment(ctx) {
	// ...
}

function instance($$self, $$props, $$invalidate) {
	let name = "world";

	function handleClick() {
		$$invalidate(0, name += "?"); // << 用 $$invalidate 取代
	}

	return [name, handleClick];
}

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

export default App;

這樣子一來就不用做不必要的更新,也是引入 compile 階段的好處之一。在演講當中有提到:

Compilers are the new frameworks

這篇文章中提到,透過 compiler 的威力,我們除了可以有效減少那些 run time 執行的程式碼,甚至可以不用被語言本身給綁定,給 compiler 更多的威力去做事情,就像靜態語言那樣。

另外像是 template 的語法也是恰到好處,舉例來說:

<ul>
  {#each Object.values(list) as item}
    <li>{item.name}</li>
  {/each}
  {#if condition}
    <p>true!</p>
  {/if}
	<button on:click={handleClick}>click me</button>
</ul>

詳細可以參考官方文件,在這裡我想表達的是這些語法非常簡單,任何一個有經驗的開發者可以在很短的時間內就學好這一整套語法。因為是 compile 的關係還可以順便檢查語法跟一些靜態檢查。

一試成主顧

最近我寫了一個網頁,整理了動物之森的魚類資料方便查詢,其中的功能包含:

  • 搜尋(純前端)
  • 表格排序
  • 按照 config 渲染表格

就這樣而已。

(搜尋的行為有點奇怪,有空再修 XD)

這是一個用 pure JavaScript 寫可能要花點時間,但用 React 寫又有點殺雞用牛刀的小專案所以就毅然決然使用 Svelte。結果非常令我滿意,bundle 後的 JavaScript 全部只有 8.2KB(gzipped 後),3G 網路只要 160ms 就可以載入。

Is it a magic?

Rethinking reactivity 第 17:11 處作者也有提到這個觀點,Evan(Vue 的作者)說這根本就不是 JavaScript 而是 SvelteScript。

其實框架本身或多或少就是在變魔術(把複雜的細節抽象化),React 把複雜的更新機制變成魔術、Vue 把 template 語法包裝成魔術,大家都在變魔術,只是 React, Vue 是在 runtime 變,Svelte 把工作交給 Compiler。

完善的 store 與 context 機制

Svelte 裡頭本身內建了 store 可以使用,簡單的用法像是這個樣子。因為是內建的,所以也不用到處戰 redux-xxx

Interaction

Svelte 還有更多讓我驚奇的語法跟實作,像是 built-in 的 transition 跟 spring / fly / flip 機制,還搭配了對應的 directive 給開發者使用,想要加入互動(微動畫)的時候,就不用再苦惱要用什麼 library,直接從 Svelte 裡面引用就好,實際寫起來像是這個樣子:

// copied from https://svelte.dev/examples#tweened
<script>
	import { tweened } from 'svelte/motion';
	import { cubicOut } from 'svelte/easing';

	const progress = tweened(0, {
		duration: 400,
		easing: cubicOut
	});
</script>

<style>
	progress {
		display: block;
		width: 100%;
	}
</style>

<progress value={$progress}></progress>

寫起來相當的直覺,Svelte 內部的機制讓開發者可以對 DOM 本身有更多的操作。

再來就是做動畫最煩人的中斷機制 ,假設使用者在動畫途中做操作的時候,如果沒有特別做處理,通常會等到 transition 結束後再重來一遍,對使用者的體驗有很大影響。

Svelte 本身的 transition 就已經是可以中斷的,詳細可以看一下 transition 這個章節,在點擊 checkbox 時會有 fadein - fadeout 動畫,就算你按得再快,他還是會從當前的進度開始做 transition。

注意到這邊你是可以直接用 {#if visible} 的方式來做 transition,而不是像 React 那樣一定要保持 component render 才有辦法保持動畫。

Note that the transition is reversible — if you toggle the checkbox while the transition is ongoing, it transitions from the current point, rather than the beginning or the end.

這些東西如果用 React 來改寫,可能需要像是 react-transition-group 或是 react-spring 之類的函式庫來輔助才會比較方便,Svelte 直接做在函式庫裡頭,不需要再另外裝套件,甚至跟語法結合,寫起來更直覺。

總結

講了那麼多壞話,我並不是想批評任何框架或是把 Svelte 捧上天,React 毫無疑問地改寫了前端、網頁發展的歷史,我也真的很喜歡 react hooks 的概念。只是 Svelte 的概念跟成果太迷人,讓我想要繼續深入研究他。

另外,對於 bundle size 跟效能的疑慮,我覺得總有一天硬體設備跟網路速度會逐漸趕上,到時候差別就沒有那麼明顯,用像是 code-splitting 跟 dynamic import 的手段也可以很有效地減少初始的 bundle size。

其實搭配 Babel 這個威猛的轉譯器,我們可以用 JavaScript 加上 Babel 的補助,盡可能減少在 runtime 要做的繁雜事項,而像是 WebAssembly 跟 ArrayBuffer 等 low-level API 的進步,網頁的發展或許又要迎來新的局面了。在這個年代,恐怕除了前端,還得學會怎麼自製簡單的 Compiler 呢。