首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >立等可取的 Vue + Typescript 函数式组件实战

立等可取的 Vue + Typescript 函数式组件实战

作者头像
江米小枣
发布于 2020-11-04 02:13:43
发布于 2020-11-04 02:13:43
2.5K00
代码可运行
举报
文章被收录于专栏:云前端云前端
运行总次数:0
代码可运行

不同于面向对象编程(OOP)中通过抽象出各种对象并注重其间的解耦问题等,函数式编程(FP) 聚焦最小的单项操作,将复杂任务变成一次次 f(x) = y 式的函数运算叠加。函数是 FP 中的一等公民(First-class object),可以被当成函数参数或被函数返回;同时,这些函数应该不依赖或影响外部状态,这意味着对于给定的输入,将产生相同的输出

在 Vue 中,一个函数式组件(FC - functional component)就意味着一个没有实例(没有 this 上下文、没有生命周期方法、不监听任何属性、不管理任何状态)的组件。从外部看,它也可以被视作一个只接受一些 prop 并按预期返回某种渲染结果的 fc(props) => VNode 函数。Vue 中的 FC 有时也被称作无状态组件(stateless component)

❓为何需要函数式(无状态)组件

  • 因为函数式组件忽略了生命周期和监听等实现逻辑,所以渲染开销很低、执行速度快
  • 相比于普通组件中的 v-if 等指令,使用 h 函数或结合 jsx 更容易地实现子组件的条件性渲染
  • 比普通组件中的 <component> + v-if 指令 更容易地实现高阶组件(HOC - higher-order component)模式,即一个封装了某些逻辑并条件性地渲染参数子组件的容器组件

❓函数式组件与真正 FP 有何区别

真正的 FP 函数基于不可变状态(immutable state),但 Vue 中的“函数式”组件没有这么理想化。后者基于可变数据,相比普通组件也只是没有实例概念而已。

同时,与 React Hooks 类似的是,Vue Composition API 也在一定程度上为函数式组件带来了少许响应式特征、onMounted 等生命周期式的概念和管理副作用的方法。

❓TypeScript 对于函数式组件有何意义

无论是 React 还是 Vue,本身都提供了一些验证 props 类型的手段。但这些方法一来配置上都稍显麻烦,二来对于轻巧的函数式组件都有点过“重”了。

TypeScript 作为一种强类型的 JavaScript 超集,可以被用来更精确的定义和检查 props 的类型、使用更简便,在 VSCode 或其他支持 Vetur 的开发工具中的自动提示也更友好

?React 中的 FC + TS

在 React 中,可以 使用 FC<propsType> 来约束一个返回了 jsx 的函数入参:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
import React from "react";

type GreetingProps = {
 name: string;
}

const Greeting:React.FC<GreetingProps> = ({ name }) => {
 return <h1>Hello {name}</h1>
} ;

也可以直接定义函数的参数类型,这样的好处是可以对 props 的类型再使用泛型

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
interface IGreeting<T = 'm' | 'f'> {
 name: string;
 gender: T
}
export const Greeting = ({ name, gender }: IGreeting<0 | 1>): JSX.Element => {
 return <h1>Hello { gender === 0 ? 'Ms.' : 'Mr.' } {name}</h1>
} ;

而 Vue 中的做法该如何呢?

本文主要基于 vue 2.x 版本,结合 tsx 语法,尝试探讨一种在大多数现有 vue 项目中马上就能用起来的、具有良好 props 类型约束的函数式组件实践

Vue 3 风格的 tsx 函数式组件

?RenderContext

RenderContext 类型被用来约束 render 函数的第二个参数,vue 2.x 项目中对渲染上下文的类型定义如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// types/options.d.ts 

export interface RenderContext<Props=DefaultProps> {
  props: Props;
  children: VNode[];
  slots(): any;
  data: VNodeData;
  parent: Vue;
  listeners: { [key: string]: Function | Function[] };
  scopedSlots: { [key: string]: NormalizedScopedSlot };
  injections: any
}

这很清晰地对应了文档中的相应说明段落:

...组件需要的一切都是通过 context 参数传递,它是一个包括如下字段的对象:

  • props:提供所有 prop 的对象
  • children:VNode 子节点的数组
  • slots:一个函数,返回了包含所有插槽的对象
  • scopedSlots:(2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。
  • data:传递给组件的整个数据对象,作为 createElement 的第二个参数传入组件
  • parent:对父组件的引用
  • listeners:(2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是 data.on 的一个别名。
  • injections:(2.3.0+) 如果使用了 inject 选项,则该对象包含了应当被注入的 property。

?interface

正如 interface RenderContext<Props=DefaultProps> 定义的那样,对于函数式组件外部输入的 props,可以使用一个自定义的 TypeScript 接口声明其结构,如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
interface IProps {
 year: string;
 quarters: Array<'Q1' | 'Q2' | 'Q3' | 'Q4'>;
 note: {
  content: string;
  auther: stiring;
 }
}

而后指定该接口为 RenderContext 的首个泛型:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
import Vue, { CreateElement, RenderContext } from 'vue';

...

export default Vue.extend({
  functional: true,
  render: (h: CreateElement, context: RenderContext<IProps>) => {
     console.log(context.props.year);
   //...
  }
});

?emit

在函数式组件中是没有实例上的 this.$emit 可以用的,要达到同样的效果,可以采用下面的写法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
render: (h: CreateElement, context: RenderContext<IProps>) => {
  const emit = (evtName: string, value: any) => (context.listeners[evtName] as Function)(value);
  //...
}

配合上 model: { prop, event } 组件选项,对外依然可以达到 v-model 的效果。

?filter

在 jsx 返回结构中,传统模板中的 <label>{ title | withColon }</label> 过滤器语法不再奏效。

等效的写法比如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
import filters from '@/filters';

//...

const { withColon } = filters;

//...

// render 返回的 jsx 中
<label>{ withColon(title) }</label>

?子组件的 v-model

jsx 中 v-model 指令是无法正确的工作的,替代写法为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<input
 model={{
    value: formdata.iptValue,
    callback: (v: string) => (formdata.iptValue = v)
 }}
 placeholder="请填写"
/>

?作用域插槽

传统模板中对于作用域插槽的用法如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<dynamic-lines :list="attrs">
  <template v-slot="scope">
    <el-input v-model="attrs[scope.scopeIndex].attr" />
  </template>
</dynamic-lines>

jsx 中相应写法则是:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<DynamicLines
  list={attrs}
  scopedSlots={{
    default: (scope: any) => (
      <el-input
        model={{
          value: attrs[scope.scopeIndex].attr,
          callback: (v: string) => {
     //...
          }
        }}
      />
    )
  }}
/>

同时,正如例子中所示,element-ui 等全局注册的组件仍需要使用 kebab-case 形式才能正确被编译。

?与 Composition API 结合

虽说目的是简单渲染的函数式组件中不用太多响应式特性,但也并非不可以一起工作,比如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
import {
  h, inject, Ref, ref
} from '@vue/composition-api';

//...

const pageType = inject<MyPageType>('pageType', 'create');
const dictBrands = inject<Ref<any[]>>('dictBrands', ref([]));

?综合实例

了解过以上这些要点,编写一个类型良好的 tsx 函数式组件就没有什么障碍了。

一个实例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<script lang="tsx">
import Vue, { CreateElement, RenderContext } from 'vue';
import {
  h, inject, Ref, ref
} from '@vue/composition-api';
import DynamicLines from '@/components/DynamicLines';

interface IProps {
  list: Spec['model'];
}

export const getEmptyModelRow = (): Spec['model'][0] => ({
  brand_id: '',
  data: [
    {
      model: '',
    }
  ]
});

export default Vue.extend({
  functional: true,
  render: (h: CreateElement, context: RenderContext<IProps>) => {
    const emit = (evtName: string, value: any) => (context.listeners[evtName] as Function)(value);

    const pageType = inject<CompSpecType>('pageType', '');
    const dictBrands = inject<Ref<any[]>>('dictBrands', ref([]));

    const isModelRequired = pageType !== 'other';
    const list: Spec['model'] = context.props.list || [getEmptyModelRow()];

    return [
      <table class="dialog-subtable">
        <thead>
          <tr>
            <th class="required">品牌</th>
            <th class={isModelRequired ? 'required' : ''}>Model</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          {list.map((am, aidx) => (
            <tr>
              <td>
                <el-select
                  value={am.brand_id}
                  onChange={(v: string) => {
                    emit('error', '');
                    if (list.some(m => String(m.brand_id) === String(v))) {
                      list[aidx].brand_id = '';
                      emit('error', '品牌已存在');
                      emit('change', list);
                      return;
                    }
                    list[aidx].brand_id = v;
                    emit('change', list);
                  }}
                >
                  {dictBrands.value.map((dictBrand: { id: string | number; brand: string }) => (
                    <el-option key={dictBrand.id} label={dictBrand.brand} value={dictBrand.id} />
                  ))}
                </el-select>
              </td>
              <td>
                <DynamicLines
                  list={am.data}
                  onAdd={() => {
                    list[aidx].data.push(getEmptyModelRow().data[0]);
                    emit('change', list);
                  }}
                  onDelete={(delIdx: number) => {
                    list[aidx].data.splice(delIdx, 1);
                    emit('change', list);
                  }}
                  scopedSlots={{
                    default: (scope: any) => (
                      <el-input
                        placeholder="Model"
                        model={{
                          value: am.data[scope.scopeIndex].model,
                          callback: (v: string) => {
                            const brandItem = list[aidx];
                            const models = brandItem.data;
                            emit('error', '');
                            if (models.some(m => String(m) === String(v))) {
                              emit('error', '该品牌下相同model已存在');
                            }
                            models[scope.scopeIndex].model = v;
                            emit('change', list);
                          }
                        }}
                      />
                    )
                  }}
                />
              </td>
              <td>
                <el-link
                  type="danger"
                  disabled={list.length <= 1}
                  onClick={() => {
                    emit('error', '');
                    list.splice(aidx, 1);
                    emit('change', list);
                  }}
                >
                  删除行
                </el-link>
              </td>
            </tr>
          ))}
        </tbody>
      </table>,
      <el-link
        type="primary"
        onClick={() => {
          emit('error', '');
          list.push(getEmptyModelRow());
          emit('change', list);
        }}
        domPropsInnerHTML="&plus;添加行"
      />
    ];
  }
});
</script>

<style lang="scss" scoped>
@import "@/styles/create";
::v-deep {
  .dynamic-lines > .line {
    white-space: no-wrap;
  }
}
</style>

函数式组件的单元测试

有了 TypeScript 的强类型加持,组件内外的参数类型有了较好的保障。

而对于组件逻辑上,仍需要通过单元测试完成安全脚手架的搭建。同时,由于函数式组件一般相对简单,测试编写起来也不麻烦。

关于 Vue 组件的单元测试,可以参阅以下文章:

在实践中,由于 FC 与普通组件的区别,还是有些小问题需要注意:

?re-render

由于函数式组件只依赖其传入 props 的变化才会触发一次渲染,所以在测试用例中只靠 nextTick() 是无法获得更新后的状态的,需要设法手动触发其重新渲染

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
  it("批量全选", async () => {
    let result = mockData;
    // 此处实际上模拟了每次靠外部传入的 props 更新组件的过程
    // wrapper.setProps() cannot be called on a functional component
    const update = async () => {
      makeWrapper(
        {
          value: result
        },
        {
          listeners: {
            change: m => (result = m)
          }
        }
      );
      await localVue.nextTick();
    };
    await update();
    expect(wrapper.findAll("input")).toHaveLength(6);

    wrapper.find("tr.whole label").trigger("click");
    await update();
    expect(wrapper.findAll("input:checked")).toHaveLength(6);

    wrapper.find("tr.whole label").trigger("click");
    await update();
    expect(wrapper.findAll("input:checked")).toHaveLength(0);

    wrapper.find("tr.whole label").trigger("click");
    await update();
    wrapper.find("tbody>tr:nth-child(3)>td:nth-child(2)>ul>li:nth-child(4)>label").trigger("click");
    await update();
    expect(wrapper.find("tr.whole label input:checked").exists()).toBeFalsy();
  });

?多个根节点

函数式组件的一个好处是可以返回一个元素数组,相当于在 render() 中返回了多个根节点(multiple root nodes)。

这时候如果直接用 shallowMount 等方式在测试中加载组件,会出现报错:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
[Vue warn]: Multiple root nodes returned from render function. Render function should return a single root node.

解决方式是封装一个包装组件

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
import { mount } from '@vue/test-utils'
import Cell from '@/components/Cell'

const WrappedCell = {
  components: { Cell },
  template: `
    <div>
      <Cell v-bind="$attrs" v-on="$listeners" />
    </div>
  `
}

const wrapper = mount(WrappedCell, {
  propsData: {
    cellData: {
      category: 'foo',
      description: 'bar'
    }
  }
});

describe('Cell.vue', () => {
  it('should output two tds with category and description', () => {
    expect(wrapper.findAll('td')).toHaveLength(2);
    expect(wrapper.findAll('td').at(0).text()).toBe('foo');
    expect(wrapper.findAll('td').at(1).text()).toBe('bar');
  });
});

?辅助 fragment 组件的测试

另一个可用到 FC 的小技巧是,对于一些引用了 vue-fragment (一般也是用来解决多节点问题)的普通组件,在其?单元测试中可以封装一个函数式组件 stub 掉 fragment 组件,从而减少依赖、方便测试:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
let wrapper = null;
const makeWrapper = (props = null, opts = null) => {
  wrapper = mount(Comp, {
    localVue,
    propsData: {
      ...props
    },
    stubs: {
      Fragment: {
        functional: true,
        render(h, { slots }) {
          return h("div", slots().default);
        }
      }
    },
    attachedToDocument: true,
    sync: false,
    ...opts
  });
};

总结

  • 一个 Vue 函数式组件就是一个没有实例的组件,也称“无状态组件”
  • 函数式组件渲染速度快,更易于实现条件性渲染和高阶特性
  • Vue 中的“函数式”组件基于可变数据,并非纯粹的函数式编程
  • TypeScript 可以更精确的定义和检查 props 类型,自动提示也更友好
  • 可使用自定义的 TS 接口声明 Vue FC 的 props 结构
  • Vue 函数式组件可以与 Composition API 结合使用
  • 对 Vue 函数式组件进行单元测试时需要注意渲染触发问题
  • 在测试中可以通过封装包装组件方式解决多节点问题

参考资料

  • https://stevenklambert.com/writing/unit-testing-vuejs-functional-component-multiple-root-nodes/
  • https://devinduct.com/blogpost/47/understanding-stateless-components-in-vue
  • https://cn.vuejs.org/v2/guide/render-function.html#%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BB%84%E4%BB%B6
  • https://juejin.im/post/6844904205669367822
  • https://zh.wikipedia.org/wiki/%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BC%96%E7%A8%8B
  • https://stevenklambert.com/writing/unit-testing-vuejs-functional-component-multiple-root-nodes/
  • https://zhuanlan.zhihu.com/p/71879386
  • https://fettblog.eu/typescript-react-why-i-dont-use-react-fc/
  • https://juejin.im/post/6844904175831089165
  • https://medium.com/@ethan_ikt/react-stateless-functional-component-with-typescript-ce5043466011

--End--

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-10-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云前端 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
Redis6安装(下) - 集群与故障转移
如果一个master挂了,那么剩余的2个master会发起投票选举,从挂了的master对应的slave中选举出一个新的master,发生故障的master不会参与投票,这个要注意。
风间影月
2020/12/24
8630
Redis常见面试题(二):redis分布式锁、redisson;Redis集群、主从复制,哨兵模式,分片集群;Redis为什么这么快,I/O多路复用模型
还记得Redis使用场景、缓存穿透、缓存击穿、缓存雪崩、Redis持久化、数据过期策略、数据淘汰策略吗?如果忘记可以到这里重新温习, Redis常见面试题(一):Redis使用场景,缓存、分布式锁;缓存穿透、缓存击穿、缓存雪崩;双写一致,Canal,Redis持久化,数据过期策略,数据淘汰策略。
寻求出路的程序媛
2024/07/24
3.6K0
Redis常见面试题(二):redis分布式锁、redisson;Redis集群、主从复制,哨兵模式,分片集群;Redis为什么这么快,I/O多路复用模型
java-redis
Redis是基于内存运行的高性能 K-V 数据库,官方提供的测试报告是单机可以支持约10w/s的QPS,每秒的请求.
知识浅谈
2022/05/19
2860
java-redis
Redis集群原理详解
在讲Redis集群架构之前,我们先简单讲下Redis单实例的架构,从最开始的一主N从,到读写分离,再到Sentinel哨兵机制,单实例的Redis缓存足以应对大多数的使用场景,也能实现主从故障迁移。
全栈程序员站长
2022/06/29
2.8K0
Redis集群原理详解
【云原生进阶之PaaS中间件】第一章Redis-2.1架构综述
        Redis 组件的系统架构如图所示,主要包括事件处理、数据存储及管理、用于系统扩展的主从复制/集群管理,以及为插件化功能扩展的 Module System 模块。
江中散人_Jun
2023/10/16
4510
【云原生进阶之PaaS中间件】第一章Redis-2.1架构综述
redis高并发高可用
对于性能来说,单主用来写入数据,单机几万QPS,多从用来查询数据,多个从实例可以提供每秒 10w 的 QPS。
Tim在路上
2020/08/04
2.9K0
一个例子,看懂关系型数据库和Redis的区别
互联网产品正从“满足用户单向浏览的需求”发展为“满足用户个性化信息获取及社交的需求”。随着 5G的到来,会有越来越多“不可思议”的场景被搬到互联网上。这就要求产品做到以用户和关系为基础,对海量数据进行实时分析计算。
江南一点雨
2023/01/04
7810
一个例子,看懂关系型数据库和Redis的区别
2024年java面试准备--redis(2)
一个简单直观的想法是直接用Hash来计算,以Key做哈希后对节点数取模。可以看出,在key足够分散的情况下,均匀性可以获得,但一旦有节点加入或退出,所有的原有节点都会受到影响,稳定性无从谈起。
终有救赎
2023/10/16
4740
2024年java面试准备--redis(2)
Redis的集群解决分布式系统中负载均衡的原理
一 Redis单机缺陷 redis单机容量方面会有瓶颈,主从模式只能保证支撑更多读并发,但是slave和master的数据是一模一样的,也就是说master能存储多少数据,slave就也只能存储这么多
名字是乱打的
2022/09/29
2.7K0
Redis的集群解决分布式系统中负载均衡的原理
面试必问之redis
redis是当前比较热门的NOSQL系统之一,它是一个开源的使用ANSI c语言编写的key-value存储系统(区别于MySQL的二维表格的形式存储。)。和Memcache类似,但很大程度补偿了Memcache的不足。和Memcache一样,Redis数据都是缓存在计算机内存中,不同的是,Memcache只能将数据缓存到内存中,无法自动定期写入硬盘,这就表示,一断电或重启,内存清空,数据丢失。所以Memcache的应用场景适用于缓存无需持久化的数据。而Redis不同的是它会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,实现数据的持久化
一笠风雨任生平
2022/01/06
3780
面试必问之redis
Redis、面试、缓存、雪崩、分布式锁实现一篇文章搞定!
近乎所有与Java相关的面试都会问到缓存的问题,基础一点的会问到什么是“二八定律”、什么是“热数据和冷数据”,复杂一点的会问到缓存雪崩、缓存穿透、缓存预热、缓存更新、缓存降级等问题,这些看似不常见的概念,都与我们的缓存服务器相关,一般常用的缓存服务器有Redis、Memcached等,而笔者目前最常用的也只有Redis这一种。
马士兵的朋友圈
2020/09/18
7590
Redis缓存那点破事 | 绝杀面试官 25 问!
为了便于大家查找问题,了解全貌,整理个目录,我们可以快速全局了解关于Redis 缓存,面试官一般喜欢问哪些问题?
微观技术
2021/09/15
5500
Redis 面试题
阿彬学java
2025/01/13
2010
Redis 常见面试题
(1)Redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
Tim在路上
2020/08/05
7670
Redis 常见面试题
4种 Redis 集群方案介绍+优缺点对比
点击上方“芋道源码”,选择“设为星标” 管她前浪,还是后浪? 能浪的浪,才是好浪! 每天 10:33 更新文章,每天掉亿点点头发... 源码精品专栏 原创 | Java 2021 超神之路,很肝~ 中文详细注释的开源项目 RPC 框架 Dubbo 源码解析 网络应用框架 Netty 源码解析 消息中间件 RocketMQ 源码解析 数据库中间件 Sharding-JDBC 和 MyCAT 源码解析 作业调度中间件 Elastic-Job 源码解析 分布式事务中间件 TCC-Transaction
芋道源码
2022/05/06
2.4K0
4种 Redis 集群方案介绍+优缺点对比
Redis相关底层面试题
Redis是一个开源的高性能键值对存储系统,具有快速、灵活和可扩展的特性。它是一个基于内存的数据结构存储系统,可以用作数据库、缓存和消息代理。Redis支持多种类型的数据结构,如字符串(strings),散列(hashes),列表(lists),集合(sets)等。
半月无霜
2023/10/18
2880
Redis相关底层面试题
Redis读写分离和分布式缓存算法原理
当启动一个slave node的时候,它会发送一个PSYNC命令给master node
丁D
2022/08/12
5480
redis集群的架构、问题,附脑洞
Redis 是一种开源(BSD 许可)、数据结构存储在内存中的系统,用作数据库、缓存和消息队列。Redis 提供了诸如字符串、散列、列表、集合、带范围查询的排序集合、位图、超级日志、地理空间索引和流等数据结构。Redis 内置复制、Lua 脚本、LRU 驱逐、事务和不同级别的磁盘持久化,并通过 Redis Sentinel 和 Redis Cluster 自动分区提供高可用性。
一凡sir
2023/07/19
6320
Redis原理—3.复制、哨兵和集群
命令传播操作用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态。
东阳马生架构
2025/02/24
3210
一篇文章理解Redis集群
Redis作为一款性能优异的内存数据库,支撑着亿级数据量的社交平台,也成为很多互联网公司的标配。这里将以Redis Cluster 集群为核心,基于最新的Redis5版本,从原理到实战,玩儿转Redis集群。
用户1212940
2019/11/02
9250
相关推荐
Redis6安装(下) - 集群与故障转移
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验