Qwik - 前端性能的終極方案?

出品:西瓜視頻前端技術團隊

作者:張明遠

Qwik 是什麼

Qwik 是一個前端框架,語法類似 React 使用 JSX 和 Hooks,不過 Qwik 是全棧 SSR 框架,而且 Qwik 採用了一系列策略優化頁面的首屏性能,做的無論應用體積多大,首屏性能 PageSpeed 測試基本都能達到滿分 Framework Benchmarks。

Misko Hevery

Misko Hevery,Qwik 的作者,更爲知名的 Title 是 Angular.js&Angular 的作者。

他在聖克拉拉大學畢業後先後去了硅谷很多公司,Intel、Xerox(施樂)、Sun 和 Adobe 公司他都有工作過,在這些公司他主要從事數據庫 / 後端方面的工作。

2005 年加入了谷歌,2009 年和 Adam Abrons 一起開發了 Angular.js,後來成爲谷歌的項目。

2021 年從谷歌離職加入 builder.io 成爲 CTO 並開始了 Qwik 項目。

一個問題

爲什麼前端框架層出不窮,Svelte、SolidJS、Astro、Fresh、Marko、Qwik?

Big Runtime

React 和 Vue 都是基於 Runtime 的框架,即框架本身有很多的代碼,且都會打包到最終的產物中被髮送到用戶的瀏覽器裏執行,當用戶需要在頁面上執行操作改變組件的狀態時,框架的 Runtime 會根據新的組件狀態 (State) 計算 (Diff) 出需要更新的 DOM 節點從而更新 View。

從上圖可以看出,像 Angular、React、Vue 等 Big Runtime 的框架體積都比較大,在首屏加載的時候就需要加載框架的 JS 文件並且執行相關的代碼,那麼就會產生一定的性能開銷,尤其是弱網和低性能手機,性能影響就會越大。

Virtual DOM is Pure Overhead

Virtual DOM is pure overhead

Virtual DOM 並不高效。說 Virtual DOM 高效是因爲它不會直接操作原生 DOM,操作 DOM 比較消耗性能。應用狀態變化時框架會生成新的 Virtual DOM,並通過 diff 算法去計算出本次數據更新真實的視圖變化,然後只改變 “需要改變” 的 DOM 節點。

React 頂層組件 state 更新,如果不進行任何優化,則所有子組件都會重渲染 (re-render)。所謂的 re-render 是你 Class Component 的 render 方法被重新執行,或者函數組件被重新執行。組件被重渲染是因爲 Vitual DOM 的高效是建立在 Diff 算法上的,而要有 Diff 一定要將組件重渲染才能知道組件的新狀態和舊狀態有沒有發生改變,從而才能計算出哪些 DOM 需要被更新。

正是因爲框架本身很難避免無用的渲染,React 才允許使用一些諸如shouldComponentUpdatePureComponentmemouseMemo的 API 去告訴框架哪些組件不需要被重渲染。

在 React 16 版本之前,Virtual DOM 的對比是通過遞歸實現,如果組件樹嵌套很深,那性能勢必降低;React 16 之後,推出 Fiber 架構,雖然省不掉必要的 render,但把遞歸 Diff 改爲可打斷的循環,並且花費精力解決任務優先級調度問題,優化了用戶體驗。

Virtual DOM 還有個最大的問題——額外的內存佔用,以 Vue 的 Virtual DOM 對象爲例,100W 個空的 Virtual DOM(Vue) 會佔用 110M 內存。

我認爲最重要的問題是組件狀態到頁面元素是有映射關係的,而是用 Virtual DOM 則丟失了這個映射關係,需要 DOM Diff 來重新構建這個關係,純粹是多餘的消耗 (Pure Overhead)。

SSR Hydration is Pure Overhead

Hydration is Pure Overhead

Hydration 是爲了對服務端進行渲染的 HTML 提供交互能力的方案。Hydration 的過程一般是運行框架和組件代碼,還原應用狀態和構建 Virtual DOM,並將事件監聽添加到 HTML 元素上。

Misko Hevery 認爲 Hydration 是很低效的解決方案。

從上圖可以看出,Hydration 需要較長的時間來進行應用的狀態恢復,主要因爲以下兩點:

1、框架必須下載與當前頁面相關的所有組件代碼並且由瀏覽器引擎進行解析和執行。

2、框架必須執行與頁面上的組件關聯的代碼重新構建整個應用程序,以重建事件監聽和內部組件樹,即使實際上沒有創建任何新的 DOM。

所以 Hydration 是純開銷,因爲整個應用的構建過程在 Node 上都已經運行過了,但是這部分信息沒有同步到瀏覽器,而是丟棄掉了,所以客戶端需要重新執行一遍代碼進行 Hydration 來重新恢復整個應用。而如果服務端將所有的應用所需要的信息序列化傳輸到瀏覽器,那麼 Hydration 的過程完全可以省略。

