【🚨万字警告】了不得的Vue3(下)

前文再续:【🚨万字警告】了不得的Vue3(上)javascript

What——新东西?

一块儿看看Vue3给咱们带来了哪些值得关注的新东西。css

Composition API

首先固然是万众瞩目的Composition API。html

为此,我搬运了然叔的一晚上动画~前端

咱们先回顾一下在Vue2中OptionsAPI是怎么写的: vue

随着产品迭代,产品经理不断提出了新的需求: java

因为相关业务的代码须要遵循option的配置写到特定的区域,致使后续维护很是的复杂,代码可复用性也不高。最难受的是敲代码的时候不得不上下反复横跳,晃得眼瞎...node

用了CompositionAPI会变成什么样呢? react

咱们能够看到,功能相关的代码都聚合起来了,代码变得井井有理,再也不频繁地上下反复横跳。但还差点意思,事实上,咱们不少逻辑相关的操做是不须要体现出来的,真正须要使用到的可能只是其中的一些变量、方法,而Composition API带来的出色代码组织和复用能力,让你能够把功能相关的代码抽离出去成为一个可复用的函数JS、TS文件,在.vue文件中经过函数的调用把刚刚这些函数的返回值组合起来,最后返回模板真正须要使用到的东西: webpack

巴适得很~git

Composition API为什么这么好用,得益于它的两个核心组成:

  • Reactivity——响应式系统
  • 生命周期钩子

响应式系统暴露了更多底层的API出来,从而让咱们很轻松地去建立使用响应式变量。而后结合暴露出来的生命周期钩子,基本就能够完成整个组件的逻辑运做。固然还能够结合更多的api完成更复杂的工做,社区也有不少关于CompositionAPI的使用技巧和方法,这一块就不去细化了,点到为止。

优点

对比Class API

  • 更好的 TypeScript 类型推导支持

    function对于类型系统是很是友好的,尤为是函数的参数和返回值。

  • 代码更容易被压缩

    代码在压缩的时候,好比对象的key是不会进行压缩的,这一点能够从咱们刚刚对于Three shaking demo构建出来的包就能够看得出来:

而composition API声明的一些响应式变量,就能够很安全地对变量名进行压缩。

  • Tree-shaking 友好

    CompositionAPI这种引用调用的方式,构建工具能够很轻松地利用Tree shaking去消除咱们实际未使用到 “死代码“

  • 更灵活的逻辑复用能力

    在Vue2中,咱们一直缺乏一种很干净方便的逻辑复用方法。

    以往咱们要想作到逻辑复用,主要有三种方式:

    1. 混入——Mixins
    2. 高阶组件——HOC
    3. 做用域插槽

为了更好地体会这三种方法的恶心之处,我用一个简单的demo去分别演示这三种方法。

案例:鼠标位置侦听:

先看看Mixins的方式:

Mixins

MouseMixin.js:

import {throttle} from "lodash"

let throttleUpdate;

export default {
    data:()=>({
        x:0,
        y:0
    }),
    
    methods:{
        update(e){
            console.log('still on listening')
            this.x = e.pageX
            this.y = e.pageY
        }
    },
    
    beforeMount() {
        throttleUpdate = throttle(this.update,200).bind(this)
    },
    mounted() {
        window.addEventListener('mousemove',throttleUpdate)
    },
    unmounted() {
        window.removeEventListener('mousemove',throttleUpdate)
    }
}

复制代码

使用:

<template>
  <header> <h1>获取鼠标位置——Mixins</h1> </header>

  <main> <span>(</span> <transition name="text" mode="out-in"> <div :key="x" class="position">{{ x }}</div> </transition> <span>,</span> <transition name="text" mode="out-in"> <div :key="y" class="position">{{ y }}</div> </transition> <span>)</span> </main>
</template>


<script> import {defineComponent} from "vue" import MouseMixin from "@/components/Mouse/MouseMixin.js"; export default defineComponent({ mixins: [MouseMixin], components: {} }) </script>
复制代码

当大量使用mixin时:

  • 命名空间冲突
  • 模版数据来源不清晰

HOC——高阶组件

HOC在React使用得比较多,它是用来替代mixin的方案。事实上Vue也能够写HOC。

其原理就是在组件外面再包一层父组件,复用的逻辑在父组件中,经过props传入到子组件中。

