乐趣区

来自-Vue-30-的-Composition-API-尝鲜

前段时间,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 列表构成:

大家也可以在这里体验。

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

退出移动版