iT邦幫忙

2023 iThome 鐵人賽

DAY 17
0
Vue.js

Nuxt 3 實戰筆記系列 第 17

[Day 17] Nuxt 3 頁面載入效果 - 使用 NuxtLoadingIndicator 元件與實作自訂 Loading 元件

  • 分享至 

  • xImage
  •  

前言

當路由頁面跳轉時,可能會因為下一個頁面阻塞而導致畫面卡住,使用者無法得知頁面的載入狀況,Nuxt 3 提供一個 <NuxtLoadingIndicator> 元件,用作頁面導航後在頁面上方顯示載入的進度條 (Progress bar),提供給使用者一個頁面正在讀取中的感覺,除了使用官方的元件,我們也可以自己來實作自訂 Loading 元件。

頁面載入進度內建元件

我們可以在專案的 app.vue 或布局中添加 <NuxtLoadingIndicator> 元件,例如下面程式碼:

<template>
  <div>
    <NuxtLoadingIndicator />
    <NuxtPage />
  </div>
</template>

NuxtLoadingIndicator 元件可傳入的屬性如下:

  • color: 進度條的顏色,可以傳入CSS 支援的 色碼repeating-linear-gradient() 函数,預設為 repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%),傳入 false 表示禁用顏色樣式。
  • height: 進度條的高度數值,單位: px,預設值為 3
  • duration: 進度條載入的持續時間,單位: 毫秒,預設值為 2000
  • throttle: 進度條的隱藏與顯示的節流時間,單位: 毫秒,預設值為 200,表示只有在頁面轉換超過 200 毫秒時才會顯示。

為了展示方便,實作時可以將 throttle 屬性節流時間設定為 0,意即 ,當設定完成後,我們只要觸發頁面路由的切換,網頁上方就會出現一個進度條,表示頁面正在加載中,直至頁面完全載入完畢後消失。

https://i.imgur.com/sofkl2P.gif

Loading 元件的顯示方式的設計

當頁面在跳轉載入中時,使用者除了能看見載入中的圖示回饋外,我們可能也希望使用者不要再進行其他的操作,例如再次點擊其他連結或與網頁互動,可能會導致請求異常或頁面錯亂等。

為了解決跳轉時不在允許使用者互動,所以有一派的做法是使用一個遮罩 (Mask) 來覆蓋整個可視區域,再將 Loading 圖示放在上層,如此一來整個畫面就會因為遮罩無法進行互動,也讓網頁重點變成在呈現載入中的狀態,這種做法通常也比較暴力,因為使用者除了等待,並無法再繼續其他操作。

為了讓使用者不再無盡的等待或者因為全螢幕的載入狀態可能產生焦慮,Nuxt 3 所提供的 <NuxtLoadingIndicator> 元件,正是一種解套方案,它僅在頁面的最上方出現一條載入進度條,而且沒有全螢幕的遮擋,類似的做法也有僅在畫面左上角或底部等位置實作出讀取中的圈圈或載入進度,其目的都是為了不完全遮擋畫面,而且能有效的顯示正在載入中的效果,使用者如果發現頁面真的載入太久,可以再次點擊其他畫面元素進行互動或跳轉至其他頁面。

實作自訂 Loading 元件

如果你不喜歡 Nuxt 3 提供的 Loading 元件,你也可以安裝其他套件或自己實作,實作的方式也非常的簡單,只要照著下面的步驟來完成,很輕易的就能客製化 Loading 元件,接下來我們就來教大家如何實作全螢幕遮罩的 Loading,只要學會了基本上頁面的 Loading 都可以根據這個概念流程來實作,至於要以什麼樣子呈現就各憑本事了。

設計載入的效果

通常載入的效果會是一個轉場,例如進度條的長度變化,或是以動畫來無限的重複一個載入或讀取中的感覺。

我們可以在 UIverse.io 挑選一些開源的 UI 元素,其中包含了不少的載入元件 (Loaders),你可以在裡面稍微挑一些喜歡的元件再來客製化,這裡的元件多數以原生的 CSS 或 Tailwind CSS 撰寫,在調整上也非常的方便。

