來自 Vue 3.0 的 Composition API 嚐鮮

image

前段時間,Vue 官方釋出了 Composition API RFC 的文檔,我也在收到消息的第一時間上手嚐鮮。

雖然 Vue 3.0 尚未發佈,但是其處於 RFC 階段的 Composition API 已經可以通過插件 @vue/composition-api 進行體驗了。接下來的內容我將以構建一個 TODO LIST 應用來體驗 Composition API 的用法。

本文示例的代碼:https://github.com/jrainlau/v...

一、Vue 2.x 方式構建應用。

這個 TODO LIST 應用非常簡單,僅有一個輸入框、一個狀態切換器、以及 TODO 列表構成:

image

大家也可以在這裏體驗。

藉助 vue-cli 初始化項目以後,我們的項目結構如下(僅討論 /src 目錄):

.
├── App.vue
├── components
│   ├── Inputer.vue
│   ├── Status.vue
│   └── TodoList.vue
└── main.js

/components 裏文件的命名不難發現,三個組件對應了 TODO LIST 應用的輸入框、狀態切換器,以及 TODO 列表。這三個組件的代碼都非常簡單就不展開討論了,此處只討論核心的 App.vue 的邏輯。

  • App.vue
<template>
  <div class="main">
    <Inputer @submit="submit" />
    <Status @change="onStatusChanged" />
    <TodoList
      :list="onShowList"
      @toggle="toggleStatus"
      @delete="onItemDelete"
    />
  </div>
</template>

<script>
import Inputer from './components/Inputer'
import TodoList from './components/TodoList'
import Status from './components/Status'

export default {
  components: {
    Status,
    Inputer,
    TodoList
  },

  data () {
    return {
      todoList: [],
      showingStatus: 'all'
    }
  },
  computed: {
    onShowList () {
      if (this.showingStatus === 'all') {
        return this.todoList
      } else if (this.showingStatus === 'completed') {
        return this.todoList.filter(({ completed }) => completed)
      } else if (this.showingStatus === 'uncompleted') {
        return this.todoList.filter(({ completed }) => !completed)
      }
    }
  },
  methods: {
    submit (content) {
      this.todoList.push({
        completed: false,
        content,
        id: parseInt(Math.random(0, 1) * 100000)
      })
    },
    onStatusChanged (status) {
      this.showingStatus = status
    },
    toggleStatus ({ isChecked, id }) {
      this.todoList.forEach(item => {
        if (item.id === id) {
          item.completed = isChecked
        }
      })
    },
    onItemDelete (id) {
      let index = 0
      this.todoList.forEach((item, i) => {
        if (item.id === id) {
          index = i
        }
      })
      this.todoList.splice(index, 1)
    }
  }
}
</script>

在上述的代碼邏輯中,我們使用 todoList 數組存放列表數據,用 onShowList 根據狀態條件 showingStatus 的不同而展示不同的列表。在 methods 對象中定義了添加項目、切換項目狀態、刪除項目的方法。總體來說還是非常直觀簡單的。

按照 Vue 的官方說法,2.x 的寫法屬於 Options-API 風格,是基於配置的方式聲明邏輯的。而接下來我們將使用 Composition-API 風格重構上面的邏輯。

二、使用 Composition-API 風格重構邏輯

下載了 @vue/composition-api 插件以後,按照文檔在 main.js 引用便開啓了 Composition API 的能力。

  • main.js
import Vue from 'vue'
import App from './App.vue'
import VueCompositionApi from '@vue/composition-api'

Vue.config.productionTip = false
Vue.use(VueCompositionApi)

new Vue({
  render: h => h(App),
}).$mount('#app')

回到 App.vue,從 @vue/composition-api 插件引入 { reactive, computed, toRefs } 三個函數:

import { reactive, computed, toRefs } from '@vue/composition-api'

僅保留 components: { ... } 選項,刪除其他的,然後寫入 setup() 函數:

export default {
  components: { ... },
  setup () {}
}

接下來,我們將會在 setup() 函數裏面重寫之前的邏輯。

首先定義數據

爲了讓數據具備“響應式”的能力,我們需要使用 reactive() 或者 ref() 函數來對其進行包裝,關於這兩個函數的差異,會在後續的章節裏面闡述,現在我們先使用 reactive() 來進行。

setup() 函數裏,我們定義一個響應式的 data 對象,類似於 2.x 風格下的 data() 配置項。

