ÿÿæ·±å…¥è§£æž Nuxt.js çš„ <nuxt-link> Smart Prefetching 實作 | Alex Liu

æ·±å…¥è§£æž Nuxt.js çš„ <nuxt-link> Smart Prefetching 實作

• 21 min read

Smart Prefetching(或稱 Router Prefetch) 機制改善了使用者等待æ›é è«‹æ±‚的體驗,他讓é é¢åˆ‡æ›æ›´ç‚ºé †æš¢ã€‚在 Nuxt.js 中內建的 <nuxt-link> 組件也實åšäº†é€™é …功能,讓我們一起從 Nuxt.js çš„ source code 中èªè­˜é€™é …技術並從中å¸å–大神們的寶貴經驗å§ï¼

å‰è¨€

這裡以 Nuxt.js 2.12.0 的版本為主,本篇撰寫當下 Nuxt.js 最新的版本為 2.14.3,但在 <nuxt-link> 組件上差異ä¸å¤§ã€‚在開發模å¼ä¸­ï¼Œæœƒæœ‰å…©å€‹è·¯ç”±çµ„件的檔案,分別為 nuxt-link.server.js 與 nuxt-link.client.js 兩隻檔案,這裡會èšç„¦åœ¨ nuxt-link.client.js。

<nuxt-link> 為 Nuxt.js 的路由切æ›çµ„件。他的 API è·Ÿ Vue Router çš„ <router-link> 幾乎一模一樣,差別在於 Nuxt.js 為 <nuxt-link> 增加了 Smart Prefetching 的功能,讓使用者在切æ›é é¢æ™‚更為順暢。

ä¸éŽç¾åœ¨æ™®é使用者的網路下載速度大多時候都å¯ä»¥åˆ° 10 å¹¾ Mbps 以上,所以一般而言 Smart Prefetching æå‡çš„é é¢åˆ‡æ›é€Ÿåº¦æå‡å…¶å¯¦çœŸä¸å®¹æ˜“被察覺,但我們後是å¯ä»¥é€éŽ Chrome DevTools 中的 Network é¢ç‰ˆæ¨¡æ“¬ä½Žç¶²é€Ÿçš„æ–¹å¼ä¾†è§€å¯Ÿã€‚

以下範例利用 Chrome DevTools 模擬 fast 3g çš„ç¶²è·¯é€Ÿåº¦ï¼Œå·¦å´æœ‰ prefetch 與å³å´å‰‡æ²’有。è¦ç¨å¾®ä»”細觀察他們之間的差異,是範例組件åšçš„æ¯”教單純,但如果å†è¤‡é›œä¸€é»žå·®ç•°å°±æœƒè¢«æ”¾å¤§ã€‚

<nuxt-link> Smart Prefetching 差異比較

由此å¯çŸ¥ Smart Prefetching åŠŸèƒ½å°æ–¼åœ¨ç¶²è·¯é€Ÿåº¦è¼ƒå·®çš„æƒ…æ³ä¸‹ï¼Œèƒ½è®“ä½¿ç”¨è€…ä¿æŒç›¸å°é †æš¢çš„使用體驗。


在開始了解 <nuxt-link> 的原始碼å‰ï¼Œå…ˆä¾†çœ‹éŽä»–çš„ API 設計。

