先发原文作者、地址等信息。我把内容全部搬过来了,也可以去看原文。内容绝对是满满的干货,给原作者点赞!(我添加的内容在转载过来的后面,内容不多)

作者: ustbhuangyi 
链接:http://www.imooc.com/article/18232
来源:慕课网

  在我们日常的移动端项目开发中,处理滚动列表是再常见不过的需求了,以滴滴为例,可以是这样竖向滚动的列表,如图所示:

也可以是横向滚动的导航栏,如图所示:

可以打开“微信 —> 钱包—>滴滴出行”体验效果。

我们在实现这类滚动功能的时候,会用到我写的第三方库,better-scroll。

什么是 better-scroll

better-scroll 是一个移动端滚动的解决方案,它是基于 iscroll 的重写,它和 iscroll 的主要区别在这里。better-scroll 也很强大,不仅可以做普通的滚动列表,还可以做轮播图、picker 等等。

better-scroll 的滚动原理

不少同学可能用过 better-scroll,我收到反馈最多的问题是:

我的 better-scroll 初始化了, 但是没法滚动。

不能滚动是现象,我们得搞清楚这其中的根本原因。在这之前,我们先来看一下浏览器的滚动原理:
浏览器的滚动条大家都会遇到,当页面内容的高度超过视口高度的时候,会出现纵向滚动条;当页面内容的宽度超过视口宽度的时候,会出现横向滚动条。也就是当我们的视口展示不下内容的时候,会通过滚动条的方式让用户滚动屏幕看到剩余的内容。

那么对于 better-scroll 也是一样的道理,我们先来看一下 better-scroll 常见的 html 结构:

复制代码
<div class="wrapper">
  <ul class="content">
    <li>...</li>
    <li>...</li>
    ...
  </ul>
</div>
复制代码

为了更加直观,我们再来看一张图:

布局

绿色部分为 wrapper,也就是父容器,它会有固定的高度。黄色部分为 content,它是父容器的第一个子元素,它的高度会随着内容的大小而撑高。那么,当 content 的高度不超过父容器的高度,是不能滚动的,而它一旦超过了父容器的高度,我们就可以滚动内容区了,这就是 better-scroll 的滚动原理。

那么,我们怎么初始化 better-scroll 呢,如果是上述 html 结构,那么初始化代码如下:

import BScroll from 'better-scroll'
let wrapper = document.querySelector('.wrapper')
let scroll = new BScroll(wrapper, {})

better-scroll 对外暴露了一个 BScroll 的类,我们初始化只需要 new 一个类的实例即可。第一个参数就是我们 wrapper 的 DOM 对象,第二个是一些配置参数,具体参考 better-scroll 的文档

better-scroll 的初始化时机很重要,因为它在初始化的时候,会计算父元素和子元素的高度和宽度,来决定是否可以纵向和横向滚动。因此,我们在初始化它的时候,必须确保父元素和子元素的内容已经正确渲染了。如果子元素或者父元素 DOM 结构发生改变的时候,必须重新调用 scroll.refresh() 方法重新计算来确保滚动效果的正常。所以同学们反馈的 better-scroll 不能滚动的原因多半是初始化 better-scroll 的时机不对,或者是当 DOM 结构发送变化的时候并没有重新计算 better-scroll。

better-scroll 遇见 Vue

相信很多同学对 Vue.js 都不陌生,当 better-scroll 遇见 Vue,会擦出怎样的火花呢?

如何在 Vue 中使用 better-scroll

很多同学开始接触使用 better-scroll 都是受到了我的一门教学课程——《Vue.js高仿饿了么外卖App》 的影响。在那门课程中,我们把 better-scroll 和 Vue 做了结合,实现了很多列表滚动的效果。在 Vue 中的使用方法如下:

复制代码
<template>
  <div class="wrapper" ref="wrapper">
    <ul class="content">
      <li>...</li>
      <li>...</li>
      ...
    </ul>
  </div>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    mounted() {
      this.$nextTick(() => {
        this.scroll = new Bscroll(this.$refs.wrapper, {})
      })
    }
  }
</script>
复制代码