最後我挑了由 mobinkakei 製作的元件,從中挑選了一個三角形類似 Nuxt Logo 的小山,作為我們的範例元件。

https://i.imgur.com/UfC2kmt.gif

建立 Loading 元件檔案

在 Nuxt 專案目錄下建立 ./components/CustomLoading.vue 檔案,作為 Loading 元件,將剛才挑選的樣式與元件貼上或依據設計稿刻出來。

<template>
  <div class="loader">
    <svg viewBox="0 0 86 80">
      <polygon points="43 8 79 72 7 72" />
    </svg>
  </div>
</template>

<style scoped>
.loader-wrap {
  position: fixed;
  width: 100vw;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  top: 0;
  left: 0;
  z-index: 999999;
  background-color: rgb(0 0 0 / 0.35);
  backdrop-filter: blur(4px);
  transition-property: background-color, visibility, opacity, scale;
  transition-duration: .2s;
}

.loader {
  --path: #2f3545;
  --dot: #00bd6f;
  --duration: 1.5s;
  width: 48px;
  height: 44px;
  position: relative;
}

.loader:before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  position: absolute;
  display: block;
  background: var(--dot);
  top: 37px;
  left: 21px;
  transform: translate(-10px, -18px);
  animation: dotTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}

.loader svg {
  display: block;
  width: 100%;
  height: 100%;
}

.loader svg polygon {
  fill: none;
  stroke: var(--path);
  stroke-width: 10px;
  stroke-linejoin: round;
  stroke-linecap: round;
  stroke-dasharray: 145 76 145 76;
  stroke-dashoffset: 0;
  animation: pathTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}

@keyframes pathTriangle {
  33% {
    stroke-dashoffset: 74;
  }

  66% {
    stroke-dashoffset: 147;
  }

  100% {
    stroke-dashoffset: 221;
  }
}

@keyframes dotTriangle {
  33% {
    transform: translate(0, 0);
  }

  66% {
    transform: translate(10px, -18px);
  }

  100% {
    transform: translate(-10px, -18px);
  }
}
</style>

這邊稍微調整了一下顏色與持續時間,當你載入這個元件時顯示的效果如下圖。

https://i.imgur.com/xUfJkaX.gif

因為我們要建立的是全螢幕 Loading,我們可以在 Loading 元件外面包裝一層類別樣式 loader-wrap。

<template>
  <div class="loader-wrap">
    <div class="loader">
      <svg viewBox="0 0 86 80">
        <polygon points="43 8 79 72 7 72" />
      </svg>
    </div>
  </div>
</template>

為這個類別樣式設定遮罩給予背景顏色與模糊的效果。

.loader-wrap {
  position: fixed;
  width: 100vw;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  top: 0;
  left: 0;
  z-index: 999999;
  background-color: rgb(0 0 0 / 0.35);
  backdrop-filter: blur(4px);
  transition-property: background-color, visibility, opacity, scale;
  transition-duration: .2s;
}

添加控制顯示的狀態

建立一個 isLoading 的響應式狀態,以此狀態控制元件的樣式,當 isLoading 為真時顯示反之則隱藏,並實作出幾個函式來呼叫控制。

建立 isLoading 響應式狀態,實作 show 及 hide 函式來控制狀態,建議可以設定 process.client 僅允許在客戶端進行狀態的改變。

<script setup>
const isLoading = ref(false)

function show() {
	if (process.client) {
	  isLoading.value = true
	}
}

function hide() {
	if (process.client) {
	  isLoading.value = false
	}
}
</script>

添加 :class="isLoading ? 'visible' : 'hidden'" 至元件上。

<template>
  <div class="loader-wrap" :class="isLoading ? 'loader-visible' : 'loader-hidden'">
    <div class="loader">
      <svg viewBox="0 0 86 80">
        <polygon points="43 8 79 72 7 72" />
      </svg>
    </div>
  </div>
