追求極致效能的Qwik

語言: CN / TW / HK

背景:

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(可恢復)的概念,啟動時則不需要這個補水的過程,也就大大縮減了客戶端的啟動時間。

0

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>
    </>
  );
});

頁面效果:

0

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、點選事件

點選按鈕後:這裡只是展示了一個列印函式,和本例無關,本例程式碼在下邊再說~

0

內部程式碼:

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

0

其中點選事件程式碼:

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策略是否真的好用,等等問題。但是整體來說,還是一個很有前瞻性的框架的,也真正解決了一些現有的問題, 如果有機會,針對頁面首屏載入速度,首次互動要求很高的網頁,是可以嘗試一下的。