来自 Vue 3.0 的 Composition API 尝鲜

13,663

本文首发于我的博客:《来自 Vue 3.0 的 Composition API 尝鲜》

image

前段时间,Vue 官方释出了 Composition API RFC 的文档,我也在收到消息的第一时间上手尝鲜。

虽然 Vue 3.0 尚未发布,但是其处于 RFC 阶段的 Composition API 已经可以通过插件 @vue/composition-api 进行体验了。接下来的内容我将以构建一个 TODO LIST 应用来体验 Composition API 的用法。

本文示例的代码:github.com/jrainlau/vu…

一、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 阶段,所以文章当中可能会有难以避免的谬误,如果有任何的意见和看法都欢迎和我交流。