追求極致性能的 Qwik

背景:

Builder.io 的產品專注於電子商務,而電子商務熱愛速度!

感官上提升速度需要考慮的兩個維度:FCP 和 TTI

FCP(First Contentful Paint,首次內容繪製)當瀏覽器第一次渲染任何文字、圖片,以及非空白的 canvas 或 SVG 的時間

產物:SSR

TTI(Time to Interactive,用戶可交互時間)用於描述頁面何時包含有用的內容,並且主線程何時空閒並且可以自由響應用戶交互,包括註冊事件處理程序。

產物:Qwik

簡介:

Qwik 是一個以 DOM 爲核心的可恢復 Web 框架,旨在實現最佳的交互時間,專注於可恢復性和代碼的細粒度延遲加載的 SSR 框架。

Qwik 在服務器上開始執行,序列化爲 HTML, 發送給客戶端。序列化後的 HTML 中,除了包含 qwikloader.js(1kb)以外,不包含任何 js 的加載及執行。當用戶進行交互後,請求下載相應的交互代碼,Qwik 從服務器停止的地方恢復執行。

目標:

Qwik 的目標是提供即時應用程序,Qwik 通過兩個主要策略實現了這一點:

1、儘可能長時間地延遲 JavaScript 的執行和下載。

2、在服務器端序列化應用程序和框架的執行狀態,在客戶端恢復。

分析:

Qwik 速度快不是因爲它使用了聰明的算法,而是因爲它的設計方式使得大多數 JavaScript 永遠不需要下載或執行。它的速度來自於不做其他框架必須做的事情(例如水合作用 - hydration)。

比較:

現有的 SSR/SSG 應用在客戶端啓動時,它需要客戶端上的恢復三條信息:

1、偵聽器 - 定位事件偵聽器並將它們安裝在 DOM 節點上以使應用程序具有交互性;

2、組件樹 - 構造數據並表現在組件樹上。

3、應用程序狀態 - 恢復應用程序狀態。

這被稱爲水合作用。當前所有框架都需要此步驟以使應用程序具有交互性。

這個補水過程可以說是很昂貴的,主要因爲以下兩點:

1、框架必須下載與當前頁面相關的所有組件代碼。

2、框架必須執行與頁面上的組件關聯的模板,以重建偵聽器位置和內部組件樹。

而 Qwik 則不同,Qwik 提出 Resumable(可恢復)的概念,啓動時則不需要這個補水的過程,也就大大縮減了客戶端的啓動時間。

Resumable:

指服務器暫停執行並在客戶端恢復執行,而無需重新構建和下載所有應用程序邏輯。

爲了實現這一點,Qwik 需要解決 3 個問題:偵聽器、組件樹、應用程序狀態

偵聽器:

現有框架通過下載組件並執行來收集事件偵聽器,然後將這些事件偵聽器附加到 DOM 上。

當前的方法存在以下問題:

1、需要快速下載模板代碼。

2、需要立即執行模板代碼。

3、需要急切地下載事件處理程序代碼。

以上問題,會隨着業務越來越複雜,造成代碼量越來越大,從而對性能產生影響。

Qwik 則通過將事件偵聽序列化到 DOM 中 + Qwikloader 來解決上述問題

<button on:click="./chunk.js#handler_symbol">click me</button>

Qwik 仍然需要收集偵聽器信息,但是這一步放到服務器去完成, 將其序列化成 HTML,以便後續進行恢復。

on:click 屬性包含恢復應用程序的所有信息, 該屬性告訴 Qwikloader 要下載哪個代碼塊以及從該塊中執行函數名。

渲染首屏中,在 HTML 中會插入偵聽器的核心代碼 Qwikloader,小於 1kb,將在 1ms 內執行,首次渲染只有這一段 js,使得首屏速度接近純 HTML 頁面,也是 Qwik 頁面在 PageSpeed Insights 上得分 將近 100 分的原因。

組件樹:

現有框架,如果組件邊界信息已被破壞,則需要重新下載組件模板並執行補水,Hydration 的成本很高,所以性能也會受到損失。

Qwik 會將該組件信息序列化爲 HTML,則可以

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

2、Qwik 只能爲需要重新渲染的組件而不是所有預先渲染的組件延遲執行此操作。

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

應用狀態:

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

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

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

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

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

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

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

原理簡析:

我們通過實現一個計數器,來分析一下

環境:node14

代碼:

import { component$, useStore } from '@builder.io/qwik';

