Vue3 Composition API

2020 年 9 月,它终于来了,尽管很早就上了 Beta 版本,但是尤大确一直没有推出正式版,如今正式版上线,让我们来一睹为快,看看 Vue3 带来了哪些改变吧!

Vue3 文档地址:https://v3.cn.vuejs.org/

Composition API


setup

什么是 setupvue3 之前我们的代码逻辑会在组件的各个角落,大量碎片化的代码使得理解和维护组件变得异常困难,在处理单个逻辑关注点时,我们必须不断地跳转相关代码的选项块。试想一下如果我们能够将同一个逻辑的相关代码都配置在一起,是不是更容易理解和维护,那么我们将这些逻辑和代码写在哪里了?在 vue3 中,我们将此位置称为 setup

setup 执行时,组件实例尚未被创建 (beforeCreate - created),因此在 setup 中没有 thissetup 选项应该是一个接收 propscontent 的函数,我们这里先来看看 props 的用法,在 setup 函数中除了 props 之外,我们无法访问组件中声明的任何属性(本地状态、计算属性或方法)。直接上栗子吧:

<!DOCTYPE html>
<html lang="en">
<head>
  <script src="https://unpkg.com/vue@next"></script>
</head>
<body>
  <div id="root"></div>
</body>
<script>
  const app = Vue.createApp({
    template: `
      <test username='张三' />
    `
  })
  app.component('test', {
    props: ['username'],
    setup(props) {
      console.log(props) // Proxy {username: "张三"}
      // console.log(this) // window
    },
    template: `<div>{{username}}</div>`
  })
  const vm = app.mount('#root')
</script>
</html>

上述代码中,我们创建全局组件 test , 父组件往 test 上传入 usernametest 中通过 props 接收父组件传递过来的 username,此时在子组件的 setup 执行时可以接收到 props 中的值,但是打印出 this 是指向 window 的。

但是 setup 函数返回的所有内容都将暴露给组件的其余部分(计算属性、方法、生命周期钩子等等)以及组件的模板。我们还是在刚刚的 test 组件中来验证下这句话:

app.component('test', {
  props: ['username'],
  mounted() { // mounted 声明周期中可以使用 setup 中返回出来的 username
    console.log(this.username) // 张三  
  },
  setup(props) {
    const username = props.username
    return { username }
  },
  template: `<div>{{username}}</div>` // 张三
  })
const vm = app.mount('#root') 

当然我们也可以在组件的生命周期或者方法中直接访问 setup 函数里面的方法,栗子如下:

const app = Vue.createApp({
  mounted() {
    // 可以直接在生命周期里面调用 setup() 里面的方法
    this.$options.setup().handleClick()
  },
  setup(props, context) {
    return {
      handleClick() {
        alert('setup')
    }
  }
}})

总结:执行 setup() 是在 created() 之前执行,由于尚未创建实例,所以 setup()中无法直接使用 this,但是 methods/computed/watch 等其他地方都可以直接调用 setup()。并且 setup() 返回的所有内容都将暴露给组件的其余部分(计算属性、方法、生命周期钩子等等)以及组件的模板。

ref 语法

vue3 之前,我们只需要在 data 中定义变量,那么这个变量在 methods/computed/watch 等中的改变就会直接响应到组件模板中了。但是在 vue3 之中我们只有通过 ref 函数和 reactive 函数来实现响应式变量在组件任何地方起作用。我们先通过小栗子可以了解 ref 函数的基本用法:

const app = Vue.createApp({
  setup() {
    // 通过 ref 初始化一个响应式变量 count 并将其初始值设为 0
    const { ref } = Vue
    let count = ref(0)
    const increase = () => {
      return count.value += 1
    }
    return { count, increase }
  },
  template: `
    <div>{{count}}</div>
    <button @click="increase">增加</button>
  `
})

通过上述代码我们可以看到 ref 接受参数,并将其包裹在一个带有 value property 的对象中返回,然后可以使用该 property 访问或更改响应式变量的值。那么问题来了为什么要将值封装在一个对象中呢?这是因为在 JavaScript 中,NumberString 等基本类型是通过值传递的,而不是通过引用传递的。我们知道值传递一般指在调用函数时将实际参数复制一份传递到函数中,这样如果在函数中如果对参数进行修改,不会影响到实际参数,既占用内存空间也无法实现数据的响应式。而通过引用传递其实是直接把内存地址传过去,也就是说在引用传值的过程中操作的都是源数据,也就能更好的实现数据响应式驱动。