setup () {
    const data = reactive({
      todoList: [],
      showingStatus: 'all',
      onShowList: computed(() => {
        if (data.showingStatus === 'all') {
          return data.todoList
        } else if (data.showingStatus === 'completed') {
          return data.todoList.filter(({ completed }) => completed)
        } else if (data.showingStatus === 'uncompleted') {
          return data.todoList.filter(({ completed }) => !completed)
        }
      })
    })
}

其中計算屬性 onShowList 經過了 computed() 函數的包裝,使得它可以根據其依賴的數據的變化而變化。

接下來定義方法

setup() 函數裏面,對之前的幾個操作選項的方法稍加修改即可直接使用:

    function submit (content) {
      data.todoList.push({
        completed: false,
        content,
        id: parseInt(Math.random(0, 1) * 100000)
      })
    }
    function onStatusChanged (status) {
      data.showingStatus = status
    }
    function toggleStatus ({ isChecked, id }) {
      data.todoList.forEach(item => {
        if (item.id === id) {
          item.completed = isChecked
        }
      })
    }
    function onItemDelete (id) {
      let index = 0
      data.todoList.forEach((item, i) => {
        if (item.id === id) {
          index = i
        }
      })
      data.todoList.splice(index, 1)
    }

與在 methods: {} 對象中定義的形式所不同的地方是,在 setup() 裏的方法不能通過 this 來訪問實例上的數據,而是通過直接讀取 data 來訪問。

最後,把剛剛定義好的數據和方法都返回出去即可:

    return {
      ...toRefs(data),
      submit,
      onStatusChanged,
      toggleStatus,
      onItemDelete,
    }

這裏使用了 toRefs()data 對象包裝了一下,是爲了讓它的數據保持“響應式”的,這裏面的原委會在後續章節展開。

重構完成後,發現其運行的結果和之前的完全一致,證明 Composition API 是可以正確運行的。接下來我們來聊聊 reactive()ref() 的問題。

三、響應式數據

我們知道 Vue 的其中一個賣點,就是其強大的響應式系統。無論是哪個版本,這個核心功能都貫穿始終。而說到響應式系統,往往離不開響應式數據,這也是被大家所津津樂道的話題。

回顧一下,在2.x版本中 Vue 使用了 Object.defineProperty() 方法改寫了一個對象,在它的 getter 和 setter 裏面埋入了響應式系統相關的邏輯,使得一個對象被修改時能夠觸發對應的邏輯。在即將到來的 3.0 版本中,Vue 將會使用 Proxy 來完成這裏的功能。爲了體驗所謂的“響應式對象”,我們可以直接通過 Vue 提供的一個 API Vue.observable() 來實現:

const state = Vue.observable({ count: 0 })

const Demo = {
  render(h) {
    return h('button', {
      on: { click: () => { state.count++ }}
    }, `count is: ${state.count}`)
  }
}
上述代碼引用自官方文檔

從代碼可以看出,通過 Vue.observable() 封裝的 state,已經具備了響應式的特性,當按鈕被點擊的時候,它裏面的 count 值會改變,改變的同時會引起視圖層的更新。

回到 Composition API,它的 reactive()ref() 函數也是爲了實現類似的功能,而 @vue/composition-api 插件的核心也是來自 Vue.observable()

function observe<T>(obj: T): T {
  const Vue = getCurrentVue();
  let observed: T;
  if (Vue.observable) {
    observed = Vue.observable(obj);
  } else {
    const vm = createComponentInstance(Vue, {
      data: {
        $$state: obj,
      },
    });
    observed = vm._data.$$state;
  }

  return observed;
}
節選自插件源碼

在理解了 reactive()ref() 的目的之後,我們就可以去分析它們的區別了。

首先我們來看兩段代碼:

// style 1: separate variables
let x = 0
let y = 0

function updatePosition(e) {
  x = e.pageX
  y = e.pageY
}

// --- compared to ---

// style 2: single object
const pos = {
  x: 0,
  y: 0
}

function updatePosition(e) {
  pos.x = e.pageX
  pos.y = e.pageY
}

假設 xy 都是需要具備“響應式”能力的數據,那麼 ref() 就相當於第一種風格,單獨地爲某個數據提供響應式能力;而 reactive() 則相當於第二種風格,給一整個對象賦予響應式能力。

但是在具體的用法上,通過 reactive() 包裝的對象會有一個坑。如果想要保持對象內容的響應式能力,在 return 的時候必須把整個 reactive() 對象返回出去,同時在引用的時候也必須對整個對象進行引用而無法解構,否則這個對象內容的響應式能力將會丟失。這麼說起來有點繞,可以看看官網的例子加深理解:

// composition function
function useMousePosition() {
  const pos = reactive({
    x: 0,
    y: 0
  })

  // ...
  return pos
}

