Vue3基础-组合式API

ObjectKaz Lv4

介绍

在Vue2,中我们定义一个组件通常是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default {
data() {
return {
userInfo: {
firstName: "a",
lastName: "b"
},
postInfo: {}
}
},
computed: {
userFullName(){
return this.userInfo.firstName + this.userInfo.lastName
},
postRelativeDate(){
return null
}
},
}

但这样将同一业务逻辑,按照不同的底层逻辑进行分割,导致同一业务逻辑被分散到了各处。修改同一业务逻辑,往往需要到处跳转。

Vue3 使用组合API后,可以将相同的业务逻辑放在一起,提高代码的内聚性。此外,Vue3暴露了更多的底层API,可以将不同的业务逻辑单独抽象成 hooks,从而更好的实现逻辑复用。(在Vue2中,这只能通过 mixins 来实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default {
setup()
{
// 用户相关的逻辑
const userInfo = reactive({
firstName: "a",
lastName: "b"
})
const userFullName = computed(() => userInfo.firstName + userInfo.lastName)

// 文章相关的逻辑
const postInfo = reactive({})
const postRelativeDate = computed(() => null)

return {
userInfo,
userFullName,
postInfo,
postRelativeDate
}
}
}

但这种把各种业务逻辑揉在一个函数中的做法对程序员提出了更高的要求,尤其是规范性,否则代码会非常混乱,难以维护。

组合API的使用方法

组件选项中的 setup 函数

介绍

通常可以把 setup 直接写在组件的选项中:

1
2
3
4
5
6
// 组件选项
export default {
setup(props, context) {
// 内容
}
}

对于 ts 来说,如果需要获得更好的类型提示,应当使用 defineComponent 函数来定义组件:

1
2
3
4
import { defineComponent } from 'vue'
export default defineComponent({
// 已启用类型推断
})

属性的定义

这种方法仍然可以使用Vue2中的 props 属性定义 props

1
2
3
4
5
6
7
8
9
10
// 组件选项
export default {
props: {
id: Number,
name: String,
},
setup(props, context) {
// 内容
}
}

对于 ts,为了更好的类型推断,需要在后面加上 PropType 的类型断言:

1
2
3
4
5
6
7
8
9
10
11
// 组件选项
export default {
props: {
id: Number as PropType<number>,
name: String as PropType<string>,
sex: String as PropType<'male' | 'female'>,
},
setup(props, context) {
// 内容
}
}

所有已定义的属性都会通过 props 参数来传递。需要注意的是,props 是一个 Proxy 对象,不要对其进行解构,否则响应式会丢失。

对于没有在 props 定义的属性,例如事件和原生的属性,则会通过第二个参数 context 中的 attrs 属性来传递。attrsprops 一样,是 Proxy 对象,结果对丢失响应性。

事件的定义和触发

和 Vue2 一样,事件只需要在引用组件出绑定函数或表达式即可,而可以不用定义事件。

Vue3 中的组件实例没有 $listeners 属性,其已经合并到 $attrs 属性中。对于绑定的事件,例如 @clickattrs 中会自动加上 on 前缀,即 onClick

第二个参数 context 还有一个 emit 属性,可以用来触发事件,这个函数和 Vue2 中的 this.$emit 是一样的。

1
emit('click',{id: 120})

如果非要定义事件,也可以通过组件选项的 emits 属性来定义:

1
2
3
export default {
emits: ['click']
}

对于 ts,通过 emits 属性可以对触发的事件进行类型注解,这样对于未定义或者类型不正确的事件可以提示错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Component = defineComponent({
emits: {
addBook(payload: { bookName: string }) {
// perform runtime 验证
return payload.bookName.length > 0
}
},
methods: {
onSubmit() {
this.$emit('addBook', {
bookName: 123 // 类型错误!
})
this.$emit('non-declared-event') // 类型错误!
}
}
})

访问插槽

context 的第三个属性 slots 可以用来访问已经定义的插槽。 slots.插槽名 可以访问插槽实例。

slots 属性本身也是一个 Proxy 对象,解构会丢失响应性。

1
2
3
<HelloWorld msg="Hello Vue 3 + TypeScript + Vite" >
<p>123456</p>
</HelloWorld>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default defineComponent({
name: 'HelloWorld',
props: {
msg: {
type: String,
required: true
}
},
setup: (props, {slots}) => {
const count = ref(0)
console.log(slots)
return { count }
}
})

setup 函数的返回值

返回对象

可以返回一个对象,这些对象会和其他的像 data 等属性进行合并,且 setup 中定义的属性优先

目前对 setup 使用 async 仍然是一个RFC讨论的话题,具体可参考 async setup usability

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 返回对象
export default {
setup()
{
// 用户相关的逻辑
const userInfo = reactive({
firstName: "a",
lastName: "b"
})
const userFullName = computed(() => userInfo.firstName + userInfo.lastName)

// 文章相关的逻辑
const postInfo = reactive({})
const postRelativeDate = computed(() => null)

return {
userInfo,
userFullName,
postInfo,
postRelativeDate
}
}
}

如果在一个普通对象里嵌套响应式对象如 refreactive,将无法被 Vue 识别:

1
2
3
4
5
6
7
const count = ref(0)
const user = reactive({id: 1, name: 'kaz'})

return { count, user, deep: {
count,
user
}}

返回函数

除了返回一个对象,还可以返回一个函数,其返回值是渲染函数或者 tsx

1
2
3
4
5
6
7
8
9

// 返回渲染函数
export default {
setup() {
const readersNumber = ref(0)
const book = reactive({ title: 'Vue3 666' })
return () => h('div', [readersNumber.value, book.title])
}
}

一些细节

  1. setup 的执行时间:在 beforeCreate 执行

    • 此时组件对象还未创建,不能使用 this来访问别的属性
    • 只会执行一次
  2. 参数:

参数类型功能
propsProxy已定义的属性
context普通对象上下文
context.attrsProxy原生属性/事件等
context.emit函数触发事件
context.slotsProxy插槽

script setup 语法糖

介绍

对于单文件组件 (SFC),如果只需要使用组合式API,但仍然定义一大堆诸如 defineComponentexport default 之类的东西仍然显得很复杂。而且 setup 函数需要把函数体内需要导出的对象进行返回,这显得不太简洁。

下面是官方给出的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
// imported components are also directly usable in template
import Foo from './Foo.vue'
import { ref } from 'vue'

// write Composition API code just like in a normal setup()
// but no need to manually return everything
const count = ref(0)
const inc = () => {
count.value++
}
</script>

<template>
<Foo :count="count" @click="inc" />
</template>

从中我们可以看出两点:

  1. import 进来的组件可以直接使用在模板中
  2. script 标签中内定义的所有变量默认都是直接暴露给模板的

使用组件

对于组件,直接引入即可:

1
import Foo from './Foo.vue'

属性和事件的定义

使用这个语法糖以后,就不能直接使用 props 属性了,这时候,我们可以使用 defineProps 来定义组件的属性。

1
2
3
const props = defineProps({
foo: String
})

同样的,也可以使用 defineEmits 来定义事件,它返回一个 emit 函数来触发事件

1
2
3
const emit = defineEmits(['update', 'delete']) // 和 emits 属性是对应的

emit('update', {})

ts 中,可以使用类型声明代替运行时的声明:

1
2
3
4
5
const props = defineProps<{foo: string}>()
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()

默认情况下,defineProps 无法设置默认值,但可以通过 withDefaults 编译宏来实现:

1
2
3
4
5
6
7
interface Props {
msg?: string
}

const props = withDefaults(defineProps<Props>(), {
msg: 'hello'
})

注意:

  • defineEmitsdefineProps 都只是编译宏,只能在 script setup 中使用
  • defineEmitsdefineProps 不需要 import,直接使用
  • defineEmitsdefineProps 最终会编译到 setup 函数外面,所以这两个宏不能引用其他的变量
  • 类型声明和运行时声明只能使用其中一个,否则会编译错误
  • 类型声明目前是通过静态分析的方式来完成的,所以从其他文件导入或者使用需要推断的类型可能会导致错误

使用 attrsslots

同样的,官方也提供了函数 useSlotsuseAttrs

1
2
3
4
5
6
<script setup>
import { useSlots, useAttrs } from 'vue'

const slots = useSlots()
const attrs = useAttrs()
</script>

这两个函数都是运行时的,因此可以在这个语法糖之外继续使用

await/async

目前对于 setup 中的异步处理仍然是一个有待讨论的话题,相关议案暂未定下来,故暂不做讨论。

可访问性

script setup 语法糖中,默认情况下所有导出的对象在其他组件中是不可见的(即使用 ref 访问组件)。这时候需要使用 defineExpose 编译宏来指定需要暴露的数据:

1
2
3
4
5
6
7
const a = 1
const b = ref(2)

defineExpose({
a,
b
})

和 script 标签一起使用

如果组件除了默认导出,还有其他的导出,或者需要做一些全局性的工作,或者需要指定其他的属性,这个时候可以添加一个 script 标签。

1
2
3
4
5
6
7
8
9
10
11
<script>
export default {
name: 'CustomName',
inheritAttrs: false,
customOptions: {}
}
</script>

<script setup>
// script setup logic
</script>

响应式数据的定义和使用

ref

如果有了解过Vue3,可以大概知道Vue3采用了 Proxy 来代替 Vue2 的 Object.defineProperty,解决了 Vue2的响应式存在的问题。但是 Proxy 是针对一个对象的,对于基本数据就不行了。所以,为了解决对基本数据的引用,就出现了 ref

ref 用来定义一个数据的引用。在实际项目中通常用来引用基本数据

1
const data = ref(初始值)

js 代码中,如果需要获得其值,应当通过 .value

1
data.value = 1

在模板中,Vue3会自动识别并加上 .value

1
<p>{{data}}</p>

ref 是如何实现响应式的呢?首先,要想实现响应式,有访问器和Proxy两种办法。对于一个基本数据,没有必要使用代理,那么,就使用访问器了。所以,ref 仍然是一个访问器。每个 ref 创建的响应式引用都有一个重要的属性 value ,其本质上就是一个 gettersetter

reactive

reactive 便使用了知名的 Proxy 来将对象转换成响应式的。它的格式如下:

1
const data = reactive(初始值)

ref 不同的是,它使用了代理,因此这个初始值应当是一个对象。

由于这里没有使用访问器属性,所以访问其值无需添加额外的 .value

1
2
3
4
5
6
7
const val = reactive({
id: 1,
name: 'kaz',
age: 19
})

// 访问:val.age

这和之前的 props 对象一样,如果使用解构运算符,那么响应式会丢失:

1
2
3
4
5
6
// 非响应式的数据
const {id, name, age} = reactive({
id: 1,
name: 'kaz',
age: 19
})

但这又有个问题,比如说清空数组我们会这么写:

1
arr = []

现在如果数组是响应式的:

1
let arr = reactive([1,2,3,4,5])

如果某个时候清空一个数组,可能会这么写:

1
arr = reactive([]) // 检测不到

需要注意的是,arr 的值最终在 setup 函数末尾返回了,而且 setup 函数只执行一次,因此 Vue 只会拿到最初执行 setup 时, arr 的值,后面对 arr 的所有直接修改都是检测不到的。

不过,对 arr 的直接修改在模板是不可见的,但通过 js 代码仍然可以拿到最新的值的。

这意味着,使用 reactive 定义的对象,其所有的属性是有响应式的,但它本身没有响应式

所以如果需要对整个对象进行替换,通常有三种方法。

第一种先清空 arr,再逐个赋值。但这样看起来比较慢。

第二种就是把这个对象套在另一对象上,例如:

1
2
3
4
5
const state = reactive({
arr: [1,2,3,4]
})

state.arr = []

这种情况我们可以直接定义一个 state 对象来管理整个组件的数据

第三种便是使用 ref。尽管我们之前说 ref 主要是引用基本类型的数据,但如果其值是一个对象,那么整个在内部会自动调用 reactive

1
2
3
const state = ref([1,2,3,4])

state.value = [] //state.value 遇到对象时,自动将其转换成 reactive

如果不希望当 .value 为对象时,执行到 reactive 的转换,那么可以使用 shallowRef 代替 ref

此外,reactive 定义的对象的响应式 是深度的,这意味着:

1
2
3
4
5
6
7
8
const info = reactive({
id: 1,
name: 'kaz',
role: [{name: 'admin', permissions: ['read','write']}]
})

// 这也是可以可以刷新UI的
info.role[0].name = 'visitor'

reactive 对象的属性为对象时,其在访问时也会被自动转换成 Proxy,从而实现深度的响应式。如果不需要深度的响应式,那么可以使用 shallowReactive 代替 reactive

如果希望这个 reactive 是只读的,可以使用 readonly

readonlyreactive 一样,是深度的。如果不需要当值为对象时的转换,那么可以使用 shallowReadonly 代替 readonly

1
2
3
4
const copy = readonly(info)

copy.id++ // error
copy.role[0].name = 'visitor' // error

计算属性和侦听器

computed

在 Vue 中,计算属性定义通过一个 computedAPI 来定义,例如:

1
2
3
4
const user = reactive({
firstName: 'foo',
lastName: 'bar'
})

这个时候我们可以定义一个 computed

1
const fullName = computed(() => user.firstName + ' ' + user.lastName)

除了可以定义 getter,还可以定义 setter

1
2
3
4
5
6
const fullName = computed({
get: () => user.firstName + ' ' + user.lastName,
set: (newValue) => {
[user.firstName, user.lastName] = newValue.split(' ')
}
})

Computed 属性在内部通过依赖收集的方式来实现其依赖数据的变化自动刷新。具体可以参考 Vue 的依赖收集技术。

如果 computed 里面没有响应式的数据,那么如果里面引用的值发生变化,computed 的值不会自动刷新。

引用 computed 的值和 ref 是一样的:

1
// fullName.value

watchEffect

介绍

watchEffectcomputed 非常像,但有一处不同的是 watchEffect 最终不需要得到一个值。它和 computed 相似的地方有:

  • 组件初始化时执行一次
  • watchEffect 内部访问的响应式数据发生变化时,watchEffect 便会重新执行一次。
1
2
3
4

const count = ref(0)
const count1 = ref(1)
watchEffect(() => console.log(count1.value = count.value))

在启动时,控制台会先输出 0,然后每当 count 发生变化时,这个函数就会被执行一次。

只有被读取的属性,发生变更时才会被监听。也就是说 count1 发生了变化,这个函数并不会被执行。

停止侦听

watchEffect 还有一个返回值 stop,它可以停止侦听某个数据:

1
2
3
4
const stop = watchEffect(() => console.log(count1.value = count.value))

// 其他地方
stop()

清除副作用

如果 watchEffect 里面含有异步函数,如下面的网络请求:

1
2
3
4
5
6
7
8
const userInfo = ref({})

const stop = watchEffect(() => {
getUserInfo(route.userId).then((res) => {
userInfo.value = res.data
})
})

这样就出现了一些问题:

  • 组件卸载的时候,请求已经发出去了,这时候就不需要它的响应数据了,所以没有必要让这个请求继续发出去了,所以应当取消请求
  • 如果 userId 变化非常快,那么这个异步的函数就会调用很多次,但实际情况时,用户只需要拿到最后一次请求的返回数据,其他的请求都是没有必要的,应该取消。

可能有人就想到使用 stop 了,但是 stop 不能阻止已经发出的异步请求呀。

这时候,watchEffect 就提供了一个参数 onInvalidate ,传入一个函数,当 组件卸载 或者 函数将要重新执行 时,便会调用这个回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const userInfo = ref({})

const stop = watchEffect((onInvalidate) => {
const source = axios.CancelToken.source();
getUserInfo(route.userId, {
cancelToken: source.token
}).then((res) => {
userInfo.value = res.data
})

onInvalidate(() => {
source.cancel() // 取消请求
})

})

执行时机

watchEffect 在下面两种情况下会执行:

  • 组件初始化时执行一次
  • 如果发现其依赖的数据发生变化(一个或多个),组件DOM更新前执行一次

watch

介绍

相比于 watchEffectwatch 则要求手动指定要监听哪些数据:

1
2
3
4
const count = ref(0)
const count1 = ref(1)

watch(count, (newValue) => console.log(count1.value = newValue))

此外,watch 可以拿到更新前后的数值:

1
watch(count, (newValue, oldValue) => console.log(count1.value = newValue + oldValue))

监听 Reactive 对象

如果需要监听一个 reactive对象,可以直接把 reactive 传入:

1
2
3
4
5
6
7
8
9
10
11
12
const user = reactive({
firstName: 'foo',
lastName: 'bar',
age: 1
})

watch(user, (n, o) => {
console.log(n.age, o.age)
})

// <button @click="user.age++">update</button>

由于Vue3 使用了 Proxy 来处理响应式数据,导致 Vue 只能监听改变,但拿不到改变前的值,这在目前似乎是一个BUG

reactive 对象的监听 默认是深度的,且是强制性的(无法关闭) ,例如:

1
2
3
4
5
6
7
8
9
10
11
12
const user = reactive({
firstName: 'foo',
lastName: 'bar',
age: 1,
collectionIds: [0,1,2,3,4]
})

watch(user, (n, o) => {
console.log(n, o)
})

// <button @click="user.collectionIds.push(5)">update</button>

监听 Reactive 对象的一个属性

有时候并不需要监听整个 reactive 对象,而是只需要监听一个属性,例如:

1
2
3
4
5
6
7
8
9
10
11
12
const user = reactive({
firstName: 'foo',
lastName: 'bar',
age: 1,
collectionIds: [0,1,2,3,4]
})

watch(user.collectionIds, (n, o) => {
console.log(n, o)
})

// <button @click="user.collectionIds.push(5)">update</button>

监听属性的时候,需要注意两点:

  1. 如果监听的属性是原始值,这样最终传入的内容是一个普通的数据,而不是 Ref 或者 Reactive
1
2
3
4
5
6
7
8
9
10
11
const user = reactive({
firstName: 'foo',
lastName: 'bar',
age: 1,
collectionIds: [0,1,2,3,4]
})

// watch(1,xxx)
watch(user.age, (n, o) => {
console.log(n, o)
})

对于这种情况,应该使用函数返回的形式

1
2
3
watch(() => user.age, (n, o) => {
console.log(n, o)
})
  1. 如果监听的属性是对象,那么它仍然是 Proxy,这时候就不需要使用函数返回的形式了。

监听 Ref 对象

如果需要监听一个 ref对象,那么第一个参数不需要加 .value,在回调中拿到的值也不需要加 .value

1
2
3
4
const count = ref(1)
watch(count, (n, o) => {
console.log(n, o)
})

注意:不要在 watch 的第一个参数里使用 .value,否则在执行的时候监视的不是对象,而是一个值:

1
2
3
4
5
const count = ref(1)
// 相当于:watch(1,xxx)
watch(count.value, (n, o) => {
console.log(n, o)
})

如果 Ref 引用的值是一个对象,默认情况下,它只能监视 .value 本身的变化,但是如果 .value 里面某个属性变化了,监听就无效了。如下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
const user = ref({
firstName: 'foo',
lastName: 'bar',
age: 1
})

watch(user, (n, o) => {
console.log(n.age, o.age)
})

// 尝试在一个地方修改
user.value.age = 10

这时候,一种办法是在 user 那里添加 .value,那么就回到了之前监听 reactive 对象的情况了:

1
2
3
4
5
6
7
8
9
10
11
12
const user = ref({
firstName: 'foo',
lastName: 'bar',
age: 1
})

watch(user.value, (n, o) => {
console.log(n.age, o.age)
})

// 尝试在一个地方修改
user.value.age = 10

另一种办法,则是添加一个 deep 选项,这样就可以深度监听了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const user = ref({
firstName: 'foo',
lastName: 'bar',
age: 1
})

watch(user, (n, o) => {
console.log(n.age, o.age)
}, {
deep: true
})

// 尝试在一个地方修改
user.value.age = 10

一次性监视多个数据

除了监视一个数据, watch 还可以一次性监视多个数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const count = ref(0)
const modifiedCount = ref(0)
const user = reactive({
firstName: 'foo',
lastName: 'bar',
age: 1
})

watch([user, count], (n, o) => {
console.log(n,o)
modifiedCount++
})

// 尝试在一个地方修改
user.value.age = 10
count++

每个数组项的使用方法和监听单个数据一致,它的返回值则是按照数组顺序排列的。

小结

watch 本身有比较多的细节,但总体来说只需要记住几点:

  1. 搞清楚 watch 的是一个原始值、ref 对象,还是 reactive 对象
  2. ref 对象只能监听 .value 整个的变化
  3. reactive 对象监听原始值属性的变化,需要使用函数回调

生命周期钩子

生命周期的变化

Vue2钩子Vue3钩子
beforeDestroybeforeUnmount
destroyedunmounted

对应的函数

选项式 API钩子 setup
beforeCreateNot needed*
createdNot needed*
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
errorCapturedonErrorCaptured
renderTrackedonRenderTracked
renderTriggeredonRenderTriggered
activatedonActivated
deactivatedonDeactivated

这些生命周期钩子通过传入一个函数,来实现各声明周期函数的调用。

依赖注入

依赖注入是一种组件与组件间的通信机制,通过 provide,可以将数据跨组件传递给后代组件;
通过 inject,可以拿到祖先组件传递给后代组件的属性。

例如:

1
2
3
// App
// 传递数据到ComonentA
<ComponentA :userInfo="userInfo" />
1
2
3
// ComonentA
// 传递数据到ComonentB
<ComponentB :userInfo="userInfo" />

通过依赖注入,可以避免数据通过组件层层传递:

1
2
3
4
5
6
7
8
9

// 祖先组件 App
provide('userInfo',reactive({
name: 'foo bar',
age: 19
}))

// 后代组件 ComponentB,无需 ComponentA 做什么操作
const userInfo = inject('userInfo')

响应式数据的判断

注:在 typescript 中,这些函数都是 类型守卫

reactive 和 readonly 的判断

  1. isProxy(data):判断 data 是否是 reactivereadonlyshallowReactiveshallowReadonly创建的对象
  2. isReactive(data):判断 data 是否是 reactiveshallowReactive创建的代理
  3. isReadonly(data):判断 data 是否是 readonlyshallowReadonly创建的代理

注意:如果 readonly 是通过一个现有的 reactive 对象创建的,那么用 isReactive 创建仍然是 true

1
console.log(isReactive(readonly(reactive({})))) // true

ref 的判断

  1. isRef(data):判断 data 是否是使用 ref 或者 computed 创建。

响应式数据的处理和转换

解除响应性

  1. unref(value):如果参数是一个 ref,则返回内部值,否则返回参数本身。无论它是否是 ref,它最终都可以拿到其值。
  2. toRaw(value):返回 reactive 或者 readonly 代理的原始对象

防止响应性转换

在实际应用中,有些数据是基本上不会发生变化的,如一些配置和定义。这时候,可以使用markRaw(data):标记一个对象,防止其被转换成 reactive对象。

当对其使用 reactive ,则会直接返回原值。

1
isReactive(reactive(markRaw({}))) // false

reactive 转换成 ref

  1. 转换某个属性:toRef

这个函数会将reactive对象的某个属性转换成 ref,并且其引用同一套数据:

1
2
3
4
5
6
7
8
const user = reactive({
firstName: 'foo',
lastName: 'bar',
age: 1,
collectionIds: [0,1,2,3,4]
})
const age = toRef(user,'age')

  1. 转换整个对象: toRefs(data) 将整个 reactive 对象的所有属性转换成 ref,这非常适合解构的情况:
1
2
3
4
5
6
7
8
9
10
const user = reactive({
firstName: 'foo',
lastName: 'bar',
age: 1,
collectionIds: [0,1,2,3,4]
})

return {
...toRefs(user)
}
  • 标题: Vue3基础-组合式API
  • 作者: ObjectKaz
  • 创建于: 2021-07-20 13:48:41
  • 更新于: 2021-08-28 13:12:25
  • 链接: https://www.objectkaz.cn/40c667d5f3dc.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。