看看这个带有可复用逻辑的MouseHOC怎么写:

import Mouse2 from "@/views/Mouse/Mouse2.vue";

import { defineComponent } from "vue";
import { throttle } from "lodash";

let throttleUpdate;

export default defineComponent({
  render() {
    return (
        <Mouse2 x={this.x} y={this.y}/>
    );
  },
  data: () => ({
    x: 0,
    y: 0,
  }),
  methods: {
    update(e) {
      this.x = e.pageX;
      this.y = e.pageY;
    },
  },
  beforeMount() {
    throttleUpdate = throttle(this.update, 200).bind(this);
  },
  mounted() {
    window.addEventListener("mousemove", throttleUpdate);
  },
  unmounted() {
    window.removeEventListener("mousemove", throttleUpdate);
  },
});

复制代码

HOC内部的子组件——Mouse2.vue:

<template>
  <header> <h1>获取鼠标位置——HOC</h1> </header>

  <main> <span>(</span> <transition name="text" mode="out-in"> <div :key="x" class="position">{{ x }}</div> </transition> <span>,</span> <transition name="text" mode="out-in"> <div :key="y" class="position">{{ y }}</div> </transition> <span>)</span> </main>
</template>

<script lang="ts"> import {defineComponent} from "vue" export default defineComponent({ props:['x','y'] }) </script>
复制代码

一样,在大量使用HOC的时候的问题:

  • props 命名空间冲突
  • props 来源不清晰
  • 额外的组件实例性能消耗

做用域插槽

原理就是经过一个无需渲染的组件——renderless component,经过做用域插槽的方式把可复用逻辑输出的内容放到slot-scope中。

看看这个无渲染组件怎么写:

<template>
  <slot :x="x" :y="y"></slot>
</template>

<script> import {throttle} from "lodash"; let throttleUpdate; export default { data:()=>({ x:0, y:0 }), methods:{ update(e){ console.log('still on listening') this.x = e.pageX this.y = e.pageY } }, beforeMount() { throttleUpdate = throttle(this.update,200).bind(this) }, mounted() { window.addEventListener('mousemove',throttleUpdate) }, unmounted() { window.removeEventListener('mousemove',throttleUpdate) } } </script>
复制代码

在页面组件Mouse3.vue中使用:

<template>
  <header> <h1>获取鼠标位置——slot</h1> </header>
  <main> <span>(</span> <MouseSlot v-slot="{x,y}"> <transition name="text" mode="out-in"> <div :key="x" class="position">{{ x }}</div> </transition> <span>,</span> <transition name="text" mode="out-in"> <div :key="y" class="position">{{ y }}</div> </transition> </MouseSlot> <span>)</span> </main>
</template>

<script lang="ts"> import {defineComponent} from "vue" import MouseSlot from "@/components/Mouse/MouseSlot.vue" export default defineComponent({ components: { MouseSlot } }) </script>
复制代码

当大量使用时:

  • 没有命名空间冲突
  • 数据来源清晰
  • 额外的组件实例性能消耗

虽然无渲染组件已是一种比较好的方式了,但写起来仍然蛮恶心的。

因此,在Composition API中,怎么作到逻辑复用呢?

Composition API

暴露一个可复用函数的文件:useMousePosition.ts,这个命名只是让他看起来更像react hooks一些,一眼就能看出来这个文件这个函数是干什么的,实际上你定义为其余也不是不能够。

import {ref, onMounted, onUnmounted} from "vue"
import {throttle} from "lodash"

export default function useMousePosition() {

    const x = ref(0)
    const y = ref(0)

    const update = throttle((e: MouseEvent) => {
        x.value = e.pageX
        y.value = e.pageY
    }, 200)

    onMounted(() => {
        window.addEventListener('mousemove', update)
    })
    onUnmounted(() => {
        window.removeEventListener('mousemove', update)
    })

    return { x, y }
}
复制代码

页面组件Mouse4.vue中使用:

<template>
  <header> <h1>获取鼠标位置——Composition API</h1> </header>

  <main> <span>(</span> <transition name="text" mode="out-in"> <div :key="x" class="position">{{ x }}</div> </transition> <span>,</span> <transition name="text" mode="out-in"> <div :key="y" class="position">{{ y }}</div> </transition> <span>)</span> </main>
