新鲜出炉的Composition API中文翻译, 以表期待Vue 3.0的到来!

·  阅读 3974

本人声明:本文属于译文,原文出处于Vue Composition API RFC

为了不混淆作者本意、以及方便用户对比查阅的宗旨,本译文尽可能的直译,并且与原文的段落结构、风格保持一致,有些时候也会将原文放在译文后方便用户参考理解。

总之希望大家能学有所获,加油!

Composition API RFC

  • 开始日期:2019-07-10
  • 主要目标版本:2.x / 3.x
  • Issues索引:#42
  • 实现的PR<置空>

1. 前言

这是关于 合成API(Composition API) 的介绍:一组新增的、基于函数式的、允许弹性组合组件逻辑的 APIs。

2. 基础示例

<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>
复制代码

3. 动机

3.1 逻辑复用 & 代码组织

我们都因为Vue非常容易上手、构建中小型应用非常简单而爱上它,但如今随着Vue的使用率增长,大量用户也正在使用Vue构建大型的项目 - 比如有些项目需要多人协作开发的团队花很长的时间迭代和维护。过去这些年,我们亲眼目睹了这些项目的一部分由于Vue现有的API遇到了编程模型的限制。问题汇总起来主要有两大类:

  1. 随着新功能和特性的开发迭代,复杂组件的代码会变得越来越难以推理。 尤其是开发者遇到不是自己写的代码的时候。根本的原因是Vue现有的API是通过选项来组织的;但有的时候,通过关注逻辑来组织代码会更有意义。
  2. 缺少从多个组件中优雅的提取、复用逻辑的机制

这篇 RFC 里所提议的APIs为用户在组织组件代码时提供了更多的灵活性。相比于之前一直通过选项来组织代码,现在我们可以针对某个功能像函数一样的处理方式来组织代码。这些APIs还使得在组件之间甚至外部组件之间提取和复用逻辑更加简单明了。我们将在 细节设计 章节描述如何实现这些目标。

3.2 更友好的类型推断

为大型项目服务的开发者还有一个共同的特性诉求:更好的 TypeScript 支持。 将Vue现有的API在与Typescript集成时的确有一些挑战,主要是因为Vue依赖于 this 上下文来抛出属性;而且相较于纯JS,this 在Vue组件中有很多魔法性 (比如:嵌套在 methods 下的函数里的 this 指向的是组件实例,而不是 methods 对象)。再者,Vue现有的API在设计时就根本没有考虑类型推论,在想着尝试优雅的集成 Typescript 时就导致增加了很多复杂性。

如今大部分用户在 Vue 中集成使用 Typescript 时都会使用 vue-class-component 库,这个库实现了使用 Typescript 的 classes 配合装饰器来书写组件。在设计3.0的时候,我们在 上一个RFC (已删除) 中尝试提供一个内置的 Class API 来解决类型问题。但是在设计时经过讨论和迭代,我们注意到为了让Class API能够解决类型问题,就必须依赖装饰器,而装饰器当前处在非常不稳定的 stage 2 提案,在实现细节方面还有很多不确定性,这就导致将来会有很大的风险。

相比之下,这篇RFC里的APIs主要使用了一些天生类型友好的变量和函数。使用提议的这些APIs来写代码可以充分享受类型推论,无需手动输入类型提示;而且代码看上去会和Typescript、纯JS几乎一致。所以,即时非Typescript用户也从类型中获得更友好的IDE支持。

4. 细节设计

4.1 API介绍

这里提出的APIs更多的是将Vue的核心功能作为独立功能展开,而不是引入新的概念 - 例如创建并观测反应性状态。我们将介绍一些最基本的APIs,以及如何使用它们替代2.x版本中的选项来描述组件逻辑。注意,本章将着重介绍基本的思路想法,所以不会很深入每一个API的细节。更多APIs规范可以在API 索引章节找到

4.1.1 可反应状态 & 副作用(Reactive State and Side Effect)

让我们从一个简单的任务开始:声明一些可反应状态。

import { reactive } from 'vue'

