1. 程式人生 > >【音樂App】—— Vue-music 專案學習筆記:推薦頁面開發

【音樂App】—— Vue-music 專案學習筆記:推薦頁面開發

前言:以下內容均為學習慕課網高階實戰課程的實踐爬坑筆記。

上一篇總結了專案概述、專案準備、頁面骨架搭建。這一篇重點梳理推薦頁面開發。專案github地址:https://github.com/66Web/ljq_vue_music,歡迎Star。


 

一、頁面簡介+輪播圖資料分析
  • 資料:從QQ音樂抓取的真實資料
輪播圖 熱門歌單推薦
二、JSONP原理介紹
  • 一句話解釋JSONP原理:動態生成一個JavaScript標籤,其src由介面url、請求引數、callback函式名拼接而成;利用js標籤沒有跨域限制的特性實現跨域請求
  • 有幾點需要注意:
  1. callback函式要繫結在window物件上
  2. 服務端返回資料有特定格式要求:callback函式名+’(‘+JSON.stringify(返回資料) +’)’
  3. 不支援post,因為js標籤本身就是一個get請求
  • 什麼是Promise:
  1. 簡單說就是一個容器,裡面儲存著某個未來才會結束的事件 (通常是一個非同步操作)的結果。
  2. 從語法上說,Promise是一個物件,從它可以獲取非同步操作的訊息
  • Promise基本用法:
  1. ES6規定,Promise物件是一個建構函式,用來生成Promise例項
    var
    promise = new Promise(function(resolve,reject){ // ... some code if(/* 非同步操作成功 */){ resolve(value); }else{ reject(error); } });
  2. Promise建構函式接受一個函式作為引數,該函式的兩個引數分別是resolve和reject。它們是兩個函式,由JavaScript引擎提供,不是自己部署。
  3. Promise例項生成以後,可以用then方法分別制定Resolved狀態和Rejected狀態的回撥函式:
    promise.then(function
    (value){ // sucess },function(error){ // failure });
三、JSONP
  • 安裝JSONP依賴:
    npm install jsonp --save
四、封裝JSONP、Primise
  • common->js目錄下: 建立 jsonp.js
    import originJSONP from 'jsonp'
    
    export default function jsonp(url, data, option) {
              url += (url.indecOf('?') < 0 ? '?' : '&') + param(data);
    
              return new Promise((resolve, reject) => {
                   originJSONP(url, option, (err, data) => {
                        if(!err){
                             resolve(data)
                        }else{
                             reject(err)
                        }
                  })
             })
    }
    
    function param(data) {
            let url = ""
            for(var k in data){
                 let value = data[k] !== undefined ? data[k] : ''
                 url += `&${k}=${encodeURIComponent(value)}`
            }
            return url ? url.substring(1) : ''
    }
五、JSONP的應用+輪播圖資料抓取
  • api目錄下建立 config.js:配置與介面統一的引數
    /**
    * 為了和QQ音樂介面一致,配置一些公用的引數、options和err_num碼
    */
    export const commonParams = {
              g_tk: 5381,  //會變,以實時資料為準
              inCharset: 'utf-8',
              outCharset: 'utf-8',
              notice: 0,
              format: 'jsonp'
    }
    
    export const options = {
              param: 'jsonpCallback'
    }
    
    export const ERR_OK = 0
  • api目錄下建立 recommend.js
    import jsonp from '@/common/js/jsonp'
    import {commonParames, options} from './config'
    
    export function getRecommend() {
           const url = 'https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg'
            
           const data = Object.assign({}, commonParames, {
                 platfrom: 'h5',
                 uin: 0,
                 needNewCode: 1
           })
           return jsonp(url, data, options)
    }
  • recommend.vue中呼叫並獲取資料
    import {getRecommend} from '@/api/recommend'
    import {ERR_OK} from '@/api/config'
    
    export default {
        created() {
               this._getRecommend();
        },
        methods: {
               _getRecommend() {
                       getRecommend().then((res) => {
                             if(res.code === ERR_OK) {
                                  console.log(res.data.slider)
                             }
                       })
                 }
         }
    }
六、 輪播圖元件實現
  • base目錄下: 建立slider.vue元件
  • 插槽<slot></slot>:外部引用slider.vue時,<slider></slider>裡面包裹的DOM,會被插入到插槽的部分
    <div class="slider-group">
         <slot></slot>
    </div>
  • recommend.vue 中編寫插槽中的DOM:
    <slider>
          <div v-for="(item, index) in recommends" :key="index">
                 <a :href="item.linkUrl">
                        <img :src="item.picUrl">
                 </a>
           </div>
    </slider> 
  • slider.vue 中指定需要從父元件接收的屬性:loop是否迴圈、autoPlay是否自動播放、interval間隔時間
    props: {
         loop: {
              type: Boolean,
              default: true
        },
        autoPlay: {
              type: Boolean,
              default: true
        },
        interval: {
              type: Number,
              default: 4000
        }
    }
  • 橫向滾動:使用better-scroll

better-scroll中文文件:https://ustbhuangyi.github.io/better-scroll/doc/zh-hans/options-advanced.html#snap

better-scroll中的相關選項:

  • snap:專門為slide元件使用的
  • SnapSpeed : 輪播圖切換的動畫時間

 

  1. 安裝better-scroll依賴:
    npm install better-scroll --save
  2. slider.vue 中引用:
    import BScroll from 'better-scroll'
  3. ref引用外層容器和內層元素:

    <div class="slider" ref="slider">
    <div class="slider-group" ref="sliderGroup">
  4. common->js目錄下建立 dom.js:封裝一些DOM操作相關的程式碼

    //為元素新增Class、判斷元素是否有指定class
    export function addClass(el, className){
             if(hasClass(el, className)){
                      return
             }
             let newClass = el.className.split(' ')
             newClass.push(className)
             el.className = newClass.join(' ')
    }
    
    export function hasClass(el, className){
            let reg = new RegExp('(^|\\s)' + className + '(\\s|$)')
            return reg.test(el.className)
    }
  5. slider.vue 中引用:
    import {addClass, hasClass} from '@/common/js/dom'
  6. 在methods中定義兩個方法:設定slider寬度、初始化slider

    methods: {
         _setSliderWidth() {
                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')//為迴圈生成的slider子元素,動態新增slider-item class
                     child.style.width = sliderWidth + 'px'//不要忘記加單位!
                     width += sliderWidth
                }
    
                if(this.loop){ //如果loop為true,BScroll的snap屬性會左右克隆兩個DOM,保證迴圈切換
                    width += 2 * sliderWidth
                }
    
                this.$refs.sliderGroup.style.width = width + 'px'//不要忘記加單位!
         },
         _initSilder() {
                this.slider = new BScroll(this.$refs.slider,{
                      scrollX: true, //橫向滾動
                      scrollY: false, //禁止縱向滾動
                      momentum: false,//禁止慣性運動
                      snap: {
                           loop: this.loop,
                           threshold: 0.3,
                           speed: 400
                     }
               })
        }
    }
  7. 初始化BScroll的時機:必須保證元件已經渲染好了,DOM高度已經被撐開
    //在mouted生命鉤子中通過setTimeout呼叫:
    mouted() {
         setTimeout(() => {
                this._setSliderWidth()
                this._initSlider()
          }, 20)
    }
  8. 坑:recommend.vue中直接引用了<slider>,recommends的引用時機是在created()中呼叫了_getRecommend(),_getRecommend()的這個時間是一個非同步過程,可能會有延遲,因為它取的是真實資料;因此,當recommends還沒有get到時,即還沒有填入任何資料時,slider.vue中的mouted()實際上已經執行了。
  9. 解決:recommend.vue中為slider-wrapper新增v-if="recommends.length",確保recommends陣列中有內容時,才渲染<slider>
    <div v-if="recommends.length" class="slide-wrapper">
  • 新增dots區塊,實現自動輪播
  1. data中維護一個數據dots,預設是一個空陣列
    dots: []
  2. methods中初始化Dots:
    _initDots() {
        this.dots = new Array(this.children.length)
    }
  3. 渲染dots:
    <span class="dot" v-for="(item, index) in dots" :key="index"></span>
  4. 選中高亮:
    /**  data中維護一個數據currentPageIndex:0,表示當前預設是第一頁
     *   v-bind動態繫結 :class="{active: currentPageIndex === index}">
     *   在_initSlider()方法中給slider新增事件:
    */
    
    this.slider.on('scrollEnd', () => { //當一個頁面滾動完畢後,會派發一個scrollEnd事件
             let pageIndex = this.slider.getCurrentPage().pageX //獲得slider的pageIndex
             if(this.loop) { //如果是迴圈,snap會預設給子元素前面增加一個拷貝
                pageIndex -= 1 //要得到實際的pageIndex,pageInde需要-1
             }
             this.currentPageIndex = pageIndex
    })
  5. 自動播放:
    //mounted()->setTimeout中判斷autoplay屬性,呼叫_play(): 
    if(this.autoplay) {
       this._play()
    }
    
    //methods中定義_play():
    _play() {
       let pageIndex = this.currentPageIndex + 1;//this.currentPageIndex從0開始的
       if(this.loop) {
          pageIndex += 1//loop為true時,最開始有一個複製的副本,實際的pageIndex需要+1
       }
       this.timer = setTimeout(() => { //頁面的切換,利用BScroll的介面goToPage
            this.slider.goToPage(pageIndex, 0, 400) //引數:X方向、Y方向、時間間隔
       },this.interval)
    }
  6. 坑:使用setTimeout,只會執行一次,從第一張自動滾動到第二張就停止了。
  7. 解決:scrollEnd事件中新增:
    if(this.autoPlay) {
       this._play()
    }
  8. 坑:自動滾動後不到400ms時,手動滑動後又執行了自動滾動,體驗效果會很奇怪
  9. 解決:slider 新增 beforeScrollStart事件
    this.slider.on('beforeScrollStart', () => {
          if (this.autoPlay) {
              clearTimeout(this.timer)
          }
    })
  10. 坑:在滾動中,改變視口大小,圖片會同時顯示兩張,因為之前設定好的width都沒變
  11. 解決:mounted中監聽window的resize事件 —— 視窗改變事件,當視窗改變時,重新呼叫_setSlideWidth()
  12. 坑:如果視窗變和不變時都呼叫_setSlideWidth(),就會執行兩次width += 2 * sliderWidth,這一定是不對的
  13. 解決:呼叫_setSlideWidth(),需要同時傳入一個引數,用來判斷視窗是否改變了
    window,addEventListener('resize',(() => {
          if(!this.slider) {
              return
          }
          this._setSliderWidth(true)
          this.slider.refresh()
    }))
    
    _setSliderWidth(isResize) {
         //其它程式碼
         if(this.loop && !isResize){ 
             width += 2 * sliderWidth
         }
    }
  14. App.vue 中優化:快取DOM到記憶體中,不用重新發送請求,這樣slider就不會有閃動的現象

    <keep-alive>
          <router-view></router-view>
    </keep-alive> 
  15. slider中優化:當元件中有定時器,一定要記得在元件銷燬時清理掉這些定時器,使用生命週期destroyed()
    destroyed() {
        clearTimeout(this.timer)
    }
七、歌單資料介面分析

問題: QQ音樂歌單資料的請求頭中有域名Host、來源Referer,所以請求的介面應該是有加上該域名和來源,直接請求就會報HTTP-500錯誤。

原因: 前端不能直接修改request header,所以要通過後端代理的方式解決

解決: 採用 axios 在node.js中傳送http請求

  •  安裝axios: 
    npm install axios --save
  • build->webpack.dev.conf.js
  1. 定義路由,通過axios傳送一個Http請求,同時修改header中的和QQ相關的Host、Referer,
  2. 將瀏覽器傳遞過來的引數全部傳給服務端,然後通json響應的內容輸出到瀏覽器端。
  3. 在 const portfinder = require('portfinder') 後新增:
    const express = require('express')
    const axios = require('axios')
    const app = express()
    var apiRoutes = express.Router()
    app.use('/api', apiRoutes)
  4. devServer 中新增:
    before(app) {
       //定義getDiscList介面,回撥傳入兩個引數,前端請求這個介面
       app.get('/api/getDiscList', function(req, res){
             var url = "https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg"
             axios.get(url, {
                  headers: { //通過node請求QQ介面,傳送http請求時,修改referer和host
                  referer: 'https://y.qq.com/',
                  host: 'c.y.qq.com'
            },
            params: req.query //把前端傳過來的params,全部給QQ的url
       }).then((response) => { //成功與失敗的回撥
            res.json(response.data)
       }).catch((e) => {
            console.log(e)
       })
    })
  • recommend.js中:
    import axios from 'axios';
    
    export function getDiscList() {
            // const url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg'
            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' //使用的時axios,所以format使用的是json,不是jsonp
           })
    
           // return jsonp(url, data, options)
           return axios.get(url, {
                   params: data
           }).then((res) => {
                  return Promise.resolve(res.data) //es6新語法,返回一個以給定值解析後的Promise物件
           })
    }
  • Promise.resolve(value)方法返回一個以給定值解析後的Promise物件;但如果這個值是個thenable(即帶有then方法),返回的promise會“跟隨” 
  • 這個thenable的物件,採用它的最終狀態(指resolved/rejected/pending/settled);
  • 如果傳入的value本身就是promise物件,則該物件作為Promise.resolve方法的返回值返回;否則以該值為成功狀態返回promise物件。
  • recommend.vue中:定義和呼叫獲取資料的方法
    //created()中:
     this._getDiscList(); 
    
    //methods中:
     _getDiscList() {
        getDiscList().then((res) => {
             if(res.code === ERR_OK) {
                console.log(res.data)
             }
       })
    }
八、歌單列表元件開發和資料的應用
  • data中定義資料:  
    discList: []
  •  _getDiscList()中將返回的資料list賦給discList:
    this.discList = res.data.list
  • 使用 v-html="item.creator.name" 給html字元做轉義
    <div class="recommend-list">
         <h1 class="list-title">熱門歌單推薦</h1>
         <ul>
             <li v-for="(item, index) in discList" :key="index" class="item">
                 <div class="icon">
                       <img :src="item.imgurl" width="60" height="60">
                 </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>
  • CSS樣式:經典flex佈局
  1. 左邊固定寬高,右邊根據手機視口寬度自適應
  2. 右側:
    .item
        display: flex 
        align-items:center //水平方向居中
  3. 右側文字內容:
    .text
        display: flex
        flex-direction: column //縱向排列
        justify-content: center //垂直居中
  4. 一個元素,既可以是flex佈局的item,同時也可做flex佈局
九、scroll元件的抽象和應用
  •   better-scroll滾動佈局:只會滾動父元素下的第一個子元素 —— 想要slider和recommend-list同時可以滾動,需要在外層再巢狀一個<div>,將兩個元素包裹起來
  •  抽象出scorll元件 -- 基礎元件
  1. base->scroll目錄下: 建立 scroll.vue
  2. 佈局DOM:一個wrapper加一個插槽
    <template>
       <div ref="wrapper">
           <slot></slot>
       </div>
    </template>
  3. 引入BScroll:

    import BScroll from 'better-scroll'
  4. 需要傳入props引數:
    props: {
       //probeType: 1 滾動的時候會派發scroll事件,會截流。2 滾動的時候實時派發scroll事件,不會截流 。3 除了實時派發scroll事件,在swipe的情況下仍然能實時派發scroll事件
       probeType: { 
                type: Number, 
                default: 1
       },
      // click: true 是否派發click事件,通常判斷瀏覽器派發的click還是betterscroll派發的click,可以用event._constructed,若是bs派發的則為true
       click: { 
                type: Boolean,
                default: true
       },
       data: {
                type: Array,
                default: null
       }
    }
  5. 確保DOM已經渲染,再執行_initScroll:
    mouted() {
        setTimeout(() => { //確保DOM已經渲染
             this. _initScroll()
        }, 20)
    }
  6. methods中定義初始化scroll的方法,並代理幾個必需的方法:
    methods: {
          _initScroll() {
                if(!this.$refs.wrapper){
                         return
                }
                this.scroll = new BScroll(this.$refs.wrapper, {
                      probeType : this.probeType,
                      click: this.click
                })
          },
          enable() {
                // 啟用 better-scroll,預設開啟
                this.scroll && this.scroll.enable()
          },
          disable() {
               // 禁用better-scroll, 如果不加,scroll的高度會高於內容的高度
               this.scroll && this.scroll.disable()
          },
          refresh() {
               // 強制 scroll 重新計算,當 better-scroll 中的元素髮生變化的時候呼叫此方法
               this.scroll && this.scroll.refresh()
          }
    }
  7. watch監聽data資料:
    watch: {
         data() { //監測data的變化
              setTimeout(() => {
                   this.refresh()
              }, 20)
         }
    }
  8. 後面在專案的開發中,可以根據需要再隨時新增props引數和methods代理方法
  • recommend.vue 中使用:
  1. 引用scroll元件:
    import Scroll from '@/base/scroll/scroll'
  2. 把class="recommend-content"的<div>改成<scroll>
  3. 坑:此時scroll已經初始化了,但還不能滾動
  4. 原因:scroll初始化的時機,是在scroll元件的mounted();但<scroll>包含的DOM是由獲取到的data資料填充撐開高度才可以滾動,此時還沒撐開,就滾動不了;當資料改變後,scroll應該改變
  5. 解決:<scroll>傳入一個數據 :data="discList";當資料discList接收到時,scroll元件中的watch監聽到這個變化,就會強制scroll重新計算
  6. 坑:因為整個頁面會有兩個部分都是請求資料,當_getRecommend()的請求時間大於this._getDiscList()的時候,頁面的高度就不夠
  7. 如果:如下 ↓ 滾動的高度就會差一個slider的高度,滾不到底部。
    因為refresh()之前,slider的資料還沒有渲染出來,scroll會認為,需要滾動的高度,只是列表的高度
    created() { 
        setTimeout(() => {
             this._getRecommend();
        }, 1000) 
        this._getDiscList(); 
    }
  8. 實際中,並不能知道兩個部分,哪一個會先出現,需要注意還有一個坑:不能用計算屬性計算兩個部分的資料
  9. 原因:與圖片的載入,視口的大小(實時圖片的寬高)有關。
  10. 解決:給<img>新增onload事件
    <img :src="item.picUrl" @load="loadImage">
    loadImage() {
         if(!this.checkloaded){ //新增一個標誌位,如果load一次了,就不再執行onload事件了
             this.checkloaded = true
             this.$refs.scroll.refresh()
         }
    }
十、 lazyload懶載入外掛介紹和應用
  • 歌單優化:歌單是由很多張圖片組成的,使用vue-lazyload外掛 解決圖片懶載入 的問題
  • vue-lazyload github地址: https://github.com/hilongjw/vue-lazyload
  • 安裝外掛: 
    npm install vue-lazyload --save
  • 引用註冊: main.js 中
    import VueLazyload from 'vue-lazyload'
    
    Vue.use(VueLazyload, {
       loading: require('@/common/image/default.png') //loading時預設顯示的圖片
    })
  • 使用外掛:recommend.vue 中把歌單列表<img>中原來的 :src替換為v-lazy
    <img v-lazy="item.imgurl" width="60" height="60">
  • 這樣,只有使用者滾動過的地方,圖片才會載入,沒有看的地方,就不會進行載入
  • 問題:fastclick和better-scroll的click會有衝突.
  • 解決:slider中的<img>新增一個class="needsclick",這是fastclick中的一個屬性
    <img class="needsclick" :src="item.picUrl" @load="loadImage">

 

十一、 loading基礎元件的開發和應用
  • 優化體驗:在歌單列表沒有渲染好之前,展示一個轉圈loading
  • 佈局DOM:
    <div class="loading">
           <img width="24" height="24" src="./loading.gif">
           <p class="desc">{{title}}</p>
    </div>
  • props引數:
    props: {
         title: {
             type: String,
             default: '正在載入...'
         }
    }
  • CSS樣式: View Code
  • recommend.vue 中引用註冊,在<scroll>中使用:
    <div class="loading-container" v-show="!disList.length">
           <loading></loading>
    </div>

 


注:專案來自慕課網