1. 主题换肤
header头部属于一个单独的部分,所以将其封装为一个组件,命名为Header.vue
。
实现此功能需配合组件中的一些data
数据和scss混合
加上一些vue的事件交互
来完成响应式地更改主题
我们需要做一些准备工作,例如我们给项目设置了3个主题,就在组件的data中设定一些变量
data () {
return {
themes: ['theme', 'theme1', 'theme2'],
themeIndex: 0
}
}
通过点击header区域来更改主题,所以需要设置一个事件,用一个主题索引值来标识当前的主题范围,然后给html标签设置一个全局属性data-theme
来标识当前主题
methods: {
changeTheme () {
// 循环主题索引
this.themeIndex = (this.themeIndex + 1) % this.themes.length
document.documentElement.setAttribute('data-theme', this.themes[this.themeIndex])
}
}
接下来就是准备好主题的素材和scss样式部分,在这里针对不同的缩放比例下的屏幕,制作了不同比例的图片,以确保界面在缩放的过程中,图片不会失真。(注意:这里的图片命名要规范,方便后续进行scss混合)
我们还需要对整个项目设置通用的全局变量和混合方法
/* variable.scss */
//字体定义规范
$font_samll:12Px;
$font_medium_s:13Px;
$font_medium:15Px;
$font_large:17Px;
// 背景颜色规范(主要)
$background-color-theme: #d43c33;//背景主题颜色默认(网易红)
$background-color-theme1: #42b983;//背景主题颜色1(QQ绿)
$background-color-theme2: #333;//背景主题颜色2(夜间模式)
// 背景颜色规范(次要)
$background-color-sub-theme: #f5f5f5;//背景主题颜色默认(网易红)
$background-color-sub-theme1: #f5f5f5;//背景主题颜色1(QQ绿)
$background-color-sub-theme2: #444;//背景主题颜色2(夜间模式)
// 字体颜色规范(默认)
$font-color-theme : #666;//字体主题颜色默认(网易)
$font-color-theme1 : #666;//字体主题颜色1(QQ)
$font-color-theme2 : #ddd;//字体主题颜色2(夜间模式)
// 字体颜色规范(激活)
$font-active-color-theme : #d43c33;//字体主题颜色默认(网易红)
$font-active-color-theme1 : #42b983;//字体主题颜色1(QQ绿)
$font-active-color-theme2 : #ffcc33;//字体主题颜色2(夜间模式)
// 边框颜色
$border-color-theme : #d43c33;//边框主题颜色默认(网易)
$border-color-theme1 : #42b983;//边框主题颜色1(QQ)
$border-color-theme2 : #ffcc33;//边框主题颜色2(夜间模式)
前期我们通过js设置了html的data-theme属性,所以在编写scss混合的时候就可以通过属性选择器来设置不同主题下的样式了
@import "./variable.scss";
/*根据dpr计算font-size*/
@mixin font_dpr($font-size){
font-size: $font-size;
[data-dpr="2"] & { font-size: $font-size * 2;}
[data-dpr="3"] & { font-size: $font-size * 3;}
}
/*通过该函数设置字体大小,后期方便统一管理;*/
@mixin font_size($size){
@include font_dpr($size);
}
// 根据属性选择器来设置背景颜色
@mixin bg_color(){
background: $background-color-theme;
[data-theme=theme1] & {
background: $background-color-theme1;
}
[data-theme=theme2] & {
background: $background-color-theme2;
}
}
@mixin bg_sub_color(){
background: $background-color-sub-theme;
[data-theme=theme1] & {
background: $background-color-sub-theme1;
}
[data-theme=theme2] & {
background: $background-color-sub-theme2;
}
}
@mixin font_color(){
color: $font-color-theme;
[data-theme=theme1] & {
color: $font-color-theme1;
}
[data-theme=theme2] & {
color: $font-color-theme2;
}
}
@mixin font_active_color(){
color: $font-active-color-theme;
[data-theme=theme1] & {
color: $font-active-color-theme1;
}
[data-theme=theme2] & {
color: $font-active-color-theme2;
}
}
@mixin border_color(){
border-color: $border-color-theme;
[data-theme=theme1] & {
border-color: $border-color-theme1;
}
[data-theme=theme2] & {
border-color: $border-color-theme2;
}
}
// 根据传入的url拼接统一规范的资源名称
@mixin bg_img($url){
[data-theme=theme] & {
background-image: url($url + '_163.png');
}
[data-theme=theme1] & {
background-image: url($url + '_qq.png');
}
[data-theme=theme2] & {
background-image: url($url + '_it666.png');
}
background-size: cover;
background-repeat: no-repeat;
[data-theme=theme][data-dpr='2'] & {
background-image: url($url + '_163@2x.png');
}
[data-theme=theme][data-dpr='3'] & {
background-image: url($url + '_163@3x.png');
}
[data-theme=theme1][data-dpr='2'] & {
background-image: url($url + '_qq@2x.png');
}
[data-theme=theme1][data-dpr='3'] & {
background-image: url($url + '_qq@3x.png');
}
[data-theme=theme2][data-dpr='2'] & {
background-image: url($url + '_it666@2x.png');
}
[data-theme=theme2][data-dpr='3'] & {
background-image: url($url + '_it666@3x.png');
}
}
所以我们在使用的时候只需要优雅地导入进来并调用即可:
<style scoped lang="scss">
@import "../assets/css/variable";
@import "../assets/css/mixin";
.header{
...
@include bg_color();
.header-left{
...
@include bg_img('../assets/images/logo');
}
.header-right{
...
@include bg_img('../assets/images/account');
}
.header-title{
...
@include font_size($font_medium);
}
}
</style>
2. Tab切换栏
Tab栏切换就是需要我们点击到哪个栏目,就显示出相对应的区域内容。这里用专业的做法是使用Router来完成,上面几个点击的栏目就是router-link
,下面显示区域就是router-view
。
这里可以单独作为一个组件封装起来,命名为Tabbar.vue
。
注意,这里为了层级关系不将
router-view
放在Tabbar.vue
里,而是直接放在App.vue
中方便显示。
因为每个tab显示出的内容较为庞大,我们还需要为每个tab栏目设置一个单独的view级组件
路由规则设置(按需加载组件)
这里有个注意点:
通过 import xxx from xxx的方式加载组件,无论组件有没有被用到,都会被加载进来,这样十分不利于页面性能
import Recommend from '../views/Recommend'
import Singer from '../views/Singer'
import Rank from '../views/Rank'
import Search from '../views/Search'
优化:
import Vue from 'vue'
import VueRouter from 'vue-router'
// 实现Vue组件的按需加载
const Recommend = (resolve) => {
import('../views/Recommend').then((module) => {
resolve(module)
})
}
const Singer = (resolve) => {
import('../views/Singer').then((module) => {
resolve(module)
})
}
const Rank = (resolve) => {
import('../views/Rank').then((module) => {
resolve(module)
})
}
const Search = (resolve) => {
import('../views/Search').then((module) => {
resolve(module)
})
}
Vue.use(VueRouter)
const routes = [
{ path: '/', redirect: '/recommend' },
{ path: '/recommend', component: Recommend },
{ path: '/singer', component: Singer },
{ path: '/rank', component: Rank },
{ path: '/search', component: Search }
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
3. Banner
Banner虽然是属于推荐界面的一部分,但是为了便于维护,这里将Banner独立为一个组件。在components
文件夹里建立一个Banner.vue
这里使用的是基于Vue的swiper插件库(V5.4.5),基于Vue的swiper文档写的不是特别详细,版本之前存在一定差异,所以一定要好好看一下文档不然会容易踩坑。。。
样式设置:
注意点:如果想覆盖swiper的样式,那么style标签不能是scoped的,否则无法覆盖
因为基于scoped的style形成的样式是 .swiper-pagination-bullet-active[data-v-7b7e5f8e]所以样式无法穿透
<style lang="scss">
@import "../assets/css/mixin";
.banner{
.swiper-pagination-bullet{
width: 16px;
height: 16px;
background: #fff;
opacity: 1;
&.swiper-pagination-bullet-active{
@include bg_color();
}
}
}
</style>
由于我们的Banner数据是获取的动态数据,所以我们可以对此次项目中需要用到网络获取的地方进行一个整体的封装,可以视作一个通用方法
网络工具类封装:
在src
文件夹中单独建立一个api
文件夹作为以后的一个通用网络工具类,这里我们的请求全部基于axios
第三方库,所以封装之前先安装好axios。
/* network.js */
import axios from 'axios'
// 全局配置
axios.defaults.baseURL = 'http://127.0.0.1:3000/'
axios.defaults.timeout = 5000
// 封装自己的get/post方法
export default {
get (path = '', data = {}) {
return new Promise((resolve, reject) => {
axios.get(path, {
params: data
})
.then(response => {
resolve(response.data)
})
.catch(error => {
reject(error)
})
})
},
post (path = '', data = {}) {
return new Promise((resolve, reject) => {
axios.post(path, data)
.then(response => {
resolve(response.data)
})
.catch(error => {
reject(error)
})
})
}
}
然后再封装一个index.js统一管理针对首页所有需要请求数据的部分
/* index.js */
// 这个JS文件是专门用于管理请求各种接口地址的
import Network from './network'
export const getBanner = () => Network.get('banner?type=2')
Banner组件一切准备就绪以后,我们将组件放在推荐界面(Recommend.vue
)里使用,并在其created生命周期方法中获取banner网络数据,在Recommend.vue中获取到数据以后,我们采用父组件传递数据给子组件的方式将获取到的数据传递给Banner组件,供Banner组件渲染出来。
渲染问题:
在Banner的swiper渲染的过程中还有可能遇到一些问题
swiper的bug:如果数据是从网络获取的,那么自动轮播到最后一页之后就不轮播了
解决办法: 只需要在swiper组件上面加上v-if=”数据.length > 0”
4. 首页可复用组件
推荐歌单和最新专辑布局都是一样,因此可以定义为一个通用的组件。
创建一个名为Personalized.vue
的组件,并定义好样式和接口数据。
由于这两个板块之间唯一的区别就是title
不一样,因此可以用插槽的方式自定义title部分,在父组件中使用的时候进行自定义。而接口的数据部分则在父组件中请求获取到以后通过父组件传递给子组件渲染。
<!-- Personalized.vue -->
<template>
<div class="personalized">
<div class="personalized-top">
<!-- 插槽 -->
<slot name="personalizedTitle">
<h3>推荐</h3>
</slot>
</div>
<div class="personalized-list">
<div class="item" v-for="value in personalized" :key="value.id">
<img :src="value.picUrl" alt="">
<p>{{value.name}}</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Personalized',
// 接收父组件请求到的数据
props: {
personalized: {
type: Array,
default: () => [],
required: true
}
}
}
</script>
<!-- Recommend.vue -->
<Personalized :personalized="personalized">
<template #personalizedTitle>
<h3>推荐歌单</h3>
</template>
</Personalized>
<Personalized :personalized="albums">
<template #personalizedTitle>
<h3>最新专辑</h3>
</template>
</Personalized>
5. 图片懒加载
需要安装vue-lazyload
,然后在Vue中注册这个全局组件并添加配置项,再在需要用到的地方添加上对应的标签
import VueLazyload from 'vue-lazyload'
Vue.use(VueLazyload, {
// 可以通过配置loading来设置图片还未加载好之前的占位图片
loading: require('./assets/images/loading.svg')
})
<!-- <img :src="value.picUrl" alt=""> -->
<!-- :src属性替换为v-lazy -->
<img v-lazy="value.picUrl" alt="">
6. 滚动效果组件封装
需要使用到IScroll
插件,由于此项目多出页面都会用到滚动插件,所以直接封装成一个单独的组件方便后期使用,创建一个ScrollView.vue
的组件,将推荐页面的所有内容通过插槽的方式都放在ScrollView组件中。使用IScroll会遇到一些常见的问题,例如将内容嵌套进滚动容器中无法拖动,或是拖动卡顿,最大拖动距离出错。
ScrollView.vue组件定义:
<template>
<div id="wrapper" ref="wrapper">
<slot name="scorllContent"></slot>
</div>
</template>
<script>
import IScroll from 'iscroll/build/iscroll-probe'
export default {
name: 'ScrollView',
mounted () {
this.iscroll = new IScroll(this.$refs.wrapper, {
mouseWheel: true,
scrollbars: false,
probeType: 3,
// 解决拖拽卡顿问题
scrollX: false,
scrollY: true,
disablePointer: true,
disableTouch: false,
disableMouse: true
})
}
}
</script>
<style scoped>
#wrapper{
width: 100%;
height: 100%;
}
</style>
recommend中使用
<template>
<div class="recommend">
<ScrollView>
<template #scorllContent>
<div>
<!-- 将recommend中的所有组件通过ScrollView组件插槽的方式放入 -->
<Banner></Banner>
<Personalized></Personalized>
<SongList></SongList>
</div>
</template>
</ScrollView>
</div>
</template>
<script>
import Banner from '../components/Banner'
import Personalized from '../components/Personalized'
import SongList from '../components/SongList'
import ScrollView from '../components/ScrollView'
export default {
name: 'Recommend',
components: {
Banner,
Personalized,
SongList,
ScrollView
}
}
</script>
无法拖动解决办法:
出现无法拖动一般是我们没有指定外层滚动容器的高度,此时我们只需要将推荐界面容器也就是recommend
设置为固定定位,高度设置为视口剩余的全部高度(视口中除开header和tabbar的高度)而wrapper
容器宽高设置为100%参照父元素recommend宽高,假设header和tabbar的高度总共为184px,这里可以这样设置CSS,视口剩余的高度就是recommend的高度
/*这种布局可以使推荐界面只占可视区域的固定高度*/
.recommend{
overflow: hidden;
position: fixed;
top: 184px;
left: 0;
right: 0;
bottom: 0;
}
拖动卡顿解决办法:
使用IScroll时加入如下配置
new IScroll(this.$refs.wrapper, {
mouseWheel: true,
scrollbars: false,
probeType: 3,
// 解决拖拽卡顿问题
scrollX: false,
scrollY: true,
disablePointer: true,
disableTouch: false,
disableMouse: true
})
我们的全局scss中也要设置
html, body{
width: 100%;
height: 100%;
overflow: hidden;
// 解决IScroll拖拽卡顿问题
touch-action: none;
}
最大拖动距离出错解决办法:
通过上面几项设置只能保证基本能使用IScroll,但是还会有一些隐藏的问题出现,例如我们滚动容器中有很多内容都是动态获取添加的,所以在渲染的时候,IScroll容器可能会错误地计算最大滚动距离。这时候需要我们监听滚动容器中的所有子节点的变化,只要以检测到变化就立即刷新滚动容器,所以在ScrollView.vue中还需要更改一下JS配置
<script>
import IScroll from 'iscroll/build/iscroll-probe'
export default {
name: 'ScrollView',
mounted () {
this.iscroll = new IScroll(this.$refs.wrapper, {
mouseWheel: true,
scrollbars: false,
probeType: 3,
// 解决拖拽卡顿问题
scrollX: false,
scrollY: true,
disablePointer: true,
disableTouch: false,
disableMouse: true
})
// 1.创建一个观察者对象
/*
MutationObserver构造函数只要监听到了指定内容发生了变化,就会执行传入的回调函数
mutationList:发生变化的数组
observer:观察者对象
*/
const observer = new MutationObserver((mutationList, observer) => {
this.iscroll.refresh()
// console.log(this.iscroll.maxScrollY)
})
// 2.告诉观察者对象我们需要观察什么内容
const config = {
childList: true, // 观察目标子节点的变化,是否有添加或者删除
subtree: true, // 观察后代节点,默认为 false
attributeFilter: ['height', 'offsetHeight'] // 观察特定属性变动
}
// 3.告诉观察者对象,我们需要观察谁,观察什么内容
/*
第一个参数:告诉观察者对象我们需要观察谁
第二个参数:告诉观察者对象我们需要观察什么内容
*/
observer.observe(this.$refs.wrapper, config)
}
}
</script>
7. 跳转歌单详情(二级路由)
要想在推荐界面Recommend.vue
实现点击推荐歌单或者最新专辑中的一项就跳转到详情界面,这里可以使用二级路由,在recommend.vue中加入一个路由出口,这里加上transition组件是为了在切换路由界面时能够有动画过渡。
<template>
<div class="recommend">
<ScrollView>
<template #scorllContent>
<div>
<Banner></Banner>
<Personalized></Personalized>
<SongList></SongList>
</div>
</template>
</ScrollView>
<transition>
<!-- 详情界面路由出口 -->
<router-view></router-view>
</transition>
</div>
</template>
配置路由(因为详情detail界面包含了歌单详情和专辑详情需要在跳转路由时标识一下相应的类型)
const routes = [
{ path: '/', redirect: '/recommend' },
{
path: '/recommend',
component: Recommend,
children: [
{
path: 'detail/:type/:id',
component: Detail
}
]
},
{ path: '/singer', component: Singer },
{ path: '/rank', component: Rank },
{ path: '/search', component: Search }
]
由于推荐歌单和最新专辑布局都是一样,所以定义的是一个通用组件,只需要在item
中绑定一个click事件传入对应的id即可,但在通用组件中我们并不知道type是什么类型,需要在Recommend推荐页中通过了父传子的方式把type和路由跳转方法传给子组件,子组件再通过获取到的id和type去回调父亲方法,父亲方法拿到id和type以后使用路由方法进行路由跳转(比较绕。。。)
<!-- Recommend.vue部分代码 -->
<Personalized :personalized="personalized" :type="'personalized'" @select="fatherSelectItem">
<template #personalizedTitle>
<h3>推荐歌单</h3>
</template>
</Personalized>
<Personalized :personalized="albums" :type="'albums'" @select="fatherSelectItem">
<template #personalizedTitle>
<h3>最新专辑</h3>
</template>
</Personalized>
<script>
export default {
/* 省略部分代码 */
methods: {
fatherSelectItem (id, type) {
this.$router.push({
path: `/recommend/detail/${type}/${id}`
})
}
}
}
</script>
<!-- Personalized.vue部分代码 -->
<template>
<div class="personalized">
<div class="personalized-top">
<slot name="personalizedTitle">
<h3>推荐</h3>
</slot>
</div>
<div class="personalized-list">
<div class="item" v-for="value in personalized" :key="value.id" @click="selectItem(value.id)">
<div class="image">
<img v-lazy="value.picUrl" alt="">
</div>
<p>{{value.name}}</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Personalized',
props: {
personalized: {
type: Array,
default: () => [],
required: true
},
type: {
type: String,
default: '',
required: true
}
},
methods: {
selectItem (songId) {
this.$emit('select', songId, this.type)
}
}
}
</script>
8. 骨架屏加载(自定义插件)
由于一跳转到歌曲详情界面里面的内容都是动态获取的,所以在获取之前没有元素填充,部分组件高度会塌陷或者空白,这样效果很不美观,所以自行封装了一个简易的骨架屏,在全局注册好就可以使用。
<template>
<div class="skeleton">
<div class="skeleton-item" v-for="index in length" :key="index"></div>
</div>
</template>
<script>
export default {
name: 'Skeleton',
props: {
length: {
type: Number,
default: 1,
required: false
}
}
}
</script>
<style lang="scss">
.skeleton{
.skeleton-item{
width: 100%;
height: 120px;
margin-bottom: 20px;
background: linear-gradient(-45deg, hsla(0, 0%, 74.5%, .2) 25%, hsla(0, 0%, 50.6%, .24) 37%, hsla(0, 0%, 74.5%, .2) 63%);
background-size: 400% 100%;
animation: skeleton-loading 4s linear infinite;
}
@-webkit-keyframes skeleton-loading {
0% {
background-position: 400% 200%;
}
to {
background-position: 0 100%;
}
}
@keyframes skeleton-loading {
0% {
background-position: 400% 200%;
}
to {
background-position: 0 100%;
}
}
}
</style>
import Skeleton from './Skeleton'
export default {
install (Vue) {
Vue.component(Skeleton.name, Skeleton)
}
}
在main.js中引入使用即可import Skeleton from './plugins/skeleton/index'
Vue.use(Skeleton)
具体使用场景:
<template>
<!-- 利用v-if判断动态数据的获取状态,方便骨架屏的加载 -->
<ul v-if="playlist.length === 0" class="detail-bottom">
<li class="bottom-top">
<div class="button-icon"></div>
<div class="button-title">播放全部</div>
</li>
<Skeleton :length="5"></Skeleton>
</ul>
<ul v-else class="detail-bottom">
<li class="bottom-top">
<div class="button-icon"></div>
<div class="button-title">播放全部</div>
</li>
<li class="item" v-for="value in playlist" :key="value.id">
<h3>{{value.name}}</h3>
<p>{{value.al.name}} - {{value.ar[0].name}}</p>
</li>
</ul>
</template>
9. 真机调试阶段踩坑总结
通过Vue-CLI的npm run serve
指令打包的项目是可以通过同一个局域网中查看的
但首先第一个踩到的坑就是网络请求问题
网络请求问题
如果发现项目中的所有网络请求都失败了,主要有两方面原因。一个是后端api接口可能存在跨域问题,还有就是前端的请求地址出了问题。
如果是跨域问题,可以找到后端的api文件设置相应头的Access-Control-Allow-Origin
如果跨域问题解决了还是不行,就要检查一下此时的请求地址了
页面显示问题(flexible.js)
在index.html使用flexible.js作为外部本地JS文件引用在有路由(history模式)刷新时界面布局会出现问题,此时我们只需要把源码嵌入到当前html的script标签里,或是引用外部cdn地址的文件即可。
click事件无法触发
这个问题如果没有同行遇到过,处理起来是很棘手的!因为在真机调试时候,触发点击的地方没有反应,而且也没有报错!最初我以为是vue代码哪里出了问题,结果经过指点以后发现是项目中使用的IScroll.js
插件的锅,也就是说在项目中只要使用到IScroll的地方注册了点击事件就有可能没有反应,此时我们只需要在实例化IScroll对象的配置中加入两行配置就可以解决。
10. 图标替换
在真机调试阶段发现有些大屏安卓手机的浏览器上设备缩放比为1,这样就会导致有些小图标以缩放比为1的尺寸呈现在大屏幕上,质量很差很模糊。由于暂时还未找出部分机型浏览器上缩放比为1的原因,再加上如果去调整一张张图标尺寸和质量是很费时费力,所以决定统一更换为字体图标(iconfont)
,这样做的好处是这样的字体图标比较轻量,放大不会失真,且在打包过程中不会被压缩。
11目录调整
随着组件的增多,如果将所有组件全部建立在components文件夹中会非常混乱不利于观察,不知道其所属关系,所以需要调整一下目录结构:
这样就知道页面级组件与其所属的子组件的关系,例如将Detail详情界面这个页面级组件中所包含的子组件存放在components文件夹中的Detail文件夹,这样就方便管理。