Vue.js 提供了我们一个获取 DOM 对象的接口—— vm.$refs</code>。在这里,我们通过了&nbsp;<code>this.$refs.wrapper 访问到了这个 DOM 对象,并且我们在 mounted 这个钩子函数里,this.$nextTick 的回调函数中初始化 better-scroll 。因为这个时候,wrapper 的 DOM 已经渲染了,我们可以正确计算它以及它内层 content 的高度,以确保滚动正常。

这里的 this.$nextTick</code>&nbsp;是一个异步函数,为了确保 DOM 已经渲染,感兴趣的同学可以了解一下它的<a href="https://github.com/vuejs/vue/blob/dev/src/core/util/env.js#L66-L122" rel="nofollow" target="_blank">内部实现细节</a>,底层用到了 MutationObserver 或者是&nbsp;<code>setTimeout(fn, 0)</code>。其实我们在这里把&nbsp;<code>this.$nextTick 替换成 setTimeout(fn, 20) 也是可以的(20 ms 是一个经验值,每一个 Tick 约为 17 ms),对用户体验而言都是无感知的。

异步数据的处理

在我们的实际工作中,列表的数据往往都是异步获取的,因此我们初始化 better-scroll 的时机需要在数据获取后,代码如下:

复制代码
<template>
  <div class="wrapper" ref="wrapper">
    <ul class="content">
      <li v-for="item in data">{{item}}</li>
    </ul>
  </div>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    data() {
      return {
        data: []
      }
    },
    created() {
      requestData().then((res) => {
        this.data = res.data
        this.$nextTick(() => {
          this.scroll = new Bscroll(this.$refs.wrapper, {})
        })
      })
    }
  }
</script>
复制代码

这里的 requestData 是伪代码,作用就是发起一个 http 请求从服务端获取数据,并且这个函数返回的是一个 promise(实际项目中我们可能会用 axios 或者 vue-resource)。我们获取到数据的后,需要通过异步的方式再去初始化 better-scroll,因为 Vue 是数据驱动的, Vue 数据发生变化(this.data = res.data)到页面重新渲染是一个异步的过程,我们的初始化时机是要在 DOM 重新渲染后,所以这里用到了 this.$nextTick,当然替换成 setTimeout(fn, 20) 也是可以的。

为什么这里在 created 这个钩子函数里请求数据而不是放到 mounted 的钩子函数里?因为 requestData 是发送一个网络请求,这是一个异步过程,当拿到响应数据的时候,Vue 的 DOM 早就已经渲染好了,但是数据改变 —> DOM 重新渲染仍然是一个异步过程,所以即使在我们拿到数据后,也要异步初始化 better-scroll。

数据的动态更新

我们在实际开发中,除了数据异步获取,还有一些场景可以动态更新列表中的数据,比如常见的下拉加载,上拉刷新等。比如我们用 better-scroll 配合 Vue 实现下拉加载功能,代码如下:

复制代码
<template>
  <div class="wrapper" ref="wrapper">
    <ul class="content">
      <li v-for="item in data">{{item}}</li>
    </ul>
    <div class="loading-wrapper"></div>
  </div>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    data() {
      return {
        data: []
      }
    },
    created() {
      this.loadData()
    },
    methods: {
      loadData() {
        requestData().then((res) => {
          this.data = res.data.concat(this.data)
          this.$nextTick(() => {
            if (!this.scroll) {
              this.scroll = new Bscroll(this.$refs.wrapper, {})
              this.scroll.on('touchend', (pos) => {
                // 下拉动作
                if (pos.y > 50) {
                  this.loadData()
                }
              })
            } else {
              this.scroll.refresh()
            }
          })
        })
      }
    }
  }
</script>
复制代码

这段代码比之前稍微复杂一些, 当我们在滑动列表松开手指时候, better-scroll 会对外派发一个 touchend 事件,我们监听了这个事件,并且判断了 pos.y > 50(我们把这个行为定义成一次下拉的动作)。如果是下拉的话我们会重新请求数据,并且把新的数据和之前的 data 做一次 concat,也就更新了列表的数据,那么数据的改变就会映射到 DOM 的变化。需要注意的一点,这里我们对 this.scroll 做了判断,如果没有初始化过我们会通过 new BScroll 初始化,并且绑定一些事件,否则我们会调用 this.scroll.refresh 方法重新计算,来确保滚动效果的正常。

