目录
仿网易云移动端项目Vue3.2+Pinia+Vant+axios
前期准备(Pinia,rem,初始化样式,图标引入,vant组件,axios)
1.在官网添加项目后复制symbol代码在index.html引入
3.对播放量处理(定义一个函数对渲染的数据进行处理返回出去)
6.用v-for渲染数组时,数组内还有数组的元素可以再v-for进行渲染
2.回车搜索需要进行去重和数组追加(unshift和Set语法)
基于vue-cli创建,rem移动适配方案

npm install pinia -S
- import { createApp } from 'vue'
- import App from './App.vue'
- import router from './router'
- import { createPinia } from 'pinia'
-
- const app = createApp(App)
-
- app.use(router).use(createPinia()).mount('#app')
- import { defineStore } from 'pinia'
- export const useStore = defineStore('main', {
- state: () => ({
- }),
- getters: {
- },
- actions: {
- }
- })
在public存放静态资源新建js文件夹,在js文件夹创建rem.js实现移动适配布局:
- function remSize () {
- /* 获取设备宽度 */
- let deviceWith = document.documentElement.clientWidth || window.innerWidth
- /* 设计稿宽度 */
- if (deviceWith >= 750) {
- deviceWith = 750
- }
- if (deviceWith <= 320) {
- deviceWith = 320
- }
- /* 设置rem,
- 750px--> 1rem=100px
- 375px--> 1rem=50px
- */
- document.documentElement.style.fontSize = (deviceWith / 7.5) + 'px'
- /* 设置字体大小 */
- document.querySelector('body').style.fontSize = 0.3 + 'rem'
- }
- remSize()
- /* 当窗口发生变化 */
- window.onresize = function () {
- remSize()
- }
- <div id="app"></div>
- <!-- 引入rem.js,<%= BASE_URL %>这个为基础路径 -->
- <script src="<%= BASE_URL %>js/rem.js"></script>
需要对插件进行配置,基准font-size这里设置为50
那就是1rem=50px
- <link rel="icon" href="<%= BASE_URL %>favicon.ico">
- <!-- 引入阿里图标 -->
- <script src="//at.alicdn.com/t/font_3415587_mpuudaajazg.js"></script>
在官网:https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=3415587
查看使用帮助,这里用到的是symbol
组件内使用:
- <svg class="icon" aria-hidden="true">
- <use xlink:href="#icon-liebiao2"></use>
- </svg>
注意:类名要加#号
symbol是设置width和height,而font class是设置fontsize
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
- .icon {
- width: .4rem;
- height: .4rem;
- }
- a{
- color: black;
- }
官网:https://vant-contrib.gitee.io/vant/#/zh-CN
npm i vant
参照官网步骤
npm i babel-plugin-import -D
在.babelrc 或 babel.config.js 中添加配置
- import { Button } from 'vant';
-
- app.use(Button);

在src目录创建plugins文件夹,在plugins文件夹创建index.js:
- import { Swipe, SwipeItem, NavBar, Button, Search, Popup } from 'vant'
- /* 放入数组中 */
- const plugins = [
- Swipe, NavBar, SwipeItem, Button, Popup, Search
- ]
-
- /* 循环将每一个插件注册到app上,main.js直接调用这个方法即可 */
- export default function getVant (app) {
- plugins.forEach((item) => {
- return app.use(item)
- })
- }
在main.js引入调用方法即可
- import { createApp } from 'vue'
- import App from './App.vue'
- import router from './router'
- import { createPinia } from 'pinia'
-
- /* 引入插件 */
- import getVant from './plugins'
- const app = createApp(App)
- getVant(app)
-
- app.use(router).use(createPinia()).mount('#app')
npm i axios -S

