簡單、好懂的Svelte實現原理
大家好,我卡頌。
Svelte問世很久了,一直想寫一篇好懂的原理分析文章,拖了這么久終于寫了。
本文會圍繞一張流程圖和兩個Demo講解,正確的食用方式是用電腦打開本文,跟著流程圖、Demo一邊看、一邊敲、一邊學。
讓我么開始吧。
Demo1
Svelte的實現原理如圖:
圖中Component是開發者編寫的組件,內部虛線部分是由Svelte編譯器編譯而成的。圖中的各個箭頭是運行時的工作流程。
首先來看編譯時,考慮如下App組件代碼:
- <h1>{count}</h1>
- <script>
- let count = 0;
- </script>
完整代碼見Demo1 repl[1]
瀏覽器會顯示:
這段代碼經由編譯器編譯后產生如下代碼,包括三部分:
- create_fragment方法
- count的聲明語句
- class App的聲明語句
- // 省略部分代碼…
- function create_fragment(ctx) {
- let h1;
- return {
- c() {
- h1 = element("h1");
- h1.textContent = `${count}`;
- },
- m(target, anchor) {
- insert(target, h1, anchor);
- },
- d(detaching) {
- if (detaching) detach(h1);
- }
- };
- }
- let count = 0;
- class App extends SvelteComponent {
- constructor(options) {
- super();
- init(this, options, null, create_fragment, safe_not_equal, {});
- }
- }
- export default App;
create_fragment
首先來看create_fragment方法,他是編譯器根據App的UI編譯而成,提供該組件與瀏覽器交互的方法,在上述編譯結果中,包含3個方法:
- c,代表create,用于根據模版內容,創建對應DOM Element。例子中創建H1對應DOM Element:
- h1 = element("h1");
- h1.textContent = `${count}`;
- m,代表mount,用于將c創建的DOM Element插入頁面,完成組件首次渲染。例子中會將H1插入頁面:
- insert(target, h1, anchor);
insert方法會調用target.insertBefore:
- function insert(target, node, anchor) {
- target.insertBefore(node, anchor || null);
- }
- d,代表detach,用于將組件對應DOM Element從頁面中移除。例子中會移除H1:
- if (detaching) detach(h1);
detach方法會調用parentNode.removeChild:
- function detach(node) {
- node.parentNode.removeChild(node);
- }
仔細觀察流程圖,會發現App組件編譯的產物沒有圖中fragment內的p方法。
這是因為App沒有「變化狀態」的邏輯,所以相應方法不會出現在編譯產物中。
可以發現,create_fragment返回的c、m方法用于組件首次渲染。那么是誰調用這些方法呢?
SvelteComponent
每個組件對應一個繼承自SvelteComponent的class,實例化時會調用init方法完成組件初始化,create_fragment會在init中調用:
- class App extends SvelteComponent {
- constructor(options) {
- super();
- init(this, options, null, create_fragment, safe_not_equal, {});
- }
- }
總結一下,流程圖中虛線部分在Demo1中的編譯結果為:
- fragment:編譯為create_fragment方法的返回值
- UI:create_fragment返回值中m方法的執行結果
- ctx:代表組件的上下文,由于例子中只包含一個不會改變的狀態count,所以ctx就是count的聲明語句
可以改變狀態的Demo
現在修改Demo,增加update方法,為H1綁定點擊事件,點擊后count改變:
- <h1 on:click="{update}">{count}</h1>
- <script>
- let count = 0;
- function update() {
- count++;
- }
- </script>
完整代碼見Demo2 repl[2]
編譯產物發生變化,ctx的變化如下:
- // 從module頂層的聲明語句
- let count = 0;
- // 變為instance方法
- function instance($$self, $$props, $$invalidate) {
- let count = 0;
- function update() {
- $$invalidate(0, count++, count);
- }
- return [count, update];
- }
count從module頂層的聲明語句變為instance方法內的變量。之所以產生如此變化是因為App可以實例化多個:
- // 模版中定義3個App
- <App/>
- <App/>
- <App/>
- // 當count不可變時,頁面渲染為:<h1>0</h1>
- <h1>0</h1>
- <h1>0</h1>
當count不可變時,所有App可以復用同一個count。但是當count可變時,根據不同App被點擊次數不同,頁面可能渲染為:
- <h1>0</h1>
- <h1>3</h1>
- <h1>1</h1>
所以每個App需要有獨立的上下文保存count,這就是instance方法的意義。推廣來說,Svelte編譯器會追蹤<script>內所有變量聲明:
- 是否包含改變該變量的語句,比如count++
- 是否包含重新賦值的語句,比如count = 1
- 等等情況
一旦發現,就會將該變量提取到instance中,instance執行后的返回值就是組件對應ctx。
同時,如果執行如上操作的語句可以通過模版被引用,則該語句會被$$invalidate包裹。
在Demo2中,update方法滿足:
- 包含改變count的語句 —— count++
- 可以通過模版被引用 —— 作為點擊回調函數
所以編譯后的update內改變count的語句被$$invalidate方法包裹:
- // 源代碼中的update
- function update() {
- count++;
- }
- // 編譯后instance中的update
- function update() {
- $$invalidate(0, count++, count);
- }
從流程圖可知,$$invalidate方法會執行如下操作:
- 更新ctx中保存狀態的值,比如Demo2中count++
- 標記dirty,即標記App UI中所有和count相關的部分將會發生變化
- 調度更新,在microtask中調度本次更新,所有在同一個macrotask中執行的$$invalidate都會在該macrotask執行完成后被統一執行,最終會執行組件fragment中的p方法
p方法是Demo2中新的編譯產物,除了p之外,create_fragment已有的方法也產生相應變化:
- c() {
- h1 = element("h1");
- // count的值變為從ctx中獲取
- t = text(/*count*/ ctx[0]);
- },
- m(target, anchor) {
- insert(target, h1, anchor);
- append(h1, t);
- // 事件綁定
- dispose = listen(h1, "click", /*update*/ ctx[1]);
- },
- p(ctx, [dirty]) {
- // set_data會更新t保存的文本節點
- if (dirty & /*count*/ 1) set_data(t, /*count*/ ctx[0]);
- },
- d(detaching) {
- if (detaching) detach(h1);
- // 事件解綁
- dispose();
- }
p方法會執行$$invalidate中標記為dirty的項對應的更新函數。
在Demo2中,App UI中只引用了狀態count,所以update方法中只有一個if語句,如果UI中引用了多個狀態,則p方法中也會包含多個if語句:
- // UI中引用多個狀態
- <h1 on:click="{count0++}">{count0}</h1>
- <h1 on:click="{count1++}">{count1}</h1>
- <h1 on:click="{count2++}">{count2}</h1>
對應p方法包含多個if語句:
- p(new_ctx, [dirty]) {
- ctx = new_ctx;
- if (dirty & /*count*/ 1) set_data(t0, /*count*/ ctx[0]);
- if (dirty & /*count1*/ 2) set_data(t2, /*count1*/ ctx[1]);
- if (dirty & /*count2*/ 4) set_data(t4, /*count2*/ ctx[2]);
- },
Demo2完整的更新步驟如下:
- 點擊H1觸發回調函數update
- update內調用$$invalidate,更新ctx中的count,標記count為dirty,調度更新
- 執行p方法,進入dirty的項(即count)對應if語句,執行更新對應DOM Element的方法
總結
Svelte的完整工作流程會復雜的多,但是核心實現便是如此。
我們可以直觀的感受到,借由模版語法的約束,經過編譯優化,可以直接建立「狀態與要改變的DOM節點的對應關系」。
在Demo2中,狀態count的變化直接對應p方法中一個if語句,使得Svelte執行「細粒度的更新」時對比使用虛擬DOM的框架更有性能優勢。
上述性能分析中第四行「select row」就是一個「細粒度的更新」。想比較之下,React(倒數第三列)性能就差很多。
參考資料
[1]Demo1 repl:
https://svelte.dev/repl/9945d189204a4168b4c23890f1d92a3a?version=3.19.1[2]Demo2 repl:
https://svelte.dev/repl/bf22a31a0eff4875b5b3084aa2b85fc3?version=3.19.1