<nuxt-link> 與 <router-link> 的路由組件使用方å¼å¤§è‡´ç›¸åŒï¼Œé™¤æ­¤ä¹‹å¤– <nuxt-link> 多了兩個屬性å¯ä»¥è¨­å®šï¼Œåˆ†åˆ¥æ˜¯ï¼š

  • prefetch
    • Type: Boolean,
    • Default: true(在 Nuxt.js 2.10.0 之後會根據 nuxt.config.js 中ä¸åŒçš„設定有ä¸åŒçš„é è¨­ï¼‰
  • no-prefetch
    • Type: Boolean,
    • Default: false

從字é¢å¯ä»¥ç†è§£ï¼Œä½¿ç”¨è€…å¯ä»¥é€éŽçµ¦äºˆ prefetch 或 no-prefetch ä¸åŒçš„值,決定是å¦è¦åŸ·è¡Œ prefetch。é è¨­ç‹€æ³éƒ½æ˜¯æœƒ prefetch 的,如果è¦é—œé–‰ prefetch æ–¹å¼å¦‚下:

<nuxt-link to="/about" no-prefetch>About page not pre-fetched</nuxt-link>
<!-- or -->
<nuxt-link to="/about" :prefetch="false">About page not pre-fetched</nuxt-link>

如果是使用 Nuxt.js 2.10.0 之後的版本,則å¯ä»¥åœ¨ nuxt.config.js 中直接將 prefetch 給關閉。

// nuxt.config.js

export default {
  router: {
    prefetchLinks: false
  }
}

é€™æ™‚å¦‚æžœç‰¹å®šé€£çµæƒ³è¦å•Ÿç”¨ prefetch 的話å†å°‡å…¶é–‹å•Ÿ

<nuxt-link to="/about" prefetch>About page pre-fetched</nuxt-link>

接下來進入到本篇é‡é»žï¼ŒNuxt æ˜¯å¦‚ä½•å¯¦åš Smart Prefetching 的呢?


è¦é–‹å§‹æ·±å…¥ <nuxt-link> 的實作內容å‰ï¼Œå¦‚果有基本的 Vue Router API èªè­˜æœƒæ›´å¥½ï¼Œä»¥åŠæŽ¥ä¸‹ä¾†æœƒå…ˆç°¡ä»‹ä¸‰å€‹æœƒä½¿ç”¨åˆ°çš„ Web APIs,之後å†é–‹å§‹å¯¦ä½œåˆ†æžéƒ¨åˆ†ã€‚

使用到的 Web API

  • IntersectionObserver
    如åŒé–‹é ­éƒ¨åˆ†æ‰€æåŠï¼Œå¯ä»¥è§€å¯Ÿå…ƒç´ æ˜¯å¦é€²åˆ°ç•«é¢æˆ–指定å€åŸŸï¼Œå°æ–¼ä¸æ”¯æ´çš„ç€è¦½å™¨éœ€è¦è‡ªå·±å¼•入尿‡‰çš„ polyfill。這之å‰ç ”ç©¶éŽé€™å€‹ Web API 的細節,有興趣的å¯ä»¥çœ‹é€™ç¯‡ï¼šIntersection Observer API 使用筆記
  • requestIdleCallback
    ç›¸å°æ–¼ requestAnimationFrame æ˜¯åœ¨ç•«é¢æ›´æ–°çš„æ¯ä¸€å¹€ï¼ˆframe)更新時執行 callback,這裡的 requestIdleCallback 則是在 幀與幀之間的空檔視情æ³åŸ·è¡Œï¼Œä¸”執行的 callback ä¸å»ºè­°å«æœ‰ä¿®æ”¹ DOM çš„æ“作。
    使用方å¼å¦‚下:
    var handle = window.requestIdleCallback(callback[, options])
    

    options 為一個物件,å¯ä»¥è¨­å®šçš„åƒæ•¸å¦‚下(目å‰ä¹Ÿåªæœ‰ä¸€å€‹åƒæ•¸ï¼‰ï¼š
    • timeout
      • Type: Number
      • 超時強制執行時間,如果超éŽé€™å€‹æ™‚間都還等ä¸åˆ°ç©ºæª”執行 callback,就強制呼å«ä»–執行。

    如果在 requestIdleCallback çš„ callback 執行å‰è¦æ¸…除他的話則需è¦ç”¨åˆ° cancelIdleCallback
    使用方å¼å¦‚下:
    window.cancelIdleCallback(handle)
    

    這樣看起來 requestIdleCallback() çš„ç”¨æ³•æ˜¯ä¸æ˜¯è·Ÿ setTimeout() 很åƒï¼
    支æ´åº¦éƒ¨åˆ†ï¼Œæ ¹æ“š MDN 表示,這還是是一個實驗中的功能,目å‰åƒæ˜¯ Safari 尚未支æ´ï¼Œä¸éŽ Nuxt.js 在實作上有為其添加簡易的 polyfill。
  • navigator.connection
    這裡會用到 navigator.connection 中的 effectiveType åŠ saveData 屬性。
    effectiveType 這個屬性全å:Effective connection type(ECT,Google 翻譯:有效連接類型),而這個屬性å¯èƒ½çš„值(狀態)如下:
    type EffectiveConnectionType =  'slow-2g' | '2g' | '3g' | '4g'
    

    在 MDN çš„ Effective connection type ä¸­ï¼Œå°æ¯ä¸€å€‹ç‹€æ…‹å°æ‡‰çš„網路下載速率有清楚的定義。而 <nuxt-link> 會ä¾ç…§ effectiveType 是å¦å«æœ‰ 2g ï¼ˆç¶²è·¯é€Ÿåº¦è¦ 3g 以上)來決定是å¦é€²è¡Œ predetch。
    å¦å¤–一個 saveData 則是如果使用者有設定減少æµé‡ä¹‹é¡žçš„設定,他就會返回 true。
    ä¸éŽ navigator.connection 一樣有支æ´åº¦çš„å•題,在 FireFox 需è¦è‡ªè¡Œå•Ÿå‹•這項功能(ä¾ç„¶ä¸æ”¯æ´ effectiveType è·Ÿ saveData),而 Safari å‰‡æ˜¯å®Œå…¨ä¸æ”¯æ´ã€‚

