原文地址:vue-composition-api-rfc.netlify.app/#summary
细节设计
API介绍
这里提议的APIs并没有带来新的概念,更多的是以独立函数的方式揭露Vue的核心能力,例如创建并观察响应式state。这里我们将介绍大部分基础APIs,以及如何使用他们来实现 Vue 2版本中的组件逻辑。注意这个部分主要聚焦于基础部分,因此没有详细介绍每个API的细节。可以在API Reference这个部分查阅所有API。
响应式数据和副作用
让我们从一个简单任务开始:声明一些响应式数据
import { reactive } from 'vue'
// reactive state
const state = reactive({
count: 0
})
reactive相当于 Vue 2版本中的 Vue.observable() API,之所以重命名是为了避免RxJs中某些概念带来的困惑。返回值state是一个响应式数据,所有的Vue开发者都应该熟悉。
在Vue中使用响应式数据的核心场景是在渲染函数中使用。因为依赖追踪,所以视图在响应式数据变化是自动更新。在DOM中渲染某些东西被认为为“副作用”:我们的程序正在修改程序(DOM)本身的外部状态。为了运行和自动重新运行一个基于响应式数据的副作用,我们可以使用watchEffectAPI:
import { reactive, watchEffect } from 'vue'
const state = reactive({
count: 0
})
watchEffect(() => {
document.body.innerHTML = `count is #{ state.count}`
})
watchEffect接收一个函数为参数,这个函数用来运行预期的副作用(例如在上面例子中的设置innerHTML)。这个函数会立即执行,并且追踪所有函数运行中用到的响应式数据属性作为依赖。这里,在初次执行完成后,state.count就被追踪为这个观察者的依赖。当state.count发生突变(mutate),这个内部函数将会再次执行。
这是Vue响应式系统的本质。当你在一个组件中通过data()返回一个对象,在内部就会通过reactive()方法使这个对象变为响应式对象。模板被编译成使用这些响应式对象属性的渲染函数(可以想象成更高效的innerHTML)。
watchEffect类似于2.x版本中的watch选项,但是它不要求分离被观察的数据和副作用回调。Composition API同样会提供一个watch函数,这个函数的功能和2.x版本的watch选项完全一样。
继续上面的例子,来看一下我们如何处理用户输入:
function increment() {
state.count++;
}
document.body.addEventListener('click', increment);
使用Vue的模板系统让我们不用和innerHTML或者手动监听事件系统纠缠。让我们使用假设的renderTemplate方法简化这个例子,以集中精力在响应式数据上:
import { reactive, watchEffect } from 'vue'
const state = reactive({
count: 0
})
function increment() {
state.count++
}
const renderContext = {
state,
increment,
}
watchEffect(() => {
// 模拟代码,非真实API
renderTemplate(
`<button @click="increment">{{ state.count }}</button>`,
renderContext)
})
计算属性和Refs
有时候我们需要的某些属性依赖另一些属性,在Vue中,可以使用computed属性实现。我们可以使用computed API直接创建一个计算属性:
import { reactive, computed } from 'vue'
const state = reactive({
count: 0
})
const double = computed(() => state.count * 2)
这里的computed返回什么?如果我们猜想computed在内部如何实现的,我们可以想出如下的代码:
// 简单模拟
function computed(getter) {
let value;
watchEffect(() => {
value = getter();
})
return value;
}
但是我们知道这是无效的。如果value是原始类型,例如number,一旦执行完成返回,在computed内部的更新逻辑关联就会丢失。这是因为JavaScript里,原始类型使用真实值传值,而不是引用传值。
在给一个对象的属性赋值时也会发生相同的问题。如果一个响应式数据不能在作为属性赋值或者由函数返回时保持其响应式性,那它就不是很有用了。为了确保我们可以读取计算的最新值,我们必须用对象包裹真实的值,并返回这个对象:
// 模拟
function computed(getter) {
const ref = {
value: null
}
watchEffect(() => {
ref.value = getter();
})
return ref;
}
另外,我们必须拦截对象.value属性的读/写操作,以实现依赖追踪和变化通知(为了简化,代码中省略了这一步)。现在我们可以通过引用到处传递值了,不用再担心失去其响应式性。为了获取最新的值,我们约定必须通过 .value的方式获取值:
const double = computed(() => state.count * 2)
watchEffect(() => {
console.log(double.value)
}) // -> 0
state.count++ // -> 2
这里的double是一个对象,我们可以称之为”ref”,它是响应式引用,内部保持着value的值。
你可能想到Vue已经有”refs”的概念了,但是仅仅是在模板中引用DOM元素或者组件实例。查看Template Refs API去了解新的refs系统如何在逻辑状态盒模板refs中应用。
另外为了计算refs,我们也可以使用ref API直接创建纯的、可变的refs:
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
直接取值的Ref
我们可以在渲染上下文中将ref作为属性暴露。在Vue内部,将对refs区别对待,在渲染环境中遇到ref时,可以使用ref代替ref.value来获取值。这意味着在模板中,我们可以直接使用{{ count }} 代替 {{ count.value }}。
这是一个相同的counter的例子,使用ref代替reactive:
import { ref, watch } from 'vue'
const count = ref(0)
function increment() {
count.value++;
}
const renderContext = {
count,
increment
}
watchEffect(() => {
renderTemplate(
`<button @click="increment">{{count}}</button>`,
renderContext)
})
另外,如果ref作为属性嵌套在响应式对象内,它同样可以直接获取值:
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
// 不需要使用state.double.value
console.log(state.double)
在组件中的应用
目前,我们代码已经提供了一个更新基于输入的工作中UI,但是只可以运行一次而且不可重用。如果我们想重用这个逻辑,下一步可能就是重构它为函数:
import { reactive, computed, watchEffect } from 'vue'
function setup() {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
function increment() {
state.count++
}
return {
state,
increment
}
}
const renderContext = setup()
watchEffect(() => {
renderTemplate(
`<button @click="increment">
Count is: {{ state.count }}, double is: {{ state.double }}
</button>`,
renderContext
)
})
注意上面的代码不需要依赖组件实例的存在。实际上,现在介绍的APIs目前可以在组件上下文环境以外使用,允许我们在更广泛的场景中使用 Vue的响应式系统。
现在如果我们不需要调用setup(),创建观察者,渲染模板到页面的任务,我们可以仅仅使用setup函数和模板来定义一个组件:
<template>
<button @click="increment">
Count is: {{ state.count }}, double is: {{ state.double }}
</button>
</template>
<script>
import { reactive, computed } from 'vue'
export default {
setup() {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
function increment() {
state.count++
}
return {
state,
increment
}
}
}
</script>
上面的例子是我们熟悉的单文件组件格式,唯一不同的一点是逻辑部分(script)采用不同的格式编写。模板语法没有任何变化。<style>的部分虽然没有写出来,但是语法也是和之前一样。
生命周期函数
在上面的例子中,我们说到了组件状态相关的知识:响应式数据,计算属性,在用户输入的时候修改属性。但是组件可能还需要执行一些其他的副作用,例如,在控制台输出日志,发送一个ajax请求,或者监听某些window上的事件等。这些副作用一般在如下的这些时间点监听:
1.当某些状态改变时。
2.当组件已挂载、已更新或者已卸载(生命周期函数)时。
我们已经知道可以使用watchEffect和watch2个API来运行基于状态修改的副作用。为了在不同的生命周期中执行副作用,我们可以使用形如onXXX的APIs(对应于当前已经存在的生命周期选项):
import { onMounted } from 'vue'
export default {
setup() {
onMounted(() => {
console.log('component is mounted!')
})
}
}
在这生命周期函数只能在setup函数运行时注册。它通过全局状态来识别当前调用setup的实例。之所以这么设计,是为了减少抽出逻辑到另外函数时的障碍。
可以在API Reference章节中查看更多的API细节。不管怎样,我们推荐在深入学习设计细节前先完成本章节的阅读。
代码组织
在这里,我们用引入的函数替代组件API,但是这是因为什么?相比于把所有代码一起放入一个大函数,使用options定义组件的方式似乎更有利于组织代码。
第一眼有这种感觉是正常。但是正如动机那一章所提到的,我们相信Composition API实际上更有利于代码组织,特别是复杂组件。接下来我们就会解释为什么会这样。
什么是“代码组织”?
让我们先退一步思考,何为“代码组织”?代码组织的最终目标应该是易读、易理解。所以所谓“理解代码”又是什么意思呢?我们是否能声称我们理解某个组件,仅仅是因为我们知道这个组件的选项中包含了什么?你是否曾理解过别人写的一个大组件(例如这个),并且在阅读理解上有一定的困难?
试着思考一下,我们是如何向小伙伴介绍大组件的,比如我们上面链接打开的那个。你可能会首先说:这个组件是用来处理X、Y和Z的,而不是这个组件有这些data属性,这些计算属性和这些方法。当我们去理解一个组件时,我们更关心这个组件是干什么的(即代码背后的需求),而不是这个组件使用了哪些选项。当我们使用基于选项的API来编写代码,这些选项自然的回答了上述所说的第二个问题,但是并没有很好的回答第一个问题。
关注逻辑 vs. 选项类型
假设组件处理的X、Y和Z是它的逻辑关注点。在一个小、单功能的组件中是不存在不易阅读问题的,因为整个组件只处理一个单一的逻辑关注点。然而,在一个功能复杂的案例中,不易阅读的问题是非常显著的。就拿Vue CLI UI file explorer 作为例子来说吧。这个组件需要处理很多不同的关注点:
- 追踪当前的文件夹状态,展示它的内容
- 处理文件夹的操作(打开、关闭、刷新…)
- 处理新文件夹的创建
- 只显示喜爱文件夹的开关
- 显示隐藏文件夹的开关
- 处理当前工作目录的修改
你能通过阅读基于选项的代码,马上识别并说出这些单独的逻辑关注点吗?这是非常困难的。你会注意到这些关联某个特殊逻辑关注点的代码是分散在代码的不同地方。例如,“创建新文件夹”这个功能使用到“2个data属性、一个计算属性和一个方法属性”,而方法定义的地方距离data属性定义的地方相隔了100多行。
如果我们给每个逻辑点关联的代码标上色块,你就会发现,在基于选项的方式实现的组件中,他们有多么分散、零碎。

