Vue首屏效能優化元件

WindrunnerMax發表於2021-11-09

Vue首屏效能優化元件

簡單實現一個Vue首屏效能優化元件,現代化瀏覽器提供了很多新介面,在不考慮IE相容性的情況下,這些介面可以很大程度上減少編寫程式碼的工作量以及做一些效能優化方面的事情,當然為了考慮IE我們也可以在封裝元件的時候為其兜底,本文的首屏效能優化元件主要是使用IntersectionObserver以及requestIdleCallback兩個介面。

描述

先考慮首屏場景,當做一個主要為展示用的首屏時,通常會載入較多的資源例如圖片等,如果我們不想在使用者開啟時就載入所有資源,而是希望使用者滾動到相關位置時再載入元件,此時就可以選擇IntersectionObserver這個介面,當然也可以使用onscroll事件去做一個監聽,只不過這樣效能可能比較差一些。還有一些元件,我們希望他必須要載入,但是又不希望他在初始化頁面時同步載入,這樣我們可以使用非同步的方式比如PromisesetTimeout等,但是如果想再降低這個元件載入的優先順序,我們就可以考慮requestIdleCallback這個介面,相關程式碼在https://github.com/WindrunnerMax/webpack-simple-environmentvue--first-screen-optimization分支。

IntersectionObserver

IntersectionObserver介面,從屬於Intersection Observer API,提供了一種非同步觀察目標元素與其祖先元素或頂級文件視窗viewport交叉狀態的方法,祖先元素與視窗viewport被稱為根root,也就是說IntersectionObserver API,可以自動觀察元素是否可見,由於可見visible的本質是,目標元素與視口產生一個交叉區,所以這個API叫做交叉觀察器,相容性https://caniuse.com/?search=IntersectionObserver

const io = new IntersectionObserver(callback, option);

// 開始觀察
io.observe(document.getElementById("example"));
// 停止觀察
io.unobserve(element);
// 關閉觀察器
io.disconnect();
  • 引數callback,建立一個新的IntersectionObserver物件後,當其監聽到目標元素的可見部分穿過了一個或多個閾thresholds時,會執行指定的回撥函式。
  • 引數optionIntersectionObserver建構函式的第二個引數是一個配置物件,其可以設定以下屬性:
    • threshold屬性決定了什麼時候觸發回撥函式,它是一個陣列,每個成員都是一個門檻值,預設為[0],即交叉比例intersectionRatio達到0時觸發回撥函式,使用者可以自定義這個陣列,比如[0, 0.25, 0.5, 0.75, 1]就表示當目標元素0%25%50%75%100%可見時,會觸發回撥函式。
    • root屬性指定了目標元素所在的容器節點即根元素,目標元素不僅會隨著視窗滾動,還會在容器裡面滾動,比如在iframe視窗裡滾動,這樣就需要設定root屬性,注意,容器元素必須是目標元素的祖先節點。
    • rootMargin屬性定義根元素的margin,用來擴充套件或縮小rootBounds這個矩形的大小,從而影響intersectionRect交叉區域的大小,它使用CSS的定義方法,比如10px 20px 30px 40px,表示toprightbottomleft四個方向的值。
  • 屬性IntersectionObserver.root只讀,所監聽物件的具體祖先元素element,如果未傳入值或值為null,則預設使用頂級文件的視窗。
  • 屬性IntersectionObserver.rootMargin只讀,計算交叉時新增到根root邊界盒bounding box的矩形偏移量,可以有效的縮小或擴大根的判定範圍從而滿足計算需要,此屬性返回的值可能與呼叫建構函式時指定的值不同,因此可能需要更改該值,以匹配內部要求,所有的偏移量均可用畫素pixelpx或百分比percentage%來表達,預設值為0px 0px 0px 0px
  • 屬性IntersectionObserver.thresholds只讀,一個包含閾值的列表,按升序排列,列表中的每個閾值都是監聽物件的交叉區域與邊界區域的比率,當監聽物件的任何閾值被越過時,都會生成一個通知Notification,如果構造器未傳入值,則預設值為0
  • 方法IntersectionObserver.disconnect(),使IntersectionObserver物件停止監聽工作。
  • 方法IntersectionObserver.observe(),使IntersectionObserver開始監聽一個目標元素。
  • 方法IntersectionObserver.takeRecords(),返回所有觀察目標的IntersectionObserverEntry物件陣列。
  • 方法IntersectionObserver.unobserve(),使IntersectionObserver停止監聽特定目標元素。