而且 Hydration 是與應用的複雜度成正比的。所以即使用了 SSR,頁面的 TTI 也可能不是很好。

社區的探索

Precompile

如今很多新的框架都沒有 VDOM,反而是通過預編譯然後直接進行細粒度的 DOM 操作來達到比 VDOM 更好的性能。

Svelte 是 Precompile 的先行者,其通過靜態編譯減少框架運行時的代碼量,一個 Svelte2 組件編譯了以後,所有需要的運行時代碼都包含在裏面了,除了引入這個組件本身,你不需要再額外引入一個所謂的框架運行時!

<a>{{ msg }}</a>

會被編譯成如下代碼:

function renderMainFragment(root, component, target) {
  var a = document . createElement('a');

  var text = document . createTextNode(root . msg);
  a . appendChild(text);

  target . appendChild(a)

  return {
    update: function (changed, root) {
      text . data = root . msg;
    },

    teardown: function (detach) {
      if (detach) a . parentNode . removeChild(a);
    }
  };
}

可以看到,跟基於 Virtual DOM 的框架相比,這樣的輸出不需要 Virtual DOM 的 diff/patch 操作,自然可以省去大量代碼,同時,性能上也和 vanilla JS 相差無幾(僅就這個簡單示例而言),內存佔用更是極佳。這個思路其實並不是它首創,之前有一個性能爆表的模板引擎 Monkberry.js 也是這樣實現的,ng2 的模板編譯其實也跟這個很類似(但是中間加了一層渲染抽象層)。

如何看待 svelte 這個前端框架? - 尤雨溪的回答 - 知乎 https://www.zhihu.com/question/53150351/answer/133912199

SolidJS 也是 Precompile,和 Svelte2 相比有少量的運行時,目前 Svelte 也改爲有少量運行時的方案來減少代碼體積,而且支持 Tree Shaking。

SolidJS

Demo 在線示例

Svelte 和 SolidJS 等預編譯框架解決了 Runtime 和 VDOM 的問題,沒有解決了 Hydration 的問題。

Islands Architecture

Islands 架構模型早在 2019 年就被提出來了,並在 2021 年被 Preact 作者Json Miller 在 Islands Architecture 一文中得到推廣。這個模型主要用於 SSR (也包括 SSG) 應用,我們知道,在傳統的 SSR 應用中,服務端會給瀏覽器響應完整的 HTML 內容,並在 HTML 中注入一段完整的 JS 腳本用於完成事件的綁定,也就是完成 hydration (注水) 的過程。當注水的過程完成之後,頁面也才能真正地能夠進行交互。當一個頁面中只有部分的組件交互,那麼對於這些可交互的組件,我們可以執行 hydration 過程,因爲組件之間是互相獨立的。

而對於靜態組件,即不可交互的組件,我們可以讓其不參與 hydration 過程,直接複用服務端下發的 HTML 內容。可交互的組件就猶如整個頁面中的孤島 (Island),因此這種模式叫做 Islands 架構。

摘錄自 Islands 架構原理和實踐

Islands Architecture 沒有解決 Runtime 的問題,部分解決了 Hydration 和 VDOM 的問題。

React Server Component(RSC)

React 在 2020 年 12 月發佈了 RSC 的 Demo,可以在 Node.js 上運行 RSC,然後生成 DSL 下發到瀏覽器,最後由框架層解析 DSL 並更新到 DOM 上。

RSC 可以將一些組件的渲染放到服務端,前端做純展示,而且僅 RSC 的依賴不會被打包到客戶端,這樣如果某個組件有一個較大的第三方依賴,就可以把第三方依賴放到 RSC 裏,在服務端運行組件並將產生的結果傳輸到瀏覽器端進行展示。

用官方給出的 demo 來舉例子,爲了渲染一個用 markdown 寫的筆記,我們需要用到 240kb 的 js 代碼(gzip 之後是 74kb)充當運行時:

// NoteWithMarkdown.js
// NOTE: *before* Server Components
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
   const html = sanitizeHtml(marked(text));
   return (/* render */); 
}

筆記只是用於查看,此時此刻它是純靜態的 (不需要用戶與之交互)。那麼如果我們能夠在服務器上把它渲染成靜態內容,我們是不是省掉把大量 js 代碼傳輸到客戶端,解析和執行的成本了呢?有了 React Server Component,我們能夠做到這一點。

RSC 沒有解決 Runtime 問題,部分解決了 Hydration 和 VDOM 的問題。

Resumable

