前端的編譯器,除了可以做轉換,是否還有其它用途?
由於現在國內“前端主流框架”的稱號基本被 React、Vue、Angular 佔領,前二者在渲染層的技術,本質上都是極為相似的:在記憶體中維護 Virtual DOM,透過某些方式獲取到變數被修改,然後利用 DOM Diff 演算法(以及一些最佳化)計算出需要對 DOM 做的操作,再 Patch 到真實的 DOM 上。相信大家(尤其是準備過面試的小夥伴)一定對這些內容比較瞭解。
Angular 的最新版使用了一個叫 Ivy 的引擎。對於 Angular 來說,髒值檢測跟之前的方法類似(監聽使用者輸入、劫持 setTimeout 等),渲染方面則是用了 incremental-dom 的思想做增量渲染。
我第一次聽說 Svelte(讀作 [svɛlt]),也只是在兩個月前。我剛看到 Svelte 3 文章時,就被“編譯型框架”的字樣吸引住了。據說它的編譯器幫忙做了很多工作,可以不用髒值檢測、setState、Proxy 等方式來觸發檢視更新,也沒有了 DOM Diff 的代價。我決定好好研究一番。

Svelte 3 的特點
在翻閱 Svelte 的部落格時,很容易翻到這篇文章:Virtual DOM is pure overhead。通讀下來,作者的觀點是,Virtual DOM 可以做到狀態驅動 UI,效能也不差,並且程式碼的 Bug 更少;但 Svelte 不用 Virtual DOM 也可以做到類似的效果,並且效能更高。
模板語法、模板資料分離
本文的重點不是詳細講述 Svelte 3 的語法(照著文件來就可以了),這裡只舉一些最常用也最簡單的例子,一個元件基本長這樣,跟 Vue 的元件長得差不多,但有點區別。我列舉了變數、雙向繫結、事件處理、Watch、常見的控制語句、生命週期這幾個特性:
<!-- App.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
// 1:變數、雙向繫結、事件處理
let name = 'world';
const onClick = () => name = 'Rex';
// 2:`if` 塊、Watch
let x = 0;
const incX = () => x++;
// 這就像 Vue 中的 `watch` 功能
// 語法很奇怪,但是是合法的
$: doubleX = x * 2;
// 3:`each` 塊
let characters = [
{ name: 'Aroma White' },
{ name: 'Neko Asakura' },
{ name: 'ROBO Head' }
];
const append = (name: string) => {
if (!characters.find(t => t.name === name)) {
characters = [...characters, { name }];
}
};
// 生命週期
onMount(async () => {
const res = await fetch(`/photos`);
const photos = await res.json();
console.log(photos);
});
</script>
<main>
<section class="my-section-1">
<h1>Hello {name}!</h1>
<input bind:value={name}>
<button on:click={onClick}>I'm Rex</button>
</section>
<section class="my-section-2">
{#if x > 10}
<p>{x} is greater than 10</p>
{:else if x < 5}
<p>{x} is less than 5</p>
{:else}
<p>{x} is between 5 and 10</p>
{/if}
<p>Double x is {doubleX}</p>
<button on:click={incX}>x++</button>
</section>
<section class="my-section-3">
<button on:click={() => append('Simon Jackson')}>
Add Simon
</button>
<ul>
{#each characters as c, i}
<li>{i + 1}: {c.name}</li>
{/each}
</ul>
</section>
</main>
<style>
main {
font-size: 18px;
}
</style>單看 <main> 的部分,是不是感覺找回了上古時期寫 PHP 和 Django 模板的感覺?雖然語法比較復古,但其實現代前端支援的東西,它基本都能做:
- 元件化:每個
.svelte檔案就是一個元件,並且也完整支援 Props、Context、Slot; - 完整的生命週期:
onMount、onDestroy、beforeUpdate、afterUpdate。
利用編譯器實現響應式
那麼它是怎麼做的呢?記得我之前年少輕狂寫 rsjs 的時候(那時候的主流還是 Angular 1),由於水平太菜,遲遲不知道如何做到 Angular 的髒值檢測,於是只好退而求其次,搞了個 setData 函式,並且每次資料更新都是完整渲染,可以說是效率很低了。這其實跟 React 更貼近一些……
其實 Svelte 以前也是這樣做的,但 Svelte 3: Rethinking reactivity 這篇文章的意思是,由於 Svelte 3 在編譯期間就可以知道每個變數在模板的什麼地方被用到,因此只要變數有被模板用到,編譯器就會在該變數的所有“直接賦值”的語句外面包一個 $$invalidate 函式,例如這樣:
// 原始程式碼:
count += 1;
// 會被編譯成這樣的形式:
$$invalidate('count', count += 1);至於 Array.prototype.push 之類的函式,以及修改引用值,Svelte 沒有像 Vue 那樣劫持原生函式,也沒有在編譯器中做引用判斷,因此需要開發者變通一下,轉換成對變數直接賦值的語句。鑑於 React 也是這樣的要求(每次 setState 都需要設定一個新物件),因此這一點我覺得是可以接受的。
區域性更新和直接操作 DOM
由於 Svelte 3 使用的是類似上古時期的模板語法,沒有 TSX Render Function 這麼靈活的功能,因此可以更好地被用作靜態分析。
我我把剛剛的程式碼編譯了一下(詳見彩蛋),在未壓縮的模式下,很容易找出每個函式和變數在原始碼中的位置。大概看了一眼,能得出這樣的結論:
- 資料會被統一打包到一個數組中方便管理,類似 React Hooks 的線性儲存方式;
- 模板被轉換成渲染函式,每個 if 和 each 塊也被轉換成渲染函式,方便區域性更新;
- 對於每個渲染函式,有 Create、Make Tree、Patch、Detach 四個操作;
- 沒有 Virtual DOM,而是為每個塊定製 Create 和 Make Tree 操作,直接修改 DOM;
- Vue 透過 Proxy 來實現的監聽,在 Svelte 3 中成了為每個塊定製的 Patch 操作;
- 在解除安裝的時候,會先解除安裝元件,再執行清理函式(取消監聽器之類的)。
其它
對 Svelte 3 的更具體的分析,由於網上已經有人寫了(詳見文末的參考資料),這裡就不多提了。
我從中得到的啟示
1. Virtual DOM 並不是唯一出路
由於我和我的前端朋友們幾乎全都是寫 React 和 Vue 的,因此我們很自然地認為 Virtual DOM、TSX 等方式已經足夠了。但是 Svelte 3 跟快被我們忽略的 Angular,堅持採用模板的方式,沒有擁抱 Virtual DOM,並且也取得了不錯的成果。這說明 Virtual DOM 並不是唯一出路。
個人覺得,Angular 現在之所以沒有成為主流,是因為它是一整套服務,上手難度高,而非因為沒有擁抱 Virtual DOM。
2. 無論如何編碼,總會有 Tradeoff
我們應該聽到過很多“最佳實踐”,或者是“錯誤的寫法”,例如:
- 在 React 中,不管是
setState還是 Reducer,每次必須返回一個新的物件; - 在 React Hooks 中,一個 Hooks 必須只能寫在函式式元件裡或另一個 Hooks 裡;
- 在 Vue 的
data中必須要宣告所有用到的欄位,否則必須要用$set來更新; - 在 Vue 的模板中,不能直接使用模組內變數或全域性變數,需要透過
data傳過去; - 在 immerjs 中,不能認為
a=b之後修改a物件就是修改b,它倆不是同一個東西。
這些都是為了實現庫或框架的功能而必須的 Tradeoff,因為過度自由的編碼,會導致你的程式碼無法透過統一的演算法來檢測和最佳化,也無法讓你的程式碼更加工程化。舉個例子,我在寫面單編輯器的時候針對業務資料做了一些限制,否則不可能一個人在一週的時間內快速完成;TypeScript 的程式碼可達性檢測也不能檢測停機問題。
Svelte 3 針對編譯器的功能,限制了使用者使用 Render Function 以及 Virtual DOM,以及不能對需要響應變化的變數用 push 這些可以原地操作資料的函式(物件的深層次賦值是可以的),雖然會失去一定的靈活性,但可以讓編譯器幫你做更多的事情。

3. 編譯器能做的事情,比我認為的要多
其實這一點,搞二進位制開發、逆向的大佬們感觸應該會更深。例如:
- C 語言程式開始執行後並不會先進入
main函式,而是一個叫做_start的函式; - 上古時期
x * 9會被開發者寫成x << 3 + x,但現在大部分最佳化都被編譯器做了(而且絕對比你手工最佳化做的好); - 事實上,C 編譯的過程就是把支援了很多特性的高階語言,轉換成只支援基本指令的機器語言,原始碼和編譯產物往往很不相似(這也是為什麼 C 逆向比 JS 逆向難得多)。
前端編譯器最開始的作用,無非是轉換一些語法、新增一些 Runtime;後來逐漸出現了各種 Webpack Loader 和 Rollup 外掛,支援了更多的檔案格式(.vue 檔案轉換前後整個結構就變了),這也在某種程度上反映出前端在複雜度和工程化程度上的提升。這些東西也成了一些公司面試的必考點,用來區分普通程式設計師和高階工程師。
這次 Svelte 3 的編譯器,在保留了“響應式前端”概念的基礎之上,進一步讓程式碼得到了簡化,並且也極大減少了執行時程式碼的執行,為世界的碳排放做出了一定的貢獻。(咦?)
我有一個小預測:隨著前端複雜度和工程化程度的進一步提升,未來的編譯器一定可以幫我們做更多的事情,現有的框架可能也會將一部分在執行時的功能使用編譯器輔助實現。由於編譯器的功能越來越多,面試的要點會逐漸從框架的原理,變成編譯器的原理。
4. 最佳化除了提高演算法效率,還有預處理
在以前當賽棍的時候,經常會提到兩個概念:時間複雜度、空間複雜度。利用空間換時間的技術一般被叫做“預處理”,即提前計算好一些東西,用的時候直接呼叫。
谷歌出的一個程式碼壓縮器 GCC(Google Closure Compiler),可以幫忙做一些 Inline 的操作,以及提前計算,避免在執行的過程中重複取值。不過一些最佳化過於激進,可能會導致程式碼無法執行,加上後來出現的 Terser 等工具做的確實不錯,因此 GCC 沒有被廣泛使用。
而 Svelte 3 的做法,則是利用編譯器直接計算出每個值改變時需要做什麼,相當於直接把髒值檢測跟 DOM Diff 計算後的執行流程寫死到了元件的程式碼裡,免去了在執行時計算的過程。雖然程式碼會比較長,但由於重複操作多,資訊量小,因此壓縮後並不會佔用太多的空間。這是個既省空間又省時間的做法。
P.S. 三年前我面試 Shopee 的時候,老闆問了我一個主觀問題(之前的文章可以看 這裡):
如果單從修改 DOM 結構來看,React + DOM Diff 演算法與純 jQuery 直接操作(假設已經知道該如何最快的 Patch)相比,哪個效率比較高?
我的答案是:當結構不復雜的時候,DOM Diff 的耗時佔比重是不能被忽略的,此時當然是 jQuery 快;但是當結構複雜了之後,DOM Diff 的耗時佔比重就比較低了,更多的時間被消耗在 DOM 的操作上——React 是直接 Patch 到 DOM 上,但 jQuery 是在原生 DOM 上面封裝了一層,所以會再慢一些,這個的時間是要多於 DOM Diff 的。
當時我並不知道標準答案是什麼,現在看前端的趨勢也沒必要再考慮 jQuery 了。但如果再加上 Svelte 3,問題就有意思起來了。Svelte 由於把預處理的時間放到了編譯期間,在執行時也只對 DOM 操作做了最簡單的封裝,因此既沒有 React 的 DOM Diff 時間,又比 jQuery 要快。
究竟封裝有多簡單呢?可以看一下 Svelte 3 的工具函式,基本相當於沒有封裝啊……估計是為了以後新增更多的鉤子吧:
function append(target, node) {
target.appendChild(node);
}
function insert(target, node, anchor) {
target.insertBefore(node, anchor || null);
}
function detach(node) {
node.parentNode.removeChild(node);
}
function element(name) {
return document.createElement(name);
}
function text(data) {
return document.createTextNode(data);
}
function space() {
return text(' ');
}
function listen(node, event, handler, options) {
node.addEventListener(event, handler, options);
return () => node.removeEventListener(event, handler, options);
}
function attr(node, attribute, value) {
if (value == null)
node.removeAttribute(attribute);
else if (node.getAttribute(attribute) !== value)
node.setAttribute(attribute, value);
}
function children(element) {
return Array.from(element.childNodes);
}
function set_input_value(input, value) {
input.value = value == null ? '' : value;
}彩蛋
到底該信哪一份資料
我在統計框架使用量的時候,一開始認為開發者提的問題越多,代表這個框架越火。根據 StackOverflow 在 2020 年對接近 65000 個開發者的調查(傳送門),可以發現最主流的幾個框架依次是 Angular/Angular.js、React、Vue。這跟我的認知不符,因為在國內我幾乎沒有遇到多少 Angular 的開發者,反而因為 Vue 的上手難度低,導致中小型公司更喜歡用 Vue。

我在想,可能是因為倖存者偏差,可以再看看 NPM 上面的下載量。於是我用 npm-stat 統計了一下三大框架在 2020 年的下載量,結果是這樣的:

可以看到 React 基本佔了主流,跟 Angular 簡直是數量級的差距。
但是 NPM 的下載量就一定能說明結果嗎?是否有可能因為 Angular 的使用者由於構建最佳化的更好,避免了每次流水線都從 NPM 下載?我不瞭解,也不知道從何瞭解。
編譯結果究竟長啥樣
答案:長這樣,長相併不好看。(攤手)
我已經儘可能添加註釋幫助理解了,如果不願意看程式碼,把註釋當文章來讀也是可以的。
// 一堆通用的工具函式,用來建立 DOM、設定資料等
import { ... } from "svelte/internal";
import { onMount } from "svelte";
// 一個取決於元件資料的工具函式
// 用於找到 `each` 塊中的 Context
function get_each_context(ctx, list, i) {
const child_ctx = ctx.slice();
child_ctx[9] = list[i];
child_ctx[11] = i;
return child_ctx;
}
// 第 48 行的 else 塊,主要包含了四個函式
function create_else_block(ctx) {
// 這些是每一個 DOM 元素
let p;
let t0;
let t1;
return {
// c:建立(Create)
// 呼叫工具函式,依次建立每一個 DOM 元素
c() {
p = element("p");
// 這裡 ctx[0] 就對應了 x,編譯器的註釋標的恰到好處
t0 = text(/*x*/ ctx[0]);
t1 = text(" is between 5 and 10");
},
// m:建樹(Make Tree)
// 透過建立好的 DOM 元素,將 DOM 樹構建好
m(target, anchor) {
insert(target, p, anchor);
append_1(p, t0);
append_1(p, t1);
},
// p:更新(Patch)
// 在這個 else 塊中,只依賴了一個 x,因此只有一個 if 語句
// 若元件被標記為 dirty,且 x 被改
// 則呼叫工具函式 set_data 直接更新 DOM 元素中的 innerText
// Svelte 將“某變數被修改”的標記用位運算做了壓縮,詳見參考資料
p(ctx, dirty) {
if (dirty & /*x*/ 1) set_data(t0, /*x*/ ctx[0]);
},
// d:解除安裝(Detach)
// 當元件被解除安裝時需要做的事情,一般是解除安裝元素
// 如果有事件監聽的話也會取消監聽
d(detaching) {
if (detaching) detach(p);
}
};
}
// 接下來的兩個函式分別是:
// 第 46 行的 else 塊
// 第 44 行的 if 塊
// 裡面長得跟剛才基本一樣,不多提了
function create_if_block_1(ctx) { ... }
function create_if_block(ctx) { ... }
// 第 59 行的 each 塊
// 稍微有一點區別,因為有 key
// 並且模板渲染用到的資料不是 JS 的基本型別
function create_each_block(ctx) {
let li;
// 因為在模板中我們寫了 i + 1
let t0_value = /*i*/ ctx[11] + 1 + "";
let t0;
let t1;
// 因為在模板中我們寫了 c.name
let t2_value = /*c*/ ctx[9].name + "";
let t2;
return {
c() {
li = element("li");
// 這裡直接呼叫 i + 1 的值
t0 = text(t0_value);
t1 = text(": ");
// 這裡直接呼叫了 c.name 的值
t2 = text(t2_value);
},
m(target, anchor) { ... },
p(ctx, dirty) {
// 這裡除了判斷元件是 dirty 和 characters 被修改以外
// 還多判斷了一步 characters[i].name 是否被修改
if (
dirty & /*characters*/ 4 &&
t2_value !== (t2_value = /*c*/ ctx[9].name + "")
) {
set_data(t2, t2_value);
}
},
d(detaching) { ... }
};
}
// 整個元件的建立函式
function create_fragment(ctx) {
// 依舊是各種 DOM 元素
// ...為了簡潔,我只保留了 main,省略其它所有變數
let main;
// 這個用來判斷元件是否已經掛載結束
let mounted;
// 這個用來收集在解除安裝時需要做的事情,是一個數組
// Svelte 的監聽器跟 Angular、React.useEffect 類似
// 都是返回一個解除安裝函式
let dispose;
// 第 44 行的 if 塊,在這裡被轉換成了真正的 JS
// 這裡判斷該使用哪個渲染函式來渲染 if 塊
function select_block_type(ctx, dirty) {
if (/*x*/ ctx[0] > 10) return create_if_block;
if (/*x*/ ctx[0] < 5) return create_if_block_1;
return create_else_block;
}
// 第 44 行的 if 塊,需要用這個渲染函式來渲染
let current_block_type = select_block_type(ctx, -1);
let if_block = current_block_type(ctx);
// 第 59 行的 each 塊,需要轉換為若干個渲染函式
// 每個函式都只負責渲染其中的一次迭代
let each_value = /*characters*/ ctx[2];
let each_blocks = [];
for (let i = 0; i < each_value.length; i += 1) {
each_blocks[i] = create_each_block(
get_each_context(ctx, each_value, i)
);
}
// 跟剛才類似的幾個函式,只不過複雜了很多
return {
// 這裡的 c 裡面除了建立基本的元素以外
// 還要呼叫剛剛 if、each 相關的 c 函式
c() {
main = element("main"); // ...省略其它
// 這裡呼叫了 if 塊的 c 函式
if_block.c();
t7 = space(); // ...省略其它
// 這裡呼叫了 each 陣列中每個元素的 c 函式
for (let i = 0; i < each_blocks.length; i += 1) {
each_blocks[i].c();
}
// 為剛建立好的 DOM 元素設定屬性
attr(section0, "class", "my-section-1");
attr(section1, "class", "my-section-2");
attr(section2, "class", "my-section-3");
// 元件自身的 Scoped CSS 在編譯階段即完成
attr(main, "class", "svelte-5if2as");
},
// 跟之前類似,利用建立好的 DOM 元素,構建 DOM 樹結構
// 這裡也會呼叫每個 if、each 塊中的 m 函式
m(target, anchor) {
insert(target, main, anchor); // ...省略其它
// 模板中的 input 有個雙向繫結
set_input_value(input, /*name*/ ctx[1]);
append_1(section0, t4); // ...省略其它
for (let i = 0; i < each_blocks.length; i += 1) {
each_blocks[i].m(ul, null);
}
// 執行到這裡,其實已經掛載完成了
// 收集一下解除安裝時需要執行的函式,然後標記為 mounted
if (!mounted) {
dispose = [
listen(input, "input", /*input_input_handler*/ ctx[7]),
listen(button0, "click", /*onClick*/ ctx[4]),
listen(button1, "click", /*incX*/ ctx[5]),
listen(button2, "click", /*click_handler*/ ctx[8])
];
mounted = true;
}
},
// 需要 Patch 的變數變得多了起來
// 可以看出來,這是依據元件內容生成的程式碼
// 隨著元件中用到的變數增多,這個函式會變得越來越大
p(ctx, [dirty]) {
if (dirty & /*name*/ 2) set_data(t1, /*name*/ ctx[1]);
// 這裡是雙向繫結
if (dirty & /*name*/ 2 && input.value !== /*name*/ ctx[1]) {
set_input_value(input, /*name*/ ctx[1]);
}
// ...省略其它
},
i: noop,
o: noop,
// 在解除安裝的時候:
// 先解除安裝元件
// 再解除安裝所有塊
// 最後執行之前收集的清理函式
d(detaching) {
if (detaching) detach(main);
if_block.d();
destroy_each(each_blocks, detaching);
mounted = false;
run_all(dispose);
}
};
}
// 這一段是原始碼中的 <script> 部分
// 編譯器很貼心,把我在原始碼中寫的註釋都給保留了
function instance($$self, $$props, $$invalidate) {
let doubleX;
let name = "world";
const onClick = () => $$invalidate(1, name = "Rex");
// 2:`if` 塊、Watch
let x = 0;
const incX = () => $$invalidate(0, x++, x);
// 3:`each` 塊
let characters = [
{ name: "Aroma White" },
{ name: "Neko Asakura" },
{ name: "ROBO Head" }
];
const append = name => {
if (!characters.find(t => t.name === name)) {
$$invalidate(2, characters = [...characters, { name }]);
}
};
// 生命週期
onMount(async () => {
const res = await fetch(`/photos`);
const photos = await res.json();
console.log(photos);
});
function input_input_handler() {
name = this.value;
$$invalidate(1, name);
}
const click_handler = () => append("Simon Jackson");
// 補充:
// 在這裡 doubleX 被直接套在了一個叫 update 的函式中
// 並且也用跟 p 函式類似的方式來判斷是否需要更新(如果 x 被改變)
// 如果有其它 Watch 的變數,也會被放在這裡
$$self.$$.update = () => {
if ($$self.$$.dirty & /*x*/ 1) {
// 這就像 Vue 中的 `watch` 功能
// 語法很奇怪,但是是合法的
$: $$invalidate(3, doubleX = x * 2);
}
};
return [x, name, characters, doubleX, onClick, incX, append, input_input_handler, click_handler];
}
// 很簡單地對外暴露一個例項
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, {});
}
}
export default App;參考資料
- 【譯】Angular Ivy的變更檢測執行:你準備好了嗎? | tc9011's
- 都2020年了你還不知道Svelte(2)—— 更新渲染原理 | 碼農家園
- Advanced Compilation | Closure Compiler | Google Developers
Update 2021-01-18
經大佬提示,有個叫 State of JS 的網站會對開發者做調查,併發布一份報告。在連結中,可以看到目前用途最廣泛的是 React,然後 Angular 比 Vue 略高一籌。此外,Svelte 無論是滿意度、使用量還是價值,都在穩步提升,公眾對它也始終保持很高的興趣。
Update 2021-01-21
從另一個大佬那裡得知了 Vue 有兩個新提案:New script setup and ref sugar,第一個是為了直接用最新的 setup 函式替代之前的元件建立流程,第二個是跟 Svelte 類似,利用 JS Label 的語法來做響應式的語法糖。
它倆分別是這樣用的:
<script setup>
// imported components are also directly usable in template
import Foo from './Foo.vue'
import { ref } from 'vue'
// write Composition API code just like in a normal setup()
// but no need to manually return everything
const count = ref(0)
const inc = () => { count.value++ }
</script>
<template>
<Foo :count="count" @click="inc" />
</template><script setup>
// declaring a variable that compiles to a ref
ref: count = 1
function inc() {
// the variable can be used like a plain value
count++
}
// access the raw ref object by prefixing with $
console.log($count.value)
</script>
<template>
<button @click="inc">{{ count }}</button>
</template>跟大佬聊了一下,我的觀點主要是這樣的:
- 我對這提案的看法,和對 Svelte 的看法類似,甚至我都覺得 Evan You 也發現了編譯器可能是個未來的最佳化點。
- 至於它破壞了 JS 語義的問題,我覺得與其讓 Label 這個語法被忘記,不如賦予它新的意義,因為目前寫 JS 已經基本離不開編譯器了(例如 TypeScript、JSX、Vue Loader)。
- 此外,語言本身就是在變化中的,JS 正是因為以前的語法不好,才有了那麼多提案和 Linter。
- 在其它行業,Lisp 有 Clojure、Common Lisp、Scheme 這樣一堆方言,Java“語系”中的 Groovy、Scala、Kotlin 也差不多能算是方言,我覺得 JS 演變出 React、Vue、Angular、Svelte 幾種方言是完全合理的。
之後,大佬拿出了另外一份 JS 的提案:Reference (ref) declarations and expressions,這樣確實不會 Break the Web 了,不過看起來有點像 C 中的 int const a = 1 或者 Rust 中的 let mut x = 5,這在我看來也不是壞事。
當然,語言不可能無限新增 Feature,否則容易長成這樣:cRAzY eSnEXt (*all* proposals mixed in)……