其实简单点就是基础类型的值都是存在栈中,引用类型的值都是存在堆中,堆共用一个数据源,而每个栈都是一个独立的空间,所以我们将数据存入堆中才能更好的做到数据响应式改变。

reactive 语法

reactive 语法和 ref 都是支持数据的响应式,但是 reactive 接收的一般是数组和对象,直接来看小栗子:

const app = Vue.createApp({
  setup() {
    // 通过 reactive 初始化一个响应式对象
    const { reactive, computed } = Vue
    const data = reactive({
      count: 0,
      increase: () => data.count++,
      docuble: computed(() => data.count * 2)
    })
    return { data }
  },
  template: `
    <div>{{data.count}}</div>
    <div>{{data.docuble}}</div>
    <button @click="data.increase">增加</button>
  `
})

上述代码中我们会发现模板中每次都要写 data.countdata.docuble 过于繁琐,如果我们在 return { data } 时使用展开运算符或者对象赋值的方式导出,是否可以直接使用 countdocuble 等变量呢。

// 使用展开运算符
return {...data}
// 使用对象赋值
return {
  count: data.count,
  increase: data.increase,
  docuble: data.docuble
}

我们发现取出来之后,countincreasedocuble 都丧失了响应式的活性,他们变成了不同的 javascript 类型。那么我们如何解决这种情况呢?vue3 为我们推出了 toRefs 函数。

toRefs 语法

我们先试用 toRefs 改写上面的代码:

const app = Vue.createApp({
  setup() {
    // 通过 reactive 初始化一个响应式对象
    const { reactive, computed, toRefs } = Vue
    const data = reactive({
      count: 0,
      increase: () => data.count++,
      docuble: computed(() => data.count * 2)
    })
    // toRefs 将 data 转变为一个响应式对象
    const { count, increase, docuble } = toRefs(data)
    return { count, increase, docuble }
},
  template: `
    <div>{{count}}</div>
    <div>{{docuble}}</div>
    <button @click="increase">增加</button>
  `
})

toRefs 函数接收一个 reactive 对象作为参数,返回一个普通的对象,但是这个普通对象的每一项都变成了 ref 类型的对象,即支持响应式。

1、ref / reactive 通过 proxy 对数据进行封装,当数据变化时,触发模板等内容的更新。
2、ref 处理基础类型的数据。例如 ref('cc') 变成 proxy({value: 'chen'}) 这样一个响应式的引用。
3、reactive 处理非基础类型的数据。例如 reactive({name: 'cc'}) 变成 proxy({name: 'cc'}) 这样一个响应式引用。
4、toRefs 接收一个 reactive 对象作为参数,返回一个普通对象,并将普通对象的每一项都变成了 ref 类型的对象,保持器响应式活力。例如:toRefs proxy({name: 'cc'}) 变成 {name: proxy({value: 'cc'})}

readonly 语法

获取一个对象 (响应式或纯对象) 或 ref 并返回原始 proxy 的只读 proxy。举个栗子:

const app = Vue.createApp({
    setup(props, context) {
      const { reactive, readonly, toRefs } = Vue
      let nameObj = reactive([123])
      // 获取 nameObj 这个响应对象为参数
      let copyNameObj = readonly(nameObj)
      setTimeout(() => {
        nameObj[0] = 456
        // 因为使用了 readonly 无法改变 copyNameObj 的值
        copyNameObj[0] = 456
      }, 2000)
      return { nameObj, copyNameObj }
    }
  })

toRef 语法

toRefs 只差了一个 s,那么它有什么用呢?让我们先来假设一个场景,如下栗子:

const app = Vue.createApp({
  template: `
    <div>{{age}}</div>
  `,
  setup(props, context) {
    const { reactive, toRefs, toRef } = Vue
    const data = reactive({ name: 'zhangsan' })
    const { age } = toRefs(data)
    console.log(age) // undefined
    setTimeout(() => {
      age.value = 20 // 报错 Cannot set property 'value' of undefined
    }, 2000)
    return { age }
  }
}).mount('#root')

