淺談VueUse設計與實現

null仔發表於2022-03-15

前言

大家好,我是webfansplz.首先跟大家分享一個好訊息,我加入VueUse團隊啦,感謝@antfu的邀請,很開心成為團隊的一員.今天跟大家聊聊VueUse的設計與實現.

介紹

大家都知道Vue3引入了組合式API,大大提升了邏輯複用能力.VueUse基於組合式API實現了很多易用、實用且有趣的功能.比如:

useMagicKeys

useMagicKeys 監聽按鍵狀態,並提供了組合熱鍵的功能,非常的神奇和有趣.使用它,我們可以很容易的監聽我們使用CV大法的次數 :)

useScroll

useScroll 提供了一些響應式的狀態和值,比如滾動狀態、抵達狀態、滾動方向以及當前滾動位置.

useElementByPoint

useElementByPoint 用於實時獲取當前座標位置最頂層的元素,配合 useMouse,我們可以做一些有趣的互動和效果.

使用者體驗

使用者體驗

VueUse無論是面向使用者還是開發者都做到了很棒的使用者體驗.我們先來看看使用者體驗:

強型別支援

VueUse採用了TypeScript進行編寫並且帶有完整的TS文件,有良好的TS支援能力.

SSR支援

我們對SSR進行了友好的支援,它可以在服務端渲染場景工作的很好.

易用性

一些支援傳入配置選項的函式我們會為使用者提供一套常用的預設選項,這樣可以保證使用者在大多數應用 場景下並不需要過多的關注你的功能實現和細節.以 useScroll 為例:

<script setup lang="ts">
import { useScroll } from '@vueuse/core'

const el = ref<HTMLElement | null>()
// 只需傳入滾動元素就可以工作
const { x, y } = useScroll(el)
// 節流支援選項
const { x, y } = useScroll(el, { throttle: 200 })
</script>

useScroll 對一些有效能要求的開發者提供了節流選項.但是我們希望的是使用者有需求的時候才關注到有這個配置,因為當配置引數一多的時候,理解引數含義和配置其實是一種心智負擔.另外,通用預設配置其實也是開箱能力的一種體現 !

使用文件

使用文件我們提供了可互動的Demo和精簡的Usage,使用者可以通過把玩Demo進一步瞭解功能,也可以通過CV大法複製Usage很容易的就用上功能.真香 !

相容性

前面我們提到了Vue3引入了組合式API的概念,但是得益於composition-api外掛的實現,我們也能在Vue2專案使用組合式API.為了讓更多的使用者能夠使用VueUse,Anthony Fu 實現了vue-demi ,它通過判斷使用者安裝環境 (Vue2專案 引用composition-api外掛,Vue3專案引用官方包),這樣Vue2使用者也能用上VueUse啦,奈斯 !

開發者體驗

目錄結構

在基於Monorepo的基礎上,專案採用了扁平化目錄結構,便於開發者查詢相應的函式.

我們為每個函式的實現建立了一個獨立的資料夾,這樣開發者在修復Bug和新增功能的時候,只需要關注該資料夾下具體函式的實現,並不需要關注專案本身實現的細節,大大降低了上手的成本.Demo和文件的編寫也在該資料夾下完成,避免了上下反覆橫跳尋找目錄結構檔案的糟糕研發體驗.

貢獻指南

我們提供了非常詳細的貢獻指南幫助想要貢獻的開發者快速開始並且編寫了一些自動化指令碼幫助開發者避免一些手動的工作.

原子化CSS

專案使用原子化CSS作為CSS的編寫方案,我個人覺得原子化CSS可以幫助我們快速的編寫出演示Demo,並且每個函式的Demo獨立不耦合,不會產生抽象複用的心智負擔.

設計思想

可組合的函式

可組合的函式簡單來說就是函式間可以建立組合關係,舉個例子:

useScroll 的實現組合了三個函式,將一個個單一職責的函式組合形成另一個函式,達到邏輯複用的能力,我覺得這也便是組合式函式的魅力所在吧.當然,每個函式也都可以進行獨立使用,使用者可以根據自己的需要進行選擇.

開發者在處理功能函式的時候可以做到更好的關注點分離,比如處理 useScroll 時我們只需要關注滾動功能的實現,並不需要關注防抖節流及事件繫結內部的邏輯與實現.

建立"連結"

Anthony Fu 在 Vue Conf 2021中分享了這樣一個模式:

  • 建立輸入->輸出的連結
  • 輸出會自動根據輸入的改變而改變

我們在編寫可組合式函式的時候建立資料和邏輯的連結,這樣我們就不用關心如何更新資料,什麼時候更新.舉個例子:

<script setup lang="ts">
import { ref } from 'vue'
import { useDateFormat, useNow } from '@vueuse/core'

const now = useNow() // 返回一個ref值
const formatted = useDateFormat(now) // 將資料傳入與邏輯建立連結

</script>

// useDateFormat實現
function useDateFormat(date, formatStr = 'HH:mm:ss') {
  return computed(() => formatDate(normalizeDate(unref(date)), unref(formatStr)))
}

從上面這個例子中我們可以看出, useDateFormat 在內部邏輯中使用了計算屬性對輸入進行包裹,這樣我們就可以做到輸出自動根據輸入改變而改變,而使用者只需傳入一個響應式值,不需要關注具體更新邏輯.

儘可能使用ref替代reactive

refreactive有各自的優缺點,這裡主要從使用者角度談談我個人的看法 :

// reactive