// consuming component
export default {
  setup() {
    // reactivity lost!
    const { x, y } = useMousePosition()
    return {
      x,
      y
    }

    // reactivity lost!
    return {
      ...useMousePosition()
    }

    // this is the only way to retain reactivity.
    // you must return `pos` as-is and reference x and y as `pos.x` and `pos.y`
    // in the template.
    return {
      pos: useMousePosition()
    }
  }
}

舉一個不太恰當的例子。“對象的特性”是賦予給整個“對象”的,它裏面的內容如果也想要擁有這部分特性,只能和這個對象捆綁在一塊,而不能單獨拎出來。

但是在具體的業務中,如果無法使用解構取出 reactive() 對象的值,每次都需要通過 . 操作符訪問它裏面的屬性會是非常麻煩的,所以官方提供了 toRefs() 函數來爲我們填好這個坑。只要使用 toRefs()reactive() 對象包裝一下,就能夠通過解構單獨使用它裏面的內容了,而此時的內容也依然維持着響應式的特性。

至於何時使用 reactive()ref(),都是按照具體的業務邏輯來選擇。對於我個人來說,會更傾向於使用 reactive() 搭配 toRefs() 來使用,因爲經過 ref() 封裝的數據必須通過 .value 才能訪問到裏面的值,寫法上要注意的地方相對更多一些。

四、Composition API 的優勢及擴展

Vue 其中一個被人詬病得很嚴重的問題就是邏輯複用。隨着項目越發的複雜,可以抽象出來被複用的邏輯也越發的多。但是 Vue 在 2.x 階段只能通過 mixins 來解決(當然也可以非常繞地實現 HOC,這裏不再展開)。mixins 只是簡單地把代碼邏輯進行合併,如果需要對邏輯進行追蹤將會是一個非常痛苦的過程,因爲繁雜的業務邏輯裏面往往很難一眼看出哪些數據或方法是來自 mixins 的,哪些又是來自當前組件的。

另外一點則是對 TypsScript 的支持。爲了更好地進行類型推斷,雖然 2.x 也有使用 Class 風格的 ts 實現方案,但其冗長繁雜和依賴不穩定的 decorator 的寫法,並非一個好的解決方案。受到 React Hooks 的啓發,Vue Composition API 以函數組合的方式完成邏輯,天生就適合搭配 TypeScript 使用。

至於 Options API 和 Composition API 孰優孰劣的問題,在本文所展示的例子中其實是比較難區分的,原因是這個例子的邏輯實在是太過簡單。但是如果深入思考的話不難發現,如果項目足夠複雜,Composition API 能夠很好地把邏輯抽離出來,每個組件的 setup() 函數所返回的值都能夠方便地被追蹤(比如在 VSCode 裏按着 cmd 點擊變量名即可跳轉到其定義的地方)。這樣的能力在維護大型項目或者多人協作項目的時候會非常有用,通用的邏輯也可以更細粒度地共享出去。

關於 Composition API 的設計理念和優勢可以參考官網的 Motivation 章節

如果腦洞再開大一點,Composition API 可能還有更酷的玩法。

  • 對於一些第三方組件庫(如 element-ui),除了可以提供包含了樣式、結構和邏輯的組件之外,還可以把部分邏輯以 Composition API 的方式提供出來,其可定製化和玩法將會更加豐富。
  • reactive() 方法可以把一個對象變得響應式,搭配 watch() 方法可以很方便地處理 side effects:

    import { reactive, watch } from 'vue'
    
    const state = reactive({
      count: 0
    })
    
    watch(() => {
      document.body.innerHTML = `count is ${state.count}`
    })

    上述例子中,當響應式的 state.count 被修改以後,會觸發 watch() 函數裏面的回調。基於此,也許我們可以利用這個特性去處理其他平臺的視圖更新問題。微信小程序開發框架 mpvue 就是通過魔改 Vue 的源碼來實現小程序視圖的數據綁定及更新的,如果擁有了 Composition API,也許我們就可以通過 reactive()watch() 等方法來實現類似的功能,此時 Vue 將會是位於數據視圖中間的一層,數據的綁定放在 reactive(),而視圖的更新則統一放在 watch() 當中進行。

五、小結

本文通過一個 TODO LIST 應用,按照官網的指導完成一次對 Composition API 的嚐鮮式探索,學習了新的 API 的用法並討論了當中的一些設計理念,分析了當中的一些問題,最後腦洞大開對立面的用法進行了探索。由於相關資料較少且 Composition API 仍在 RFC 階段,所以文章當中可能會有難以避免的謬誤,如果有任何的意見和看法都歡迎和我交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章