我们定义了一个响应式变量 datatoRefs 会将相应对象解析成一个普通对象,并保证该普通对象的属性都是 ref 响应式属性,我们又想在新增加一个 age 的响应式变量,结果只得到了一个 undefined 类型而不是预料中的 ref 类型。此时我们可以使用 toRef 改造上面的代码:

const app = Vue.createApp({
  template: `
    <div>{{age}}</div>
  `,
  setup(props, context) {
    const { reactive, toRefs, toRef } = Vue
    const data = reactive({ name: 'zhangsan' })
    const age = toRef(data, 'age')
    setTimeout(() => {
      age.value = 20
    }, 2000)
    return { age }
  }
}).mount('#root')

toRef 可以用来为源响应式对象上的某个 property 新创建一个 ref,这里我们就新建了一个 ref 类型的 age 变量,然后 age 就可以完美继承响应活性了。

setup 参数 context

前面我们介绍了 setup 函数中的第一个参数 props,这里我们再来看看它的第二个参数 context 的用法,根据官网文档传递给 setup 函数的第二个参数是 contextcontext 是一个普通的 JavaScript 对象,它暴露三个组件的 property

export default {
  setup(props, context) {
    const { attrs, slots, emit } = context
  }
}

context 是一个普通的 JavaScript 对象,也就是说,它不是响应式的,这意味着你可以安全地对 context 使用 ES6 解构。

export default {
  setup(props, { attrs, slots, emit }) {}
}

那首先我们来看一下 attrs 的使用:

const app = Vue.createApp({
  template: `
    <div>
      <child username="zhangsan"></child>
    </div>
  `
})
app.component('child', {
  template: `
    <div></div>
  `,
  setup(props, context) {
    const { attrs, slots, emit } = context
    // attrs 接收一个 None-Props 属性
    console.log(attrs.username)
  }
})
const vm = app.mount('#root')

什么是 None-Props 属性 呢?正常情况下,父组件给子组件传递了 username ,子组件应该通过 props: ['username'] 去接收,但是如果子组件如果不使用 props 接收,那么就可以在 attrs 处接收到 username。而这个 username 就是 None-Props 属性

slots 看名字应该就可以大致猜到,这是用来接收插槽的,同样是上面的栗子,我们改写一下:

const app = Vue.createApp({
  template: `
  <div>
    <child>parent</child>
  </div>
`
})
app.component('child', {
  setup(props, context) {
    const { h } = Vue
    const { attrs, slots, emit } = context
    return () => h('div', {}, slots.default())
  }
})
const vm = app.mount('#root')

而如果不在 setup 函数中,而是在组件外部的生命周期函数中,我们只能通过 this 去获取 slots ,现在我们可以完全脱离 vue2 的写法,将所有的东西都聚合到 setup 函数中。

mounted() {
  console.log(this.$slots.default())
},

emit 看名字其实应该也可以猜出来,子组件像父组件的事件传递,直接看栗子:

const app = Vue.createApp({
  template: `
    <div>
      <child @change="change"></child>
    </div>
  `,
  setup() {
    const change = () => {
      alert('change')
    }
    return { change }
  }
})
app.component('child', {
  template: `
    <div @click="handleClick">child</div>
  `,
  setup(props, context) {
    const { attrs, slots, emit } = context
    const handleClick = () => {
      // 使用 emit 直接传递给父组件
      emit('change')
    }
    return { handleClick }
  }
})
const vm = app.mount('#root')

watch 语法

熟悉 vue2 的小伙伴对 watch 肯定不陌生,在 vue3 中,我们可以把 watch 集成到 setup 函数中进行使用。watch 需要侦听特定的数据源,并在单独的回调函数中执行副作用。默认情况下,它也是惰性的——即回调仅在侦听源发生更改时被调用。

使用 watch 监听单个数据源

const app = Vue.createApp({
  setup() {
    const { ref, reactive, watch } = Vue
    let title = ref("")
    const editTitle = () => {
      title.value = 'hello world'
    }
    watch(title, (newValue, oldValue) => {
      console.log('old', oldValue) // 'old', ""
      console.log('new', newValue) // 'new', "hello world"
    })
    return { title, editTitle }
  },
  template: `
    <div>ref 值监听:{{title}}</div>
    <button @click="editTitle">改变title</button>
  `
}).mount('#root')

使用 watch 监听多个数据源

