前言
vue3目前已经发布了alpha版本,除了服务端渲染,其它工作已经全部完成。尤大大也升级了vue-loader,提供了一个可以使用.vue组件的测试模板。vue3最大的改变是加入了这个灵感来源于React Hook的Composition API(组成API),这个API将对vue编程产生了根本性变革,但是vue3还是兼容vue2的Options API。除此之外,还引入了一些不兼容修改,具体可查看Vue3已合并的RFC。本文并不会全面介绍vue3的新特效,只会着重与vue3的核心——Composition API。
小试牛刀
先看一下基础例程:
<template>
<button @click="increment">
Count is: {{ state.count }}, double is: {{ state.double }}
</button>
</template>
<script>
import { reactive, computed } from 'vue'
export default {
setup(props, context) {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
})
function increment() {
state.count++
}
return {
state,
increment
}
}
}
</script>
从上面的基础例程可以看到,vue3的.vue组件大体还是和vue2一致,由template``、script
和style
组成,作出的改变有以下几点:
- 组件增加了setup选项,组件内所有的逻辑都在这个方法内组织,返回的变量或方法都可以在模板中使用。
- vue2中
data
、computed
等选项仍然支持,但使用setup
时不建议再使用vue2中的data
灯选项。 - 提供了
reactive
、computed
、watch
、onMounted
等抽离的接口代替vue2中data
等选项。
为什么Composition API?
为什么使用Composition API代替Options API?难道将各种类型的代码分类写在对应的地方不比把所有的代码都写在一个setup函数中要好?要解答这两个问题,我们首先来思考一下Options API的局限。
代码组织
Options API是把代码分类写在几个Option中:
export default {
components: {
},
data() {
},
computed: {
},
watch: {
},
mounted() {
},
}
当组件比较简单只有一个逻辑的时候,稍微麻烦的是用户必须在几个Option之间跳来跳去,这个是小问题,写代码哪能不用鼠标呢😄?挑战往往在复杂的情况下才会出现。当一个vue组件内存在多个逻辑时会怎样呢?
图片中相同的颜色表示一种逻辑。
- 使用Options API时,相同的逻辑写在不同的地方,各个逻辑的代码交叉错乱,这对维护别人代码的开发者来说绝不是一件简单的事,理清楚这些代码都需要花费不少时间。
- 而使用Composition API时,相同的逻辑可以写在同一个地方,这些逻辑甚至可以使用函数抽离出来,各个逻辑之间界限分明,即便维护别人的代码也不会在“读代码”上花费太多时间(前提是你的前任会写代码)。
必须指出的是,Composition API提高了代码的上限,也降低了代码的下限。在使用Options API时,即便再菜的鸟也能保证各种代码按其种类进行划分。但使用Composition API时,由于其开放性,出现什么代码是无法想象的。但毫无疑问,Options API到Composition API是vue的一个巨大进步,vue从此可以从容面对大型项目。
逻辑抽取与复用
在vue2中要实现逻辑复用主要有两种方式:
mixin
mixin的确能抽取逻辑并实现逻辑复用(我更多用它来定义接口),但这种方式着实不好,mixin更多是一种代码复用的手段: * 命名冲突。mixin的所有option如果与组件或其它mixin相同会被覆盖(这个问题可以使用Mixin Factory解决)。 * 没有运行时实例。顾名思义,mixin不过是把对应的option混入组件内,运行时不存在所抽取的逻辑实例。 * 松散的关系。Options API注定所抽取的逻辑的组织是松散,逻辑内部之间的关系也是松散的。 * 含蓄的属性增加。mixin加入的option是含蓄的,新手会迷惑于莫名其妙就存在的一个属性,尤其是在有多个mixin的时候,无法知道当前属性是哪个mixin的。
Scoped slot
scoped slot也可以实现逻辑抽取,使用一个组件抽取逻辑,然后通过作用域插槽暴露给子组件。
// GenericSort.vue
<template>
<div>
<!-- 暴露逻辑的数据给子组件 -->
<slot :data="data"></slot>
</div>
</template>
<script>
export default {
// 在这里完成逻辑
data() {
}
}
</script>
使用的时候
<template>
<!--传入未排序的数据unSortdata-->
<GenericSort data="unSortdata">
<template v-slot="sortData">
<!-- 使用经过处理后的sortData数据 -->
<template>
</GenericSearch>
</template>
但这种方式还是有许多缺点:
- 增加缩进。一两个时没多大影响,但过多时使可读性变差。
- 一般需要增加配置,不灵活。需要在slot上增加配置,以应对更多的情况。
- 性能差。仅为了抽取逻辑,需要创建维护一个组件实例。
Composition Function
在vue3中提供了一种叫Composition Function的方式,这种方式允许像函数般抽离逻辑:
<template>
<div>
<p> {{count}}</p>
<button @click="onClick" :disabled="state">Start</button>
</div>
</template>
<script>
import {ref} from 'vue'
// 倒计时逻辑的Composition Function
const useCountdown = (initialCount) => {
const count = ref(initialCount)
const state = ref(false)
const start = (initCount) => {
state.value = true;
if (initCount > 0) {
count.value = initCount
}
if (!count.value) {
count.value = initialCount
}
const interval = setInterval(() => {
if (count.value === 0) {
clearInterval(interval)
state.value = false
} else {
count.value--
}
}, 1000)
}
return {
count,
start,
state
}
}
export default {
setup() {
// 直接使用倒计时逻辑
const {count, start, state} = useCountdown(10)
const onClick = () => {
start()
}
return {count, onClick, state}
}
}
</script>
vue3建议使用如React hook中一样使用use开头命名抽取的逻辑函数,如上代码抽取的逻辑几乎如函数一般,使用的时候也极其方便,完胜vue2中抽取逻辑的方法。
ts类型支持
vue2比较令人诟病的地方还是对ts的支持,对ts支持不好是vue2不适合大型项目的一个重要原因。其根本原因是Vue依赖单个this
上下文来公开属性,并且vue中的this
比在普通的javascript更具魔力(如methods对象下的单个method中的this
并不指向methods,而是指向vue实例)。换句话说,尤大大在设计Option API时并没有考虑对ts引用的支持。那vue2中是怎样做到对ts的支持?
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
@Component
export default class YourComponent extends Vue {
@Prop(Number) readonly propA: number | undefined
@Prop({ default: 'default value' }) readonly propB!: string
@Prop([String, Boolean]) readonly propC: string | boolean | undefined
}
</script>
vue2对ts的支持主要是通过vue class component,还需引入vue-property-decorator包,该库完全依赖于vue-class-component包。咋一看,这不是支持了吗?下面聊聊这种方式的缺点:
- vue class component与js的vue组件差异太大,另外需要引入额外的库,学习成本大幅度增高。
- 依赖于装饰器语法。而目前装饰器目前还处于stage2阶段(可查看tc39 decorators),在实现细节上还存在许多不确定性,这使其成为一个相当危险的基础。
- 复杂性增高。采用Vue Class Component且需要使用额外的库,相比于简单的js vue组件,显然复杂化。
这些原因让人望而却步,vue2的ts项目数量不多也是可以让人理解的。相比与vue2,vue3对ts的支持则好得多:
- vue3中是在setup中进行编程,setup不依赖
this
,大部分API大多使用普通的变量和函数,它们天然类型友好。 - 用Composition API编写的代码可以享受完整的类型推断,几乎不需要手动类型提示。这也意味着用提议的API编写的代码在TypeScript和普通JavaScript中看起来几乎相同。
- 这些接口已获得更好的IDE支持,即使非TypeScript用户也可以从键入中受益。
接口一览
Composition API是一系列接口的总称,下文将逐一介绍Composition API的各个接口。学习代码
setup
setup是vue新增的一个选项,它是组件内使用Composition API的入口。setup中是没有this
上下文的(js 函数都有this,但是setup的this跟vue2的this不是一个东西,已经完全没用了,故为没有)。
- 执行时机
setup是在创建vue组件实例并完成props的初始化之后执行,也是在beforeCreate
钩子之前执行,这意味着setup中无法使用其它option(如data)中的变量,而其它option可以使用setup中返回的变量。 - 与模板结合使用
如果setup返回一个对象,这个对象的所有属性会合并到template的渲染上下文中,也就是说可以在template中使用setup返回的对象的属性。
<template>
<div>{{ count }} {{ object.foo }}</div>
</template>
<script>
import { ref, reactive } from 'vue'
export default {
setup() {
const count = ref(0)
const object = reactive({ foo: 'bar' })
// 暴露至模板中
return {
count,
object
}
}
}
</script>
- 与Render Function/jsx结合使用
setup也可以返回render function,这个时候则与vue2中的render方法l类似,此时不需要template。
import { h, ref, reactive } from 'vue'
export default {
setup() {
const count = ref(0)
const object = reactive({ foo: 'bar' })
return () => h('div', [
count.value,
object.foo
])
}
}
setup
的参数
setup有两个参数,第一个参数为props,是组件的所有参数,与vue2中一样,参数是响应式的,改变会触发更新;第二个参数是setup context,包含emit等属性。下面是其函数签名:
interface Data {
[key: string]: unknown
}
interface SetupContext {
attrs: Data
slots: Slots
parent: ComponentInstance | null
root: ComponentInstance
emit: ((event: string, ...args: unknown[]) => void)
}
function setup(
props: Data,
context: SetupContext
): Data
reactive
reactive
函数接收一个对象,并返回一个对这个对象的响应式代理。它与vue2中的Vue.obserable()
是等价的,为避免与RxJs中的observable
重名,故改名为reactive
。
<template>
<div>
<input type="text" v-model="state.input">
<p>input: {{state.input}}</p>
<p>computedInput: {{state.computedInput}}</p>
</div>
</template>
<script>
import {reactive, computed, watch} from 'vue'
export default {
setup(props) {
const state = reactive({
input: '',
computedInput: computed(() => '? '+state.input+' ?' )
})
return {state, singleComputed}
}
}
</script>
必须注意的是,reactive使用者必须始终保持对返回对象的引用,以保持反应性。该对象不能被破坏或散布,所有setup和组合函数中不能返回reactive的解构。
<template>
<div>
{{input}}
<button @click="onClick">change</button>
</div>
</template>
<script>
import {reactive, computed, watch, toRefs} from 'vue'
export default {
setup(props) {
const state = reactive({input: ''})
const onClick = () => {
state.input = '我已经改变了'
}
// return state 模板上下文丢失引用
// return {...state, onClick} 模板上下文丢失引用
return {...toRefs(state), onClick} // 正确的
}
}
</script>
如上组件如果不使用toRefs
,在点击change按钮时,组件并不会重新渲染,也就是说模板中的input还是之前的值。出现这种现象的根本原因是js中是值传递,并不是引用传递。解决方案是使用toRefs
,至于ref是什么,请看下文。
ref
ref函数接收一个用于初始化的值并返回一个响应式的和可修改的ref对象。该ref对象存在一个value属性,value保存着ref对象的值。
<template>
<div class="tf">
<div>
<!--在模板中使用时不需要使用count.value, 会自动解包-->
<p>{{count}}</p>
<button @click="onClick(true)">+</button>
<button @click="onClick(false)">-</button>
</div>
</div>
</template>
<script>
import {ref, reactive} from 'vue'
export default {
setup() {
const count = ref(0)
<!--在reactive中使用也不需要count.value, 也会解包-->
const state = reactive({count})
const onClick = (isAdd) => {
isAdd ? count.value++ : count.value --
}
return {count, onClick}
},
}
</script>
注意:在reactive和tempalte中使用的时候,不需要使用.value
,它们会自动解包。
isRef
isRef
用于判断变量是否为ref对象。
const unwrapped = isRef(foo) ? foo.value : foo
toRefs
toRefs
用于将一个reactive对象转化为属性全部为ref对象的普通对象。
const state = reactive({
foo: 1,
bar: 2
})
const stateAsRefs = toRefs(state)
/*
Type of stateAsRefs:
{
foo: Ref<number>,
bar: Ref<number>
}
*/
toRefs
在setup或者Composition Function的返回值特别有用:
import {reactive, toRefs} from 'vue'
function useFeatureX() {
const state = reactive({
foo: 1,
bar: 2
})
return state
}
function useFeature2() {
const state = reactive({
a: 1,
b: 2
})
return toRefs(state)
}
export default {
setup() {
// 使用解构之后foo和bar都丧失响应式
const { foo, bar } = useFeatureX()
// 即便使用了解构也不会丧失响应式
const {a, b}= useFeature2()
return {
foo,
bar
}
}
}
computed
computed函数与vue2中computed功能一致,它接收一个函数并返回一个value为getter返回值的不可改变的响应式ref对象。
const count = ref(1)
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value) // 2
plusOne.value++ // 错误,computed不可改变
// 同样支持set和get属性
onst count = ref(1)
const plusOne = computed({
get: () => count.value + 1,
set: val => { count.value = val - 1 }
})
plusOne.value = 1
console.log(count.value) // 0
readonly
readonly
函数接收一个对象(普通对象或者reactive对象)或者ref并返回一个只读的参数对象代理,在参数对象改变时,返回的代理对象也会相应改变。如果传入的是reactive或ref响应对象,那么返回的对象也是响应的。
<template>
<!--vue3中允许template下有多个根元素-->
<input type="text" v-model="state.count">
<button @click="onClick">change</button>
</template>
<script>
import {reactive, readonly, watch} from 'vue'
export default {
setup() {
const state = reactive({count: 12})
const rnState = readonly(state)
watch(() => {
// state改变也会触发rnState的watch
console.log(rnState.count)
})
const planObj = {count: 12}
const rnPlanObj = readonly(planObj)
const onClick = () => {
planObj.count = 888
console.log(rnPlanObj.count) // 888
}
return {state, onClick}
}
}
</script>
watch
相比于vue2的watch,Composition API的watch接口不仅仅是将其逻辑抽取出来,其功能也得到了极大的丰富。下面看其类型定义:
type StopHandle = () => void
type WatcherSource<T> = Ref<T> | (() => T)
type MapSources<T> = {
[K in keyof T]: T[K] extends WatcherSource<infer V> ? V : never
}
type InvalidationRegister = (invalidate: () => void) => void
interface DebuggerEvent {
effect: ReactiveEffect
target: any
type: OperationTypes
key: string | symbol | undefined
}
interface WatchOptions {
lazy?: boolean
flush?: 'pre' | 'post' | 'sync'
deep?: boolean
onTrack?: (event: DebuggerEvent) => void
onTrigger?: (event: DebuggerEvent) => void
}
// basic usage
function watch(
effect: (onInvalidate: InvalidationRegister) => void,
options?: WatchOptions
): StopHandle
// wacthing single source
function watch<T>(
source: WatcherSource<T>,
effect: (
value: T,
oldValue: T,
onInvalidate: InvalidationRegister
) => void,
options?: WatchOptions
): StopHandle
// watching multiple sources
function watch<T extends WatcherSource<unknown>[]>(
sources: T
effect: (
values: MapSources<T>,
oldValues: MapSources<T>,
onInvalidate: InvalidationRegister
) => void,
options? : WatchOptions
): StopHandle
- 基础用法
template>
<div>
<input type="text" v-model="state.count">{{state.count}}
<input type="text" v-model="inputRef">{{inputRef}}
</div>
</template>
<script>
import {watch, reactive, ref} from 'vue'
export default {
setup() {
const state = reactive({count: 0})
const inputRef = ref('')
// state.count与inputRef中任意一个源改变都会触发watch
watch(() => {
console.log('state', state.count)
console.log('ref', inputRef.value)
})
return {state, inputRef}
}
}
</script>
- 指定依赖源
在基础用法中:- 当依赖了多个reactive或ref是无法直接看到的,需要在回调中寻找.
- 在回调中我虽然使用了多个reactive,但我希望只有在某个reactive改变时才触发watch。
<template>
<div>
state2.count: <input type="text" v-model="state2.count">
{{state2.count}}<br/><br/>
ref2: <input type="text" v-model="ref2">{{ref2}}<br/><br/>
</div>
</template>
<script>
import {watch, reactive, ref} from 'vue'
export default {
setup() {
const state2 = reactive({count: ''})
const ref2 = ref('')
// 通过函数参数指定reative依赖源
// 只有在state2.count改变时才会触发watch
watch(
() => state2.count,
() => {
console.log('state2.count',state2.count)
console.log('ref2.value',ref2.value)
})
// 直接指定ref依赖源
watch(ref2,() => {
console.log('state2.count',state2.count)
console.log('ref2.value',ref2.value)
})
return {state, inputRef, state2, ref2}
}
}
</script>
- watch多个数据源
<template>
<div>
<p>
<input type="text" v-model="state.a"><br/><br/>
<input type="text" v-model="state.b"><br/><br/>
</p>
<p>
<input type="text" v-model="ref1"><br/><br/>
<input type="text" v-model="ref2"><br/><br/>
</p>
</div>
</template>
<script>
import {reactive, ref, watch} from 'vue'
export default {
setup() {
const state = reactive({a: 'a', b: 'b'})
// state.a和state.b任意一个改变都会触发watch的回调
watch(() => [state.a, state.b],
// 回调的第二个参数是对应上一个状态的值
([a, b], [preA, preB]) => {
console.log('callback params:', a, b, preA, preB)
console.log('state.a', state.a)
console.log('state.b', state.b)
console.log('****************')
})
const ref1 = ref(1)
const ref2 = ref(2)
watch([ref1, ref2],([val1, val2], [preVal1, preVal2]) => {
console.log('callback params:', val1, val2, preVal1, preVal2)
console.log('ref1.value:',ref1.value)
console.log('ref2.value:',ref2.value)
console.log('##############')
})
return {state, ref1, ref2}
}
}
</script>
- 取消watch
watch接口会返回一个函数,该函数用以取消watch。
<template>
<div>
<input type="text" v-model="inputRef">
<button @click="onClick">stop</button>
</div>
</template>
<script>
import {watch, ref} from 'vue'
export default {
setup() {
const inputRef = ref('')
const stop = watch(() => {
console.log('watch', inputRef.value)
})
const onClick = () => {
// 取消watch,取消之后对应的watch不会再执行
stop()
}
return {inputRef, onClick}
}
}
</script>
- 清除副作用
为什么需要清除副作用?有这样一种场景,在watch中执行异步操作时,在异步操作还没有执行完成,此时第二次watch被触发,这个时候需要清除掉上一次异步操作。
<template>
<div>
<h3>Cleanup</h3>
<input type="text" v-model="inputRef">
<input type="text" v-model="inputRef2">
</div>
</template>
<script>
import {ref, watch} from 'vue'
export default {
setup() {
const inputRef = ref('')
const getData = (value) => {
const handler = setTimeout(() => {
console.log('已获得数据', value)
}, 5000)
return handler
}
watch((onCleanup) => {
const handler = getData(inputRef.value)
// 清除副作用
onCleanup(() => {
clearTimeout(handler)
})
})
// 另外一种获取onCleanup的方式
const inputRef2 = ref('')
watch(inputRef2, (val, oldVal, onCleanup) => {
const handler = getData(val)
// 清除副作用
onCleanup(() => {
clearTimeout(handler)
})
})
return {inputRef, inputRef2}
}
}
</script>
watch提供了一个onCleanup的副作用清除函数,该函数接收一个函数,在该函数中进行副作用清除。那么onCleanup什么时候执行?
-
watch的callback即将被第二次执行时先执行onCleanup。
-
watch被停止时,即组件被卸载之后。
-
watch选项
watch接口还支持配置一些选项以改变默认行为,配置选项可通过watch的最后一个参数传入。 -
lazy
vue2中的watch默认在组件挂载之后是不会执行的,但如果希望立刻执行,可设置immediate为true。而在vue3中,watch默认会在组件挂载之后执行,如果希望取得与vue2 watch同样的行为,可配置lazy为true:<template> <div> <h3>Watch Option</h3> <input type="text" v-model="inputRef"> </div> </template> <script> import {watch, ref} from 'vue' export default { setup() { const inputRef = ref('') // 配置lazy为true之后,组件挂载不会执行,知道inputRef改变才执行 watch(() => { console.log(inputRef.value) }, {lazy: true}) return {inputRef} } } </script>
-
deep
如vue2中的deep参数一般,在设置deep为true之后,深层对象的任意一个属性改变都会触发回调的执行。<template> <div> <button @click="onClick">CHANGE</button> </div> </template> <script> import {watch, ref} from 'vue' export default { setup() { const objRef = ref({a: {b: 123}, c: 123}) watch(objRef,() => { console.log('objRef.value.a.b',objRef.value.a.b) }, {deep: true}) const onClick = () => { // 设置了deep之后,深层对象任意属性的改变都会触发watch回调的执行 objRef.value.a.b = 780 } return {onClick} } } </script>
-
flush
当同一个tick中发生许多状态突变时,Vue的反应性系统会缓冲观察者回调并异步刷新它们,以避免不必要的重复调用。默认的行为是:当调用观察者回调时,组件状态和DOM状态已经同步。
这种行为可以通过flush来进行配置,flush有三个值,分别是post
(默认)、pre
与sync
。sync
表示在状态更新时同步调用,pre
则表示在组件更新之前调用。 -
onTrack与onTrigger
onTrack与onTrigger用于调试:- onTrack:在reactive属性或ref被追踪为依赖时调用。
- onTrigger: 在watcher的回调因依赖改变而触发时调用。
<template> <div> <h4>test onTrigger & onTrack</h4> <input type="text" v-model="debugRef"> </div> </template> <script> import {watch, ref, reactive} from 'vue' export default { setup() { const debugRef = ref(0) // 在组件挂载之后只有onTrack被调用 // 在debugRef改变之后先调用onTrigger,再调用onTrack watch(() => { console.log('debugRef.value:',debugRef.value) }, { onTrack() { debugger; }, onTrigger() { debugger; } }) return {inputRef, onClick, debugRef} } } </script>
Lifecycle Hooks
Composition API当然也提供了组件生命周期钩子的回调。
import { onMounted, onUpdated, onUnmounted } from 'vue'
const MyComponent = {
setup() {
onMounted(() => {
console.log('mounted!')
})
onUpdated(() => {
console.log('updated!')
})
onUnmounted(() => {
console.log('unmounted!')
})
}
}
对比vue2的生命周期钩子:
beforeCreate
-> 使用setup()
created
-> 使用setup()
beforeMount
->onBeforeMount
mounted
->onMounted
beforeUpdate
->onBeforeUpdate
updated
->onUpdated
beforeDestroy
->onBeforeUnmount
destroyed
->onUnmounted
errorCaptured
->onErrorCaptured
provide
&inject
类似于vue2中provide
与inject
, vue3提供了对应的provide
与inject
API。
import { provide, inject } from 'vue'
const ThemeSymbol = Symbol()
const Ancestor = {
setup() {
provide(ThemeSymbol, 'dark')
}
}
const Descendent = {
setup() {
const theme = inject(ThemeSymbol, 'light' /* optional default value */)
return {
theme
}
}
}
Template Refs
当使用前文中的ref时会感到迷惑,模板中也有一个ref用于获取组件实例或dom对象,这样会不会冲突。而实际上在vue3中,ref会用来保存templates的ref。
<template>
<div ref="root">
Test Template Refs
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
export default {
setup() {
const root = ref(null)
onMounted(() => {
// 在render初始化之后,DOM元素将被赋值为ref
console.log(root.value) // div dom对象
})
return {
root
}
}
}
</script>
defineComponent
该接口是为了支持ts类型引用:
import { defineComponent } from 'vue'
export default defineComponent({
props: {
foo: String
},
setup(props) {
props.foo // <- type: string
}
})
当只有setup
选项时:
import { defineComponent } from 'vue'
// provide props typing via argument annotation.
export default defineComponent((props: { foo: string }) => {
props.foo
})
总结
总的来说,vue3的Composition API为开发者提供了更大的灵活,但更大的灵活需要更大的规则,对编程者的代码素质有一定的要求,一定程度上增加了vue的入门难度,另外为了持有原始类型的引用引入了ref,引入了一些复杂度,但相比于Composition API 所产生的效益,这些微不足道。至此本文已到尾声,本文讲解比较浅显,并没有深入讲解各个接口的实现原理,但对于使用Vue3的Composition API已经足够,有不足之处请指出及谅解。