前段时间,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
}
假设 x
和 y
都是需要具备“响应式”能力的数据,那么 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 阶段,所以文章当中可能会有难以避免的谬误,如果有任何的意见和看法都欢迎和我交流。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。