export default component$(() ={
  const counter = useStore({ coun: 0 });
  useServerMount$(() ={
    console.log("服務器執行");
  });
  useClientEffect$(() ={
    console.log("客戶端執行");
  });
  return (
    <>
      <div>Count: {counter.coun}</div>
      <button onClick$={() => counter.coun++}>+1</button>
    </>
  );
});

頁面效果:

1、先看語法

1、$ 後綴, 表示懶加載該函數

2、useStore 狀態管理

3、Hooks: useServerMount、useClientEffect...

等等

可以看出整體結構其實和 React 還是很類似的,只是提供了很多自己獨特的 api,上手成本可以說不高~

2、HTML

<html q:version="0.0.39" q:container="paused" q:host="" q:id="0" q:ctx="qc-c qc-ic qc-h qc-l qc-n" q:base="/build/">
<head q:host="" q:id="1">
<meta q:head="" charset="utf-8">
<link q:head="" rel="canonical" href="http://localhost:5173/">
<style q:style="s87awj-0">
    header {
      background-color: #0093ee;
    }
</style>
<link rel="stylesheet" href="/src/global.css">
</head>
<body q:host="" q:id="2">
    <div q:key="haiwfuvnx7g:" q:id="3" q:host="">
    <div q:key="Li90Ltjk0Is:" q:id="4" q:host="" q:sref="p">
    <main>
        <q:slot q:sref="p">
            <div q:key="buH6QBbKJm4:" q:id="7" q:host="">
                <h1 q:id="8" on:click="/src/routes_component_host_h1_onclick_a0y0gxm29ey.js#routes_component_Host_h1_onClick_A0y0gXM29EY">
                Welcome to Qwik City
                </h1>
            </div>
        </q:slot>
    </main>
<script type="qwik/json">
    {
      "ctx"{},
      "objs"[],
      "subs"[]
    }
</script>
<script id="qwikloader">
    (() ={
        ...
    })();
</script>
</body>
</html>

這裏是通過 renderToStream 函數生成的 HTML

我們可以看到裏邊包含了

1、Qwik 特有屬性 q:id、q:container、q:slot、q:host、on:click 等等

2、script 代碼塊 qwik/json

3、script 代碼塊 qwikloader

其中 qwikloader 包含了偵聽器核心邏輯,其他屬性則是用來反序列化,進行渲染組件樹和處理狀態時用。

3、點擊事件

點擊按鈕後:這裏只是展示了一個打印函數,和本例無關,本例代碼在下邊再說~

內部代碼:

export const routes_component_Host_h1_onClick_A0y0gXM29EY = ()=>console.warn('hola');

可以看到裏邊就是我們寫的執行函數~

這一步主要是通過 html 內的 Qwikloader.js 來實現的

核心原理就是通過事件委託來監聽所有事件,當點擊時,獲取當前 dom 上的屬性,進行規則解析,然後 import 加載進來

const dispatch = async (element, onPrefix, eventName, ev) ={   
            element.hasAttribute('preventdefault:' + eventName) &&  // preventdefault:click
              ev.preventDefault()
            const attrValue = element.getAttribute(      // 獲取on-document:click 屬性
              'on' + onPrefix + ':' + eventName // on-document:click
            )
            console.log('dispatch獲取當前元素'+'on' + onPrefix + ':' + eventName+ '事件屬性值', attrValue)
            if (attrValue) {  // 存在on:click 屬性
              for (const qrl of attrValue.split('\n')) {
                console.log('屬性上原url', qrl)
                const url = qrlResolver(element, qrl) // 是否自定義域名
                console.log('處理後url', url)
                if (url) {
                  const symbolName = getSymbolName(url)
                  console.log('symbolName-hash值', symbolName)
                  console.log('引入js路徑', url.href.split('#')[0])
                  const handler =
                    (window[url.pathname] ||
                      findModule(await import(url.href.split('#')[0])))[    // 引入js
                      symbolName
                    ] || error(url + ' does not export ' + symbolName)
                  const previousCtx = doc.__q_context__
                  if (element.isConnected) {    // 已經插入dom
                    try {
                      doc.__q_context__ = [element, ev, url]
                      handler(ev, element, url) // 執行引入的js
                    } finally {
                      doc.__q_context__ = previousCtx
                      emitEvent(element, 'qsymbol', symbolName)
                    }
                  }
                }
              }
            }
          }

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

下邊說下 Qwik 是怎麼解決的,官方文檔只是說 Qwik 自己做了一些優化策略,但是沒有細說。

我簡單看了下,Qwik 是用了 html 的 prefetch, 對要加載的 js 文件進行了預加載,這樣儘量保證點擊前已經加載完 js 代碼,又不影響主程序的加載