function useScroll(element){
  const position = reactive({ x: 0, y: 0 });
  // impl...
  return { position,...}
}
// 解構丟失響應性
const { position } = useScroll(element)
// 使用者需手動toRefs保持響應性
const { x, y } = toRefs(position)

// ref

function useScroll(element){
  const x = ref(0);
  const y = ref(0);
  // impl...
  return {x,y,...}
}
// 不會丟失響應性,使用者可直接拿來渲染,watch..
const { x, y } = useScroll(element)

從上面這個例子中我們可以看到,如果我們使用reactive的話,使用者需要考慮解構會丟失響應性的問題,這也從一定程度上限制了使用者使用解構的自由度和降低了這個函式的易用性.

可能有的人會吐槽ref.value使用,其實在大多數情況下,我們可以通過一些技巧減少它的使用:

  • unref API
const x = ref(0)
console.log(unref(x)) // 0
  • 使用reactive解包ref
const x = ref(0)
const y = ref(0)
const position = reactive({x, y})
console.log(position.x, position.y) // 0 0
  • 還在實驗階段的Reactivity Transform
<script setup>
let count = $ref(0)
count++
</script>

使用options物件作為引數

在實現一個函式時,如果有選項引數的場景,我們通常建議開發者使用物件來作為入參,舉個例子 :

// good

function useScroll(element, { throttle, onScroll, ...}){...}

// bad

function useScroll(element, throttle, onScroll, ....){...}

大家可以很清晰的看到兩者的區別,毫無疑問第一種寫法的擴充套件性會更強,在之後迭代中也不容易對功能本身造成一些破壞性的改動.

文件實現

關於函式的具體實現就不細說了,畢竟我們有200個那麼多 ? . 這裡跟大家分享一下VueUse構建文件部分比較有意思的實現,我覺得做的很棒.

文件組成

我們先來看下一個功能函式文件的組成部分 :

構建流程

VueUse 使用了 VitePress 作為文件構建工具,下面我們來看下比較有意思的部分:

  • 以packages資料夾為入口啟動 VitePress 服務

VitePress 使用了約定式路由 (檔案即路由),所以訪問http://xxx.com/core/onClickOutside實際上就會解析我們對應的index.md檔案.看到這裡大家就會有疑問了,index.md檔案裡只包含了usage啊,其他的資訊是哪裡來的呢 ? 有趣的部分來了~

  • 編寫 Vite 外掛 MarkdownTransform對Markdown檔案進行處理 :
export function MarkdownTransform(): Plugin {
 
  return {
    name: 'vueuse-md-transform',
    enforce: 'pre',
    async transform(code, id) {
      if (!id.endsWith('.md'))
        return null

      const [pkg, name, i] = id.split('/').slice(-3)

      if (functionNames.includes(name) && i === 'index.md') {
        // 對index.md進行處理
        // 使用拼接字串的方式拼接Demo,型別宣告,貢獻者資訊和更新日誌
        const { footer, header } = await getFunctionMarkdown(pkg, name)

        if (hasTypes)
          code = replacer(code, footer, 'FOOTER', 'tail')
        if (header)
          code = code.slice(0, sliceIndex) + header + code.slice(sliceIndex)
      }

      return code
    },
  }
}

通過這個 Vite 外掛的處理,我們的文件部分就完整了.這裡又有一個疑問,貢獻者的資料和更新日誌資料是怎麼來的呢 ? 這兩個資料處理的方式都差不多,我就拿其中一個來說明實現 :

  • 獲取 git 提交者資訊
import Git from 'simple-git'

export async function getContributorsAt(path: string) {
    const list = (await git.raw(['log', '--pretty=format:"%an|%ae"', '--', path]))
      .split('\n')
      .map(i => i.slice(1, -1).split('|') as [string, string])
    return list
}

我們通過simple-git外掛讀取到相關檔案提交者的資訊,有了資料之後,那麼我們怎麼將它們渲染到頁面中呢 ? 還是使用 Vite 外掛,不過這次我們要做的是註冊虛擬模組.

  • 註冊虛擬模組
const ID = '/virtual-contributors'

export function Contributors(data: Record<string, ContributorInfo[]>): Plugin {
  return {
    name: 'vueuse-contributors',
    resolveId(id) {
      return id === ID ? ID : null
    },
    load(id) {
      if (id !== ID) return null
      return `export default ${JSON.stringify(data)}`
    },
  }
}

將我們剛才獲取到的資料在註冊虛擬模組的時候傳入就可以了,接下來我們就可以在元件中引入虛擬模組對資料進行訪問.

  • 使用資料
<script setup lang="ts">
import _contributors from '/virtual-contributors'
import { computed } from 'vue'

const props = defineProps<{ fn: string }>()

const contributors = computed(() => _contributors[props.fn] || [])
</script>

拿到資料後,我們就可以進行頁面渲染了. 這就是文件中 Contributors 和 Changelog 部分的實現原理. 我們來看下效果 :

看完這個是不是覺得還蠻有意思的,Vite 外掛其實還是可以用來搞很多事情的.

V8.0來啦 ?

我們在前兩天正式釋出了 V8.0, 主要帶來了:

  • 對一些函式的命名進行了規範化,並使用別名做了向下相容
  • 新增了幾個函式,目前函式數量達到了 200 +
  • @vueuse/core/nuxt => @vueuse/nuxt
  • 對一些函式做了指令支援,歡迎使用

結語

最後,感謝Anthony Fu對本文的指正和建議,瑞思拜 ! 如果我的文章對你有幫助,歡迎關注我一起學習.

相關文章