</template>


<script lang="ts"> import {defineComponent} from "vue" import useMousePosition from "@/components/Mouse/useMousePosition"; export default defineComponent({ setup() { const { x, y } = useMousePosition() return { x, y } } }) </script>
复制代码

即便在大量使用时:

  • 没有命名空间冲突
  • 数据来源清晰
  • 没有额外的组件实例性能消耗

干净清晰

除此以外,这种函数式也给予了优秀的代码组织能力。

为了演示这一点,我把Vue2示例中的todoMVC项目搬下来用CompositionAPI重构了一下。

todoMVC就是一个待办事项的小应用,功能有:

  1. 本地缓存,并动态存储到LocalStorage中
  2. 新增代办事项
  3. 点击完成代办事项,一键所有完成/未完成
  4. 删除代办事项
  5. 清空已完成的代办事项
  6. 根据完成状态筛选代办事项列表

(刁钻的朋友可能发现我把编辑功能阉割掉了,这里确实偷了个懒,当时写得比较着急,又由于一些兼容性的缘由,编辑状态点不出来,一气之下把编辑阉了....其实有没有也不太影响我想要说明的东西)

来码,整个代办事项组件:TodoMVC.vue

import {defineComponent} from "vue"
import useTodoState from "@/views/TodoMVC/useTodoState";
import useFilterTodos from "@/views/TodoMVC/useFilterTodos";
import useHashChange from "@/views/TodoMVC/useHashChange";

export default defineComponent({
  setup() {

    /*响应式变量、新增和删除代办事项的方法*/
    const {
      todos,
      newTodo,
      visibility,
      addTodo,
      removeTodo
    } = useTodoState()

    // 筛选数据、一键所有完成/未完成、清空所有已完成事项
    const {
      filteredTodos,
      remaining,
      allDone,
      filters,
      removeCompleted
    } = useFilterTodos(todos, visibility)


    // 监听路由哈希变化
    useHashChange(filters, visibility)


    return {
      todos,
      newTodo,
      filteredTodos,
      remaining,
      allDone,
      visibility,
      removeCompleted,
      addTodo,
      removeTodo,
    }
  },

})
复制代码

useTodoState中又调用了一个本地存储逻辑相关的composition function:useTodoStorage.ts

useTodoState.ts:

import { Todo, Visibility } from "@/Types/TodoMVC";
import { ref, watchEffect, } from "vue"
import useTodoStorage from "@/views/TodoMVC/useTodoStorage";

export default function useTodoState() {

    const { fetch, save, uid } = useTodoStorage()

    // 所有事项
    const todos = ref(fetch())
    
    // 即将新增事项的内容
    const newTodo = ref("")

    // 新增代办事项
    const addTodo = () => {
        const value = newTodo.value && newTodo.value.trim()
        if (!value) {
            return;
        }
        todos.value.push({
            id: uid.value,
            title: value,
            completed: false
        })
        uid.value += 1
        newTodo.value = ""
    }

    // 删除代办事项
    const removeTodo = (todo: Todo) => {
        todos.value.splice(todos.value.indexOf(todo), 1)
    }

    // 使用todos.value的反作用去动态保存代办事项到本地缓存中
    watchEffect(() => {
        save(todos.value)
    })

    // 当前筛选的类型(url的hash值与此值一致)
    const visibility = ref<Visibility>("all")
    
    return {
        todos,
        newTodo,
        visibility,
        addTodo,
        removeTodo
    }
}
复制代码

用于本地缓存的useTodoStorage.ts

import {Todo} from "@/Types/TodoMVC";
import {ref, watchEffect} from "vue"


export default function useTodoStorage() {

    const STORAGE_KEY = 'TodoMVC——Vue3.0'


    // 获取LocalStorage中的数据
    const fetch = (): Todo[] => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");

    // 数据存储到LocalStorage中
    const save = (todos: Todo[]) => {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
    }

    // 用于新增代办事项的id自动生成
    const uid = ref(~~(localStorage.getItem('uid') || 0));
    watchEffect(() => localStorage.setItem('uid', uid.value.toString()))

    return {
        fetch,
        save,
        uid
    }

}
复制代码