// reactive state
const state = reactive({
    count: 0
})
复制代码

reactive 等价于2.x版本中的 Vue.observable() API,重命名是为了避免与RxJS的 observables 混淆。如上,返回的 state 现在已经是Vue用户都很熟悉的可反应对象了。

在Vue中,可反应状态的基本应用场景是我们可以在整个渲染期间使用它。感谢依赖追踪的机制,当可反应状态的值变更后,视图将会自动更新。在DOM中渲染某些内容会被视为副作用:我们的程序正在修改程序本身(the DOM)的一些状态。

为了应用、并根据可反应状态自动重新应用副作用,我们可以使用 watch API:

import { reactive, watch } from 'vue'

const state = reactive({
    count: 0
})

watch(() => {
    document.body.innerHTML = `count is ${state.count}`
})
复制代码

watch 接收一个函数作为参数,这个函数体内部是期望将被应用的副作用(在上例中是设置了 innerHTML )。它将自动执行函数,并且将追踪整个执行过程所使用的可反应状态作为依赖。在上例中,侦听器在初次执行后,state.count 将作为依赖被追踪。当 state.count 在未来某个时间刻发生更改时,函数内部将会再次执行。

这是Vue的可反应系统机制的本质。当你在一个组件的 data() 中返回一个对象时,将会被 reactive() 这个API内部转化成可反应的。模板会被编译成使用这些可反应状态属性的渲染函数(render function, 可以看成更高效的 innerHTML)。

继续上面的例子,下面是如何处理用户输入:

function increment() {
    state.count++
}

document.body.addEventListener('click', increment)
复制代码

但是借助Vue的模板系统,我们不需要去纠结 innerHTML 或者手动添加事件监听器。为了更加的关注可反应性,现在我们使用一个伪代码 renderTemplate 来简化这个例子,

import { reactive, watch } from 'vue'

const state = reactive({
    count: 0
})

function increment() {
    state.count++
}

const renderContext = {
    state,
    increment
}

watch(() => {
    // 假设的内部代码,不是真实的API
    renderTemplate(
    	`<button @click="increment">{{ state.count }}</button>`,
    	renderContext
    )
})
复制代码

4.1.2 计算状态 & 引用(Computed State and Refs)

有些时候,我们需要一些状态依赖于其他状态,在Vue中可以使用 computed 属性来处理。为了直接创建一个计算值,我们可以使用 computed API:

import { reactive, computed } from 'vue'

const state = reactive({
	count: 0
})

const double = computed(() => {
	return state.count * 2
})
复制代码

这里的 computed 返回什么呢?如果我们构思一下它内部的实现的话,可能会想到如下:

// 简单的伪代码
function computed(getter) {
	let value
	watch(() => {
		value = getter()
	})
	
	return value
}
复制代码

但是我们都知道这是行不通的,因为 value 值如果是一个基本类型,那么它在 computed 内部与更新逻辑的连接将在返回值后立刻失去联系。这是因为JavaScript的基本类型传递的是值,而不是引用:

引用和值的区别

将值作为属性传递给对象也会发生同样的问题。如果在赋值操作过程中或者从一个函数中返回时,一个可反应值不能保持它的反应状态则没有什么实际意义。为了从计算属性中能够一直读取到最新的值,我们需要将真实的值包装在一个对象中,然后返回这个对象:

// 简单的伪代码
function computed(getter) {
	const ref = {
		value: null
	}
	watch(() => {
		ref.value = getter()
	})
	return ref
}
复制代码

另外,为了执行依赖追踪和更改通知,我们还需要拦截这个对象的 .value 属性的读 / 写操作(简单起见,此处省略了代码)。现在我们可以按引用传递计算值,而无需担心失去反应性。不过为了取到最新值,我们现在需要使用 .value 来访问:

const double = computed(() => state.count * 2)

watch(() => {
	console.log(double.value)
}) // output: 0

state.count++ // output: 2
复制代码

在上面的例子中 double 是一个对象,我们称之为 引用( ref ),因为它为内部持有的值提供可反应引用。