</template>

建立類別樣式 loader-visibleloader-hidden,來實現顯示與隱藏。

.loader-wrap.loader-visible {
  visibility: visible;
  opacity: 1;
  scale: 1.5;
}

.loader-wrap.loader-hidden {
  visibility: hidden;
  opacity: 0;
  scale: 1;
}

添加頁面中

我們可以在專案的 app.vue 或布局中添加 CustomLoading 元件,例如下面程式碼:

<template>
  <div>
    <CustomLoading />
    <NuxtPage />
  </div>
</template>

接下來只要控制 CustomLoading 元件內的 Loading 顯示與隱藏就可以囉。

建立 Loading 顯示的時機

比較簡單的做法可以使用 Nuxt 提供的 hook,分別是 page:startpage:finish 來控制顯示,當路由觸發後 頁面準備載入 (hook: page:start) 則讓 Loading 顯示,如果頁面載入完成 (hook: page:finish) 則隱藏 Loading。

const nuxtApp = useNuxtApp()
const unsubPageStart = nuxtApp.hook('page:start', show)
const unsubPageFinish = nuxtApp.hook('page:finish', hide)

onBeforeUnmount(() => {
  unsubPageStart()
  unsubPageFinish()
})

如此一來最基本的頁面跳轉載入效果就完成了,你可以試試看路由切換時是否會有效果。

控制 Loading 持續時間

如果你的頁面幾乎沒有需要等待的請求,那麼你可能也不會看到 Loading 的效果,若你想無論如何一定要展示一下 Loading 那麼你可以在 hide 函式中設定一個定時器,讓時間到後才隱藏效果。

舉例來說,添加一個 hold 的 props,預設數值為 400,並在 hide 函式包裝一個 Timeout 時間為 hold 來將載入效果隱藏,表示 Loading 效果至少持續 400 毫秒。

const props = defineProps({
  hold: {
    type: Number,
    default: 400,
  },
})

function hide() {
	if (process.client) {
	  setTimeout(() => {
	    isLoading.value = false
	  }, props.hold)
	}
}

添加節流時間屬性

實務上來說,頁面的跳轉如果不大需要等待時間,那麼大可不必顯示 Loading 的效果,因為就算出現了但只持續了幾毫秒,畫面出現效果所造成的閃爍,反而造成使用者體驗不佳

為此,我們可以參考官方 <NuxtLoadingIndicator> 元件原始碼,來實作一個節流時間的屬性 throttl,其目的就是為了讓能快速展示頁面可以不在顯示 Loading 效果。

定義 throttl props 屬性。

const props = defineProps({
  throttle: {
    type: Number,
    default: 200,
  },
  hold: {
    type: Number,
    default: 400,
  },
})

添加定時器變數,用來存放可以被清除的定時器,這個 clear 函式的做用在於防止定時器依然故我的執行。

let _throttleTimer = null

function clear() {
  clearTimeout(_throttleTimer)
  _throttleTimer = null
}

調整 show 函式,首先一定要先清除定時器,防止重複設定導致顯示異常,判斷傳入的 throttle 是否大於 0,並以此數值建立一個 Timeout 來顯示載入效果,意思就是如果 Timeout 觸發了,定時器還沒被清除,那麼就代表載入的時間已經超過這個節流時間的數值,應該顯示載入了。

function show() {
  clear()
	if (process.client) {
	  if (props.throttle > 0) {
	    _throttleTimer = setTimeout(() => {
	      isLoading.value = true
	    }, props.throttle)
	  } else {
	    isLoading.value = true
	  }
  }
}

當頁面載入完成,我們也要記得清除 throttle 的定時器,以面已經載入完成,定時器觸發後又開始顯示載入。

function hide() {
  clear()
  if (process.client) {
    setTimeout(() => {
      isLoading.value = false
    }, props.hold)
  }
}

更多的觸發時機

