|
我们在Vue2编写组件的时候,会在props、data、methods、computed等options中定义一些变量。在组件初始化阶段,Vue内部会处理这些options,即把定义的变量添加到了组件实例上。等模板编译成render函数的时候,内部通过with(this){}的语法去访问在组件实例中的变量。那么到了Vue3,新出现的setup启动函数,是整个组件逻辑组织的入口,我们可以在它内部写composition-api,以更加直观的形式声明变量,有利于代码的逻辑组织和复用,但是我们要明确一点,composition-api属于api的增强,它并不是Vue3组件开发的范式,如果组件足够简单,还是可以使用options-api,在了解了composition-api的应用场景和使用方式后,我们需要进一步思考,setup启动函数是如何执行的,它所返回的数据又是如何与模板建立联系的?创建并设置组件实例首先,我们来回顾一下组件的渲染流程:创建vnode、渲染vnode和生成DOM。其中渲染vnode的过程主要就是在挂载组件:const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => { // 创建组件实例 const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense)) // 设置组件实例 setupComponent(instance) // 设置并运行带副作用的渲染函数 setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)}可以看到,这段挂载组件的代码主要做了三件事情:创建组件实例、设置组件实例和设置并运行带副作用的渲染函数。前两个流程就跟我们要探讨的问题息息相关,先看创建组件实例的流程,我们要关注createComponentInstance方法的实现:function createComponentInstance (vnode, parent, suspense) { // 继承父组件实例上的 appContext,如果是根组件,则直接从根 vnode 中取。 const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext; const instance = { // 组件唯一 id uid: uid++, // 组件 vnode vnode, // 父组件实例 parent, // app 上下文 appContext, // vnode 节点类型 type: vnode.type, // 根组件实例 root: null, // 新的组件 vnode next: null, // 子节点 vnode subTree: null, // 带副作用更新函数 update: null, // 渲染函数 render: null, // 渲染上下文代理 proxy: null, // 带有 with 区块的渲染上下文代理 withProxy: null, // 响应式相关对象 effects: null, // 依赖注入相关 provides: parent ? parent.provides : Object.create(appContext.provides), // 渲染代理的属性访问缓存 accessCache: null, // 渲染缓存 renderCache: [], // 渲染上下文 ctx: EMPTY_OBJ, // data 数据 data: EMPTY_OBJ, // props 数据 props: EMPTY_OBJ, // 普通属性 attrs: EMPTY_OBJ, // 插槽相关 slots: EMPTY_OBJ, // 组件或者 DOM 的 ref 引用 refs: EMPTY_OBJ, // setup 函数返回的响应式结果 setupState: EMPTY_OBJ, // setup 函数上下文数据 setupContext: null, // 注册的组件 components: Object.create(appContext.components), // 注册的指令 directives: Object.create(appContext.directives), // suspense 相关 suspense, // suspense 异步依赖 asyncDep: null, // suspense 异步依赖是否都已处理 asyncResolved: false, // 是否挂载 isMounted: false, // 是否卸载 isUnmounted: false, // 是否激活 isDeactivated: false, // 生命周期,before create bc: null, // 生命周期,created c: null, // 生命周期,before mount bm: null, // 生命周期,mounted m: null, // 生命周期,before update bu: null, // 生命周期,updated u: null, // 生命周期,unmounted um: null, // 生命周期,before unmount bum: null, // 生命周期, deactivated da: null, // 生命周期 activated a: null, // 生命周期 render triggered rtg: null, // 生命周期 render tracked rtc: null, // 生命周期 error captured ec: null, // 派发事件方法 emit: null } // 初始化渲染上下文 instance.ctx = { _: instance } // 初始化根组件指针 instance.root = parent ? parent.root : instance // 初始化派发事件方法 instance.emit = emit.bind(null, instance) return instance}从上述代码中可以看到,组件实例instance上定义了很多属性,其中很多一些属性是为了实现某个场景或者某个功能所定义的,只需要通过代码中的注释大概知道它们是做什么的即可。Vue2使用newVue()来初始化一个组件的实例,而到了Vue3,移除了Vue构造函数,我们直接通过创建对象字面量的方式去创建组件的实例。这两种方式并无本质的区别,都是引用一个对象,然后在整个组件的生命周期中去维护组件的状态数据和上下文环境创建好instance实例后,接下来就是设置它的一些属性。目前已完成了组件的上下文、根组件指针以及派发事件方法的设置,之后会有更多instance实例属性的设置逻辑。接着是组件实例的设置流程,对setup函数的处理就在这里完成,我们来看一下setupComponent方法的实现:function setupComponent (instance, isSSR = false) { const { props, children, shapeFlag } = instance.vnode // 判断是否是一个有状态的组件 const isStateful = shapeFlag & 4 // 初始化 props initProps(instance, props, isStateful, isSSR) // 初始化 插槽 initSlots(instance, children) // 设置有状态的组件实例 const setupResult = isStateful ? setupStatefulComponent(instance, isSSR) : undefined return setupResult}const shapeFlag = isString(type) ? 1 /* ELEMENT */ : isSuspense(type) ? 128 /* SUSPENSE */ : isTeleport(type) ? 64 /* TELEPORT */ : isObject(type) ? 4 /* STATEFUL_COMPONENT */ : isFunction(type) ? 2 /* FUNCTIONAL_COMPONENT */ : 0可以看到,我们从组件vnode中获取了props、children、shapeFlag等属性,然后分别对props和插槽进行初始化。而shapeFlag则是把vnode的类型信息做了编码,以便在后面的patch阶段,可以根据不同的类型执行相应的处理逻辑,在这里根据shapeFlag的值,我们可以判断这是不是一个有状态组件,如果是则要进一步去设置有状态组件的实例。接下来我们要关注到setupStatefulComponent函数,它主要做了三件事:创建渲染上下文代理、判断处理setup函数和完成组件实例设置。它代码如下所示:function setupStatefulComponent (instance, isSSR) { const Component = instance.type // 创建渲染代理的属性访问缓存 instance.accessCache = {} // 创建渲染上下文代理 instance.proxy = new roxy(instance.ctx, ublicInstanceProxyHandlers) // 判断处理 setup 函数 const { setup } = Component if (setup) { // 如果 setup 函数带参数,则创建一个 setupContext const setupContext = (instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null) // 执行 setup 函数,获取结果 const setupResult = callWithErrorHandling(setup, instance, 0 /* SETUP_FUNCTION */, [instance.props, setupContext]) // 处理 setup 执行结果 handleSetupResult(instance, setupResult) } else { // 完成组件实例设置 finishComponentSetup(instance) }}首先是创建渲染上下文代理的流程,它主要对instance.ctx做了代理。在分析实现前,我们需要思考一个问题,这里为什么需要代理呢?创建渲染上下文代理其实在Vue2中,也有类似的数据代理逻辑,比如props求值后的数据,实际上存储在this._props上,而data中定义的数据存储在this._data上。举个例子:
{{zz}}在初始化组件的时候,data中定义的inZZ在组件内部是存储在this._data上的,而模板渲染的时候访问this.inZZ,实际上访问的是this._data.inZZ,这是因为Vue2在初始化data的时候,做了一层proxy代理。到了Vue3,为了方便维护,我们把组件中不同状态的数据存储到不同的属性中,比如存储到setupState、ctx、data、props中。我们在执行组件渲染函数的时候,为了方便用户使用,会直接访问渲染上下文instance.ctx中的属性,所以我们也要做一层proxy,对渲染上下文instance.ctx属性的访问和修改,代理到对setupState、ctx、data、props中的数据的访问和修改。明确了代理的需求后,我们接下来就要分析proxy的几个方法:get、set和has,当我们访问instance.ctx渲染上下文中的属性时,就会进入get函数,我们来看一下它的实现:const ublicInstanceProxyHandlers = { get ({ _: instance }, key) { const { ctx, setupState, data, props, accessCache, type, appContext } = instance if (key[0] !== ') { // setupState / data / props / ctx // 渲染代理的属性访问缓存中 const n = accessCache[key] if (n !== undefined) { // 从缓存中取 switch (n) { case 0: /* SETUP */ return setupState[key] case 1 :/* DATA */ return data[key] case 3 :/* CONTEXT */ return ctx[key] case 2: /* ROPS */ return props[key] } } else if (setupState !== EMPTY_OBJ & hasOwn(setupState, key)) { accessCache[key] = 0 // 从 setupState 中取数据 return setupState[key] } else if (data !== EMPTY_OBJ & hasOwn(data, key)) { accessCache[key] = 1 // 从 data 中取数据 return data[key] } else if ( type.props & hasOwn(normalizePropsOptions(type.props)[0], key)) { accessCache[key] = 2 // 从 props 中取数据 return props[key] } else if (ctx !== EMPTY_OBJ & hasOwn(ctx, key)) { accessCache[key] = 3 // 从 ctx 中取数据 return ctx[key] } else { // 都取不到 accessCache[key] = 4 } } const publicGetter = publicPropertiesMap[key] let cssModule, globalProperties // 公开的 $xxx 属性或方法 if (publicGetter) { return publicGetter(instance) } else if ( // css 模块,通过 vue-loader 编译的时候注入 (cssModule = type.__cssModules) & (cssModule = cssModule[key])) { return cssModule } else if (ctx !== EMPTY_OBJ & hasOwn(ctx, key)) { // 用户自定义的属性,也用 ` 开头 accessCache[key] = 3 return ctx[key] } else if ( // 全局定义的属性 ((globalProperties = appContext.config.globalProperties), hasOwn(globalProperties, key))) { return globalProperties[key] } else if ((process.env.NODE_ENV !== 'production') & currentRenderingInstance & key.indexOf('__v') !== 0) { if (data !== EMPTY_OBJ & key[0] === ' & hasOwn(data, key)) { // 如果在 data 中定义的数据以 $ 开头,会报警告,因为 $ 是保留字符,不会做代理 warn(`Property ${JSON.stringify(key)} must be accessed via $data because it starts with a reserved ` + `character and is not proxied on the render context.`) } else { // 在模板中使用的变量如果没有定义,报警告 warn(`Property ${JSON.stringify(key)} was accessed during render ` + `but is not defined on instance.`) } } }}可以看到,函数首先判断key不以$开头的情况,这部分数据可能是setupState、data、props、ctx中的一种,其中data、props我们已经很熟悉了,setupState就是setup函数返回的数据,ctx包括了计算属性、组件方法和用户自定义的一些数据。如果key不以$开头,那么就依次判断setupState、data、props、ctx中是否包含这个key,如果包含就返回对应值。注意这个判断顺序很重要,在key相同时它会决定数据获取的优先级,举个例子:
{{msg}}我们在data和setup中都定义了msg变量,但最终输出到界面上的是"msgfromsetup",这是因为setupState的判断优先级要高于data。再回到get函数中,我们可以看到这里定义了accessCache作为渲染代理的属性访问缓存,它具体是干什么的呢?组件在渲染时会经常访问数据进而触发get函数,这其中最昂贵的部分就是多次调用hasOwn去判断key在不在某个类型的数据中,但是在普通对象上执行简单的属性访问相对要快得多。所以在第一次获取key对应的数据后,我们利用accessCache[key]去缓存数据,下一次再次根据key查找数据,就可以直接通过accessCache[key]获取对应的值,就不需要依次调用hasOwn去判断了,这也是一个性能优化的小技巧。如果key以$开头,那么接下来又会有一系列的判断,首先判断是不是Vue内部公开的$xxx属性或方法(比如$parent),然后判断是不是vue-loader编译注入的css模块内部的key,接着判断是不是用户自定义以$开头的key,最后判断是不是全局属性。如果都不满足,就剩两种情况了,即在非生产环境下就会报两种类型的警告,第一种是在data中定义的数据以$开头的警告,因为$是保留字符,不会做代理,第二种是在模板中使用的变量没有定义的警告。接下来是set代理过程,当我们修改instance.ctx渲染上下文中的属性的时候,就会进入set函数,我们来看一下set函数的实现:const ublicInstanceProxyHandlers = { set ({ _: instance }, key, value) { const { data, setupState, ctx } = instance if (setupState !== EMPTY_OBJ & hasOwn(setupState, key)) { // 给 setupState 赋值 setupState[key] = value } else if (data !== EMPTY_OBJ & hasOwn(data, key)) { // 给 data 赋值 data[key] = value } else if (key in instance.props) { // 不能直接给 props 赋值 (process.env.NODE_ENV !== 'production') & warn(`Attempting to mutate prop "${key}". rops are readonly.`, instance) return false } if (key[0] === ' & key.slice(1) in instance) { // 不能给 Vue 内部以 $ 开头的保留属性赋值 (process.env.NODE_ENV !== 'production') & warn(`Attempting to mutate public property "${key}". ` + `Properties starting with $ are reserved and readonly.`, instance) return false } else { // 用户自定义数据赋值 ctx[key] = value } return true }}结合代码来看,函数主要做的事情就是对渲染上下文instance.ctx中的属性赋值,它实际上是代理到对应的数据类型中去完成赋值操作的,这里仍然要注意顺序问题,和get一样,优先判断setupState,然后是data,接着是props。我们对之前的例子做点修改,添加一个方法:
{{msg}}Randommsg我们点击按钮会执行random函数,这里的this指向的就是instance.ctx,我们修改this.msg会触发set函数,所以最终修改的是setupState中的msg对应的值。注意,如果我们直接对props中的数据赋值,在非生产环境中会收到一条警告,这是因为直接修改props不符合数据单向流动的设计思想,如果对Vue内部以$开头的保留属性赋值,同样也会收到一条警告。如果是用户自定义的数据,比如在created生命周期内定义的数据,它仅用于组件上下文的共享,如下所示:export default { created() { this.userMsg = 'msg from user' }}当执行this.userMsg赋值的时候,会触发set函数,最终userMsg会被保留到ctx中。最后是has代理过程,当我们判断属性是否存在于instance.ctx渲染上下文中时,就会进入has函数,这个在平时项目中用的比较少,同样来举个例子,当执行created钩子函数中的'msg'inthis时,就会触发has函数。export default { created () { console.log('msg' in this) }}下面我们来看一下has函数的实现:const ublicInstanceProxyHandlers = { has ({ _: { data, setupState, accessCache, ctx, type, appContext } }, key) { // 依次判断 return (accessCache[key] !== undefined || (data !== EMPTY_OBJ & hasOwn(data, key)) || (setupState !== EMPTY_OBJ & hasOwn(setupState, key)) || (type.props & hasOwn(normalizePropsOptions(type.props)[0], key)) || hasOwn(ctx, key) || hasOwn(publicPropertiesMap, key) || hasOwn(appContext.config.globalProperties, key)) }}这个函数的实现很简单,依次判断key是否存在于accessCache、data、setupState、props、用户数据、公开属性以及全局属性中,然后返回结果。至此,我们就搞清楚了创建上下文代理的过程,让我们回到setupStatefulComponent函数中,接下来分析第二个流程——判断处理setup函数。判断处理setup函数我们看一下整个逻辑涉及的代码:// 判断处理 setup 函数const { setup } = Componentif (setup) { // 如果 setup 函数带参数,则创建一个 setupContext const setupContext = (instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null) // 执行 setup 函数获取结果 const setupResult = callWithErrorHandling(setup, instance, 0 /* SETUP_FUNCTION */, [instance.props, setupContext]) // 处理 setup 执行结果 handleSetupResult(instance, setupResult)}如果我们在组件中定义了setup函数,接下来就是处理setup函数的流程,主要是三个步骤:创建setup函数上下文、执行setup函数并获取结果和处理setup函数的执行结果,接下来我们就逐个来分析。首先判断setup函数的参数长度,如果大于1,则创建setupContext上下文。const setupContext = (instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null)我们知道第一个参数props对应父组件传入的props数据,第二个参数ctx是一个对象,实际上就是setupContext。下面我们来看一下用createSetupContext函数来创建setupContext:function createSetupContext (instance) { return { attrs: instance.attrs, slots: instance.slots, emit: instance.emit }}这里返回了一个对象,包括attrs、slots和emit三个属性。setupContext让我们在setup函数内部可以获取到组件的属性、插槽以及派发事件的方法emit。通过下面这行代码来执行setup函数并获取结果:const setupResult = callWithErrorHandling(setup, instance, 0 /* SETUP_FUNCTION */, [instance.props, setupContext])我们具体来看一下callWithErrorHandling函数的实现:function callWithErrorHandling (fn, instance, type, args) { let res try { res = args ? fn(...args) : fn() } catch (err) { handleError(err, instance, type) } return res}可以看到,它其实就是对fn做的一层包装,内部还是执行了fn,并在有参数的时候传入参数,所以setup的第一个参数是instance.props,第二个参数是setupContext。函数执行过程中如果有JavaScript执行错误就会捕获错误,并执行handleError函数来处理。执行setup函数并拿到了返回的结果,那么接下来就要用handleSetupResult函数来处理结果。handleSetupResult(instance, setupResult)我们详细看一下handleSetupResult函数的实现:function handleSetupResult(instance, setupResult) { if (isFunction(setupResult)) { // setup 返回渲染函数 instance.render = setupResult } else if (isObject(setupResult)) { // 把 setup 返回结果变成响应式 instance.setupState = reactive(setupResult) } finishComponentSetup(instance)}可以看到,当setupResult是一个对象的时候,我们把它变成了响应式并赋值给instance.setupState,这样在模板渲染的时候,依据前面的代理规则,instance.ctx就可以从instance.setupState上获取到对应的数据,这就在setup函数与模板渲染间建立了联系。另外setup不仅仅支持返回一个对象,也可以返回一个函数作为组件的渲染函数。在handleSetupResult的最后,会执行finishComponentSetup函数完成组件实例的设置,其实这个函数和setup函数的执行结果已经没什么关系了,个人认为提取到外面放在handleSetupResult函数后面执行更合理一些。另外当组件没有定义的setup的时候,也会执行finishComponentSetup函数去完成组件实例的设置。完成组件实例的设置接下来我们来看一下finishComponentSetup函数的实现:function finishComponentSetup (instance) { const Component = instance.type // 对模板或者渲染函数的标准化 if (!instance.render) { if (compile & Component.template & !Component.render) { // 运行时编译 Component.render = compile(Component.template, { isCustomElement: instance.appContext.config.isCustomElement || NO }) Component.render._rc = true } if ((process.env.NODE_ENV !== 'production') & !Component.render) { if (!compile & Component.template) { // 只编写了 template 但使用了 runtime-only 的版本 warn(`Component provided template option but ` + `runtime compilation is not supported in this build of Vue.` + (` Configure your bundler to alias "vue" to "vue/dist/vue.esm-bundler.js".` ) /* should not happen */) } else { // 既没有写 render 函数,也没有写 template 模板 warn(`Component is missing template or render function.`) } } // 组件对象的 render 函数赋值给 instance instance.render = (Component.render || NOOP) if (instance.render._rc) { // 对于使用 with 块的运行时编译的渲染函数,使用新的渲染上下文的代理 instance.withProxy = new roxy(instance.ctx, RuntimeCompiledPublicInstanceProxyHandlers) } } // 兼容 Vue2 options-api { currentInstance = instance applyOptions(instance, Component) currentInstance = null }}函数主要做了两件事情:标准化模板或者渲染函数和兼容options-api,接下来我们详细分析这两个流程。标准化模板或者渲染函数在分析这个过程之前,我们需要了解一些背景知识。组件最终通过运行render函数生成子树vnode,但是我们很少直接去编写render函数,通常会使用两种方式开发组件。第一种是使用SFC(SingleFileComponents)单文件的开发方式来开发组件,即通过编写组件的template模板去描述一个组件的DOM结构。我们知道.vue类型的文件无法在Web端直接加载,因此在webpack的编译阶段,它会通过vue-loader编译生成组件相关的JavaScript和CSS,并把template部分转换成render函数添加到组件对象的属性中。另外一种开发方式是不借助webpack编译,直接引入Vue,开箱即用,我们直接在组件对象template属性中编写组件的模板,然后在运行阶段编译生成render函数,这种方式通常用于有一定历史包袱的古老项目。因此Vue在Web端有两个版本:runtime-only和runtime-compiled。我们更推荐用runtime-only版本的Vue,因为相对而言它体积更小,而且在运行时不用编译,不仅耗时更少而且性能更优秀。遇到一些不得已的情况比如上述提到的古老项目,我们也可以选择runtime-compiled版本。runtime-only和runtime-compiled的主要区别在于是否注册了这个compile方法。在Vue3中,compile方法是通过外部注册的:let compile;function registerRuntimeCompiler(_compile) { compile = _compile;}回到标准化模板或者渲染函数逻辑,我们先看instance.render是否存在,如果不存在则开始标准化流程,这里主要需要处理以下三种情况。compile和组件template属性存在,render方法不存在的情况。此时,runtime-compiled版本会在JavaScript运行时进行模板编译,生成render函数。compile和render方法不存在,组件template属性存在的情况。此时由于没有compile,这里用的是runtime-only的版本,因此要报一个警告来告诉用户,想要运行时编译得使用runtime-compiled版本的Vue。组件既没有写render函数,也没有写template模板,此时要报一个警告,告诉用户组件缺少了render函数或者template模板。处理完以上情况后,就要把组件的render函数赋值给instance.render,到了组件渲染的时候,就可以运行instance.render函数生成组件的子树vnode了。另外对于使用with块运行时编译的渲染函数,渲染上下文的代理是RuntimeCompiledPublicInstanceProxyHandlers,它是在之前渲染上下文代理PublicInstanceProxyHandlers的基础上进行的扩展,主要对has函数的实现做了优化:const RuntimeCompiledPublicInstanceProxyHandlers = { ...PublicInstanceProxyHandlers, get(target, key) { if (key === Symbol.unscopables) { return } return ublicInstanceProxyHandlers.get(target, key, target) }, has(_, key) { // 如果 key 以 _ 开头或者 key 在全局变量白名单内,则 has 为 false const has = key[0] !== '_' & !isGloballyWhitelisted(key) if ((process.env.NODE_ENV !== 'production') & !has & ublicInstanceProxyHandlers.has(_, key)) { warn(`Property ${JSON.stringify(key)} should not start with _ which is a reserved prefix for Vue internals.`) } return has }}这里如果key以_开头,或者key在全局变量的白名单内,则has为false,此时则直接命中警告,不用再进行之前那一系列的判断了。了解完标准化模板或者渲染函数流程,我们来看完成组件实例设置的最后一个流程——兼容Vue2的options-api。兼容Vue2的options-api我们知道Vue2是通过组件对象的方式去描述一个组件,Vue3也仍然支持Vue2options-api的写法,这主要就是通过applyOptions方法实现的。function applyOptions(instance, options, deferredData = [], deferredWatch = [], asMixin = false) { const { // 组合 mixins, extends: extendsOptions, // 数组状态 props: propsOptions, data: dataOptions, computed: computedOptions, methods, watch: watchOptions, provide: provideOptions, inject: injectOptions, // 组件和指令 components, directives, // 生命周期 beforeMount, mounted, beforeUpdate, updated, activated, deactivated, beforeUnmount, unmounted, renderTracked, renderTriggered, errorCaptured } = options; // instance.proxy 作为 this const publicThis = instance.proxy; const ctx = instance.ctx; // 处理全局 mixin // 处理 extend // 处理本地 mixins // props 已经在外面处理过了 // 处理 inject // 处理 方法 // 处理 data // 处理计算属性 // 处理 watch // 处理 provide // 处理组件 // 处理指令 // 处理生命周期 option}applyOptions的代码特别长,所以只用注释列出了它主要做的事情,如果你对Vue2的原理如数家珍,理解这段代码会很轻松,感兴趣的小伙伴可以去翻阅它的源码。总结我们主要分析了组件的初始化流程,主要包括创建组件实例和设置组件实例。通过进一步细节的深入,我们也了解了渲染上下文的代理过程,了解了composition-api中的setup启动函数执行的时机,以及如何建立setup返回结果和模板渲染之间的联系,了解了组件定义的模板或者渲染函数的标准化过程,了解了如何兼容Vue2的options-api。最后,我们通过一张图再直观感受一下Vue3组件的初始化流程:由于篇幅有限,文中仅贴出精简过后的代码,本文所涉及源代码的仓库地址如下:packages/runtime-core/src/renderer.tspackages/runtime-core/src/component.tspackages/runtime-core/src/errorHandling.ts有关setup函数的分享就到这里了,如果阅读完本篇文章后,你对setup函数有了更深一步地理解和认识的话,欢迎给笔者点赞鼓励😄!若有不对的地方也希望大家多多指正,一起进步~
|
|