此外當執行callback函式時,會傳遞一個IntersectionObserverEntry物件引數,其提供的資訊如下。

  • time:可見性發生變化的時間,是一個高精度時間戳,單位為毫秒。
  • target:被觀察的目標元素,是一個DOM節點物件。
  • rootBounds:根元素的矩形區域的資訊,是getBoundingClientRect方法的返回值,如果沒有根元素即直接相對於視口滾動,則返回null
  • boundingClientRect:目標元素的矩形區域的資訊。
  • intersectionRect:目標元素與視口或根元素的交叉區域的資訊。
  • intersectionRatio:目標元素的可見比例,即intersectionRectboundingClientRect的比例,完全可見時為1,完全不可見時小於等於0
{
  time: 3893.92,
  rootBounds: ClientRect {
    bottom: 920,
    height: 1024,
    left: 0,
    right: 1024,
    top: 0,
    width: 920
  },
  boundingClientRect: ClientRect {
     // ...
  },
  intersectionRect: ClientRect {
    // ...
  },
  intersectionRatio: 0.54,
  target: element
}

requestIdleCallback

requestIdleCallback方法能夠接受一個函式,這個函式將在瀏覽器空閒時期被呼叫,這使開發者能夠在主事件迴圈上執行後臺和低優先順序工作,而不會影響延遲關鍵事件,如動畫和輸入響應,函式一般會按先進先呼叫的順序執行,如果回撥函式指定了執行超時時間timeout,則有可能為了在超時前執行函式而打亂執行順序,相容性https://caniuse.com/?search=requestIdleCallback

const handle = window.requestIdleCallback(callback[, options]);
  • requestIdleCallback方法返回一個ID,可以把它傳入window.cancelIdleCallback()方法來結束回撥。
  • 引數callback,一個在事件迴圈空閒時即將被呼叫的函式的引用,函式會接收到一個名為IdleDeadline的引數,這個引數可以獲取當前空閒時間以及回撥是否在超時時間前已經執行的狀態。
  • 引數options可選,包括可選的配置引數,具有如下屬性:
    • timeout: 如果指定了timeout,並且有一個正值,而回撥在timeout毫秒過後還沒有被呼叫,那麼回撥任務將放入事件迴圈中排隊,即使這樣做有可能對效能產生負面影響。

實現

實際上編寫元件主要是搞清楚如何使用這兩個主要的API就好,首先關注IntersectionObserver,因為考慮需要使用動態元件<component />,那麼我們向其傳值的時候就需要使用非同步載入元件() => import("component")的形式。監聽的時候,可以考慮載入完成之後即銷燬監聽器,或者離開視覺區域後就將其銷燬等,這方面主要是策略問題。在頁面銷燬的時候就必須將Intersection Observer進行disconnect,防止記憶體洩漏。使用requestIdleCallback就比較簡單了,只需要將回撥函式執行即可,同樣也類似於Promise.resolve().then這種非同步處理的情況。
這裡是簡單的實現邏輯,通常observer的使用方案是先使用一個div等先進行佔位,然後在observer監控其佔位的容器,當容器在視區時載入相關的元件,相關的程式碼在https://github.com/WindrunnerMax/webpack-simple-environmentvue--first-screen-optimization分支,請儘量使用yarn進行安裝,可以使用yarn.lock檔案鎖住版本,避免依賴問題。使用npm run dev執行之後可以在Console中看到這四個懶載入元件created建立的順序,其中Aobserver懶載入是需要等其載入頁面渲染完成之後,判斷在可視區,才進行載入,首屏使能夠直接看到的,而D的懶載入則是需要將滾動條滑動到D的外部容器出現在檢視之後才會出現,也就是說只要不滾動到底部是不會載入D元件的,另外還可以通過component-paramscomponent-eventsattrslisteners傳遞到懶載入的元件,類似於$attrs$listeners,至此懶載入元件已簡單實現。

