Vue2 - 网易云音乐项目笔记(基于Vant UI组件库)

目录

  • 一、项目技术
  • 二、准备工作
    • 1、初始化Vue项目
    • 2、配置Vant UI组件库
    • 3、下载并使用vue-router库
    • 4、接口API
    • 5、postcss插件
  • 三、分析页面实现功能
    • 1、路由页面准备
    • 2、封装axios网路请求
    • 3、公共组件
  • 四、具体页面实现
    • 1、Layout组件相关
    • 2、Home组件相关
      • ① 封装home相关axios请求
      • ② Home组件代码
    • 3、Search组件相关
      • ① 封装search相关axios请求
      • ②Search组件代码
    • 4、Comment组件相关
      • ① 封装comment相关axios请求
      • ②Comment组件代码
    • 5、Play组件相关
      • ① 封装play相关axios请求
      • ②Play组件代码
    • 6、API统一出口
  • 五、Play和Comment组件详解

一、项目技术

基于 Vue + Vant UI组件库 实现网易云音乐
Vant UI (有赞团队打造的移动端Vue组件库)地址:https://vant-contrib.gitee.io/vant/#/zh-CN

共项目共实现四个功能:主页Home、搜索页Search、评论页Comment、播放页Play
网易云手机端地址:https://y.music.163.com/m

二、准备工作

1、初始化Vue项目

① 安装Vue脚手架

npm i @vue/cli -g

② 切换到要创建项目的目录,然后创建music项目

vue create music

③ 启动项目

npm run serve

2、配置Vant UI组件库

① 下载vant ui库
注意项:Vant组件库要下载配合Vue2版本的使用

npm i vant@latest-v2

② 配置按需引入样式
在基于 vue-cli 的项目中使用 Vant 时,可以使用 unplugin-vue-components 插件,它可以自动引入组件,并按需引入组件的样式。
安装插件

npm i unplugin-vue-components -D

配置插件:在 vue.config.js 文件中配置插件

const { VantResolver } = require('unplugin-vue-components/resolvers');
const ComponentsPlugin = require('unplugin-vue-components/webpack');

module.exports = {
  configureWebpack: {
    plugins: [
      ComponentsPlugin({
        resolvers: [VantResolver()],
      }),
    ],
  },
};

优点:减少代码体积
缺点:使用起来会变得繁琐一些

3、下载并使用vue-router库

① 下载vuex库(vue2中必须使用vue-router3版本)

npm i vue-router@3

② 引入

import VueRouter from "vue-router"

③ 使用VueRouter

Vue.use(VueRouter)

④ 使用VueRouter之后,在创建vm的时候就可以传入一个router配置项

new Vue({
	//使用VueRouter之后,创建vm的时候可以传入一个router配置项
    router,
    render:h=>h(App),
}).$mount("#app")

4、接口API

项目地址:https://neteasecloudmusicapi.js.org/#/
文档地址:https://binaryify.github.io/NeteaseCloudMusicApi/#/
① 安装项目

// 从github上克隆项目到本地
git clone git@github.com:Binaryify/NeteaseCloudMusicApi.git

// 安装依赖包
npm install

② 运行项目

node app.js

③ 本地地址
http://localhost:3000
Vue2 - 网易云音乐项目笔记(基于Vant UI组件库)_第1张图片

5、postcss插件

webpack的一个插件,将要px转化为rex插件
中文官网:https://www.postcss.com.cn/
① 安装postcss

npm install postcss

② 安装postcss-pxtorem(最新版本6.0与Vant不兼容,因此安装5.1.1版本)
postcss-pxtorem是postcss插件,把px转换成rem

npm install postcss-pxtorem@5.1.1 --s-d

③ 根目录下创建postcss.config.js文件

module.exports = {
    plugins: {
        'postcss-pxtorem': {
            rootValue: 37.5, // 已设计稿宽度375px为例 vant用的是375的设计稿
            propList: ['*'],
        },
    },
};

三、分析页面实现功能

分析:
根据项目页面构成,我们要创建4个vue组件,分别是views/Home、views/Search、views/Comment、views/Play

页面之间通过路由跳转,因此要创建文件夹 router/index.js

页面所需要的数据是通过发送网络请求获取的,因此要创建untils/request.js文件,api/index.js作为统一出口