这样的分散确实造成了理解和维护复杂组件的困难。这种通过选项的强制分离使背后的逻辑关注点很难发现和理解。另外,即使我们在理解一个单个逻辑点的组件时,也需要不断的在选项中跳来跳去,才能发现这个关注点相关的代码。
注意:上面的代码有些地方是可以改进的,但是我们在最后一次提交的时候并没有把修改提交,这样在我们就可以在自己的生产环境中提供一个真实的案例。
如果我们能够把相同关注点的代码都放在一起,代码看起来就会好很多。其实通过Composition API就可以做到。“创建新文件夹”的功能就可以重写为以下这样:
function useCreateFolder (openFolder) {
// originally data properties
const showNewFolder = ref(false)
const newFolderName = ref('')
// originally computed property
const newFolderValid = computed(() => isValidMultiName(newFolderName.value))
// originally a method
async function createFolder () {
if (!newFolderValid.value) return
const result = await mutate({
mutation: FOLDER_CREATE,
variables: {
name: newFolderName.value
}
})
openFolder(result.data.folderCreate.path)
newFolderName.value = ''
showNewFolder.value = false
}
return {
showNewFolder,
newFolderName,
newFolderValid,
createFolder
}
}
正如上面代码所写,与创建新文件夹逻辑相关的代码都被归纳并封装到一个单独的函数中。函数的命名同样也指明了这段代码的功能。这就是我们所说的组合函数(composition function)。推荐函数命名以use开头,即约定这是一个组合函数.这种方式同样适用于组件中的其他逻辑关注点,可以分解出数个友好的解耦的函数:

以上对比的代码只包含引入语句和setup函数。完整的组件代码实现不在这里。
现在每个逻辑关注点的相关代码都被归纳到组合函数中。这样组织代码,即使在阅读一个大组件的代码时,“跳来跳去”查看代码的次数会大大减少。组合代码在编辑器中也可以被折叠,这样浏览一个组件的全貌也更简单点:
export default {
setup() { // ...
}
}
function useCurrentFolderData(networkState) { // ...
}
function useFolderNavigation({ networkState, currentFolderData }) { // ...
}
function useFavoriteFolder(currentFolderData) { // ...
}
function useHiddenFolders() { // ...
}
function useCreateFolder(openFolder) { // ...
}
现在这个setup函数更多的是作为所有组合函数触发的入口:
export default {
setup () {
// Network
const { networkState } = useNetworkState()
// Folder
const { folders, currentFolderData } = useCurrentFolderData(networkState)
const folderNavigation = useFolderNavigation({ networkState, currentFolderData })
const { favoriteFolders, toggleFavorite } = useFavoriteFolders(currentFolderData)
const { showHiddenFolders } = useHiddenFolders()
const createFolder = useCreateFolder(folderNavigation.openFolder)
// Current working directory
resetCwdOnLeave()
const { updateOnCwdChanged } = useCwdUtils()
// Utils
const { slicePath } = usePathUtils()
return {
networkState,
folders,
currentFolderData,
folderNavigation,
favoriteFolders,
toggleFavorite,
showHiddenFolders,
createFolder,
updateOnCwdChanged,
slicePath
}
}
}
诚然,在使用选项API时,上面的代码是不需要写的。但是,请注意,这里的setup函数更像是一份简单文档,用来描述组件是干什么的,在基于选项开发的组件里是无法获取 这些信息的。通过传入的参数可以清晰的展示各组合函数之间的依赖关系。最后,返回语句是唯一的暴露出口,展示了所有暴露给模板的方法或者属性。
一句话总结: 功能代码解耦,setup作为controller(控制器)管理其他功能代码的调用,通过传入参数展示各功能之间的依赖的关系,setup()的return语句是唯一的输出口。
逻辑抽象和重用
在从各组件中抽象和重用逻辑的场景中,使用Composition API具有更多的灵活性。相比于只依赖怪异的this上下文,一个组合函数只依赖传入的参数和全局引入的Vue APIs。你可以轻易的重用组件中的任一逻辑部分,只需要简单的把这部分当作函数export出去。你甚至可以将组件的整个setup函数导出,以实现和extends一样的功能。
来看看下面的例子:追踪鼠标的位置:
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
下面是展示如何使用上面定义的函数:
import { useMousePosition } from './mouse'
export default {
setup() {
const { x, y } = useMousePosition()
// other logic...
return { x, y }
}
}
在Composition API实现的file explorer例子中,我们把公共的代码(例如usePathUtils和useCwdUtils)抽离到单独文件中,因为我们发现这些公共代码在其他组件中也很有用。
类似的逻辑重用也可以使用混入(mixins),高阶组件(high-order component)或者无渲染函数组件(renderless component)(通过scoped slot)来实现。网上有很多关于上述几种方式的解释,这里就不多说了。相对于组合函数,上面几种方式都有着各自的缺点:
- 不清晰的数据流。举个例子,如果一个组件是用了多个mixins,那么就很难找出模板中用到的某个属性具体是由哪个minxin注入的。
- 命名冲突。混入可能会造成属性和方法名称的命名冲突,同时高阶组件可能在传入props是存在命名冲突。
- 性能问题。高阶组件和无渲染函数组件都必须有一个额外的有状态的组件实例,带来一定的性能开销。
相比之下,使用Composition API的优点:
- 清晰的数据流,因为所有的数据都是由组合函数返回。
- 没有命名冲突,由组合函数返回的数据名可以随意命名。
- 逻辑重用时,不需要额外的组件实例,所以没有多余的性能开销。
兼容老版本的API
Composition API的使用兼容已经存在的基于选项的API。
- Composition API兼容2.x版本的options,同时Composition API没有获取options中定义的属性的权限。
- setup()返回的属性挂载在this上,因此在2.x版本的options中可以通过this.的方式获取。
插件开发
现在有很多Vue插件扩展this的功能。例如,Vue Router在this上新增了this.router,Vuex新增了this.$store。由于每个插件都要求开发者增加注入属性的Vue类型说明,导致最后的类型推断很麻烦。
在使用Composition API时,是没有this的。插件内部使用provide和inject,同时暴露组合函数。假设一个插件代码如下:
const StoreSymbol = Symbol()
export function provideStore(store) {
provide(StoreSymbol, store)
}
export function useStore() {
const store = inject(StoreSymbol)
if (!store) {
// throw error, no store provided
}
return store
}
如何使用:
// provide store at component root
//
const App = {
setup() {
provideStore(store)
}
}
const Child = {
setup() {
const store = useStore()
// use the store
}
}
注意,store同样可以使用app级别的provide实现,在Global API change RFC中提到过的,但是useStore的使用方法是相同的。