關於 vue 全家桶的四個 “最佳實踐”

丁香園F2E發表於2017-12-23

前言

在讀這篇文章之前,我想先安利大家一個東西:

Vue.js 元件編碼規範

看到這副黑框眼鏡,你是不是想到了什麼?

對,就是它:Vue.js 元件編碼規範。讀過的同學忽略,沒讀過的同學有時間的話請花 20 分鐘認真看看,文章的內容都是在認可這篇規範的基礎上展開的。

另外,本文中的“最佳實踐”(注意引號),全都是一家之言,不一定對,歡迎各路大佬討論拍磚。

實踐一:如何分類元件

元件(component)是 vue 最核心的概念之一,但是正因為這一概念太過寬泛,我們會在實際開發中看到各種各樣的元件,對開發和維護的同學帶來了很大的困惑和混亂。這裡我把元件分成四類:

view

顧名思義,view 指的是頁面,你也可以把它叫做 page。它的定義是:和具體的某一條路由對應,在 vue-router 配置中指定。view 是頁面的容器,是其他元件的入口。它可以和 vuex store 通訊,再把資料分發給普通元件。

global component

全域性元件,作為小工具而存在。例如 toast、alert 等。他的特點是具備全域性性,直接巢狀在 root 下,而不從屬於哪個 view。global component 也和 vuex store 通訊,它單獨地使用 state 中的一個 module,這個 state 中的資料專門用來控制 gloabl component 的顯隱和展示,不和其他業務實體用到的 state 混淆。
其他元件想修改它,可以直接派發相應的 mutation。而要監聽它的變化(比如一個全域性的confirm,確認之後在不同的元件中觸發不同的操作),則使用全域性事件匯流排(event bus)。

simple component

簡單元件。這種元件對應的是 vue 中最傳統的元件概念。它的互動和資料都不多,基本上就是起到一個簡單展示,拆分父元件的作用。這種元件和父元件之間通過最傳統的方式進行通訊:父元件將 props 傳入它,而它通過 $emit 觸發事件到父元件。
簡單元件內部是不寫什麼業務邏輯的,它可以說是生活不能自理,要展示什麼就等著父元件傳入,要幹什麼就 $emit 事件出去讓父元件幹,父元件夠操心的。

complex component

複雜元件。這種元件的特點是,內部包含有很多互動邏輯,常常需要訪問介面。另外,展示的資料也往往比較多。如下圖。

關於 vue 全家桶的四個 “最佳實踐”
圖中紅框內部的就是一個複雜元件的例項。它是一個大列表的列表項,展示的資料很多,而且點選左下角的幾個 button,還會彈出相應的彈窗,彈窗內有複雜的表單需要填寫提交···邏輯可以說是相當複雜了。如果這時我們還拘泥於簡單元件的那種通訊方式,衣來伸手飯來張口,啥事兒不幹,那麼:
1.所有的 props 都由父元件一一傳入,如果有十幾個乃至幾十個要展示的資料,那麼父元件 <template> 內的程式碼可不得上天了?
2.所有的業務流程都要 $emit 出去要父元件處理,那麼父元件 <script> 內的程式碼可不得上天了?
所以,對於這種複雜元件,我們應該允許它有一定的“自主權”。可以跳過父元件,自行和 vuex 通訊,獲取一下 state,派發一下 mutation 和 action,不是很開心麼。

我畫了一張圖來說明上面這四種 component 的關係,希望能幫助大家更好理解。

關於 vue 全家桶的四個 “最佳實踐”

在區分了這四種 component 後,我們在編碼時就能做到心裡有數,現在在寫的元件,到底屬於哪一類?每一類以特定的方式編寫和互動,邏輯上就會清晰很多。 使用 vue-cli 構建的專案中都會有一個目錄叫做 component,以前是一股腦往裡塞,現在可以在此基礎上再設定幾個子目錄,放置不同型別的元件。

實踐二:如何優雅地修改 props