首页和搜索页的顶部和底部一样,可以将这部分提取为一个组件,因此要创建views/layout.vue

首页最新新闻和搜索页最佳匹配样式结构一样,因此也可以提取出来,创建components/songItem.vue组件

1、路由页面准备

layout属于一级路由,home和search属于二级路由;comment、play属于一级路由
src/router/index.js文件代码

import Vue from "vue";

import VueRouter from "vue-router";

import Layout from "@/views/Layout";
import Home from "@/views/Home";
import Search from "@/views/Search";
import Play from "@/views/Play";
import Comment from "@/views/Comment"

Vue.use(VueRouter);

export default new VueRouter({
    routes:[
        {
            path:"/",
            redirect:'/layout',
        },
        {
            path:"/layout",
            redirect:'/layout/home',
            component:Layout,
            children:[
                {
                    name:'home',
                    path:'home',
                    component:Home,
                    meta:{
                        title:'首页'
                    }
                },{
                    name:'search',
                    path:'search',
                    component:Search,
                    meta:{
                        title:'搜索'
                    }
                }
            ]
        },
        {
            name:'play',
            path:'/play',
            component:Play,
        },
        {
            name:'comment',
            path:'/comment',
            component:Comment
        }
    ]
})

2、封装axios网路请求

新建untils/request.js

import axios from "axios";

// axios.create()创建一个axios对象
const request = axios.create({
    //基础路径,发请求的时候,路径当中会出现api,不用你手写
	baseURL:'http://localhost:3000',
	//请求时间超过5秒
	timeout:5000
});

export default request

3、公共组件

components/songItem.vue组件

<template>
        <van-cell :id="id" :title="name" :label="autor" @click="getComment(id)">
        <template #right-icon>
            <van-icon  name="play-circle-o" class="search-icon" @touchstart ="playFn(id)"/>
        template>
        van-cell>
template>
<script>

export default {
    name:'SongItem',
    props:{name:String,autor:String,id:Number},
    methods:{
        playFn(id){
            this.$router.push({
                name:'play',
                query:{
                    id:id
                }
            });
            console.log(id);
        },
        getComment(id){
            this.$router.push({
                name:'comment',
                query:{
                    id
                }
            })
        }
    }
}
script>

四、具体页面实现

1、Layout组件相关

views/Layout/index.vue

<template>
    <div>
        
        <van-nav-bar :title="activeTitle" fixed/>
        
        <div class="main">
            
            <keep-alive include="Home,Search">
                <router-view>router-view>
            keep-alive>
        div>
        
        <van-tabbar v-model="active">
            <van-tabbar-item icon="home-o" to="/layout/home">首页van-tabbar-item>
            <van-tabbar-item icon="search" to="/layout/search">搜索van-tabbar-item>
        van-tabbar>
    div>

template>

<script>
import { ref } from 'vue';
export default {
    name:'Layout',
    data(){
        return {
            activeTitle:this.$route.meta.title,
        }
    },
    setup() {
        const active = ref(0);
        return {
           active,
        };
    },
    // 通过监听路由切换
    watch:{
        $route(){
            this.activeTitle = this.$route.meta.title
        }
    }
}
script>
<style scoped>
    .main {
        padding-top: 56px;
        padding-bottom: 66px;
    }
style>

2、Home组件相关

① 封装home相关axios请求

api/home.js

import request from "@/untils/request";

// 推荐音乐
export const hotMusic = (params)=>request({url:'/personalized',params})

// 最新音乐
export const newMusic = (params)=>request({url:'/personalized/newsong',params})

② Home组件代码

views/Home/index.vue

<template>
  <div>
    <p class="title">推荐歌单p>
    <van-row gutter="10" >
        <van-col span="8" v-for="m in hotMusic" :key="m.id">
          <van-image width="100%" height="3rem" :src="m.picUrl"/>
          <p class="song_name">{{m.name}}p>
        van-col>
    van-row>

    <p class="title">最新音乐p>
    <SongItem v-for="n in newMusic" :key="n.id" :name="n.name" :autor="n.song.artists[0].name + n.name" :id="n.id">SongItem>
  div>