const app = Vue.createApp({
  setup() {
    const { ref, reactive, watch } = Vue
    let title = ref("")
    const data = reactive({
      count: 0
    })
    const editTitle = () => {
      title.value = 'hello world'
    }
    const editCount = () => {
      data.count++
    }
    // watch 可以监听一个数组,里面是需要监听的多个值
    watch([title, data], (newValue, oldValue) => {
      console.log('old', oldValue) //  ["", Proxy]
      console.log('new', newValue) // ["hello world", Proxy]
    })
    return { title, editTitle, data, editCount }
  },
  template: `
    <div>reactive 值监听:{{data.count}}</div>
    <div>ref 值监听:{{title}}</div>
    <button @click="editTitle">改变title</button>
    <button @click="editCount">改变count</button>
  `
}).mount('#root')

上面的栗子中,我们使用监听了 reactive 新增监听了一个 data 对象,但是如果我们只想监听 data 对象中的 count 属性,而不是监听整个 data 对象,那么我们应该如何实现呢?

// watch 中接收一个函数,其返回值为 reactive 函数对象中想监听的值
watch([title, () => data.count], (newValue, oldValue) => {
  console.log('old', oldValue) // ["", 0]
  console.log('new', newValue) // ["hello world", 1]
})

watchEffect 语法

watchEffectwatch 都是用来对数据的侦听,但是他们有 3 个比较大的差别:

1、watchEffect 立即执行,没有惰性。
2、不需要传递你要侦听的内容,自动会感知代码依赖,不需要传递很多参数,只需要传递一个回调函数。
3、不能获取之前数据的值 。

举个栗子:

const app = Vue.createApp({
  template: `
    <input v-model="nameObj.name" />
  `,
  setup() {
    const { reactive, watchEffect } = Vue
    const nameObj = reactive({ name: 'cc' })
    watchEffect(() => {
      // 初次加载就会执行打印出 `abc`
      // 但是自动检测到 `abc` 和页面没有任何关系后面就不会在执行 
      console.log('abc')
      // nameObj.name 绑定了页面的 input,所以每次输入改变 name 的值都会执行一次
      console.log(nameObj.name)
    })
    return { nameObj }
  }
}).mount('#root')

如果我们要在 5 秒后停止 watch 或者 watchEffect 对属性的监听应该如何做呢,如下栗子:

const app = Vue.createApp({
  template: `
    <input v-model="nameObj.name" />
  `,
  setup() {
    const { reactive, watchEffect } = Vue
    const nameObj = reactive({ name: 'cc' })
    // 将函数变成一个命名函数,然后 5s 后在执行一次这个命名函数(watch 同理)
    const stop = watchEffect(() => {
      console.log(nameObj.name)
      setTimeout(() => {
        stop()
      }, 5000)
    })
    return { nameObj }
  }
}).mount('#root')

老瓶新酒 - 生命周期

vue3 组件中的生命周期和 vue2 组件生命周期基本相同,唯一不同的是将 beforeDestroydestroyed 改成了 beforeUnmountunmounted,根据尤大的说法是后者语义性更强,更能表达组件卸载的说法。通过代码简单回顾下:

// 生命周期函数:在某一时刻会自动执行的函数
const app = Vue.createApp({
  data() {
    return {
      message: 'see you'
    }
  },
  template: `
    <div @click="handleClickItem">{{message}}</div>
  `,
  methods: {
    handleClickItem() {
      this.message = 'bye bye'
      setTimeout(() => {
        app.unmount()
      }, 1000)
    }
  },
  // 在实例生成之前会自动执行的函数
  beforeCreate() {
    console.log('beforeCreated', this.message)
  },
  // 在实例生成之后会自动执行的函数
  created() {
    console.log('created', this.message)
  },
  // 在组件内容被渲染到页面之前自动执行的函数
  beforeMount() {
    console.log('beforeMounte', document.getElementById('root').innerHTML)
  },
  // 在组件内容被渲染到页面之后自动执行的函数
  mounted() {
    console.log('mounted', document.getElementById('root').innerHTML)
  },
  // 在组件内容被修改之前自动执行的函数
  beforeUpdate() {
    console.log('beforeUpdate', document.getElementById('root').innerHTML)
  },
  // 在组件内容被修改之后自动执行的函数
  updated() {
    console.log('updated', document.getElementById('root').innerHTML)
  },
  // 在 Vue 应用失效时,自动执行的函数 可以通过 app.unmount() 触发
  beforeUnmount() {
    console.log('beforeUnmount', document.getElementById('root').innerHTML)
  },
  // 当 Vue 应用失效且 Dom 完全销毁之后,自动执行的函数
  unmounted() {
    console.log('unmount', document.getElementById('root'))
  }
})
const vm = app.mount('#root')

