摘要
本篇文章用来整理记录vue2与vue3的一些区别和变化,帮助学习和理解。
以下内容仅作为理解参考,具体使用请以最新的 API 为准!!!
注意:文章内容来源参考 Vue Function-based API RFC
设计动机
逻辑组合与复用
组件 API 设计所面对的核心问题之一就是如何组织逻辑,以及如何在多个组件之间抽取和复用逻辑。基于 Vue 2.x 的 API 目前有一些常见的逻辑复用模式,但都或多或少存在一些问题。这些模式包括:
- Mixins
- 高阶组件 (Higher-order Components, aka HOCs)
- Renderless Components (基于 scoped slots / 作用域插槽封装逻辑的组件)
不过,对于以上这些模式存在以下问题:
- 模版中的数据来源不清晰。举例来说,当一个组件中使用了多个 mixin 的时候,光看模版会很难分清一个属性到底是来自哪一个 mixin。HOC 也有类似的问题。
- 命名空间冲突。由不同开发人员开发的 mixin 无法保证不会正好用到一样的属性或是方法名。HOC 在注入的 props 中也存在类似问题。
- 性能。HOC 和 Renderless Components 都需要额外的组件实例嵌套来封装逻辑,导致无谓的性能开销。
Function-based API(函数式API) 受 React Hooks 的启发,提供了一个全新的逻辑复用方案,且不存在上述问题。使用基于函数的 API,我们可以将相关联的代码抽取到一个 "composition function"(组合函数)中,该函数封装了相关联的逻辑,并将需要暴露给组件的状态以响应式数据源的方式返回出来。
类型推导
Vue 3.0 的一个主要设计目标是增强对 TypeScript 的支持。基于函数的 API 对类型推导很友好,因为 TS 对函数的参数、返回值和泛型的支持已经非常完备。另外基于函数的 API 在使用 TS 或是原生 JS 时写出来的代码几乎是完全一样的。
打包尺寸
基于函数的 API 每一个函数都可以被单独引入,这使得它们对 tree-shaking 非常友好。没有被使用的 API 的相关代码可以在最终打包时被移除。同时,基于函数 API 所写的代码也有更好的压缩效率,因为所有的函数名和 setup 函数体内部的变量名都可以被压缩,但对象和 class 的属性/方法名却不可以。
设计细节
setup() 函数
setup()
一个新的组件选项,这个函数是编写组件逻辑的地方,该函数会在一个组件实例被创建时,初始化了 props 之后调用。setup()
会接收到初始的 props 对象作为参数,它是响应式的,即可以被当作数据源去观测,当后续 props 发生变动时它也会被框架内部同步更新。但它是不可修改的。
const MyComponent = {
props: {
name: String
},
setup(props) {
return {
msg: `hello ${props.name}!`
}
},
template: `<div>{{ msg }}</div>`
}
组件状态
如果我们想要创建一个可以在 setup()
内部被管理的值,可以使用 ref
函数:
import { ref } from 'vue'
const MyComponent = {
setup(props) {
const msg = ref('hello')
const appendName = () => {
msg.value = `hello ${props.name}`
}
return {
msg,
appendName
}
},
template: `<div @click="appendName">{{ msg }}</div>`
}
ref()
返回的是一个包装对象。一个包装对象只有一个属性:.value
,该属性指向内部被包装的值。在上面的例子中,msg
包装的是一个字符串。包装对象的值可以被直接修改:
// 读取
console.log(msg.value) // 'hello'
// 修改
msg.value = 'bye'
为什么需要包装对象?
在 JavaScript 中,原始值类型如 string 和 number 是只有值,没有引用的。如果在一个函数中返回一个字符串变量,接收到这个字符串的代码只会获得一个值,是无法追踪原始变量后续的变化的。
因此,包装对象的意义就在于提供一个能够在函数之间以引用的方式传递任意类型值的容器。这类似 React Hooks 中的 useRef
,但不同的是 Vue 的包装对象同时还是响应式的数据源。有了这样的容器,就可以在封装了逻辑的组合函数中将状态以引用的方式传回给组件。组件负责展示(追踪依赖),组合函数负责管理状态(触发更新)。
包装对象也可以包装非原始值类型的数据,被包装的对象中嵌套的属性都会被响应式地追踪。用包装对象去包装 对象或是数组 并不是没有意义的:它让我们可以对整个对象的值进行替换,比如用一个 filter 过的数组去替代原数组:
const numbers = ref([1, 2, 3])
// 替代原数组,但引用不变
numbers.value = numbers.value.filter(n => n > 1)
如果想创建一个没有包装的响应式对象,可以使用 reactive
API(和 2.x 的 Vue.observable()
等同):
import { reactive } from 'vue'
const object = reactive({
count: 0
})
object.count++
Computed Value (计算值)
除了直接包装一个可变的值,也可以包装通过计算产生的值:
import { ref, computed } from 'vue'
const count = ref(0)
const countPlusOne = computed(() => count.value + 1)
console.log(countPlusOne.value) // 1
count.value++
console.log(countPlusOne.value) // 2
计算值的行为跟计算属性 (computed property) 一样:只有当依赖变化的时候才会被重新计算。
computed()
返回的是一个只读的包装对象,它可以和普通的包装对象一样在 setup()
中被返回 ,也一样会在渲染上下文中被自动展开。默认情况下,如果试图去修改一个只读包装对象,会触发警告。
双向计算值可以通过传给 computed
第二个参数作为 setter 来创建:
const count = value(0)
const writableComputed = computed(
// read
() => count.value + 1,
// write
val => {
count.value = val - 1
}
)
Watchers
watch()
API 提供了基于观察状态变化来执行副作用的能力。它接收的第一个参数被称作 “数据源”,可以是:
- 一个返回任意值的函数
- 一个包装对象
- 一个包含上述两种数据源的数组
第二个参数是回调函数。回调函数只有当数据源发生变动时才会被触发。
watch(
// getter
() => count.value + 1,
// callback
(value, oldValue) => {
console.log('count + 1 is: ', value)
}
)
// -> count + 1 is: 1
count.value++
// -> count + 1 is: 2
和 Vue 2.x 的 $watch
有所不同的是,watch()
的回调会在创建时就执行一次。这有点类似 2.x watcher 的 immediate: true
选项,但有一个重要的不同:默认情况下 watch()
的回调总是会在当前的 renderer flush 之后才被调用,也就是 watch()
的回调在触发时,DOM 总是会在一个已经被更新过的状态下。 这个行为是可以通过选项来配置的。
在 2.x 的代码中,经常会遇到同一份逻辑需要在 mounted
和一个 watcher 的回调中执行(比如根据当前的 id 抓取数据),3.0 的 watch()
默认行为可以直接表达这样的需求。
观察 props
上面提到了 setup()
接收到的 props
对象是一个可观测的响应式对象:
const MyComponent = {
props: {
id: Number
},
setup(props) {
const data = ref(null)
watch(() => props.id, async (id) => {
data.value = await fetchData(id)
})
return {
data
}
}
}
观察包装对象
watch()
可以直接观察一个包装对象:
// double 是一个计算包装对象
const double = computed(() => count.value * 2)
watch(double, value => {
console.log('double the count is: ', value)
}) // -> double the count is: 0
count.value++ // -> double the count is: 2
观察多个数据源
watch()
也可以观察一个包含多个数据源的数组,在这种情况下,任意一个数据源的变化都会触发回调,同时回调会接收到包含对应值的数组作为参数:
watch(
[refA, () => refB.value],
([a, b], [prevA, prevB]) => {
console.log(`a is: ${a}`)
console.log(`b is: ${b}`)
}
)
停止观察
watch()
返回一个停止观察的函数:
const stop = watch(...)
// stop watching
stop()
如果 watch()
是在一个组件的 setup()
或是生命周期函数中被调用的,那么该 watcher 会在当前组件被销毁时也一同被自动停止:
清理副作用
有时候当观察的数据源变化后,可能需要对之前所执行的副作用进行清理。例如:一个异步操作在完成之前数据就产生了变化,就可能要撤销还在等待的前一个操作。为了处理这种情况,watcher 的回调会接收到的第三个参数是一个用来注册清理操作的函数。调用这个函数可以注册一个清理函数。清理函数会在下属情况下被调用:
- 在回调被下一次调用前
- 在 watcher 被停止前
watch(idValue, (id, oldId, onCleanup) => {
const token = performAsyncOperation(id)
onCleanup(() => {
// id 发生了变化,或是 watcher 即将被停止.
// 取消还未完成的异步操作。
token.cancel()
})
})
Watcher 回调的调用时机
默认情况下,所有的 watcher 回调都会在当前的 renderer flush 之后被调用。这确保了在回调中 DOM 永远都已经被更新完毕。如果想要让回调在 DOM 更新之前或是被同步触发,可以使用 flush
选项:
watch(
() => count.value + 1,
() => console.log(`count changed`),
{
flush: 'post', // 默认,渲染器刷新后触发
flush: 'pre', // 渲染器刷新之前触发
flush: 'sync' // 同步触发
}
)
全部的 watch 选项(TS 类型声明)
interface WatchOptions {
lazy?: boolean
deep?: boolean
flush?: 'pre' | 'post' | 'sync'
onTrack?: (e: DebuggerEvent) => void
onTrigger?: (e: DebuggerEvent) => void
}
interface DebuggerEvent {
effect: ReactiveEffect
target: any
key: string | symbol | undefined
type: 'set' | 'add' | 'delete' | 'clear' | 'get' | 'has' | 'iterate'
}
lazy
与 2.x 的immediate
正好相反deep
与 2.x 行为一致onTrack
和onTrigger
是两个用于 debug 的钩子,分别在 watcher 追踪到依赖和依赖发生变化的时候被调用,获得的参数是一个包含了依赖细节的 debugger event。
生命周期函数
所有现有的生命周期钩子都会有对应的 onXXX
函数(只能在 setup()
中使用):
import { onMounted, onUpdated, onUnmounted } from 'vue'
const MyComponent = {
setup() {
onMounted(() => {
console.log('mounted!')
})
onUpdated(() => {
console.log('updated!')
})
// destroyed 调整为 unmounted
onUnmounted(() => {
console.log('unmounted!')
})
}
}
依赖注入
import { provide, inject } from 'vue'
const CountSymbol = Symbol()
const Ancestor = {
setup() {
// providing a ref can make it reactive
const count = ref(0)
provide(CountSymbol, count)
}
}
const Descendent = {
setup() {
const count = inject(CountSymbol)
return {
count
}
}
}
如果注入的是一个包装对象,则该注入绑定会是响应式的(也就是说,如果 Ancestor 修改了 count,会触发 Descendent 的更新)。
类型推导
为了能够在 TypeScript 中提供正确的类型推导,需要通过一个函数来定义组件:
import { defineComponent, ref } from 'vue'
const MyComponent = defineComponent({
// props声明用于推断props类型
props: {
msg: String
},
setup(props) {
props.msg // string | undefined
// 从setup()返回的绑定可用于模板中的类型推断
const count = ref(0)
return {
count
}
}
})
defineComponent
从概念上来说和 2.x 的 Vue.extend
是一样的,但在 3.0 中它其实是单纯为了类型推导而存在的,内部实现是个 noop(直接返回参数本身)。它的返回类型可以用于 TSX 和 Vetur 的模版自动补全。如果你使用单文件组件,则 Vetur 可以自动隐式地帮你添加这个调用。
Required Props
Props 默认都是可选的,也就是说它们的类型都可能是 undefined
。非可选的 props 需要声明 required: true
:
import { defineComponent } from 'vue'
defineComponent({
props: {
foo: {
type: String,
required: true
},
bar: {
type: String
}
},
setup(props) {
props.foo // string
props.bar // string | undefined
}
})
复杂 Props 类型
Vue 提供的 PropType
类型可以用来声明任意复杂度的 props 类型,但需要用 as any
进行一次强制类型转换:
import { defineComponent, PropType } from 'vue'
defineComponent({
props: {
options: (null as any) as PropType<{ msg: string }>
},
setup(props) {
props.options // { msg: string } | undefined
}
})
依赖注入类型
依赖注入的 inject
方法是唯一必须手动声明类型的 API:
import { defineComponent, inject, Ref } from 'vue'
defineComponent({
setup() {
const count: Ref<number> = inject(CountSymbol)
return {
count
}
}
})
这里的 Ref
类型即是包装对象的类型 ,通过泛型参数来声明其内部包装的值的类型。
附录
与 React Hooks 的对比
这里提出的 API 和 React Hooks 有一定的相似性,具有同等的基于函数抽取和复用逻辑的能力,但也有很本质的区别。React Hooks 在每次组件渲染时都会调用,通过隐式地将状态挂载在当前的内部组件节点上,在下一次渲染时根据调用顺序取出。而 Vue 的 setup()
每个组件实例只会在初始化时调用一次 ,状态通过引用储存在 setup()
的闭包内。这意味着基于 Vue 的函数 API 的代码:
- 整体上更符合 JavaScript 的直觉;
- 不受调用顺序的限制,可以有条件地被调用;
- 不会在后续更新时不断产生大量的内联函数而影响引擎优化或是导致 GC 压力;
- 不需要总是使用
useCallback
来缓存传给子组件的回调以防止过度更新; - 不需要担心传了错误的依赖数组给
useEffect/useMemo/useCallback
从而导致回调中使用了过期的值 —— Vue 的依赖追踪是全自动的。
注:React Hooks 的开创性毋庸置疑,也是灵感来源。Hooks 代码和 JSX 并置使得对值的使用更简洁也是其优点,但其设计确实存在上述问题,而 Vue 的响应式系统恰巧能够绕过这些问题。