template>
<script>
import SongItem from "@/components/songItem.vue";
import {hotMusicAPI,newMusicAPI} from "@/api";
export default {
  name:'Home',
  data(){
    return {
      hotMusic:[],
      newMusic:[],
    }
  },
  components:{SongItem},
  async created(){
    // 获取推荐歌曲
    const resHot= await hotMusicAPI({limit:6})
    this.hotMusic = resHot.data.result;
    // 获取最新音乐
    const resNew = await newMusicAPI({limit:20});
    this.newMusic = resNew.data.result;
  },
}
script>

<style>
/* 标题 */
.title {
  padding: 0.266667rem 0.24rem;
  margin: 0 0 0.24rem 0;
  background-color: #eee;
  color: #333;
  font-size: 15px;
}
/* 推荐歌单 - 歌名 */
.song_name {
  font-size: 0.346667rem;
  padding: 0 0.08rem;
  margin-bottom: 0.266667rem;
  word-break: break-all;
  text-overflow: ellipsis;
  display: -webkit-box; /** 对象作为伸缩盒子模型显示 **/
  -webkit-box-orient: vertical; /** 设置或检索伸缩盒对象的子元素的排列方式 **/
  -webkit-line-clamp: 2; /** 显示的行数 **/
  overflow: hidden; /** 隐藏超出的内容 **/
}
style>

3、Search组件相关

① 封装search相关axios请求

import request from "@/untils/request";

//热词
export const hotSearch = ()=>request({url:'/search/hot/detail'});

// 关键词搜索

export const keywordSearch = (params) => request({url:"/cloudsearch",params})

②Search组件代码

<template>
    <div>
        <van-search v-model="searchValue" placeholder="请输入搜索关键词" shape="round"/>
         
        <div class="search_wrap" v-if="searchValue.length==0">
            <p class="hot_title">热门搜索p>
            <div class="hot_name_wrap">
                <span class="hot_item" v-for="h in hotSearch" :key="h.score" @click="btn(h.searchWord)">{{h.searchWord}}span>
            div>
        div>
        
        <div class="search_wrap" v-else>
            <p class="hot_title">最佳匹配p>
             <SongItem v-for="k in keywordSearch" :key="k.id" :name="k.name" :autor="k.ar[0].name + ' / '+ k.al.name" :id="k.id">SongItem>
        div>
    div>
template>
<script>
import {hotSearchAPI,keywordSearchAPI} from "@/api";
import SongItem from "@/components/songItem.vue";
export default {
    name:'Search',
    data() {
        return {
            searchValue:'', // 搜索框的值
            hotSearch:[],    // 热词
            keywordSearch:[]    //关键词搜索结果
        }
    },
    components:{SongItem},
    async created(){
        //获取热搜词
       const hotSearch = await hotSearchAPI();
       this.hotSearch = hotSearch.data.data;
    },
    methods:{
        async btn(str){
            this.searchValue = str;
            const keywordSearch = await keywordSearchAPI({keywords:this.searchValue,type:1});
            this.keywordSearch = keywordSearch.data.result.songs;
            //点击热词不需要等待立即执行
            setTimeout(()=>{
                clearTimeout(this.timer);
            })
        },
    },
     watch:{
        // 通过监视searchValue的变化来获取输入框的内容
        searchValue(){
            if(this.searchValue.length==0) {
                return this. keywordSearch = []
            }else{
                //设置防抖减少向服务器发送请求。类似于王者荣耀回车
                clearTimeout(this.timer);
                this.timer = setTimeout(async()=>{
                    const keywordSearch = await keywordSearchAPI({keywords:this.searchValue,type:1});
                    this.keywordSearch = keywordSearch.data.result.songs;
                },3000)
            }
        }
    }
}
script>
<style scoped>
    /* 热门搜索容器的样式 */
    .search_wrap {
    padding: 0.266667rem;
    }

    /*热门搜索文字标题样式 */
    .hot_title {
    font-size: 0.32rem;
    color: #666;
    }

    /* 热搜词_容器 */
    .hot_name_wrap {
    margin: 0.266667rem 0;
    }

    /* 热搜词_样式 */
    .hot_item {
    display: inline-block;
    height: 0.853333rem;
    margin-right: 0.213333rem;
    margin-bottom: 0.213333rem;
    padding: 0 0.373333rem;
    font-size: 0.373333rem;
    line-height: 0.853333rem;
    color: #333;
    border-color: #d3d4da;
    border-radius: 0.853333rem;
    border: 1px solid #d3d4da;
    }
