Vue Custom Plugin 範例

前端開發過程多多少少都會碰到要自己包裝 library 作為 plugin, 這裡直接用既有的 code 來說明感覺比較快。 包裝的東西是 google 登入按鈕。

如果覺得有更好的做法歡迎直接發 PR 修改這邊文章。

上原始的 code,註明我覺得不妥。

// GoogleLoginPlugin.ts
import { ref, watch } from 'vue'
import { useAccountStore } from '@/stores'

const isLoaded = ref(false)
// 這裡的 enum,可以不必要,而且使用範圍都只在這份檔案裡,不必是 string enum。
enum GoogleSigninStatus {
  init = 'init',
  success = 'success',
  failed = 'failed',
}

const googleSigninResult = ref<keyof typeof GoogleSigninStatus>('init')

function loadScript() {
  const head = document.head
  const script = document.createElement('script')
  // 習慣上會把 script.src 放到最後,在這裡應該是沒有關係,因為 <script> 被 append 到 head 是放在最後,理論上 <script> 被 append 到 dom 上時才會開始執行,但萬一是 script.src 被宣告後立即執行,那 async/defer 屬性可能就會吃不到?
  script.src = 'https://accounts.google.com/gsi/client'
  script.async = true
  script.defer = true

  script.onload = () => {
    isLoaded.value = true
    // 混雜了 pinia,應該藉由 argument 傳進來
    const accountStore = useAccountStore()
    google.accounts.id.initialize({
      // 理想的話 client_id, callback 都應該由外部傳進來
      client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
      callback: res => {
        googleSigninResult.value = GoogleSigninStatus['init']
        accountStore
          .handleGoogleCredentialResponse(res)
          .then(() => (googleSigninResult.value = GoogleSigninStatus['success']))
          .catch(() => (googleSigninResult.value = GoogleSigninStatus['failed']))
      },
    })
  }

  script.onerror = () => {
    alert('google library error')
  }

  head.appendChild(script)
}

// 感覺 watch 應該拿出來包裝 renderGoogleButton,而不是包裝在 renderGoogleButton 裏面
function renderGoogleButton(buttonDiv: HTMLElement) {
  // 如果要做的事情僅是 render,那完成後應該要 unwatch,這個 plugin 是在 login 頁面使用比較沒關係,因為一旦登入後頁面進行跳轉,這裡的 watch 會被自動抹消,但如果UI設計上是 button 會一直存在,就會有一個無用的 watch 一直長存。
  watch(
    isLoaded,
    () => {
      if (isLoaded.value) {
        window.google.accounts.id.renderButton(buttonDiv, {
          type: 'standard',
          theme: 'outline',
          size: 'large',
          width: '264',
          text: 'signin_with',
          locale: 'en-US',
        })
      }
    },
    { immediate: true }
  )
}

const create = (buttonDiv: HTMLElement) => {
  renderGoogleButton(buttonDiv)
  return new Promise((resolve, reject) => {
    // 用 promist 包裝 watch,很酷,沒想過可以這樣操作
    // 感覺這裡的 watch 不必要,而且做的事情是接續 callback,被拆成兩個部分,應該整個從外部傳進來就好
    watch(googleSigninResult, result => {
      return googleSigninResult.value === GoogleSigninStatus['success']
        ? resolve('login success')
        : reject('login failed')
    })
  })
}

export default {
  install() {
    loadScript()
  },
  create,
}

// 使用這個 plugin 的邏輯必須散落在兩個地方
// 使用1:必須先在 main.ts 安裝

import { googlePlugin } from './plugin'

createApp(App)
  .use(googlePlugin)
  .mount('#app')

// 使用2: 之後才在 component 引用
<script lang="ts" setup>
import { onMounted } from 'vue'
import google from '@/plugin/google'

onMounted(() => {
  const googleBtn = document.getElementById('buttonDiv')
  google
    .create(googleBtn as HTMLElement)
    .then(() => emit('success'))
    .catch(() => emit('failed'))
})

const emit = defineEmits<{ (e: 'success'): void; (e: 'failed'): void }>()
</script>

<template>
  <div id="buttonDiv"></div>
</template>


改成

import { watch } from 'vue'

let isLoaded = ref(false)
// 定義一個專屬的 script.id
const googleSDKid = 'google-account-script'
// params 型別可以去從 @types/google.accounts 找出來用
function loadScript(SigninCallback: (res: google.accounts.id.CredentialResponse) => void) {
  const head = document.head
  const script = document.createElement('script')
  script.id = googleSDKid
  script.async = true
  script.defer = true
  script.onload = () => {
    isLoaded.value = true
    google.accounts.id.initialize({
      client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
      callback: res => SigninCallback(res),
    })
  }
  script.onerror = () => alert('google library error')
  script.src = 'https://accounts.google.com/gsi/client'


  head.appendChild(script)
}

// 去除掉 watch
function renderGoogleButton(buttonDiv: HTMLElement) {
  window.google.accounts.id.renderButton(buttonDiv, {
    type: 'standard',
    theme: 'outline',
    size: 'large',
    width: '264',
    text: 'signin_with',
    locale: 'en-US',
  })
}

// 從 loadScript 拿型別出來用
// 還是保留可以在 main.ts 先安裝 plugin 的作法,但加入錯誤訊息提醒使用這個 plugin 的人類
function create(buttonDiv: HTMLElement, SigninCallback?: Parameters<typeof loadScript>[0]) {
  if (!document.getElementById(googleSDKid)) {
    if (!SigninCallback) {
      throw new Error("If didn't install this plugin in entry point, please approve 'callbackAfterInit'.")
    } else loadScript(SigninCallback)
  }

  // 如果 script 已經載入了就直接 render button,如果沒有才用 watch,並且避免時間差所以用 immediate 屬性
  if (!isLoaded.value) {
    const unwatch = watch(
      isLoaded,
      v => {
        if (!v) return
        renderGoogleButton(buttonDiv)
        unwatch()
      },
      { immediate: true }
    )
  } else renderGoogleButton(buttonDiv)
}

export const GoogleLoginPlugin = {
  create,
  install: (_app: App, callback: Parameters<typeof loadScript>[0]) => loadScript(callback),
}

// 使用上就不必在 main.ts 先安裝一次,相關的程式碼會比較集中

<script lang="ts" setup>
import { GoogleLoginPlugin } from '@lubn/shared/plugin'
import { useAccountStore } from '@/stores'

const emit = defineEmits<{ (e: 'success'): void; (e: 'failed'): void }>()

const accountStore = useAccountStore()

// onMounted 雖然也可以包裝進去 plugin.ts 裏面,但他屬用 component life cycle hook,
// 不像 watch,在上面的使用上只關注在資料狀態,所以在 component 內使用會比較恰當
onMounted(() => {
  const googleBtn = document.getElementById('buttonDiv')
  GoogleLoginPlugin.create(googleBtn as HTMLElement, res => {
    // callback 的邏輯在這裡傳進去給 plugin
    accountStore
      .handleGoogleCredentialResponse(res)
      .then(() => emit('success'))
      .catch(() => emit('failed'))
  })
})
</script>

<template>
  <div id="buttonDiv"></div>
</template>

Last Updated:
Contributors: chris