其余就不一一展现了,代码最终都放在文末的连接中的github仓库里了,感兴趣的能够细品。这个demo由于写得比较仓促,自我感受写得不咋滴,逻辑的组织有待商榷,这也从侧面展现了composition API给咱们带来的高灵活组织和复用能力,至于如何把代码组织得更漂亮就是开发者本身的事了,我也在试图慢慢摸索出写得更舒服的最佳实践

与React Hooks对比

  • 一样的逻辑组合、复用能力
  • 只调用一次
    • 符合 JS 直觉
    • 没有闭包变量问题
    • 没有内存/GC 压力
    • 不存在内联回调致使子组件永远更新的问题

不可置否,Composition API的诞生确实受到了React Hooks的启发,若是所以就贴上抄袭的标签就未免太流于表面了,也不想在此去引战。框架都是好框架,前端圈内要以和为贵,互相借鉴学习难道很差吗,不要搞窝里斗。

事实上,Composition API的实现与使用方式也都是大相径庭的,懂得天然懂。

与React Hooks的对比也已经有很多文章说得挺详细了,这里就再也不进行赘述。

简单来讲就是得益于响应式系统,Composition API 使用的心智负担相比之下实在是小太多了。

Fragment

这个新特性比较简单,就是在模板中能够写多个根节点。至于它的意义:

  • 减小无心义的根节点元素
  • 能够平级递归组件

第二个意义比较重要,利用这个新特性,好比能够写一个骚气的快速排序组件

QuickSort.vue:

<template>
  <quick-sort :list="left" v-if="left.length"></quick-sort>
  <span class="item">{{ flag }}</span>
  <quick-sort :list="right" v-if="right.length"></quick-sort>
</template>


