音乐webapp - CSDN
精华内容
参与话题
  • 一款基于vuejs开发的高仿网易云音乐webapp
  • GitHub: https://github.com/bxm0927/vue-music-webapp项目演示地址: https://bxm0927.github.io/vue-music-webapp/dist/ (在 GitHub Pages 中,通过 jsonp 请求的数据会被正常渲染,而由于无 node 服务,通过 ...

    GitHub: https://github.com/bxm0927/vue-music-webapp

    项目演示地址: https://bxm0927.github.io/vue-music-webapp/dist/在 GitHub Pages 中,通过 jsonp 请求的数据会被正常渲染,而由于无 node 服务,通过 axios 请求的数据不会被正常渲染

    基于 Vue 全家桶 (2.x) 制作的移动端音乐 WebApp ,一个媲美原生的移动端音乐 App,项目完整、功能完备、UI美观、交互一流。

    图片预览

    技术栈

    【前端】

    • Vue:用于构建用户界面的 MVVM 框架。它的核心是响应的数据绑定组系统件
    • vue-router:为单页面应用提供的路由系统,项目上线前使用了 Lazy Loading Routes 技术来实现异步加载优化性能
    • vuex:Vue 集中状态管理,在多个组件共享某些状态时非常便捷
    • vue-lazyload:第三方图片懒加载库,优化页面加载速度
    • better-scroll:iscroll 的优化版,使移动端滑动体验更加流畅
    • Sass(Scss):css 预编译处理器
    • ES6:ECMAScript 新一代语法,模块化、解构赋值、Promise、Class 等方法非常好用

    【后端】

    • Node.js:利用 Express 起一个本地测试服务器
    • jsonp:服务端通讯。抓取 QQ音乐(移动端)数据
    • axios:服务端通讯。结合 Node.js 代理后端请求,抓取 QQ音乐(PC端)数据

    【自动化构建及其他工具】

    • vue-cli:Vue 脚手架工具,快速初始化项目代码
    • eslint:代码风格检查工具,规范代码书写
    • vConsole:移动端调试工具,在移动端输出日志

    收获

    1. 总结了一套 Vue 通用组件,可以在其它项目中复用的 10+ 个基础组件、15+ 个业务组件
    2. 总结了一套常用的 SCSS mixin 库
    3. 总结了一套常用的 JS 工具函数库
    4. 体会到组件化、模块化开发带来的便捷
    5. 体会到将对象封装成类(ES6 class) 的便捷性,以及利用工厂方式初始化类实例
    6. 学会利用 js 编写过渡效果及动画效果制作良好的用户交互体验

    TODO

    1. 歌曲数据全部来自 QQ 音乐,接口改变了可能就要修改 jsonpaxios 代码
    2. 由于项目的应用级状态不多(10个左右),所以就没有将 actionmutation、和 getters 分割到单独的文件。但这样架构并不便于维护

    实现细节

    主要页面:播放器内核页、推荐页、歌单详情页、歌手页、歌手详情页、排行页、搜索页、添加歌曲页、个人中心页等。

    核心页面:播放器内核页

    组件树

    <app> ................... 根组件
      <my-player> ........... 全局的播放器内核组件
      <my-header> ........... 头部组件
      <my-tab> .............. 导航栏组件
      <router-view> ......... 路由
        <recommend> ......... 推荐页
        <singer> ............ 歌手页
        <rank> .............. 排行页
        <search> ............ 搜索页
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    推荐页

    上部分是一个轮播图组件,使用第三方库 better-scroll 辅助实现,使用 jsonp 抓取 QQ音乐(移动端)数据

    下部分是一个歌单推荐列表,使用 axios + Node.js 代理后端请求,绕过主机限制 (伪造 headers),抓取 QQ音乐(PC端)数据

    歌单推荐列表图片,使用图片懒加载技术 vue-lazyload,优化页面加载速度

    为了更好的用户体验,当数据未请求到时,显示 loading 组件

    推荐页 -> 歌单详情页

    由于歌手的状态多且杂,这里使用 vuex 集中管理歌手状态

    这个组件更加注重 UX,做了很多类原生 APP 动画,如下拉图片放大、跟随推动、ios 渐进增强的高斯模糊效果 backdrop-filter

    歌手页

    左右联动是这个组件的难点

    左侧是一个歌手列表,使用 jsonp 抓取 QQ音乐(PC端)歌手数据并重组 JSON 数据结构

    列表图片使用懒加载技术 vue-lazyload,优化页面加载速度

    右侧是一个字母列表,与左侧歌手列表联动,滚动固定标题实现

    歌手页 -> 歌手详情页

    复用歌单详情页,只改变传入的参数,数据同样爬取自 QQ音乐

    播放器内核页

    核心组件。用 vuex 管理各种播放时状态,播放、暂停等功能调用 audio API

    播放器可以最大化和最小化

    中部唱片动画使用第三方 JS 动画库 create-keyframe-animation 实现

    底部操作区图标使用 iconfonts

    抽象了一个横向进度条组件和一个圆形进度条组件,横向进度条可以拖动小球和点击进度条来改变播放进度,圆形进度条组件使用 SVG <circle> 元素

    播放模式有:顺序播放、单曲循环、随机播放,原理是调整歌单列表数组

    歌词的爬取利用 axios 代理后端请求,伪造 headers 来实现,先将歌词 jsonp 格式转换为 json 格式,再使用第三方库 js-base64 进行 Base64 解码操作,最后再使用第三方库 lyric-parser对歌词进行格式化

    实现了侧滑显示歌词、歌词跟随进度条高亮等交互效果

    增加了当前播放列表组件,可在其中加入/删除歌曲

    排行页

    普通组件,没什么好说的

    排行页 -> 歌单详情页

    复用歌单详情页,没什么好说的

    搜索页

    抓数据,写组件,另外,根据抓取的数据特征,做了上拉刷新的功能

    考虑到数据量大且频繁的问题,对请求做了节流处理

    考虑到移动端键盘占屏的问题,对滚动前的 input 做了 blur() 操作

    对搜索历史进行了 localstorage 缓存,清空搜索历史时使用了改装过的 confirm 组件

    支持将搜索的歌曲添加到播放列表

    个人中心

    localstorage 中 “我的收藏” 和 “最近播放” 反映到界面上

    其他

    此应用的全部数据来自 QQ音乐,推荐页的歌单列表及歌词是利用 axios 结合 node.js 代理后端请求抓取的。

    全局通用的应用级状态使用 vuex 集中管理

    全局引入 fastclick 库,消除 click 移动浏览器300ms延迟

    页面是响应式的,适配常见的移动端屏幕,采用 flex 布局

    Build Setup

    # clone the repo into your disk.
    $ git clone https://github.com/bxm0927/music-app.git
    
    # install dependencies
    $ npm install
    
    # serve with hot reload at localhost:8080
    $ npm run dev
    
    # build for production with minification
    $ npm run build

    License

    The code is available under the MIT license.

    展开全文
  • 网易云音乐接口+vue全家桶开发一款移动端音乐webApp 项目还在develop中,感兴趣想要参与的小伙伴可以私我 效果图: 骨架屏 首页 侧边栏 每日推荐 歌单 播放器(小) 播放器(大) 详细信息 ...

    网易云音乐接口+vue全家桶开发一款移动端音乐webApp

    项目还在develop中,感兴趣想要参与的小伙伴可以私我

    效果图:

    骨架屏

    首页

    侧边栏

    每日推荐

    歌单

    播放器(小)

    播放器(大)

    详细信息

    测试地址

    开发总结

    项目结构

    vue-cli搭建

    新增目录如下:

    ---src 
      ------api        // 放置api的目录
      ---------base.js // 放置axios的一些配置,接口域名地址,以及公共参数配置,与后台约定跨域的配置,全局loading配置等
      ---------urls.js // 放置接口url 
      ---------api.js  // 放置封装的promise请求
      ------base       // 放置一些基础组件 
      ------common  
      ---------js      // 公共js 
      ---------sass    // 公共样式 
    复制代码

    类库使用

    • fastclick解决移动端300ms延迟

    • vux 快速构建一些常规页面

    • vue-lazyLoad 对图片进行懒加载处理

    • better-scroll 轮播图

    • NeteaseCloudMusicApi wy音乐接口,node封装转发,部署在自己服务器上

    路由按需加载

    const view = (path, name) => () => import(`@/components/${path}${name}`)// 路由按需加载
     //这边用的是vue异步组件的方式实现路由的按需加载
     new Vue({
       // ...
       components: {
         'my-component': () => import('./my-async-component')
       }
     })
    
    复制代码
    • 路由加载时用了transition动画组件添加了一个切换动画
    • 注意如果你希望在 Vue Router 的路由组件中使用上述语法的话,你必须使用 Vue Router 2.4.0+ 版本。

    播放器组件

    大小播放器分别写了 MiniPlayer.vue 和 NormalPlayer.vue 两个组件,因为想要职责单一,就没有放在一起

    • 隐藏显示 通过vuex进行管理

    • 动画

      1. 头部下坠和底部的上浮

        <transition name="example">
        
        </transition>
        
        /*css 样式*/
        // 给 transition下第一个元素显示或隐藏时添加的样式
         //这两个类名都是定义开始到结束的持续时间 方式 以及延迟
        .example-enter-active{
          transition:all 0.4s linear  对所有属性执行0.4s的动画 匀速
        }
        .example-leave-active{
          transition:all 0.4s linear  对所有属性执行0.4s的动画 匀速
        }
        // 进入过度的开始状态 触发时机 元素被插入前 插入后下一帧移除
        .example-enter{
        
        
        }
        // 离开过度的结束状态 触发时机 example-leave下一帧  动画过度完成被移除
        .example-leave-to{
        
        
        }
        
         可以使用碟中谍6中的halo跳伞来理解
        
         .example-enter-active就是从飞机上离开到开伞的时间
        
         .example-enter 下坠前在飞机上的最后一刻
        
         .example-enter-to  开始下坠,具备加速度的那一刻 
        
         .example-leave-active 开伞到着陆的时间
        
         .example-leave 开伞命令发出时
        
         .example-leave-to 伞开下一刻
        复制代码
      2. 播放器的cd的位移及缩放

        先计算出小播放器图片离最终大播放器cd的x,y轴上的距离

        使用 create-keyframe-animation 进行一个 css3 动画状态的注册

        再利用transition的动画方法钩子

        在 enter 时 run 动画, afterEnter 时清除动画 leave 同理

      3. 播放器的旋转

        定义一个旋转的 css 动画,在一个 class 中进行调用,在 play 的状态下给它 addClss , pause 时加上 animation-play-state: paused

    audio的使用

    使用 html5 的 audio 结合 vuex 来进行播放器功能的实现,包括进度条,播放,暂停,上一曲,下一曲,播放模式等

    布局

    • 绝大多数使用了flex webpack中配置低版本安卓,ios加前缀

    • 考虑到fixed元素的移动端问题,在这种场景下,使用100%高度+absolute方案更适合

    • 使用媒体查询,兼容一下某些样式在768px以上的样式变形

    • 使用rem 在vue实例的mounted的钩子里注册resizeonload监听,进行最外层rem基准的计算

    • 使用骨架屏进行加载资源白屏时填充,待优化至完全的主页面服务端渲染

    感谢

    • vue

    • vuex

    • vue-router

    • vux

    • vue-lazyLoad

    • NeteaseCloudMusicApi

    github地址欢迎star.


    作者:小笼包
    链接:https://juejin.im/post/5b927479e51d450e9942df40
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    展开全文
  • 移动端音乐WebApp

    千次阅读 2020-06-03 19:49:35
    版本: Node – v10.15.0 Vue – 2.5.2 目录 所有目录src: ├─api ->放置和后端请求相关的代码,包括ajax等 ├─base │ ├─confirm ->删除时候的提示框,是否真正删除 │ ├─listview ->...

    这篇文章是写给自己今后复习用的笔记,如果有哪些不懂的,可以私信,不喜也勿喷,感谢阅读~~~
    github源码地址:Music
    慕课原课程地址

    版本:

    Node – v10.15.0
    Vue – 2.5.2

    部分功能

    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    目录在这里插入图片描述

    1. 所有目录
      src:
          ├─api                   ->放置和后端请求相关的代码,包括ajax等
          ├─base
          │  ├─confirm            ->删除时候的提示框,是否真正删除
          │  ├─listview           ->歌手列表的核心组件(singer中使用)
          │  ├─loading            ->等待数据加载时显示的组件(就是一个转圈圈的图案)
          │  ├─no-result          ->搜索结果不存在时候显示的组件
          │  ├─progress-bar       ->播放器页面的进度条
          │  ├─progress-circle    ->mini播放器的播放按钮
          │  ├─scroll             ->用于给组件实现滚动
          │  ├─search-box         ->搜索框组件
          │  ├─search-list        ->根据搜索结果显示的搜索列表
          │  ├─slider             ->轮播图组件
          │  ├─song-list          ->只用于显示所有歌曲(只要获取歌曲就需要这个组件)
          │  ├─switches           ->最近播放/搜索历史
          │  └─top-tip 			->成功添加歌曲提示
          ├─common                ->放置静态资源,像图片,字体等
          │  ├─fonts
          │  ├─image
          │  ├─js
          │  ├─scss
          │  └─stylus
          ├─components            ->公共组件
          │  ├─add-song           ->添加歌曲到队列
          │  ├─disc               ->歌单详情页
          │  ├─m-header           ->首页顶部
          │  ├─music-list         ->歌手详情页主要组件(在singer-detail中被使用)
          │  ├─player             ->播放器页面(大的播放器,mini播放器)
          │  ├─playlist           ->mini播放器右下角的歌曲列表
          │  ├─rank               ->所有榜单页面
          │  ├─recommend          ->歌单页面
          │  ├─search             ->整个搜索页面的实现
          │  ├─singer             ->所有歌手列表页(引用listview)
          │  ├─singer-detail      ->歌手详情页(引用music-list)
          │  ├─suggest            ->搜索页面核心组件,数据获取等
          │  ├─tab                ->导航栏(歌手,排行等)
          │  ├─top-list           ->榜单详情页面
          │  └─user-center
          ├─router                ->路由相关文件
          ├─store                 ->vuex相关代码
          ├─main.js               ->入口相关文件
          └─App.vue
      index.html                  ->添加meta标签,移动端常见设置
      package.json                ->添加了
                                      ->"fastclick": "^1.0.6":解决移动端点击300毫秒延迟
                                      ->"babel-runtime": "^6.26.0",:对es语法进行转义
                                      ->"babel-polyfill": "^6.26.0",对es6api进行转义,例如promise等
      

    项目初始化

    1. 解决300毫秒延迟的问题
      src/main.js目录:
          import fastclick from 'fastclick'
          fastclick.attach(document.body)
      
      • fastclick解决其他问题
        • https://segmentfault.com/a/1190000003848737
        • https://www.jamescathy.top/p/113.html
        • needsclick
    2. 别名的配置:在webpack.base.conf.js下配置
      • common:src/common
        resolve: {
            alias: {
                'common': resolve('src/common'), 
            }
        }
        function resolve (dir) {
          return path.join(__dirname, '..', dir)
        }
        
      • 同理,也可以多配制几个别名
        module.exports = {
          resolve: {
            extensions: ['.js', '.vue', '.json'],
            alias: {
              'vue$': 'vue/dist/vue.esm.js',
              'src': resolve('src'),
              'api': resolve('src/api'),
              'base': resolve('src/base'),
              'common': resolve('src/common'),
              'components': resolve('src/components'),
              'router': resolve('src/router'),
              'store': resolve('src/store')
             }
            },
          }
        
    3. 改变默认的样式
      • 背景色:src/common/stylus/variable.styl的background
      • 图标:src/components/mHeader
    4. 与本项目有关的数据的获取:大部分利用jsonp方式
      • jsonp原理:
        利用动态创建一个script标签,因为script标签是没有同源策略限制的,然后把script的src指向我们请求支持的服务端地址,地址后面有个callback函数
      • jsonp使用:
        • 安装: npm i jsonp
        • 封装一个jsonp方法:src/common/js/jsonp.js(具体见代码注释)
        • 封装具体的获取数据(包含实际的的url和请求参数)的jsonp的方法:
          src/api/recommend.js(具体见代码注释)
        • 在组件中调用方法获取数据:src/components/recommend.vue
          src/components/recommend.vue
          1. 引入:import { getRecommend} from 'api/recommend'
                   import { ERR_OK } from 'api/config'
          2. 创建方法
              methods:{
                  _getRecommend(){
                    getRecommend().then((res) => {
                      不是直接使用数字,是为了语义化更好
                      if(res.code == ERR_OK){
                        console.log(res.data.slider);
                      }
                    })
                  },
              }
          3. 在生命周期函数created时候执行,获取数据
              created(){
                  this._getRecommend()
              },
          
      • 参考网址:https://github.com/webmodules/jsonp
    5. vuex
      • 用state管理状态,详情都见store文件夹,都有注释
        actions.js              对mutation进行封装 
        getters.js              对状态获取的封装
        mutation-types.js       保存一些常量(mutations中函数的函数名)
        mutations.js            用于更改状态(state中的数据)
        state.js                用于存储状态信息,管理vuex数据的
        vuex.js                 引入以上所有文件
        
      • 举个栗子:调用vuex存储数据
        1. 在main.js中引入import store from './store'
        2. 在main.js中注册
                new Vue({
                  store
                })
        3. 在src/components/singer中使用
            import {mapMutations} from 'vuex'
            methods:{
                  // 经过这个映射,在代码中就可以调用this.setSinger
                ...mapMutations({
                  setSinger: 'SET_SINGER'
                })
              }
        

    header部分

    在这里插入图片描述

    1. 创建组件src/components/mHeader
    2. 在src/App.vue中引入
      src/App.vue中引入
      1. import MHeader from 'components/mHeader/mHeader'
      2. components: {
          MHeader,
         }
      3. 在组件中引入
          <template>
              <m-header></m-header>
          </template>
      

    推荐(recommend),歌手(singer),排行(rank),搜索(search)

    在这里插入图片描述

    1. 创建组件(src/components下):
      • 推荐(src/components/recommend.vue),
      • 歌手(src/components/singer.vue),
      • 排行(src/components/rank.vue),
      • 搜索(src/components/search.vue)
    2. 在src/router/index.js下,创建这些组件对应的路由并注册
      import Router from 'vue-router'
      import Recommend from 'components/recommend/recommend'
      import Singer from 'components/singer/singer'
      import Rank from 'components/rank/rank'
      import Search from 'components/search/search'
      Vue.use(Router)
      export default new Router({
          routes:[
              {path: '/', redirect:'/recommend'},
              {path: '/recommend', component: Recommend,},
              {path: '/singer', component: Singer,},
              {path: '/rank', component: Rank,},
              {path: '/search', component:Search,}
          ]
      })
      
    3. 在src/main.js添加路由文件,并注册到实例身上
      import router from './router'
      new Vue({
        router
      })
      
    4. 添加router-view到src/App.vue
    5. src/components/tab下引入router-link
      <div class="tab">
      	 //   tag:将router-link渲染成什么标签
          <router-link tag="div" class="tab-item" to="/recommend">
            <span class="tab-link">推荐</span>
          </router-link>
          <router-link tag="div" class="tab-item" to="/singer">
            <span class="tab-link">歌手</span>
          </router-link>
          <router-link tag="div" class="tab-item" to="/rank">
            <span class="tab-link">排行</span>
          </router-link>
          <router-link tag="div" class="tab-item" to="/search">
            <span class="tab-link">搜索</span>
          </router-link>
      </div>
      样式:当跳转到对应路由的时候,当前路由显示高亮样式
       &.router-link-active {
            .tab-link {
              color: $color-theme;
              border-bottom: 2px solid $color-theme;
            }
          }
      
    6. src/App.vue下引入tab组件
      1. import Tab from 'components/tab/tab'
      2. components: {
          MHeader,
          Tab,
         }
      3. 在组件中引入
          <template>
              <m-header></m-header>
              <tab></tab>
              <router-view></router-view>
          </template>
      
    7. 默认显示推荐组件recommend.vue
      • router/index.js中routes下配置{path: ‘/’, redirect:’/recommend’},

    推荐(recommend)

    轮播图组件

    在这里插入图片描述

    1. src下创建base目录,base下创建silder文件夹,然后创建silder.vue组件(具体见代码注释)
    2. 在components/recommend.vue文件中引入轮播图组件
      1. 引入:import Slider from 'base/slider/slider'
      2. components: {
          Slider,
        },
      3. 在template中添加插件
          <div class="slider-wrapper">
              <slider>
                <div v-for="">     
                </div>
              </slider>
          </div>
      
    3. 渲染轮播图组件
      • jsonp获取数据成功后,用recommends接收数据
        data(){
            return{
              recommends: [],
            }	
          },
        if(res.code == ERR_OK){
          // 当jsonp获取数据成功后,用数组接收数据,然后用来渲染轮播图组件
          this.recommends = res.data.slider;
        } 
        
      • 用得来的数据渲染轮播图组件
        <slider>
          <div v-for="item in recommends" :key="item.id">
            <a :href="item.linkUrl">
              <img :src="item.picUrl">
            </a>
          </div>
        </slider>
        
    4. 轮播图逻辑:见src/base/slider.vue
      • 在slider.vue中使用better-scroll:
        官网:https://github.com/ustbhuangyi/better-scroll
             https://ustbhuangyi.github.io/better-scroll/doc/zh-hans/
        1. 引入:import BScroll from 'better-scroll'
        2. 定义相关方法
        3. 在生命周期函数mounted时候执行方法
        4. 使用scroll的时候一定一定要注意:
            必须保证当前 能得到组件的正确高度才能滚动!!!!
        
      • 完成轮播图需要实现的功能:
        • 正确计算每个子元素宽度,保证样式正确,能够正确获取整个轮播图的图片总宽度
          举个栗子,比如说5个图片的循环轮播图
          屏幕宽度 100px
            设置子元素宽度:100px
            设置父元素宽度:100 * 5 + 2 * 100 = 700px
          
        • 实现轮播图下面点dots的问题,怎么确定当前页,并且更改的点的样式
          在这里插入图片描述
          设置样式:class="{ active: currentPageIndex === index }"
          通过维护一个变量:currentPageIndex(默认值为0),实现轮播图和dots相对应
              // 轮播图每次在切换下一张图片,会触发一个scrollEnd事件
            this.slider.on('scrollEnd', () => { // better-scroll scrollEnd trigger
              let pageIndex = this.slider.getCurrentPage().pageX // pageIndex: stop 
              this.currentPageIndex = pageIndex
            })
          
        • 设置轮播图属性,例如:是否能循环播放,间隔等
        • 自动播放方法autoPlay实现,
          _autoPlay() {
                this.timer = setTimeout(() => {
                  this.slider.next();
                }, this.interval)
            }
          
    5. 出现问题:报错
      • 单纯报错
        原因:见src/base/slider和src/components/recommend组件
            因为我获取数据执行周期是created,而且是异步的
            数据渲染的执行周期是mounted
            但是此时可能还没有 得到数据,所以会报错
        解决:src/components/recommend中的slider组件,添加如下逻辑
            v-if="recommends.length"
        
      • 轮播到第一页就停止了
        原因:只调用了一次autoplay
        解决:在每次scrollEnd事件触发的时候就调用一次
            if (this.autoPlay) {
              clearTimeout(this.timer)
              this._autoPlay()
            }
        
      • 轮播图滑动不连续,轮播图小点不在图片下面,在整个页面最底部
        better-scroll源码有更新,现在的使用方法,和原来的有差异
        小点父元素position没有定位,相对的元素是body,所以在页面最底部 
        
      • 轮播图自动播放到最后一页就停了,
        新版api改变了,或者就继续用这种方法,或者用next,不过动画效果没有了
        
      • 手机端,电脑端切换时候轮播图宽度出现错误
        监听"窗口改变"事件,当改变时候重新计算宽度
        但是每次重新计算时候,_setSliderWidth中的width都会width += 2 * sliderWidth,这样是不对
        所以,我们在_setSliderWidth中传入一个参数,判断此时方法的执行是不是重新resize过来的,然后再重新渲染轮播图
        剩余详细见代码注释src/base/slider    
        
      • 在手机模式下,点击轮播图,不能进行页面跳转
        由于better-scroll传了一个参数是click为true
        然后它在better-scroll内部实现的时候,会阻止浏览器默认的click
        然后自己派发一个click,然后,它派发的click恰好又被fastClick这个库监听到
        ,然后fastClick又阻止了这个事件,导致click不能被执行,
        解决:就是删掉click:true,因为a链接本身就能实现跳转,所以不需要监听click
        
      • 当我们推荐页面切换到歌手页面,再切回来的时候,会出现闪烁的效果
        原因:我们每切换一次的话,都会重新发送一个数据请求,所有dom都会重新渲染,
        生命周期都会重新走一遍获取数据,然后重新初始化slider,体验不好,且没有必要
        解决:给App.vue中添加keep-alive,这样就可以将他们的dom都缓存到内存中
                这样就不会多发送请求,其次也不会有闪的效果
                <keep-alive>
                  <router-view></router-view>
                </keep-alive>
        keep-alive:https://segmentfault.com/a/1190000010546663
        
      • 当slider被切走的时候实际上会调用destroyed(),然后在这里把定时器等资源进行清理,有利于内存的释放

    歌单部分(src/components/recommend.vue包含歌单)

    在这里插入图片描述

    1. 数据的抓取见上面的jsonp
      • 在src/api/recommend.js中封装获取歌单数据的jsonp的方法
      • 在src/components/recommend.vue中调用歌单数据
      • 用这种方法报错
    2. 问题:用1的方法获取数据失败
      • 原因:
        因为有一个host和referer的限制,前端之间不能修改request-header的,通过后端代理的方式处理
        那么后端代理怎么做呢?
        
      • 解决:
        开发vue时会启动一个server,这个server就是nodejs气的devServer的逻辑
        那么需要我们手动代理这样的请求
            1. 在build/webpack.dev.conf.js下写服务器代理
                第一步
                    const express = require('express')
                    const app = express()
                    // 接口代理绕过主机和引用程序
                    // browser: XMLHttpRequst, node.js: http
                    const axios = require('axios')
                    var apiRoutes = express.Router()
                    // 最后一步,将api注册使用
                    app.use('/api', apiRoutes)
                第二步
                    在devServer中配置接口,因为直接请求会出错,referer和host不一样,不让请求
                    通过axios该改变headers之后带着浏览器发送过来的参数,重新发送请求
            2. src/api/recommend.js封装获取歌单数据的ajax的方法
            见webpack.dev.conf.js文件和src/api/recommend.js,有详细注释 
        
      • 补充:大公司是怎么防止我们抓取数据的呢?
        可以设置:获取数据时候带一个独有的签名,我才给你数据
    3. 数据的渲染,见src/components/recommend.vue,其实和轮播图渲染数据一样的
    4. 滚动部分的优化:
      • 想实现可滑动,但是总写很多次better-scroll很麻烦,单独封装成一个单独的组件
        那我就将可滑动的部分封装成一个单独的组件,然后将所有可滑动的部分都放scroll组件中去:src/base/scroll.vue
        better-scroll详细解释在src/base/slider.vue中有
        
      • 什么时候应该重新计算refresh
        1)在src/components/recommend.vue组件中,为什么向scroll组件中传的是discList,而不是recommends?
            因为轮播图接口获取的数据优先于歌单获取的数据,就是当歌单获取的discList,
            再调用refresh的时候轮播图部分高度已经撑开了,也就是说scroll是可以正确计算到高度的
            若是我们延迟1s获取轮播图数据,那么就是discList先获取到,然后计算完scroll的高度之后,recommends才获取到数据
            那么造成的问题就是,我们的歌单下面有一部分滚动不了,因为它把轮播图的高度算在了歌单scroll高度中
            那么歌单部分就有差不多轮播图高度的部分不能滑动
            那么说明,我refresh时机不对,不应该是watch到data改变之后refresh()2)那么什么时候重新计算滚动区高度也就是refresh呢?
            在图片加载完,将上面的部分撑起来了,这个时候我才refresh,
            但是我不需要等到所有图片加载完,只要一个图片撑开就可以了,
            所以设置一个变量记录this.checkloaded
        
    5. 图片懒加载:src/main.js和src/components/recommend.vue
      在这里插入图片描述
      官网:https://github.com/hilongjw/vue-lazyload
      1. 安装:npm i vue-lazyload
      2. src/main.js中引入组件
      3. 注册
          Vue.use(VueLazyload, {
            loading: require('common/image/default.png')
          })
      4. 图片路径不用src,用v-lazy
      5. 最好看一下lazyload源码
      
    6. 问题:src/components/recommend.vue
      better-scroll和fastclick冲突时候,可以利用class="needsclick"解决
      就是当fastclick监听到某个dom结点上的点击事件的时候,发现有class="needsclick"
      它就不会手动拦截这个过程,详细见recommend.vue
      
    7. loading组件,就是当歌单列表没有获取来之前,展示一个转圈的loading
      在这里插入图片描述
      见src/base/loading和src/components/recommend.vue
      
    8. 歌单样式:看看,很经典的flex布局

    歌手组件

    在这里插入图片描述

    1. 抓取歌手
      • 封装获取数据的jsonp的方法:src/api/singer.js(具体见代码注释)
      • 获取数据:在src/components/singer.vue中,具体见此文件中的注释
            data() {
                return {
                    singers: []
                }
            },
            created() {
                this._getSingerList() // get singer data
            },
            methods:{
                _getSingerList() {
                    getSingerList().then(res => {
                        if (res.code === ERR_OK) {
                          console.log(res.data.list);
                        }
                    })
                },
            }
        
    2. 数据处理:src/components/singer.vue
      • 将数据分类:
        1. 热门数据,(10条数据)
        2. 按字母分类的数据('A', 'B', 'C' ......)
        
      • 因为获取数据过程中,有很多重复的操作或者数据,所以需要优化:单独封装一个歌手类,见src/common/singer.js
      • 将数据格式化:将数据处理成我们需要的数据格式
        1. 先格式化为
            {
                hot:{
                    title:'hot'
                    item:[{},{},{}],
                },
                A:{
                    title:'A'
                    item:[{},{},{}],
                }
                ...
                ...
            }
        2. 然后:[{},{},{},{}]
            第一个对象是热门数据,后面依次按字母表排列的对象
        
    3. 数据渲染:
      将歌手列表单独封装成一个组件src/base/listview.vue,数据由父组件传递,就是上面格式化好的数据
      然后在src/components/singer.vue中引入组件
      

    快速入口组件,字母列表(src/base/listview.vue)

    在这里插入图片描述

    1. 根据title,得到字母集合
      computed: {
          shortcutList() { // ['A','B','C'...]
            return this.data.map(group => {
              return group.title.substr(0, 1)
          })
      },
      
    2. 绑定事件,当触摸字母列表时候可以实现一些功能
      • 点击某个字母,左侧可以直接定位到这个字母下的歌手名(详细见src/base/listview.vue)
        better-scroll本身有个api就是scrollToElement,可以滚动到某个元素 
        (1)先将每个字母绑定一个属性data-index,用来唯一标识字母
        (2)再给每个字母绑定方法,touchstart触发"onShortCutTouchStart"方法
        (3)"onShortCutTouchStart":获取当前字母data-index
            (封装了一个单独的类(src/common/js/dom.js)用来获取data-index)
        (4)scrollToElement跳到左侧这个索引指定位置
        
      • 指尖滑动时候,左侧也跟着一起滑动:通过计算pageY值
        记录手指开始的位置,和手指挪到的位置,中间差值除以每个字母设定的高度值
        然后跳到指定位置
        
      • 左右联动效果:左侧滚动,右侧字母表有高亮的效果
        在这里插入图片描述
        • 先在src/base/scroll/scroll.vue中添加相应属性及方法
              // 要不要监听滚动事件
              props: {
                  listenScroll: {
                    type: Boolean,
                    default: false
                  },
              },
              methods: {
                  _initScroll() {
                      if (this.listenScroll) {
                          const that = this
                          // 监听scroll的滚动事件,并且拿到位置,也就是事件的回调
                          this.scroll.on('scroll', pos => {
                            that.$emit('scroll', pos)
                          })
                      }
                  }
              }
          
        • src/base/listview中的相应组件传递给子组件值,并且获取子组件传来的值
          <scroll @scroll="scroll" :listenScroll="listenScroll"></scroll>
          然后大致思路就是根据手指的位置,计算当前在哪个字母下
          然后让右侧该字母高亮,详细将代码listview.vue
          
        • 当歌手列表滚动到最顶部的时候将滚动逻辑进行优化,src/base/listview.vue
           分为三种情况
              (1) 当滚动到最顶部的时候
              (2) 当滚动到中间部分
              (3) 当滚动到最底部的时候 
          
    3. 滚动固定标题实现src/base/listview.vue
      在这里插入图片描述
      • 实现
        <div class="list-fixed" ref="fixed">
            <!-- list-fixed使用了绝对定位,所以会定到那里-- >
          <h2 class="fixed-title">{{fixedTitle}}</h2>
        </div>   
        
        fixedTitle() {
          if (this.scrollY > 0) {
            return ''
          }
          return this.data[this.currentIndex] ? this.data[this.currentIndex].title : ''
        }
        
      • 问题:
        • 当我们滑动到最底部,又滑回到最顶部的时候,会出现两个热门的title,这样是不对的
          解决:当 当前滑动的scrollY为正数时,设置fixedTitle为'',
              然后上面的list-fixed结构,添加v-show,如果fixedTitle为空就不显示
          
        • 当滑动时候,两个fixedTitle相遇后的效果不好
          是下面那个慢慢上去,然后一点一点覆盖的,这样的体验是不好的
          解决:通过计算两个title的差值diff,
              当差值大于0小于title,就让上面的title偏移差值距离
              否则,什么都不变,
              就会实现下一个往上顶的效果
          
    4. 出现其他小问题和优化:
      • 手指滑动,左侧跟着动,但是滑动位置不对,索引相加的时候没有转化为整型,记得加上一个parseInt
      • 写字母高亮功能的时候,左侧计算出来的索引位置不对,因为src/base/scroll/scroll.vue中设置probeType:1,改为3就好了
      • 直接点击右侧字母表,并不会高亮
        因为直接点击并不会触发滚动事件,所以不会派发一个pos事件,我们这里手动设置
        this.scrollY = -this.listHeight[index],实现高亮
        
      • 点击字母表上下两个区块的时候是有问题的,是可以点击的,然而我们不想让那部分可点击
        所以加上判断,如果点击部分为null那么不做任何事情,返回
        if (!index && index !== 0) { // click on the blank
            return
        }
        
      • 当拖动时候,拖动到顶部,或者拖动到底部时候做一个边界处理
      • 整体思路:5-8的6:36部分重新捋了一下
      • 数据没有请求到的时候,有一个loading的效果
        (1)先引入import loading from 'base/loading/loading'
        (2)注册组件 components: {
                    loading
                },
        (3)如果没有数据,就显示这个组件
            <div class="loading-container" v-show="!data.length">
              <loading></loading>
            </div>
        
    5. 难点:左右联动
      根据左边滚动位置,计算在哪个group区间,然后就知道右侧对应哪个索引
      

    歌手详情页面组件src/components/singer-detail

    在这里插入图片描述

    1. 跳转路由至歌手详情页
      • 添加路由:先在router/index.js中引入,添加路由
                    import SingerDetail from 'components/singer-detail/singer-detail'
                    {
                        path: '/singer', 
                        component: Singer,
                        children: [
                          {
                          // 以id为变量,可以传入不同的id值,然后去渲染不同的歌手详情页
                          path: ':id',
                          component: SingerDetail,
                          }
                        ]
                    }
        
      • router-view:写在src/components/singer.vue中
      • 点击歌手跳转到详情页
        • 在src/base/listview.vue中添加事件
          当某个歌手被点击了,向外派发一个事件,告诉外部我被点击了,并且被点击的元素是什么
              selectItem(item) {
                this.$emit('select', item)
              },
          
        • 父组件src/components/singer.vue接受子组件传来的值,然后利用this.$router.push,跳转到该歌手页面
        • 路由跳转动画效果
          在src/components/singer-detail上给路由加上动画效果
          <transition name="slide">
              <div class="singer-detail"></div>
          </transition>
          
            .slide-enter-active,
            .slide-leave-active {
              transition: all .3s;
            }
            .slide-enter,
            .slide-leave-to {
              transform: translate3d(100%, 0, 0);
            }
          
    2. 使用vuex管理状态,将歌手存进去
      • 存数据src/components/singer.vue
        import {mapMutations} from 'vuex'
        ...mapMutations({
          setSinger: 'SET_SINGER'
        })
        
      • 取数据:src/components/singer-detail.vue
        import { mapGetters } from 'vuex'
        ...mapGetters('singer')
        
    3. 抓取数据
      • 利用jsonp抓取数据,见src/api/singer.js中getSingerDetail,有详细注释
      • 在src/components/singer-detail中获取数据
        (1)先引入
            import { getSingerDetail } from 'api/singer'
            import { ERR_OK } from 'api/config'
        (2)定义方法_getDetail,然后再created生命周期时候执行
        
      • 获取歌曲的vkey(只有得到vkey才能获取歌曲播放地址)
        1. 在build/webpack.dev.conf.js中配置获取vkey的接口,更改referer和host
        2. 在src/api/song.js中定义获取全部歌曲的方法getMusic,调用接口/api/music
        3. 在src/components/singer-detail中使用该方法
            获取每个歌曲的vkey放到song对象中,用于获取歌曲播放地址
            getMusic(musicData.songmid).then((res) => { // 这里需要先获取vkey
                if (res.code === ERR_OK) {
                  const svkey = res.data.items
                  const songVkey = svkey[0].vkey
                  const newSong = createSong(musicData, songVkey)            
                  ret.push(newSong)
                }
              })
        
      • 封装歌曲数据
        因为有很多组件都需要整个歌曲的数据,所以将歌曲的数据封装成一个单独的src/api/song.js文件,用来获取歌曲的数据
        见src/common/js/song.js有详细注释
        1. 先创建一个歌曲类Song
        2. 每次都需要new,然后传进去很多参数,很麻烦,所以,再新建一个方法createSong,在这里返回一个new的实例对象
            实际上这也是一种工厂方法模式,就是不直接new,是调用一个方法,返回一个实例对象
        3. 在src/components/singer-detail中使用
        

    歌手详情页面核心页面(可以复用):src/components/music-list.vue

    在这里插入图片描述

    1. 在singer-detail.vue中引入并传入数据
      import MusicList from 'components/music-list/music-list'
      <music-list :songs="songs" :title="title" :bg-image="bgImage"></music-list>
       components: {
          MusicList
        },
      
    2. 写dom结构,完善歌手详情页面
    3. 将显示歌曲列表的部分封装成一个单独的组件src/base/song-list.vue,因为有很多地方都需要用
    4. 还有一点就是,把这个歌曲列表放到scroll组件中,因为歌单列表也是需要滚动效果的
    5. 在滑动歌曲列表时,想要实现在手指往上滑的时候,歌曲列表是要适当覆盖最上面歌手图片的
      在这里插入图片描述
      (1) 先将scroll组件样式中的overflow: hidden去掉
      (2) 只滚上去是不够的,要实现当手指往上滑的时候,字的后面背景部分(不是图片,是灰色背景)也要一起向上滚
          在scroll组件上面放一个div,然后监听滚动距离,然后让这个div和字一起滚动
          <div class="bg-layer" ref="layer"></div>
          this.minTransalteY = -this.imageHeight
          scrollY(newVal) {
              // newVal:整个滑动部分相对于初始位置的偏移
              //        手指往上滑为负,往下滑为正
              //        滑动到最底部,pos值为"负值绝对值"最大的时候
              const translateY = Math.max(this.minTransalteY, newVal)
              this.$refs.layer.style[transform] = `translate3d(0,${translateY}px,0)`
          }
      
    6. 但是,当向上滑了一段距离之后,就出现问题了
      因为bg-layer高度设置的是100%,也就是屏幕高度
      所以当手指滑动过屏幕高这么多距离之后,这个bg-layer就滑走了,就没有背景颜色显示了
      将最上方图片的高度记录下来,然后设置bg-layer最多滚动的距离不超过图片的高度
      代码与上面一样,不过minTransalteY的值进行了更改
          this.minTransalteY = -this.imageHeight + RESERVED_HEIGHT
      
    7. 但是我们要实现这样的效果:就是不让歌曲列表滚动到顶部,顶部预留一些地方来
      那么只要调整bg-layer背景的最多偏移量就可以了,就是设置最多偏移量小一点
      this.minTransalteY = -this.imageHeight + RESERVED_HEIGHT// bg-layer minTransalteY
      
    8. 当向上滚动的时候,顶部的歌词名字覆盖在了图片的上面,我想要实现的效果是图片覆盖在字上面
      在这里插入图片描述
      并且更改图片的zIndex值和paddingTop值和width值,因为图片的大小我是通过设置宽高比的,见该页面的scss文件
      如果没滚动到顶部的时候,一切恢复原样(图片的高度),就是将图片的z-index值和height等值改回来
      
      if (newVal < this.minTransalteY) { // scroll to top滚动到顶部
          zIndex = 10
          this.$refs.bgImage.style.paddingTop = 0
          this.$refs.bgImage.style.height = `${RESERVED_HEIGHT}px`
        } else {
          this.$refs.bgImage.style.paddingTop = '70%'
          this.$refs.bgImage.style.height = 0
        }
        this.$refs.bgImage.style.zIndex = zIndex
      
    9. 当往底下拉的时候,我想要实现图片跟随者放大或者缩小的效果
      在这里插入图片描述
      当往底下拉的时候,通过percent = newY/this.imageHeight来设置图片放大的比例
      当new>0的时候,设置放大比例scale为scale = 1 + percent,并且zIndex为10,
      这样保证图片放大的时候,歌单列表不会覆盖图片
      为什么是这个比例呢?因为当往下拉的时候,以这个比例,图片增加的高度就是newY的大小
      
      const percent = Math.abs(newVal / this.imageHeight)
        if (newVal > 0) { // scroll down
          scale = 1 + percent
          // 如果不设置zIndex = 10,那么当向下拉的时候,歌曲列表会覆盖图片
          zIndex = 10
        }
        this.$refs.bgImage.style[transform] = `scale(${scale})`      
      
    10. 为什么图片放大的时候总是从头部开始放大,因为bg-image设置了transform-origin为top,所以图片放大的时候总是从头部开始放大
    11. 当我们手指往上滑的过程中,想要实现图片的模糊效果,并且,越往上,就越模糊
      设置一个属性blur,代表模糊的程度
      通过css3来设置模糊效果:this.$refs.filter.style[backdrop] = `blur(${blur}px)`
      
      const percent = Math.abs(newVal / this.imageHeight)
      if (newVal > 0) { // scroll down
          ...
          ...
        } else {
          //模糊程度最大是20
          blur = Math.min(percent * 20, 20)
        }
      
    12. 因为不同浏览器前缀不一样,js中怎么自动加上呢?
      css中不用写浏览器前缀的原因是因为,vue-loader用到了autoprefixer插件,会帮我们自动补全前缀
      但是js中就不会了,就需要我们自己判断,那么我们可以封装一个方法来判断浏览器前缀是什么
      见src/common/js/dom.js文件
      
    13. 随机播放全部按钮
      在这里插入图片描述
      首先要等得到歌曲列表数据之后才显示
      其次,当我们手指向上滑动的过程中,因为按钮设置的位置是bottom为20,所以当图片高度改变了,按钮位置也改变了,但是我们要的效果是:
      当手指滑动到最顶部的时候,整个按钮是消失的,所以
          // 设置随机播放全部的按钮,在滑动到最顶端时候消失
          this.$refs.playBtn.style.display = 'none'
          // 设置随机播放全部的按钮在正常情况下,display = '',让它正常显示
          this.$refs.playBtn.style.display = ''
      
    14. 在没有获取到数据的时候显示loading
      在这里插入图片描述
      (1)引入import Loading from 'base/loading/loading'
      (2)注册组件
          components: {
              Loading
            },
      (3)在组件中应用
          <div v-show="!songs.length" class="loading-container">s
              <loading></loading>
          </div>
      

    显示歌曲列表的组件src/base/song-list.vue(会在music-list中使用)

    在这里插入图片描述

    1. 很简单,就是从引入它的父组件获得歌曲列表所有值,然后在组件中进行渲染
    2. 问题:src/components/music-list中歌曲列表显示位置不对,直接跑到了最顶部
      原因:scroll没有正确计算高度,
      解决:将上面歌手背景图片的高度计算出来,然后让显示歌曲列表组件的top设置成图片高度
      

    播放器页面src/components/player/player.vue(App.vue中引入)

    在这里插入图片描述

    1. 数据的存放:考虑多个组件都可以操作播放器,所以控制播放器的数据一定是个全局的,所以通过vuex来管理
      src/store/state.js              定义和组件相关的,最底层的数据
      src/store/getter.js             对数据的映射,可以是一个函数,函数类似计算属性,根据state.js中的值计算出新的值
      src/store/mutation-types.js     见代码,理清和mutations.js的关系
      src/store/mutations.js          定义数据修改的逻辑,但是定义mutations之前,先定义mutation-types
      
    2. 显示播放器组件的流程:src/components/player/player(见代码)
      • 在app.vue下引用src/components/player/player
        因为不是和路由相关的组件,切换到任何组件都不影响播放
      • 在app.vue下不能默认展示player.vue这个组件,要在src/components/player/player中引入vuex,
        通过取vuex中的变量playlist,计算长度length,来控制player组件的显示,不能默认显示
          import { mapGetters } from 'vuex'
          ...mapGetters([
              // 播放列表,当playlist的length>0之后才能显示播放器页面
              'playlist',
          ]),
          <div class="player" v-show="playlist.length>0">
        
      • 因为点击歌曲,才会触发这个组件,所以src/base/song.list组件要添加部分事件逻辑
        当点击歌曲时候,触发事件selectItem,然后这个函数将触发的事件和当前歌曲索引传递给父组件src/music-list/music-list.vue
        selectItem(item, index) {
          this.$emit('select', item, index)
        },
        
        • 为什么传递索引呢:因为播放歌曲列表的时候,要从当前歌曲开始播放,所以要获取索引
      • 当点击歌曲列表的某首歌的时候要触发
        • 设置playlist和sequencelist
        • 根据点击的索引设置currentlist
        • 设置playing,设置歌曲播放还是暂停的状态,因为点击的时候,歌曲要播放,
        • 默认展开大的播放器,设置fullScreen
        • 注意:因为要改变很多state,所以封装一个action(见src/store/action.js)
      • 因为要改变state、索引,要使用action,所以要在src/components/music-list.vue中引入mapActions
        import { mapActions } from 'vuex'
        ...mapActions(['selectPlay']),
        selectItem(item, index) {
          this.selectPlay({
            list: this.songs,
            index
          })
          // console.log(this.currentIndex);
        },
        
      • 总结流程:
        • 点击歌曲(src/base/song-list)
        • 提交action(src/components/music-list)
        • 提交mutation(src/store/action.js)修改state
        • playlist.length > 0(src/components/player.vue)
        • 然后显示player.vue组件
    3. 给播放器添加交互动画和样式
      • 样式1(player.vue):
        当切换展开的播放器或者缩小的播放器时有动画效果,样式详见player.scss
        • 给展开的播放器添加transition样式为normal
        • 给缩小版的播放器添加transition样式为mini
      • 样式2 (player.vue):
        当由缩小版的播放器切换为展开版的播放器时,左下的CD封面也会由小拉到大的 动画
        在这里插入图片描述
        我们要通过js方式获取css动画,所以引入了create-keyframe-animation插件
        因为设置动画需要获取参数,我们要定义的_getPosAndScale函数来获得相应参数
        在enter中设置动画
        
      • 样式3(player.vue):
        当由放大版的的播放器切换为缩的播放器时,CD封面从中间移到左下角的实现
        不过没有样式2那种动画,是直接用js操作css样式实现的
        因为设置动画需要获取参数,我们要定义的_getPosAndScale函数
        来获得相应参数在leave中设置动画
      • 样式4(player.scss):
        当别的页面切换为大的播放器页面的时候动画效果
        • 效果1:背景有一个渐隐渐现的效果,纯利用css3,transition实现的
        • 效果2:头部和底部又一个回弹的效果,这个效果是利用贝塞尔曲线cubic-bezier实现的
          在这里插入图片描述
      • 给样式填充数据
        • 填充数据
          • 唱片图片:src=“currentSong.image”
          • 歌曲名: v-html=“currentSong.name”
          • 歌曲名称: v-html=“currentSong.singer”
        • 添加简单方法
          • 点击右上角的按钮实现返回:添加back方法,back方法中用Vuex的mutations设置fullScreen为false
          • 点击小型播放器后显示大的播放器:在小型播放器样式上绑定@click=“open”,open方法中设置fullScreen为true
    4. 播放功能:利用html5的audio实现
      在这里插入图片描述
      • 添加audio标签,当currentSong改变时候play方法执行

        • 错误:
          <audio ref="audio" :src="currentSong.url"></audio>
          watch: {
              currentSong(newSong, oldSong) { 
                  this.$refs.audio.play()//只写这一句是会报错的
              }
          }
          报错:The play() request was interrupted by a new request
          
        • 原因:dom异常,这时候调用play时候,我们同时请求src是不可以的,这个dom还没有ready
        • 解决:我们设置一个延迟
          // 设置一个延迟
          this.$nextTick(() => {
            this.$refs.audio.play()
          })
          
      • 设置歌曲播放和暂停:

        (1)class名为i-center的dom元素上绑定方法togglePlaying,切换暂停或者播放状态
        (2)togglePlaying() { 
            this.setPlayingState(!this.playing)
            //setPlayingState: 获取更改state中playing的mutations方法
            //playing:这个playing就是通过getters从vuex state获取的playing
            }
        (3)仅通过改变状态是不能控制歌曲的播放或者暂停的
          我们可以watch这个playing,然后触发audio的play或者pause方法
          但是因为我点击歌曲的时候,就会触发watch的playing属性
          然后就会执行play,所以还是会出现上面的问题,解决方法也是一样,设置一个延迟
        
      • 播放或着暂停的圆圈圈样式改变
        在这里插入图片描述

            1. 添加计算属性
                 playIcon() {
                    return this.playing ? 'icon-pause' : 'icon-play'
                 },
            2. 添加样式<i :class="playIcon" @click="togglePlaying"></i>
        
      • 小的播放器图标的改变:miniIcon 同理 大播放器
        在这里插入图片描述

        注意:当点击小的播放器播放按钮的时候,会产生冒泡事件,需要阻止冒泡事件
                <i class="icon-mini" :class="miniIcon" @click.stop="togglePlaying"></i>
        
      • 播放时候CD封面转圈,停止的时候CD封面停止

        在这里插入图片描述

        • 样式:
          &.play {
            animation: rotate 20s linear infinite;
          }
          &.pause {
            animation-play-state: paused;
          }
          @keyframes rotate {
            0% {
              transform: rotate(0);
            }
          
            100% {
              transform: rotate(360deg);
            }
          }
          
        • DOM中添加样式:
          	<img class="image" :src="currentSong.image" :class="cdcls"/>
          
        • 事件逻辑:
           cdCls() {
            return this.playing ? 'play' : 'play pause'
           },
          
        • mini播放器同理
      • 点击播放下一首或者上一首歌曲
        因为我们在state中记录了当前歌曲的索引,并且我们也有当前歌曲列表
        所以向上或者向下只要改变索引就可以了

        (1)给播放下一首歌曲按钮绑定事件(播放上一首歌曲按钮同理)
            <div class="icon i-right">
              <i @click="next" class="icon-next"></i>
            </div>
        (2)事件逻辑
            next() {
                let index = this.currentIndex + 1
                // 如果当前歌曲是最后一首歌
                if (index === this.playlist.length) {
                  index = 0
                }
                this.setCurrentIndex(index)
              this.songReady = false
            },
        
        • 问题1:点击按钮(播放上一首)后,发现歌曲虽然跳到了下一首,但是图标并没有,图标还是处于暂停状态
          • 解决:在next方法下添加如下语句
            if (!this.playing) {
              this.togglePlaying()
            }
            
        • 问题2:不停点击下一首那个按钮,会报问题1的错误(pre同理)
          • 解决:查看audio官方文档,发现audio标签会派发两个事件,一个是canplay 和 error
            (1)<audio ref="audio" :src="currentSong.url" @canplay="ready" @error="error">
            (2)所以根据这个,我们data中设置一个标志位,
                data() {
                    return {
                      songReady: false,
                      }
                  }
            (3)然后
                ready() {
                  this.songReady = true
                  this.savePlayHistory(this.currentSong)
                },
            (4)然后next
                next(){
                    if (!this.songReady) {
                        return
                    }
                    // 点击之后this.songReady = false,确保下一首歌曲准备好时 才可以点击也就是ready时候
                    this.songReady = false
                }
            (5)容错处理:因为只有ready的时候才会触发this.songReady = true,
                如果报错就会阻止继续播放,所以我们这里有一个容错处理
                当前歌曲播放不出来的话也可以实现点击下一首的功能
                事件:
                error(){
                    this.songReady = true
                }
                样式也要体现:当 当前歌曲出错的时候,图标有一个变灰的样式
                <div class="icon i-left" :class="disableCls">
                  <i @click="pre" class="icon-prev" ></i>
                </div>
            
    5. 大的播放器底部进度条src/base/progress-bar/progress-bar.vue:
      在这里插入图片描述
      • 获取当前播放时间和总时间
        (1)设置标签实现进度条<div class="progress-wrapper">
        (2)因为当audio标签中歌曲播放的时候会派发一个事件就是@timeupdate="updateTime"
        (3)updateTime(e) {
          // update会传进来一个e的事件,这个事件有一个target属性就是audio标签
          // 这个audio还有一个可以获取到当前播放时间的属性就是currenTime,
          // 这个currentTime是一个时间戳的形式,是可读写属性
          this.currentTime = e.target.currentTime // <audio> current time
        },
        (4)编写一个函数format,将currentTime格式化为分和秒的形式
            // 给时间格式化的函数
            format(interval) {
              //interval|0就是一个正数的向下取整
              interval = interval | 0  // | 0: math.floor
              const minute = (interval / 60) | 0
              // 当前播放时长我们要显示0.06不是0.6,也就是当余数为个位数的时候,需要一个补0的函数pad
              const second = this._pad(interval % 60) // _pad: Use 0 to fill 2 bits
              return `${minute}:${second}`
            },
        (5)_pad(num, n = 2) { // _pad: use 0 to fill 2 bits
                // 获取字符串长度
              let len = num.toString().length
              while (len < n) {
                num = '0' + num
                len++
              }
              return num
            },
        
      • 设置单独的组件显示进度条,接受父组件src/components/player/player.vue传过来的百分比的值
        (1)根据百分比,求走过的距离,然后设置样式
        (2)根据百分比,用transform设置小球的偏移
        
      • 拖拽或者点击,小球实现歌曲的实时播放
        @touchstart.prevent="progressTouchStart"
        @touchmove.prevent="progressTouchMove"
        @touchend="progressTouchEnd"
        progressTouchStart(){
            用一个变量init表示touch已经初始化了
            记录touch的开始位置
            记录当前进度条的的偏移距离
        }
        progressTouchMove(e) {
            手指移动的距离
            最初的位置加上手指移动的距离,不能比0小,不能比进度条整个宽度大
            设置进度条和小球偏移到该位置
        },
        progressTouchEnd(){
            设置init变量为false,表示结束,不能操作了
        }
        点击同理progressClick(e)
        
        • 进度条的小球拖拽过程中,进度条会出现跳的情况
          • 原因:当拖拽过程中,有两个事件改变进度条,
            一个是拖拽事件,一个是歌曲播放事件
            所以我们要设置拖拽过程中,拖拽事件权重更大一点
          • 解决:if (newPercent >= 0 && !this.touch.init)
            当没有拖拽事件的时候,才设置歌曲播放改变进度条
      1. 当拖拽完之后,进度条虽然同步了,但是已播放秒数还是没有改变
        拖拽完之后,重新计算进度条的百分比,然后向父组件派发一个事件
        父组件player监听percent的改变,然后改变audio的currentTime,然后进一步改变秒数
        子组件:
            progressTouchEnd() {
              this._triggerPercent()
            },
            _triggerPercent() {
              const progressBarWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
              const percent = this.$refs.progress.clientWidth / progressBarWidth 
              this.$emit('percentChange', percent)
            },
        父组件:
            onProgressBarChange(percent) {
                  const currentTime = this.currentSong.duration * percent
                  this.$refs.audio.currentTime = currentTime
                  // 拖动完之后还要继续播放
                  if (!this.playing) {
                    this.togglePlaying()
                 }
             },
        
      • 小的播放器转圈圈的播放进度条:src/base/progress-circle/progress-circle.vue:
        在这里插入图片描述
        引入:import ProgressCircle from 'base/progress-circle/progress-circle'
        注册:
            components: {
                ProgressCircle,
            },
        dom中使用
              <progress-circle :radius="radius" :percent="percent">
                <i class="icon-mini" :class="miniIcon" @click.stop="togglePlaying"></i>
              </progress-circle>
            这个i标签会在progress-circle组件中的slot标签中生效
        progress-circle里面放置了两个circle,一个是默认全部灰度圆,一个是设置偏移量,剩下部分高亮
        
    6. 播放模式
      在这里插入图片描述
      • 样式的改变:
        从state中引入mode变量,根据mode给dom结构增加样式,
        点击切换模式时,引入mutations改变mode
        changeMode(){
           const mode = (this.mode + 1) % 3
           this.setPlayMode(mode)
         },
        
      • 播放列表的改变
        (1)如果是随机播放,需要将列表打乱,也就是洗牌,那么封装一个函数(src/common/js/util.js)完成洗牌
        (2)引入setPlayList:'SET_PLAY_LIST',为了改变当前播放列表
            如果是随机播放则将打乱(shuffle函数)的歌曲列表赋给playList
            如果不是随机播放,那么playList就等于sequenceList
        (3)因为当前歌曲是根据歌曲列表和索引设置的,所以如果歌曲列表改变的话,
            当前播放的歌曲也可能发生变化,所以我们要重新计算currentIndex
            resetCurrentIndex(list){
              let index = list.findIndex((item) => {
                return item.id == this.currentSong.id
              })
              this.setCurrentIndex = index;
            }
        
        • 问题: 我把歌曲暂停了之后,点击切换播放模式,会发生歌曲还是继续播放的情况
          • 原因:当我切换播放模式之后,因为重新计算歌曲index,也算是改变了currentSong,所以就会被watch监听到,就会继续播放歌曲
          • 解决:
            watch: {
                currentSong(newSong, oldSong) {
                    if (newSong.id === oldSong.id) {
                        return
                    }
                }
            }
            
      • 当前歌曲播放完了之后,如果是循环模式就继续播放当前歌曲,如果不是那就播放下一首
        audio标签:ended="end": 歌曲播放结束了会派发一个事件
        end() {
          if (this.mode === playMode.loop) {
            // 如果是单曲循环那就调用loop函数
            this.loop()
          } else {
            // 否则(顺序或者随机)就直接跳到下一首
            this.next()
          }
        },
        loop() {
          this.$refs.audio.currentTime = 0
          this.$refs.audio.play()
        },
        
      • 随机播放全部按钮实现:src/components/music-list.vue
        1. 给按钮绑定事件:random
            random() {
              this.randomPlay({
                list: this.songs
              })
            }
        2. 整个randomPlay是从action.js(src/store/action.js)中获取的
        
        • 当我点击随机播放了之后,再去点击歌曲列表,就会出现,点击的歌曲和实际播放的歌曲不一样
          • 原因:
            当我点击随机播放全部了之后,此时playList列表已经变为了随机播放列表,原本是顺序列表的时候,playList和currentIndex确定了当前播放的歌曲,
            但是现在playList已经变为了随机播放列表,所以不能获取到正确的歌曲
            
          • 解决: 在actions的selectPlay中添加逻辑:
            if (state.mode === playMode.random) {
                const randomList = shuffle(list)    //经过shuffle后不改变原数组
                commit(types.SET_PLAYLIST, randomList)
                index = findIndex(randomList, list[index])
              } else {
                commit(types.SET_PLAYLIST, list)
            }
            
    7. 歌词(大部分在src/components/player.vue中完成)
      在这里插入图片描述
      • 获取歌词数据
        (1)定义获取歌词数据接口src/api/song.js --->getLyric
        (2)在webpack.dev.conf.js中定义路由api/lyric,因为在src/api/song.js中是通过api/lyric来获取数据的
            注意:因为得到的好像还是一个jsonp形式的数据,所以我们这里要做一个小小的处理
        (3)在src/common/js/song.js中调用getLyric,把歌词和歌曲的其他数据都封装在一个song对象中,因为他们都是歌曲整体的一部分
        (4)需要将数据解码,所以 利用插件base64
            src/common/js/song.js中引入import { Base64 } from 'js-base64'
           将得到的lyric解码
            this.lyric = Base64.decode(res.lyric)
        (5)因为得到的是各一个很长的字符串,我们需要转化为我们需要的格式,所以引入插件lyric-parser
            在player.vue中解码
            引入:import Lyric from 'lyric-parser'
            将歌词格式化,并且设置处理函数(this.handleLyric)this.currentSong.getLyric().then(lyric => {
              this.currentLyric = new Lyric(lyric, this.handleLyric)
            })
        
      • 设置歌词高亮
        (1) 因为当歌词每一行发生"改变"时,会触发this.handleLyric这个方法,
            因为我们解析到的歌词是有很多数据的,包括每行歌词的开始时间,
            所以说这里的改变是指随着时间改变,歌词的改变
            这个方法handleLyric({ lineNum, txt }) 传入两个参数
                // lineNum:当前行
                // txt:当前行的歌词
        (2)设置一个变量记录当前行this.currentLineNum
                this.currentLineNum = lineNum
        (3)在dom中遍历并显示所有歌词,如果遍历到的歌词等于当前行歌词,那就设置样式高亮显示
            <p 
                v-for="(line,index) in currentLyric.lines" 
                :key="index" 
                :class="{'current': currentLineNum === index}"
            >
            {{line.txt}}
            </p>
        
      • 设置歌词可以滚动
        引入scroll组件,然后传入数据:data="currentLyric && currentLyric.lines"
        传入数据目的就是确保歌词数据存在,并且当歌词数据改变的时候调用scroll的refresh方法
        
      • 保证当前歌词总在中间的位置上
        handleLyric({ lineNum, txt }) {
          this.currentLineNum = lineNum
          if (lineNum > 5) {
            const lineEl = this.$refs.lyricLine[lineNum - 5]
            // 滚动到指定行,时间为1秒
            this.$refs.lyricList.scrollToElement(lineEl, 1000)
          } else {
            // 滚动到指定位置,时间为1秒
            this.$refs.lyricList.scrollTo(0, 0, 1000)
          }
          // this.playingLyric = txt
        },
        
      • 歌词和唱片左右滑动效果
        在这里插入图片描述
        (1)先设置左右滑动的时候,底部小圆圈的样式
            <span class="dot" :class="{'active':currentShow==='cd'}"></span>
            <span class="dot" :class="{'active':currentShow==='lyric'}"></span>
        (2)CD页面,往左滑时候,歌词列表页面可以滚过来,然后CD页面有渐隐的效果
            先绑定事件,设定变量
                在.middle"上绑定三个事件
                    @touchstart.prevent="middleTouchStart"
                    @touchmove.prevent="middleTouchMove"
                    @touchend="middleTouchEnd"
                设置变量touch = {},关联touchdown和touchmove
            事件逻辑:就那三个函数,自己看吧
                包括:左滑右滑dom元素位置的变化 offsetWidth
                      中间动画的效果           duration
                      dom元素渐隐渐现的的效果   opcity
        
      • 当拖动进度条的时候,歌词部分高亮部分也要相应改变
            // 歌曲拖动的时候,歌词高亮部分也相应改变
            onProgressBarChange(percent) {
                if (this.currentLyric) {
                    // currentTime:是秒形式的
                    this.currentLyric.seek(currentTime * 1000)
                }
              }
        
        • 问题1:当切换很多歌曲的时候,会出现高亮部分一直在跳
          • 原因:开启了多个计时器,没有关掉
          • 解决:当监听到currentSong改变了之后,我们要关掉当前歌词的计时器
            if (this.currentLyric) {
                this.currentLyric.stop()
            }
            
        • 问题2:当歌曲暂停的时候歌词并没有暂停,还在继续走
          • 解决:
            当togg随机播放lePlaying的时候,将歌词也暂停
            if (this.currentLyric) { 
                // 将歌词暂停
                this.currentLyric.togglePlay()
            }
            
        • 问题3 循环播放的时候,歌曲播放完了,歌词并没有回到最开始
          • 解决:
            loop() {
              if (this.currentLyric) {
                // 将歌词偏移到最开始
                this.currentLyric.seek(0) // <audio>, song jump to begin
              }
            },
            
      • 在CD封面下面会显示当前歌词这一行
        在这里插入图片描述
            创建显示结构
                <div class="playing-lyric-wrapper">
                  <div class="playing-lyric">{{playingLyric}}</div>
                </div>
            当歌词改变时候,给playingLyric赋值
                handleLyric({ lineNum, txt }) {
                    this.playingLyric = txt
                }
        
      • 当获取不到歌词,做容错处理
         this.currentSong.getLyric().then(lyric => {
            ......
        })
        // 当获取不到歌词的时候,变量都清空
        .catch(() => {
          this.currentLyric = null
          this.playingLyric = ''
          this.currentLineNum = 0
        })
        
    8. 其他问题
      • 获取播放源错误:https://blog.csdn.net/a151913232/article/details/85034283+ 当只获取到一首歌的时候index = 0,我们点击next时候,下面这行语句执行
        let index = this.currentIndex + 1       //index = 1
        if (index === this.playlist.length) {
          index = 0                             //index = 0
        }
        也就是index的值并没有变,所以,watch监听到的currentSong也没变,也就是往后的语句都不会
        
        • 执行解决:在next()和prev()上加个逻辑判断,当只获取到一首歌的时候,调用loop函数,转为单曲循环模式
      • 当程序在微信端运行的时候,js是不执行的,所以audio只会把当前歌曲播放完,之后就不执行了
        // 不用$nextTick,而是用setTimeout,这样就保证了我们微信从后台切到前台的时候,我们的歌曲又可以重新播放了
        setTimeout(() => {
            this.$refs.audio.play()//只写这一句是会报错的,因为调用play时候,我们同时请求src是不可以的,这个dom还没有ready
            this.getLyric()
          }, 1000)
        
      • 底部mini播放器会占据正常播放列表页面最后一行
        就比如说,薛之谦所有音乐,最后一个是"演员",
        整个mini播放器就会把演员列表那一行遮挡
        • 解决:将解决问题的逻辑封装成一个单独的文件src/common/js/mixin.js
          关于mixin:https://www.jianshu.com/p/f34863f2eb6d
        • 思路:当playlist中有数据的时候,就将滚动组件的bottom设置为60px
          实现三部曲:
              先引入:import { playlistMixin } from 'common/js/mixin'
              再注册:export default {
                          mixins: [playlistMixin],
                      }
              实现handlePlaylist方法
              handlePlaylist(playlist) {
                // 当playlist中有数据的时候,就将滚动组件的bottom设置为60px
                const bottom = playlist.length > 0 ? '60px' : ''
                this.$refs.list.$el.style.bottom = bottom
                // console.log(this.$refs.list, '-----------')
                this.$refs.list.refresh()
              },
          
        • 具体实现见:
          src/common/js/mixin.js
          src/components/music-list
          src/components/singer
          src/base/listview
          src/components/recommend
          

    歌单详情页src/components/disc.vue(类似歌手详情页)

    在这里插入图片描述

    1. 定义二级路由
      • 定义在router/index.js的recommend的children中
      • 在recommend.vue中引入路由组件router-view
    2. 点击歌单路由跳转:src/components/recommend.vue
      • 点击li触发:@click=“selectItem(item)” selectItem(item)
      • 路由跳转:
         // 点击li,带参数的路由跳转
        selectItem(item) {
            this.$router.push({
                path: `/recommend/${item.dissid}`
            })
        }
        
    3. 歌单部分数据的存放和获取
      • 在vuex中的state,mutations,mutations-type中都设置有关歌单的变量
      • 在组件中使用和更改vuex中的数据
        src/components/recommend.vue中,引入mutations
            在src/components/recommend.vue中利用this.setDisc(item)将vuex中的disc值设置为当前点击的li
            然后利用getters得到
        src/components/disc.vue中,引入getters
            从vuex中获取当前歌单的标题,背景图等,传入到music-list组件中
        
    4. 从qq音乐抓取每个歌单全部歌曲
        1. 在build/webpack.dev.conf.js中配置获取所有歌曲的接口,更改referer和host
        2. 在src/api/recommend.js中定义获取全部歌曲的方法getSongList,调用接口/api/getSongList
        2. 在src/components/disc.vue中使用该方法
            getSongList(this.disc.dissid).then(res => {
                if (res.code === ERR_OK) {
                  this.songs = this._normalizeSongs(res.cdlist[0].songlist)
                }
              })
        3. 获取到songs之后,将songs传入到子组件music-list
        ```
      
    5. 问题:当刷新了之后,暂时获取不到数据,不能一直在这个页面等着,所以设置跳转到父组件
        _getSongList() {
            if (!this.disc.dissid) {
                this.$router.push('/recommend')
                return
            }
        },
      
      

    所有排行榜页面src/components/rank/rank.vue

    在这里插入图片描述

    1. 抓取数据src/api/rank.js(rank.vue中有具体实现,一看应该就能懂)
    2. 在页面内获取数据并使用(rank.vue中有具体实现,一看应该就能懂)
    3. 设置组件可以滚动(rank.vue中有具体实现,一看应该就能懂)
      需要把数据传递给scroll,当数据全部存在时候,才能正确判断可滑动部分的高度
      
    4. 数据未加载时候,显示一个转圈圈的图标loading
    5. 当底部有mini播放器占位的时候,需要处理底部高度:使用mixin.js

    对于数据获取的总结:

    1. json
      如果获取数据的接口要求传入的的参数中format:json,那么就用axios配合更改referer和host的方式
      先在webpack.dev.conf.js中
          定义接口改变referer和host,
          并且 获取来自 请求这个接口时 传过来的数据,
          然后再从qq音乐上获取数据
          由src/api/....js文件中的方法取得最终获取的数据
      
    2. jsonp
      直接在src/api下的js文件中定义获取数据方法
      然后通过jsonp将所有请求数据连在一起 并请求网页
      然后src/api/js文件中的方法 中得到jsonp取得的数据
      
    3. 注意:如果需要获取播放源的,需要先引入获取vkey的函数,在获取歌曲

    排行榜详情页面src/components/top-list.vue(和disc.vue很像很像)

    在这里插入图片描述

    1. 路由
      • 编写路由:
            是一个二级路由,在route.js中的rank下面定义
            children: [
              {
              // 以id为变量,可以传入不同的id值,然后去渲染不同的歌手详情页
              path: ':id',
              component: TopList,
              }
            ]
        
      • 跳转路由src/components/rank/rank.vue
        给遍历排行榜的li添加点击事件@click="selectItem(item)"
            selectItem(item) {
              this.$router.push({
                path: `/rank/${item.id}`
              })
              this.setTopList(item)
            },
        
      • 添加在src/components/rank/rank.vue中
    2. 关于排行榜内的所有歌曲数据
      • 在src/api/rank.js中获取数据
      • src/components/top-list下使用数据
      • 将获取的song传进music-list,然后设置没有数据时候(this.$route.push)默认返回上一级父元素
    3. 设置榜单样式src/base/song-list.vue
      • 设置变量,默认rank为false,代表默认没有排行的样式
        <div class="rank" v-show="rank">
          <span :class="getRankCls(index)">{{getRankText(index)}}</span>
        </div>
        props: {
            rank: {
              type: Boolean,
              default: false 
            }
        }
        
    4. 设置事件改变样式,当排行在前三名时显示图片,往后显示数字
      在这里插入图片描述
      // 和排行榜有关的样式
      getRankCls(index) {
        // 前三名,是图片的样式
        if (index <= 2) {
          // 在scss文件中有对应的样式
          return `icon icon${index}`
        } else {
          // 否则就是文字样式,文字的实现在getRankText(index)中
          return 'text'
        }
      },
      getRankText(index) {
        if (index > 2) {
          //因为排名是从1开始的,不是索引0开始的
          return index + 1
        }
      }
      
    5. rank的值
      • top-list引入music-list
      • music-list引入rank
      • 所以由top-list传入rank值为true

    搜索页面src/components/search.vue

    搜索框组件src/base/search-box.vue(在src/components/search.vue中引用)

    在这里插入图片描述

    1. 默认搜索框中的内容是"搜索歌曲、歌手",但是可以更改,所以在src/base/search-box.vue设置
      <input ref="query" class="box" :placeholder="placeholder"/>
      props: {
          placeholder: {
            type: String,
            default: '搜索歌曲、歌手'
          }
      },
      
    2. 获取来自搜索框中输入的内容
      <input ref="query" class="box" :placeholder="placeholder" v-model="query"/>
      data() {
          // 获取来自输入搜索框中的内容,利用双向绑定v-model
          return {
            query: ''
          }
      },
      
    3. 通过是否输入内容,控制输入框后面的"×"图标是否显示,当输入内容不为空的时候才显示
      <i class="icon-dismiss" v-show="query"></i>
    4. 点击×图标的时候,输入框中的内容为空
      <i class="icon-dismiss" v-show="query" @click="clear"></i>
      clear() {
        this.query = ''
      },
      
    5. 监听query的改变,传递给父元素
      created() {
          // 这种写法和直接在下面写watch差不多
          this.$watch('query', debounce(newQuery => {
              this.$emit('query', newQuery)
            }, 200)
          )
      },
      

    热门搜索

    在这里插入图片描述

    1. 数据:
      • 获取数据:因为不是jsonp方式,见数据获取方式
      • 使用数据,在src/components/search.vue中调用方法getHotKey
        • 引入import { getHotKey } from ‘api/search’
        • 在created时调用
          created() {
              this._getHotKey()
          },
          
        • this._getHotKey
          _getHotKey() {
            getHotKey().then(res => {
              if (res.code === ERR_OK) {
                this.hotKey = res.data.hotkey.slice(0, 10)
              }
            })
          }
          
        • 将hotkey数据填入到dom结构 中
          <li class="item" v-for="(item,index) in hotKey" :key="index">
              <span>{{item.k}}</span>
          </li>
          
    2. 逻辑:点击热门搜索的数据,可以自动将数据填充到搜索框中
      • 在src/base/search-box中设置改变query的方法
      • 在父组件src/components/search中,
        //query为 当前点击的 热门搜索中的内容
        this.$refs.searchBox.setQuery(query)
        
      • 当搜索框中有关键词的时候,应该显示一个搜索结果的列表见src/components/suggest.vue

    搜索组件src/components/suggest.vue(当点击搜索框中内容不为空的时候,显示搜索列表)

    在这里插入图片描述

    1. 获取数据
      • search: 用于获取数据
      • _genResult:将获取到的数据格式化,得到我们想要的格式
        如果根据检索词返回数据中zhida的值不为空,那么说明检索到了包含检索词的歌手
        将zhida中的内容加上type:singer键值对形成一个新的对象加入到ret中
        再将所有和检索词有关的歌曲放入到ret中
        
    2. 遍历数据
      <li class="suggest-item" v-for="(item,index) in result" :key="index">
      
    3. 设置样式,因为歌手和歌曲显示的图标和文字都是不一样的
      在这里插入图片描述
      <div class="icon">
          <!-- 图标是动态的,也就是歌手和歌曲显示的图标是不一样的-->
        <i :class="getIconCls(item)"></i>
      </div>
      <div class="name">
          <!-- 歌手与歌曲显示的数据也是不一样的 -->
        <p class="text" v-html="getDisplayName(item)"></p>
      </div>
      getIconCls(item) {
        if (item.type === TYPE_SINGER) {
          return 'icon-mine'
        } else {
          return 'icon-music'
        }
      }
      getDisplayName(item) {
        if (item.type === TYPE_SINGER) {
          // 如果当前对象中含有歌手名,那就显示歌手名
          return item.singername
        } else {
          // 否则显示歌手名
          // item中song已经被处理过了,所以直接写item.name就行不,用写item.songname
          return `${item.name}-${item.singer}`
        }
      },
      
    4. 搜索逻辑:
      • 在src/components/search.vue中引入组件
        src/base/SearchBox:      负责搜索框的内容
        src/components/Suggest:  负责根据搜索框中内容的查找相关歌曲或者歌手
        
      • 逻辑:
        1. 在searchbox组件中,监听搜索框中内改变,然后将改变的值传递给父组件search组件
        2. 父组件接受事件,并将query的值传递给子组件suggest.vue
        3. suggest.vue子组件监听到query的改变,调用search方法从qq音乐端请求数据
        
    5. 优化:将根据检索词返回的数据中的res.data.song.list格式化
      利用common/js/song.js中的createSong处理
      this._normalizeSongs(data.song.list)_normalizeSongs(list) { // filter
        const ret = []
        list.forEach(musicData => {
          if (musicData.songid && musicData.albummid) {
            ret.push(createSong(musicData))
          }
        })
        return ret
      },
      
    6. 搜索列表实现滚动功能:引入scroll组件
    7. 上拉刷新功能(扩展scroll组件)(视频:10-5:17:30)
      在这里插入图片描述
      • 是否开启上拉刷新功能,默认是false
        src/base/scroll.vue
        props: {
            // 是否开启上拉刷新功能,默认是false
            pullup: {
              type: Boolean,
              default: false
            },
        }
        
      • 在scroll组件初始化的时候,判断是否开启了上拉刷新该功能
        如果开启了,监听scrollEnd事件,就是当停止滚动的时候派发一个scrollEnd事件
        如果此时滚动到了底部,那就向父组件派发一个事件,代表父元素可以进行上拉刷新功能了
        
        if (this.pullup) { // pullup: drop-down refresh
            // scrollEnd:停止滚动了
            // scrollToEnd:滚动到底部了
            this.scroll.on('scrollEnd', () => {
                //当满足这个条件的时候,表示当前已经滚动到底部了
                  if (this.scroll.y <= this.scroll.maxScrollY + 50) {
                    this.$emit('scrollToEnd') 
                  }
            })
        }
        
      • 当滚动到底部,scroll向父组件suggust派发事件,父组件suggest接收事件,并触发searchMore方法
        • searchMore:获取更多数据,加载更多
          <scroll :pullup="pullup" @scrollToEnd="searchMore">
          //上拉刷新
          searchMore() {
            // 如果此时数据已经加载完了,就不能实现上拉刷新的功能了
            // _checkMore来检测是否数据都请求完毕,也就是 是否改变hasMore
            if (!this.hasMore) {
              return
            }
            this.page++
              // 刷新一次,page++,再请求page对应页数的数据
            search(this.query, this.page, this.showSinger, perpage).then(res => {
              if (res.code === ERR_OK) {
                this.result = this.result.concat(this._genResult(res.data))
                this._checkMore(res.data)
              }
            })
          },
          
        • _checkMore:检查是否还有数据,以此确定是否可以上拉刷新
          _checkMore(data) {
            const song = data.song
            // data中的song有几个参数:
            // 1. curnum:当前页的歌曲数量
            // 2. curpage:当前是第几页
            // 3. 和检索词有关的所有歌曲数量
          
            // 不能上拉刷新的条件:当前歌曲长度为0,已经检索到最后数据了
            
            if (!song.list.length || song.curnum + (song.curpage) * perpage > song.totalnum) {
              this.hasMore = false
            }
          },
          
      • 当上拉刷新时候显示转圈圈的样式,
        1. 引入loading组件:import Loading from 'base/loading/loading'
        2. loading显示的条件是hasmore为true
        3. 改变hasMore的条件是
                if(!song.list.length || song.curnum + (song.curpage) * perpage > song.totalnum){
                    this.hasMore = false
                }
        
      • 当滑动到页面底部的时候,scroll给父组件传递事件
        父组件调用searchMore方法获取下一页的数据
        也就是请求数据时候,将page++传递过去
        每次请求,获取数据之后都判断一下是否数据 全部都已经请求:_checkMore(){}
      • 10-5-17:48重新捋清思路
      • 优化:每次都搜素列表顶部开始显示数据
        search() {
            // query改变的时候,第一次调用search,page都要从第一个开始
            this.page = 1
            // query改变的时候,第一次调用search,都要滚动到底部
            this.$refs.suggest.scrollTo(0, 0)
        }
        
    8. 点击搜索到的内容,跳转相应页面
      • 对搜索列表中歌手的点击:
        • 设置路由:当搜索内容含有歌手的时候,跳转路由,跳到歌手详情页
        • 当点击click搜索列表的时候,绑定selectItem事件
        • 如果点击的是歌手,那么跳转路由,并且设置vuex中singer的值改变
          selectItem(item) {
              if (item.type === TYPE_SINGER) {
                  this.$router.push({
                    path: `/search/${singer.id}`
                  })
                  //调用mutations改变vuex中state中的值
                  //...mapMutations({
                  //  setSinger: 'SET_SINGER'
                  //}),
                  this.setSinger(singer)
              }
          }
          
      • 对搜索列表中歌曲的点击:
        如果点击歌曲,state中的playlist、sequencelist、currentIndex这三个变量都要改变,所以我们要在action中进行一个封装,见src/store/action.js的insertSong
        注意,点击歌曲要实现:
        • 歌曲播放
        • playlist和sequenceList中要添加歌曲
          判断这两个列表中是否已经含有了这首歌
          如果有还要判断这首歌在currentSong的前面还是后面
          以便调用删除这首歌之前的位置
          只保留当前新添加的歌曲(位置)
          
      • 问题1:报do not mutate vuex store state outside mutations…
        • 原因:没有通过mutations方式修改vuex
          let playlist = state.playlist
          后来直接操作playlist是不行
          
        • 解决:在actions中,拷贝出一个新的数组进行操作
          let playlist = state.playlist.slice()
          currentIndex就不会报这个错,因为基本数据类型是值传递
          
      • 不知道loading组件在这里显示是怎么个情况,反正我是没看懂
    9. 优化:
      • 当搜索列表为空的时候,显示相应的组件src/base/no-result.vue代表没有搜索到相关内容
        在这里插入图片描述
      • 每输入一个字符都会派发一个事件,但是我们不想派发这么频繁,所以编写防抖函数
        • 防抖函数:
          传入一个函数,返回一个防抖函数
              function debounce(func, delay) {
                let timer
                return function(...args) {
                  if (timer) {
                    clearTimeout(timer)
                  }
                  timer = setTimeout(() => {
                    func.apply(this, args)
                  }, delay)
                }
              }
          
        • 在src/base/search-box中监听query改变后,应用防抖函数
          this.$watch(
            'query',
            // debounce节流函数,在一定时间改变多次,以最后一次为准
            debounce(newQuery => {
              this.$emit('query', newQuery)
            }, 200)
          )
          
      • 移动端,输入搜索内容的时候,会调起键盘,当搜索结束后不会收起键盘
        我们需要实现的是:监听到滚动事件,input失去焦点,键盘收起
        • 由scroll.vue组件在滚动前(beforeScroll)派发事件
        • 由suggest.vue组件接受来自scroll组件的事件,并传递给父组件search
        • 由search接收来自suggest组件的事件,并调用search-box中的事件来使search-box中的搜索框失去焦点,
    10. 最大的问题:因为getmusic获取vkey需要时间,在没有获到数据的时候,是拿不到返回的歌曲列表的,所以在ret.concat(this._normalizeSongs(dta.song.list))是拿不到数据的,就没办法渲染
      解决:将ret传进去,把之前返回值的初始值(我设置的是[])直接设置成ret而不是[]

    搜索历史

    在这里插入图片描述

    1. 数据的存取因为很多地方都用到了搜索历史,所以我们将他保存到全局的vuex中
      state,mutation-type, mutations
    2. 在suggest中点击搜索列表,派发事件(因为每个组件都实现特定的功能,实现功能分离)
    3. 要将结果缓存到localStorage中,所以封装一个单独的js文件src/common/js/cache.js,专门操作localStorage
      • 安装插件good-storage操作localStorage
      • 网站:https://github.com/ustbhuangyi/storage
      • 具体见src/common/js/cache.js的saveSearch和loadSearch方法
        • saveSearch
          在不超过存储长度的情况下,将后搜索的值,填入到数组的前面,先搜索的值,填入到数组的后面
          将搜索历史列表存入到localStorage
          将搜索历史列表返回给vuex
          
        • loadSearch:从本地拿取搜索历史的值,用于:赋给state中的搜索历史的初始值
    4. 总结逻辑:
      • 当点击搜索列表的时候,suggest向父组件search传递当前被点击元素item
      • 父组件调用actions,将query加入到搜索历史中
      • 由actions调用commit方法将的新的数组提交给mutations,
      • mutations将最终的数组存到localStorage和vuex中
    5. 将vuex中的搜索历史 数据渲染到dom上
      • 取数据:mapGetters
      • 引用组件:search-list
      • 因为很多地方都要用显示列表的组件,所以单独封装一个组件src/base/search-list
        当需要显示列表的时候,引入并将列表数据传给组件,就可以了
    6. 当点击搜索历史中的数据的时候,要进行搜索
      • 在search-list组件中,绑定点击事件,向父元素search传当前点击的item
      • 父组件search接受子组件search-list传来的事件,并且触发addQuery事件,完成逻辑
    7. 当点击搜索历史中数据后面的×的时候,将该条历史删除,或者点击垃圾桶,清空历史
      和存储搜索历史差不多
      • 点击×:
        • 由search-list派发事件给父组件search
        • 由search处理事件,调用actions中的deleteSearchHistory方法
        • 在actions封装方法deleteSearchHistory
        • deleteSearchHistory方法中调用cache.js中的deleteSearch方法
        • 经过deleteSearch后,将处理过的数组重新赋给vuex state中的searchHistory
      • 点击垃圾桶图标:
        • 点击时,触发事件actions中的clearSearchHistory方法
        • 在actions封装方法clearSearchHistory
        • clearSearchHistory方法中调用cache.js中的clearSearch方法
        • 经过clearSearch后,将处理过的数组重新赋给vuex state中的searchHistory
    8. 优化:
      • 点击垃圾桶时候,有一个弹出的框提醒用户是否全部删除->弹框组件:src/base/confirm
        在这里插入图片描述
        • 当点击清空按钮的时候,触发的不是清空历史的操作了,是显示弹窗的操作
        • 当点击弹窗中的确定或者取消的时候,confirm会触发相应的的事件
          事件中定义:
          弹窗的隐藏
          向父组件传递当前点击的是确定还是取消操作
          
      • 当搜索历史有点多的时候,设置滚动事件
        给热门搜索和搜索历史加上滚动事件
        因为它们两个是两个div,所以我们应该在最外层添加一个div
        否则scroll默认是第一个div添加滚动事件,
        还有就是数据的问题,我们不能只监听一个div数据的改变,所以设置计算属性
        shortcut()
            {
                return this.hotKey.concat(this.searchHistory)
            }
        
        • 问题:
          • 描述:当搜索历史歌曲 高度在屏幕大小的边缘的时候,再添加一首的话不出现滚动情况
          • 原因:因为这个时候,dom是在搜索结果页面,而不是在search页面
          • 解决:要加一个逻辑,在search中watch query的改变,
            因为在组件切换的过程中,query有一个从有到无的状态,
            所以如果newquery为空的话,手动刷新scroll
      • 当歌曲播放的时候,也就是最下方的mini播放器显示的时候,要重新计算搜索列表和搜索历史的高度
        mixin配合handlePlaylist
        见player组件的"其他问题"标题
        
        

    歌曲列表组件src/components/playlist.vue(在src/components/player.vue中引入)

    在这里插入图片描述

    1. 显示和隐藏
      <div class="playlist" v-show="showFlag">
      showFlag默认是false,可以通过shiw和hide这两个方法进行改变
          show() {
            this.showFlag = true
          },
          hide() {
            this.showFlag = false
          },
      
      • 显示:点击歌曲列表按钮(src/components/player.vue的control)时触发方法show()
      • 隐藏:点击歌曲列表蒙层(.playlist)的时候,或者点击关闭(.list-close)的时候
        在playlist.vue中的蒙层部分和关闭部分绑定事件,点击就触发hide方法
        
      • 补充:因为蒙层代表的是整个playlist组件,我们要是点击弹出框内容的时候,也会造成playlist组件消失,所以在.list-wrapper上添加阻止默认事件@click.stop
    2. 数据:
      • vuex中导出sequenceList,在dom中遍历
      • 当数据很多的时候,要实现滚动效果,引入scroll组件
      • 保证获取到所有数据正确的高度,scroll才能正常滚动:当调用show方法的时候,延迟20秒获取数据
        show() {
          this.showFlag = true
          // 当点击按钮显示组件的时候,要延迟20秒之后刷新一下scroll,因为这样才能正确的到数据的高度,才能确保滚动
          setTimeout(() => {
            this.$refs.listContent.refresh()
          }, 20)
        }
        
    3. 当前歌曲样式:遍历的歌曲中如果某一首歌曲和vuex中currentSong匹配上的话,那么这首歌曲设置特殊样式,其余的样式都一样
      在这里插入图片描述
      click:
          getCurrentIcon(item) {
            if (this.currentSong.id === item.id) {
              return 'icon-play'
            }
            return ''
          },
      
    4. 切歌:如果点击歌曲列表中的某首歌,那么当前播放歌曲要改变,,并且设置歌曲状态改变为true
      selectItem(item, index) {
        // 当前遍历的是sequenceList,然而如果是随机模式的话,playList中是被打乱的数组
        // 那么只能通过找到索引,因为歌曲的播放是依赖于数组和索引的,然后通过playList[index]找到歌曲
        if (this.mode === playMode.random) {
          index = this.playlist.findIndex(song => {
            return song.id === item.id
          })
        }
        this.setCurrentIndex(index)
        // 点击完歌曲,同时设置歌曲状态为true
        this.setPlayingState(true)
      },
      
      • 这里注意一个点:playlist正常是和sequenceList中是一样的,如果是随机模式就不一样了
        如果切歌,必须在随机列表中找这首歌的位置,然后playList[index]才能播放,
      • 还有:并不是,随机模式对应歌曲列表中就是随机歌曲列表了,只是播放的时候随机播放而已
    5. 当点击歌曲列表的某一首歌之后,也要实现滚动效果,并且,当前播放的歌曲始终在列表的顶部显示
      • 当监听到当前播放歌曲改变的时候或者当显示这个playlist组件的时候,触发scrollToCurrent事件
        • 定义跳转(到顶部)方法:
        scrollToCurrent(current) {
          // 找到当前播放歌曲对应在sequenceList中的位置
          const index = this.sequenceList.findIndex(song => {
            return current.id === song.id
          }) 
          // 跳到指定位置
          // this.$refs.listContent.scrollToElement(this.$refs.list.$el.children[index], 300)
          this.$refs.listContent.scrollToElement(this.$refs.listItem[index])
        },
        
        • 跳转(到顶部)时机1:当前播放歌曲改变的时候
          watch: {
              currentSong(newSong, oldSong) {
                if (!this.showFlag || newSong.id === oldSong.id) {
                  return
                }
                // setTimeout(() => {
                  this.scrollToCurrent(newSong)
                // }, 20)
              }
          }
          
        • 跳转(到顶部)时机2:显示这个playlist组件的时候
          show() {
              this.showFlag = true
              // 当点击按钮显示组件的时候,要延迟20秒之后刷新一下scroll,因为这样才能正确的到数据的高度,才能确保滚动
              setTimeout(() => {
                  this.$refs.listContent.refresh()
                  this.scrollToCurrent(this.currentSong)
              }, 20)
          },
          
    6. 点击×的时候,将这首从当前播放列表中删除
      • 点击按钮,触发deleteOne事件
      • deleteOne调用actions中的deleteSong事件
      • delteSong:删除playlist,sequenceList中的歌曲,重新计算currentIndex
      • 问题
        • 描述:当删掉歌曲列表唯一的一首歌时候,报错,
        • 原因:因为歌曲改变会触发player的watch
        • 解决:
          watch中:
              newSong根本没有,所以newSong != oldsong 所以所有逻辑都会执行,所以我们应该当newSong没有的时候,直接返回
          动画:
              将ul替换为transition-group
          
      • playlist.vue中当删除最后一首歌的时候需要将歌曲列表hide掉
        deleteOne(item) {
          this.deleteSong(item)
          if (!this.playlist.length) {
            this.hide()
          }
        },
        
    7. 点击垃圾桶图标,删除播放列表所有歌曲(playlist组件)
      • 使用actions中封装好的方法deleteSongList
      • 引用confirm组件
      • 注意:要将confirm的点击事件阻止冒泡,并且删除最后一首歌的时候隐藏playlist
    8. 在player和playlist组件中有很多相同的逻辑,所以将相同的逻辑放在mixin中,实现复用
      在player和playlist组件中引入mixin
      import { playerMixin } from 'common/js/mixin'
      mixins: [playerMixin]
      然后在提取组件的过程中,在mixin中该引入引入,该配置配置,最后大功告成   
      

    添加歌曲列表页面src/components/add-song

    在这里插入图片描述

    1. 控制组件的显示或者隐藏
      • 设置v-show=“showflag”
        <div class="add-song" v-show="showFlag" @click.stop>
        
      • showFlag默认是false
      • show和hide可以实现改变showflag的值,控制组件隐藏
        show() {
          this.showFlag = true
        },
        hide() {
          this.showFlag = false
        },
        
      • 在playlist组件中点击"添加歌曲…"按钮,触发add-song中的show方法
        addSong() {
          this.$refs.addSong.show()
        }
        
    2. 搜索歌曲
      在这里插入图片描述
      • 引用search-box,suggest
      • 监听query的改变,设置query(搜索框中的关键词)
        <search-box
          ref="searchBox"
          placeholder="搜索歌曲"
          @query="onQueryChange"
        >
        </search-box>
        onQueryChange(query) {
          this.query = query
        },
        
      • 还可以利用query设置最近播放/搜索历史的显示与隐藏
            <!-- .shortcut:最近播放和搜索历史 -->
        <div class="shortcut"  v-show="!query"></div>
            <!-- .search-result:搜索结果 -->
        <div class="search-result" v-show="query"></div>
        
      • 注意suggest中是将歌手一起搜索到的,但是我们这里不需要搜索歌手
        设置变量showSinger(false)并传递给子组件:   suggest组件,
        <suggest :query="query" :showSinger="showSinger"></suggest>
        
      • 因为这里很多功能很search组件都是I重复的,所以我们定义在mixin中,然后又是一顿大整改,然后就没有然后了
    3. 最近播放和搜索历史(src/base/switches)
      • 切换和样式的改变:由currentIndex控制哪个部分高亮(最近播放/搜索历史)
        currentIndex默认是0,代表最近播放
        当点击:最近播放/搜索历史,任何一部分的话,
        都会给父组件add-song派发事件,参数为当前索引index
        由父组件监听事件,改变currentIndex,再传入子组件,进而改变哪部分高亮
        
    4. 最近播放和搜索历史列表:(src/components/add-list)
      • 最近播放部分:
        在这里插入图片描述
        • player中,audio标签在歌曲准备好的时候就会派发一个ready事件
          在ready方法中,利用mapActions将当前歌曲加入到最近播放
             player.vue中:
                 this.savePlayHistory(this.currentSong)
             mapActions中;
                 // 最近播放
                 export const savePlayHistory = function({ commit }, song) {
                   // 调用cache中的方法,将数组在本地缓存一份
                   commit(types.SET_PLAY_HISTORY, savePlay(song))
                 }
             cache.js中:
                 export function savePlay(song) {}
             ```
          - 拿到数据之后渲染
             + 数据利用mapgetters从vuex中取
             + 组件可滚动:scroll
             + 数据渲染:song-list组件
             + 点击歌曲:添加到歌曲列表,播放当前歌曲
                 ```javascript
                 // 将 从本地取出来的对象转化为Song实例
                 import Song from 'common/js/song'
                 ...mapActions(['insertSong']),
                 selectSong(song, index) {
                   if (index !== 0) { 
                     // song自己并不是一个Song实例,它只是拥有Song的属性,
                     this.insertSong(new Song(song)) // new Song() -> 传入实例, song 不是实例是对象
                     this.$refs.topTip.show()
                   }
                 }
                 ```
          
      • 搜索历史:复用search-list
        在这里插入图片描述
        search-list中很多方法和数据都在mixin共享中
    5. 优化
      • 删除搜索历史的时候添加动画效果src/base/search-list -->transition-group
      • 有时最近播放是不能滚动的src/components/add-song
        • 原因:因为scroll没有正确计算高度
        • 解决:每次add-song调用show()的时候,都重新计算高度
          setTimeout(() => {
              // 保证scroll可以正确计算时间
              if (this.currentIndex === 0) {
                this.$refs.songList.refresh()
              } else {
                this.$refs.searchList.refresh()
              }
            }, 20)
          
      • 还是发现歌曲列表高度不对
        • 原因:监听到数据改变,需要100ms刷新数据,20ms不够
        • 解决:改变scroll组件中的刷新的时间,之前是20,是个定值
          • 我们这里改为变量,变量的值默认是20ms,但是可以更改
          • 然后在playlist组件中的scroll组件传入值refreshDelay: 120
          • 同理 search和add-Song中要改变刷新时间
          • 因为这两个组件都用到了mixin,所以在mixin中设置refreshDelay的值就可以了
          • 这个refreshDelay目的就是保证在这个时间内去refresh,我们的高度已经计算出来了
    6. 当添加歌曲到歌曲列表的时候,有一个提示框src/base/top-tips
      在这里插入图片描述
      • 显示和隐藏,参见add-song的显示和隐藏,一样一样的
        在top-tips中定义事件控制自己的显示和隐藏,由父元素调用事件改变该组件的状态
           data() {
               return {
                 // 默认隐藏
                 showFlag: false
               }
             },
           methods: {
               show() {
                 this.showFlag = true
                 // 过2秒自动关闭,避免产生多个timer,所以要清理
                 clearTimeout(this.timer)
                 this.timer = setTimeout(() => {
                   this.hide()
                 }, this.delay)
               },
               hide() {
                 this.showFlag = false
               }
             }
        
      • 当点击最近播放中的歌曲,或者搜索结果中歌曲 都能触发show方法显示toptip组件
      • 几秒自动关闭,或者点击直接隐藏
        this.timer = setTimeout(() => {
            this.hide()
        }, 2000)
        

    用户中心页面src/componennts/user-center和收藏功能

    在这里插入图片描述

    1. 在m-header页面添加路由,点击个人中心图标,进入个人中心页面
      • src/componennts/m-header中
        <router-link tag="div" class="mine" to="/user">
          <i class="icon-mine"></i>
        </router-link>
        
      • 添加路由,router/index.js
         {
            path:'/user',
            component: UserCenter
          }
        
    2. 我喜欢的/最近播放
      • user-center中引入switches组件,并传入两个参数,以便正确显示高亮样式
        :currentIndex="currentIndex"
        :switches="switches"
        
    3. 收藏列表:favoriteList
      • 数据:
        • 在state中设置变量存储数组
        • 在mutations,actions,getters等vuex相关的文件,依次添加数据
        • 在cache.js中创建存储,删除,加载 收藏列表的方法,然后再actions中被使用
      • 收藏事件逻辑:(player和playlist)因为逻辑不仅是player中需要的,也是playlist中需要的,所以定义在mixin中
        <i class="icon" :class="getFavoriteIcon(currentSong)" @click="toggleFavorite(currentSong)"></i>
        mixin中:
        1. toggleFavorite(song) {
              if (this.isFavorite(song)) {
                this.deleteFavoriteList(song)
              } else {
                this.saveFavoriteList(song)
              }
            },
        2. getFavoriteIcon(song) {
              if (this.isFavorite(song)) {
                return 'icon-favorite'
              }
              return 'icon-not-favorite'
            },
        3. // 判断当前歌曲是否已经在收藏列表中了
            isFavorite(song) {
              const index = this.favoriteList.findIndex((item) => {
                return item.id === song.id
              })
              return index > -1
            }
        
      • 在user-center的个人中心,我喜欢的/播放历史 部分进行渲染数据
        和add-song组件一样一样的,照样搬就行,只改了数据就可以了
        注意:点击列表的时候,依旧完成两个事件:播放歌曲,添加到播放历史中
    4. 剩余部分的逻辑user-center
      • 点击返回,回到来时的页面,也就是主页
        <!-- .back返回按钮 -->
        <div class="back" @click="back">
            <i class="icon-back"></i>
        </div>
        back() {
              this.$router.back()
        },
        
      • 我喜欢的/最近播放下面的随机播放按钮
        <div class="play-btn" ref="playBtn" @click="random">
            <i class="icon-play"></i>
            <span class="text">随机播放全部</span>
        </div>
        ...mapActions(['randomPlay']),
        random() {
          let list = this.currentIndex === 0 ? this.favoriteList : this.playHistory
          // 如果没有数据,那么什么都不做,节省性能
          if (list.length === 0) {
            return
          }
          // list不是一个song实例,需要包装
          list = list.map(song => {
            return new Song(song)
          })
          this.randomPlay({ list })
        } 
        
        • 注意:并不是随机播放全部,那个播放列表中就是随机列表了,只是播放的时候是随机的而已
        • 而且:在最近播放页面,点击随机播放按钮,歌曲列表排序和最近播放列表排序并不一样,因为,最近播放会利用insertSong将最近播放的排在前面
      • 歌曲播放的时候,scroll正确计算高度(利用mixin.js)
        • import { playlistMixin } from ‘common/js/mixin’
        • mixins: [playlistMixin],
        • handlePlaylist
          // scroll正确计算高度
          handlePlaylist(playlist) {
            const bottom = playlist.length > 0 ? '60px' : ''
            this.$refs.listWrapper.style.bottom = bottom
            // favoriteList:有可能不存在
            this.$refs.favoriteList && this.$refs.favoriteList.refresh() // $refs.favoriteList use v-if, make sure it is not undefined
            this.$refs.playList && this.$refs.playList.refresh()
          },
          
      • 当我喜欢的/最近播放没有数据,为空的时候,给出提示
        • import引入,components注册
        • 两个计算属性noResult,noResultDesc
            <!-- 当我喜欢的/最近播放 没有数据,为空的时候,给出提示 -->
            <div class="no-result-wrapper" v-show="noResult">
              <!-- noResult:这个组件是否显示 -->
              <!-- noResultDesc:提示内容 -->
              <no-result :title="noResultDesc"></no-result>
            </div>
             noResult() {
                if (this.currentIndex === 0) {
                  return !this.favoriteList.length
                } else {
                  return !this.playHistory.length
                }
              },
              noResultDesc() {
                if (this.currentIndex === 0) {
                  return '暂无收藏歌曲'
                } else {
                  return '你还没有听过歌曲'
                }
              }
          

    收尾工作

    1. 当切了很多歌的时候,最后停下来,发现CD和歌词都在继续走
      • 原因:
        在src/components/player.vue中,在监听到当前歌曲改变后,是推迟了1s才播放的
         setTimeout(() => {
            this.$refs.audio.play()//只写这一句是会报错的,因为调用play时候,我们同时请求src是不可以的,这个dom还没有ready
            this.getLyric()
          }, 1000)
          也就是如果此时我们在这1s之内按了暂停键,1s后这个逻辑还是会执行,所以产生了这样的问题
        
      • 解决:
        clearTimeout(this.timer)
        this.timer = setTimeout(() => {
            this.$refs.audio.play()//只写这一句是会报错的,因为调用play时候,我们同时请求src是不可以的,这个dom还没有ready
            this.getLyric()
          }, 1000)
        同时audio中监听 @play="ready"
        这样才能保证,先play调用,改变播放状态,然后才能执行audio.pause()方法,也就是pause肯定在play之后执行
        依旧保留1000延迟是因为,如果歌曲在手机上后台播放,切换到前台的时候,,为了能请求成功,所以设置1s延迟
        
    2. 当连续切了很多歌的时候,歌词显示错误
      • 原因:歌词获取是异步获取,所以当不停切歌的时候
        可能这歌词刚请求回来,歌都已经不知道跳了多少个了
      • 解决:在player中填入下面语句
        if (this.currentSong.lyric !== lyric) {
            return
        }
        当 当前歌词不等于获取来的歌词的时候,就什么都不做,否则才将歌词格式化操作
        
    3. 当歌曲列表中只有一首歌的时候,切换下一首,出现 下面样式是都是灰色的
      • 解决:当只有一首歌的时候,next或者prev方法 调用loop方法后直接返回,不执行下面的this.songReady = false这条语句
    展开全文
  • vuejs2.0 高级实战 全网稀缺 音乐WebAPP

    千次阅读 2019-06-23 16:09:53
    词条 目前市面上还没有一个Vue 2.0 的高级教学,都是一些基础的入门课程,你很难找到一个基于Vue.js的复杂应用的教学, 但是,我们为你准备了这门独一无二的Vue 2.0 高级实战课程 ...i...

    这里写图片描述

    src简单的介绍

    这里写图片描述

    入口文件main.js

    import 'babel-polyfill'  //写在第一位
    import Vue from 'vue'
    import App from './App'
    import router from './router'
    import fastclick from 'fastclick'
    import VueLazyload from 'vue-lazyload'
    import store from './store'
    
    import 'common/stylus/index.styl'
    
    /* eslint-disable no-unused-vars */
    // import vConsole from 'vconsole'
    
    fastclick.attach(document.body)
    
    Vue.use(VueLazyload, {
      loading: require('common/image/default.png')  //传一个默认参数
    })
    
    /* eslint-disable no-new */
    new Vue({
      el: '#app',
      router,
      store,
      render: h => h(App)
    })
    
    

    babel-polyfill是es6底层铺垫即支持一些API,比如promise,balbel-runtime为es6语法转义,fastclick解决移动端点击300毫秒的延迟

    devDependencies 里面的插件(比如各种loader,babel全家桶及各种webpack的插件等)只用于开发环境,不用于生产环境,因此不需要打包;而 dependencies 是需要发布到生产环境的,是要打包的。

    dependencies:应用能够正常运行所依赖的包。这种 dependencies 是最常见的,用户在使用 npm install 安装你的包时会自动安装这些依赖。
    devDependencies:开发应用时所依赖的工具包。通常是一些开发、测试、打包工具,例如 webpack、ESLint、Mocha。应用正常运行并不依赖于这些包,用户在使用 npm install 安装你的包时也不会安装这些依赖。

    {
      "name": "vue-music",
      "version": "1.0.0",
      "description": "音乐播放器",
      "author": "songhao",
      "private": true,
      "scripts": {
        "dev": "node build/dev-server.js",
        "start": "node build/dev-server.js",
        "build": "node build/build.js",
        "lint": "eslint --ext .js,.vue src"
      },
      "dependencies": {
        "babel-runtime": "^6.0.0",
        "vue": "^2.3.3",
        "vue-router": "^2.5.3",
        "vuex": "^2.3.1",
        "fastclick": "^1.0.6",
        "vue-lazyload": "1.0.3",
        "axios": "^0.16.1",
        "jsonp": "0.2.1",
        "better-scroll": "^0.1.15",
        "create-keyframe-animation": "^0.1.0",
        "js-base64": "^2.1.9",
        "lyric-parser": "^1.0.1",
        "good-storage": "^1.0.1"
      },
      "devDependencies": {
        "autoprefixer": "^6.7.2",
        "babel-core": "^6.22.1",
        "babel-eslint": "^7.1.1",
        "babel-loader": "^6.2.10",
        "babel-plugin-transform-runtime": "^6.22.0",
        "babel-preset-env": "^1.3.2",
        "babel-preset-stage-2": "^6.22.0",
        "babel-register": "^6.22.0",
        "babel-polyfill": "^6.2.0",
        "chalk": "^1.1.3",
        "connect-history-api-fallback": "^1.3.0",
        "copy-webpack-plugin": "^4.0.1",
        "css-loader": "^0.28.0",
        "eslint": "^3.19.0",
        "eslint-friendly-formatter": "^2.0.7",
        "eslint-loader": "^1.7.1",
        "eslint-plugin-html": "^2.0.0",
        "eslint-config-standard": "^6.2.1",
        "eslint-plugin-promise": "^3.4.0",
        "eslint-plugin-standard": "^2.0.1",
        "eventsource-polyfill": "^0.9.6",
        "express": "^4.14.1",
        "extract-text-webpack-plugin": "^2.0.0",
        "file-loader": "^0.11.1",
        "friendly-errors-webpack-plugin": "^1.1.3",
        "html-webpack-plugin": "^2.28.0",
        "http-proxy-middleware": "^0.17.3",
        "webpack-bundle-analyzer": "^2.2.1",
        "semver": "^5.3.0",
        "shelljs": "^0.7.6",
        "opn": "^4.0.2",
        "optimize-css-assets-webpack-plugin": "^1.3.0",
        "ora": "^1.2.0",
        "rimraf": "^2.6.0",
        "url-loader": "^0.5.8",
        "vue-loader": "^11.3.4",
        "vue-style-loader": "^2.0.5",
        "vue-template-compiler": "^2.3.3",
        "webpack": "^2.3.3",
        "webpack-dev-middleware": "^1.10.0",
        "webpack-hot-middleware": "^2.18.0",
        "webpack-merge": "^4.1.0",
        "stylus": "^0.54.5",
        "stylus-loader": "^2.1.1",
        "vconsole": "^2.5.2"
      },
      "engines": {
        "node": ">= 4.0.0",
        "npm": ">= 3.0.0"
      },
      "browserslist": [
        "> 1%",
        "last 2 versions",
        "not ie <= 8"
      ]
    }
    
    

    index.html

    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <meta name="viewport"
              content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
        <title>vue-music</title>
      </head>
      <body>
        <div id="app"></div>
        <!-- built files will be auto injected -->
      </body>
    </html>
    

    APP.vue

    <template>
      <div id="app" @touchmove.prevent>
        <m-header></m-header>
        <tab></tab>
        <keep-alive>
          <router-view></router-view>
        </keep-alive>
        //全局的播放器组件
        <player></player>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      import MHeader from 'components/m-header/m-header'
      import Player from 'components/player/player'
      import Tab from 'components/tab/tab'
    
      export default {
        components: {
          MHeader,
          Tab,
          Player
        }
      }
    </script>
    

    jsonp原理

    它发送的不是一个ajax请求,是创建了一个script标签不受同源策略的影响,通过src指向服务器的地址,在地址后面加一个callback=a,服务器解析这个URL发现有一个callback=a的参数,返回数据的时候调用这个a方法,前端定义的a方法就能直接拿到数据

    最后返回的时候从1开始截取,是因为上面的方法已经添加了&符号,所有返回&符之后的内容

    jsonp的封装 这个js放置于静态文件夹下

    import originJsonp from 'jsonp'   //jsonp 结合promise 封装
    
    export default function jsonp(url, data, option) {
      url += (url.indexOf('?') < 0 ? '?' : '&') + param(data)
    
      return new Promise((resolve, reject) => {
        originJsonp(url, option, (err, data) => {
          if (!err) {
            resolve(data)
          } else {
            reject(err)
          }
        })
      })
    }
    
    export function param(data) {
      let url = ''
      for (var k in data) {
        let value = data[k] !== undefined ? data[k] : ''
        url += '&' + k + '=' + encodeURIComponent(value)
        //新型写法 es6写法
        //url += `&${k}=${encodeURIComponent(value)}`   es6语法
      }
      return url ? url.substring(1) : ''
    }
    
    

    推荐页面 recommend.js 使用jsonp 调取轮播图的数据

    用到了es6对象的合并方法Object.assign,浅拷贝的方法

    import jsonp from 'common/js/jsonp'
    import {commonParams, options} from './config'
    
    export function getRecommend() {
      const url = 'https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg'
    
      const data = Object.assign({}, commonParams, {  //assign es6语法
        platform: 'h5',
        uin: 0,
        needNewCode: 1
      })
    
      return jsonp(url, data, options)
    }
    
    

    config.js 因为数据是爬的,所以定义了通用的参数对象,私有的参数通过assign方法添加

    export const commonParams = {
      g_tk: 1928093487,
      inCharset: 'utf-8',
      outCharset: 'utf-8',
      notice: 0,
      format: 'jsonp'
    }
    
    export const options = {
      param: 'jsonpCallback'
    }
    
    export const ERR_OK = 0
    

    components/recommend.vue 在组件中调用接口

    export default {
        data() {
          return {
            recommends: []
          }
        },
        created() {
          this._getRecommend()
        },
        methods: {
          
          _getRecommend() {
            getRecommend().then((res) => {
              if (res.code === ERR_OK) {
                this.recommends = res.data.slider
              }
            })
          }
        },
        components: {
          Slider
        }
      }
    
    <div v-if="recommends.length" class="slider-wrapper" ref="sliderWrapper">
              <slider>
                <div v-for="item in recommends">
                  <a :href="item.linkUrl">
                    <img class="needsclick" @load="loadImage" :src="item.picUrl">
                    <!-- 如果fastclick监听到有class为needsclick就不会拦截 -->
                  </a>
                </div>
              </slider>
            </div>
    

    这里用到了slider组件以及slot的知识,也遇到了一个坑,因为数据响应 必须确定有数据v-if="recommends.length"才能保证插槽的正确显示

    dom.js ,比较重要

    操作dom的文件,位于通用静态文件

    //是否有指定class存在
    export function hasClass(el, className) {
      let reg = new RegExp('(^|\\s)' + className + '(\\s|$)')
      return reg.test(el.className)
    }
    //如果存在什么都不做,否则就设置添加
    export function addClass(el, className) {
      if (hasClass(el, className)) {
        return
      }
    
      let newClass = el.className.split(' ')
      newClass.push(className)
      el.className = newClass.join(' ')
    }
    
    //展现了方法的设置技巧,一个getter、一个setter
    export function getData(el, name, val) {
      const prefix = 'data-'
      if (val) {
        return el.setAttribute(prefix + name, val)
      }
      return el.getAttribute(prefix + name)
    }
    //下面的方法,在歌手详情页的自组件music-list用到,用于控制属性的兼容性
    let elementStyle = document.createElement('div').style
    
    let vendor = (() => {
      let transformNames = {
        webkit: 'webkitTransform',
        Moz: 'MozTransform',
        O: 'OTransform',
        ms: 'msTransform',
        standard: 'transform'
      }
    
      for (let key in transformNames) {
        if (elementStyle[transformNames[key]] !== undefined) {
          return key
        }
      }
    
      return false
    })()
    
    export function prefixStyle(style) {
      if (vendor === false) {
        return false
      }
    
      if (vendor === 'standard') {
        return style
      }
    //数据的拼接,首字母大写加上剩余的部分
      return vendor + style.charAt(0).toUpperCase() + style.substr(1)
    }
    
    

    从下面开始开始编写项目

    首先是推荐页面,由轮播图和热门歌单组成

    轮播图的数据通过jsonp可以得到,但是热门歌单因为有referer、host的认证,所以需要在dev-server.js设置代理,(欺骗服务器)用到axios而不是jsonp

    bulid目录下dev-server.js处理代理

    require('./check-versions')()
    
    var config = require('../config')
    if (!process.env.NODE_ENV) {
      process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV)
    }
    
    var opn = require('opn')
    var path = require('path')
    var express = require('express')
    var webpack = require('webpack')
    var proxyMiddleware = require('http-proxy-middleware')
    var webpackConfig = require('./webpack.dev.conf')
    var axios = require('axios') //第一步
    
    // default port where dev server listens for incoming traffic
    var port = process.env.PORT || config.dev.port
    // automatically open browser, if not set will be false
    var autoOpenBrowser = !!config.dev.autoOpenBrowser
    // Define HTTP proxies to your custom API backend
    // https://github.com/chimurai/http-proxy-middleware
    var proxyTable = config.dev.proxyTable
    
    var app = express()
    
    var apiRoutes = express.Router()   //以下是后端代理接口 第二步
    
    apiRoutes.get('/getDiscList', function (req, res) {
      var url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg'
      axios.get(url, {
        headers: {
          referer: 'https://c.y.qq.com/',
          host: 'c.y.qq.com'
        },
        params: req.query
      }).then((response) => {
        res.json(response.data)  //输出到浏览器的res
      }).catch((e) => {
        console.log(e)
      })
    })
    
    apiRoutes.get('/lyric', function (req, res) {  //这是另一个接口下节将用到
      var url = 'https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg'
    
      axios.get(url, {
        headers: {
          referer: 'https://c.y.qq.com/',
          host: 'c.y.qq.com'
        },
        params: req.query
      }).then((response) => {
        var ret = response.data
        if (typeof ret === 'string') {
          var reg = /^\w+\(({[^()]+})\)$/
          var matches = ret.match(reg)
          if (matches) {
            ret = JSON.parse(matches[1])
          }
        }
        res.json(ret)
      }).catch((e) => {
        console.log(e)
      })
    })
    
    app.use('/api', apiRoutes)   //最后一步
    
    var compiler = webpack(webpackConfig)
    
    var devMiddleware = require('webpack-dev-middleware')(compiler, {
      publicPath: webpackConfig.output.publicPath,
      quiet: true
    })
    
    var hotMiddleware = require('webpack-hot-middleware')(compiler, {
      log: () => {}
    })
    // force page reload when html-webpack-plugin template changes
    compiler.plugin('compilation', function (compilation) {
      compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
        hotMiddleware.publish({ action: 'reload' })
        cb()
      })
    })
    
    // proxy api requests
    Object.keys(proxyTable).forEach(function (context) {
      var options = proxyTable[context]
      if (typeof options === 'string') {
        options = { target: options }
      }
      app.use(proxyMiddleware(options.filter || context, options))
    })
    
    // handle fallback for HTML5 history API
    app.use(require('connect-history-api-fallback')())
    
    // serve webpack bundle output
    app.use(devMiddleware)
    
    // enable hot-reload and state-preserving
    // compilation error display
    app.use(hotMiddleware)
    
    // serve pure static assets
    var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
    app.use(staticPath, express.static('./static'))
    
    var uri = 'http://localhost:' + port
    
    var _resolve
    var readyPromise = new Promise(resolve => {
      _resolve = resolve
    })
    
    console.log('> Starting dev server...')
    devMiddleware.waitUntilValid(() => {
      console.log('> Listening at ' + uri + '\n')
      // when env is testing, don't need open it
      if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {
        opn(uri)
      }
      _resolve()
    })
    
    var server = app.listen(port)
    
    module.exports = {
      ready: readyPromise,
      close: () => {
        server.close()
      }
    }
    
    

    recommend.js 使用axios 调取热门歌单的数据

    export function getDiscList() {
      const url = '/api/getDiscList'
    
      const data = Object.assign({}, commonParams, {
        platform: 'yqq',
        hostUin: 0,
        sin: 0,
        ein: 29,
        sortId: 5,
        needNewCode: 0,
        categoryId: 10000000,
        rnd: Math.random(),
        format: 'json'
      })
    
      return axios.get(url, {
        params: data
      }).then((res) => {
        return Promise.resolve(res.data)
      })
    }
    

    slider轮播图组件用到了dom.js中的addclass方法,引入了better-scroll,注意一下window.addEventListener,初始化的时候这个方法this._setSliderWidth(true)传入true,来控制2倍的dom复制,特别注意初始化dots的方法

    <template>
      <div class="slider" ref="slider">
        <div class="slider-group" ref="sliderGroup">
          <slot></slot>
        </div>
        <div class="dots">
          <span
            class="dot"
            :class="{active: currentPageIndex === index }"
            v-for="(item, index) in dots"
          ></span>
        </div>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
    import { addClass } from "common/js/dom";
    import BScroll from "better-scroll";
    
    export default {
      name: "slider",
      props: {
        loop: {
          type: Boolean,
          default: true
        },
        autoPlay: {
          type: Boolean,
          default: true
        },
        interval: {
          type: Number,
          default: 4000
        }
      },
      data() {
        return {
          dots: [],
          currentPageIndex: 0
        };
      },
      mounted() {
        setTimeout(() => {
          this._setSliderWidth();
          this._initDots();
          this._initSlider();
    
          if (this.autoPlay) {
            this._play();
          }
        }, 20);
    
        window.addEventListener("resize", () => {
          if (!this.slider) {
            return;
          }
          this._setSliderWidth(true);
          this.slider.refresh();
        });
      },
      //keep-active下的声明周期,相当于小程序的onshow
      activated() {
        if (this.autoPlay) {
          this._play();
        }
      },
      //组件销毁后清除定时器,有利于内存释放
      deactivated() {
        clearTimeout(this.timer);
      },
      beforeDestroy() {
        clearTimeout(this.timer);
      },
      methods: {
        //计算轮播的宽度
        _setSliderWidth(isResize) {
          this.children = this.$refs.sliderGroup.children;
    
          let width = 0;
          let sliderWidth = this.$refs.slider.clientWidth;
          for (let i = 0; i < this.children.length; i++) {
            let child = this.children[i];
            addClass(child, "slider-item");
    
            child.style.width = sliderWidth + "px";
            width += sliderWidth;
          }
          if (this.loop && !isResize) {
            width += 2 * sliderWidth;
          }
          this.$refs.sliderGroup.style.width = width + "px";
        },
        //初始化BScroll
        _initSlider() {
          this.slider = new BScroll(this.$refs.slider, {
            scrollX: true,
            scrollY: false,
            momentum: false,
            snap: true,
            snapLoop: this.loop,
            snapThreshold: 0.3,
            snapSpeed: 400
          });
    
          // 设置currentPageIndex
          this.slider.on("scrollEnd", () => {
            let pageIndex = this.slider.getCurrentPage().pageX;
            if (this.loop) {
              pageIndex -= 1;
            }
            this.currentPageIndex = pageIndex;
    
            if (this.autoPlay) {
              this._play();
            }
          });
          //手动出发的时候清楚定时器
          this.slider.on("beforeScrollStart", () => {
            if (this.autoPlay) {
              clearTimeout(this.timer);
            }
          });
        },
        //初始化dots
        _initDots() {
          this.dots = new Array(this.children.length);
        },
        //轮播关键实现
        _play() {
          let pageIndex = this.currentPageIndex + 1;
          if (this.loop) {
            pageIndex += 1;
          }
          this.timer = setTimeout(() => {
            this.slider.goToPage(pageIndex, 0, 400);
          }, this.interval);
        }
      }
    };
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
    @import '~common/stylus/variable';
    
    .slider {
      min-height: 1px;
    
      .slider-group {
        position: relative;
        overflow: hidden;
        white-space: nowrap;
    
        .slider-item {
          float: left;
          box-sizing: border-box;
          overflow: hidden;
          text-align: center;
    
          a {
            display: block;
            width: 100%;
            overflow: hidden;
            text-decoration: none;
          }
    
          img {
            display: block;
            width: 100%;
          }
        }
      }
    
      .dots {
        position: absolute;
        right: 0;
        left: 0;
        bottom: 12px;
        text-align: center;
        font-size: 0;
    
        .dot {
          display: inline-block;
          margin: 0 4px;
          width: 8px;
          height: 8px;
          border-radius: 50%;
          background: $color-text-l;
    
          &.active {
            width: 20px;
            border-radius: 5px;
            background: $color-text-ll;
          }
        }
      }
    }
    </style>
    

    loading组件

    <template>
      <div class="loading">
        <img width="24" height="24" src="./loading.gif">
        <p class="desc">{{title}}</p>
      </div>
    </template>
    <script type="text/ecmascript-6">
      export default {
        props: {
          title: {
            type: String,
            default: '正在载入...'
          }
        }
      }
    </script>
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
      .loading
        width: 100%
        text-align: center
        .desc
          line-height: 20px
          font-size: $font-size-small
          color: $color-text-l
    </style>
    
    

    接下来开发推荐页面滚动列表--仿原声应用的scorllview,所以抽出来一个公用组件Scroll.vue

    <template>
      <div ref="wrapper">
        <slot></slot>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      import BScroll from 'better-scroll'
    
      export default {
        props: {
          probeType: {
            type: Number,
            default: 1
          },
          click: {
            type: Boolean,
            default: true
          },
          listenScroll: {
            type: Boolean,
            default: false
          },
          data: {
            type: Array,
            default: null
          },
          pullup: {
            type: Boolean,
            default: false
          },
          beforeScroll: {
            type: Boolean,
            default: false
          },
          refreshDelay: {
            type: Number,
            default: 20
          }
        },
        mounted() {
          setTimeout(() => {
            this._initScroll()
          }, 20)
        },
        methods: {
          _initScroll() {
            if (!this.$refs.wrapper) {
              return
            }
            this.scroll = new BScroll(this.$refs.wrapper, {
              probeType: this.probeType,
              click: this.click
            })
    
            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.beforeScroll) {
              this.scroll.on('beforeScrollStart', () => {
                this.$emit('beforeScroll')
              })
            }
          },
          disable() {
            this.scroll && this.scroll.disable()
          },
          enable() {
            this.scroll && this.scroll.enable()
          },
          refresh() {
            this.scroll && this.scroll.refresh()
          },
          //用于歌手页面
          scrollTo() {
            this.scroll && this.scroll.scrollTo.apply(this.scroll, arguments)
          },
          //用于歌手页面
          scrollToElement() {
            this.scroll && this.scroll.scrollToElement.apply(this.scroll, arguments)
          }
        },
        watch: {
          data() {
            setTimeout(() => {
              this.refresh()
            }, this.refreshDelay)
          }
        }
      }
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
    
    </style>
    
    

    recommend.vue 推荐页面

    可能会遇到一个问题,初始化后不能滚动,是因为高度的问题,所以给img加了一个方法,注意h2标签的应用,以及图片懒加载,这里提到了vuex的使用,那怎么给vuex提交数据细心的同学可能会发现↓↓↓↓↓ scroll的用法,只对下面的第一个div起作用

    通过图片的高度撑起盒子,通过loadImage的方法限制让方法只执行一次!很实用的方法

    <template>
      <div class="recommend" ref="recommend">
        <scroll ref="scroll" class="recommend-content" :data="discList">
          <div>
            <div v-if="recommends.length" class="slider-wrapper" ref="sliderWrapper">
              <slider>
                <div v-for="item in recommends">
                  <a :href="item.linkUrl">
                    <img class="needsclick" @load="loadImage" :src="item.picUrl">
                  </a>
                </div>
              </slider>
            </div>
            <div class="recommend-list">
              <h1 class="list-title">热门歌单推荐</h1>
              <ul>
                <li @click="selectItem(item)" v-for="item in discList" class="item">
                  <div class="icon">
                    <img width="60" height="60" v-lazy="item.imgurl">
                  </div>
                  <div class="text">
                    <h2 class="name" v-html="item.creator.name"></h2>
                    <p class="desc" v-html="item.dissname"></p>
                  </div>
                </li>
              </ul>
            </div>
          </div>
          <div class="loading-container" v-show="!discList.length">
            <loading></loading>
          </div>
        </scroll>
        <router-view></router-view>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      import Slider from 'base/slider/slider'
      import Loading from 'base/loading/loading'
      import Scroll from 'base/scroll/scroll'
      import {getRecommend, getDiscList} from 'api/recommend'
      import {playlistMixin} from 'common/js/mixin'
      import {ERR_OK} from 'api/config'
      import {mapMutations} from 'vuex'
    
      export default {
        mixins: [playlistMixin],
        data() {
          return {
            recommends: [],
            discList: []
          }
        },
        created() {
          this._getRecommend()
    
          this._getDiscList()
        },
        methods: {
          handlePlaylist(playlist) {
            const bottom = playlist.length > 0 ? '60px' : ''
    
            this.$refs.recommend.style.bottom = bottom
            this.$refs.scroll.refresh()
          },
          //通过图片的高度撑起盒子,通过下面的方法限制让方法只执行一次!
          loadImage() {
            if (!this.checkloaded) {
              this.checkloaded = true
              this.$refs.scroll.refresh()
            }
          },
          selectItem(item) {
            this.$router.push({
              path: `/recommend/${item.dissid}`
            })
            this.setDisc(item)
          },
          _getRecommend() {
            getRecommend().then((res) => {
              if (res.code === ERR_OK) {
                this.recommends = res.data.slider
              }
            })
          },
          _getDiscList() {
            getDiscList().then((res) => {
              if (res.code === ERR_OK) {
                this.discList = res.data.list
              }
            })
          },
          ...mapMutations({
            setDisc: 'SET_DISC'
          })
        },
        components: {
          Slider,
          Loading,
          Scroll
        }
      }
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
    
      .recommend
        position: fixed
        width: 100%
        top: 88px
        bottom: 0
        .recommend-content
          height: 100%
          overflow: hidden
          .slider-wrapper
            position: relative
            width: 100%
            overflow: hidden
          .recommend-list
            .list-title
              height: 65px
              line-height: 65px
              text-align: center
              font-size: $font-size-medium
              color: $color-theme
            .item
              display: flex
              box-sizing: border-box
              align-items: center
              padding: 0 20px 20px 20px
              .icon
                flex: 0 0 60px
                width: 60px
                padding-right: 20px
              .text
                display: flex
                flex-direction: column
                justify-content: center
                flex: 1
                line-height: 20px
                overflow: hidden
                font-size: $font-size-medium
                .name
                  margin-bottom: 10px
                  color: $color-text
                .desc
                  color: $color-text-d
          .loading-container
            position: absolute
            width: 100%
            top: 50%
            transform: translateY(-50%)
    </style>
    

    为什么变量定义在created里面而不是data里面?

    因为这些变量不需要被监听,定义在data、props中的变量vue都会添加一个getter、setter方法用来监听数据变化实现双向数据绑定

    向上滚动Y值为负数

    代码详细注释 listview组件

    <template>
      <scroll @scroll="scroll"
              :listen-scroll="listenScroll"
              :probe-type="probeType"
              :data="data"
              class="listview"
              ref="listview">
        <ul>
          <li v-for="group in data" class="list-group" ref="listGroup">
            <h2 class="list-group-title">{{group.title}}</h2>
            <uL>
              <li @click="selectItem(item)" v-for="item in group.items" class="list-group-item">
                <img class="avatar" v-lazy="item.avatar">
                <span class="name">{{item.name}}</span>
              </li>
            </uL>
          </li>
        </ul>
        <div class="list-shortcut" @touchstart.stop.prevent="onShortcutTouchStart" @touchmove.stop.prevent="onShortcutTouchMove"
             @touchend.stop>
          <ul>
            <li v-for="(item, index) in shortcutList" :data-index="index" class="item"
                :class="{'current':currentIndex===index}">{{item}}
            </li>
          </ul>
        </div>
        <div class="list-fixed" ref="fixed" v-show="fixedTitle">
          <div class="fixed-title">{{fixedTitle}} </div>
        </div>
        <div v-show="!data.length" class="loading-container">
          <loading></loading>
        </div>
      </scroll>
    </template>
    
    <script type="text/ecmascript-6">
      import Scroll from 'base/scroll/scroll'
      import Loading from 'base/loading/loading'
      import {getData} from 'common/js/dom'
    
      const TITLE_HEIGHT = 30  //向上顶起的元素高度
      const ANCHOR_HEIGHT = 18 // 字母导航的高度 (字体高度加上padding值)
    
      export default {
        //接受参数的类型(父组件传入的数据)
        props: {
          data: {
            type: Array,
            default: []
          }
        },
        //计算属性
        computed: {
          //展示字母导航的数据
          shortcutList() {
            return this.data.map((group) => {
              return group.title.substr(0, 1)
            })
          },
          //字母浮层的数据
          fixedTitle() {
            if (this.scrollY > 0) {
              return ''
            }
            return this.data[this.currentIndex] ? this.data[this.currentIndex].title : ''
          }
        },
        data() {
          return {
            scrollY: -1, //scroll组件传入的y轴
            currentIndex: 0, //当前联动的下标
            diff: -1 //控制字母浮层是否被下一块内容顶上去,(是一个顶上去的效果)
          }
        }, //为什么要定义在created里面而不是data里面?
        created() {
          this.probeType = 3  //scroll组件需要的参数,表示不节流,慢滚动与快滚动都生效
          this.listenScroll = true //是否触发scroll组件的监听事件
          this.touch = {} //定义的一个touch事件
          this.listHeight = [] //获取list的高度,是一个数组
        },
        methods: {
          selectItem(item) {
            this.$emit('select', item)
          },
          onShortcutTouchStart(e) {
            let anchorIndex = getData(e.target, 'index')
            let firstTouch = e.touches[0]
            this.touch.y1 = firstTouch.pageY
            this.touch.anchorIndex = anchorIndex
    
            this._scrollTo(anchorIndex)
          },
          onShortcutTouchMove(e) {
            let firstTouch = e.touches[0]
            this.touch.y2 = firstTouch.pageY
            //最后 | 0 表示向下取整
            let delta = (this.touch.y2 - this.touch.y1) / ANCHOR_HEIGHT | 0
            let anchorIndex = parseInt(this.touch.anchorIndex) + delta
    
            this._scrollTo(anchorIndex)
          },
          refresh() {
            this.$refs.listview.refresh()
          },
          //获取scroll组件传入的scroll值
          scroll(pos) {
            this.scrollY = pos.y
          },
          //计算列表的高度
          _calculateHeight() {
            this.listHeight = []
            const list = this.$refs.listGroup
            let height = 0
            this.listHeight.push(height)
            for (let i = 0; i < list.length; i++) {
              let item = list[i]
              height += item.clientHeight
              this.listHeight.push(height)
            }
          },
          _scrollTo(index) {
            //控制touchstart,点击边缘地带
            if (!index && index !== 0) {
              return
            }
            //控制touchstart,点击边缘地带
            if (index < 0) {
              index = 0
            } else if (index > this.listHeight.length - 2) { // 控制touchmove,向上滚动的时候,可能大于最后一项的优化
              index = this.listHeight.length - 2
            }
            // 控制touchstart,点击后通过手动设置scrollY实现,点击联动
            this.scrollY = -this.listHeight[index]
            //实现列表的滚动
            this.$refs.listview.scrollToElement(this.$refs.listGroup[index], 0)
          }
        },
        watch: {
          //监听data,当数据变化,延迟20ms等都dom渲染完毕,再计算
          data() {
            setTimeout(() => {
              this._calculateHeight()
            }, 20)
          },
          //监听scrollY的值 
          scrollY(newY) {
            const listHeight = this.listHeight
            // 当滚动到顶部,newY>0
            if (newY > 0) {
              this.currentIndex = 0
              return
            }
            // 在中间部分滚动
            for (let i = 0; i < listHeight.length - 1; i++) {
              let height1 = listHeight[i]
              let height2 = listHeight[i + 1]
              if (-newY >= height1 && -newY < height2) {
                this.currentIndex = i
                this.diff = height2 + newY
                return
              }
            }
            // 当滚动到底部,且-newY大于最后一个元素的上限
            this.currentIndex = listHeight.length - 2
          },
          //控制字母浮层是否要被顶上去
          diff(newVal) {
            let fixedTop = (newVal > 0 && newVal < TITLE_HEIGHT) ? newVal - TITLE_HEIGHT : 0
            if (this.fixedTop === fixedTop) {
              return
            }
            this.fixedTop = fixedTop
            this.$refs.fixed.style.transform = `translate3d(0,${fixedTop}px,0)`
          }
        },
        components: {
          Scroll,
          Loading
        }
      }
    
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
    
      .listview
        position: relative
        width: 100%
        height: 100%
        overflow: hidden
        background: $color-background
        .list-group
          padding-bottom: 30px
          .list-group-title
            height: 30px
            line-height: 30px
            padding-left: 20px
            font-size: $font-size-small
            color: $color-text-l
            background: $color-highlight-background
          .list-group-item
            display: flex
            align-items: center
            padding: 20px 0 0 30px
            .avatar
              width: 50px
              height: 50px
              border-radius: 50%
            .name
              margin-left: 20px
              color: $color-text-l
              font-size: $font-size-medium
        .list-shortcut
          position: absolute
          z-index: 30
          right: 0
          top: 50%
          transform: translateY(-50%)
          width: 20px
          padding: 20px 0
          border-radius: 10px
          text-align: center
          background: $color-background-d
          font-family: Helvetica
          .item
            padding: 3px
            line-height: 1
            color: $color-text-l
            font-size: $font-size-small
            &.current
              color: $color-theme
        .list-fixed
          position: absolute
          top: 0
          left: 0
          width: 100%
          .fixed-title
            height: 30px
            line-height: 30px
            padding-left: 20px
            font-size: $font-size-small
            color: $color-text-l
            background: $color-highlight-background
        .loading-container
          position: absolute
          width: 100%
          top: 50%
          transform: translateY(-50%)
    </style>
    
    
    

    Singer class类

    export default class Singer {
      constructor({id, name}) {
        this.id = id
        this.name = name
        this.avatar = `https://y.gtimg.cn/music/photo_new/T001R300x300M000${id}.jpg?max_age=2592000`
      }
    }
    

    歌手页面 singer.vue ?‍? 代码注释,字母排序

    引入listview组件,有一个20毫秒的定时器,关键在于左右联动的思路很重要,以及关于diff的处理增强用户体验

    <template>
      <div class="singer" ref="singer">
        <list-view @select="selectSinger" :data="singers" ref="list"></list-view>
        <router-view></router-view>
      </div>
    </template>
    
    <script>
      import ListView from 'base/listview/listview'
      import {getSingerList} from 'api/singer'
      import {ERR_OK} from 'api/config'
      import Singer from 'common/js/singer'
      import {mapMutations} from 'vuex'  //对Mutations的封装是个语法糖
      import {playlistMixin} from 'common/js/mixin'
    
      const HOT_SINGER_LEN = 10
      const HOT_NAME = '热门'
    
      export default {
        mixins: [playlistMixin],
        data() {
          return {
            singers: []
          }
        },
        created() {
          this._getSingerList()
        },
        methods: {
          handlePlaylist(playlist) {
            const bottom = playlist.length > 0 ? '60px' : ''
            this.$refs.singer.style.bottom = bottom
            this.$refs.list.refresh()
          }, 
          //跳到歌手详情页面
          selectSinger(singer) {
            this.$router.push({
              path: `/singer/${singer.id}`
            })
            //调用被映射出来的方法,把数据传递到vuex里面,到这一步完成setter设置
            this.setSinger(singer)
          },
          _getSingerList() {
            getSingerList().then((res) => {
              if (res.code === ERR_OK) {
                this.singers = this._normalizeSinger(res.data.list)
              }
            })
          },
          //整理数据结构
          _normalizeSinger(list) {
          //首先定义热门数据对象
            let map = {
              hot: {
                title: HOT_NAME,
                items: []
              }
            }
            
            list.forEach((item, index) => {
            	//默认前10条为热门数据
              if (index < HOT_SINGER_LEN) {
                map.hot.items.push(new Singer({
                  name: item.Fsinger_name,
                  id: item.Fsinger_mid
                }))
              }
              //key 为ABCDEF。。。。。。
              const key = item.Findex
              if (!map[key]) {
                map[key] = {
                  title: key,
                  items: []
                }
              }
              map[key].items.push(new Singer({
                name: item.Fsinger_name,
                id: item.Fsinger_mid
              }))
            })
            // 为了得到有序列表,我们需要处理 map
            let ret = []
            let hot = []
            for (let key in map) {
              let val = map[key]
              if (val.title.match(/[a-zA-Z]/)) {
                ret.push(val)
              } else if (val.title === HOT_NAME) {
                hot.push(val)
              }
            }
            //对字母排序
            ret.sort((a, b) => {
              return a.title.charCodeAt(0) - b.title.charCodeAt(0)
            })
            return hot.concat(ret)
          },
          //做一层映射,映射到mutations
          ...mapMutations({
            setSinger: 'SET_SINGER'
          })
        },
        components: {
          ListView
        }
      }
    
    </script>
    
    
    

    nextTic延时函数 es6 FindIndex方法

    vuex 状态管理

    使用场景

    1. 多组件的状态共享
    2. 路由间复杂数据传递

    vuex
    mutation-type 用来定义常量,便于项目维护,mutation同步修改数据

    export const SET_SINGER = 'SET_SINGER'
    
    export const SET_PLAYING_STATE = 'SET_PLAYING_STATE'
    
    export const SET_FULL_SCREEN = 'SET_FULL_SCREEN'
    
    export const SET_PLAYLIST = 'SET_PLAYLIST'
    
    export const SET_SEQUENCE_LIST = 'SET_SEQUENCE_LIST'
    
    export const SET_PLAY_MODE = 'SET_PLAY_MODE'
    
    export const SET_CURRENT_INDEX = 'SET_CURRENT_INDEX'
    
    export const SET_DISC = 'SET_DISC'
    
    export const SET_TOP_LIST = 'SET_TOP_LIST'
    
    export const SET_SEARCH_HISTORY = 'SET_SEARCH_HISTORY'
    
    export const SET_PLAY_HISTORY = 'SET_PLAY_HISTORY'
    
    export const SET_FAVORITE_LIST = 'SET_FAVORITE_LIST'
    

    mutations,接收两个参数,state就是state.js定义的变量,另一个就是入参(此参数要改变state里的值)

    import * as types from './mutation-types'
    
    const mutations = {
      [types.SET_SINGER](state, singer) {
        state.singer = singer
      },
      [types.SET_PLAYING_STATE](state, flag) {
        state.playing = flag
      },
      [types.SET_FULL_SCREEN](state, flag) {
        state.fullScreen = flag
      },
      [types.SET_PLAYLIST](state, list) {
        state.playlist = list
      },
      [types.SET_SEQUENCE_LIST](state, list) {
        state.sequenceList = list
      },
      [types.SET_PLAY_MODE](state, mode) {
        state.mode = mode
      },
      [types.SET_CURRENT_INDEX](state, index) {
        state.currentIndex = index
      },
      [types.SET_DISC](state, disc) {
        state.disc = disc
      },
      [types.SET_TOP_LIST](state, topList) {
        state.topList = topList
      },
      [types.SET_SEARCH_HISTORY](state, history) {
        state.searchHistory = history
      },
      [types.SET_PLAY_HISTORY](state, history) {
        state.playHistory = history
      },
      [types.SET_FAVORITE_LIST](state, list) {
        state.favoriteList = list
      }
    }
    export default mutations
    

    getters,相当于vuex的计算属性

    export const singer = state => state.singer
    
    export const playing = state => state.playing
    
    export const fullScreen = state => state.fullScreen
    
    export const playlist = state => state.playlist
    
    export const sequenceList = state => state.sequenceList
    
    export const mode = state => state.mode
    
    export const currentIndex = state => state.currentIndex
    
    export const currentSong = (state) => {
      return state.playlist[state.currentIndex] || {}
    }
    
    export const disc = state => state.disc
    
    export const topList = state => state.topList
    
    export const searchHistory = state => state.searchHistory
    
    export const playHistory = state => state.playHistory
    
    export const favoriteList = state => state.favoriteList
    
    

    index.js初始化vuex,启用debug

    import Vue from 'vue'
    import Vuex from 'vuex'
    import * as actions from './actions'
    import * as getters from './getters'
    import state from './state'
    import mutations from './mutations'
    import createLogger from 'vuex/dist/logger' //通过他能在控制台看到每次修改的日志
    
    Vue.use(Vuex)
    
    const debug = process.env.NODE_ENV !== 'production'  //debug模式,是否是通过state来修改数据,只有在run dev 的模式下可行,应该他对性能也有消耗,不建议在正式环境使用
    
    export default new Vuex.Store({
      actions,
      getters,
      state,
      mutations,
      strict: debug,
      plugins: debug ? [createLogger()] : []
    })
    

    state.js

    import {playMode} from 'common/js/config'
    import {loadSearch, loadPlay, loadFavorite} from 'common/js/cache'
    
    const state = {
      singer: {},
      playing: false, //是否播放
      fullScreen: false, //是否全屏播放
      playlist: [], //播放列表
      sequenceList: [], // 顺序列表
      mode: playMode.sequence, //播放模式
      currentIndex: -1, //当前播放的那首歌
      disc: {},
      topList: {},
      searchHistory: loadSearch(),
      playHistory: loadPlay(),
      favoriteList: loadFavorite()
    }
    
    export default state
    

    actions 处理异步操作、对mutations的封装(批量处理mutations)

    import * as types from './mutation-types'
    import {playMode} from 'common/js/config'
    import {shuffle} from 'common/js/util'
    import {saveSearch, clearSearch, deleteSearch, savePlay, saveFavorite, deleteFavorite} from 'common/js/cache'
    //findindex 用来找出 歌曲在当前列表 下的index
    function findIndex(list, song) {
      return list.findIndex((item) => {
        return item.id === song.id
      })
    }
    //选择播放
    export const selectPlay = function ({commit, state}, {list, index}) {
      commit(types.SET_SEQUENCE_LIST, list) //设置值
      if (state.mode === playMode.random) {
        let randomList = shuffle(list)
        commit(types.SET_PLAYLIST, randomList)
        index = findIndex(randomList, list[index])
      } else {
        commit(types.SET_PLAYLIST, list)
      }
      commit(types.SET_CURRENT_INDEX, index) //播放索引
      commit(types.SET_FULL_SCREEN, true) //播放器打开
      commit(types.SET_PLAYING_STATE, true) //播放状态打开
    }
    //music-list里面的随机播放全部 事件
    export const randomPlay = function ({commit}, {list}) {
      commit(types.SET_PLAY_MODE, playMode.random)
      commit(types.SET_SEQUENCE_LIST, list)
      let randomList = shuffle(list)
      commit(types.SET_PLAYLIST, randomList)
      commit(types.SET_CURRENT_INDEX, 0)
      commit(types.SET_FULL_SCREEN, true)
      commit(types.SET_PLAYING_STATE, true)
    }
    
    export const insertSong = function ({commit, state}, song) {
      let playlist = state.playlist.slice()
      let sequenceList = state.sequenceList.slice()
      let currentIndex = state.currentIndex
      // 记录当前歌曲
      let currentSong = playlist[currentIndex]
      // 查找当前列表中是否有待插入的歌曲并返回其索引
      let fpIndex = findIndex(playlist, song)
      // 因为是插入歌曲,所以索引+1
      currentIndex++
      // 插入这首歌到当前索引位置
      playlist.splice(currentIndex, 0, song)
      // 如果已经包含了这首歌
      if (fpIndex > -1) {
        // 如果当前插入的序号大于列表中的序号
        if (currentIndex > fpIndex) {
          playlist.splice(fpIndex, 1)
          currentIndex--
        } else {
          playlist.splice(fpIndex + 1, 1)
        }
      }
    
      let currentSIndex = findIndex(sequenceList, currentSong) + 1
    
      let fsIndex = findIndex(sequenceList, song)
    
      sequenceList.splice(currentSIndex, 0, song)
    
      if (fsIndex > -1) {
        if (currentSIndex > fsIndex) {
          sequenceList.splice(fsIndex, 1)
        } else {
          sequenceList.splice(fsIndex + 1, 1)
        }
      }
    
      commit(types.SET_PLAYLIST, playlist)
      commit(types.SET_SEQUENCE_LIST, sequenceList)
      commit(types.SET_CURRENT_INDEX, currentIndex)
      commit(types.SET_FULL_SCREEN, true)
      commit(types.SET_PLAYING_STATE, true)
    }
    
    export const saveSearchHistory = function ({commit}, query) {
      commit(types.SET_SEARCH_HISTORY, saveSearch(query))
    }
    
    export const deleteSearchHistory = function ({commit}, query) {
      commit(types.SET_SEARCH_HISTORY, deleteSearch(query))
    }
    
    export const clearSearchHistory = function ({commit}) {
      commit(types.SET_SEARCH_HISTORY, clearSearch())
    }
    
    export const deleteSong = function ({commit, state}, song) {
      let playlist = state.playlist.slice()
      let sequenceList = state.sequenceList.slice()
      let currentIndex = state.currentIndex
      let pIndex = findIndex(playlist, song)
      playlist.splice(pIndex, 1)
      let sIndex = findIndex(sequenceList, song)
      sequenceList.splice(sIndex, 1)
      if (currentIndex > pIndex || currentIndex === playlist.length) {
        currentIndex--
      }
    
      commit(types.SET_PLAYLIST, playlist)
      commit(types.SET_SEQUENCE_LIST, sequenceList)
      commit(types.SET_CURRENT_INDEX, currentIndex)
    
      if (!playlist.length) {
        commit(types.SET_PLAYING_STATE, false)
      } else {
        commit(types.SET_PLAYING_STATE, true)
      }
    }
    
    export const deleteSongList = function ({commit}) {
      commit(types.SET_CURRENT_INDEX, -1)
      commit(types.SET_PLAYLIST, [])
      commit(types.SET_SEQUENCE_LIST, [])
      commit(types.SET_PLAYING_STATE, false)
    }
    
    export const savePlayHistory = function ({commit}, song) {
      commit(types.SET_PLAY_HISTORY, savePlay(song))
    }
    
    export const saveFavoriteList = function ({commit}, song) {
      commit(types.SET_FAVORITE_LIST, saveFavorite(song))
    }
    
    export const deleteFavoriteList = function ({commit}, song) {
      commit(types.SET_FAVORITE_LIST, deleteFavorite(song))
    }
    
    

    用vuex在路由间传递复杂数据

    歌手页面完成数据的设置,在歌手详情页面开始获取数据
    代码注释:获取单个数据字段,刷新的边界处理

    歌手详情页,为了组件重用抽出来一个music-list.vue,在此基础又抽出来一个song-list.vue,有两层组件

    song类

    将乱而散的数据整理成我们想要的数据

    import {getLyric} from 'api/song'
    import {ERR_OK} from 'api/config'
    import {Base64} from 'js-base64'
    
    export default class Song {
      constructor({id, mid, singer, name, album, duration, image, url}) {
        this.id = id
        this.mid = mid
        this.singer = singer
        this.name = name
        this.album = album
        this.duration = duration
        this.image = image
        this.url = url
      }
    
      getLyric() {
        if (this.lyric) {
          return Promise.resolve(this.lyric)
        }
    
        return new Promise((resolve, reject) => {
          getLyric(this.mid).then((res) => {
            if (res.retcode === ERR_OK) {
              this.lyric = Base64.decode(res.lyric)
              resolve(this.lyric)
            } else {
              reject('no lyric')
            }
          })
        })
      }
    }
     //工厂函数,调用song类
    export function createSong(musicData) {
      return new Song({
        id: musicData.songid,
        mid: musicData.songmid,
        singer: filterSinger(musicData.singer),
        name: musicData.songname,
        album: musicData.albumname,
        duration: musicData.interval,
        image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${musicData.albummid}.jpg?max_age=2592000`,
        url: `http://ws.stream.qqmusic.qq.com/${musicData.songid}.m4a?fromtag=46`
      })
    }
    //这个方法是将name数组按照/的方式展示,两个以上有效
    function filterSinger(singer) {
      let ret = []
      if (!singer) {
        return ''
      }
      singer.forEach((s) => {
        ret.push(s.name)
      })
      return ret.join('/')
    }
    
    
    

    歌手详情页,transition动画、song类的应用

    <template>
      <transition name="slide">
        <music-list :title="title" :bg-image="bgImage" :songs="songs"></music-list>
      </transition>
    </template>
    
    <script type="text/ecmascript-6">
      import MusicList from 'components/music-list/music-list'
      import {getSingerDetail} from 'api/singer'
      import {ERR_OK} from 'api/config'
      import {createSong} from 'common/js/song'
      import {mapGetters} from 'vuex'  //通过mapgetters语法糖
    
      export default {
        computed: {
        	//只需要数据结构的某一个字段
          title() {
            return this.singer.name
          },
          //只需要数据结构的某一个字段
          bgImage() {
            return this.singer.avatar
          },
          //位于计算属性,获取vuex里面的数据
          ...mapGetters([
            'singer'
          ])
        },
        data() {
          return {
            songs: []
          }
        },
        created() {
          this._getDetail()
        },
        methods: {
        //如果用户在当前页面刷新,返回上一个路由,这是一个边界问题
          _getDetail() {
            if (!this.singer.id) {
              this.$router.push('/singer')
              return
            }
            //请求数据
            getSingerDetail(this.singer.id).then((res) => {
              if (res.code === ERR_OK) {
                this.songs = this._normalizeSongs(res.data.list)
              }
            })
          },
          _normalizeSongs(list) {
            let ret = []
            list.forEach((item) => {
              let {musicData} = item
              if (musicData.songid && musicData.albummid) {
                ret.push(createSong(musicData))
              }
            })
            return ret
          }
        },
        components: {
          MusicList
        }
      }
    </script>
     //只需要数据结构的某一个字段
    <style scoped lang="stylus" rel="stylesheet/stylus">
      .slide-enter-active, .slide-leave-active
        transition: all 0.3s
    
      .slide-enter, .slide-leave-to
        transform: translate3d(100%, 0, 0)
                               x轴  , y轴  ,z轴
    </style>
    

    歌手详情,子组件music-list

    <template>
      <div class="music-list">
        <div class="back" @click="back">
          <i class="icon-back"></i>
        </div>
        <h1 class="title" v-html="title"></h1>
        //歌手头像背景,关注背景图的设置,以及样式,传说中的10:7写法???????
        <div class="bg-image" :style="bgStyle" ref="bgImage">
          <div class="play-wrapper">
            <div ref="playBtn" v-show="songs.length>0" class="play" @click="random">
              <i class="icon-play"></i>
              <span class="text">随机播放全部</span>
            </div>
          </div>
          //用来控制模糊层,但是兼容不好,pc、和部分安卓看不到效果,只有iOS可以看到
          <div class="filter" ref="filter"></div>
        </div>
        //用于歌曲列表向上滚动,来做遮挡层
        <div class="bg-layer" ref="layer"></div>
        <scroll :data="songs" @scroll="scroll"
                :listen-scroll="listenScroll" :probe-type="probeType" class="list" ref="list">
          <div class="song-list-wrapper">
            <song-list :songs="songs" :rank="rank" @select="selectItem"></song-list>
          </div>
          <div v-show="!songs.length" class="loading-container">
            <loading></loading>
          </div>
        </scroll>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      import Scroll from 'base/scroll/scroll'
      import Loading from 'base/loading/loading'
      import SongList from 'base/song-list/song-list'
      import {prefixStyle} from 'common/js/dom'
      import {playlistMixin} from 'common/js/mixin'
      import {mapActions} from 'vuex'  //语法糖用于获取actions
    
      const RESERVED_HEIGHT = 40 //重制高度,向上滚动的时候预留的高度
      //下面是用到的两个属性,拼接浏览器前缀做浏览器兼容
      const transform = prefixStyle('transform') 
      const backdrop = prefixStyle('backdrop-filter')
    
      export default {
        mixins: [playlistMixin],
        props: {
          bgImage: {
            type: String,
            default: ''
          },
          songs: {
            type: Array,
            default: []
          },
          title: {
            type: String,
            default: ''
          },
          rank: {
            type: Boolean,
            default: false
          }
        },
        data() {
          return {
            scrollY: 0
          }
        },
        computed: {
        //计算属性设置背景
          bgStyle() {
            return `background-image:url(${this.bgImage})`
          }
        },
        created() {
          this.probeType = 3
          this.listenScroll = true
        },
        mounted() {
          this.imageHeight = this.$refs.bgImage.clientHeight
          this.minTransalteY = -this.imageHeight + RESERVED_HEIGHT
          this.$refs.list.$el.style.top = `${this.imageHeight}px`
        },
        methods: {
          handlePlaylist(playlist) {
            const bottom = playlist.length > 0 ? '60px' : ''
            this.$refs.list.$el.style.bottom = bottom
            this.$refs.list.refresh()
          },
          scroll(pos) {
            this.scrollY = pos.y
          },
          back() {
            this.$router.back()
          },
          //当前点击的歌曲
          selectItem(item, index) {
            this.selectPlay({
              list: this.songs,
              index
            })
          },
          //随机播放全部歌曲
          random() {
            this.randomPlay({
              list: this.songs
            })
          },
          //一般会将这个方法放到methods对象的末尾
          ...mapActions([
            'selectPlay',
            'randomPlay'
          ])
        },
        watch: {
          scrollY(newVal) {
            let translateY = Math.max(this.minTransalteY, newVal) //最大不超过this.minTransalteY
            let scale = 1
            let zIndex = 0
            let blur = 0
            const percent = Math.abs(newVal / this.imageHeight) //获取绝对值
            if (newVal > 0) { //向上滚动的时候
              scale = 1 + percent
              zIndex = 10
            } else {
              blur = Math.min(20, percent * 20) //最小是20
            }
    
            this.$refs.layer.style[transform] = `translate3d(0,${translateY}px,0)`
            this.$refs.filter.style[backdrop] = `blur(${blur}px)`
            if (newVal < this.minTransalteY) {  //向上滚动的时候
              zIndex = 10
              this.$refs.bgImage.style.paddingTop = 0
              this.$refs.bgImage.style.height = `${RESERVED_HEIGHT}px`
              this.$refs.playBtn.style.display = 'none'
            } else { //向下滚动的时候再恢复默认
              this.$refs.bgImage.style.paddingTop = '70%'
              this.$refs.bgImage.style.height = 0
              this.$refs.playBtn.style.display = ''
            }
            this.$refs.bgImage.style[transform] = `scale(${scale})`
            this.$refs.bgImage.style.zIndex = zIndex
          }
        },
        components: {
          Scroll,
          Loading,
          SongList
        }
      }
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
      @import "~common/stylus/mixin"
    
      .music-list
        position: fixed
        z-index: 100
        top: 0
        left: 0
        bottom: 0
        right: 0
        background: $color-background
        .back
          position absolute
          top: 0
          left: 6px
          z-index: 50
          .icon-back
            display: block
            padding: 10px
            font-size: $font-size-large-x
            color: $color-theme
        .title
          position: absolute
          top: 0
          left: 10%
          z-index: 40
          width: 80%
          no-wrap()
          text-align: center
          line-height: 40px
          font-size: $font-size-large
          color: $color-text
        .bg-image
          position: relative
          width: 100%
          height: 0
          padding-top: 70%
          transform-origin: top
          background-size: cover
          .play-wrapper
            position: absolute
            bottom: 20px
            z-index: 50
            width: 100%
            .play
              box-sizing: border-box
              width: 135px
              padding: 7px 0
              margin: 0 auto
              text-align: center
              border: 1px solid $color-theme
              color: $color-theme
              border-radius: 100px
              font-size: 0
              .icon-play
                display: inline-block
                vertical-align: middle
                margin-right: 6px
                font-size: $font-size-medium-x
              .text
                display: inline-block
                vertical-align: middle
                font-size: $font-size-small
          .filter
            position: absolute
            top: 0
            left: 0
            width: 100%
            height: 100%
            background: rgba(7, 17, 27, 0.4)
        .bg-layer
          position: relative
          height: 100%
          background: $color-background
        .list
          position: fixed
          top: 0
          bottom: 0
          width: 100%
          background: $color-background
          .song-list-wrapper
            padding: 20px 30px
          .loading-container
            position: absolute
            width: 100%
            top: 50%
            transform: translateY(-50%)
    </style>
    

    song-list子组件

    <template>
      <div class="song-list">
        <ul>
          <li @click="selectItem(song, index)" class="item" v-for="(song, index) in songs">
            <div class="rank" v-show="rank">
              <span :class="getRankCls(index)" v-text="getRankText(index)"></span>
            </div>
            <div class="content">
              <h2 class="name">{{song.name}}</h2>
              <p class="desc">{{getDesc(song)}}</p>
            </div>
          </li>
        </ul>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      export default {
        props: {
          songs: {
            type: Array,
            default: []
          },
          rank: {
            type: Boolean,
            default: false
          }
        },
        methods: {
        //向父组件传递事件
          selectItem(item, index) {
            this.$emit('select', item, index)
          },
          getDesc(song) {
            return `${song.singer}·${song.album}`
          },
          getRankCls(index) {
            if (index <= 2) {
              return `icon icon${index}`
            } else {
              return 'text'
            }
          },
          getRankText(index) {
            if (index > 2) {
              return index + 1
            }
          }
        }
      }
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
      @import "~common/stylus/mixin"
    
      .song-list
        .item
          display: flex
          align-items: center
          box-sizing: border-box
          height: 64px
          font-size: $font-size-medium
          .rank
            flex: 0 0 25px
            width: 25px
            margin-right: 30px
            text-align: center
            .icon
              display: inline-block
              width: 25px
              height: 24px
              background-size: 25px 24px
              &.icon0
                bg-image('first')
              &.icon1
                bg-image('second')
              &.icon2
                bg-image('third')
            .text
              color: $color-theme
              font-size: $font-size-large
          .content
            flex: 1
            line-height: 20px
            overflow: hidden
            .name
              no-wrap()
              color: $color-text
            .desc
              no-wrap()
              margin-top: 4px
              color: $color-text-d
    </style>
    

    随机播放 技术实现 随机数组

    //随机数
    function getRandomInt(min, max) {
      return Math.floor(Math.random() * (max - min + 1) + min)
    }
    //把数组打乱
    export function shuffle(arr) {
      let _arr = arr.slice()
      for (let i = 0; i < _arr.length; i++) {
        let j = getRandomInt(0, i)
        let t = _arr[i]
        _arr[i] = _arr[j]
        _arr[j] = t
      }
      return _arr
    }
    
    export function debounce(func, delay) {
      let timer
    
      return function (...args) {
        if (timer) {
          clearTimeout(timer)
        }
        timer = setTimeout(() => {
          func.apply(this, args)
        }, delay)
      }
    }
    

    时间戳转分秒 补零方法

     //转换时间
     format(interval) {
       interval = interval | 0 //时间戳 向下取整
       const minute = interval / 60 | 0  //向下取整
       const second = this._pad(interval % 60)
       return `${minute}:${second}`
     },
      //时间补零 n相当于补充的字符串的长度
     _pad(num, n = 2) {
       let len = num.toString().length //获取字符串的长度
       while (len < n) {
         num = '0' + num
         len++
       }
       return num
     },
    

    播放器内置组件 player.vue,通过actions的方法--selectPlay,在此组件拿到currentSong,这里再重点说一下mutations和它的type要做到命名一致,nutations本质就是函数,第一个参数是state第二个参数是要修改的对象值

    条形进度条应用到全屏播放

    clientWidth 为content+padding的值

    <template>
      <div class="progress-bar" ref="progressBar" @click="progressClick">
        <div class="bar-inner">
          <!-- 背景 -->
          <div class="progress" ref="progress"></div>
          <!-- 小圆点 -->
          <div class="progress-btn-wrapper" ref="progressBtn"
               @touchstart.prevent="progressTouchStart"
               @touchmove.prevent="progressTouchMove"
               @touchend="progressTouchEnd"
          >
            <div class="progress-btn"></div>
          </div>
        </div>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      import {prefixStyle} from 'common/js/dom'
    
      const progressBtnWidth = 16
      const transform = prefixStyle('transform')
    
      export default {
        props: {
          percent: {
            type: Number,
            default: 0
          }
        },
        created() {
          this.touch = {}  //创建一个touch对象
        },
        methods: {
          progressTouchStart(e) {
            //创建一个标志,意思它已经初始化完
            this.touch.initiated = true
            //手指的位置
            this.touch.startX = e.touches[0].pageX
            //当前滚动,滚动条的位置
            this.touch.left = this.$refs.progress.clientWidth
          },
          progressTouchMove(e) {
            //如果初始化完则什么都不做
            if (!this.touch.initiated) {
              return
            }
            const deltaX = e.touches[0].pageX - this.touch.startX //计算差值 
            //max 的0  意思不能小于0 、、、、min,不能超过整个滚动条的宽度
            const offsetWidth = Math.min(this.$refs.progressBar.clientWidth - progressBtnWidth, Math.max(0, this.touch.left + deltaX)) 
            this._offset(offsetWidth)
          },
          progressTouchEnd() {
            this.touch.initiated = false
            //滚动完后要给父组件派发一个事件
            this._triggerPercent()
          },
          //点击改变歌曲播放进度
          progressClick(e) {
            const rect = this.$refs.progressBar.getBoundingClientRect() //是一个获取距离的方法 也就是当前内容距离屏幕的左右间距
            const offsetWidth = e.pageX - rect.left
            this._offset(offsetWidth)
            // 这里当我们点击 progressBtn 的时候,e.offsetX 获取不对
            // this._offset(e.offsetX)
            this._triggerPercent()
          },
          _triggerPercent() {
            const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth
            const percent = this.$refs.progress.clientWidth / barWidth
            this.$emit('percentChange', percent)
          },
          //偏移方法
          _offset(offsetWidth) {
            this.$refs.progress.style.width = `${offsetWidth}px`  //获取进度条的位置,距离左右的距离
            this.$refs.progressBtn.style[transform] = `translate3d(${offsetWidth}px,0,0)`  //小球的偏移
          }
        },
        watch: {
          //它是不断改变的
          percent(newPercent) {
            //大于0 而且不是在拖动的状态下,拖动的时候不要改变
            if (newPercent >= 0 && !this.touch.initiated) {
              const barWidth = this.$refs.progressBar.clientWidth - progressBtnWidth  //进度条的总宽度 内容-按钮的宽度
              const offsetWidth = newPercent * barWidth //应该偏移的宽度
              this._offset(offsetWidth)
            }
          }
        }
      }
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
    
      .progress-bar
        height: 30px
        .bar-inner
          position: relative
          top: 13px
          height: 4px
          background: rgba(0, 0, 0, 0.3)
          .progress
            position: absolute
            height: 100%
            background: $color-theme
          .progress-btn-wrapper
            position: absolute
            left: -8px
            top: -13px
            width: 30px
            height: 30px
            .progress-btn
              position: relative
              top: 7px
              left: 7px
              box-sizing: border-box
              width: 16px
              height: 16px
              border: 3px solid $color-text
              border-radius: 50%
              background: $color-theme
    </style>
    

    圆形进度条 用到SVG

    <template>
      <div class="progress-circle">
        <!-- viewBox 视口位置 width、height 是显示到页面的宽高 -->
        <svg :width="radius" :height="radius" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
          <!-- 内层圆圈 r半径 cx\yx为圆心的坐标,说明这个圆是100的宽度 -->
          <circle class="progress-background" r="50" cx="50" cy="50" fill="transparent"/>
          <!-- 外层圆圈 stroke-dasharray描边 stroke-dashoffset偏移量-->
          <circle class="progress-bar" r="50" cx="50" cy="50" fill="transparent" :stroke-dasharray="dashArray"
                  :stroke-dashoffset="dashOffset"/>
        </svg>
        <slot></slot>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      export default {
        props: {
          radius: {
            type: Number,
            default: 100
          },
          percent: {
            type: Number,
            default: 0
          }
        },
        data() {
          return {
            dashArray: Math.PI * 100
          }
        },
        computed: {
          dashOffset() {
            return (1 - this.percent) * this.dashArray
          }
        }
      }
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
    
      .progress-circle
        position: relative
        circle
          stroke-width: 8px
          transform-origin: center
          &.progress-background
            transform: scale(0.9)
            stroke: $color-theme-d
          &.progress-bar
            transform: scale(0.9) rotate(-90deg)
            stroke: $color-theme
    </style>
    

    播放器内核

    <template>
      <div class="player" v-show="playlist.length>0">
        <!-- 下面用到了动画的js钩子,用它来实现左下到右上的飞跃 -->
        <transition name="normal"
                    @enter="enter" 
                    @after-enter="afterEnter" 
                    @leave="leave"
                    @after-leave="afterLeave"
        >
        <!-- 全屏播放器 -->
          <div class="normal-player" v-show="fullScreen">
            <!-- 背景虚化处理 -->
            <div class="background">
              <img width="100%" height="100%" :src="currentSong.image">
            </div>
            <div class="top">
               <!-- 收起按钮 -->
              <div class="back" @click="back">
                <i class="icon-back"></i>
              </div>
              <!-- 标题/作者 -->
              <h1 class="title" v-html="currentSong.name"></h1>
              <h2 class="subtitle" v-html="currentSong.singer"></h2>
            </div>
            <div class="middle"
                 @touchstart.prevent="middleTouchStart"
                 @touchmove.prevent="middleTouchMove"
                 @touchend="middleTouchEnd"
            >
              <!-- 播放器左侧 -->
              <div class="middle-l" ref="middleL">
                <div class="cd-wrapper" ref="cdWrapper">
                  <!-- 当前播放歌曲的海报 -->
                  <div class="cd" :class="cdCls">
                    <img class="image" :src="currentSong.image">
                  </div>
                </div>
                <div class="playing-lyric-wrapper">
                  <div class="playing-lyric">{{playingLyric}}</div>
                </div>
              </div>
              <!-- 播放器右侧 歌词列表 -->
              <scroll class="middle-r" ref="lyricList" :data="currentLyric && currentLyric.lines">
                <div class="lyric-wrapper">
                  <div v-if="currentLyric">
                    <p ref="lyricLine"
                       class="text"
                       :class="{'current': currentLineNum ===index}"
                       v-for="(line,index) in currentLyric.lines">{{line.txt}}</p>
                  </div>
                </div>
              </scroll>
            </div>
            <div class="bottom">
              <div class="dot-wrapper">
                <span class="dot" :class="{'active':currentShow==='cd'}"></span>
                <span class="dot" :class="{'active':currentShow==='lyric'}"></span>
              </div>
              <!-- 条形进度条的大盒子 -->
              <div class="progress-wrapper">
                <!-- 歌曲开始播放时间 -->
                <span class="time time-l">{{format(currentTime)}}</span>
                <!-- 条形进度条 -->
                <div class="progress-bar-wrapper">
                  <progress-bar :percent="percent" @percentChange="onProgressBarChange"></progress-bar>
                </div>
                <!-- 歌曲结束时间 -->
                <span class="time time-r">{{format(currentSong.duration)}}</span>
              </div>
              <div class="operators">
                <!-- 歌曲播放模式 -->
                <div class="icon i-left" @click="changeMode">
                  <i :class="iconMode"></i>
                </div>
                <!-- 上一首 -->
                <div class="icon i-left" :class="disableCls">
                  <i @click="prev" class="icon-prev"></i>
                </div>
                <!-- 播放与暂停 -->
                <div class="icon i-center" :class="disableCls">
                  <i @click="togglePlaying" :class="playIcon"></i>
                </div>
                <!-- 下一首 -->
                <div class="icon i-right" :class="disableCls">
                  <i @click="next" class="icon-next"></i>
                </div>
                <!-- 收藏按钮 -->
                <div class="icon i-right">
                  <i @click="toggleFavorite(currentSong)" class="icon" :class="getFavoriteIcon(currentSong)"></i>
                </div>
              </div>
            </div>
          </div>
        </transition>
        <!-- 收起后的迷你播放器 -->
        <transition name="mini">
          <div class="mini-player" v-show="!fullScreen" @click="open">
            <div class="icon">
              <img :class="cdCls" width="40" height="40" :src="currentSong.image">
            </div>
            <div class="text">
              <h2 class="name" v-html="currentSong.name"></h2>
              <p class="desc" v-html="currentSong.singer"></p>
            </div>
            <!-- 圆形进度条 用到svg -->
            <div class="control">
              <progress-circle :radius="radius" :percent="percent">
                <i @click.stop="togglePlaying" class="icon-mini" :class="miniIcon"></i>
              </progress-circle>
            </div>
            <div class="control" @click.stop="showPlaylist">
              <i class="icon-playlist"></i>
            </div>
          </div>
        </transition>
        <playlist ref="playlist"></playlist>
        <!-- 音频播放器 核心 -->
        <audio ref="audio" :src="currentSong.url" @play="ready" @error="error" @timeupdate="updateTime"
               @ended="end"></audio>
      </div>
    </template>
    
    <script type="text/ecmascript-6">
      import {mapGetters, mapMutations, mapActions} from 'vuex'
      import animations from 'create-keyframe-animation' //用到这个动画库
      import {prefixStyle} from 'common/js/dom'
      import ProgressBar from 'base/progress-bar/progress-bar'
      import ProgressCircle from 'base/progress-circle/progress-circle'
      import {playMode} from 'common/js/config'
      import Lyric from 'lyric-parser'
      import Scroll from 'base/scroll/scroll'
      import {playerMixin} from 'common/js/mixin'
      import Playlist from 'components/playlist/playlist'
      //下面的对象是 'common/js/config' 里面的内容
    // export const playMode = {
    //   sequence: 0, //顺序播放
    //   loop: 1, //循环播放
    //   random: 2 //随机播放
    // }
    
      const transform = prefixStyle('transform')
      const transitionDuration = prefixStyle('transitionDuration')
    
      export default {
        mixins: [playerMixin],
        data() {
          return {
            songReady: false, //控制播放器,歌曲加载完后再走下面的逻辑
            currentTime: 0, //当前歌曲播放的时间
            radius: 32,
            currentLyric: null,
            currentLineNum: 0,
            currentShow: 'cd',
            playingLyric: ''
          }
        },
        computed: {
          cdCls() {
            return this.playing ? 'play' : 'play pause'
          },
          //字体图标的应用
          playIcon() {
            return this.playing ? 'icon-pause' : 'icon-play'
          },
          //字体图标的应用
          miniIcon() {
            return this.playing ? 'icon-pause-mini' : 'icon-play-mini'
          },
          //添加歌曲不能播放的状态样式
          disableCls() {
            return this.songReady ? '' : 'disable'
          },
          //歌曲播放的比例
          percent() {
            return this.currentTime / this.currentSong.duration
          },
          ...mapGetters([
            'currentIndex',
            'fullScreen',
            'playing'
          ])
        },
        created() {
          this.touch = {}
        },
        methods: {
          back() {
            this.setFullScreen(false)
          },
          open() {
            this.setFullScreen(true)
          },
          // el 是元素、done执行会跳到afterEnter
          enter(el, done) {
            const {x, y, scale} = this._getPosAndScale()
            //从零先跳到那个初始化的位置,60的时候放大,100的时候恢复正常
            let animation = {
              0: {
                transform: `translate3d(${x}px,${y}px,0) scale(${scale})`
              },
              60: {
                transform: `translate3d(0,0,0) scale(1.1)`
              },
              100: {
                transform: `translate3d(0,0,0) scale(1)`
              }
            }
    
            animations.registerAnimation({
              name: 'move',
              animation,
              presets: {
                duration: 400,
                easing: 'linear'
              }
            })
            // 把动画追加到制定元素
            animations.runAnimation(this.$refs.cdWrapper, 'move', done)
          },
          afterEnter() {
            animations.unregisterAnimation('move')
            this.$refs.cdWrapper.style.animation = ''
          },
          // el 是元素、done执行会跳到afterLeaave
          leave(el, done) {
            this.$refs.cdWrapper.style.transition = 'all 0.4s'
            const {x, y, scale} = this._getPosAndScale()
            this.$refs.cdWrapper.style[transform] = `translate3d(${x}px,${y}px,0) scale(${scale})`
            this.$refs.cdWrapper.addEventListener('transitionend', done)
          },
          afterLeave() {
            this.$refs.cdWrapper.style.transition = ''
            this.$refs.cdWrapper.style[transform] = ''
          },
          // 切换歌曲播放与暂停
          togglePlaying() {
            if (!this.songReady) {
              return
            }
            this.setPlayingState(!this.playing)
            if (this.currentLyric) {
              this.currentLyric.togglePlay()
            }
          },
          //歌曲播放结束事件
          end() {
            //如果是单曲循环,播放结束后再次播放
            if (this.mode === playMode.loop) {
              this.loop()
            } else {
              this.next()
            }
          },
          loop() {
            this.$refs.audio.currentTime = 0
            this.$refs.audio.play()
            this.setPlayingState(true)
            if (this.currentLyric) {
              this.currentLyric.seek(0)
            }
          },
          // 切换下一首歌曲
          next() {
            //歌曲没有加载完
            if (!this.songReady) {
              return
            }
            if (this.playlist.length === 1) {
              this.loop()
              return
            } else {
              let index = this.currentIndex + 1 //下一首歌,所以要加一
              //如果到底类,就重置为0
              if (index === this.playlist.length) {
                index = 0
              }
              //设置vuex的index
              this.setCurrentIndex(index)
             //如果是暂停状态下,切换,就调取方法改变它的值 
              if (!this.playing) {
                this.togglePlaying()
              }
            }
            this.songReady = false
          },
          prev() {
             //歌曲没有加载完
            if (!this.songReady) {
              return
            }
            if (this.playlist.length === 1) {
              this.loop()
              return
            } else {
              let index = this.currentIndex - 1
              //如果到头,就换成最后一首歌
              if (index === -1) {
                index = this.playlist.length - 1
              }
              this.setCurrentIndex(index)
              //如果是暂停状态下,切换,就调取方法改变它的值 
              if (!this.playing) {
                this.togglePlaying()
              }
            }
            this.songReady = false
          },
          //歌曲加载完事件
          ready() {
            this.songReady = true
            this.savePlayHistory(this.currentSong)
          },
          error() {
            this.songReady = true
          },
          //获取歌曲当前播放的时间长度
          updateTime(e) {
            this.currentTime = e.target.currentTime
          },
          //转换时间
          format(interval) {
            interval = interval | 0 //时间戳 向下取整
            const minute = interval / 60 | 0  //向下取整
            const second = this._pad(interval % 60)
            return `${minute}:${second}`
          },
          //监听子组件返回的数值,改变播放器的进度
          onProgressBarChange(percent) {
            const currentTime = this.currentSong.duration * percent
            this.$refs.audio.currentTime = currentTime
            //如果当前状态没有播放,就去播放歌曲
            if (!this.playing) {
              this.togglePlaying()
            }
            if (this.currentLyric) {
              this.currentLyric.seek(currentTime * 1000)
            }
          },
          getLyric() {
            this.currentSong.getLyric().then((lyric) => {
              if (this.currentSong.lyric !== lyric) {
                return
              }
              this.currentLyric = new Lyric(lyric, this.handleLyric)
              if (this.playing) {
                this.currentLyric.play()
              }
            }).catch(() => {
              this.currentLyric = null
              this.playingLyric = ''
              this.currentLineNum = 0
            })
          },
          handleLyric({lineNum, txt}) {
            this.currentLineNum = lineNum
            if (lineNum > 5) {
              let lineEl = this.$refs.lyricLine[lineNum - 5]
              this.$refs.lyricList.scrollToElement(lineEl, 1000)
            } else {
              this.$refs.lyricList.scrollTo(0, 0, 1000)
            }
            this.playingLyric = txt
          },
          showPlaylist() {
            this.$refs.playlist.show()
          },
          middleTouchStart(e) {
            this.touch.initiated = true
            // 用来判断是否是一次移动
            this.touch.moved = false
            const touch = e.touches[0]
            this.touch.startX = touch.pageX
            this.touch.startY = touch.pageY
          },
          middleTouchMove(e) {
            if (!this.touch.initiated) {
              return
            }
            const touch = e.touches[0]
            const deltaX = touch.pageX - this.touch.startX
            const deltaY = touch.pageY - this.touch.startY
            if (Math.abs(deltaY) > Math.abs(deltaX)) {
              return
            }
            if (!this.touch.moved) {
              this.touch.moved = true
            }
            const left = this.currentShow === 'cd' ? 0 : -window.innerWidth
            const offsetWidth = Math.min(0, Math.max(-window.innerWidth, left + deltaX))
            this.touch.percent = Math.abs(offsetWidth / window.innerWidth)
            this.$refs.lyricList.$el.style[transform] = `translate3d(${offsetWidth}px,0,0)`
            this.$refs.lyricList.$el.style[transitionDuration] = 0
            this.$refs.middleL.style.opacity = 1 - this.touch.percent
            this.$refs.middleL.style[transitionDuration] = 0
          },
          middleTouchEnd() {
            if (!this.touch.moved) {
              return
            }
            let offsetWidth
            let opacity
            if (this.currentShow === 'cd') {
              if (this.touch.percent > 0.1) {
                offsetWidth = -window.innerWidth
                opacity = 0
                this.currentShow = 'lyric'
              } else {
                offsetWidth = 0
                opacity = 1
              }
            } else {
              if (this.touch.percent < 0.9) {
                offsetWidth = 0
                this.currentShow = 'cd'
                opacity = 1
              } else {
                offsetWidth = -window.innerWidth
                opacity = 0
              }
            }
            const time = 300
            this.$refs.lyricList.$el.style[transform] = `translate3d(${offsetWidth}px,0,0)`
            this.$refs.lyricList.$el.style[transitionDuration] = `${time}ms`
            this.$refs.middleL.style.opacity = opacity
            this.$refs.middleL.style[transitionDuration] = `${time}ms`
            this.touch.initiated = false
          },
          //时间补零 n相当于补充的字符串的长度
          _pad(num, n = 2) {
            let len = num.toString().length //获取字符串的长度
            while (len < n) {
              num = '0' + num
              len++
            }
            return num
          },
          _getPosAndScale() {
            const targetWidth = 40 //目标宽度,小圆圈的宽度
            const paddingLeft = 40 //目标宽度,小圆圈的左边距
            const paddingBottom = 30 //目标宽度,小圆圈的下边距
            const paddingTop = 80 //大唱片到页面顶部的距离
            const width = window.innerWidth * 0.8 //大唱片的宽度,因为设置的是80%
            const scale = targetWidth / width //初开始的缩放
            const x = -(window.innerWidth / 2 - paddingLeft) //初始的x轴,从左下到右上所以是负值
            const y = window.innerHeight - paddingTop - width / 2 - paddingBottom //初始化 y轴  2分支1的宽度
            return {
              x,
              y,
              scale
            }
          },
          ...mapMutations({
            setFullScreen: 'SET_FULL_SCREEN'
          }),
          ...mapActions([
            'savePlayHistory'
          ])
        },
        watch: {
          currentSong(newSong, oldSong) {
            if (!newSong.id) {
              return
            }
            //发现ID没变 就什么都不做,在切换模式的时候用到
            if (newSong.id === oldSong.id) {
              return
            }
            if (this.currentLyric) {
              this.currentLyric.stop()
              this.currentTime = 0
              this.playingLyric = ''
              this.currentLineNum = 0
            }
            // 函数防抖的处理
            clearTimeout(this.timer)
            this.timer = setTimeout(() => {
              this.$refs.audio.play()
              this.getLyric()
            }, 1000)
          },
          //控制暂停和播放
          playing(newPlaying) {
            //先缓存一下引用
            const audio = this.$refs.audio
            this.$nextTick(() => {
              newPlaying ? audio.play() : audio.pause()
            })
          },
          fullScreen(newVal) {
            if (newVal) {
              setTimeout(() => {
                this.$refs.lyricList.refresh()
              }, 20)
            }
          }
        },
        components: {
          ProgressBar,
          ProgressCircle,
          Scroll,
          Playlist
        }
      }
    </script>
    
    <style scoped lang="stylus" rel="stylesheet/stylus">
      @import "~common/stylus/variable"
      @import "~common/stylus/mixin"
    
      .player
        .normal-player
          position: fixed
          left: 0
          right: 0
          top: 0
          bottom: 0
          z-index: 150
          background: $color-background
          .background
            position: absolute
            left: 0
            top: 0
            width: 100%
            height: 100%
            z-index: -1
            opacity: 0.6
            filter: blur(20px)
          .top
            position: relative
            margin-bottom: 25px
            .back
              position absolute
              top: 0
              left: 6px
              z-index: 50
              .icon-back
                display: block
                padding: 9px
                font-size: $font-size-large-x
                color: $color-theme
                transform: rotate(-90deg)
            .title
              width: 70%
              margin: 0 auto
              line-height: 40px
              text-align: center
              no-wrap()
              font-size: $font-size-large
              color: $color-text
            .subtitle
              line-height: 20px
              text-align: center
              font-size: $font-size-medium
              color: $color-text
          .middle
            position: fixed
            width: 100%
            top: 80px
            bottom: 170px
            white-space: nowrap
            font-size: 0
            .middle-l
              display: inline-block
              vertical-align: top
              position: relative
              width: 100%
              height: 0
              padding-top: 80%
              .cd-wrapper
                position: absolute
                left: 10%
                top: 0
                width: 80%
                height: 100%
                .cd
                  width: 100%
                  height: 100%
                  box-sizing: border-box
                  border: 10px solid rgba(255, 255, 255, 0.1)
                  border-radius: 50%
                  &.play
                    animation: rotate 20s linear infinite
                  &.pause
                    animation-play-state: paused
                  .image
                    position: absolute
                    left: 0
                    top: 0
                    width: 100%
                    height: 100%
                    border-radius: 50%
    
              .playing-lyric-wrapper
                width: 80%
                margin: 30px auto 0 auto
                overflow: hidden
                text-align: center
                .playing-lyric
                  height: 20px
                  line-height: 20px
                  font-size: $font-size-medium
                  color: $color-text-l
            .middle-r
              display: inline-block
              vertical-align: top
              width: 100%
              height: 100%
              overflow: hidden
              .lyric-wrapper
                width: 80%
                margin: 0 auto
                overflow: hidden
                text-align: center
                .text
                  line-height: 32px
                  color: $color-text-l
                  font-size: $font-size-medium
                  &.current
                    color: $color-text
          .bottom
            position: absolute
            bottom: 50px
            width: 100%
            .dot-wrapper
              text-align: center
              font-size: 0
              .dot
                display: inline-block
                vertical-align: middle
                margin: 0 4px
                width: 8px
                height: 8px
                border-radius: 50%
                background: $color-text-l
                &.active
                  width: 20px
                  border-radius: 5px
                  background: $color-text-ll
            .progress-wrapper
              display: flex
              align-items: center
              width: 80%
              margin: 0px auto
              padding: 10px 0
              .time
                color: $color-text
                font-size: $font-size-small
                flex: 0 0 30px
                line-height: 30px
                width: 30px
                &.time-l
                  text-align: left
                &.time-r
                  text-align: right
              .progress-bar-wrapper
                flex: 1
            .operators
              display: flex
              align-items: center
              .icon
                flex: 1
                color: $color-theme
                &.disable
                  color: $color-theme-d
                i
                  font-size: 30px
              .i-left
                text-align: right
              .i-center
                padding: 0 20px
                text-align: center
                i
                  font-size: 40px
              .i-right
                text-align: left
              .icon-favorite
                color: $color-sub-theme
          &.normal-enter-active, &.normal-leave-active
            transition: all 0.4s
            .top, .bottom
              transition: all 0.4s cubic-bezier(0.86, 0.18, 0.82, 1.32)
          &.normal-enter, &.normal-leave-to
            opacity: 0
            .top
              transform: translate3d(0, -100px, 0)
            .bottom
              transform: translate3d(0, 100px, 0)
        .mini-player
          display: flex
          align-items: center
          position: fixed
          left: 0
          bottom: 0
          z-index: 180
          width: 100%
          height: 60px
          background: $color-highlight-background
          &.mini-enter-active, &.mini-leave-active
            transition: all 0.4s
          &.mini-enter, &.mini-leave-to
            opacity: 0
          .icon
            flex: 0 0 40px
            width: 40px
            padding: 0 10px 0 20px
            img
              border-radius: 50%
              &.play
                animation: rotate 10s linear infinite
              &.pause
                animation-play-state: paused
          .text
            display: flex
            flex-direction: column
            justify-content: center
            flex: 1
            line-height: 20px
            overflow: hidden
            .name
              margin-bottom: 2px
              no-wrap()
              font-size: $font-size-medium
              color: $color-text
            .desc
              no-wrap()
              font-size: $font-size-small
              color: $color-text-d
          .control
            flex: 0 0 30px
            width: 30px
            padding: 0 10px
            .icon-play-mini, .icon-pause-mini, .icon-playlist
              font-size: 30px
              color: $color-theme-d
            .icon-mini
              font-size: 32px
              position: absolute
              left: 0
              top: 0
    
      @keyframes rotate
        0%
          transform: rotate(0)
        100%
          transform: rotate(360deg)
    </style>
    

    一级页面向二级页面传参数

    首先在路由中定义一个二级,path就是id,再回到推荐页面加一个routerview以及点击事件,到此完成第一步。见下面的部分代码

    {
          path: '/recommend',
          component: Recommend,
          children: [
            {
              path: ':id',
              component: Disc
            }
          ]
        },
    
    <div class="recommend-list">
              <h1 class="list-title">热门歌单推荐</h1>
              <ul>
                <li @click="selectItem(item)" v-for="item in discList" class="item">
                  <div class="icon">
                    <img width="60" height="60" v-lazy="item.imgurl">
                  </div>
                  <div class="text">
                    <h2 class="name" v-html="item.creator.name"></h2>
                    <p class="desc" v-html="item.dissname"></p>
                  </div>
                </li>
              </ul>
            </div>
          </div>
          <div class="loading-container" v-show="!discList.length">
            <loading></loading>
          </div>
        </scroll>
        <router-view></router-view>
      </div>
    </template>
    
    selectItem(item) {
            this.$router.push({
              path: `/recommend/${item.dissid}`
            })
            this.setDisc(item)
          },
    

    正则整理数据结构

    看下面的数据,其实请求接口的时候设置了json但是还是返回了,jsonp的格式怎么办?

    if (typeof ret === 'string') {
    //以字母开始 +表示一位或多位 \( \) 转义小括号 ()用它来分组 {} 表示下面的{}符号 \(\)表示不是小括号的任意字符 +表示一个或者多个
          var reg = /^\w+\(({[^\(\)]+})\)$/
          var matches = response.data.match(reg)
          if (matches) {
            ret = JSON.parse(matches[1])
          }
        }
    

    在这里插入图片描述

    打包后如何运行dist目录

    1、npm run build 是本地打包 2、新建prod.server.js 最后运行 node prod.server.js 就能把项目跑起来

    var express = require('express')
    var config = require('./config/index')
    var axios = require('axios')
    
    var port = process.env.PORT || config.build.port
    
    var app = express()
    
    var apiRoutes = express.Router()
    
    apiRoutes.get('/getDiscList', function (req, res) {
      var url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg'
      axios.get(url, {
        headers: {
          referer: 'https://c.y.qq.com/',
          host: 'c.y.qq.com'
        },
        params: req.query
      }).then((response) => {
        res.json(response.data)
      }).catch((e) => {
        console.log(e)
      })
    })
    
    apiRoutes.get('/lyric', function (req, res) {
      var url = 'https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg'
    
      axios.get(url, {
        headers: {
          referer: 'https://c.y.qq.com/',
          host: 'c.y.qq.com'
        },
        params: req.query
      }).then((response) => {
        var ret = response.data
        if (typeof ret === 'string') {
          var reg = /^\w+\(({[^\(\)]+})\)$/
          var matches = response.data.match(reg)
          if (matches) {
            ret = JSON.parse(matches[1])
          }
        }
        res.json(ret)
      }).catch((e) => {
        console.log(e)
      })
    })
    
    app.use('/api', apiRoutes)
    
    app.use(express.static('./dist'))
    
    module.exports = app.listen(port, function (err) {
      if (err) {
        console.log(err)
        return
      }
      console.log('Listening at http://localhost:' + port + '\n')
    })
    

    是为了在build里面加port 让项目跑在这个端口

    config/index.js

    // see http://vuejs-templates.github.io/webpack for documentation.
    var path = require('path')
    module.exports = {
      build: {
        env: require('./prod.env'),
        port: 9000,
        index: path.resolve(__dirname, '../dist/index.html'),
        assetsRoot: path.resolve(__dirname, '../dist'),
        assetsSubDirectory: 'static',
        assetsPublicPath: '',
        productionSourceMap: true,
        // Gzip off by default as many popular static hosts such as
        // Surge or Netlify already gzip all static assets for you.
        // Before setting to `true`, make sure to:
        // npm install --save-dev compression-webpack-plugin
        productionGzip: false,
        productionGzipExtensions: ['js', 'css'],
        // Run the build command with an extra argument to
        // View the bundle analyzer report after build finishes:
        // `npm run build --report`
        // Set to `true` or `false` to always turn it on or off
        bundleAnalyzerReport: process.env.npm_config_report
      },
      dev: {
        env: require('./dev.env'),
        port: 8080,
        autoOpenBrowser: true,
        assetsSubDirectory: 'static',
        assetsPublicPath: '/',
        proxyTable: {},
        // CSS Sourcemaps off by default because relative paths are "buggy"
        // with this option, according to the CSS-Loader README
        // (https://github.com/webpack/css-loader#sourcemaps)
        // In our experience, they generally work as expected,
        // just be aware of this issue when enabling this option.
        cssSourceMap: false
      }
    }
    
    

    如何优化首屏加载 提到了路由懒加载

    让组件等于一个方法,这个方法再resolve这个组件

    import Vue from 'vue'
    import Router from 'vue-router'
    
    Vue.use(Router)
    
    const Recommend = (resolve) => {
      import('components/recommend/recommend').then((module) => {
        resolve(module)
      })
    }
    
    const Singer = (resolve) => {
      import('components/singer/singer').then((module) => {
        resolve(module)
      })
    }
    
    const Rank = (resolve) => {
      import('components/rank/rank').then((module) => {
        resolve(module)
      })
    }
    
    const Search = (resolve) => {
      import('components/search/search').then((module) => {
        resolve(module)
      })
    }
    
    const SingerDetail = (resolve) => {
      import('components/singer-detail/singer-detail').then((module) => {
        resolve(module)
      })
    }
    
    const Disc = (resolve) => {
      import('components/disc/disc').then((module) => {
        resolve(module)
      })
    }
    
    const TopList = (resolve) => {
      import('components/top-list/top-list').then((module) => {
        resolve(module)
      })
    }
    
    const UserCenter = (resolve) => {
      import('components/user-center/user-center').then((module) => {
        resolve(module)
      })
    }
    

    vue升级的注意事项

    在devDependencies里面的vue-template-compiler一定要和vue的版本保持一致否则编译的时候会报错。

    展开全文
  • 最近看了一个vue移动音乐webapp教程,老师是一个来自滴滴公司的名为黄轶的前端大神,之前学习了他的一个基于vue仿饿了么webapp的初级教程,感觉非常好,十分适合新手,但是那个教程的数据都是前端模拟的一个data....
  • Vue 全家桶实现网易云音乐 WebApp

    千次阅读 2018-05-16 19:41:47
    基于 Vue(2.5) + vuex + vue-router + vue-axios +better-scroll + Scss + ES6 等开发一款移动端音乐 WebApp,UI 界面参考了安卓版的网易云音乐、flex 布局适配常见移动端。因为服务器的原因,所以可能多人访问的...
  • vue音乐webApp案例分析总结

    千次阅读 2018-03-01 12:40:16
    看了慕课网的vue音乐webApp实战,感觉自己学到了很多东西,无论对vue的整体架构和组件设计都有了清晰的认识,在以后的工作中对组件封装时可能会考虑的多一些,能够有效的掌握组件的边界条件,分清木偶组件和智能组件...
  • 项目预览地址:http://ustbhuangyi.com/music/#/recommend 获取歌曲 url 地址方法升级:https://github.com/ustbhuangyi/vue-music/issues/111 参考: ...
  • Cool Music一个基于 Vue2.0 版本的音乐 WebAPP
  • 基于vue的仿网易云音乐webapp
  • 用vue实现简易的音乐webApp

    千次阅读 2017-08-24 13:42:00
    数据是实时抓取自QQ音乐的api接口,主要的功能实现是对网页版的qq音乐功能来做参考。 2、关于项目 这个小项目用了webpack+vue全家桶+es6等技术栈来实现的,基本实现了音乐播放,数据的动态抓取,因为要开学了,...
  • 网易云音乐接口 vue全家桶开发一款移动端音乐webApp
  • 基于 Vue(2.5) + vuex + vue-router + vue-axios +better-scroll + Scss + ES6 等开发一款移动端音乐 WebApp,UI 界面参考了安卓版的网易云音乐、flex 布局适配常见移动端。 因为服务器的原因,所以可能多人访问的...
  • 基于vu2(2.5) vuex vue-router vue-axios better-scroll scss es6等开发一款音乐WebApp,UI界面参考了移动端QQ音乐、flex布局适配常见移动端
  • 基于Vue (2.x) 全家桶制作的移动端音乐 WebApp ,所有数据均来自于qq音乐线上数据。
  • 打造移动端音乐WebAPP,实现了轮播图、音乐推荐、歌手列表、音乐搜索、注册等功能。 技术栈 MVVM框架:Vue.js 2.0 状态管理:Vuex 前端路由:Vue-router 数据交互:Vue-resource、axios、jsonp 打包工具:webpack ...
  • vue 移动端音乐webapp

    2019-06-10 12:38:56
    本人一直从事前端相关开发的工作,最近利用业余时间,仿照qq音乐做了一个移动webapp.用到的技术:vue2.3、vue-cli、vue-router、vue-lazyload、axios、vuex、移动端滚动插件 better-scroll 、fastclick等,下面是...
1 2 3 4 5 ... 20
收藏数 2,328
精华内容 931
热门标签
关键字:

音乐webapp