簡單、好懂的 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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/6nS6jI-1Q0BOkxEQ1HmD_A