先來看一個栗子?
假設有一個模態對話方塊的元件。父元件為了能夠開啟模態框,給模態框傳入了一個控制其顯隱的 props,命名為 visible,type 為 Boolean,繫結模態框外層的 v-if 指令。那麼,問題來了,如果我們點選了模態框內部的關閉按鈕,關閉自身,應該怎麼寫?
當然,最傳統的方式自然還是模態框丟擲事件,父元件中設定監聽,然後修改值。但這種方式無疑有很強的侵入性,無端增加了很多的程式碼量。關閉按鈕在模態框內部,關閉自己是我自己的事兒,能不能不讓父元件管這些?
有同學說了,直接在模態框內部修改 visible 啊。this.visible = false ,不行嗎?
還真不行。如果這麼幹,你會看到以下一堆報錯:

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value.

vue 很明確地告訴你了,作為子元件,你要安分守己,不許隨便修改老爹傳給你的 props。
那麼我們應該怎麼辦?

方法一

我們思考一下,如果不允許修改 props 的值,那我們修改 porps 的······屬性如何?
事實證明,是可以的。
我們可以把上面 visible 的 type 設為 Object,模態框的顯隱決定於 visible.value。當模態框想要關閉自身時,只需 this.visible.value = false 即可。 這種方式看起來相當方便,但實際是一種投機取巧的方法。上面安利的 Vue.js 元件編碼規範中明確有一條規範,就是 props 原子化,也就是說,props 裡的欄位必須是簡單的 String,Number 或 Boolean。這麼做的原因是:

  • 使得元件 API 清晰直觀。
  • 只使用原始型別和函式作為 props 使得元件的 API 更接近於 HTML(5) 原生元素。
  • 其它開發者更好的理解每一個 prop 的含義、作用。
  • 傳遞過於複雜的物件使得我們不能夠清楚的知道哪些屬性或方法被自定義元件使用,這使得程式碼難以重構和維護。

所以,我們把 visible 改為 Object,本來就是違反規範的。

方法二

vue 中有種已經存在的機制,和現有需求很像,這就是 v-model。在表單中,每一個 input,就像一個子元件。在外層通過 v-model 繫結的值可以在 input 中回顯,而 input 本身的值也能改變。
事實上,v-model 僅僅是一個語法糖,v-model="xxx",就相當於 :value="xxx" @input="val=>xxx=val"。那麼,我們就可以利用 v-model 的這種特性來實現我們的需求。我們只需要在模態框內部丟擲一個 input 事件 this.$emit('input', false),就能關閉自身了。
這種方式比較簡潔,也不違反規範,但是容易讓人困惑,以為這裡是要進行什麼表單操作。
我們還有沒有什麼更好的方式呢?

方法三

如果你是從大版本為 1 時就開始接觸 vue,那你可以知道一個修飾符,叫做.sync。如果你是從 2.0 開始接觸的,則很可能不熟悉它。這是因為,vue 在 2.0 版本時把它刪除了,不過好在, 2.3 版本之後,它又回來了
這個修飾符簡直就是為我們這個需求量身定製的。它本身是一個和 v-model 類似的語法糖,我們要做的,僅僅是在元件內部需要改動值的地方,丟擲一個 update 事件。this.$emit('update:foo', newValue)。既不違反規範,也足夠清晰,可以說是最佳的解決方案了。唯一的不足之處,就是對版本有一點要求。

實踐三:如何封裝請求介面

資料是 SPA 的核心,而資料的來源都是介面。如何優雅、高效地通過介面請求資料,是開發者必須要關心的問題。在實踐中,我是這樣封裝介面的:

關於 vue 全家桶的四個 “最佳實踐”
從高層到底層,依次說明。
第一層就是元件。
第二層則是 vuex 中的 action,我們在元件中呼叫 action,基本操作。
第三層是 api。在這裡,我們預先定義了每一個介面。包括介面的 url、type、content-type,以及寫死的請求引數。在 action 中,我們呼叫 api 請求介面。
第四層是 request,這是我們請求的公共方法,作用就是對特定的 http client。 進行封裝,實現一套統一的介面請求——處理流程。
第五層則是以 axios 為代表的各種 http client。
我們主要進行編碼的是第三層和第四層,也就是 api 和 request。api 的編寫沒有什麼難點,主要談談 request 的程式碼。這部分程式碼,我們要關心以下幾個方面。

  • loading 處理。當請求時間比較長時,要跳出全域性的 loading 讓使用者知曉。
  • 錯誤處理。有兩種錯誤,第一種是 http 請求直接返回錯誤碼。第二種,雖然請求的返回值是 200,但是返回結果中提示錯誤。比如返回的 json 中 success: false。對於這兩種錯誤,我們都要捕獲並處理。
  • api 一致性處理。http client 接受的引數是有講究的,以 axios為例,get 請求的請求引數為 params,而 post 請求的引數則為 data。對於這種差異,request 這層需要將其抹平,api 層不需要在定義介面時關心這些。