在 options 裏有個 prefetchStrategy 的配置,可以自定義配置相應的 url 進行 prefetch

4、頁面渲染

我們繼續看計數器這個例子

點擊後 + 1

其中點擊事件代碼:

import { useLexicalScope } from "/node_modules/@builder.io/qwik/core.mjs?v=d5d641c1";
export const _id__component__Fragment_button_onClick_yirrteWPaW0 = ()=>{
    const [counter] = useLexicalScope();
    return counter.coun++;
};

可以看到,我們源代碼中的 useStore 會被轉化成 useLexicalScope,並且下載運行時的 core.mjs

在 core.js 內會執行恢復, 主要邏輯在 resumeContainer 函數內,以下爲刪減後代碼

const resumeContainer = (containerEl) ={
    // 恢復
    const doc = getDocument(containerEl);
    const isDocElement = containerEl === doc.documentElement;
    const parentJSON = isDocElement ? doc.body : containerEl;
    const script = getQwikJSON(parentJSON); // 獲取qwik/json數據
    script.remove();
    const containerState = getContainerState(containerEl);
    const meta = JSON.parse(unescapeText(script.textContent || '{}'));
    const getObject = (id) ={
        console.log('getObject值', id, getObjectImpl(id, elements, meta.objs, containerState))
        return getObjectImpl(id, elements, meta.objs, containerState);
    };
    const parser = createParser(getObject, containerState);    // 反序列化Dom屬性工具函數
    // 啓動代理,和Vue類似,通過修改get和set函數來實現發佈訂閱
    reviveValues(meta.objs, meta.subs, getObject, containerState, parser);
    // 重建當前state的obj
    for (const obj of meta.objs) {
        reviveNestedObjects(obj, getObject, parser);
    }
    Object.entries(meta.ctx).forEach(([elementID, ctxMeta]) ={
        const el = getObject(elementID);
        assertDefined(el, `resume: cant find dom node for id`, elementID);
        const ctx = getContext(el);
        const qobj = ctxMeta.r;
        const seq = ctxMeta.s;
        const host = ctxMeta.h;
        const contexts = ctxMeta.c;
        const watches = ctxMeta.w;
        if (qobj) {
            console.log('推送的啥', ...qobj.split(' ').map((part) => getObject(part)))
            ctx.$refMap$.$array$.push(...qobj.split(' ').map((part) => getObject(part)));
        }
        if (seq) {
            ctx.$seq= seq.split(' ').map((part) => getObject(part));
        }
        if (watches) {
            ctx.$watches= watches.split(' ').map((part) => getObject(part));
        }
        if (contexts) {
            contexts.split(' ').map((part) ={
                const [key, value] = part.split('=');
                if (!ctx.$contexts$) {
                    ctx.$contexts= new Map();
                }
                ctx.$contexts$.set(key, getObject(value));
            });
        }
        // Restore sequence scoping
        if (host) {
            const [props, renderQrl] = host.split(' ');
            assertDefined(props, `resume: props missing in q:host attribute`, host);
            assertDefined(renderQrl, `resume: renderQRL missing in q:host attribute`, host);
            ctx.$props= getObject(props);
            ctx.$renderQrl= getObject(renderQrl);
            console.log('ctx', ctx)
        }
    });
    directSetAttribute(containerEl, QContainerAttr, 'resumed');
    emitEvent(containerEl, 'qresume', undefined, true);
};

主要邏輯爲:

1、獲取 html 中的 qwik/json

2、通過解析 json 創建 state

3、獲取 container 的 state

4、創建反序列化 Dom 屬性工具函數

5、啓動代理 Proxy,實現 get、set 的發佈訂閱

6、重建 state

7、觸發 set,觸發 render

通過以上例子,我們基本瞭解了 Qwik 實現的原理。

最後:

我們可以看出,Qwik 的優點還是很明顯的,通過更加細粒的代碼,以及事件委託來大大縮短了首次可交互時間,在渲染上,也充分利用了 dom 的屬性,使組件可以獨立渲染等等。但是也會存在一些爭議的地方,像點擊事件後,是否會下載代碼失敗,prefetch 策略是否真得好用,等等問題。但是整體來說,還是一個很有前瞻性的框架的,也真正解決了一些現有的問題, 如果有機會,針對頁面首屏加載速度,首次交互要求很高的網頁,是可以嘗試一下的。好了,就先寫到這裏,如果有寫的不對的地方,歡迎大家指正,共同進步~~~

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