better-Bscroll

谢谢作者的文章  非常喜欢  请允许收藏!

博客园首页博问闪存新随笔订阅管理


vue之better-scroll的封装,包含下拉刷新,上拉加载功能及UI(核心为借鉴,我仅仅是给轮子套上了外胎...)

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

作者: 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 结构:

  • ...
  • ...
  • ...

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

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

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

    import BScroll from 'better-scroll'let wrapper= document.querySelector('.wrapper')

    let scroll=newBScroll(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 中的使用方法如下:

     

       
           
    • ...
    •      
    • ...
    • ...
     
    import BScroll from'better-scroll'exportdefault{

    mounted() {this.$nextTick(() =>{this.scroll =newBscroll(this.$refs.wrapper, {})

    })

    }

    }

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

    这里的this.$nextTick是一个异步函数,为了确保 DOM 已经渲染,感兴趣的同学可以了解一下它的内部实现细节,底层用到了 MutationObserver 或者是setTimeout(fn, 0)。其实我们在这里把this.$nextTick替换成setTimeout(fn, 20)也是可以的(20 ms 是一个经验值,每一个 Tick 约为 17 ms),对用户体验而言都是无感知的。

    异步数据的处理

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

     

       
           
    • {{item}}
    •    
     
    import BScroll from'better-scroll'exportdefault{

    data() {return{

    data: []

    }

    },

    created() {

    requestData().then((res)=>{this.data =res.datathis.$nextTick(() =>{this.scroll =newBscroll(this.$refs.wrapper, {})

    })

    })

    }

    }

    这里的 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 实现下拉加载功能,代码如下:

     

       
           
    • {{item}}
    •    
       
     
    import BScroll from'better-scroll'exportdefault{

    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 =newBscroll(this.$refs.wrapper, {})this.scroll.on('touchend', (pos) =>{//下拉动作if(pos.y > 50) {this.loadData()

    }

    })

    }else{this.scroll.refresh()

    }

    })

    })

    }

    }

    }

    这段代码比之前稍微复杂一些, 当我们在滑动列表松开手指时候, 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 结构十分简单,如下所示:

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

    import BScroll from'better-scroll'exportdefault{

    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-scrollsetTimeout(() =>{this._initScroll()

    },20)

    },

    methods: {

    _initScroll() {if(!this.$refs.wrapper) {return}//better-scroll的初始化this.scroll =newBScroll(this.$refs.wrapper, {

    probeType:this.probeType,

    click:this.click,

    scrollX:this.scrollX

    })//是否派发滚动事件if(this.listenScroll) {

    let me=thisthis.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)}}}

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

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

         

           
    • {{item}}
    •    
       
      import BScroll from'better-scroll'exportdefault{

    data() {return{

    data: [],

    pulldown:true}

    },

    created() {this.loadData()

    },

    methods: {

    loadData() {

    requestData().then((res)=>{this.data = res.data.concat(this.data)

    })

    }

    }

    }

    可以很明显的看到我们的 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 实现” 这句话从问号变成句号。

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

    {{pulldownTip.text}}
    {{loadingStatus.status}}import BScroll from'better-scroll'exportdefault{

    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-scrollsetTimeout(()=>{this._initScroll()

    },20)

    },

    methods: {

    _initScroll() {if(!this.$refs.wrapper) {return;

    }//better-scroll的初始化this.scroll=newBScroll(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);

    }

    }

    }$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);}}

    }

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

    need-to-insert-img

    need-to-insert-img

    need-to-insert-img

    以上内容还不够精细,等这段时间忙过去了会继续优化。如有bug,欢迎各位看官批评指正。

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

    推荐阅读更多精彩内容