下面是示例程式碼,可供參考。

  if (opt.method === 'post') {
    axiosOpt.data = opt.payload
  } else if (opt.method === 'get') {
    axiosOpt.params = opt.payload
  }
  if (opt.withFile) {
    Object.assign(axiosOpt, { headers: {
      'Content-Type': 'multipart/form-data'
    }})
  }

  // 全域性請求的 loading,當請求 300 ms 後還沒返回,才會出現 loading
  const timer = setTimeout(() => {
    store.dispatch('showLoading', {
      text: '載入資料中'
    })
  }, 300)

  try {
    // 開始請求
    const result = await axios(axiosOpt)
    // 如果 300 ms 還沒到,就取消定時器
    clearTimeout(timer)
    store.dispatch('closeLoading')

    if (result.status === 200 && result.statusText === 'OK') {
      if (result.data.success) {
        return result.data.results || true
      } else {
        // 請求失敗的 toast
        store.dispatch('showAlert', {
          type: 'error',
          text: `請求失敗${result.data.message ? `,資訊:${result.data.message}`: ''}`
        })
        return false
      }
    } else {
      return false
    }
  } catch(e) {
    clearInterval(timer)
    // 請求失敗的 toast
    store.dispatch('closeLoading')
    store.dispatch('showAlert', {
      type: 'error',
      text: '請求失敗'
    })
    return false
  }
複製程式碼

實踐四:如何決定請求資料的時機

SPA中,每一個 view 中的都有很多資料是需要通過介面請求獲得的,如果沒有獲得,頁面中就會有很多空白。上面,我們討論瞭如何封裝好介面請求,下一步就是決定什麼時候請求初始化資料,即,程式碼在哪裡寫的問題。實踐下來,有兩個時機是比較理想的。

beforeRouteEnter/Update

vue-router 提供了以上兩個生命週期鉤子,分別會在進入路由和路由改變時觸發。這兩個鉤子是寫的 view 中的。

router.beforeEach

vue-router還提供了一個全域性性的 beforeEach 方法,任何一個路由改變時,都會被這個方法攔截,我們可以在這個方法中加入我們自己的程式碼,做統一處理。比如,對於所有 view 初始化請求的 action,我們可以以特定的名稱命名,如以 _init 作為字尾等。在 beforeEach 方法內,我們對當前 view 對應的 store 進行監聽,查詢到其中以 _init 命名的 action 並派發。
以上兩種方式各有特點。
對於前者,優點是資料獲取的程式碼和具體的 view 是繫結在一起的,我們可以在 view 內部就清晰地看到資料獲取的流程。缺點是,每增加一個頁面,都要在其內部寫一堆初始化程式碼,增加了程式碼量。 對於後者。優點是,程式碼統一且規整,使用了配置的方式,寫一次即可,不需要每次增加額外的程式碼。缺點是比較隱晦,且初始化程式碼和 view 本身割裂了。
對於以上兩種方式如何取捨的問題,我傾向於,大型專案用後者,小型專案用前者。

Other Tips

  • 多使用 mixing,能夠在元件級別抽離公共部分,減少冗餘,極好的機制。
  • 多使用常量,這點和 vue 本身沒有關係,但是能極大地提升程式碼的健壯性。
  • 連結如果是在專案內部跳轉,多使用 ,而不是去拼 a 標籤的 href。
  • 不要用 dom 操作。但如果迫不得已,比如你要獲得某個 dom 的 scrollTop 屬性,用 $ref,而不是用選擇器去取。
  • 能想到的就這些,歡迎大佬們討論補充。

作者:丁香園前端團隊-㍿社長

相關文章