style>

4、Comment组件相关

① 封装comment相关axios请求

import request  from "@/untils/request";

// 获取评论
export const getComment = (params) =>request ({url:"/comment/hot",params})

②Comment组件代码

<template>
    <div>
        <van-nav-bar title="评论" fixed left-arrow @click-left="$router.back()"/>
        <div>
            <div class="main" >
                
                <van-pull-refresh v-model="refreshing" @refresh="onRefresh" success-text="刷新成功">
                    <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
                        <van-cell v-for="(c,index) in list" :key="index">
                            <div class="wrap" >
                                <div class="img_wrap">
                                    <img :src="c.user.avatarUrl" alt="">
                                div>
                                <div class="conent_wrap">
                                    <div class="header_wrap" >
                                        <div class="info_wrap">
                                            <p>{{c.user.nickname}}p>
                                            <p>{{c.time}}p>
                                        div>
                                        <div>{{c.likedCount}}点赞div>
                                    div>
                                    <div class="footer_wrap">
                                        {{c.content}}
                                    div>

                                div>
                            div>
                        van-cell>
                    van-list>
                van-pull-refresh>

            div>
        div>
    div>
template>
<script>
import {getCommentAPI} from "@/api";
export default {
    name:'Comment',
    data(){
        return {
            id : this.$route.query.id,
            commentsInfo:[], // 每次接收20个评论数据
            page:1, // 页码
            loading:false, // 下拉加载状态
            finished:false, // 所有数据是否加载完成状态
            refreshing:true, // 上拉加载状态
            list:[] // 所有数据
        }
    },
    methods: {
        //获取数据
        async getList(){
            const getComment = await getCommentAPI({id:this.id,type:0,limit:20,offset:(this.page -1 )*20});
            this.commentsInfo = getComment.data.hotComments;
            this.commentsInfo.forEach(obj=>this.list.push(obj))
            this.loading = false;
        },

        // 上拉刷新
        async onLoad(){
            console.log(this.list.length)
                    if(this.loading){
                     this.getList();
                     this.page++;
                     this.refreshing = false;
            }

            if(this.list.length %20 != 0) {
                this.loading = false;
                this.finished = true;
            }
        },

        // 下拉刷新
        async onRefresh(){
            this.finished = false;
            this.loading = true;
            this.onLoad();
        }

    },

}
script>

<style scoped>
    .main {
        padding-top: 46px;
    }
    .wrap {
        display: flex;
    }
    .img_wrap {
        width: 0.8rem;
        height: 0.8rem;
        margin-right: 0.266667rem;
    }
    .img_wrap img {
        width: 100%;
        height: 100%;
        border-radius: 50%;
    }
    .conent_wrap {
        flex: 1;
    }
    .header_wrap {
        display: flex;
    }
    .info_wrap {
        flex: 1;
    }
    .info_wrap p:first-child {
        font-size: 0.373333rem;
        color: #666;
    }
    .info_wrap p:last-of-type {
        font-size: 0.24rem;
        color: #999;
    }
    .header_wrap div:last-of-type {
        color: #999;
        font-size: 0.293333rem;
    }
    .footer_wrap {
        font-size: 0.4rem;
        color: #333;
    }
style>

5、Play组件相关

① 封装play相关axios请求

import request from "@/untils/request";

//获取音乐播放地址
export const getSongById = (params)=>request({url:"/song/url/v1",params});

//获取歌词
export const getLyricById = (params)=>request({url:'/lyric',params});

//获取歌曲详情
export const getMusicById = (params)=>request({url:'/song/detail',params})

②Play组件代码