你可能注意到了Vue已经有了 "refs" 的概念,但是它只适用于在模板中引用DOM元素或者组件实例。查看这里以了解新的引用系统(refs system)如何在逻辑状态值和模板引用中都可以使用。

除了计算引用( computed refs )外,我们还可以通过 ref API直接创建单纯的可变引用:

const count = ref(0)
console.log(count.value) // 0

count.value++
console.log(count.value) // 1
复制代码

4.1.3 引用展开

我们可以将一个引用作为渲染上下文的属性公开。在内部,Vue将会对在渲染上下文中遇到的所有引用执行特殊对待,上下文直接展开引用内部的值。这意味着我们可以直接写 {{ count }} ,而不需要写成 {{ count.value }}

下面这个例子是使用 ref 代替 reactive 实现与上面计算器相同的例子:

import { ref, watch } from 'vue'

const count = ref(0)

function increment() {
	count.value++
}

const renderContext = {
	count,
	increment
}

watch(() => {
	renderTemplate(
		`<button @click="increment">{{ count }}</button>`,
		renderContext
	)
})
复制代码

除此之外,当一个引用作为属性嵌套一个可反应对象下时,在访问时也会自动展开:

const state = reactive({
	count: 0,
	double: computed(() => state.count * 2)
}) 

// 不需要使用 `state.double.value`
console.log(state.double)
复制代码

4.1.4 组件中的用法

到目前为止,我们的代码已经提供了能够根据用户输入而更新的有效UI,但是这份代码只运行了一次并且不可重复使用。如果我们希望复用这些逻辑,将它们包装成一个函数看上去似乎是合理的下一步:

import { reactive, computed, watch } from 'vue'

function setup() {
	const state = reactive({
		count: 0,
		double: computed(() => state.count * 2)
	})
	
	function increment() {
		state.count++
	}
	
	return {
		state,
		increment
	}
}

const renderContext = setup()