<!-- App.vue -->
<template>
    <div>
        <section>1</section>
        <section>
            <div>2</div>
            <lazy-load
                :lazy-component="Example"
                type="observer"
                :component-params="{ content: 'Example A' }"
                :component-events="{
                    'test-event': testEvent,
                }"
            ></lazy-load>
        </section>
        <section>
            <div>3</div>
            <lazy-load
                :lazy-component="Example"
                type="idle"
                :component-params="{ content: 'Example B' }"
                :component-events="{
                    'test-event': testEvent,
                }"
            ></lazy-load>
        </section>
        <section>
            <div>4</div>
            <lazy-load
                :lazy-component="Example"
                type="lazy"
                :component-params="{ content: 'Example C' }"
                :component-events="{
                    'test-event': testEvent,
                }"
            ></lazy-load>
        </section>
        <section>
            <div>5</div>
            <lazy-load
                :lazy-component="Example"
                type="observer"
                :component-params="{ content: 'Example D' }"
                :component-events="{
                    'test-event': testEvent,
                }"
            ></lazy-load>
        </section>
    </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import LazyLoad from "./components/lazy-load/lazy-load.vue";
@Component({
    components: { LazyLoad },
})
export default class App extends Vue {
    protected Example = () => import("./components/example/example.vue");

    protected testEvent(content: string) {
        console.log(content);
    }
}
</script>

<style lang="scss">
@import "./common/styles.scss";
body {
    padding: 0;
    margin: 0;
}
section {
    margin: 20px 0;
    color: #fff;
    height: 500px;
    background: $color-blue;
}
</style>
<!-- lazy-load.vue -->
<template>
    <div>
        <component
            :is="renderComponent"
            v-bind="componentParams"
            v-on="componentEvents"
        ></component>
    </div>
</template>

<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
@Component
export default class LazyLoad extends Vue {
    @Prop({ type: Function, required: true })
    lazyComponent!: () => Vue;
    @Prop({ type: String, required: true })
    type!: "observer" | "idle" | "lazy";
    @Prop({ type: Object, default: () => ({}) })
    componentParams!: Record<string, unknown>;
    @Prop({ type: Object, default: () => ({}) })
    componentEvents!: Record<string, unknown>;

    protected observer: IntersectionObserver | null = null;
    protected renderComponent: (() => Vue) | null = null;

    protected mounted() {
        this.init();
    }

    private init() {
        if (this.type === "observer") {
            // 存在`window.IntersectionObserver`
            if (window.IntersectionObserver) {
                this.observer = new IntersectionObserver(entries => {
                    entries.forEach(item => {
                        // `intersectionRatio`為目標元素的可見比例,大於`0`代表可見
                        // 在這裡也有實現策略問題 例如載入後不解除`observe`而在不可見時銷燬等
                        if (item.intersectionRatio > 0) {
                            this.loadComponent();
                            // 載入完成後將其解除`observe`
                            this.observer?.unobserve(item.target);
                        }
                    });
                });
                this.observer.observe(this.$el.parentElement || this.$el);
            } else {
                // 直接載入
                this.loadComponent();
            }
        } else if (this.type === "idle") {
            // 存在`requestIdleCallback`
            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
            // @ts-ignore
            if (window.requestIdleCallback) {
                requestIdleCallback(this.loadComponent, { timeout: 3 });
            } else {
                // 直接載入
                this.loadComponent();
            }
        } else if (this.type === "lazy") {
            // 存在`Promise`
            if (window.Promise) {
                Promise.resolve().then(this.loadComponent);
            } else {
                // 降級使用`setTimeout`
                setTimeout(this.loadComponent);
            }
        } else {
            throw new Error(`type: "observer" | "idle" | "lazy"`);
        }
    }

    private loadComponent() {
        this.renderComponent = this.lazyComponent;
        this.$emit("loaded");
    }

    protected destroyed() {
        this.observer && this.observer.disconnect();
    }
}
</script>

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://www.ruanyifeng.com/blog/2016/11/intersectionobserver_api.html
https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver
https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback

相關文章