创建utils文件夹,在其文件夹下创建request.js用于封装请求根路径:
- import axios from 'axios'
- const service = axios.create({
- baseURL: 'http://localhost:3000',
- timeout: 3000
- })
- export default service
创建api文件夹,在其文件夹下创建homeApi.js模块对关于首页api接口进行封装:
- import service from '@/utils/request.js'
-
- /* 获取首页轮播图数据 */
- /* 第一种写法
- export function getBanner () {
- return service({
- method: 'GET',
- url: '/banner?type=2'
- })
- } */
- // 第二种写法
- export function getBanner (type) {
- return service.get('/banner', {
- /* 请求参数 */
- params: {
- type
- }
- })
- }
- <script setup >
- import { toRefs, reactive, onMounted } from 'vue'
- import { getBanner } from '@/api/homeApi.js'
-
- const state = reactive({
- images: [
- 'https://c99=1533&h=575&f=webp&q=90',
- 'https://cdn.cnbj1.fds.api.mi-img.c=90'
- ]
- })
- onMounted(async () => {
- /* axios.get('http://localhost:3000/banner?type=2').then((res) => {
- console.log(res)
- state.images = res.data.banners
- console.log(state.images)
- }) */
- const res = await getBanner(2)
- state.images = res.data.banners
- })
-
- const { images } = toRefs(state)
-
- </script>
判断进入个人中心页面是否有登录或者是否有token,如果没有就跳转到登录页面
- {
- path: '/infoUser',
- name: 'InfoUser',
- // 独享路由守卫
- beforeEnter: (to, from, next) => {
- // pinia要在这里定义
- const store = FooterMusicStore()
- if (store.isLogin || store.token || localStorage.getItem('token')) {
- next()
- } else {
- next('/login')
- }
- },
- component: () => import('../views/InfoUser.vue')
- }
如果跳转到登录页面就将隐藏底部播放栏组件
- const router = createRouter({
-
- history: createWebHashHistory(),
- routes
- })
-
- // 全局前置守卫
- router.beforeEach((to, from) => {
- const store = FooterMusicStore()
- if (to.path === '/login') {
- store.isFooterMusic = false
- } else {
- store.isFooterMusic = true
- }
- })
-
- export default router

路由组件:HomeView
组件:

点击我的就跳转到个人中心页面
$router.push('路径')
- <div class="topContent">
- <span @click="$router.push('/infoUser')">我的</span>
- <span class="active">发现</span>
- <span>云村</span>
- <span>视频</span>
- </div>
- display: flex;
- //设置盒子距离
- justify-content: space-between;
- //垂直居中
- align-items: center;
- /* 修改主轴对齐方向 */
- flex-direction: column;
- <script setup >
- import { toRefs, reactive, onMounted } from 'vue'
- import { getBanner } from '@/api/homeApi.js'
- const state = reactive({
- images: [
- ]
- })
- onMounted(async () => {
- const res = await getBanner(2)
- state.images = res.data.banners
- })
- const { images } = toRefs(state)
- </script>
-
- <template>
- <div id="swiperTop">
- <!-- 懒加载 -->
- <van-swipe :autoplay="3000" lazy-render>
- <van-swipe-item v-for="image in images" :key="image">
- <img :src="image.pic" />
- </van-swipe-item>
- </van-swipe>
- </div>
- </template>
vue2
- export default {
- data () {
- return {
- musicList: []
- }
- },
- methods: {
- // 获取发现歌单
- async getGnedan () {
- const res = await getMusicList()
- console.log(res)
- this.musicList = res.data.result
- },
- // 对播放量进行处理
- changeCount: function (num) {
- if (num >= 100000000) {
- // toFixed(1)显示一位小数
- return (num / 100000000).toFixed(1) + '亿'
- } else if (num >= 10000) {
- return (num / 10000).toFixed(1) + '万'
- }
- }
- },
- mounted () {
- this.getGnedan()
- }
- }
vue3
- import { getMusicList } from '@/api/homeApi'
- import { reactive, onMounted, toRefs } from 'vue'
-
- export default {
- setup () {
- const state = reactive({
- musicList: []
- })
- onMounted(async () => {
- const res = await getMusicList()
- // console.log(res)
- state.musicList = res.data.result
- })
- // 对播放量进行处理
- const changeCount = function (num) {
- if (num >= 100000000) {
- // toFixed(1)显示一位小数
- return (num / 100000000).toFixed(1) + '亿'
- } else if (num >= 10000) {
- return (num / 10000).toFixed(1) + '万'
- }
- }
- return {
- ...toRefs(state),
- changeCount
- }
- }
- }
from组件:
- <van-swipe-item v-for="item in musicList" :key="item.id">
- <router-link :to="{ path: '/itemMusic', query: { id: item.id } }">
- <img :src="item.picUrl">
- </router-link>
- </van-swipe-item>
to跳转的路由组件进行接收:
可以通过useRoute()获取到路由信息
- import { useRoute } from 'vue-router'
- import { onMounted } from 'vue'
-
- // useRoute可以拿到路由的参数
- onMounted( () => {
- /* 可以调用useRoute方法的query拿到id */
- const id = useRoute().query.id
- console.log(id)
- })

路由组件:ItemMusic.vue
组件:

1.通过父传子值props(Vue3.2语法糖和Vue3写法)
父组件:
- <script setup>
- import { useRoute } from 'vue-router'
- import { onMounted, reactive } from 'vue'
- import { getMusicItem, getMusicItemList } from '@/api/itemApi.js'
- /* 引入子组件 */
- import ItemMusicTop from '@/components/item/itemMusicTop.vue'
- import ItemMusicList from '@/components/item/itemMusicList.vue'
-
- const state = reactive({
- playlist: {}, // 歌单详情页数据
- itemList: []// 歌单的歌曲
- })
-
- // useRoute可以拿到路由的参数
- onMounted(async () => {
- /* 可以调用useRoute方法的query拿到id */
- const id = useRoute().query.id
- // console.log(id)
- /* 获取歌单详情 */
- const res = await getMusicItem(id)
- // console.log(res)
- state.playlist = res.data.playlist
- /* 获取歌单歌曲 */
- const result = await getMusicItemList(id)
- // console.log(result)
- state.itemList = result.data.songs
- /* 为防止页面刷新,数据丢失,将数据保存到sessionStorage */
- sessionStorage.setItem('itemDetail', JSON.stringify(state))
- })
-
- </script>
-
- <template>
- <ItemMusicTop :playlist="state.playlist"></ItemMusicTop>
- <ItemMusicList :itemList="state.itemList" :subscribedCount="state.playlist.subscribedCount"></ItemMusicList>
- </template>
-
- <style lang='less' scoped>
- </style>
子组件:
vue3.0写法:
- <script>
- import { reactive } from 'vue'
- export default {
- props: ['playlist'],
- setup (props) {
- // 通过props进行传值,判断如果数据拿不到,则从本地读取数据
- let creator = reactive({})
- if ((props.playlist.creator === '')) {
- creator = JSON.parse(sessionStorage.getItem('itemDetail')).playlist.creator
- }
- // 对播放量进行处理
- const changeCount = (num) => {
- if (num >= 100000000) {
- // toFixed(1)显示一位小数
- return (num / 100000000).toFixed(1) + '亿'
- } else if (num >= 10000) {
- return (num / 10000).toFixed(1) + '万'
- }
- }
- return {
- props,
- creator,
- changeCount
- }
- }
-
- }
-
- </script>
vue3.2写法:
- <script setup>
- import { defineProps } from 'vue'
- const props = defineProps(['itemList', 'subscribedCount'])
- console.log(props)
-
- </script>
保存:
- /* 为防止页面刷新,数据丢失,将数据保存到sessionStorage */
- sessionStorage.setItem('itemDetail', JSON.stringify(state))
使用:
creator = JSON.parse(sessionStorage.getItem('itemDetail')).playlist.creator
删除:
sessionStorage.removeItem('itemDetail')
- // 对播放量进行处理
- const changeCount = (num) => {
- if (num >= 100000000) {
- // toFixed(1)显示一位小数
- return (num / 100000000).toFixed(1) + '亿'
- } else if (num >= 10000) {
- return (num / 10000).toFixed(1) + '万'
- }
- }
在模板使用:
{{ changeCount(playlist.playCount) }}
- .bgimg {
- width: 100%;
- height: 11rem;
- position: absolute;
- z-index: -1;
- //虚化
- filter: blur(0.6rem);
- }
- span {
- width: 80%;
- height: .6rem;
- text-overflow: ellipsis;
- overflow: hidden;
- display: -webkit-box; //使用自适应布局
- -webkit-line-clamp: 2; //设置超出行数,要设置超出几行显示省略号就把这里改成几
- -webkit-box-orient: vertical;
- }
如渲染歌曲列表每一首歌曲,而每一首歌曲又有多少歌手
- <div class="item" v-for="(item, index) in itemList" :key="item">
- <span class="id">{{ index + 1 }}</span>
- <div class="songmsg" @click="playMusic(index)">
- <p class="song">{{ item.name }}</p>
- <span class="singer" v-for="(item1, i) in item.ar" :key="i">{{ item1.name }}</span>
- </div>
- </div>

