今天继续琢磨播放器的用户体验细节,发现主流播放器的歌单、歌手、专辑、mv等详情页的描述文字如果太长的话,会自动收起,并提供一个展开按钮,点击即可展开,真方便啊,再看看自己的滚动实现
滚的快看不清,滚的慢又浪费时间
滚的快看不清,滚的慢又浪费时间,简直了。

还是来实现这个长文本的展开和收起功能吧

设计


这个功能实现起来并不难,首先需要一个元素存放描述正文,这个元素的高度是文字本身的高度,很多用户喜欢使用换行和空行来格式化美丽的文字,为了保留文字本身的换行和空行内容,这里我使用<pre>元素作为容器,(也可以通过设置white-space属性来保留连续空格),然后需要一个定高的父容器,并设置溢出隐藏,再放置一个展开/收起按钮,点击展开时取消父容器的定高,点击收起时恢复父容器的定高,这样主要工作就完成了,很简单,最后可以把按钮美化一番。

实现


先来看看完成后的实际效果
收起展开长文字

点这里查看简单版演示

由于需要在很多个页面中使用到这个展开收起功能,因此考虑将它抽成一个通用组件,首先需要声明需要接收的props

 props: {
    description: {
        type: String,
        default: ''
    }, // 描述文字
    lineNumbers: {
        type: Number,
        default: 3
    }, // 收起状态下的最大显示行数
    lineHeight: {
        type: Number,
        default: 22
    }, // 行高
    fontSize: {
        type: Number,
        default: 14
    }, // 字体大小
    btnOutOfWords: {
        type: Boolean,
        default: true
    } // 展开按钮是否不覆盖文本
 },
 

都是些简单的配置,接下来需要使用一些内部data来控制组件的运作,

data () {
    return {
        descHasOverflow: false, // 内容是否溢出
        showMore: false, // 是否已展开
        saveDescription: '' // 转义后的描述内容
    }
}

组件初始化以及每次监听到描述内容变化时我们都需要判断新内容是否溢出父容器,并将结果记录在descHasOverflow中,只有溢出时才会显示展开按钮,判断溢出的技巧也很简单,通过比较元素的clientHeightscrollHeight的大小即可,将判断逻辑封装在init方法中

 init () {
    this.descOverflow = false // 还原未溢出状态
    this.showMore = false // 还原收起状态
    this.saveDescription = this.saveDescription && this.description.replace(/[<]/g, '&lt;') // 转义<符号
    // 如果不使用this.$nextTick会发生什么?
    this.$nextTick(() => {
        const desc = this.$refs.desc
        if (desc.clientHeight < desc.scrollHeight) {
            this.descOverflow = true
        }
    })
}

接下来在组件mounted时以及监听到description变化时调用init方法即可

 watch: {
    description () {
        this.init()
    }
 },
 mounted () {
    this.init()
 }
 

如果父组件是通过路由加载,则在相同路由间跳转时不会触发本身以及子组件的mounted钩子,如果放在了<keep-alive>内部,则它本身以及它的子组件自始自终只会触发mounted钩子一次,所以仅在mounted钩子内部调用init还不够。

定义一个方法响应按钮事件

 handleShowMore () {
    this.showMore = !this.showMore
 }
 

在模板内部根据shouMore的不同,为父容器元素以及展开/收起按钮应用不同的样式即可

<template>
    <div class="desc-box" :class="{'desc-show-full': showMore}">
        <div
            class="desc-content"
            :style="{
                'height': showMore ? 'auto' : (lineNumbers + 1) * lineHeight + 'px',
                'font-size': fontSize + 'px',
                'line-height': lineHeight + 'px'
            }"
            ref="desc"
        >
            <span>简介:</span>
            <pre v-if="description && description.length > 0">{{ saveDescription }}</pre>
            <span v-else>无</span>
        </div>
        <div class="show-more" v-if="descOverflow" :style="{'right': btnOutOfWords ? '-30px' : '6px'}">
            <em></em>
            <a class="more_link" @click.prevent="handleShowMore">{{ showMore ? '^收起' : '...展开' }}</a>
        </div>
    </div>
</template>

最后再加亿点css代码美化一下就完成啦,点这里查看完整代码

最后

在上文提到的init方法中,我们在监听到description 改变后,让操作dom的代码在$nextTick方法中执行,如果不使用这个方法,也就是立即同步计算元素的 clientHeightscrollHeight ,此时参与计算的dom元素是description改变前的结果(如果仅在mounted钩子中,则不会发生这种情况),因为Vue中的虚拟dom是异步渲染的,它会在同步代码执行完毕后合并对虚拟dom的所有更新,比如

example () {
    for (let i = 0; i < 100; i++) {
        this.a = i
    }
}

当调用example方法时,尽管改变了一百次thia.a的值,却只会触发一次虚拟dom更新。

我们的目的是在dom更新完成后计算高度,而Vue提供的$nextTick方法正是为此而生的:

为了在数据变化之后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用Vue.nextTick(callback) 。这样回调函数在 DOM 更新完成后就会调用。

它内部利用了MutationObserver来监听dom元素的修改,一旦dom元素被修改,才会调用传入的回调,以此来确保回调函数执行时dom已经更新完毕。


LaiTaoGDUT
0 声望1 粉丝