å¦‚æžœå°æ–¼å‰é¢æåˆ°çš„三個 Web API 都有了基本èªè­˜çš„話,接下來的部分就應該是蠻好ç†è§£çš„ï¼ä»¥ä¸‹æœƒè©³ç´°èªªæ˜Žæ¯ä¸€æ®µ JavaScript 的功能,一步一步拆解 <nuxt-link> 實作的內容。

1. 觀察 <nuxt-link> 是å¦é€²åˆ°ç•«é¢

首先先建立一個 IntersectionObserver 實例,但如果ç€è¦½å™¨ä¸æ”¯æ´ä¸”沒有加入需è¦çš„ polyfill 這裡拿到的就會是 undefined

const observer = window.IntersectionObserver &&
  new window.IntersectionObserver((entries) => {
    entries.forEach(({ intersectionRatio, target: link }) => {
      if (intersectionRatio <= 0) {
        return
      }
      link.__prefetch()
    })
  })

callback 的部分。當被觀察的 DOM é€²å…¥åˆ°ç•«é¢æ™‚,就去執行 DOM 上的 __prefetch() 方法,é€éŽé€™å€‹æ–¹æ³•去 prefetch 需è¦çš„é é¢çµ„件。

這裡所有 <nuxt-link> 組件都會共用åŒä¸€å€‹ IntersectionObserver 實例。但 observer 實例無法直接å–用æ¯å€‹çµ„件實例上的 prefetch 方法,為此 Nuxt 鏿“‡çš„作法是:在組件將 DOM 物件交給 observer å‰ï¼Œå…ˆåœ¨ MOD 上加上一個 __prefetch 屬性並將 prefetch 方法的åƒè€ƒå‚³å…¥ã€‚這樣一來,就å¯ä»¥è®“ observer 在觀察到 DOM é€²å…¥åˆ°ç•«é¢æ™‚é€éŽå‘¼å«è©² DOM 上的 __prefetch() å–用到組件實例上é¢çš„æ–¹æ³•囉ï¼

2. 處裡 requestIdleCallback() 的相容議題

如果é‡åˆ°ä¸æ”¯æ´ requestIdleCallback() çš„ç€è¦½å™¨ï¼Œå‰‡ç”¨ setTimeout() 替代。

const requestIdleCallback = window.requestIdleCallback ||
  function (cb) {
    const start = Date.now()
    return setTimeout(function () {
      cb({
        didTimeout: false,
        timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
      })
    }, 1)
  }

const cancelIdleCallback = window.cancelIdleCallback ||
  function (id) { clearTimeout(id) }

由於 Event Loop 的特性,setTimeout çš„ callback 會在所有執行堆疊(called stack)的工作跑完後æ‰é–‹å§‹è¨ˆæ™‚並執行,所以這裡的目的就是讓傳進來的 callback 等到其他事情處裡玩後æ‰åŸ·è¡Œã€‚

å¦å¤– cb() 中傳入的 didTimeout è·Ÿ timeRemaining() 是在模擬 requestIdleCallback() çš„ callback æŽ¥æ”¶çš„çš„åƒæ•¸ IdleDeadlineï¼Œä½†æŽ¥ä¸‹ä¾†çš„å¯¦ä½œä¸æœƒä½¿ç”¨åˆ°é€™å€‹éƒ¨åˆ†ã€‚

3. <nuxt-link> 組件設計