我們顯示 Loading 時機是建立在頁面開始載入的時候,但有些等待的情況是會發生在頁面開始載入之前,甚至是發生錯誤的時候載入效果應該持續嗎?

路由中間件觸發

路由中間件的執行會發生在 A 頁面前往 B 頁面之間,也就是說 B 頁面開始載入之前,是會先經過路由中間件,如果路由中間件的處理時間比較長,那麼我們就算 hook page:start 顯示載入效果,可能會有一點小問題,比較推薦的做法是,我們可以添加一個全域的路由中間件,確保頁面切換時一定會經過這個中間件,並設定顯示載入效果。

實作的方式如下:

import { globalMiddleware } from '#build/middleware'

globalMiddleware.unshift(show)

我們將 show 函式,添加至全域的中間件,而且確保是在第一個執行,這樣就能在頁面切換時觸發中間件 show 來顯示載入效果,後續不論中間件或頁面的處理時間,就不會影響我們太晚顯示可能。

當我們確保顯示的時機是在全域中間件,也就不再需要 hook page:start 來觸發顯示了,可以移除相關程式碼

// const unsubPageStart = nuxtApp.hook('page:start', show)

onBeforeUnmount(() => {
  // unsubPageStart()
  // ...
})

最後記得,取消掛載時,也記得要把中間件 show 移除:

function unsubRouterBeforeMiddleware() {
  globalMiddleware.splice(globalMiddleware.indexOf(show), 1)
}

onBeforeUnmount(() => {
  unsubRouterBeforeMiddleware()
  // ...
})

路由導航失敗或發生異常錯誤

當路由導航失敗甚至在發生程式異常錯誤時,我們也應該將載入效果進行隱藏,我們可以使用 Vue Router 的 hook: onError, afterEach 及 Nuxt 的 hook: vue:error 來添加隱藏的時機。

const unsubError = nuxtApp.hook('vue:error', hide)

onBeforeUnmount(() => {
  // ...
  unsubError()
})
const router = useRouter()

router.onError(() => {
  hide()
})

router.afterEach((_to, _from, failure) => {
  if (failure)
    hide()
})

相同路由路經或路由頁面元件

官方的 <NuxtLoadingIndicator> 元件有額外添加一個 Vue Router 的 hook beforeResolve,用來判斷來源與目頁面,若是相同的路由路徑或路由頁面元件則隱藏載入效果,你也可以視情況添加至元件中。

router.beforeResolve((to, from) => {
  if (to === from || to.matched.every((comp, index) => comp.components && comp.components?.default === from.matched[index]?.components?.default))
    hide()
})

完整的 CustomLoading 元件程式碼

下面程式碼,可以直接建立 ./components/CustomLoading.vue 並提供給 App.vue 或布局中使用。

<template>
  <div class="loader-wrap" :class="isLoading ? 'loader-visible' : 'loader-hidden'">
    <div class="loader">
      <svg viewBox="0 0 86 80">
        <polygon points="43 8 79 72 7 72" />
      </svg>
    </div>
  </div>
</template>

<script setup>
import { globalMiddleware } from '#build/middleware'

const props = defineProps({
  throttle: {
    type: Number,
    default: 200,
  },
  hold: {
    type: Number,
    default: 400,
  },
})

const isLoading = ref(false)

let _throttleTimer = null

function clear() {
  clearTimeout(_throttleTimer)
  _throttleTimer = null
}

function show() {
  clear()
  if (process.client) {
    if (props.throttle > 0) {
      _throttleTimer = setTimeout(() => {
        isLoading.value = true
      }, props.throttle)
    } else {
      isLoading.value = true
    }
  }
}

function hide() {
  clear()
  if (process.client) {
    setTimeout(() => {
      isLoading.value = false
    }, props.hold)
  }
}

globalMiddleware.unshift(show)
function unsubRouterBeforeMiddleware() {
  globalMiddleware.splice(globalMiddleware.indexOf(show, 1))
}

const nuxtApp = useNuxtApp()
const unsubPageFinish = nuxtApp.hook('page:finish', hide)
const unsubError = nuxtApp.hook('vue:error', hide)