<script lang="ts"> import {defineComponent, ref} from "vue" export default defineComponent({ name: 'quick-sort', props: ["list"], setup(props) { // eslint-disable-next-line vue/no-setup-props-destructure const flag: number = props.list[0] const left = ref<number[]>([]) const right = ref<number[]>([]) setTimeout(() => { props.list.slice(1).forEach((item: number) => { item > flag ? right.value.push(item) : left.value.push(item) }) }, 100) return { flag, left, right } } }) </script>
复制代码

在页面组件Fragment.vue中使用:

<template>
  <h1>快速排序</h1>
  <h2> {{ list }} </h2>
  <div> <button @click="ok = !ok">SORT</button> </div>
  <hr>
  <template v-if="ok"> <QuickSort :list="list"></QuickSort> </template>
</template>

<script lang="ts"> import QuickSort from "@/components/QuickSort.vue"; import {defineComponent, ref} from "vue" import {shuffle} from "lodash" export default defineComponent({ components: { QuickSort }, setup() { const ok = ref(false) const list = ref<number[]>([]) for (let i = 1; i < 20; i++){ list.value.push(i) } list.value = shuffle(list.value) return {list, ok} } }) </script> 复制代码

QuickSort中传入一个长度为20被打乱顺序的数组

能够看到,每一个递归的组件都是平级的:

而在Vue2中的递归组件每每是层层嵌套的,由于它只能存在一个根元素,一样的写法在Vue2中将会报错。

利用这一特性,咱们就能够写一个干净的树组件等等了。

Suspense

能够理解为异步组件的爹。用于方便地控制异步组件的一个挂起完成状态。

直接上代码,

首先是一个异步组件,AsyncComponent.vue

<template>
  <h2>AsyncComponent</h2>
</template>
<script lang="ts"> import {defineComponent} from "vue" export default defineComponent({ props: { timeout:{ type: Number, required: true } }, async setup(props) { const sleep = (timeout: number) => { return new Promise(resolve => { setTimeout(resolve, timeout) }) } await sleep(props.timeout) } }) </script>
复制代码

在页面组件Suspense.vue中:

<template>
  <h1>Suspense</h1>
  <Suspense> <template #default> <AsyncComponent :timeout="5000"/> </template> <template #fallback> <p class="loading">loading {{ loadingStr }}</p> </template> </Suspense>
</template>

<script lang="ts"> import {defineComponent} from "vue" import AsyncComponent from "@/components/AsyncComponent.vue" import useLoading from "@/composables/useLoading"; export default defineComponent({ components: { AsyncComponent }, setup() { const {loading: loadingStr} = useLoading() return {loadingStr} } }) </script>
复制代码

简单来讲,就是用Vue3提供的内置组件:Suspense将异步组件包起来,template #default中展现加载完成的异步组件,template #fallback中则展现异步组件挂起状态时须要显示的内容。

看看效果:

Teleport

理解为组件任意门,让你的组件能够任意地丢到html中的任一个DOM下。在react中也有相同功能的组件——Portal,之因此更名叫Teleport是因为html也准备提供一个原生的protal标签,为了不重名就叫作Teleprot了。

利用这个特性,咱们能够作的事情就比较有想象空间了。例如,写一个Ball组件,让它在不一样的父组件中呈现不同的样式甚至是逻辑,这些样式逻辑能够写在父组件中,这样当这个Ball组件被传送到某个父组件中,就能够将父组件对其定义的样式和逻辑应用到Ball组件中了。再例如,能够在任意层级的组件中写一个须要挂载到外面去的子组件,好比一个Modal弹窗,虽然挂载在当前组件下也能够达到效果,可是有时候当前组件的根节点的样式可能会与之发生一些干扰或者冲突。

这里,我写了一个Modal弹窗的demo:

<template>
  <h1>Teleport——任意门</h1>
  <div class="customButton" @click="handleToggle">偷袭</div>
  <teleport to="body"> <TeleportModal v-if="isOpen" @click="handleToggle"></TeleportModal> </teleport>
</template>

<script lang="ts"> import {defineComponent, ref} from "vue" import TeleportModal from "@/components/TeleportModal.vue" export default defineComponent({ components: { TeleportModal }, setup() { const isOpen = ref(false) const handleToggle = () => { isOpen.value = !isOpen.value } return { isOpen, handleToggle } } }) </script>
复制代码

用Vue3内置的Teleport组件将须要被传送的Modal组件包起来,写好要被传送到的元素选择器。(有点像寄快递,用快递盒打包好,写上收货地址,起飞)

看看这个demo的效果:

能够看到,马保国确实被踢到body下面去了(🐶)。

createRenderer API

利用这个API,在Vue3中咱们能够自由方便地去构建Web(浏览器)平台非Web平台自定义渲染器

原理大概就是:将Virtual DOM平台相关的渲染分离,经过createRendererAPI咱们能够自定义Virtual DOM渲染到某一平台中时的全部操做,好比新增修改删除一个“元素”,咱们能够这些方法中替换或修改成咱们自定义的逻辑,从而打造一个咱们自定义的渲染器

固然,在web平台下是相对比较简单的,由于能够利用Vue的runtime-dom给咱们提供的一个上层的抽象层,它帮咱们完成了Virtual DOM渲染到Web DOM中的复杂浏览器接口编程操做,咱们只须要在createRenderer的参数中传入一些自定义的逻辑操做便可自动完成整合,好比你能够在createElement方法中加一段本身的逻辑: 这样在每次建立新元素的时候都会跟你“打招呼”。

调用createRenderer之后的返回值是一个renderercreateApp这个方法就是这个renderer的一个属性方法,用它替代原生的createApp方法就可使用咱们本身的自定义渲染器了~

为此,我准备了一个用Three.js和自定义渲染器实现的3D方块demo,而且用composition API将咱们以前写的侦听鼠标位置的逻辑复用过来,让这个3D方块跟着咱们的鼠标旋转。

首先,写一个自定义渲染器renderer.js:

import { createRenderer } from '@vue/runtime-dom'
import * as THREE from 'three'

let webGLRenderer

// Three.js相关
function draw(obj) {
    const {camera,cameraPos, scene, geometry,geometryArg,material,mesh,meshY,meshX} = obj
    if([camera,cameraPos, scene, geometry,geometryArg,material,mesh,meshY,meshX].filter(v=>v).length<9){
        return
    }
    let cameraObj = new THREE[camera]( 40, window.innerWidth / window.innerHeight, 0.1, 10 )
    Object.assign(cameraObj.position,cameraPos)

    let sceneObj = new THREE[scene]()

    let geometryObj = new THREE[geometry]( ...geometryArg)
    let materialObj = new THREE[material]()

    let meshObj = new THREE[mesh]( geometryObj, materialObj )
    meshObj.rotation.x = meshX
    meshObj.rotation.y = meshY
    sceneObj.add( meshObj )
    webGLRenderer.render( sceneObj, cameraObj );
}

const { createApp } = createRenderer({
      insert: (child, parent, anchor) => {
          if(parent.domElement){
              draw(child)
          }
      },
      createElement:(type, isSVG, isCustom) => {
          alert('hi Channing~')
          return {
              type
          }
      },
      setElementText(node, text) {},
      patchProp(el, key, prev, next) {
          el[key] = next
          draw(el)
      },
      parentNode: node => node,
      nextSibling: node => node,
      createText: text => text,
      remove:node=>node
});


// 封装一个自定义的createApp方法
export function customCreateApp(component) {
  const app = createApp(component)
  return {
    mount(selector) {
        webGLRenderer = new THREE.WebGLRenderer( { antialias: true } );
        webGLRenderer.setSize( window.innerWidth, window.innerHeight );
        const parentElement =  document.querySelector(selector) || document.body
        parentElement.appendChild( webGLRenderer.domElement );
        app.mount(webGLRenderer)
    }
  }
}


复制代码

App.vue,这里写一些对真实DOM的操做逻辑,好比我把meshXmeshY设置为了获取鼠标位置这个composition function 返回的鼠标xy的计算属性值(为了减少旋转的灵敏度)。

<template>
  <div camera="PerspectiveCamera" :cameraPos={z:1} scene="Scene" geometry="BoxGeometry" :geometryArg="[0.2,0.2,0.2]" material="MeshNormalMaterial" mesh="Mesh" :meshY="y" :meshX="x" > </div>

</template>

<script> import {computed} from 'vue' import useMousePosition from "./useMousePosition"; export default { setup() { const {x: mouseX, y: mouseY} = useMousePosition() const x = computed(() => (mouseY.value)/200) const y = computed(() => (mouseX.value)/200) return {x,y} } } </script>
<style> body { padding: 0; margin: 0; overflow: hidden; } </style>
复制代码

最后,在main.js中使用咱们刚刚在renderer.js中封装的带有自定义渲染器的customCreateApp方法替换普通的createApp方法,便可:

import { customCreateApp } from './renderer';
import App from "./App.vue";

customCreateApp(App).mount("#app")
复制代码

咱们看看最终的效果:

因缺思厅!

One more thing —— Vite

最后,号称面向将来的构建工具Vite

yarn dev 啪地一下应用就起来了,很啊。

它的原理就是一个基于浏览器原生 ES imports 的开发服务器。利用浏览器解析 imports,在服务器端按需编译返回,彻底跳过了打包这个概念,服务器随起随用。支持 .vue文件热更新,而且热更新的速度不会随着模块增多而变慢。

固然,生产环境的构建仍是使用的rollup进行打包。它的香是在于开发环境的调试速度。

为了更好地理解它的工做原理,我找了蜗牛老湿画的一张图:

而后,我建立了一个vite的演示demo,用来看看Vite是怎么处理咱们的文件的。

yarn create vite-app vite-demo
cd vite-demo && yarn && yarn dev
复制代码

打开http://localhost:3000/

看到localhost的请求结果,依然是保留了ES Module类型的代码

而后Vite的服务器拦截到你对main.js的请求,而后返回main.js的内容给你,里面依然是ES Module的类型,

又拦截到vue.jsApp.vue,继续返回相应的内容给你,如此类推……

因此Vite应用启动的过程彻底跳过了打包编译,让你的应用秒起。文件的热更新也是如此,好比当你修改了App.vue的内容,它又拦截给你返回一个新的编译事后的App.vue文件:

对于大型的项目来讲,这种毫秒级的响应实在是太舒服了。去年参与过一个内部组件库的开发工做,当时是修改的webpack插件,每次修改都得重启项目,每次重启就是四五分钟往上,简直感受人要裂开。

固然,也不至于到能够彻底取代Webpack的夸张地步,由于Vite仍是在开发阶段,许多工程化的需求仍是难以知足的,好比Webpack丰富的周边插件等等。

写在最后

感谢你们能够耐心地读到这里。
固然,文中或许会存在或多或少的不足、错误之处,有建议或者意见也很是欢迎你们在评论交流。
文中所用的全部Demo都已放在:GitHub传送门
最后,但愿朋友们能够点赞评论关注三连,由于这些就是我分享的所有动力来源🙏

相关文章
相关标签/搜索