以下為了ä¸è¦ä¸€æ¬¡ä¸Šæ”¾å¤ªé•·çš„ JavaScript,我先將 methods ç¨ç«‹å‡ºä¾†ã€‚

  • observe () - å°‡ DOM 託付給 observer
    observe () {
      // If no IntersectionObserver, avoid prefetching
      if (!observer) {
        return
      }
      // Add to observer
      if (this.shouldPrefetch()) {
        this.$el.__prefetch = this.prefetchLink.bind(this)
        observer.observe(this.$el)
        this.__observed = true
      }
    }
    

    在這裡會將 this.$el 這個 DOM 交由 observer 觀察,如果 observer ä¸å­˜åœ¨ï¼ˆç€è¦½å™¨ä¸æ”¯æ´ Intersection Observer API æ™‚ï¼‰å‰‡ç›´æŽ¥çµæŸï¼Œå¾Œé¢æ‰€æœ‰äº‹æƒ…çš„éƒ½ä¸æœƒåšäº†ã€‚
    這裡還會先é€éŽ shouldPrefetch () 這個方法確èªè©²è·¯ç”±é é¢çµ„件是å¦éœ€è¦ prefetchï¼Œå¦‚æžœç¢ºå®šéœ€è¦æ‰é€²è¡Œä»¥ä¸‹å‹•作
    1. 將執行 prefetch 的方法:prefetchLink() çš„åƒè€ƒæ”¾åˆ° DOM 物件上,這邊是為了讓在外é¢çš„ observer å–用組件上的方法。
    2. DOM 託付給 observer 觀察。
    3. 最後將 this.__observed 標記為 true 表示 DOM 已在觀察中,之後銷毀改組件時會ä¾ç…§æ­¤æ¨™è¨˜åˆ¤æ–·æ˜¯å¦å–消觀察。
  • shouldPrefetch () - æ‡‰è©²è¦ prefetch 嗎?
    shouldPrefetch () {
      return this.getPrefetchComponents().length > 0
    }
    

    é€™è£¡æœƒå›žå‚³éœ€è¦ prefetch çš„é é¢çµ„件陣列 getPrefetchComponents () æ•¸é‡æ˜¯å¦å¤§æ–¼ 0`。
  • getPrefetchComponents () - éœ€è¦ prefetch 的組件有哪些
    getPrefetchComponents () {
      const ref = this.$router.resolve(this.to, this.$route, this.append)
      const Components = ref.resolved.matched.map(r => r.components.default)
    
      return Components.filter(Component => typeof Component === 'function' && !Component.options && !Component.__prefetched)
    }
    

    é€™å€‹æ–¹æ³•æœƒå›žå‚³ä¸€å€‹éœ€è¦ prefetch çš„é é¢çµ„件陣列。
    首先,$router.resolve() å¯ä»¥å–得一包解æžéŽçš„路由資訊,如果ä¸ç†Ÿçš„人(指我),以下是這個方法的定義:
    // router.d.ts
    
    export declare class VueRouter {
      // ...ç•¥...
      resolve(to: RawLocation, current?: Route, append?: boolean): {
        location: Location
        route: Route
        href: string
        // backwards compat
        normalizedTo: Location
        resolved: Route
      }
      // ...ç•¥...
    }
    
    export interface Route {
      path: string
      name?: string | null
      hash: string
      query: Dictionary<string | (string | null)[]>
      params: Dictionary<string>
      fullPath: string
      // 會用到這個屬性
      // 但 RouteRecord çš„ interface 就先ä¸åˆ—了
      matched: RouteRecord[]
      redirectedFrom?: string
      meta?: any
    }
    

    åƒæ•¸éƒ¨åˆ†ï¼š
    • to:è¦è§£æžçš„路由,這裡就會是 <nuxt-link to="這裡的值">。
    • current:當å‰è·¯ç”±è³‡æ–™ã€‚
    • append: æ˜¯å¦ append,在組件上這樣設定的地方 <nuxt-link to="/" :append="true | false">(想ä¸åˆ°ä¸­æ–‡æ€Žéº¼è¬›æ¯”較æ°ç•¶ï¼‰ã€‚

    接著é‡å°å›žå‚³å€¼è£¡é¢çš„ resolved 屬性找到所需è¦çš„é é¢çµ„件,並å†å¾žä¸­éŽæ¿¾å‡ºçœŸæ­£éœ€è¦æ¬²å–çš„çµ„ä»¶ï¼Œä»¥ä¸‹èªªæ˜ŽéŽæ¿¾æ¢ä»¶ï¼š
    // éŽæ¿¾æ¢ä»¶
    Components.filter(Component => (
      typeof Component === 'function' &&
      !Component.options &&
      !Component.__prefetched
    ))
    
    • typeof Component === 'function'
      檢查 Component 的型別是å¦ç‚º function,在 Nuxt.js ä¾ç…§è³‡æ–™å¤¾çµæ§‹ç”Ÿæˆçš„ routes 都會是動態載入的 async function。
    • !Component.options
      如果是有開啟éŽçš„é é¢ï¼Œåœ¨ Nuxt.js 這裡解æžåŒ¹é…çš„é é¢çµ„件時å–得的會是該組件的 constructor(型別也是 function),這時å¯ä»¥ä¾æœ‰æ²’有 options 屬性判斷是å¦é–‹å•ŸéŽï¼Œæ²’有開啟éŽé é¢çµ„ä»¶çš„æ‰éœ€è¦é€²è¡Œ prefetch。
    • !Component.__prefetched
      當第一次 prefetch 時會在該組件(這時還是 async function)加上 __prefetched 的屬性,這樣下次判斷就å¯ä»¥çŸ¥é“這個部分已經é å–éŽäº†ã€‚這åªåœ¨æœªè¨ªå•éŽçš„é é¢çµ„件上有效,如果該é é¢è¢«è¨ªå•éŽï¼Œå‰‡æœƒè¢«æ›¿æ›æˆè©²çµ„ä»¶çš„ constructor,如上一步æéŽçš„。

    如果以上的都為 true(組件型別為 functionã€æ²’有 options å±¬æ€§ã€æ²’有 __prefetched 屬性)時,表示該é é¢çµ„件需è¦é å–。
    補充
    Nuxt.js 這裡用的是 router.resolve 回傳的 resolved 屬性,但在 Vue Router 2.2.0 çš„ releases log è£¡é¢æœ‰æåˆ°é€™å€‹å±¬æ€§èˆ‡ normalizedTo å·²ç¶“ä¸æŽ¨è–¦ä½¿ç”¨ï¼Œç¾åœ¨ä¿ç•™é€™å€‹å±¬æ€§æ˜¯ç‚ºäº†å‘下兼容,在官方文件上也已經看ä¸åˆ°é€™å…©å€‹å±¬æ€§äº†ï¼Œå–而代之的分別是 route è·Ÿ location。
  • prefetchLink () - 執行 prefetch
    prefetchLink () {
      if (!this.canPrefetch()) {
        return
      }
      // Stop observing this link (in case of internet connection changes)
      observer.unobserve(this.$el)
      const Components = this.getPrefetchComponents()
    
      for (const Component of Components) {
        const componentOrPromise = Component()
        if (componentOrPromise instanceof Promise) {
          componentOrPromise.catch(() => {})
        }
        Component.__prefetched = true
      }
    }
    

    åœ¨é€™è£¡å…ˆåˆ¤æ–·äº†ç•¶ä¸‹çš„ç¶²è·¯ç‹€æ…‹æ˜¯å¦æ‡‰è©²è¦åŸ·è¡Œ canPrefetch ()。之後在開始 prefetch å‰å‘Šè¨´ observer 解除å°è©² DOM 的觀察,並且開始é å–,最後在發出 prefetch 請求的組件上加上 __prefetched 屬性。
  • canPrefetch () - å¯ä»¥ prefetch å—Ž
    canPrefetch () {
      const conn = navigator.connection
      const hasBadConnection = this.$nuxt.isOffline || (conn && ((conn.effectiveType || '').includes('2g') || conn.saveData))
    
      return !hasBadConnection
    }
    

    Nuxt App 在åˆå§‹åŒ–後會將自己的實例掛到 Vue.prototype.$nuxt 上,這裡的 this.$nuxt.isOffline å–得了當下的網路連線狀態,如果是離線狀態的話為 true。
    å†ä¾†å°±çœ‹åˆ°äº† navigator.connection 的部分,如果ç€è¦½å™¨æ”¯æ´ï¼Œå‰‡åŽ»æª¢æŸ¥ effectiveType 是å¦å«æœ‰ 2g 的字眼,以åŠç¢ºèª saveData çœ‹çœ‹ä½¿ç”¨è€…æœ‰æ²’æœ‰é–‹å•Ÿç¯€çœæµé‡çš„設定。
    如果其中一邊為 true 則表示ç¾åœ¨ä¸é©åˆé å–(最後回傳的是 !hasBadConnection),é‡åˆ°ä¸æ”¯æ´ navigator.connection 或 effectiveType è·Ÿ saveData 這兩個屬性的ç€è¦½å™¨ï¼Œå‰‡åªæœƒä¾ç…§æ˜¯å¦ç‚ºé›¢ç·šç‹€æ…‹æ±ºå®šè¦ä¸è¦ prefetch。
    FireFox 79.0 ç›®å‰é›–ç„¶å¯ä»¥æ‰‹å‹•開啟 navigator.connection 的支æ´ï¼Œä½†ä¹Ÿä¾ç„¶ä¸æ”¯æ´ effectiveType è·Ÿ saveData 這兩個屬性。

å†ä¾†é€²å…¥ methods 以外的部分(唉?)

export default {
  name: 'NuxtLink',
  extends: Vue.component('RouterLink'),
  props: {
    prefetch: {
      type: Boolean,
      default: true
    },
    noPrefetch: {
      type: Boolean,
      default: false
    }
  },
  mounted () {
    if (this.prefetch && !this.noPrefetch) {
      this.handleId = requestIdleCallback(this.observe, { timeout: 2e3 })
    }
  },
  beforeDestroy () {
    cancelIdleCallback(this.handleId)

    if (this.__observed) {
      observer.unobserve(this.$el)
      delete this.$el.__prefetch
    }
  },
  methods: {
    // ...ç•¥...
  }
}

在這裡å¯ä»¥çœ‹åˆ° <nuxt-link> 實作繼承自 <router-link> 並增加了兩個 props:prefetch è·Ÿ noPrefetch。

在組件掛載到畫é¢ä¸Šå¾Œåˆ¤æ–·ä½¿ç”¨è€…æ˜¯å¦æƒ³è¦ prefetch 該組件的路由,並利用 requestIdleCallback() å°‡ this.observe 推é²åˆ°æ¯ä¸€å¹€ä¹‹é–“的空檔處ç†ï¼Œè¶…時強制執行時間設定為 2000 ms。

é é¢åœ¨åˆå§‹åŒ–時ç€è¦½å™¨æœ‰ç›¸å°ç¹é‡çš„組件åˆå§‹åŒ–è¦åŸ·è¡Œï¼Œæ“ åœ¨é€™å€‹æ™‚候將這些想è¦è§€å¯Ÿçš„ DOM 塞給 observr å…¶å¯¦ç›¸å°æ²’å¿…è¦ï¼Œé€™ä»¶äº‹å¯ä»¥ç­‰ç€è¦½å™¨æœ‰ç©ºçš„æ™‚候在處ç†ï¼Œå› æ­¤æœ‰äº†é€™æ¨£çš„設計。

最後組件銷毀時,清除 requestIdleCallback,如果 DOM 已經交由 observer è§€å¯Ÿï¼Œä¾¿å–æ¶ˆè§€å¯Ÿï¼Œä¸¦ä¸”å°‡ DOM 上的 __prefetch() æ–¹æ³•çµ¦åˆªé™¤ã€‚æ•´å€‹çµ„ä»¶è¨­è¨ˆæœ‰å…©å€‹åœ°æ–¹æœƒå–æ¶ˆ observer å° DOM 的觀察,其一是開始 prefetch å‰ï¼Œé€™è£¡å‰‡æ˜¯ç•¶çµ„ä»¶è¦è¢«éŠ·æ¯€æ™‚ï¼Œæœ‰å¯èƒ½è©² DOM 還沒有進到畫é¢ï¼Œé€™æ™‚å°±è¦åœ¨é€™è£¡å–消觀察,以å…資æºå ç”¨åœ¨ä¸€å€‹ä¸å­˜åœ¨çš„ DOM 上é¢ã€‚

以上就是 <nuxt-link> 大部分實作的內容。

æ•´åˆä»¥ä¸Šå…§å®¹ï¼Œå®Œæ•´çš„組件 JavaScript 如下:

// nuxt-link.client.js

import Vue from 'vue'

const requestIdleCallback = window.requestIdleCallback ||
  function (cb) {
    const start = Date.now()
    return setTimeout(function () {
      cb({
        didTimeout: false<D»~span class="sjzpn">,
        timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
      })
    }, 1)
  }

const cancelIdleCallback = window.cancelIdleCallback || function (id) {
  clearTimeout(id)
}

const observer = window.IntersectionObserver && new window.IntersectionObserver((entries) => {
  entries.forEach(({ intersectionRatio, target: link }) => {
    if (intersectionRatio <= 0) {
      return
    }
    link.__prefetch()
  })
})

export default {
  name: 'NuxtLink',
  extends: Vue.component('RouterLink'),
  props: {
    prefetch: {
      type: Boolean,
      default: true
    },
    noPrefetch: {
      type: Boolean,
      default: false
    }
  },
  mounted () {
    if (this.prefetch && !this.noPrefetch) {
      this.handleId = requestIdleCallback(this.observe, { timeout: 2e3 })
    }
  },
  beforeDestroy () {
    cancelIdleCallback(this.handleId)

    if (this.__observed) {
      observer.unobserve(this.$el)
      delete this.$el.__prefetch
    }
  },
  methods: {
    observe () {
      // If no IntersectionObserver, avoid prefetching
      if (!observer) {
        return
      }
      // Add to observer
      if (this.shouldPrefetch()) {
        this.$el.__prefetch = this.prefetchLink.bind(this)
        observer.observe(this.$el)
        this.__observed = true
      }
    },
    shouldPrefetch () {
      return this.getPrefetchComponents().length > 0
    },
    canPrefetch () {
      const conn = navigator.connection
      const hasBadConnection = this.$nuxt.isOffline || (conn && ((conn.effectiveType || '').includes('2g') || conn.saveData))

      return !hasBadConnection
    },
    getPrefetchComponents () {
      const ref = this.$router.resolve(this.to, this.$route, this.append)
      const Components = ref.resolved.matched.map(r => r.components.default)

      return Components.filter(Component => typeof Component === 'function' && !Component.options && !Component.__prefetched)
    },
    prefetchLink () {
      if (!this.canPrefetch()) {
        return
      }
      // Stop observing this link (in case of internet connection changes)
      observer.unobserve(this.$el)
      const Components = this.getPrefetchComponents()

      for (const Component of Components) {
        const componentOrPromise = Component()
        if (componentOrPromise instanceof Promise) {
          componentOrPromise.catch(() => {})
        }
        Component.__prefetched = true
      }
    }
  }
}

以上 JavaScript å¯ä»¥åœ¨é–‹ç™¼ä¸­çš„ Nuxt.js 專案中找到,ä½ç½®æ–¼æ ¹ç›®éŒ„çš„ .nuxt/components/nuxt-link.client.js 中。

最一開始有æåˆ°ï¼Œä¾ç…§ nuxt.config.js 設定的ä¸åŒï¼Œæœƒæœ‰äº›è¨±çš„å·®ç•°ï¼Œåƒæ˜¯å¦‚果在這裡將 <nuxt-link> çš„ prefetch 機制給關閉,這裡所產出的 props.prefetch.default 就會是 false。

詳細內容會放在最後的åƒè€ƒé€£çµè£¡é¢ã€‚


在 Vue CLI 專案應用 Smart Prefetching

看完以上內容除了更了解 Nuxt.js 冰山一å°å°è§’的實作外,é‡åˆ°ä½¿ç”¨ Vue CLI 建立的專案也å¯ä»¥å°‡ <nuxt-link> çš„ Smart Prefetching 功能應用在其中。ä¸éŽé‚„是有幾點è¦ç‰¹åˆ¥æ³¨æ„:

  1. Vue Router 裡的 component 設定必須採用動態載入的方å¼ï¼Œä¸ç„¶å…¶å¯¦ä¹Ÿä¸éœ€è¦ prfetch。
const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import('../views/Home.vue'),
    },
    {
      path: '/about',
      name: 'About',
      component: () => import('../views/About.vue'),
    },
    {
      path: '/news',
      name: 'News',
      component: () => import('../views/News.vue'),
    }
  ],
});
  1. 原本 getPrefetchComponents () 內需è¦åˆ¤æ–· Component.options 是因為在 Nuxt.js 中如果是開啟éŽçš„é é¢çµ„件,在解æžåŒ¹é…çš„é é¢çµ„件時å–得的會是該組件的 constructor,但在 Vue CLI 環境中å–的的會直接是 Component.options,型別為物件。

也因此在判斷是å¦è©² prefetch 時,å¯ä»¥åž‹åˆ¥æ˜¯å¦ç‚º function 或是有無 __prefetched 標記å³å¯ã€‚

  1. getPrefetchComponents () 中的 this.$nuxt.isOffline 記得移除,或自己å¦å¤–實作。
  2. Vue CLI é è¨­å°±æœ‰å°å‹•態載入的組件進行 prefetch 的機制

<link rel="prefetch"> 是一種 resource hint,他å¯ä»¥åˆ©ç”¨ç€è¦½å™¨ç©ºé–’的時間去é å–未來å¯èƒ½æœƒéœ€è¦çš„內容。

Vue CLI é è¨­æœƒé‡å°æ‰€æœ‰ async chunk(動態載入,Dynamic Imports) 的組件自動產出 prefetch hint,所以其實ä¸ç”¨è‡ªå·±å‹•手處ç†ã€‚但è¬ä¸€å°ˆæ¡ˆè¦æ¨¡è¼ƒå¤§ï¼Œé€™æ™‚ " 所有 " 都 prefetch é‚„æ˜¯æœ‰é»žå¯æ€•,這個部分 Vue 官方也建議將此設定關閉,改用手動的方å¼è™•ç†ã€‚

手動關閉 Vue CLI 的 prefetch hint

// vue.config.js
module.exports = {
  chainWebpack: config => {
    // 移除 prefetch plugin
    config.plugins.delete('prefetch')
  }
}

關閉 prefetch hint 後å¯ä»¥é¸æ“‡ä¸€å€‹å€‹æ‰‹å‹•設定,或是åƒè€ƒ <nuxt-link> çš„ Smart Prefetching 設計囉ï¼


çµèªž

雖然篇幅有點長,但整體而言 <nuxt-link> 的實作方å¼ä¸¦æ²’有éžå¸¸å›°é›£ã€‚

除了本篇的 <nuxt-link> 以外,之å‰åœ¨ 「Intersection Observer API 使用筆記〠中也有æåˆ° Gridsome çš„ <g-link> 組件也使用了這個 API 實作了 prefetch 的功能。å¦å¤–在 React 體系的 Next.js å’Œ Gatsby.js 分別在他們的 Link 組件中也使用了類似的手法去é å–組åŠéœ€è¦çš„資æºã€‚

除了å„大框架實作外,新版的 Facebook 在實作上則是採用 hover event è·Ÿ mouse event 去處ç†ä»–們的 prefetch 機制,更智能的é å–真正å¯èƒ½éœ€è¦çš„資æºã€‚ä¸éŽä¹Ÿå¯èƒ½å› ç‚º Facebook çš„æµé‡è¶…å¤§ï¼ŒåŒæ™‚出ç¾åœ¨ç•«é¢ä¸Šçš„è³‡æºæ›´ç‚ºç¹é‡ï¼Œæ‰€ä»¥éœ€è¦æ›´ç‚ºç²¾æº–çš„ prefetch 手段。一般情形下使用 Intersection Observer API åˆ¤æ–·éœ€è¦ prefetch çš„è³‡æºæ‡‰è©²å°±å·²ç¶“éžå¸¸è¶³å¤ äº†ã€‚

如果工作中尚未導入å‰ç«¯æ¡†æž¶çš„æ©Ÿåˆ¶ï¼Œä¹Ÿå¯ä»¥è€ƒæ…®ä½¿ä¾†è‡ª Google Chrome Labs çš„ quicklink.js,在 Nuxt.js 的官方文件中也æåˆ°ï¼Œ<nuxt-link> çš„ Smart Prefetching è¨­è¨ˆéˆæ„Ÿä¾†è‡ªæ–¼ä»–。

åƒè€ƒé€£çµ

è«‹æˆ‘å–æ¯å’–å•¡

如果這裡的內容有幫助到你的話,一æ¯å’–å•¡å°±æ˜¯å°æˆ‘最大的鼓勵。

è«‹æˆ‘å–æ¯å’–å•¡
ÿÿÿÿ