watch(() => {
	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>,但是依旧和你所熟悉的一样。

4.1.5 生命周期钩子(Lifecycle Hooks)

目前为止,我们的介绍已经包含了一个组件的纯状态方面:用户输入的可反应状态( reactive state )、计算状态( computed state )、可变状态( mutating state )。但是一个组件可能也需要执行副作用,比如打印日志、发送ajax请求、或者在 window 上建立一个事件监听。这些副作用通常在以下时间节点执行:

  • 当一些状态变更时;
  • 当组件渲染完成( mounted )、更新( updated )、或者卸载( unmounted )时(生命周期的钩子函数)

我们都知道可以基于状态变更使用 watch API来应用副作用。而在不用的生命周期钩子中执行副作用,我们可以使用指定的 onXXX APIs(与现有的生命周期选项一一对应):

import { onMounted } from 'vue'

export default {
	setup() {
		onMounted(() => {
			console.log('mounted...')
		})
	}
}
复制代码

这些生命周期方法只能在 setup 钩子中注册调用。它会通过使用内部全局状态值自动判断当前调用 setup 的实例。之所以这样设计是为了减少我们提取逻辑到外部函数中时的迷惑。

更多关于这些APIs的细节可以在API 索引中找到。但是,建议在深入细节之前先完成后续的章节阅读。

4.2 代码组织

在这之前,我们已经通过结合导入函数( imported function )复制实现了组件现有的API,但这么做是为了什么呢?

明明通过选项定义组件、看上去比把所有东西都混合在一个大函数中要有组织的多!!!

这些想法是可以理解的,但正如在 动机 章节中所描述的,我们相信合成API( Composition API )真的能够更好的组织代码,尤其是在复杂的组件中。接下来将尝试解释为什么。

4.2.1 什么是“组织代码”?

让我们回过头重新思考一下,当我们在讨论“组织代码”的时候,我们真正想说的是什么。使代码有组织性的终极目标应该是让代码更加容易阅读和理解,那我们说的“理解代码”的本质是什么?我们真的可以仅仅因为知道某个组件包含哪些选项,而声称我们“理解”了这个组件吗?你是否深入过一个由其他开发者写的超大组件(这里就有一个例子)、并且发现很难将其牢牢掌握?

思考一下你会如何向你的开发者朋友阐述介绍类似上面链接中的超大组件。你很可能青睐于从 “这个组件处理了X, Y和Z” 来开始介绍,而不是 “这个组件有这些data属性,这些computed属性和一些方法。” 当我们尝试理解一个组件时,我们更多关心的是 “这个组件正在尝试做什么” ,而不是 “这个组件中使用了哪些选项”。使用现有的基于选项的API来撰写的代码很自然的就能解释后者,但是在描述前者时表达的很不友好,甚至是差劲。

4.2.2 逻辑关注点 vs 选项类型(Logical Concerns vs. Option Types)

我们将组件处理的 “X, Y和Z” 定义为逻辑关注点。小而功能单一的组件中一般不存在可读性的问题,因为整个组件都聚焦在单一的逻辑处理。总之越是高级的使用场景,这个问题越突出。就以 Vue CLI file for explorer 为例,这个组件不得不处理大量的逻辑关注点:

  • 跟踪当前目录状态并展示它的内容
  • 处理目录导航(打开、关闭、刷新)
  • 处理新目录的创建
  • 切换显示收藏夹
  • 切换显示隐藏的文件夹
  • 处理当前工作目录的变更、切换

您能通过阅读这些基于选项的代码,立刻知道并区分这些逻辑关注点吗?有点难顶!你会发现与某一个特定逻辑关注点相关联的代码通常分散在各处。例如,"创建新目录"(crete new folder) 功能使用了两个data属性一个方法。注意观察:文件中这个方法的定义和data属性定义的距离超过了100行。

如果我们对每个逻辑关注点的代码进行着色,我们会发现当使用基于选项的方式书写组件时,这些逻辑关注点的代码是有多分散:

逻辑关注点代码着色

正是这些代码的分散和碎片化、以及选项的强制分离使得逻辑关注点变得模糊而导致的一个复杂组件难以理解和维护。另外,当我们关注一个逻辑点时,会不得不经常的在不同的选项中跳来跳去——只为了查找和它相关的另一个逻辑。

注意:源码可能会在几个地方改进,但是在撰写本文时展示的是最新提交的版本,无需任何修改就可以提供一个我们自己可能会在真实生产环境写的案例。

如果我们能够将逻辑关注点相关联的代码放在一起,那就再好不过了。这正是 合成API( Composition API ) 赋予的能力。上面“创建新目录”的功能可以通过这种方式书写:

function useCreateFolder(openFolder) {
	// 初始data属性
	const showNewFolder = ref(false)
	const newFolderName = ref('')
	
	// 初始computed属性
	const newFolderValid = computed(() => isValidMultiName(newFolderName.value))
	
	// 初始一个方法
	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
	}
}
复制代码

可以注意到我们是如何将"创建新目录"功能相关的所有逻辑都放在一起并封装在一个函数中的。由于其语义化的名称,这个函数某种程度上也是自带文档(self-documenting)的。在命名函数时,建议约定以 use 开始,以指明这是一个合成函数。这个模式可以被应用到组件中的所有逻辑关注点,进而更好的对功能解耦:

选项模式和合成模式

上图的对比排除了 import 语句和 setup() 函数。此功能组件使用合成API( Composition API )的重新实现可点击这里查看

现在:

  • 每个逻辑关注点的代码都放置在一个合成函数中 => 现有的在组件的选项之间频繁的"跳转"动作显著减少了
  • 合成函数在编辑器中可以被折叠 => 组件代码更加清晰明了
export default {
	setup() {
		// ...some code here
	}
}

function useCurrentFolderData(nextworkState) { // ...
}

function useFolderNavigation({ nextworkState, 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 这个函数读起来就好像是口头描述这个组件开始尝试做什么一样 —— 这是基于选项模式完全没有的。您还可以根据传递的参数清楚的看到各个合成函数之间的依赖关系流。最后,return 语句作为唯一的出口、可以检查暴露给模板的属性有哪些。

对于给定的功能,通过选项模式书写的组件和通过合成函数书写的组件基于同一个逻辑、表现出了两种不同的组织方式。选项模式强制我们基于 选项类型(option types) 组织代码,而合成API( Composition API )允许我们基于各个逻辑关注点组织代码。

4.3 逻辑提取 & 复用(Logic Extraction and Reuse)

当涉及到跨组件之间提取、复用逻辑时,Compositon API非常的灵活。一个合成函数只依赖于它的参数和全局引入的Vue APIs,而不是充满魔法的 this 上下文。只需要将组件中你想复用的那部分代码,简单的将它导出为函数就可以了。你甚至可以通过导出组件的整个 setup 函数实现和 extends 等价的功能。

现在我们来看一个例子:追踪鼠标位置。

import { ref, onMounted, onUnMounted } from 'vue'

export function useMouseTracking() {
	const x = ref(0)
	const y = ref(0)
	
	function update(e) {
		x.value = e.clientX
		y.value = e.clientY
	}
	
	onMounted(() => {
		window.addEventListener('mousemove', update)
	})
	
	onUnMounted(() => {
		window.removeEventListener('mousemove', update)
	})
	
	return {
		x, y
	}
}
复制代码

在其他组件中引用:

import { useMouseTracking } from '/path/to/useMouseTracking'

export default {
	setup() {
		const { x, y } = useMouseTracking()
		// ...其他的逻辑代码
		return {
			x, y
		}
	}
}
复制代码

在上面的合成API版本的文件访问例子中,我们已经提取了一些实用代码(例如 usePathUtilsuseCwdUtils)到一个外部文件中,正是因为我们发现对于其他的组件,它们同样很有用。

类似的逻辑复用也可以通过现有的方法来实现,比如 mixins 、高阶组件或者无渲染组件(通过 scoped slots)。网上有很多关于这些方法的如何使用的解释,此处就不再过多介绍了。高阶层次的想法是,这些方法模式中每一个对比合成函数都有缺点:

  • 在渲染上下文中,暴露的属性来源不清晰。例如,当我们阅读使用了多个 mixins 的组件模板时,很难判断出某个特定属性是由哪个 mixin 注入的
  • 命名空间冲突。Mixins 潜在的与属性名、方法名冲突,而高阶组件可能会与预期的 prop 名称冲突。
  • 性能。高阶组件和无渲染组件需要额外有状态的组件实例配合实现,会造成一定的性能消耗。

相比之下,使用合成API( Composition API ):

  • 暴露给模板的属性值由于是由合成函数返回的,所以它们有清晰的来源
  • 合成函数的返回值可以被任意命名,所以不会发生命名冲突
  • 为了逻辑复用,没有创建不必要的组件实例

4.4 和现有API结合使用(Usage Alongside Exisiting API)

合成API能够与现有基于选项模式的API结合使用。

  • 合成API已经在2.x选项( data, computedmethods )之前完成,并且没有权限访问由这些选项定义的属性
  • setup() 函数返回的属性将会挂载到 this 上,并且在2.x的选项中可以访问

4.5 插件开发(Plugin Development)

如今,很多Vue插件都将属性挂载到 this 上。例如: Vue Router 会注入 this.$routethis.$router, Vuex 注入了 this.$store。由于每个插件都要求用户为注入的属性增加Vue类型,导致类型推断变得有点棘手。

当使用合成API时,不可以使用 this。替而代之的,插件将利用内置的 provideinject 并抛出一个合成函数。下面是一个插件的伪代码:

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 也可以通过 Global API change RFC 中所建议的,通过应用程序级别来提供,但 useStore API在消费者组件中就将是一样的。

不足之处(Drawbacks)

5.1 Refs的开销

从技术上来讲,Ref是本提案唯一的一个新概念。之前也介绍过,它的作用是为了将可反应的值作为变量传递,并且不要依赖于 this。这样做的缺点在于:

  1. 当使用这个合成API时,我们需要不断的从单纯的值和引用对象之间区分refs,进而会增大理解上的精神负担。不过我们可以通过命名约定大幅减少这种精神负担,例如:为所有引用变量(ref variables)加上后缀 xxxRef,或者依赖于类型系统。另外一方面,由于在代码组织上提升了灵活性,组件的逻辑将会被切割成很多的小函数,这些函数的本地上下文很简单,引用的开销也很容易管理。
  2. 相比于单纯的值,refs的读取和变更显得要冗长些,因为需要通过 .value 访问。有些人建议通过编译时语法糖(类似Svelte 3)来解决这个问题。尽管在技术上来说是可行的,但我们认为在Vue中这样给予默认值没有什么意义(正如在与Svelte相比较中所讨论的)。换句话说,如果将它作为一个Babel插件处理这个问题,从技术上是可行的。

我们已经讨论了是否有可能完全禁用Ref概念、并且只使用可反应对象,但无论如何有些情况需要考虑:

  • 计算属性的getters能够返回基本类型,所以类似Ref的容器不可避免的需要使用;
  • 出于反应性的考虑,合成函数期望或者返回的只有基本类型值时,也需要将值包裹在对象中。如果框架没有提供标准的实现,用户很有可能会实现他们自己喜欢的Ref模式(这样会导致生态系统碎片化)。

5.2 引用 vs 反应性(Ref vs. Reactive)

可以预测得到,用户可能会对介于 refreactive 使用哪一个感到困惑。首先你需要知道的是,你必须要理解两者,以更有效的的使用合成API。只使用其中一个的话,可能会导致一些神奇的问题(esoteric workarounds)或者重新造了个轮子。

使用 refreactive 之间的区别,部分取决于与你会如何书写你的逻辑代码:

// 第一种:独立的变量
let x = 0
let y = 0

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

// 第二种:一个对象
let pos = {
	x: 0,
	y: 0
}
function updatePosition(e) {
	pos.x = e.pageX
	pos.y = e.pageY
}
复制代码
  • 如果使用 ref, 我们主要使用refs将第一种转换成更冗长的等式(就为了让基本类型的值具有反应性)
  • 如果使用 reactive,我们的代码将会和第二种几乎一样,只需要使用 reactive 创建对象就可以了

总之,只使用 reactive 的问题主要是:合成函数的消费者必须一直与函数的返回值保持引用关联,以保持反应性。这个对象不可以被解构或者被展开:

// 合成函数
function useMousePosition() {
	const pos = reactive({
		x: 0,
		y: 0
	})
	// ...
	return pos
}

// 使用合成函数的组件
export default {
	setup() {
		// 反应性丢失!
		const { x, y } = useMousePosition()
		return {
			x, y
		}
		
		// 反应性丢失!
		return {
			...useMousePosition()
		}
		
		// 只有这样才会保持反应性
		// 你必须原样返回 `pos`, 在模板中使用 `pos.x` 和 `pos.y`
		return {
			post: useMousePosition()
		}
	}
}
复制代码

toRefs API可以被用来处理这种约束,它将每个可反应对象转换为相应的引用:

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

// x & y 现在是refs了!
const { x, y } = useMousePosition()
复制代码

总结起来,有两种可行的风格:

  1. 同时使用 refreactive 就好像在普通的JS中,如何声明基本类型的变量和对象变量一样。当使用这种风格时建议使用一个IDE支持的类型系统。
  2. 尽可能使用 reactive, 并且在合成函数中返回反应性对象时记得使用 toRefs。这会减少一些花在 refs 上的精力,但并不意味着你不需要去了解这个API。

在本阶段,我们觉得为 ref vs. reactive 制定最佳实践还为时过早。我们建议您从上面的两种风格中衡量一下,并选择适合自己预期的模型。我们会收集全世界用户的反馈,并就这个话题提供一个更加明确的指引。

5.3 Return语句的冗长(Verbosity of the Return Statement)

一些用户已经提出了关于 setup() 函数中的 return 语句冗长、而且跟样板一样的担忧。

我们认为一个明确的 return 语句有益于维护性。它让我们可以精确的控制有哪些属性暴露给了模板,并且当我们想知道模板中用到的一个属性是在组件中哪里定义的时候,可以作为一个入口点进行追踪。

过去有一些建议说,自动暴露在 setup() 中声明的变量,return 语句作为可选项。再说一次,由于这个违背了标准JavaScript的直觉,我们不认为它应该是个默认选项。但是站在用户的角度,有几个方案可以做到这些:

  • IDE扩展基于 setup() 中声明的变量,自动生成 return 语句
  • Babel插件隐式生成并插入 return 语句

5.4 越灵活,就越要有纪律(More Flexibility Requires More Discipline)

很多用户指出,当合成API提供了更加灵活的代码组织方式时,为了让开发者做正确的事,同时也得要求更多规矩。一些人担心经验不足的开发者会写出意大利面条式代码。换句话说,尽管合成API提高了代码质量的上限,但它同时也降低了质量下限。

某种程度上我们同意上面的观点。但我们认为:

  1. 上限的收益远大于下限的损失;
  2. 通过相应的文档和社区的指导,我们可以有效的解决代码组织的问题。

有些用户使用 Angular 1 的 controllers 作为(不好的)设计会导致不好的代码的示例。在合成API和Angular 1的控制器之间,它们最大的区别是它不依赖于一个共享的局部上下文。这样就可以很容易的将逻辑拆分到函数中,这也是JavaScript代码组织的核心机制。

任何JavaScript程序都从一个入口开始(就好比 setup())。我们基于逻辑关注点、通过将它们分离到函数和模块中来构建程序,而合成API赋予我们为Vue组件的代码做同样的事情的能力(The Composition API enables us to do the same for Vue component code)。换句话说,当使用合成API时,书写优雅的JavaScript代码的技能等同于书写优雅的Vue代码的技能。

6. 采用策略

不会影响、也不会放弃任何现有的 2.x APIs,因为合成API完全是新增、独立的。通过@vue/composition 库作为一个插件已经可以在2.x中使用了。这个库的主要目标是提供API实验和收集用户反馈。当前的实现进度和本提案是保持同步更新的,但可能会由于插件技术的限制,可能会包含一些不一样的地方。也可能会由于本提案的更新出现破坏性的变更,所以在当前阶段我们不建议在生成环境中使用。

我们尝试将这些API内置在3.0中,与2.x的选项可以一起使用。

对于只选择使用合成API的用户,可以提供一个编译时期的flag以用来丢弃适用于处理2.x选项的代码,这样做可以减小包的体积。无论如何,这些都是可以选择的。

这些API将被作为高级功能,因为它处理的问题主要出现在大型的应用程序里。我们不会尝试将它作为默认文档,相反在文档中将会有专门的章节来介绍它。

7. 附录

7.1 Class API的类型问题(Type Issues with Class API)

曾经引入Class API的主要目的,是想为了获取更好的Typescript推论支持找到一个替代的API方案。但事实上Vue的组件需要合并来自多个源对象的属性挂载到单一的 this 上下文,而这会产生很多挑战 - 即便是基于Class API。

一个例子是为 props 声明类型。为了将 props 合并到 this 上,我们必须在组件类上使用一个泛型参数,或者使用装饰器。

以下是个使用了泛型参数的例子:

interface Props {
	message: string
}

class App extends Component<Props> {
	static props = {
		message: String
	}
}
复制代码

尽管将接口声明传递给了泛型参数,用户仍然需要为 this 上的 props 代理行为提供一个运行时的 props 声明。这种双重声明很没必要。

我们也考虑过使用装饰器来代替:

class App extends Component<Props> {
	@prop message: string
}
复制代码

使用装饰器会导致依赖于一个有很多不确定性的、处在stage-2的提案,而且Typescript现在的实现和TC39的提案完全不同步。另外,无法将使用装饰器实现的props的类型声明暴露给 this.$props,这会破坏TSX的支持。用户可能也会猜想可以为 prop 声明一个默认值,例如 @prop message: string = 'foo',可从实际技术出发,它们并没有按预期工作。

除此之外,目前尚无办法为类的方法参数使用上下文类型 - 这就意味着传递给一个类的render函数的参数无法基于类的其他属性使用类型推断。

7.2 与React Hooks的比较

这种基于函数的API提供了与React Hooks相同级别的逻辑组合能力,但也有一些很重要的不同之处。和React Hooks不同,setup() 方法只会被调用一次,这意味着使用了Vue的合成API的代码:

  • 一般情况下更符合常用的JavaScript代码直觉
  • 对调用顺序不敏感,也可以有条件的执行
  • 不会在每次渲染时都重复调用,并产生相对较小的垃圾回收机制压力
  • 不必考虑为了防止内联处理程序导致的子组件过渡重新渲染,而到处需要使用 useCallback 的问题
  • 不必考虑如果用户忘记传递正确的依赖数组项、而导致的 useEffectuseMemo 可能捕获过失的变量的问题,Vue的自动依赖想追踪功能会确保侦听器和计算属性值一直保持正确不过期。(原文:ensures watchers and computed values are always correctly invalidated。最后的 invalidated 可能是作者输错了)

我们非常认可React Hooks的创造价值,它也是本提案的重要灵感来源之一。总之,上面提到的问题真实存在于它的设计中,我们注意到Vue的可反应模型正好提供了解决方案。

7.3 与Svelte的比较

尽管采用的路线截然不同,但是合成API和Svelte 3基于编译期的方法从概念上讲,实际上有很大的通性。下面是个例子

Vue:

<script>
import { ref, watch, onMounted } from 'vue'

export default {
	setup() {
		const count = ref(0)
		
		function increment() {
			count.value++
		}
		
		watch(() => {
			console.log(count.value)
		})
		
		onMounted(() => {
			console.log('mounted')
		})
		
		return {
			count,
			increment
		}
	}
}
</script>
复制代码

Svelte:

<script>
import { onMount } from 'svelte'

let count = 0

function increment() {
	count++
}

$: console.log(count)

onMount(() => {
	console.log('mounted')
})
</script>
复制代码

Svelte的代码看上去简洁很多主要是因为它在编译时期做了如下的事情:

  • 隐式的将整个 <script> 块(除去 import 语句)包装到被每个组件实例调用的函数中(而不是只被执行一次)
  • 隐式的为可变的变量注册了反应性(原文:Implicitly registers reactivity on variable mutations)
  • 隐式的将局部变量暴露到渲染上下文中
  • $ 语句将被编译成重新执行的代码

从技术上来讲,在Vue中我们可以做同样的事情(用户也可以通过Babel插件)。我们没有这样做的主要原因是 和标准的JavaScript 保持一致。如果你从一个Vue文件中提取了 <script> 的代码,我们希望它和标准的ES Moudle一样。Svelte的 <script> 块内部的代码内容,某种方面来看,从技术上根本不是标准的JavaScript。这种基于编译期的方法存在很多问题:

  1. Code works differently with/without compilation(大致意思是有无编译流程,代码工作表现的不一致,后文也是)。作为一个渐进式的框架,很多Vue用户可能期望、需要、必须在没有构建流程的情况下使用它,所以“需要构建”不能作为默认项。另一方面,Svelte将自己作为一个编译器,并且只能配合构建流程使用。这是两个框架在有意识的做出取舍。
  2. Code works differently inside/outside components。当尝试从一个Svelte组件中,提取出逻辑并放到一个标准的JavaScript文件中时,会失去具有魔法性的简洁语法,而且必须使用更加冗长低级API
  3. Svelte的自动增加反应性只对顶层变量有效 - 不处理函数内部声明创建的变量,因此我们无法将反应性状态封装在组件内部声明的函数中
  4. 不标准的语义导致与Typescript集成时会有问题。

这些绝不是在说Svelte 3的想法很烂 - 事实上,这些都是很有创意的实现,我们非常认可Rich的工作。但是基于Vue的设计理念和目标,我们必须做一些不同的取舍。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改