setup 函数中的调用生命周期钩子

  • beforeCreate -> 不需要
  • created -> 不需要
  • beforeMount -> onBeforeMount
  • mounted -> onMounted
  • beforeUpdate -> onBeforeUpdate
  • updated -> onUpdated
  • beforeUnmount -> onBeforeUnmount
  • unmounted -> onUnmounted
  • errorCaptured -> onErrorCaptured
  • renderTracked -> onRenderTracked
  • renderTriggered -> onRenderTriggered

因为 setup 是围绕 beforeCreatecreated 生命周期钩子运行的,所以不需要显式地定义它们。换句话说,在这些钩子中编写的任何代码都应该直接在 setup 函数中编写。

vue3 模块化开发

前面我们所有的逻辑代码都写进了 setup 函数中,虽然所有的代码都写在 setup 函数中是可以将逻辑都聚合到一起,但是如果一个页面所有的逻辑代码都写入一个函数,只会让代码显得更加难以维护。也许你写完当前也页面的所有逻辑代码有 1000 行,但是三天之后你在想维护这段代码,估计你自己也会懵逼,不知从何写起。所以日常开发中我们更应该将 setup 当做一个流程处理函数,将该页面的所有逻辑抽离成一个一个模块,将每个模块的结果导出到 setup 函数中供页面模板使用。我们先来看一个简单的栗子:

我们这里实现一个简单的功能,就是记录每次鼠标点击屏幕时打印出鼠标当前的坐标值!

// 将鼠标坐标值更新逻辑抽离成一个单独的函数
const { reactive, toRefs, onMounted, onUnmounted } = Vue
const updateMouseEffect = () => {
    const mouseObj = reactive({
      x: 0,
      y: 0
    })
    const updateMouse = (e) => {
      mouseObj.x = e.pageX
      mouseObj.y = e.pageY
    }
    onMounted(() => {
      document.addEventListener('click', updateMouse)
    })
    onUnmounted(() => {
      document.removeEventListener('click', updateMouse)
    })
    const { x, y } = toRefs(mouseObj)
    return { x, y }
}
const app = Vue.createApp({
    // 页面上的每一个逻辑都抽离成对应的函数
    // setup 函数只负责将封装的逻辑中导出的值引入,不涉及具体逻辑编写
    setup() {
      const { x, y } = updateMouseEffect()
      return { x, y }
    },
    template: `
      <h1>x: {{x}} y: {{y}}</h1>
    `
}).mount('#root')

看了上面的代码,是不是觉得 vue3 可以完美实现各种 hooks ,接下来我们结合 axios 来实现一个涉及外部请求的封装函数。这里都是用的 cdn ,如需复现栗子,记得头部引入 axioscdn 链接。

const { ref } = Vue
const useURLLoader = (url) => {
    const result = ref(null) // 响应结果
    const loading = ref(true) // 是否显示loading
    const loaded = ref(false) // 是否加载完成
    const error = ref(null) // 是否响应错误

    axios.get(url).then((rawData) => {
      loading.value = false
      loaded.value = true
      result.value = rawData.data
    }).catch((e) => {
      error.value = e
    })

    return { result, loading, loaded, error }
}
const url = 'https://dog.ceo/api/breeds/image/random'
const app = Vue.createApp({
    setup() {
        const { result, loading, loaded, error } = useURLLoader(url)
        return { result, loading, loaded, error }
  },
    template: `
        <h1 v-if="loading">Loading!...</h1>
        <img v-if="loaded" :src="result.message" >
    `
}).mount('#root')

这个栗子其实就是我们在发请求时希望页面显示 loading ,拿到请求结果之后将 loading 状态取消然后展示我们拿到的结果数据,代码很简单,也是将所有的逻辑代码都抽离到一个单独的函数中,setup 函数只负责流程控制和数据导出。

常用的一些都整理出来了,本来想继续整理一些 vue3 中的新特性,但是篇幅太长了,就准备在下一篇中继续整理了,如有错误或不正确的地方欢迎指正,每天进步一点点,加油!!!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容