<template>
  <div class="play">
    
    <div
      class="song-bg" :style="`background-image: url();`">div>
    
    <div class="header">
      <van-icon name="arrow-left" size="20" class="left-incon" @click="$router.back()"/>
    div>
    
    <div class="song-wrapper">
      
      <div class="song-turn ani" :style="`animation-play-state:${playState ? 'running' : 'paused'}`">
        <div class="song-img">
          
          <img class="musicImg"  :src="musicInfo.al.picUrl"/>
        div>
      div>
      
      <div class="start-box" @click="audioStart">
        <span class="song-start" v-show="!playState">span>
      div>
      
      <div class="song-msg">
        
        <h2 class="m-song-h2">
          <span class="m-song-sname">{{ musicInfo.name }}-{{musicInfo.ar[0].name}}span
          >
        h2>
        
        <div class="needle" :style="`transform: rotate(${needleDeg});`">div>
        
        <div class="lrcContent">
          <p class="lrc">{{ curLyric }}p>
        div>
      div>
    div>
    
     <audio ref="audio" preload="true" :src="songInfo.url" @timeupdate="timeupdate">audio>
  div>
template>

<script>
// 获取歌曲详情和 歌曲的歌词接口
import { getSongByIdAPI, getLyricByIdAPI,getMusicByIdAPI } from '@/api'
export default {
  name: 'play',
  data() {
    return {
      playState: false, // 音乐播放状态(true暂停, false播放)
      id: this.$route.query.id, // 上一页传过来的音乐id
      songInfo: {}, // 歌曲信息
      musicInfo:"", // 歌曲详情信息
      lyric: {}, // 歌词枚举对象(需要在js拿到歌词写代码处理后, 按照格式保存到这个对象)
      curLyric: '', // 当前显示哪句歌词
      lastLy: '' ,// 记录当前播放歌词
    }
  },

  computed: {
    needleDeg() { // 留声机-唱臂的位置属性
      return this.playState ? '-7deg' : '-38deg'
    }
  },
  async created(){
   // 获取歌曲详情, 和歌词方法
      const res = await getSongByIdAPI({id:this.id})
      this.songInfo = res.data.data[0];

      // 获取歌曲详情
      const musicInfo =await getMusicByIdAPI({ids:this.id});
      this.musicInfo = musicInfo.data.songs[0];

      // 获取-并调用formatLyric方法, 处理歌词
      const lyrContent  = await getLyricByIdAPI({id:this.id});
      const lyricStr = lyrContent.data.lrc.lyric
      this.lyric = this.formatLyric(lyricStr)
       // 初始化完毕先显示零秒歌词
      this.curLyric = this.lyric[0]

  },
  methods: {
     formatLyric(lyricStr) {
      // 可以看network观察歌词数据是一个大字符串, 进行拆分.
      let reg = /\[.+?\]/g //
      let timeArr = lyricStr.match(reg) // 匹配所有[]字符串以及里面的一切内容, 返回数组
      console.log(timeArr); // ["[00:00.000]", "[00:01.000]", ......]
      let contentArr = lyricStr.split(/\[.+?\]/).slice(1) // 按照[]拆分歌词字符串, 返回一个数组(下标为0位置元素不要,后面的留下所以截取)
      console.log(contentArr);
      let lyricObj = {} // 保存歌词的对象, key是秒, value是显示的歌词
      timeArr.forEach((item, index) => {
        // 拆分[00:00.000]这个格式字符串, 把分钟数字取出, 转换成秒
        let ms = item.split(':')[0].split('')[2] * 60
        // 拆分[00:00.000]这个格式字符串, 把十位的秒拿出来, 如果是0, 去拿下一位数字, 否则直接用2位的值
        let ss = item.split(':')[1].split('.')[0].split('')[0] === '0' ? item.split(':')[1].split('.')[0].split('')[1] : item.split(':')[1].split('.')[0]
        // 秒数作为key, 对应歌词作为value
        lyricObj[ms + Number(ss)] = contentArr[index]
      })
      // 返回得到的歌词对象(可以打印看看)
      console.log(lyricObj);
      return lyricObj
    },

    // 监听播放audio进度, 切换歌词显示
    timeupdate(){
      // console.log(this.$refs.audio.currentTime)
      let curTime = Math.floor(this.$refs.audio.currentTime)
       // 避免空白出现
      if (this.lyric[curTime]) {
        this.curLyric = this.lyric[curTime]
        this.lastLy = this.curLyric
      } else {
        this.curLyric = this.lastLy
      }
    },

    // 播放按钮 - 点击事件
    audioStart() {
      if (!this.playState) { // 如果状态为false
        this.$refs.audio.play() // 调用audio标签的内置方法play可以继续播放声音
      } else {
        this.$refs.audio.pause() // 暂停audio的播放
      }
      this.playState = !this.playState // 点击设置对立状态
    },
  },
}
script>