Resumable (可恢復性) 是 Qwik 提出的一個概念,是指 SSR 時應用在服務端執行後可以在客戶端恢復執行,而不用重新構建和下載所有的應用代碼,也不需要對整個應用進行 Hydration。

Qwik 是如何實現 Resumable 的?

Precompile

Qwik 提供了 optimizer 對代碼進行預編譯,底層由 SWC 進行驅動。

看一個 HelloWorld Demo

上面的代碼經過 optimizer.transformModules編譯後會生成一個 JSON,然後對其處理成 js 文件

可以看到,Qwik 把一個組件便後成了兩個文件,一個處理 DOM 邏輯,一個處理事件邏輯。

最後通過 renderToString 生成的 SSR HTML 如下:

<!DOCTYPE html>
<html q:container="paused" q:version="0.11.1" q:render="ssr" q:base="/build/">
<!--qv q:id=0 q:key=shY4sSSi6wY:hello--> <p on:click= "s_9rnzdakbxj8.js#s_9rnZDAkBxj8[0]"  q:id= "1" > Hello Qwik </p>
<!--/qv-->  < script type= "qwik/json"  >  {  "ctx"  :{  "#1"  :{  "r"  :  "0"  }},  "objs"  :[  "Qwik"  ],  "subs"  :[]}  </ script > 
 < script > window . qwikevents ||= []; window . qwikevents . push (  "click"  )  </ script > 

</html>

Interactive

當在 SSR 階段生成 HTML,第二部就是要在瀏覽器處理事件了,Big Runtime 框架是通過 Hydration 來進行事件監聽,讓頁面進行事件響應的,而 Qwik 則採取了完全不同的方案。

Qwik 的事件監聽全部由頁面上的 qwikevents 來處理 :

 < script > window . qwikevents ||= []; window . qwikevents . push ( "click" ) </ script >
  1. Qwikevents 會監聽 document/window 的全局事件,而不是針對每個 DOM 單獨監聽其事件,這可以在不存在應用代碼的情況下實現事件監聽。

  2. 對觸發事件的 DOM 節點獲取其 on:event屬性,並從中解析出 Qrl ,調用 Qwikloader 加載對應的 JS 文件並執行對應的方法,而 DOM 上的 q:id和 事件上的[]內的數字則表示其方法的參數。

Resumable

Component tree

通常框架需要在瀏覽器中構建組件樹實現對頁面的更新 (Hydration),Qwik 則通過在 SSR renderToString 的過程中收集組件信息並將其序列化到 HTML 上,所以其不需要在運行時構建組件樹,而且可以實現以下能力:

  1. 在組件代碼不存在的情況下重建組件層次結構信息,組件代碼可以保持惰性。

  2. Qwik 可以實現懶加載,只爲需要重新渲染的組件重建組件層次結構信息,而不是整個應用。

  3. Qwik 收集 store 和組件之間的關係信息,並創建一個訂閱模型,通知 Qwik 哪些組件由於狀態更改而需要重新渲染。訂閱信息也被序列化到 HTML。

Application state

所有框架都需要保持狀態。大多數框架以引用和閉包的形式將此狀態保存在代碼中,這樣就導致初始化時候需要下載所有代碼,做好關聯,也就是 Hydration,但是這樣通常會有個問題,就是如果需要恢復子組件,那父組件也需要恢復。Qwik 的獨特之處在於狀態以屬性的形式保存在 DOM 中,這使得 Qwik 組件可以獨立進行恢復。

在 DOM 中保持狀態的後果有許多獨特的好處,包括:

1、通過以字符串屬性的形式在 DOM 中保持狀態,應用程序可以隨時序列化爲 HTML。

HTML 可以通過網絡發送並反序列化爲不同客戶端上的 DOM。然後可以恢復反序列化的 DOM。

2、每個組件都可以獨立於任何其他組件來恢復。這種只允許對整個應用程序的一個子集進行 Hydrate 且不需要時序,並僅僅下載需要響應用戶操作的代碼,這與傳統框架有很大不同。

3、Qwik 是一個無狀態框架(所有應用程序狀態都以字符串的形式存在於 DOM 中)。無狀態代碼易於序列化、傳輸和恢復。這也是允許組件彼此獨立 Hydration 的原因。

4、應用程序可以在任何時間點進行序列化(不僅僅是在初始渲染時),並且可以多次序列化。

優化

大家也會有個疑問:如果網絡延遲,點擊事件會不會卡頓呢?

Prefetching

Qwik 提供了 prefetchStrategy 方法來進行 JS 的預取:

export default function (opts: RenderToStreamOptions) {
  return renderToStream(<Root />, {
    manifest,
    prefetchStrategy: {
      // custom prefetching config
    },
    ...opts,
  });
}

默認情況下,Qwik 會預取頁面上所有可見節點的監聽器,也可以自己配置預取策略:

One More Thing

Partytown

Partytown,是一個輕量級的減少第三方 JS 腳本運行導致頁面加載問題的開源工具,由 Builder.io 維護,目前處於測試階段。通過將第三方 JS 在 WebWorker 運行,減少了由於第三方 JavaScript 導致的執行延遲。其想解決以下問題:

Web Worker 的主要問題是無法直接訪問可從主線程訪問的 DOM API,例如 window, documentlocalStorage。雖然可以在兩個線程之間創建消息傳遞系統來代理 DOM 操作,但用於 Web Worker / 主線程通信的postMessage API 是異步的。這意味着依賴於同步 DOM 操作的第三方腳本將無法按照預期運行。

Partytown 使用 JavaScript Proxy、Service Worker 和同步 XHR 請求,從 Web Worker 內部提供對 DOM API 的同步訪問。

例如在 Web Worker 中獲取 document.title 會經過以下步驟:

  1. 先對 document 使用 Proxy 進行攔截

  2. 再使用同步的 XHR 發起請求

  3. 然後使用 Service Worker 攔截請求

  4. 最後通過 postMessage異步發送到主線程。

整個流程雖然比較繁瑣,但是其好處就是在 Web Worker 運行的 JS 來說,其訪問 DOM API 是同步的,完全和主線程一樣,就不必重寫 JS 來處理 DOM API 了。

此外,通過 Proxy 來代理 DOM API,可以記錄所有的 JS 訪問 DOM 記錄,並進行攔截限制。

結語

本文對現今 (2022/11) 大部分框架進行一個簡單的分析,也探究了一些社區解決方案,Qwik 算是這些方案的集大成者,Qwik 提出的 Resumable 思想是對現今框架的一個顛覆,我認爲其對前端的意義不亞於 VDOM 和 JSX,未來幾年應該會更多的看到社區對 Resumable 探索和應用,甚至最終取代 React 也未可知。

雖然理念已經比較成熟,但 Qwik 框架本身目前還處於非常初期的版本,框架本身還有較多的問題,建議大家可以進行學習研究,持續關注其發展,但短期不要在正式項目中使用。

參考

  1. Virtual DOM is pure overhead-https://svelte.dev/blog/virtual-dom-is-pure-overhead

  2. Hydration is Pure Overhead-https://www.builder.io/blog/hydration-is-pure-overhead

  3. Resumable vs. Hydration-https://qwik.builder.io/docs/concepts/resumable/

  4. Resumable JavaScript with Qwik-https://dev.to/this-is-learning/resumable-javascript-with-qwik-2i29

  5. MPAs vs. SPAs-https://docs.astro.build/en/concepts/mpa-vs-spa/

  6. Islands Architecture-https://jasonformat.com/islands-architecture/

  7. The new wave of Javascript web frameworks-https://frontendmastery.com/posts/the-new-wave-of-javascript-web-frameworks/

  8. How we cut 99% of our JavaScript with Qwik + Partytown-https://www.builder.io/blog/how-we-cut-99-percent-js-with-qwik-and-partytown?utm_source=pocket_mylist

  9. 都快 2020 年,你還沒聽說過 SvelteJS?-https://zhuanlan.zhihu.com/p/97825481

  10. Islands 架構原理和實踐 - https://mp.weixin.qq.com/s/MfztwYyEH30F9IL0keAM5w

  11. Virtual DOM 認知誤區 - https://juejin.cn/post/6898526276529684493

  12. 如何看待 svelte 這個前端框架?- 尤雨溪 - https://www.zhihu.com/question/53150351/answer/133912199

  13. 【react】初探 server component-https://juejin.cn/post/6918602124804915208

  14. 前端框架對比(主要吐槽 React )-https://juejin.cn/post/7158285916266561572#heading-9

  15. Qwik.js 框架是如何追求極致性能的?!-https://segmentfault.com/a/1190000042250628

  16. Introducing Partytown 🎉: Run Third-Party Scripts From a Web Worker-https://dev.to/adamdbradley/introducing-partytown-run-third-party-scripts-from-a-web-worker-2cnp

  17. How Partytown Eliminates Website Bloat From Third-Party Scripts-https://www.smashingmagazine.com/2022/04/partytown-eliminates-website-bloat-third-party-apps/

關於我們

我們來自字節跳動,是旗下西瓜視頻前端部門,負責西瓜視頻的產品研發工作。

我們致力於分享產品內的業務實踐,爲業界提供經驗價值。包括但不限於營銷搭建、互動玩法、工程能力、穩定性、Nodejs、中後臺等方向。

歡迎關注我們的公衆號:xiguafe,閱讀更多精品文章。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JxJ0sD6M7i_g2qsLf5if3g