这里,我们就通过 better-scroll 配合 Vue,实现了列表的下拉刷新功能,上拉加载也是类似的套路,一切看上去都是 ok 的。但是,我们发现这里写了大量命令式的代码(这一点不是 Vue.js 推荐的),如果有很多类似滚动的组件,我们就需要写很多类似的命令式且重复性的代码,而且我们把数据请求和 better-scroll 也做了强耦合,这些对于一个追求编程逼格的人来说,就不 ok 了。

scroll 组件的抽象和封装

因此,我们有强烈的需求抽象出来一个 scroll 组件,类似小程序的 scroll-view 组件,方便开发者的使用。

首先,我们要考虑的是 scroll 组件本质上就是一个可以滚动的列表组件,至于列表的 DOM 结构,只需要满足 better-scroll 的 DOM 结构规范即可,具体用什么标签,有哪些辅助节点(比如下拉刷新上拉加载的 loading 层),这些都不是 scroll 组件需要关心的。因此, scroll 组件的 DOM 结构十分简单,如下所示:

<template>
  <div ref="wrapper">
    <slot></slot>
  </div>
</template>

这里我们用到了 Vue 的特殊元素—— slot 插槽,它可以满足我们灵活定制列表 DOM 结构的需求。接下来我们来看看 JS 部分:

复制代码
<script type="text/ecmascript-6">
  import BScroll from 'better-scroll'

  export default {
    props: {
      /**
       * 1 滚动的时候会派发scroll事件,会截流。
       * 2 滚动的时候实时派发scroll事件,不会截流。
       * 3 除了实时派发scroll事件,在swipe的情况下仍然能实时派发scroll事件
       */
      probeType: {
        type: Number,
        default: 1
      },
      /**
       * 点击列表是否派发click事件
       */
      click: {
        type: Boolean,
        default: true
      },
      /**
       * 是否开启横向滚动
       */
      scrollX: {
        type: Boolean,
        default: false
      },
      /**
       * 是否派发滚动事件
       */
      listenScroll: {
        type: Boolean,
        default: false
      },
      /**
       * 列表的数据
       */
      data: {
        type: Array,
        default: null
      },
      /**
       * 是否派发滚动到底部的事件,用于上拉加载
       */
      pullup: {
        type: Boolean,
        default: false
      },
      /**
       * 是否派发顶部下拉的事件,用于下拉刷新
       */
      pulldown: {
        type: Boolean,
        default: false
      },
      /**
       * 是否派发列表滚动开始的事件
       */
      beforeScroll: {
        type: Boolean,
        default: false
      },
      /**
       * 当数据更新后,刷新scroll的延时。
       */
      refreshDelay: {
        type: Number,
        default: 20
      }
    },
    mounted() {
      // 保证在DOM渲染完毕后初始化better-scroll
      setTimeout(() => {
        this._initScroll()
      }, 20)
    },
    methods: {
      _initScroll() {
        if (!this.$refs.wrapper) {
          return
        }
        // better-scroll的初始化
        this.scroll = new BScroll(this.$refs.wrapper, {
          probeType: this.probeType,
          click: this.click,
          scrollX: this.scrollX
        })

        // 是否派发滚动事件
        if (this.listenScroll) {
          let me = this
          this.scroll.on('scroll', (pos) => {
            me.$emit('scroll', pos)
          })
        }

        // 是否派发滚动到底部事件,用于上拉加载
        if (this.pullup) {
          this.scroll.on('scrollEnd', () => {
            // 滚动到底部
            if (this.scroll.y <= (this.scroll.maxScrollY + 50)) {
              this.$emit('scrollToEnd')
            }
          })
        }

        // 是否派发顶部下拉事件,用于下拉刷新
        if (this.pulldown) {
          this.scroll.on('touchend', (pos) => {
            // 下拉动作
            if (pos.y > 50) {
              this.$emit('pulldown')
            }
          })
        }

        // 是否派发列表滚动开始的事件
        if (this.beforeScroll) {
          this.scroll.on('beforeScrollStart', () => {
            this.$emit('beforeScroll')
          })
        }
      },
      disable() {
        // 代理better-scroll的disable方法
        this.scroll && this.scroll.disable()
      },
      enable() {
        // 代理better-scroll的enable方法
        this.scroll &&this.scroll.enable()},
      refresh(){// 代理better-scroll的refresh方法this.scroll &&this.scroll.refresh()},
      scrollTo(){// 代理better-scroll的scrollTo方法this.scroll &&this.scroll.scrollTo.apply(this.scroll, arguments)},
      scrollToElement(){// 代理better-scroll的scrollToElement方法this.scroll &&this.scroll.scrollToElement.apply(this.scroll, arguments)}},
    watch:{// 监听数据的变化,延时refreshDelay时间后调用refresh方法重新计算,保证滚动效果正常
      data(){
        setTimeout(()=>{this.refresh()},this.refreshDelay)}}}</script>
复制代码

JS 部分实际上就是对 better-scroll 做一层 Vue 的封装,通过 props 的形式,把一些对 better-scroll 定制化的控制权交给父组件;通过 methods 暴露的一些方法对 better-scroll 的方法做一层代理;通过 watch 传入的 data,当 data 发生改变的时候,在适当的时机调用 refresh 方法重新计算 better-scroll 确保滚动效果正常,这里之所以要有一个 refreshDelay 的设置是考虑到如果我们对列表操作用到了 transition-group 做动画效果,那么 DOM 的渲染完毕时间就是在动画完成之后。

有了这一层 scroll 组件的封装,我们来修改刚刚最复杂的代码(假设我们已经全局注册了 scroll 组件)。

复制代码
<template>
  <scroll class="wrapper"
          :data="data"
          :pulldown="pulldown"
          @pulldown="loadData">
    <ul class="content">
      <li v-for="item in data">{{item}}</li>
    </ul>
    <div class="loading-wrapper"></div>
  </scroll>
</template>
<script>
  import BScroll from 'better-scroll'
  export default {
    data() {
      return {
        data: [],
        pulldown: true
      }
    },
    created() {
      this.loadData()
    },
    methods: {
      loadData() {
        requestData().then((res) => {
          this.data = res.data.concat(this.data)
        })
      }
    }
  }
</script>
复制代码

可以很明显的看到我们的 JS 部分精简了非常多的代码,没有对 better-scroll 再做命令式的操作了,同时把数据请求和 better-scroll 也做了剥离,父组件只需要把数据 data 通过 prop 传给 scroll 组件,就可以保证 scroll 组件的滚动效果。同时,如果想实现下拉刷新的功能,只需要通过 prop 把 pulldown 设置为 true,并且监听 pulldown 的事件去做一些数据获取并更新的动作即可,整个逻辑也是非常清晰的。

插件 Vue 化引发的一些思考

这篇文章我不仅仅是要教会大家封装一个 scroll 组件,还想传递一些把第三方插件(原生 JS 实现)Vue 化的思考过程。很多学习 Vue.js 的同学可能还停留在 “XX 效果如何用 Vue.js 实现” 的程度,其实把插件 Vue 化有两点很关键,一个是对插件本身的实现原理很了解,另一个是对 Vue.js 的特性很了解。对插件本身的实现原理了解需要的是一个思考和钻研的过程,这个过程可能困难,但是收获也是巨大的;而对 Vue.js 的特性的了解,是需要大家对 Vue.js 多多使用,学会从平时的项目中积累和总结,也要善于查阅 Vue.js 的官方文档,关注一些 Vue.js 的升级等。

所以,我们拒绝伸手党,但也不是鼓励大家什么时候都要去造轮子,当我们在使用一些现成插件的同时,也希望大家能多多思考,去探索一下现象背后的本质,把 “XX 效果如何用 Vue.js 实现” 这句话从问号变成句号。


以下内容是我在作者基础上添加了一些交互效果,和作者的放在一起做成一个组件,可以直接拿去用。为了更容易看懂我的思路,进行了简要的注释。

复制代码
<template>
  <div ref="wrapper" class="better-scroll-root">  <!--该节点需要定位,内容以此节点的盒模型为基础滚动。另外,该节点的背景色配合上拉加载、下拉刷新的UI,正常情况下不可作它用。-->
    <div class="content-bg better-scroll-container">  <!--如果需要调滚动内容的背景色,则改该节点的背景色-->
        <div> <!--不太需要,待优化-->
            <div v-if="pulldown" class="pulldown-tip">
                <i class="pull-icon indexicon icon-pull-down" :class="[pulldownTip.rotate]"></i>
                <span class="tip-content">{{pulldownTip.text}}</span>
            </div>
            <div v-show="loadingStatus.showIcon || loadingStatus.status" class="loading-pos">
                <div v-show="loadingStatus.showIcon" class="loading-container">
                    <div class="cube">
                        <div class="side side1"></div>
                        <div class="side side2"></div>
                        <div class="side side3"></div>
                        <div class="side side4"></div>
                        <div class="side side5"></div>
                        <div class="side side6"></div>
                    </div>
                </div>
                <span class="loading-connecting">{{loadingStatus.status}}</span>
            </div>
        </div>
        <slot></slot>
    </div>
</div>
</template>
<script>
import BScroll from 'better-scroll'

export default {
    props: {
        /**
         * 1 滚动的时候会派发scroll事件,会截流。
         * 2 滚动的时候实时派发scroll事件,不会截流。
         * 3 除了实时派发scroll事件,在swipe的情况下仍然能实时派发scroll事件
         */
        probeType: {
            type: Number,
            default: 1
        },
        /**
         * 点击列表是否派发click事件
         */
        click: {
            type: Boolean,
            default: true
        },
        /**
         * 是否开启横向滚动
         */
        scrollX: {
            type: Boolean,
            default: false
        },
        /**
         * 是否派发滚动事件
         */
        listenScroll: {
            type: Boolean,
            default: false
        },
        /**
         * 列表的数据
         */
        data: {
            type: Array,
            default: null
        },
        /**
         * 是否派发滚动到底部的事件,用于上拉加载
         */
        pullup: {
            type: Boolean,
            default: false
        },
        /**
         * 是否派发顶部下拉的事件,用于下拉刷新
         */
        pulldown: {
            type: Boolean,
            default: false
        },
        /**
         * 是否派发列表滚动开始的事件
         */
        beforeScroll: {
            type: Boolean,
            default: false
        },
        /**
         * 当数据更新后,刷新scroll的延时。
         */
        refreshDelay: {
            type: Number,
            default: 20
        },
        /**
         * 如果启用loading交互,传递loading的状态
         * isShow: false
         * showIcon: false,    // 是否显示loading的icon
         * status: ''  // '正在加载...', '刷新成功', '刷新失败', ''
         */
        loadingStatus: {
            type: Object,
            default: function () {
                return {
                    showIcon: false,
                    status: ''
                };
            }
        },
        /**
         * 是否启用下拉刷新的交互
         */
        pulldownUI: {
            type: Boolean,
            default: false
        },
        /**
         * 是否启用上拉加载的交互
         */
        pullupUI: {
            type: Boolean,
            default: false
        }
    },
    data() {
        return {
            loadingConnecting: false,
            pulldownTip: {
                text: '下拉刷新',     // 松开立即刷新
                rotate: ''    // icon-rotate
            },

        };
    },
    mounted() {
        // 保证在DOM渲染完毕后初始化better-scroll
        setTimeout(() => {
            this._initScroll()
        }, 20)
    },
    methods: {
        _initScroll() {
            if (!this.$refs.wrapper) {
                return;
            }
            // better-scroll的初始化
            this.scroll = new BScroll(this.$refs.wrapper, {
                probeType: this.probeType,
                click: this.click,
                scrollX: this.scrollX
            });

            // 是否派发滚动事件
            if (this.listenScroll || this.pulldown || this.pullup) {
                let me = this;
                this.scroll.on('scroll', (pos) => {
                    if (this.listenScroll) {
                        me.$emit('scroll', pos);
                    }

                    if (this.pulldown) {
                        // 下拉动作
                        if (pos.y > 50) {
                            this.pulldownTip = {
                                text: '松开立即刷新',
                                rotate: 'icon-rotate'
                            }
                        } else {
                            this.pulldownTip = {
                                text: '下拉刷新',     // 松开立即刷新
                                rotate: ''    // icon-rotate
                            }
                        }
                    }

                    if (this.pullup) {

                    }
                })
            }

            // 是否派发滚动到底部事件,用于上拉加载
            if (this.pullup) {
                this.scroll.on('scrollEnd', () => {
                    console.log('scrollEnd');
                    console.log(this.scroll);
                    // 滚动到底部
                    if (this.scroll.y <= (this.scroll.maxScrollY + 50)) {
                        this.$emit('scrollToEnd');
                    }
                });
            }

            // 是否派发顶部下拉事件,用于下拉刷新
            if (this.pulldown) {
                this.scroll.on('touchend', (pos) => {
                    // 下拉动作
                    if (pos.y > 50) {
                        setTimeout(() => {
                            // 重置提示信息
                            this.pulldownTip = {
                                text: '下拉刷新',     // 松开立即刷新
                                rotate: ''    // icon-rotate
                            }
                        },600);
                        this.$emit('pulldown');
                    }
                });
            }

            // 是否派发列表滚动开始的事件
            if (this.beforeScroll) {
                this.scroll.on('beforeScrollStart', () => {
                    this.$emit('beforeScroll')
                });
            }
        },
        disable() {
            // 代理better-scroll的disable方法
            this.scroll && this.scroll.disable();
        },
        enable() {
            // 代理better-scroll的enable方法
            this.scroll && this.scroll.enable();
        },
        refresh() {
            // 代理better-scroll的refresh方法
            this.scroll && this.scroll.refresh();
        },
        scrollTo() {
            // 代理better-scroll的scrollTo方法
            this.scroll && this.scroll.scrollTo.apply(this.scroll, arguments);
        },
        scrollToElement() {
            // 代理better-scroll的scrollToElement方法
            this.scroll && this.scroll.scrollToElement.apply(this.scroll, arguments);
        }
    },
    watch: {
        // 监听数据的变化,延时refreshDelay时间后调用refresh方法重新计算,保证滚动效果正常
        data() {
            setTimeout(() => {
                this.refresh();
            }, this.refreshDelay);
        }
    }
}
</script>
<style lang="scss" rel="stylesheet/scss">
$cube-size: 10px; // 项目中用了scss,没用的话,替换掉样式中的变量即可
.better-scroll-root {
    background-color: rgba(7, 17, 27, 0.7);
    .loading-pos, .pulldown-tip {
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 35px;
        color: #fcfcfc;
        text-align: center;
        z-index: 2000;
    }
    .loading-pos {
        background-color: rgba(7, 17, 27, 0.7);
    }
    .pulldown-tip {
        top: -50px;
        height: 50px;
        line-height: 50px;
        z-index: 1;
    }
    .pull-icon {
        position: absolute;
        top: 0;
        left: 30%;
        color: #a5a1a1;
        font-size: 1.5em;
        transition: all 0.15s ease-in-out;
    }
    .pull-icon.icon-rotate {
        transform:rotate(180deg);
    }

    .loading-container {
        position: absolute;
        height: $cube-size;
        width: $cube-size;
        left: 35%;
        top: 50%;
        transform: translate(-50%, -50%);
        perspective: 40px;
    }
    .loading-connecting {
        line-height: 35px;
    }
    .cube{
        height:$cube-size;
        width:$cube-size;
        transform-origin:50% 50%;
        transform-style:preserve-3d;
        animation:rotate 3s infinite ease-in-out;
    }
    .side{
        position:absolute;
        height:$cube-size;
        width:$cube-size;
        border-radius:50%;
    }
    .side1{
        background: #4bc393;
        transform:translateZ($cube-size);
    }
    .side2{
        background:#FF884D;
        transform:rotateY(90deg) translateZ($cube-size);
    }
    .side3{
        background:#32526E;
        transform:rotateY(180deg) translateZ($cube-size);
    }
    .side4{
        background: #c53fa3;
        transform:rotateY(-90deg) translateZ($cube-size);
    }
    .side5{
        background:#FFCC5C;
        transform:rotateX(90deg) translateZ($cube-size);
    }
    .side6{
        background:#FF6B57;
        transform:rotateX(-90deg) translateZ($cube-size);
    }

    @keyframes rotate{
        0%{
            transform:rotateX(0deg) rotateY(0deg);
        }
        50%{
            transform:rotateX(360deg) rotateY(0deg);
        }
        100%{
            transform:rotateX(360deg) rotateY(360deg);
        }
    }
}
</style>
复制代码

下拉刷新,上拉加载(暂时未做),刷新中等效果如下:

 

  

Logo

前往低代码交流专区

更多推荐