在App.vue进行引入使用
需要用到Pinia进行全局数据共享,如底部组件是否显示
- <template>
- <router-view />
- <FooterMusic v-show="store.isFooterMusic"></FooterMusic>
- </template>
在模板中:
- <audio ref="audio" :src="`https://music.163.com/song/media/outer/url?id=${store.playlist[store.
- playListIndex].id}.mp3`"></audio>
- import { FooterMusicStore } from '@/store/FooterMusic.js'
- import { ref, onMounted, watch } from 'vue'
-
-
- /* vue3中this.$ref的使用变化 */
- const audio = ref(null)
- onMounted(() => {
- console.log(audio)
-
- })
注意改变audio需要audio.value,因为ref
- // 定时器
- let interVal = ref(0)
-
- /* vue3中this.$ref的使用变化 */
- const audio = ref(null)
-
- onUpdated(() => {
- // 渲染的时候也需要同步歌词时间
- updateTime()
- })
-
- const play = () => {
- /* 判断是否已暂停 */
- if (audio.value.paused) {
- // 触发定时器
- updateTime()
- } else {
- // 清除定时器
- clearInterval(interVal)
- }
- }
-
- // 设置定时器方法来触发更新歌词时间
- const updateTime = () => {
- interVal = setInterval(() => {
- store.udpateCurrentTime(audio.value.currentTime)
- }, 1000)
- }
-
- // 获取歌曲歌词/lyric?id=33894312
- export function getMusicLyric (data) {
- return service({
- method: 'GET',
- /* 这里用到模板字符串,将data参数传进来 */
- url: `/lyric?id=${data}`
- })
- }


在样式定义好样式和动画
- .ar {
- width: 3.2rem;
- height: 3.2rem;
- border-radius: 50%;
- position: absolute;
- bottom: 3.14rem;
- /* 使用动画匀速,无限循环 */
- animation: rotate_ar 10s linear infinite;
- }
-
- .ar_active {
- animation-play-state: running;
- }
-
- .ar_paused {
- animation-play-state: paused;
- }
-
- /* 定义图片旋转动画 */
- @keyframes rotate_ar {
- 0% {
- transform: rotateZ(0deg);
- }
-
- 100% {
- transform: rotateZ(360deg);
- }
- }
通过改变类名进行动画的开始暂停:
<img :src="musicList.al.picUrl" class="ar" :class="{ ar_active: !isbtnShow, ar_paused: isbtnShow }">
- /* 歌词 */
- .musiclyricList{
- width: 100%;
- height: 8rem;
- display: flex;
- flex-direction: column;
- align-items: center;
- margin-top: .2rem;
- //溢出滚动
- overflow: scroll;
- p{
- color:rgb(195, 239, 244);
- margin-bottom: .4rem;
- }
- //高亮显示的歌词
- .active{
- color: white;
- font-size: .4rem;
- }
- }
改变类名实现高亮:
<p v-for="item in lyric" :key="item" :class="{active:(store.currentTime*1000)>=item.time&&store.currentTime*1000<item.pre}">{{item.lrc}}</p>
- import { computed, defineProps, onMounted, ref, watch } from 'vue'
- import { Vue3Marquee } from 'vue3-marquee'
- import { FooterMusicStore } from '@/store/FooterMusic.js'
- import 'vue3-marquee/dist/style.css'
- const store = FooterMusicStore()
- const props = defineProps(['musicList', 'isbtnShow', 'play', 'addDuration'])
- const isLyricShow = ref(false)
- // 计算属性歌词处理
- const lyric = computed(() => {
- let arr
- if (store.lyricList.lyric) {
- /* 将歌词进行换行符分割 */
- /* 1.先用数组split方法对歌词的换行进行分割
- 2.用map方法,遍历数组并对其进行操作返回一个新数组
- 3.以对象形式返回为新数组
- */
- arr = store.lyricList.lyric.split(/[(\r\n)\r\n]+/).map((item, i) => {
- // 分钟,切割第一到第三
- const min = item.slice(1, 3)
- // 秒钟切割
- const sec = item.slice(4, 6)
- // 毫秒切割
- let mill = item.slice(7, 10)
- // 歌词切割
- let lrc = item.slice(11, item.length)
- // 每句歌词显示的时间
- let time = parseInt(min) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(mill)
- // 因为两句歌词后面的毫秒为两位数,则要进行处理
- if (isNaN(Number(mill))) {
- mill = item.slice(7, 9)
- lrc = item.slice(10, item.length)
- time = parseInt(min) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(mill)
- }
- // console.log(min, sec, Number(mill), lrc)
- // 返回对象组成数组
- return { min, sec, mill, lrc, time }
- })
- // 遍历拿到pre,即后一句歌词的时间
- arr.forEach((item, i) => {
- if (i === arr.length - 1 || isNaN(arr[i + 1].time)) {
- item.pre = 100000
- } else {
- item.pre = arr[i + 1].time
- }
- })
- }
- return arr
- }
- )
下载地址:https://www.npmjs.com/package/vue3-marquee
npm install vue3-marquee@latest --save
先引入:
import { Vue3Marquee } from 'vue3-marquee'
import 'vue3-marquee/dist/style.css'
- <!-- 走马灯 -->
- <Vue3Marquee>
- {{ musicList.name }}
- </Vue3Marquee>
- // 监听歌词时间
- watch(() => store.currentTime, (newValue) => {
- const p = document.querySelector('p.active')
- // console.log([p])
- if (p) {
- if (p.offsetTop > 300) {
- musicLyric.value.scrollTop = p.offsetTop - 300
- }
- }
- // console.log([musicLyric.value])
- if (newValue === store.duration) {
- if (store.playListIndex === store.playlist.length - 1) {
- store.updateplayListIndex(0)
- props.play()
- } else {
- store.updateplayListIndex(store.playListIndex + 1)
- }
- }
- })

- onMounted(() => {
- // 页面渲染时读取本地历史记录
- keyWorldList.value = JSON.parse(localStorage.getItem('keyWorldList')) ? JSON.parse(localStorage.getItem('keyWorldList')) : []
- })
- // 输入框回车操作进行搜索
- const enterKey = async () => {
- if ((searchKey.value !== '')) {
- // 数组向前追加元素
- keyWorldList.value.unshift(searchKey.value)
- // 去重,这里用到Set语法
- keyWorldList.value = [...new Set(keyWorldList.value)]
- console.log([...new Set(keyWorldList.value)])
- // 固定长度
- if (keyWorldList.value.length > 10) {
- keyWorldList.value.splice(keyWorldList.value.length - 1)
- }
- // 将历史记录保存到本地
- localStorage.setItem('keyWorldList', JSON.stringify(keyWorldList.value))
- const res = await getSearchMusic(searchKey.value)
- console.log(res)
- // 将请求回来的数据进行接收
- searchList.value = res.data.result.songs
- searchKey.value = ''
- }
- }

- // 登录/login/cellphone?phone=xxx&password=yyy
- export function getPhoneLogin (data) {
- return service({
- method: 'GET',
- url: `/login/cellphone?phone=${data.phone}&password=${data.password}`
- })
- }
- import { getPhoneLogin } from '@/api/homeApi.js'
- import { defineStore } from 'pinia'
-
- export const FooterMusicStore = defineStore('musicstore', {
- state: () => {
- return {
- isLogin: false, // 登录状态
- isFooterMusic: true, // 判断底部组件是否显示
- token: '', // 接收后台返回的token字段
- user: {}// 用户信息
- }
- },
-
- getters: {
-
- },
- actions: {
- // 登录请求
- async getLogin (value) {
- const res = await getPhoneLogin(value)
- console.log('登录返回的数据:', res)
- return res
- },
- // 更新登录状态
- udpateIsLogin (value) {
- this.isLogin = value
- },
- // 更新token字段
- updateToken (value) {
- this.token = value
- localStorage.setItem('token', this.token)
- },
- // 更新用户信息
- updateUser (value) {
- this.user = value
- localStorage.setItem('mydata', JSON.stringify(this.user))
- }
- }
-
- })
- <!-- 登录路由组件 -->
- <script setup>
- /* 在setup中使用访问路由 */
- import { useRouter } from 'vue-router'
- import { ref } from 'vue'
- import { FooterMusicStore } from '@/store/FooterMusic.js'
- import { getLoginUser } from '@/api/homeApi.js'
- const store = FooterMusicStore()
- const router = useRouter()
-
- const phone = ref('')
- const password = ref('')
-
- const Login = async () => {
- const res = await store.getLogin({ phone: phone.value, password: password.value })
- console.log(res)
- if (res.data.code === 200) {
- // 将用户登录状态传过去
- store.udpateIsLogin(true)
- // 将用户id传到发起获取用户详情的接口
- const result = await getLoginUser(res.data.account.id)
- console.log('获取用户详情返回的数据:', result)
- // 将后端返回来的token传去pinia和本地存储
- store.updateToken(res.data.token)
- // 将用户详情数据存储到pinia和本地存储
- store.updateUser(result)
- // 如果返回的code为200,说明登录成功,跳转个人中心页面
- router.push('/infoUser')
- } else {
- alert('手机号码或密码错误!')
- }
- }
-
- </script>
-
- <template>
- <div class="loginBox">
- <div class="title">
- 欢迎登录
- </div>
- <div class="form" @keydown.enter="Login">
- <input type="text" placeholder="请输入手机号" v-model="phone">
- <input type="password" placeholder="请输入密码" v-model="password">
- <van-button color="linear-gradient(to right, #ff6034, #ee0a24)" @click="Login">
- 登录
- </van-button>
- </div>
- <van-button color="linear-gradient(to right, #ff6034, #ee0a24)" @click="$router.go(-1)">
- 返回首页
- </van-button>
- </div>
- </template>
源码
https://gitee.com/zi1726517395/emo_music.git