VUE移动端音乐APP学习【六】:歌手详情页面开发


子路由配置

创建singer-detail.vue组件

<template>
  <div class="singer-detail">div>
template>

<script>
export default {
  name: 'singer-detail',
};
script>

<style lang="scss" scoped>
  .singer-detail {
    position: fixed;
    z-index: 100;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: $color-background;
  }
style>

在route --> index.js中:引入并配置Singer子路由SingerDetail

import SingerDetail from '../components/singer-detail/singer-detail';

{
    path: '/singer',
    name: 'Singer',
    component: Singer,
    children: [{
      path: ':id',
      component: SingerDetail,
    }],
  }

在singer.vue中添加

<template>
  <div class="singer">
    <list-view :data="singers">list-view>
    <router-view>router-view>
  div>
template>

在listview.vue中给

  • 添加点击事件,并在methods中定义selectItem方法

    <ul>
          <li v-for="(group,index) in data" class="list-group" ref="listGroup" :key="index">
            <h2 class="list-group-title">{{group.title}}h2>
            <ul>
              <li class="list-group-item" @click="selectItem(item)" v-for="item in group.items" :key="item.id">
                <img v-lazy="item.avatar" class="avatar">
                <span class="name">{{item.name}}span>
              li>
            ul>
          li>
    ul> methods: { selectItem(item) { this.$emit('select', item); }, }

    在singer.vue中监听select事件,触发selectSinger

    <list-view @select="selectSinger" :data="singers">list-view>
    
    
     methods: {
        selectSinger(singer) {
          this.$router.push({
            path: `/singer/${singer.id}`,
          });
        },
    }
  • 转场动画实现

    点击歌手跳转到歌手详情页可以加个过渡动画效果实现转场

    vue中的transition标签可以方便得进行动画过渡

    <template>
      <transition name="slide">
        <div v-if="show" class="singer-detail">div>
      transition>
    template>
    
    
    <script>
    export default {
      name: 'singer-detail',
      data() {
        return {
          show: false,
        };
      },
      created() {
        setTimeout(() => {
          this.show = true;
        }, 20);
      },
    };
    script>
    
    .slide-enter-active,
    .slide-leave-active {
      transition: all 0.3s;
    }
    
    .slide-enter,
    .slide-leave-to {
      transform: translate3d(100%, 0, 0);//100% 完全移动到屏幕右侧 动画开始后向左滑入
    }

    效果图

     使用Vuex实现路由之间参数数据的获取

    子路由SingerDetail需要从父路由页面Singer获取很多数据,都用参数获取内容太多,所以需要使用Vuex来进行管理。

    Vuex是一个用来管理组件之间通信的插件,它是一个专为【vue.js】应用程序开发的状态管理模式,它解决了组件之间同一状态的共享问题,它能够更好地在组件外部管理状态。

    安装 : npm install vuex --save

    在src -->store目录下创建以下js文件:

    1. index.js:入口文件
    2. state.js:管理所有状态 state
    3. mutations.js:管理所有mutation —— 更改 Vuex 的 store 中状态state的唯一方法
    4. mutation-types.js:管理所有mutation 事件类型(type)--字符串常量
    5. actions.js:处理异步操作和修改、以及对mutation的封装
    6. getters.js:对获取的state 做一些映射

    在state.js中定义singer数据

    const state = {
      singer: {},
    };
    export default state;

    在mutation-types中定义字符串常量

    // 定义一些字符串常量
    export const SET_SINGER = 'SET_SINGER';

    在mutations.js中引入mutation-types作关联,并可对state进行修改

    import * as types from './mutation-types';
    
    const mutations = {
      [types.SET_SINGER](state, singer) {
        state.singer = singer;
      },
    };
    export default mutations;

    在getters.js中对state进行包装和输出

    // 从state里取数据
    export const singer = (state) => state.singer;

    初始化 index.js入口文件

    import Vue from 'vue';
    import Vuex from 'vuex';
    // Vuex 内置日志插件用于一般的调试
    import createLogger from 'vuex/dist/logger';
    import * as actions from './actions';
    import * as getters from './getters';
    import mutations from './mutations';
    import state from './state';
    
    Vue.use(Vuex);
    // 只在开发环境时启动严格模式
    const debug = process.env.NODE_ENV !== 'production';
    export default new Vuex.Store({
      actions,
      getters,
      state,
      mutations,
      strict: debug,
      plugins: debug ? [createLogger()] : [],
    });

    注意:在严格模式下,无论何时发生了状态变更且不是由 mutation 函数引起的,将会抛出错误。这能保证所有的状态变更都能被调试工具跟踪到。不要在发布环境下启用严格模式!严格模式会深度监测状态树来检测不合规的状态变更——请确保在发布环境下关闭严格模式,以避免性能损失。 

    在main.js中引入Store

    import store from './store';
    
    new Vue({
      router,
      store,
      render: (h) => h(App),
    }).$mount('#app');

    在singer.vue中调用mapMutations作对象映射,传递参数singer

    import { mapMutations } from 'vuex';
    
     ...mapMutations({
          setSinger: 'SET_SINGER',
        }),
    
    selectSinger(singer) {
          this.$router.push({
            path: `/singer/${singer.id}`,
          });
          this.setSinger(singer);
        },

    在singer-detail.vue中通过引入mapGetters,取到vuex中存储的singer数据

    import { mapGetters } from 'vuex';
    
    computed: {
        ...mapGetters([
          'singer',
        ]),
      },
    
    created() {
        ...
        console.log(this.singer);
      },

    在singer.js获取歌手歌曲接口数据:

    export function getSingerDetail(singerId) {
      return axios.get(`/api/artists?id=${singerId}`);
    }

    在singer-detail中引入getSingerDetail方法和ERR_OK常量,并在methods中调用该方法获取singer相关歌曲数据

    import { getSingerDetail } from '../../api/singer';
    import { ERR_OK } from '../../api/config';
    
    
    methods: {
        _getDetail() {
          // 用户刷新时,mapGetters获取到的singer为空 需要回退歌手列表页
          if (!this.singer.id) {
            this.$router.push('/singer');
            return;
          }
          getSingerDetail(this.singer.id).then((res) => {
            if (res.status === ERR_OK) {
              this.songs = this._normalizeSongs(res.data.hotSongs);
              console.log(this.songs);
            }
    
          });
        },

    注意:只有从singer页面选择歌手跳转到对应singer-detail路由中,才能得到singer数据。当用户刷新时,歌手详情页面将会自动返回到歌手列表页面

    同样需要对获取到的歌手相关歌曲数据进行封装处理,创建song.js,构造一个Song类

    export default class Song {
      // song的id,mid,歌手,歌曲名name,专辑名album,歌曲长度duration,歌曲图片img,歌曲的真实路径url
      constructor({
        id, singer, name, album, duration, image, url,
      }) {
        this.id = id;
        this.singer = singer;
        this.name = name;
        this.album = album;
        this.duration = duration;
        this.image = image;
        this.url = url;
      }
    }

    歌手详情处理

    在song.js中处理musicData数据:

    export function createSong(musicData) {
      return new Song({
        id: musicData.id,
        singer: filterSinger(musicData.ar),
        name: musicData.name,
        album: musicData.al.name,
        duration: (musicData.dt / 1000) | 0,
        image: musicData.al.picUrl,
        url: `https://music.163.com/song/media/outer/url?id=${musicData.id}.mp3`,
      });
    }
    
    function filterSinger(singer) {
      let ret = [];
      if (!singer) {
        return '';
      }
      singer.forEach((s) => {
        ret.push(s.name);
      });
      return ret.join('/');
    }

    在singer-detail.vue中调用createSong方法,将处理好的数据赋值给this.songs

    _normalizeSongs(list) {
          let ret = [];
          list.forEach((item) => {
            if (item.id && item.al.id) {
              ret.push(createSong(item));
            }
          });
          return ret;
        },

    创建music-list.vue

    <div class="music-list">
            <div class="back">
                    <i class="icon-back">i>
            div>
            <h1 class="title" v-html="title">h1>
            <div class="bg-image" :style="bgStyle">
                    <div class="filter">div>
            div>
    div>
    
    export default {
      name: 'music-list',
      props: {
        bgImage: {
          type: String,
          default: '',
        },
        songs: {
          type: Array,
          // eslint-disable-next-line vue/require-valid-default-prop
          default: [],
        },
        title: {
          type: String,
          default: '',
        },
      },
    }

    在singer-detail中引入该组件,向该组件传递singer数据中的songs,name,avatar

    <template>
      <transition name="slide">
        <music-list v-if="show" :title="title" :bgImage="bgImage" :songs="songs">music-list>
      transition>
    template>
    
    
    computed: {
        title() {
          return this.singer.name;
        },
        bgImage() {
          return this.singer.avatar;
        },
        ...mapGetters([
          'singer',
        ]),
      },

    song-list组件开发

    由于歌曲列表这一部分在后续开发页面(例如歌曲排行榜)中都会使用到,所以在这里将它当作一个基础组件进行开发

    <div class="song-list">
           <ul>
                 <li v-for="(song, index) in songs" :key="index" class="item">
                     <div class="content">
                        <h2 class="name">{{song.name}}h2>
                        <p class="desc">{{getDesc(song)}}p>
                     div>
                 li>
           ul>
    div>


    props: {
        songs: {
           type: Array,
           default: []
        }
    }

    methods: {
        getDesc(song){
             return `${song.singer} 。${song.album}`
        }
    }

    在music-list.vue中引用该组件和scroll组件

    <scroll :data="songs" class="list" ref="list">
         <div class="song-list-wrapper">
                <song-list :songs="songs">song-list>
         div>
    scroll>
    
    import Scroll from '../../base/scroll/scroll';
    import SongList from '../../base/song-list/song-list';

    music-list组件实现了列表可以往上滚动,也可以往下滚动;图片随着列表滚动实现缩小放大的效果。

    music-list.vue完整代码:

      1 <template>
      2   <div class="music-list">
      3     <div class="back" @click="back">
      4       <i class="iconfont icon-back">i>
      5     div>
      6     <h1 class="title" v-html="title">h1>
      7     <div class="bg-image" :style="bgStyle" ref="bgImage">
      8       <div class="play-wrapper">
      9         <div class="play" v-show="songs.length > 0" ref="playBtn">
     10           <i class="icon-play">i>
     11           <span class="text">随机播放全部span>
     12         div>
     13       div>
     14       <div class="filter" ref="filter">div>
     15     div>
     16     <div class="bg-layer" ref="layer">div>
     17     <scroll @scroll="scroll" :probe-type="probeType"  :listen-scroll="listenScroll" :data="songs" class="list" ref="list">
     18       <div class="song-list-wrapper">
     19         <song-list :songs="songs">song-list>
     20       div>
     21       <div class="loading-container" v-show="!songs.length">
     22         <loading>loading>
     23       div>
     24     scroll>
     25   div>
     26 template>
     27 
     28 <script>
     29 import Scroll from '../../base/scroll/scroll';
     30 import SongList from '../../base/song-list/song-list';
     31 import { prefixStyle } from '../../common/js/dom';
     32 import Loading from '../../base/loading/loading';
     33 
     34 const RESERVED_HEIGHT = 40;
     35 const transform = prefixStyle('transform');
     36 export default {
     37   name: 'music-list',
     38   components: {
     39     Scroll,
     40     SongList,
     41     Loading,
     42   },
     43   props: {
     44     bgImage: {
     45       type: String,
     46       default: '',
     47     },
     48     songs: {
     49       type: Array,
     50       // eslint-disable-next-line vue/require-valid-default-prop
     51       default: [],
     52     },
     53     title: {
     54       type: String,
     55       default: '',
     56     },
     57   },
     58   computed: {
     59     bgStyle() {
     60       return `background-image:url(${this.bgImage})`;
     61     },
     62   },
     63   data() {
     64     return {
     65       scrollY: 0,
     66     };
     67   },
     68   created() {
     69     this.probeType = 3;
     70     this.listenScroll = true;
     71   },
     72   mounted() {
     73     this.imageHeight = this.$refs.bgImage.clientHeight;
     74     this.minTranslateY = -this.imageHeight + RESERVED_HEIGHT;
     75     this.$refs.list.$el.style.top = `${this.imageHeight}px`;
     76   },
     77   methods: {
     78     scroll(pos) {
     79       this.scrollY = pos.y;
     80     },
     81     back() {
     82       this.$router.back();
     83     },
     84   },
     85   watch: {
     86     scrollY(newY) {
     87       let translateY = Math.max(this.minTranslateY, newY);
     88       let zIndex = 0;
     89       // 图片放大
     90       let scale = 1;
     91       // 图片模糊
     92       let blur = 0;
     93       this.$refs.layer.style[transform] = `translate3d(0,${translateY}px,0)`;
     94       const percent = Math.abs(newY / this.imageHeight);
     95       // 图片往下拉时
     96       if (newY > 0) {
     97         scale = 1 + percent;
     98         zIndex = 10;
     99       } else {
    100         blur = Math.min(20 * percent, 20);
    101       }
    102       // CSS高斯模糊属性 只有iphone看得到效果
    103       this.$refs.filter.style['backdrop-filter'] = `blur(${blur}px)`;
    104       this.$refs.filter.style['webkiBackdrop-filter'] = `blur(${blur}px)`;
    105       // 滚到顶部时
    106       if (newY < this.minTranslateY) {
    107         zIndex = 10;
    108         // 由于bgImage是宽高比,所以要先把paddingTop设为0
    109         this.$refs.bgImage.style.paddingTop = 0;
    110         this.$refs.bgImage.style.height = `${RESERVED_HEIGHT}px`;
    111         this.$refs.playBtn.style.display = 'none';
    112       } else { // 还没滚动到顶部时
    113         this.$refs.bgImage.style.paddingTop = '70%';
    114         this.$refs.bgImage.style.height = 0;
    115         this.$refs.playBtn.style.display = '';
    116       }
    117       this.$refs.bgImage.style.zIndex = zIndex;
    118       this.$refs.bgImage.style[transform] = `scale(${scale})`;
    119     },
    120   },
    121 };
    122 script>
    123 
    124 <style lang="scss" scoped>
    125   .music-list {
    126     position: fixed;
    127     z-index: 100;
    128     top: 0;
    129     left: 0;
    130     bottom: 0;
    131     right: 0;
    132     background: $color-background;
    133 
    134     .back {
    135       position: absolute;
    136       top: 0;
    137       left: 6px;
    138       z-index: 50;
    139 
    140       .icon-back {
    141         display: block;
    142         padding: 10px;
    143         font-size: $font-size-large-x;
    144         color: $color-theme;
    145       }
    146     }
    147 
    148     .title {
    149       position: absolute;
    150       top: 0;
    151       left: 10%;
    152       z-index: 40;
    153       width: 80%;
    154 
    155       @include no-wrap();
    156 
    157       text-align: center;
    158       line-height: 40px;
    159       font-size: $font-size-large;
    160       color: $color-text;
    161     }
    162 
    163     .bg-image {
    164       position: relative;
    165       width: 100%;
    166       height: 0;
    167       padding-top: 70%;
    168       //设置旋转元素的基点位置
    169       transform-origin: top;
    170       background-size: cover;
    171 
    172       .play-wrapper {
    173         position: absolute;
    174         bottom: 20px;
    175         z-index: 50;
    176         width: 100%;
    177 
    178         .play {
    179           box-sizing: border-box;
    180           width: 135px;
    181           padding: 7px 0;
    182           margin: 0 auto;
    183           text-align: center;
    184           border: 1px solid $color-theme;
    185           color: $color-theme;
    186           border-radius: 100px;
    187           font-size: 0;
    188 
    189           .icon-play {
    190             display: inline-block;
    191             vertical-align: middle;
    192             margin-right: 6px;
    193             font-size: $font-size-medium-x;
    194           }
    195 
    196           .text {
    197             display: inline-block;
    198             vertical-align: middle;
    199             font-size: $font-size-small;
    200           }
    201         }
    202       }
    203 
    204       .filter {
    205         position: absolute;
    206         top: 0;
    207         left: 0;
    208         width: 100%;
    209         height: 100%;
    210         background: rgba(7, 17, 27, 0.4);
    211       }
    212     }
    213 
    214     .bg-layer {
    215       position: relative;
    216       height: 100%;
    217       background: $color-background;
    218     }
    219 
    220     .list {
    221       position: fixed;
    222       top: 0;
    223       bottom: 0;
    224       width: 100%;
    225       background: $color-background;
    226 
    227       .song-list-wrapper {
    228         padding: 20px 30px;
    229       }
    230 
    231       .loading-container {
    232         position: absolute;
    233         width: 100%;
    234         top: 50%;
    235         transform: translateY(-50%);
    236       }
    237     }
    238   }
    239 style>
    music-list.vue

    整体效果

    接口更新

    由于之前使用的QQ音乐API ko2部分接口失效,改换成网易云接口。

    推荐页面接口(轮播图和热门歌单推荐):

    import axios from './axios';
    // import jsonp from '../common/js/jsonp';
    
    export function getRecommend() {
      // const url = '/api/getDigitalAlbumLists';
      // return jsonp(url);
      return axios.get('/api/banner');
    }
    export function getDiscList() {
      return axios.get('/api/personalized');
    }

    歌手列表接口:

    import axios from 'axios';
    
    export function getSingerList() {
      return axios.get('/api/top/artists');
    }

    歌手歌曲接口:

    export function getSingerDetail(singerId) {
      return axios.get(`/api/artists?id=${singerId}`);
    }

    需要优化的地方:

    ①采用网易云接口后发现获取到的歌手头像变形

     解决办法:使用object-fit保持图片尺寸

    在listview.vue中添加object-fit: cover;

    .avatar {
        width: 50px;
        height: 50px;
        border-radius: 50%;
        // 保持原有尺寸比例
        object-fit: cover;
    }

    ②获取到的头像过大,导致加载过慢

    解决办法:在singer.vue中每个url后加 ?param=300x300实现压缩图片大小

    ③修改获取歌手姓名首字母方法,当getCamelChars()中传入的参数不是汉字时,不会进行转换,仍然输出源字符串

    修改代码:

    export function Getinitial(string) {
      let pinyin = require('js-pinyin');
      pinyin.setOptions({ checkPolyphone: false, charCase: 0 });
      // getCamelChars()中传入的参数不是汉字时,不会进行转换,仍然输出源字符串。
      return pinyin.getCamelChars(string).substring(0, 1).toUpperCase();
    }
    vue