Vue首屏性能優化組件
簡單實現一個Vue首屏性能優化組件,現代化瀏覽器提供了很多新接口,在不考慮IE兼容性的情況下,這些接口可以很大程度上減少編寫代碼的工作量以及做一些性能優化方面的事情,當然為了考慮IE我們也可以在封裝組件的時候為其兜底,本文的首屏性能優化組件主要是使用IntersectionObserver以及requestIdleCallback兩個接口。
描述
先考慮首屏場景,當做一個主要為展示用的首屏時,通常會加載較多的資源例如圖片等,如果我們不想在用戶打開時就加載所有資源,而是希望用戶滾動到相關位置時再加載組件,此時就可以選擇IntersectionObserver這個接口,當然也可以使用onscroll事件去做一個監聽,只不過這樣性能可能比較差一些。還有一些組件,我們希望他必須要加載,但是又不希望他在初始化頁面時同步加載,這樣我們可以使用異步的方式比如Promise和setTimeout等,但是如果想再降低這個組件加載的優先級,我們就可以考慮requestIdleCallback這個接口,相關代碼在https://github.com/WindrunnerMax/webpack-simple-environment的vue--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時,會執行指定的回調函數。 - 參數
option,IntersectionObserver構造函數的第二個參數是一個配置對象,其可以設置以下屬性: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,表示top、right、bottom和left四個方向的值。
- 屬性
IntersectionObserver.root只讀,所監聽對象的具體祖先元素element,如果未傳入值或值為null,則默認使用頂級文檔的視窗。 - 屬性
IntersectionObserver.rootMargin只讀,計算交叉時添加到根root邊界盒bounding box的矩形偏移量,可以有效的縮小或擴大根的判定范圍從而滿足計算需要,此屬性返回的值可能與調用構造函數時指定的值不同,因此可能需要更改該值,以匹配內部要求,所有的偏移量均可用像素pixel、px或百分比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:目標元素的可見比例,即intersectionRect占boundingClientRect的比例,完全可見時為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,防止內存泄漏。另外我們為了使用IntersectionObserver則必須需要一個可以觀察的目標,如果什么不都渲染,我們就無從觀察,所以我們需要引入一個骨架屏,我們可以為真實的組件做一個在尺寸上非常接近真實組件的組件,在這里為了演示只是簡單的渲染了<section />作為骨架屏。使用requestIdleCallback就比較簡單了,只需要將回調函數執行即可,同樣也類似於Promise.resolve().then這種異步處理的情況。
這里是簡單的實現邏輯,通常observer的使用方案是先使用一個div等先進行占位,然后在observer監控其占位的容器,當容器在視區時加載相關的組件,相關的代碼在https://github.com/WindrunnerMax/webpack-simple-environment的vue--first-screen-optimization分支,請盡量使用yarn進行安裝,可以使用yarn.lock文件鎖住版本,避免依賴問題。使用npm run dev運行之后可以在Console中看到這四個懶加載組件created創建的順序,其中A的observer懶加載是需要等其加載頁面渲染完成之后,判斷在可視區,才進行加載,首屏使能夠直接看到的,而D的懶加載則是需要將滾動條滑動到D的外部容器出現在視圖之后才會出現,也就是說只要不滾動到底部是不會加載D組件的,另外還可以通過component-params和component-events將attrs和listeners傳遞到懶加載的組件,類似於$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