<style scoped>
/* 歌曲封面 */
.musicImg {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  width: 100%;
  margin: auto;
  width: 370rpx;
  height: 370rpx;
  border-radius: 50%;
}

/* 歌词显示 */
.scrollLrc {
  position: absolute;
  bottom: 280rpx;
  width: 640rpx;
  height: 120rpx;
  line-height: 120rpx;
  text-align: center;
}


.header {
  height: 50px;
}
.play {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1000;
}
.song-bg {
  background-color: #161824;
  background-position: 50%;
  background-repeat: no-repeat;
  background-size: auto 100%;
  transform: scale(1.5);
  transform-origin: center;
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  height: 100%;
  overflow: hidden;
  z-index: 1;
  opacity: 1;
  filter: blur(25px); /*模糊背景 */
}
.song-bg::before{ /*纯白色的图片做背景, 歌词白色看不到了, 在背景前加入一个黑色半透明蒙层解决 */
  content: " ";
  background: rgba(0, 0, 0, 0.5);
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  bottom:0;
}
.song-wrapper {
  position: fixed;
  width: 247px;
  height: 247px;
  left: 50%;
  top: 50px;
  transform: translateX(-50%);
  z-index: 10001;
}
.song-turn {
  width: 100%;
  height: 100%;
  background: url("./img/bg.png") no-repeat;
  background-size: 100%;
}
.start-box {
  position: absolute;
  width: 156px;
  height: 156px;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  justify-content: center;
  align-items: center;
}
.song-start {
  width: 56px;
  height: 56px;
  background: url("./img/start.png");
  background-size: 100%;
}
.needle {
  position: absolute;
  transform-origin: left top;
  background: url("./img/needle-ab.png") no-repeat;
  background-size: contain;
  width: 73px;
  height: 118px;
  top: -40px;
  left: 112px;
  transition: all 0.6s;
}
.song-img {
  width: 154px;
  height: 154px;
  position: absolute;
  left: 50%;
  top: 50%;
  overflow: hidden;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}
.m-song-h2 {
  margin-top: 20px;
  text-align: center;
  font-size: 18px;
  color: #fefefe;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.lrcContent {
  margin-top: 50px;
}
.lrc {
  font-size: 14px;
  color: #fff;
  text-align: center;
}
.left-incon {
  position: absolute;
  top: 10px;
  left: 10px;
  font-size: 24px;
  z-index: 10001;
  color: #fff;
}
.ani {
  animation: turn 5s linear infinite;
}
@keyframes turn {
  0% {
    -webkit-transform: rotate(0deg);
  }
  100% {
    -webkit-transform: rotate(360deg);
  }
}
style>

6、API统一出口

API/index.js

// 统一出口

import {hotMusic,newMusic} from "@/api/home";

import {hotSearch,keywordSearch} from "@/api/search";

import { getSongById,getLyricById,getMusicById} from "@/api/play";

import {getComment} from "@/api/comment";

//导出推荐歌单方法
export const hotMusicAPI = hotMusic
//导出新歌方法
export const newMusicAPI = newMusic

//导入热搜
export const hotSearchAPI = hotSearch;
//导入关键词搜索
export const keywordSearchAPI = keywordSearch;

//播放音乐
export const getSongByIdAPI = getSongById;
//获取歌词
export const getLyricByIdAPI = getLyricById;
//获取歌曲详情
export const getMusicByIdAPI = getMusicById;

//获取评论
export const getCommentAPI = getComment;

五、Play和Comment组件详解

在做项目的过程中,感觉Play组件歌词同步逻辑有点复杂,还有Comment组件上拉和下拉刷新也有点难,因此专门写了两篇详解,可以看一下~~~
Play组件详解:Play组件播放音乐并实现同步一次显示一行歌词
Comment组件详解:Comment组件评论页上拉和下拉刷新

觉得有用,记得点赞收藏哦~~

你可能感兴趣的:(vue+组件库,ui,webpack,vue.js,vue)