onBeforeUnmount(() => {
  unsubRouterBeforeMiddleware()
  unsubPageFinish()
  unsubError()
})

const router = useRouter()

router.onError(() => {
  hide()
})

router.beforeResolve((to, from) => {
  if (to === from || to.matched.every((comp, index) => comp.components && comp.components?.default === from.matched[index]?.components?.default))
    hide()
})

router.afterEach((_to, _from, failure) => {
  if (failure)
    hide()
})
</script>

<style scoped>
.loader-wrap {
  position: fixed;
  width: 100vw;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  top: 0;
  left: 0;
  z-index: 999999;
  background-color: rgb(0 0 0 / 0.35);
  backdrop-filter: blur(4px);
  transition-property: background-color, visibility, opacity, scale;
  transition-duration: .2s;
}

.loader-wrap.loader-visible {
  visibility: visible;
  opacity: 1;
  scale: 1.5;
}

.loader-wrap.loader-hidden {
  visibility: hidden;
  opacity: 0;
  scale: 1;
}

.loader {
  --path: #2f3545;
  --dot: #00bd6f;
  --duration: 1.5s;
  width: 48px;
  height: 44px;
  position: relative;
}

.loader:before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  position: absolute;
  display: block;
  background: var(--dot);
  top: 37px;
  left: 21px;
  transform: translate(-10px, -18px);
  animation: dotTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}

.loader svg {
  display: block;
  width: 100%;
  height: 100%;
}

.loader svg polygon {
  fill: none;
  stroke: var(--path);
  stroke-width: 10px;
  stroke-linejoin: round;
  stroke-linecap: round;
  stroke-dasharray: 145 76 145 76;
  stroke-dashoffset: 0;
  animation: pathTriangle var(--duration) cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
}

@keyframes pathTriangle {
  33% {
    stroke-dashoffset: 74;
  }

  66% {
    stroke-dashoffset: 147;
  }

  100% {
    stroke-dashoffset: 221;
  }
}

@keyframes dotTriangle {
  33% {
    transform: translate(0, 0);
  }

  66% {
    transform: translate(10px, -18px);
  }

  100% {
    transform: translate(-10px, -18px);
  }
}
</style>

實際套用的效果如下:

https://i.imgur.com/J0A1aY9.gif

小結

路由頁面切換時的載入效果有許多實作的方式,只要掌握顯示與隱藏的時機,就能提供使用者更好的體驗,當然整頁遮罩這種暴力的載入效果,個人也是不大推薦,除非有特殊需求,不然官方提供的 <NuxtLoadingIndicator> 元件或實作上注意遮擋的範圍,使用者體驗肯定會好上不少。

除了頁面的載入效果外,現在也有許多網站會實作 Skeleton loading (骨架載入效果),這種效果可以幫助頁面載入後,如過還有非同步請求尚未回傳,可以先繪製一個大致的框架樣式,載入完成後才顯示完整的圖片、文字等等,這種載入效果也讓等待體驗推進一個層次。


感謝大家的閱讀,歡迎大家給予建議與討論,也請各位大大鞭小力一些:)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。

參考資料


上一篇
[Day 16] Nuxt 3 最佳管理 Meta Tag 的方式 - 使用 useSeoMeta 與 useServerSeoMeta
下一篇
[Day 18] Nuxt 3 自動產生連結縮圖 OG Image - SEO 搜尋引擎最佳化系列
系列文
Nuxt 3 實戰筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Dylan
iT邦新手 3 級 ‧ 2023-10-29 18:02:34

這段 code 的數字是不是放錯位置了:
globalMiddleware.splice(globalMiddleware.indexOf(show, 1))
應該是:
globalMiddleware.splice(globalMiddleware.indexOf(show), 1)

Ryan iT邦新手 2 級 ‧ 2023-10-30 14:24:38 檢舉

對,手打打錯了 QAQ

已修正,感謝您

我要留言

立即登入留言