开发基本准备

注册:产品定位及功能介绍 | 微信开放文档 (qq.com)

最新接口文档:https://github.com/Binaryify/NeteaseCloudMusicApi/releases

https://binaryify.github.io/NeteaseCloudMusicApi/#/?id=neteasecloudmusicapi

项目初始化

文件结构说明

小程序配置 | 微信开放文档 (qq.com)

小程序云音乐

配置项

项目的配置文件留着,其他都删掉

image-20221014223704330

一开始会报错

image-20221014223116106

app.json

完整内容查看官方文档即可,小程序配置 | 微信开放文档 (qq.com)

新建app.json

image-20221014223736990

报错,因为app.json不能为空,需要添加内容,

小程序根目录下的 app.json 文件用来对微信小程序进行全局配置,决定页面文件的路径、窗口表现、设置网络超时时间、设置多 tab 等。

image-20221014223808736

必填的两个字段是

  • pages,页面路径列表(必填)

  • sitemapLocation:

    • 指明 sitemap.json 的位置;
    • 默认为 ‘sitemap.json’ 即在 app.json 同级目录下名字的 sitemap.json 文件,可以不填了

创建页面

增加一个pages字段,保存后微信开发者工具会自动创建对应的4个文件,显示的页面在这里读取

1
2
3
4
5
6
{
"pages": [
"pages/index/index",
"pages/logs/index"
]
}

设置窗口

window字段,用于设置小程序的状态栏、导航条、标题、窗口背景色。全局配置 | 微信开放文档 (qq.com)

1
2
3
4
5
6
7
8
9
10
11
{
"pages": [
"pages/index/index",
"pages/logs/index"
],
"window": {
"navigationBarBackgroundColor": "#000",
"navigationBarTextStyle": "white",
"navigationBarTitleText": "小程序"
}
}

image-20221015081519071

app.js

除了app.json文件,全局还应该有一个app.js文件,注册小程序 | 微信开放文档 (qq.com)

每个小程序都需要在 app.js 中调用 App 方法注册小程序实例,绑定生命周期回调函数、错误监听和页面不存在监听函数等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app.js
App({
onLaunch (options) {
// Do something initial when launch.
},
onShow (options) {
// Do something when show.
},
onHide () {
// Do something when hide.
},
onError (msg) {
console.log(msg)
},
globalData: 'I am global data'
})

app.wxss

全局公共样式,WXSS | 微信开放文档 (qq.com)

窗口配置

新建一个空项目

在全局设置窗口颜色

1
2
3
4
5
6
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#d43c33",
"navigationBarTitleText": "云音乐",
"navigationBarTextStyle": "white"
},

image-20221015194312378

编写页面

修改pages/index/index.wxml

view标签相当于divtext标签相当于span

1
2
3
4
5
6
7
8
<!--pages/index/index.wxml-->
<view>
<image src="/static/images/nvsheng.jpg"></image>
<text>北方汉子</text>
<view>
<text>Hello World</text>
</view>
</view>

微信开发者工具,不支持文件的复制,可以在本地文件夹复制下(静态资源:)

路径直接从根路径开始写,保存查看页面

image-20221015083222527

添加类名开始写样式

1
2
3
4
5
6
7
8
<!--pages/index/index.wxml-->
<view class="indexContainer">
<image class="avatarUrl" src="/static/images/nvsheng.jpg"></image>
<text class="url">北方汉子</text>
<view class="goStudy">
<text>Hello World</text>
</view>
</view>

index.wxss

不能用px作为单位,否则盒子大小不会随着机型分辨率的变化而变化,如果写100px,在任何机型上就都是100px,需要做一下适配(用微信提供的rpx单位)

在iphone6上是100px,随着屏幕变大,在6 plus上的盒子大小应该大一点

那么还能是100rpx吗?不能

  • rpx(responsive pixel): 可以根据屏幕宽度进行自适应。规定屏幕宽为750rpx。如在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。
  • WXSS | 微信开放文档 (qq.com)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* pages/index/index.wxss */

/* 最外层包了个page标签,相当于body */
/* page放这里不合适,小程序会给每个页面都加一个page标签,其他页面想用就用不了,放到公共样式app.wxss里 */
/* page {
height: 100%;
} */
.indexContainer {
display: flex;
flex-direction: column;
align-items: center;
background-color: aliceblue; /*由于是flex布局,高度被内容撑开,不是全屏的*/
height: 100%;
}
.avatarUrl {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
margin: 100rpx 0;
}
.userName {
font-size: 32rpx;
margin: 100rpx 0;

}
.goStudy {
width: 300rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 28rpx;
border: 1px solid #333;
border-radius: 10rpx;
}

app.wxss

1
2
3
4
/* app.wxss */
page {
height: 100%;
}

效果:

image-20221015090618161

优化:图片很明显尺寸不对

原生小程序的wxss不支持background-image的import导入,https://www.wzjm.cn/phper/90.html,

  • 可以使用内联样式后,使用background的属性

    1
    2
    <view class="avatarUrl" style="background-image:url(/static/images/nvsheng.jpg)"> </view>

    1
    2
    3
    4
    5
    6
    7
    8
    9
    .avatarUrl {
    width: 200rpx;
    height: 200rpx;
    border-radius: 50%;
    margin: 100rpx 0;
    background-size: cover;
    background-position: center;
    /* background-image: url('static/images/nvsheng.jpg'); */
    }

直接使用image的object-fit:cover也没有生效,小程序不支持此属性,官方提供了自己的方法,mode属性。image | 微信开放文档 (qq.com)

╭(╯^╰)╮

1
2
<image class="avatarUrl" src="/static/images/nvsheng.jpg" mode="aspectFill"></image>

1
2
3
4
5
6
.avatarUrl {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
margin: 100rpx 0;
}

效果,头像不再是压缩的了:

image-20221015094047301

开发工具

使用IDE开发微信小程序

vscode

  • 识别wxss和wxml文件

    • 选择设置【Ctrl+,】或通过管理-设置

    • 选择右上角图标,第二个图标

      image-20221015200604684

    • 在setting.json中添加如下代码

      1
      "files.associations": { "*.wxml": "html", "*.wxss": "css"},
  • 安装相应插件

    • 小程序开发助手

      image-20221015200920631

    • WXML:微信小程序wxml格式化以及高亮组件(高度自定义)

      image-20221015200903093

    • WXSS-peek:为wxss样式提供跳转到定义功能

      image-20221015201053219

    • vscode weapp api:为vscode提供小程序API提示及代码片段

  • vscode开发小程序只是起到了编辑的效果,真正的调试等还需要在小程序开发工具中进行。

微信小程序中使用字体图标

阿里图标库选好图标后,由于是css,需要手动复制到wxss文件中,和平时项目中使用iconfont一样,使用类名

如果图标没出来,重启下微信开发者工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- 五个图标导航区域 -->
<view class="navContainer">
<view class="navItem">
<text class="iconfont icon-tuijian"></text>
<text class="desc">每日推荐</text>
</view>
<view class="navItem">
<text class="iconfont icon-gedan"></text>
<text class="desc">歌单</text>
</view>
<view class="navItem">
<text class="iconfont icon-paixingbang"></text>
<text class="desc">排行榜</text>
</view>
<view class="navItem">
<text class="iconfont icon-tuijian"></text>
<text class="desc">电台</text>
</view>
<view class="navItem">
<text class="iconfont icon-zhibo"></text>
<text class="desc">直播</text>
</view>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.navContainer {
display: flex;
}

.navItem {
display: flex;
flex-direction: column;
align-items: center;
width: 20%;
}

.navItem .iconfont {
width: 100rpx;
height: 100rpx;
margin: 20rpx 0;
background-color: rgb(240, 19, 19);
border-radius: 50%;
font-size: 50rpx;
line-height: 100rpx;
text-align: center;
color: #fff;
}

.navItem .desc {
font-size: 26rpx;
}

效果:

image-20221016102622176

使用vant框架

vant: 轻量、可靠的移动端 Vue 组件库 (gitee.com)

背景知识

使用 Vant Weapp 前,请确保你已经学习过微信官方的 小程序简易教程自定义组件介绍

配置注意:

2.27.0的版本(或以上),没有手动指定miniprogram_npm路径,直接在app.json中引入也可使用

使用Less预处理器

https://blog.csdn.net/qq_42592823/article/details/123603996

vscode下载插件并配置

1
2
3
"less.compile": {
"outExt": ".wxss"
}

注意:只在小程序项目里配置

获取用户信息

前置知识:了解完小程序语法这一小节

点击获取用户信息

button | 微信开放文档 (qq.com)

1
2
3
<image class="avatarUrl" src="{{userInfo.avatarUrl}}" mode="aspectFill"></image>
<button type="primary" open-type="getUserInfo" bindgetuserinfo="handleGetUserInfo">点击授权</button>
<text class="userName">{{userInfo.nickName}}</text>
1
2
3
4
5
6
7
8
9
10
11
12
data: {
msg: '初始化数据',
userInfo: {}, //初始化用户数据
},
handleGetUserInfo(res) {
console.log(res)
if(res.detail.userInfo) {
this.setData({
userInfo: res.detail.userInfo
})
}
},

详细参考官网,button按钮的open-type="getUserInfo"是开放的api,通过bindgetuserinfo绑定自定义回调,参数是点击后的详细信息

image-20221015190308372

目前上面的写法,每次重新进入小程序,已经授权的用户信息会丢失,见下一小节

onLoad时获取用户信息

什么时候获取用户信息?

  • 越早越好,因为要显示

  • 放在onLoad里,使用wx.getUserInfowx.getUserInfo(Object object) | 微信开放文档 (qq.com)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    onLoad(options) {
    console.log('我加载啦')
    wx.getUserInfo({
    success: (res) => { // 要用箭头函数,回调函数被调用时,是wx页面实例调的
    console.log('获取授权成功', res)
    this.setData({
    userInfo: res.userInfo
    })
    },
    fail: (err) => {
    console.log('获取授权失败', err)
    }
    })
    },

    image-20221015192210943

  • 由于之前的api,获取用户信息需要用户点击授权确认,放在onLoad里,是为了第二次进入小程序时,直接获取授权后的用户信息

    • 现在没那个弹出窗口了,进入时,后台直接就获取成功的结果了

使用条件渲染,优化代码:没授权时,头像和昵称的结构不应该显示

1
2
3
<image wx:if="{{userInfo.avatarUrl}}" class="avatarUrl" src="{{userInfo.avatarUrl}}" mode="aspectFill"></image>
<button wx:else type="primary" open-type="getUserInfo" bindgetuserinfo="handleGetUserInfo">点击授权</button>
<text wx:if="{{userInfo.avatarUrl}}" class="userName">{{userInfo.nickName}}</text>

轮播图

使用swiper组件,swiper | 微信开放文档 (qq.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--index.wxml-->
<view class="container">
<!-- 轮播图区域 -->
<swiper class="banners" indicator-dots indicator-color="ivory" indicator-active-color="#d43c33" autoplay interval="5000" circular>
<swiper-item>
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
</swiper-item>
<swiper-item>
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
</swiper-item>
<swiper-item>
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
</swiper-item>
</swiper>
</view>

image-20221015200104128

推荐歌曲

scroll-view,可滚动的试图区域,scroll-view | 微信开放文档 (qq.com)

该组件需要设置enable-flex属性为true,设置flex才会生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!-- 推荐歌曲区域 -->
<view class="recommendContainer">
<view class="header">
<text class="title">123</text>
<view class="item">
<text class="desc">为你精心推荐</text>
<text class="more">查看更多</text>
</view>
</view>
<!-- 内容区 -->
<scroll-view class="recommendScroll" enable-flex scroll-x>
<view class="scrollItem">
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
<text>推荐歌曲内容区</text>
</view>
<view class="scrollItem">
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
<text>推荐歌曲内容区</text>
</view>
<view class="scrollItem">
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
<text>推荐歌曲内容区推荐歌曲内容区</text>
</view>
<view class="scrollItem">
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
<text>推荐歌曲内容区推荐歌曲内容区</text>
</view>
<view class="scrollItem">
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
<text>推荐歌曲内容区推荐歌曲内容区</text>
</view>
<view class="scrollItem">
<image mode="aspectFill" src="/static/images/nvsheng.jpg"></image>
<text>aaaa</text>
</view>
</scroll-view>

</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* 推荐歌曲开始 */
.recommendContainer {
padding: 20rpx;
}
.recommendContainer .header {
margin-bottom: 20rpx;
}
.recommendContainer .header .title {
font-size: 32rpx;
line-height: 40rpx;
color: #666;
}
.recommendContainer .header .item {
height: 60rpx;
}
.recommendContainer .header .desc {
font-size: 30rpx;
line-height: 60rpx;
}
.recommendContainer .header .more {
float: right;
padding: 10rpx 20rpx;
border: 1px solid #333;
border-radius: 30rpx;
font-size: 24rpx;
}
/* 推荐歌曲内容区开始 */
.recommendScroll {
display: flex;
}
.scrollItem {
width: 200rpx;
margin-right: 20rpx;
}
.scrollItem image {
width: 200rpx;
height: 200rpx;
border-radius: 10rpx;
}
.scrollItem text {
font-size: 26rpx;
/* 单行文本溢出 */
/* display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; */

/* 多行文本溢出 */
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* 推荐歌曲内容区结束 */

/* 推荐歌曲结束 */

前后端交互

网络基础说明文档:网络 | 微信开放文档 (qq.com)

网易云音乐有自己的开放接口,我们用nodejs做一下中间层,分发路由

我们把服务部署在服务器上(也可以直接跑本地)

安装pm2管理

1
2
npm i pm2
npx pm2 start app.js --name music

image-20221017230803035

接口文档及后台,见项目资料

正式编写代码时,用测试工具测试一下接口

微信中发送网络请求:RequestTask | 微信开放文档 (qq.com)

语法:wx.request()

注意点:

  • 协议必须是https协议
  • 一个接口最多配置20个域名
  • 并发限制上限是10个

启动后台测试服务

image-20221016183008646

在小程序后台配置接口地址,配置会失败

image-20221016182917797

image-20221016183305281

只能配已备案过的域名,如果是在企业开发,后台应该提供的是https的接口

  • 包括:域名申请、付费、企业备案、接口部署

不过开发时,可以在开发者工具中,设置不校验合法域名

image-20221016182731361

在onLoad或者onReady里面发送

pages/index/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// index.js
Page({
data: {

},
onLoad: function(options) {
console.log('index加载了')
wx.request({
url: 'http://localhost:3000/banner',
data: {
type: 2
},
success: (res) => {
console.log('请求成功', res)
},
fail: (err) => {
console.log('请求失败', err)
}
})
}
})

请求成功:

image-20221016184358852

封装请求功能函数

发送ajax请求

1.封装功能函数

  • 功能点明确
  • 函数内部应该保留固定代码(静态的)
  • 将动态的数据抽取成形参,由使用者根据自身的情况动态的传入实参
  • 一个良好的功能函数应该设置形参的默认值(ES6的形参默认值)

2.封装功能组件

  • 功能点明确

  • 组件内部保留静态代码

  • 将动态的数据抽取成props参数,由使用者根据自身的情况以标签属性的形式动态传入props数据

  • 一个良好的组件应该设置组件的必要性及数据类型

    props: {

    ​ msg: {

    ​ required: true,

    ​ default: 默认值,

    ​ type: String

    ​ }

    }

新建utils/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import config from './config.js'
export default (url, data = {}, method = 'GET') => {
return new Promise((resolve, reject) => {
wx.request({
url: config.host + url,
data,
success: (res) => {
console.log('请求成功', res)
resolve(res.data)
},
fail: (err) => {
console.log('请求失败', err)
reject(err)
}
})
})
}

新建配置文件

utils/config.js

1
2
3
4
// 服务器配置相关
export default {
host: 'http://localhost:3000'
}

发送请求

1
2
3
4
5
6
7
8
9
10
11
12
// index.js
import request from '../../utils/request.js'
Page({
data: {

},
onLoad: async function(options) {
console.log('index加载了')
let result = await request('/banner', {type: 2})
console.log(result)
}
})

渲染轮播图数据

1
2
3
4
5
6
<!-- 轮播图区域 -->
<swiper class="banners" indicator-dots indicator-color="ivory" indicator-active-color="#d43c33" autoplay interval="5000" circular>
<swiper-item wx:for="{{bannerList}}" wx:key="bannerId">
<image mode="aspectFill" src="{{item.pic}}"></image>
</swiper-item>
</swiper>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// index.js
import request from '../../utils/request.js'
Page({
data: {
bannerList: []
},
onLoad: async function(options) {
console.log('index加载了')
let bannerListData = await request('/banner', {type: 2})
console.log(bannerListData)
this.setData({
bannerList: bannerListData.banners
})
}
})

效果

image-20221016213848906

推荐歌曲动态实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 推荐歌曲区域 -->
<view class="recommendContainer">
<view class="header">
<text class="title">推荐歌曲</text>
<view class="item">
<text class="desc">为你精心推荐</text>
<text class="more">查看更多</text>
</view>
</view>
<!-- 内容区 -->
<scroll-view class="recommendScroll" enable-flex scroll-x>
<view class="scrollItem" wx:for="{{recommendList}}" wx:key="id">
<image mode="aspectFill" src="{{item.picUrl}}"></image>
<text>{{item.name}}</text>
</view>
</scroll-view>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// index.js
import request from '../../utils/request.js'
Page({
data: {
bannerList: [], // 轮播图数据
recommendList: [], // 推荐歌单数据
},
onLoad: async function(options) {
console.log('index加载了')

// 获取轮播图数据
let bannerListData = await request('/banner', {type: 2})
this.setData({
bannerList: bannerListData.banners
})

// 获取歌单数据
let recommendListData = await request('/personalized', {limit: 10})
this.setData({
recommendList: recommendListData.result
})
}
})

效果:

image-20221016215310501

自定义组件

Component(Object object) | 微信开放文档 (qq.com)

新建components/NavHeader,及对应的4个文件

定义组件

1
2
3
4
5
6
7
8
<!--components/NavHeader.wxml-->
<view class="header">
<text class="title">推荐歌曲</text>
<view class="item">
<text class="desc">为你精心推荐</text>
<text class="more">查看更多</text>
</view>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* components/NavHeader.wxss */
.header {
margin-bottom: 20rpx;
}
.header .title {
font-size: 32rpx;
line-height: 40rpx;
color: #666;
}
.header .item {
height: 60rpx;
}
.header .desc {
font-size: 30rpx;
line-height: 60rpx;
}
.header .more {
float: right;
padding: 10rpx 20rpx;
border: 1px solid #333;
border-radius: 30rpx;
font-size: 24rpx;
}

index.json中注册组件

1
2
3
4
5
{
"usingComponents": {
"NavHeader": "/components/NavHeader/NavHeader"
}
}

使用组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 推荐歌曲区域 -->
<view class="recommendContainer">
<NavHeader></NavHeader>
<!-- 内容区 -->
<scroll-view class="recommendScroll" enable-flex scroll-x>
<view class="scrollItem" wx:for="{{recommendList}}" wx:key="id">
<image mode="aspectFill" src="{{item.picUrl}}"></image>
<text>{{item.name}}</text>
</view>
</scroll-view>
</view>

<!-- 排行榜区域 -->
<view>
<!-- 头部区域 -->
<NavHeader></NavHeader>
<!-- 内容区域 -->
</view>

调整scrollView的高度

1
2
3
4
.recommendScroll {
display: flex;
height: 300rpx;
}

排行榜

增加样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* 排行榜开始 */
.topList {
padding: 20rpx;
padding-top: 0;
}
.topListSwiper {
height: 400rpx;
}
.swiperItem {
width: 96%;
background-color: #fbfbfb;
}
.swiperItem .title {
font-size: 30rpx;
line-height: 80rpx;
}
.musicItem {
display: flex;
margin-bottom: 20rpx;
}

.musicItem image {
width: 100rpx;
height: 100rpx;
border-radius: 6rpx;
}

.musicItem .count {
width: 100rpx;
height: 100rpx;
text-align: center;
line-height: 100rpx;
}

.musicItem .musicName {
height: 100rpx;
line-height: 100rpx;
max-width: 400rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 排行榜结束 */

组件封装时,我们需要传入动态的数据

NavHeader.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// components/NavHeader.js
Component({
/**
* 组件的属性列表
*/
properties: {
title: {
type: String,
value: '我是title默认值'
},
nav: {
type: String,
value: '我是nav默认值'
}
},

/**
* 组件的初始数据
*/
data: {

},

/**
* 组件的方法列表
*/
methods: {

}
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- 推荐歌曲区域 -->
<view class="recommendContainer">
<NavHeader title="推荐歌曲" nav="为你精心推荐"></NavHeader>
<!-- 内容区 -->
<scroll-view class="recommendScroll" enable-flex scroll-x>
<view class="scrollItem" wx:for="{{recommendList}}" wx:key="id">
<image mode="aspectFill" src="{{item.picUrl}}"></image>
<text>{{item.name}}</text>
</view>
</scroll-view>
</view>

<!-- 排行榜区域 -->
<view class="topList">
<!-- 头部区域 -->
<NavHeader title="排行榜" nav="热歌风向标"></NavHeader>
<!-- 内容区域 -->
<swiper class="topListSwiper" circular next-margin="50rpx" previous-margin="50rpx">
<swiper-item wx:for="{{topList}}" wx:key="name">
<view class="swiperItem">
<view class="title">
{{item.name}}
</view>
<view class="musicItem" wx:for="{{item.tracks}}" wx:key="id" wx:for-item="musicItem">
<image src="{{musicItem.al.picUrl}}"></image>
<text class="count">{{index + 1}}</text>
<text class="musicName">{{musicItem.name}}</text>
</view>
</view>
</swiper-item>
</swiper>
</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    data: {
// ...
topList: [], // 排行榜数据
},


// 获取排行榜数据
/**
* 需求分析:
* 1.需要根据idx的值获取对应的数据
* 2.idx的取值范围是0-20,我们需要0-4
* 3.需要发5次请求
*/
let index = 0,
resultArr = []
while(index < 5) {
let topListData = await request('/top/list', {idx: index++})
let topListItem = {
name: topListData.playlist.name,
tracks: topListData.playlist.tracks.slice(0, 3)
}
resultArr.push(topListItem)
// 更新topList的值,发一次就更新一次(多渲染几次,牺牲点性能,用户体验较好,弱网用户不会有较长时间的白屏)
this.setData({
topList: resultArr
})
}

效果:

image-20221018213944706

更新:最新接口已不支持idx形式调用,根据最新接口文档自行调整

image-20221030185320382

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// index.js
import request from '../../utils/request.js'
Page({
data: {
bannerList: [], // 轮播图数据
recommendList: [], // 推荐歌单数据
topList: [], // 排行榜数据
},
onLoad: async function(options) {
console.log('index加载了')

// 获取轮播图数据
let bannerListData = await request('/banner', {type: 2})
this.setData({
bannerList: bannerListData.banners
})

// 获取歌单数据
let recommendListData = await request('/personalized', {limit: 10})
this.setData({
recommendList: recommendListData.result
})

// 获取排行榜数据
/**
* 需求分析:
* 1.需要根据idx的值获取对应的数据
* 2.idx的取值范围是0-20,我们需要0-4
* 3.需要发5次请求
*/
let index = 0,
resultArr = []
// while(index < 5) {
// let topListData = await request('/top/list', {idx: index++})
// let topListItem = {
// name: topListData.playlist.name,
// tracks: topListData.playlist.tracks.slice(0, 3)
// }
// resultArr.push(topListItem)
// // 更新topList的值,发一次就更新一次(多渲染几次,用户体验较好,弱网用户不会有较长时间的白屏)
// this.setData({
// topList: resultArr
// })
// }

// 获取所有排行榜的信息,根据id字段获取歌单的详细信息(排行榜也是歌单的一种)
let topListData = await request('/toplist')
let list = topListData.list

list.forEach(async (item, index) => {
if(index < 5) {
let {name, id} = item
let listDetailData = await request('/playlist/detail', {id})
console.log('aa',listDetailData)
let topListItem = {
name,
tracks: listDetailData.playlist.tracks.slice(0, 3)
}
resultArr.push(topListItem)
}
index++
this.setData({
topList: resultArr
})
})





}
})

内网穿透

真机调试时需要用到

但由于接口已经部署到服务器上了,可以公网访问,此小节跳过

tabBar使用

微信开发者工具中,新建perosnal和video两个页面

tabBar配置,全局配置 | 微信开放文档 (qq.com)

微信开发者工具中,在app.json全局配置中,新增tabBar配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"pages": [
"pages/index/index",
"pages/personal/personal",
"pages/video/video"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#d43c33",
"navigationBarTitleText": "云音乐",
"navigationBarTextStyle": "white"
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"tabBar": {
"color": "#333",
"selectedColor": "#d43c33",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"text": "主页",
"iconPath": "/static/myImages/tabs/tab-home.png",
"selectedIconPath": "/static/myImages/tabs/tab-home-current.png"
},
{
"pagePath": "pages/video/video",
"text": "视频",
"iconPath": "/static/myImages/tabs/video.png",
"selectedIconPath": "/static/myImages/tabs/video-selected.png"
},
{
"pagePath": "pages/personal/personal",
"text": "主页",
"iconPath": "/static/myImages/tabs/tab-my.png",
"selectedIconPath": "/static/myImages/tabs/tab-my-current.png"
}
]
}
}

可以去阿里图标库里找一些好看的图标

个人中心静态页面

app.json中,先把个人中心页提到第一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<view class="personalContainer">
<view class="user-section">
<image mode="aspectFill" class="bg" src="/static/images/personal/bgImg2.jpg"></image>
<view class="user-info-box" bindtap="toLogin">
<view class="portrait-box">
<image class="portrait" src='{{userInfo.avatarUrl?userInfo.avatarUrl:"/static/images/personal/missing-face.png"}}'></image>
</view>
<view class="info-box">
<text class="username">{{userInfo.nickname?userInfo.nickname: '游客'}}</text>
</view>
</view>

<view class="vip-card-box">
<image class="card-bg" src="/static/images/personal/vip-card-bg.png" mode=""></image>
<view class="b-btn">
立即开通
</view>
<view class="tit">
<!-- 会员图标-->
<text class="iconfont icon-huiyuan-"></text>
硅谷会员
</view>
<text class="e-m">atguigu Union</text>
<text class="e-b">开通会员听歌</text>
</view>
</view>

<view
class="cover-container"
bindtouchstart="handleTouchStart"
bindtouchmove="handleTouchMove"
bindtouchend="handleTouchEnd"
style="transform: {{coverTransform}}; transition: {{coveTransition}}"
>
<image class="arc" src="/static/images/personal/arc.png"></image>
<!-- 个人中心导航 -->
<view class="nav-section">
<view class="nav-item" hover-class="common-hover" hover-stay-time="50">
<text class="iconfont icon-xiaoxi"></text>
<text>我的消息</text>
</view>
<view class="nav-item" hover-class="common-hover" hover-stay-time="50">
<text class="iconfont icon-myRecommender"></text>
<text>我的好友</text>
</view>
<view class="nav-item" hover-class="common-hover" hover-stay-time="50">
<text class="iconfont icon-gerenzhuye"></text>
<text>个人主页</text>
</view>
<view class="nav-item" hover-class="common-hover" hover-stay-time="50">
<text class="iconfont icon-gexingzhuangban"></text>
<text>个性装扮</text>
</view>
</view>

<!-- 个人中心列表 -->
<view class="personalContent">
<view class="recentPlayContainer">
<text class="title">最近播放</text>
<!-- 最近播放记录 -->
<scroll-view wx:if="{{recentPlayList.length}}" scroll-x class="recentScroll" enable-flex>
<view class="recentItem" wx:for="{{recentPlayList}}" wx:key="{{id}}">
<image src="{{item.song.al.picUrl}}"></image>
</view>
</scroll-view>
<view wx:else>暂无播放记录</view>
</view>

<view class="cardList">
<view class="card-item">
<text class="title">我的音乐</text>
<text class="more"> > </text>
</view>
<view class="card-item">
<text class="title">我的收藏</text>
<text class="more"> > </text>
</view>
<view class="card-item">
<text class="title">我的电台</text>
<text class="more"> > </text>
</view>
</view>
</view>
</view>


</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/* pages/personal/personal.wxss */
.personalContainer {
width: 100%;
height: 100%;
}

.personalContainer .user-section {
height: 520rpx;
position: relative;
padding: 100rpx 30rpx 0;
}
.user-section .bg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0.95;
filter: blur(1px);

}


.user-info-box{
height: 180rpx;
display:flex;
align-items:center;
position:relative;
z-index: 1;

}

.user-info-box .portrait{
width: 130rpx;
height: 130rpx;
border:5rpx solid #fff;
border-radius: 50%;
}
.user-info-box .username{
font-size: 24;
color: #303133;
margin-left: 20rpx;
}


/* vip-box */
.vip-card-box {
position: relative;
display: flex;
flex-direction: column;
background: linear-gradient(left, red, black);
background: rgba(0, 0, 0, .7);
height: 240rpx;
color: #f7d680;
border-radius: 16rpx 16rpx 0 0;
padding: 20rpx 24rpx;
}


.vip-card-box .card-bg{
position:absolute;
top: 20rpx;
right: 0;
width: 380rpx;
height: 260rpx;
}

.vip-card-box .b-btn{
position: absolute;
right: 20rpx;
top: 16rpx;
width: 132rpx;
height: 40rpx;
text-align: center;
line-height: 40rpx;
font-size: 22rpx;
color: #36343c;
border-radius: 20px;
background: #f9e6af;
z-index: 1;
}

.vip-card-box .b-btn{
position: absolute;
right: 20rpx;
top: 16rpx;
width: 132rpx;
height: 40rpx;
text-align: center;
line-height: 40rpx;
font-size: 22rpx;
color: #36343c;
border-radius: 20px;
/*background: linear-gradient(left, #f9e6af, #ffd465);*/ /*渐变不生效*/
background: #f9e6af;
z-index: 1;
}

.vip-card-box .tit {
font-size: 22rpx;
color: #f7d680;
margin-bottom: 28rpx;
}
.vip-card-box .tit .iconfont{
color: #f6e5a3;
margin-right: 16rpx;
}




.vip-card-box .e-m{
font-size: 34rpx;
margin-top: 10rpx;
}
.vip-card-box .e-b{
font-size: 24rpx;
color: #d8cba9;
margin-top: 10rpx;
}


.cover-container{
margin-top: -150rpx;
padding: 0 30rpx;
position:relative;
background: #f5f5f5;
padding-bottom: 20rpx;
}

.cover-container .arc{
position:absolute;
left: 0;
top: -34rpx;
width: 100%;
height: 36rpx;
}


/* 导航部分 */
.cover-container .nav-section {
display: flex;
background: #fff;
padding: 20rpx 0;
border-radius: 15rpx;
}


.nav-section .nav-item {
width: 25%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}

.nav-section .nav-item .iconfont {
font-size: 50rpx;
color: #d43c33;
line-height: 70rpx;
}

.nav-section .nav-item text:last-child {
font-size: 22rpx;

}


/* 个人中心列表 */
.personalContent {
background: #fff;
margin-top: 20rpx;
}

/* 最近播放 */
.personalContent .scrollView {
display: flex;
height: 160rpx;
}
.personalContent .recentPlay {
display: flex;
}

.recentPlayContainer .title {
padding-left: 20rpx;
font-size: 26rpx;
color: #333;
line-height: 80rpx;
}

.personalContent .recentPlay image {
width: 160rpx;
height: 160rpx;
margin-left: 20rpx;
border-radius: 20rpx;
}


.cardList {
margin-top: 20rpx;

}
.cardList .card-item{
border-top: 1rpx solid #eee;
height: 80rpx;
line-height: 80rpx;
padding: 10rpx;
font-size: 26rpx;
color: #333;
}
.cardList .card-item .more {
float: right;
}

/* 最近播放记录 */
.recentScroll {
display: flex;
height: 200rpx;
}
.recentItem {
margin-right: 20rpx;
}
.recentItem image {
width: 200rpx;
height: 200rpx;
border-radius: 10rpx;
}

个人中心动画效果实现

不足:不够丝滑

  • 二次touch的时候,会立即重置动画
  • 下拉会有卡顿
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// pages/personal.js
let startY = 0,
moveY = 0,
moveDistance = 0;
Page({

/**
* 页面的初始数据
*/
data: {
coverTransform: 'translateY(0)',
coverTransition: ''
},

/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {

},

handleTouchStart(event) {
// console.log('handleTouchStart')
console.log(event)
this.setData({
coverTransition: ''
})
// 获取手指起始坐标
startY = event.touches[0].clientY


},
handleTouchMove(event) {
// console.log('handleTouchMove')
moveY = event.touches[0].clientY
moveDistance = moveY - startY
// 动态更新coverTransform的状态值
if(moveDistance <= 0) {
return
}
if(moveDistance >= 80) {
moveDistance = 80
}
this.setData({
coverTransform: `translateY(${moveDistance}rpx)`
})
},
handleTouchEnd() {
// console.log('handleTouchEnd')
this.setData({
coverTransform: 'translateY(0rpx)',
coverTransition: 'transform 0.5s linear'
})
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {

},

/**
* 生命周期函数--监听页面显示
*/
onShow() {

},

/**
* 生命周期函数--监听页面隐藏
*/
onHide() {

},

/**
* 生命周期函数--监听页面卸载
*/
onUnload() {

},

/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {

},

/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {

},

/**
* 用户点击右上角分享
*/
onShareAppMessage() {

}
})

登录界面及表单数据收集

新建login页,配置app.json

登录流程:

  • 前端验证
    • 验证用户信息(账号、密码)是否合法
    • 前端验证不通过就提示用户,不需要发请求给后端
    • 前端验证通过了,发请求(携带账号、密码(加密))给服务器
  • 后端验证
    • 查询数据库,验证用户是否存在
    • 用户不存在,告诉前端密码或账户名错误
    • 用户存在,解密验证密码是否正确
    • 密码不正确,告诉前端密码或账户名错误
    • 密码正确,签发token,提示前端登录成功
  • 前端获取token,存储在本地,每次请求携带token
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

<view class="container">
<view class="wrapper">
<view class="left-top-sign">LOGIN</view>
<view class="welcome">
欢迎回来!
</view>
<view class="input-content">
<view class="input-item">
<text class="tit">手机号码</text>
<input type="text" placeholder="请输入手机号码" data-test="abc" data-type="phone" id="phone" bindinput="handleInput"/>
</view>
<view class="input-item">
<text class="tit">密码</text>
<input type="password" placeholder="请输入密码" data-test="abc" data-type="password" id="password" bindinput="handleInput"/>
</view>
</view>
<button class="confirm-btn" bindtap="login">登录</button>
<view class="forget-section">
忘记密码?
</view>
</view>
<view class="register-section">
还没有账号?
<text >马上注册</text>
</view>
</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/* pages/login/login.wxss */
.wrapper{
position:relative;
z-index: 90;
padding-bottom: 40rpx;
}

.left-top-sign{
font-size: 120rpx;
color: #f8f8f8;
position:relative;
left: -16rpx;
letter-spacing: 2rpx;
}

.welcome{
position:relative;
left: 50rpx;
top: -90rpx;
font-size: 46rpx;
color: #555;
}


.input-content{
padding: 0 60rpx;
}
.input-item{
display:flex;
flex-direction: column;
align-items:flex-start;
justify-content: center;
padding: 0 30rpx;
background:#f8f6fc;
height: 120rpx;
border-radius: 4px;
margin-bottom: 50rpx;

}

.input-item:last-child{
margin-bottom: 0;
}
.input-item .tit{
height: 50rpx;
line-height: 56rpx;
font-size: 30rpx;
color: #606266;
}
.input-item input{
height: 60rpx;
font-size: 30rpx;
color: #303133;
width: 100%;
}
.confirm-btn{
width: 630rpx!important;
height: 76rpx;
line-height: 76rpx;
border-radius: 50rpx;
margin-top: 70rpx;
background: #d43c33;
color: #fff;
font-size: 32rpx;
padding: 0;
}
.confirm-btn2:after{
border-radius: 100px;
}

.forget-section{
font-size: 28rpx;
color: #4399fc;
text-align: center;
margin-top: 40rpx;
}

.register-section{
position:absolute;
left: 0;
bottom: 50rpx;
width: 100%;
font-size: 28rpx;
color: #606266;
text-align: center;

}
.register-section text{
color: #4399fc;
margin-left: 10rpx;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// pages/login.js
Page({

/**
* 页面的初始数据
*/
data: {
phone: '', // 手机号
password: '', // 用户密码
},
handleInput(event) {
// let type = event.currentTarget.id // phone || password 通过id传值
let type = event.currentTarget.dataset.type
console.log(type, event.detail.value)
this.setData({
[type]: event.detail.value
})
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {

},

/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {

},

/**
* 生命周期函数--监听页面显示
*/
onShow() {

},

/**
* 生命周期函数--监听页面隐藏
*/
onHide() {

},

/**
* 生命周期函数--监听页面卸载
*/
onUnload() {

},

/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {

},

/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {

},

/**
* 用户点击右上角分享
*/
onShareAppMessage() {

}
})

事件 | 微信开放文档 (qq.com)

当只传递唯一标识时,可以通过id来传递

当传递多个自定义属性,可以通过data-的方式

登录字段验证

前端验证

界面交互API,wx.showToast(Object object) | 微信开放文档 (qq.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// pages/login.js
Page({

/**
* 页面的初始数据
*/
data: {
phone: '', // 手机号
password: '', // 用户密码
},
handleInput(event) { // 收集表单数据
// let type = event.currentTarget.id // phone || password 通过id传值
let type = event.currentTarget.dataset.type
console.log(type, event.detail.value)
this.setData({
[type]: event.detail.value
})
},
login() { // 登录的回调
// 收集表单项数据
let {phone , password} = this.data
// 前端验证
if(!phone) {
// 提示用户
wx.showToast({
title: '手机号不能为空',
icon: 'none'
})
return
}

// 定义正则
let phoneReg = /^1(3|4|5|6|7|8|9)\d{9}$/
if(!phoneReg.test(phone)) {
wx.showToast({
title: '手机号格式错误',
icon: 'none'
})
return
}

if(!password) {
wx.showToast({
title: '密码不能为空',
icon: 'none'
})
return
}
},

})

后端验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// pages/login.js
import request from '../../utils/request'
Page({

/**
* 页面的初始数据
*/
data: {
phone: '', // 手机号
password: '', // 用户密码
},
handleInput(event) { // 收集表单数据
// let type = event.currentTarget.id // phone || password 通过id传值
let type = event.currentTarget.dataset.type
console.log(type, event.detail.value)
this.setData({
[type]: event.detail.value
})
},
async login() { // 登录的回调
// 收集表单项数据
let {phone , password} = this.data
// 前端验证
if(!phone) {
// 提示用户
wx.showToast({
title: '手机号不能为空',
icon: 'none'
})
return
}

// 定义正则
let phoneReg = /^1(3|4|5|6|7|8|9)\d{9}$/
if(!phoneReg.test(phone)) {
wx.showToast({
title: '手机号格式错误',
icon: 'none'
})
return
}

if(!password) {
wx.showToast({
title: '密码不能为空',
icon: 'none'
})
return
}

// 后端验证
let res = await request('/login/cellphone', {phone, password})
// 实际开发中,要考虑多种状态码的情况

switch (res.code){
case 200:
wx.showToast( {
title: '登录成功'
})
break
case 400:
wx.showToast({
title: '手机号或密码错误',
icon: 'none'
})
break
case 502:
wx.showToast({
title: '手机号或密码错误',
icon: 'none'
})
break
default:
wx.showToast({
title: res.message || '登录失败,请重新登录',
icon: 'none'
})
}


},

})

个人中心、登录页面交互(本地存储)

把个人中心页在app.json中提到第一页

数据本地存储,wx.setStorage(Object object) | 微信开放文档 (qq.com)

personal.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// pages/personal.js
let startY = 0,
moveY = 0,
moveDistance = 0;
Page({

/**
* 页面的初始数据
*/
data: {
coverTransform: 'translateY(0)',
coverTransition: '',
userInfo: {}
},

/**
* 生命周期函数--监听页面加载,只执行一次
*/
onLoad(options) {
let localUserInfo = wx.getStorageSync('userInfo')
if(localUserInfo) {
// 更新userInfo状态
this.setData({
userInfo: JSON.parse(localUserInfo)
})
}
},

handleTouchStart(event) {
// console.log('handleTouchStart')
console.log(event)
this.setData({
coverTransition: ''
})
// 获取手指起始坐标
startY = event.touches[0].clientY


},
handleTouchMove(event) {
// console.log('handleTouchMove')
moveY = event.touches[0].clientY
moveDistance = moveY - startY
// 动态更新coverTransform的状态值
if(moveDistance <= 0) {
return
}
if(moveDistance >= 80) {
moveDistance = 80
}
this.setData({
coverTransform: `translateY(${moveDistance}rpx)`
})
},
handleTouchEnd() {
// console.log('handleTouchEnd')
this.setData({
coverTransform: 'translateY(0rpx)',
coverTransition: 'transform 0.5s linear'
})
},
// 跳转到登录页面
toLogin() {
wx.navigateTo({
url: '/pages/login/login'
})
},

})

login.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
switch (res.code){
case 200:
wx.showToast( {
title: '登录成功'
})
// 本地存储个人信息
wx.wx.setStorage({
key: 'userinfo',
data: JSON.stringify(res.profile)
})
// 跳转到个人中心
wx.reLaunch({ // perosnal获取本地数据放在了onload里,用reLauch方法保证onload回调会重新执行,当然也可以不把读取用户数据的方法放在onLoad里,放在onShow里面(性能不太好,每次页面显示都会执行)
url: '/pages/personal/personal'
})
break

// ...

personal.wxml

1
2
3
4
5
6
<view class="portrait-box">
<image class="portrait" src='{{userInfo.avatarUrl ? userInfo.avatarUrl : "/static/images/personal/missing-face.png"}}'></image>
</view>
<view class="info-box">
<text class="username">{{userInfo.nickname ? userInfo.nickname : '游客'}}</text>
</view>

获取用户播放记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
onLoad(options) {
try {
let localUserInfo = wx.getStorageSync('userinfo')
// console.log(localUserInfo)
if(localUserInfo) {
// 更新userInfo状态
this.setData({
userInfo: JSON.parse(localUserInfo)
})
}

// 获取用户播放记录
this.getUserRecentPlayList(this.data.userInfo.userId)
} catch(err) {
console.log(err)
}

},
async getUserRecentPlayList(userId) {
let recentPlayListData = await request('/user/record',{uid: userId, type: 0})
console.log(recentPlayListData)
let index = 0
// 构造数组每一项,添加id
let recentPlayList= recentPlayListData.allData.splice(0, 10).map(item => {
item.id = index++
return item
})
this.setData({
recentPlayList
})
},

image-20221030221454399

video导航区域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// pages/video/video.js
import request from '../../utils/request'
Page({

/**
* 页面的初始数据
*/
data: {
videoGroupList: [], // 导航的标签数据(命名和接口保持一致)
navId: 0, // 导航的标识
},

/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
// 获取导航标签数据
this.getVideoGroupListData()
},
async getVideoGroupListData() {
let videoGroupListData = await request('/video/group/list')
this.setData({
videoGroupList: videoGroupListData.data.slice(0, 14),
navId: videoGroupListData.data[0].id
})

// 拿到navId后,获取视频列表数据
this.getVideoList(this.data.navId)
},
// 点击切换导航的回调
changeNav(event) {
let navId = Number(event.currentTarget.id)
this.setData({
navId
})
},
// 获取视频列表数据
async getVideoList(navId) {
if(!navId) return
let videoListData = await request('/video/group', {id: navId})
console.log(videoListData)
},

})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--pages/video/video.wxml-->
<view class="videoContainer">
<!-- 头部区域 -->
<view class="header">
<image src="/static/images/video/video.jpg" mode="aspectFit" lazy-load="false" binderror="" bindload="" />
<view class="search">搜索商品</view>
<image src="/static/images/logo.png" mode="aspectFit" lazy-load="false" binderror="" bindload="" />
</view>

<!-- 导航区域 -->
<scroll-view scroll-x="true" class="navScroll" enable-flex>
<view class="navItem" wx:for="{{videoGroupList}}" wx:key="id">
<view class="navContent {{navId === item.id ? 'active' : ''}}" bindtap="changeNav" id="{{item.id}}">{{item.name}}</view>
</view>

</scroll-view>

</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* pages/video/video.wxss */
.videoContainer .header {
display: flex;
padding: 10rpx;
}
.videoContainer .header image {
width: 60rpx;
height: 60rpx;
}
.videoContainer .header .search {
border: 1px solid #eee;
flex: 1;
margin: 0 20rpx;
font-size: 26rpx;
text-align: center;
line-height: 60rpx;
color: #d43c33;
}

/* 导航区域 */

.navScroll {
display: flex;
white-space: nowrap;
}

.navScroll .navItem {
height: 60rpx;
line-height: 60rpx;
padding: 0 30rpx;
font-size: 28rpx;
}

.navItem .active {
border-bottom: 1px solid #d43c33;
}

保存cookie

由于封装request时,返回的是res.data,但cookie在res中,所以需要在request.js中处理cookie

不直接判断请求是否包含login,而是通过传参控制

login.js

1
2
// 后端验证
let res = await request('/login/cellphone', {phone, password, isLogin: true})

request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
wx.request({
url: config.host + url,
data,
success: (res) => {
console.log('请求成功', res)
// 登录请求
if(data.isLogin) {
console.log('hasCookie')
wx.setStorage({
key: 'cookie',
data: res.cookie
})
}
resolve(res.data)
},
fail: (err) => {
console.log('请求失败', err)
reject(err)
}
})

但是,人家接口改了,cookie放到了res.data里,所以还是在登录成功的回调里保存cookie

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case 200:
wx.showToast( {
title: '登录成功'
})
// 本地存储个人信息
wx.setStorage({
key: 'userinfo',
data: JSON.stringify(res.profile)
})
// 存储cookie
wx.setStorage({
key: 'cookie',
data: res.cookie
})
// 跳转到个人中心
wx.reLaunch({ // perosnal获取本地数据放在了onload里,用reLauch方法保证onload回调会重新执行,当然也可以不把读取用户数据的方法放在onLoad里,放在onShow里面(性能不太好,每次页面显示都会执行)
url: '/pages/personal/personal'
})
break

在request.js中设置发送请求的请求头,获取cookie时使用同步获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
wx.request({
url: config.host + url,
data,
header: {
cookie: wx.getStorageSync('cookie') // 同步获取cookie
},
success: (res) => {
console.log('请求成功', res)
resolve(res.data)
},
fail: (err) => {
console.log('请求失败', err)
reject(err)
}
})

获取标签下的视频,这个接口的offset参数必需带上(文档上说是可选…)

视频列表动态显示

使用到了Promise.all方法,map方法迭代时,需要再一次发送异步方法,相当于map的时候,发送了多个异步请求,用Promise.all包裹一下,不然会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取视频列表数据
async getVideoList(navId) {
if(!navId) return

let videoListData = await request('/video/group', {id: navId, offset: 0}) // 还要传一个offset参数
// console.log(videoListData)
let index = 0
let videoList = await Promise.all(videoListData.datas.map(async item => {
item.id = index++
let urlInfo = await request('/video/url', {id: item.data.vid})
item.dealedUrl = urlInfo.urls[0].url
return item
}))

this.setData({
videoList
})
},

但这种写法,真机调试时,会报videoListData.datasundefined,要把拿出来

实际上发现,其实就是/video/group前面的这个接口,报302没拿到数据!!估计跟NodeJS中间件的做了2分钟缓存有关,重新清除下数据,又可以拿到了

这个接口不知道咋回事,有时候会返回数据,有时候就是空,所以在做map的时候,是undefined.map

还是做下异常处理吧

最后在issue上和人讨论解决了:通过查询视频分类列表获取视频id,用此id作为参数查询视频列表没有数据 · Issue #1640 · Binaryify/NeteaseCloudMusicApi (github.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 获取视频列表数据
async getVideoList(navId) {
if(!navId) return

try {
let videoListData = await request('/video/group', {id: navId, offset: 0}) // 这个接口有时候会报302,然后就导致获取视频url的接口有问题,undefined.map会报错

console.log(videoListData)
let index = 0

let videoList = await Promise.all(videoListData.datas.map(async item => {
item.id = index++
let urlInfo = await request('/video/url', {id: item.data.vid})
item.dealedUrl = urlInfo.urls[0].url
return item
}))

this.setData({
videoList,
})
} catch(err) {
console.log(err)
wx.showToast({
title: '资源转移,请稍后再试',
icon: 'none'
})
}
},

login.js

1
2
3
4
5
6
7
8
9
10
11
12
// 本地存储个人信息
wx.setStorage({
key: 'userinfo',
data: JSON.stringify(res.profile)
})
let cookie = res.cookie.split(';;').find(item => item.indexOf('MUSIC_U') !== -1)
console.log(cookie)
wx.setStorage({
key: 'cookie',
// data: res.cookie,
data: cookie,
})

调整下样式

在点击菜单栏的回调中,调用上面的函数

1
2
3
4
5
6
7
8
9
// 点击切换导航的回调
changeNav(event) {
let navId = Number(event.currentTarget.id)
this.setData({
navId
})
// 动态获取当前导航对应的视频数据
this.getVideoList(navId)
},

加下过渡,使用wx的api

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    wx.showLoading({
title: '加载中',
});

// 动态获取当前导航对应的视频数据
this.getVideoList(navId)



....

let videoList = await Promise.all(videoListData.datas.map(async item => {
item.id = index++
let urlInfo = await request('/video/url', {id: item.data.vid})
item.dealedUrl = urlInfo.urls[0].url
return item
}))
wx.hideLoading() // 获取全部数据后关闭

在点击新的导航时,旧的视频数据不应该让它显示出来的,只要清空下videoList变量里的数据即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 点击切换导航的回调
changeNav(event) {
let navId = Number(event.currentTarget.id)
this.setData({
navId,
videoList: [] // 每次切换时,都重置下视频数据
})
wx.showLoading({
title: '加载中',
});


this.getVideoList(navId)
},

导航过渡效果实现

点击哪个导航,哪个导航移动到第一位

使用scroll-viewscroll-into-view属性,值和被包裹的元素的id保持一致,且只能以字母开头

1
2
3
4
5
6
<!-- 导航区域 -->
<scroll-view scroll-x="true" class="navScroll" enable-flex scroll-into-view="{{'scroll' + navId}}">
<view class="navItem" wx:for="{{videoGroupList}}" wx:key="id" id="{{'scroll'+item.id}}">
<view class="navContent {{navId === item.id ? 'active' : ''}}" bindtap="changeNav" id="{{item.id}}">{{item.name}}</view>
</view>
</scroll-view>

但现在过渡很生硬,在scroll-view上加上过渡scroll-with-animation

1
2
<!-- 导航区域 -->
<scroll-view scroll-x="true" class="navScroll" enable-flex scroll-into-view="{{'scroll' + navId}}" scroll-with-animation>

解决多个视频同时播放问题

video | 微信开放文档 (qq.com)

VideoContext | 微信开放文档 (qq.com)

同时只能有一个视频播放,

当播放新的视频时,关闭播放的视频

video组件的bindplay事件

1
2
<video src="{{item.dealedUrl}}" bindplay="handlePlay" id="{{item.data.vid}}"></video>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 播放视频的回调
handlePlay(event) {
console.log('play')
/**
* 1.播放的回调中,需要找到上一个播放的视频
* 2.在播放新视频之前,关闭上一个播放的视频
* 关键:
* 1.如何找到上一个视频的实例对象:将属性添加到this身上
* 2.如何确定点击播放的视频和正在播放的视频,不是同一个视频:每次都保存在vid并比较
*
* 通过给this身上只绑定一个属性,并且控制代码顺序,可以实现上面的效果
* 这就是单例模式:
* 1.需要创建多个对象的场景下,通过一个变量接收,始终保持只有一个对象
* 2.节省内存空间
*/
let vid = event.currentTarget.id
// 关闭上一个播放的视频
this.vid !== vid && this.videoContext && this.videoContext.stop()
// this.vid // 在挂载之前,拿到的id,就是上一次id
this.vid = vid // 第一次播放时,把vid挂载到this上
// 创建控制video标签的实例对象
this.videoContext = wx.createVideoContext(vid)
},

使用image代替video

同一页面存在多个video时,video无法正常播放一直在加载转圈 | 微信开放社区 (qq.com)

使用poster属性,同时新增image标签

零和控制图片标签和video标签

  • data中定义videoId
  • 图片标签也用视频标签的回调,注意视频标签是bindplay,图片时bindtap
    • 点击图片时,也给该回调传递当前视频的id,保存到videoId中,现在点击哪张图,就存了哪个视频的id,就可以通过此控制开启哪个视频标签
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 播放视频的回调
handlePlay(event) {
// console.log('play')
/**
* 1.播放的回调中,需要找到上一个播放的视频
* 2.在播放新视频之前,关闭上一个播放的视频
* 关键:
* 1.如何找到上一个视频的实例对象:将属性添加到this身上
* 2.如何确定点击播放的视频和正在播放的视频,不是同一个视频:每次都保存在vid并比较
*
* 通过给this身上只绑定一个属性,并且控制代码顺序,可以实现上面的效果
* 这就是单例模式:
* 1.需要创建多个对象的场景下,通过一个变量接收,始终保持只有一个对象
* 2.节省内存空间
*/
let vid = event.currentTarget.id

// 图片标签相关代码1
// 点击图片时,拿到传过来对应的视频id,更新data中的videoId的状态
this.setData({
videoId: vid
})

// 关闭上一个播放的视频
this.vid !== vid && this.videoContext && this.videoContext.stop()
// this.vid // 在挂载之前,拿到的id,就是上一次id
this.vid = vid // 第一次播放时,把vid挂载到this上
// 创建控制video标签的实例对象
this.videoContext = wx.createVideoContext(vid)

// 图片标签相关代码2
// 点击图片后,视频应自动播放
this.videoContext.play()
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<video 
src="{{item.dealedUrl}}"
bindplay="handlePlay"
id="{{item.data.vid}}"
poster="{{item.data.coverUrl}}"
class="common"
wx:if="{{videoId === item.data.vid}}"
></video>
<!-- 性能优化,使用image图片代替video标签 -->
<image
class="common"
src="{{item.data.coverUrl}}"
mode="aspectFit"
bindtap="handlePlay"
id="{{item.data.vid}}"
wx:else
/>
1
2
3
4
5
6
7
8
9
.videoItem .common {
width: 100%;
height: 360rpx;
border-radius: 10rpx;
}
.videoItem image.common {
background-color: #000000;

}

注意:由于我们用image替代了video,上一小节的优化代码就可以注释掉了,因为不存在多个视频同时存在的情况了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 播放视频的回调
handlePlay(event) {
// console.log('play')
/**
* 1.播放的回调中,需要找到上一个播放的视频
* 2.在播放新视频之前,关闭上一个播放的视频
* 关键:
* 1.如何找到上一个视频的实例对象:将属性添加到this身上
* 2.如何确定点击播放的视频和正在播放的视频,不是同一个视频:每次都保存在vid并比较
*
* 通过给this身上只绑定一个属性,并且控制代码顺序,可以实现上面的效果
* 这就是单例模式:
* 1.需要创建多个对象的场景下,通过一个变量接收,始终保持只有一个对象
* 2.节省内存空间
*/
let vid = event.currentTarget.id

// 图片标签相关代码1
// 点击图片时,拿到传过来对应的视频id,更新data中的videoId的状态
this.setData({
videoId: vid
})

// 关闭上一个播放的视频
// this.vid !== vid && this.videoContext && this.videoContext.stop()
// this.vid // 在挂载之前,拿到的id,就是上一次id
// this.vid = vid // 第一次播放时,把vid挂载到this上
// 创建控制video标签的实例对象
this.videoContext = wx.createVideoContext(vid)

// 图片标签相关代码2
// 点击图片后,视频应自动播放
this.videoContext.play()
},

那么此时里面的逻辑就比较清晰了,相当于每次就只针对一个视频在写逻辑了,因为页面同时只会出现一个视频

  • 拿到视频id
  • 更新id
  • 创建视频实例
  • 播放

模拟器上有一个小问题:点击第一个播放,点击第二个播放,点击第三个播放,再回到第一个点击,没有自动播放

视频里说,这个是模拟器的问题,真机调试的时候是没有的

经过测试,真机上的问题比这个还严重,每一个点击都没有自动播放(等了一会,应该是视频资源加载的问题,但有时候又不行,等死了都没自动播放)

这一块应该可以优化,后面优化篇再说,先把整体流程过一遍

解决视频大小和图片大小不一致的问题

教程里是调整video标签的object-fit属性,但我们上一小节自己调整了图片的mode属性,

视频还是保持原宽高比最好,调整图片吧

视频列表滑动功能

1
2
3
4
5
6
7
/* 视频列表 */
.videoScroll {
margin-top: 20rpx; /* 移除掉导航的margin-bottom样式*/
/* scroll-view需要指定height才会生效,动态计算:视口高度 - 头部 - 导航 - 边距 ,计算完还要在外面滑动一下,外面不能有滚动条*/
/* height: calc(100vh - 60rpx - 60rpx - 40rpx); */
height: calc(100vh - 160rpx);
}

数字左右需要加空格,计算才会生效

实现再次播放跳转到指定位置

问题描述:点击第二个视频,再点回第一个视频,视频又重新播放了

产生原因:我们用image标签做了性能优化,每次播放的时候,相当于重新加载了

解决问题:

  • wx提供了跳转到视频指定位置的api:VideoContext.seek(number position) | 微信开放文档 (qq.com)

  • 需要知道视频播放的事件,以及视频id,所以数据结构设计成数组包对象的格式

    • data中定义videoUpdateTime: []
    • 一个视频对象,只需要push一次
  • 如何知道视频播放的时间: bindtimeupdate属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 监听视频播放进度的回调
    handleTimeUpdate(event) {
    console.log(event)
    let videoTimeObj = {
    vid: event.currentTarget.id,
    currentTime: event.detail.currentTime
    }

    let {videoUpdateTime} = this.data
    // 判断videoUpdateTime中,是否有当前视频的播放记录
    let videoItem = videoUpdateTime.find(item => item.vid === videoTimeObj.vid)
    if(videoItem) {
    videoItem.currentTime = videoTimeObj.currentTime
    } else {
    videoUpdateTime.push(videoTimeObj)
    }
    // 更新播放状态
    this.setData({
    videoUpdateTime
    })
    },
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <video 
    src="{{item.dealedUrl}}"
    bindplay="handlePlay"
    id="{{item.data.vid}}"
    poster="{{item.data.coverUrl}}"
    class="common"
    wx:if="{{videoId === item.data.vid}}"
    bindtimeupdate="handleTimeUpdate"
    ></video>

    image-20221105084451343

  • 在播放的回调中,正式播放时,再加一个判断,判读是否有播放记录,如果有,跳转至指定位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 判断是否有播放记录,如果有,跳转至指定位置
    let {videoUpdateTime} = this.data
    let videoItem = videoUpdateTime.find(item => item.vid === vid)
    if(videoItem) {
    this.videoContext.seek(videoItem.currentTime)
    }
    // 图片标签相关代码2
    // 点击图片后,视频应自动播放
    this.videoContext.play()
  • 视频播放结束时,清空播放记录,使用bindended事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 监听视频播放结束
    handleEnded(event) {
    console.log('播放结束')
    // 移出当前播放视频的对象
    let {videoUpdateTime} = this.data
    let index = videoUpdateTime.findIndex(item => item.vid === event.currentTarget.id)
    videoUpdateTime.splice(index, 1)
    this.setData({
    videoUpdateTime
    })
    },
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <video 
    src="{{item.dealedUrl}}"
    bindplay="handlePlay"
    id="{{item.data.vid}}"
    poster="{{item.data.coverUrl}}"
    class="common"
    wx:if="{{videoId === item.data.vid}}"
    bindtimeupdate="handleTimeUpdate"
    bindended="handleEnded"
    ></video>

scroll-view下拉刷新,上拉加载

下拉刷新

使用bindrefresherrefresh,自定义下拉被触发这个属性,需要使用refresher-enabled开启

刷新时调用接口获取视频数据

添加refresher-triggered属性,用一个变量标识下拉刷新是否被触发,默认给false,在获取视频数据接口中,添加控制,设置为true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 监听下拉刷新事件
handleRefresher() {
// console.log('下拉刷新')
// 再次发请求,获取视频列表的数据
this.getVideoList(this.data.navId)
},


// 获取视频列表数据
async getVideoList(navId) {
// ...
// 关闭消息提示框
wx.hideLoading()
this.setData({
videoList,
isTrigger: false, // 关闭下拉刷新
})
// ...
},

1
2
3
4
5
6
7
8
<!-- 视频列表区域 -->
<scroll-view
class="videoScroll"
scroll-y="true"
refresher-enabled
bindrefresherrefresh="handleRefresher"
refresher-triggered="{{isTrigger}}"
>

上拉加载

使用bindscrolltolowder属性

现在接口提供了offset,支持分页了,自己实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 监听下拉触底事件
async handleToLower() {
console.log('下拉')
/**
* 数据分页
* 1.前端分页
* 2.后端分页
* 备注:网易云音乐没有开放的分页的接口
*/
let newVideoList = await this.getVideoList(this.data.navId, this.data.offset+=8)
let videoList = this.data.videoList
console.log(videoList)
// 将新数据更新到原有视频列表中
videoList.push(...newVideoList)
console.log(videoList)
this.setData({
videoList
})
},

获取视频数据的接口要修正一下,

data中初始化offset为0,上拉刷新时,offset每次加8

需要对setData的逻辑做下判断处理

同时需要对id的生成规则重新做下处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 获取视频列表数据
async getVideoList(navId, offset = this.data.offset) {
if(!navId) return

try {
let videoListData = await request('/video/group', {id: navId, offset}) // 这个接口有时候会报302,然后就导致获取视频url的接口有问题,undefined.map会报错

// console.log(videoListData)
let index = 0
let videoList = await Promise.all(videoListData.datas.map(async item => {
// index++
// let temp = offset
// let distance = temp * 8 - 1

let temp = offset - 1
index++
item.id = temp += index
let urlInfo = await request('/video/url', {id: item.data.vid})
item.dealedUrl = urlInfo.urls[0].url
return item
}))
if(offset === 0) {
this.setData({
videoList,
isTrigger: false, // 关闭下拉刷新
})
} else {
this.setData({
isTrigger: false, // 关闭下拉刷新
})
return videoList // 把视频数据交出去,实际上更新数据的逻辑,放在外面会好一点
}
// 关闭消息提示框
wx.hideLoading()
} catch(err) {
console.log(err)
wx.showToast({
title: '资源转移,请稍后再试',
icon: 'none'
})
}

scrol-view的上拉触底可能会触发多次,有时候到了底部,往上滑离开底部边界时,也会触发

这部分待优化

页面下拉刷新、上拉触底事件说明

上面我们处理的是scroll-view的刷新事件

作为页面,也是有对应的事件的

在页面的生命周期函数里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {

},

/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {

},

/**
* 用户点击右上角分享
*/
onShareAppMessage() {

}

页面要有滚动条,才会触发

1
2
3
4
5
6
7
8
/* 视频列表 */
.videoScroll {
margin-top: 20rpx; /* 移除掉导航的margin-bottom样式*/
/* scroll-view需要指定height才会生效,动态计算:视口高度 - 头部 - 导航 - 边距 ,计算完还要在外面滑动一下,外面不能有滚动条*/
/* height: calc(100vh - 60rpx - 60rpx - 40rpx); */
/* height: calc(100vh - 160rpx); */
height: calc(100vh - 100rpx); /*测试页面的上拉触底*/
}

高度给高点,我们不需要做其他事情,上拉触底直接会在回调中被调用

而下拉刷新,需要设置一下Page(Object object) | 微信开放文档 (qq.com)onPullDownRefresh的属性

在video.json中配置,Page(Object object) | 微信开放文档 (qq.com)

1
2
3
4
5
{
"usingComponents": {},
"navigationBarTitleText": "视频页",
"enablePullDownRefresh": true
}

页面的下拉刷新,会自动回收的,但没有动画

实际开发时,到底用页面的事件,还是用scroll-veiw的事件,看实际需求

转发分享功能实现

视频的button转发,设置open-type="share"

页面转发,在对应js最外层的回调中,Page(Object object) | 微信开放文档 (qq.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 用户点击右上角分享
*/
onShareAppMessage({from}) {
if(from === 'button') {
return {
title: '来自button的转发',
page: '/pages/video/video',
imageUrl: '/static/images/nvsheng.jpg'
}
} else {
return {
title: '来自menu的转发',
page: '/pages/video/video',
imageUrl: '/static/images/nvsheng.jpg'
}
}
}

小程序后台,给好友添加体验权限

image-20221105114532987

每日推荐静态页面搭建

1
2
3
4
5
6
7
8
9
10
11
12
<!--pages/recommendSong.wxml-->
<view class="recommendSongContainer">
<!-- 头部 -->
<view class="header">
<image class="image" src="/static/images/recommendSong/recommendSong.jpg" />
<view class="date">
<text class="day">17 /</text>
<text class="month">10</text>
</view>
</view>
</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* pages/recommendSong.wxss */
.recommendSongContainer .header {
position: relative;
width: 100%;
height: 300rpx;
/* margin: 0; */
}

.recommendSongContainer .header .image {
width: 100%;
height: 100%;
}

.recommendSongContainer .header .date {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 300rpx;
height: 100rpx;
/* border: 1rpx solid red; */
text-align: center;
line-height: 100rpx;
color: #fff;
}

.header .date .day {
font-size: 38rpx;
}

image-20221105144928984

日期动态显示

1
2
3
4
5
6
7
8
9
10
11
12
<!--pages/recommendSong.wxml-->
<view class="recommendSongContainer">
<!-- 头部 -->
<view class="header">
<image class="image" src="/static/images/recommendSong/recommendSong.jpg" />
<view class="date">
<text class="day">{{day}} / </text>
<text class="month">{{month}}</text>
</view>
</view>
</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 页面的初始数据
*/
data: {
day: '', // 天
month: '', //月
},

/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
// 更新时间
this.setData({
day: new Date().getDate(),
month: new Date().getMonth() + 1
})
},

内容区静态页面搭建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<!-- 内容区 -->
<scroll-view class="listScroll" scroll-y>
<view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view>


<view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view><view class="scrollItem">
<image src="/static/images/nvsheng.jpg" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
<text class="author text">aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view>
</scroll-view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
.recommendSongContainer .header {
position: relative;
width: 100%;
height: 300rpx;
}

.recommendSongContainer .header .image {
width: 100%;
height: 100%;
}

.recommendSongContainer .header .date {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 300rpx;
height: 100rpx;
/* border: 1rpx solid red; */
text-align: center;
line-height: 100rpx;
color: #fff;
}

.header .date .day {
font-size: 38rpx;
}

/* 列表区域 */
.listContainer {
position: relative;
top: -20rpx;
padding: 0 20rpx;
border-radius: 30rpx;
background: #fff;
}

.listHeader {
height: 80rpx;
line-height: 80rpx;
}

.listHeader .changeMore {
float: right
}

/* 内容区 */
.listScroll {
height: calc(100vh - 300rpx - 80rpx );
}
.scrollItem {
display: flex;
padding: 0 20rpx;
margin-bottom: 10rpx;
}
.scrollItem .image {
width: 80rpx;
height: 80rpx;
border-radius: 8rpx;
}

.musicInfo {
display: flex;
flex-direction: column;
margin-left: 20rpx;

}
.musicInfo .text{
height: 40rpx; /*直接添加高度不生效,因为text标签是行内元素,给父元素添加flex,其变为了块元素*/
line-height: 40rpx;
font-size: 24rpx;

max-width: 500rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scrollItem .iconfont {
position: absolute;
right: 20rpx;

width: 80rpx;
height: 80rpx;
line-height: 80rpx;
/* background: red; */
text-align: right;
}

内容区动态显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// pages/recommendSong.js
import request from '../../utils/request.js'
Page({

/**
* 页面的初始数据
*/
data: {
day: '', // 天
month: '', //月,
recommendList: [], // 推荐列表数据
},

/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {

// 判断用户是否登录
let userInfo = wx.getStorageSync('userinfo')
if(!userInfo) {
wx.showToast({
title: '请先登录',
icon: 'none',
success: function() {
// 成功的回调中,跳转至登录界面
wx.reLaunch({
url: '/pages/login/login'
})
}
})
}


// 更新时间
this.setData({
day: new Date().getDate(),
month: new Date().getMonth() + 1
})

// 获取每日推荐歌曲数据
this.getRecommendList()

},
// 获取用户每日推荐数据
async getRecommendList() {
let recommendListData = await request('/recommend/songs')
// console.log(recommendListData)
this.setData({
recommendList: recommendListData.data.dailySongs
})
},

})
1
2
3
4
5
6
7
8
9
10
11
12
<!-- 内容区 -->
<scroll-view class="listScroll" scroll-y>
<view class="scrollItem" wx:for="{{recommendList}}" wx:key="id">
<image src="{{item.al.picUrl}}" class="image" mode="aspectFill"/>
<view class="musicInfo">
<text class="musicName text">{{item.name}}</text>
<text class="author text">{{item.ar[0].name}}</text>
</view>
<text class="iconfont icon-gengduo-shuxiang"></text>
</view>

</scroll-view>

image-20221106161343901

歌曲详情页静态页面搭建

1
2
3
4
5
6
7
8
9
10
11
12
<!--pages/songDetail/songDetail.wxml-->
<view class="songDetailContainer">
<view class="author">sai</view>
<view class="circle"></view>
<image class="needle" src="/static/images/song/needle.png" />

<view class="discContainer">
<image class="disc" src="/static/images/song/disc.png" />
<image class="musicImg" src="/static/images/nvsheng.jpg" mode="aspectFill" />
</view>
</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/* pages/songDetail/songDetail.wxss */
.songDetailContainer {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
background-color: rgba(0,0,0,.5);

}
/* 底座 */
.circle {
position: relative;
z-index: 100;
width: 60rpx;
height: 60rpx;
background-color: #fff;
border-radius: 50%;
margin: 10rpx 0;
}
/* 摇杆 */
.needle {
position: relative;
z-index: 99;
top: -40rpx;
left: 60rpx;
width: 192rpx;
height: 274rpx;
}

.discContainer {
position: relative;
top: -170rpx;
width: 598rpx;
height: 598rpx;
border: 1px solid red;

}

.disc {
width: 100%;
height: 100%;
}

.musicImg {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: 370rpx;
height: 370rpx;
border-radius: 50%;

}

image-20221106163915022

摇杆动画实现

1
2
3
4
5
6
7
8
9
10
11
12
<!--pages/songDetail/songDetail.wxml-->
<view class="songDetailContainer">
<view class="author">sai</view>
<view class="circle"></view>
<image class="needle {{isPlay ? 'needleRotate': ''}}" src="/static/images/song/needle.png" />

<view class="discContainer">
<image class="disc" src="/static/images/song/disc.png" />
<image class="musicImg" src="/static/images/nvsheng.jpg" mode="aspectFill" />
</view>
</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/* pages/songDetail/songDetail.wxss */
.songDetailContainer {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
background-color: rgba(0,0,0,.5);

}
/* 底座 */
.circle {
position: relative;
z-index: 100;
width: 60rpx;
height: 60rpx;
background-color: #fff;
border-radius: 50%;
margin: 10rpx 0;
}
/* 摇杆 */
.needle {
position: relative;
z-index: 99;
top: -40rpx;
left: 60rpx;
width: 192rpx;
height: 274rpx;
border: 1px solid red;

transform-origin: 40rpx 0;
transform: rotate(-20deg);
transition: transform 1s;
}

.needleRotate {
transform: rotate(0);
}

.discContainer {
position: relative;
top: -170rpx;
width: 598rpx;
height: 598rpx;
border: 1px solid red;

}

.disc {
width: 100%;
height: 100%;
}

.musicImg {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: 370rpx;
height: 370rpx;
border-radius: 50%;

}

磁盘动画实现

1
2
3
4
<view class="discContainer {{isPlay ? 'discAnimation': ''}}">
<image class="disc" src="/static/images/song/disc.png" />
<image class="musicImg" src="/static/images/nvsheng.jpg" mode="aspectFill" />
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 磁盘动画 */
.discAnimation {
animation: disc 4s linear infinite;
animation-delay: 0.8s;
}
@keyframes disc {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}

底部控制区域搭建

1
2
3
4
5
handleMusicPlay() {
this.setData({
isPlay: !this.data.isPlay
})
},
1
2
3
4
5
6
7
8
<!-- 底部控制区域 -->
<view class="musicControl">
<text class="text iconfont icon-24gl-shuffle"></text>
<text class="text iconfont icon-24gl-previousFrame"></text>
<text class="text iconfont {{isPlay ? 'icon-bofang' : 'icon-bofang1'}}} big" bindtap="handleMusicPlay"></text>
<text class="text iconfont icon-24gl-nextFrame"></text>
<text class="text iconfont icon-24gl-playlistMusic3"></text>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 底部控制区域 */
.musicControl {
position: absolute;
bottom: 40rpx;
left: 0;
display: flex;

width: 100%;
border-top: 1px solid #fff;
}

.musicControl .text {
width: 20%;
height: 120rpx;
line-height: 120rpx;
text-align: center;
color: #fff;
font-size: 40rpx;
}
.musicControl .text.big {
font-size: 70rpx;
}

路由跳转传参

onLoad函数中的options参数,专门用来接收路由跳转的query参数,值会转为字符串

原生小程序中,路由传参对参数的长度是有限制的,它会截取

所以我们不传整个歌曲的item对象了,告诉详情页歌曲id,让它自己重新发请求

recommendSong.js

1
2
3
4
5
6
7
8
// 跳转至歌曲详细信息页面
toSongDetail(event) {
let song = event.currentTarget.dataset.song
// 路由跳转传参,支持query参数
wx.navigateTo({
url: `/pages/songDetail/songDetail?musicId=${song.id}`
})
},

songDetail.js

1
2
3
4
onLoad(options) {
let musicId = options.musicId
console.log(options.musicId)
},

动态显示歌曲详情

1
2
3
4
5
6
7
8
<view class="author">{{song.ar[0].name}}</view>
<view class="circle"></view>
<image class="needle {{isPlay ? 'needleRotate': ''}}" src="/static/images/song/needle.png" />

<view class="discContainer {{isPlay ? 'discAnimation': ''}}">
<image class="disc" src="/static/images/song/disc.png" />
<image class="musicImg" src="{{song.al.picUrl}}" mode="aspectFill" />
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
onLoad(options) {
let musicId = options.musicId
this.getMusicInfo(musicId)
},
// 获取音乐详情的回调函数
async getMusicInfo(musicId) {
let song = await request('/song/detail', {ids: musicId})
this.setData({
song: song.songs[0]
})
// 动态修改窗口标题
wx.setNavigationBarTitle({
title: this.data.song.name,
});

},

音乐播放暂停的功能函数

BackgroundAudioManager | 微信开放文档 (qq.com)

如果不设置标题,音乐实际不会给你播放的

如果要实现后台播放,全局配置 | 微信开放文档 (qq.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
handleMusicPlay() {
this.setData({
isPlay: !this.data.isPlay
})
let {isPlay, musicId} = this.data
this.musicControl(isPlay, musicId)
},
// 控制音乐播放/暂停的功能函数
async musicControl(isPlay, musicId) {
// 创建控制音乐播放的实例
let backgroundAudioManager = wx.getBackgroundAudioManager()
if(isPlay) { // 音乐播放
let musicLinkData = await request('/song/url', {id: musicId})
let musicLink = musicLinkData.data[0].url

backgroundAudioManager.src = musicLink
backgroundAudioManager.title = this.data.song.name
} else { // 暂停音乐
backgroundAudioManager.pause()
}
},

解决系统任务栏控制音乐播放状态不一致的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
onLoad(options) {
let musicId = options.musicId
this.getMusicInfo(musicId)
this.setData({
musicId
})
// 创建控制音乐播放的实例
this.backgroundAudioManager = wx.getBackgroundAudioManager()
// 监视音乐播放/暂停
this.backgroundAudioManager.onPlay(() => {
this.changePlayState(true)

})
this.backgroundAudioManager.onPause(() => {
this.changePlayState(false)

})
this.backgroundAudioManager.onStop(() => {
this.changePlayState(false)
})
},
// 修改播放状态
changePlayState(isPlay) {
this.setData({
isPlay
})
},



// ...
handleMusicPlay() {
// this.setData({
// isPlay: !this.data.isPlay
// })
let isPlay = !this.data.isPlay
let { musicId} = this.data
this.musicControl(isPlay, musicId)
},

解决页面回退,歌曲详情页面销毁问题

如果某个音乐在播放,应该把这个状态存起来(不受页面销毁的影响:1.存本地;2.AppObject getApp(Object object) | 微信开放文档 (qq.com)

下次回到这个页面时,拿出这个状态

App.js

1
2
3
4
5
6
7
8
// app.js
App({
globalData: {
isMusicPlay: false, // 是否有音乐播放
musicId: '', // 音乐id
},
})

songDetail.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// pages/songDetail/songDetail.js
import request from '../../utils/request.js'
// 获取全局实例
const appInstance = getApp()


// ...
onLoad(options) {
let musicId = options.musicId
this.getMusicInfo(musicId)
this.setData({
musicId
})
console.log(appInstance)
// 判断当前页面音乐是否在播放
if(appInstance.globalData.isMusicPlay && appInstance.globalData.musicId == musicId) {
// 修改当前音乐播放状态为true
this.setData({
isPlay: true
})
}

this.backgroundAudioManager = wx.getBackgroundAudioManager()
this.backgroundAudioManager.onPlay(() => {
this.changePlayState(true)
// 修改全局音乐播放的状态
appInstance.globalData.musicId = musicId

})
this.backgroundAudioManager.onPause(() => {
this.changePlayState(false)

})
this.backgroundAudioManager.onStop(() => {
this.changePlayState(false)
})
},
// 修改播放状态
changePlayState(isPlay) {
this.setData({
isPlay
})
appInstance.globalData.isMusicPlay = isPlay
},

页面通信需求分析,npm包使用准备

1
2
3
<text class="text iconfont icon-24gl-previousFrame" id="prev" bindtap="handleSwitch"></text>
<text class="text iconfont {{isPlay ? 'icon-bofang' : 'icon-bofang1'}}} big" bindtap="handleMusicPlay"></text>
<text class="text iconfont icon-24gl-nextFrame" id="next" bindtap="handleSwitch"></text>
1
2
3
4
5
6
// 点击切歌的回调
handleSwitch(event) {
// 获取切歌的类型
let type = event.currentTarget.id

},

项目根目录执行npm init -y

小程序开发者工具,设置允许使用npm,2.27.0版本的基础库没有这个设置了,猜测应该默认支持了

下载pubsub-js包:npm install pubsub-js

工具-构建npm,小程序会默认到构建后的miniprogram_npm目录下找第三方的包

页面通信完整实现

songDetail.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// 点击切歌的回调
handleSwitch(event) {
// 获取切歌的类型
let type = event.currentTarget.id
// 订阅来自recommendSong页面发布的musicId的消息
PubSub.subscribe('musicId', (msg, musicId) => { // 不能放在切歌的回调中,每点击一次就会订阅一次(PubSub底层用数组存储回调的),订阅完需要取消订阅
console.log(msg, musicId)
// 每订阅完一次,取消订阅
PubSub.unsubscribe()
})
// 发布消息给recommendSong页面
PubSub.publish('switchType', type)
},

recommendSong.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// onLoad
// 订阅消息
PubSub.subscribe('switchType', (msg, type) => {
let {recommendList, index} = this.data
console.log(msg, type)
if(type === 'prev') { // 上一首
index -= 1
} else { // 下一首
index += 1
}
let musicId = recommendList[index].id
// 将id回传给detail页面
PubSub.publish('musicId', musicId)
})

recommendSong.wxml

1
2
<view class="scrollItem" wx:for="{{recommendList}}" wx:key="id" bindtap="toSongDetail" data-song="{{item}}" data-index="{{index}}">

切换歌曲功能实现

songDetail.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 点击切歌的回调
handleSwitch(event) {
// 获取切歌的类型
let type = event.currentTarget.id
// 关闭当前播放的音乐
this.backgroundAudioManager.stop()

// 订阅来自recommendSong页面发布的musicId的消息
PubSub.subscribe('musicId', (msg, musicId) => { // 不能放在切歌的回调中,每点击一次就会订阅一次(PubSub底层用数组存储回调的),订阅完需要取消订阅
console.log(musicId)

// 更新musicId(视频里漏掉了)
this.setData({
musicId
})
appInstance.globalData.musicId = musicId

// 获取音乐详情信息
this.getMusicInfo(musicId)
// 自动播放当前音乐
this.musicControl(true, musicId)
// 每订阅完一次,取消订阅
PubSub.unsubscribe('musicId')

})
// 发布消息给recommendSong页面
PubSub.publish('switchType', type)
},

recommendSong.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// onLoad

// 订阅消息
PubSub.subscribe('switchType', (msg, type) => {
let {recommendList, index} = this.data
// console.log(msg, type)
if(type === 'prev') { // 上一首
(index === 0) && (index = recommendList.length)
index -= 1
} else { // 下一首
(index === recommendList.length - 1) && (index = -1)
index += 1
}
this.setData({
index
})
let musicId = recommendList[index].id
// 将id回传给detail页面
PubSub.publish('musicId', musicId)
})

播放歌曲性能优化

暂停歌曲后,每次点击播放,都会发送请求,没必要

利用形参来控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 控制音乐播放/暂停的逻辑函数
handleMusicPlay() {
// this.setData({
// isPlay: !this.data.isPlay
// })
let isPlay = !this.data.isPlay
let { musicId, musicLink} = this.data
this.musicControl(isPlay, musicId, musicLink)
},
// 控制音乐播放/暂停的功能函数
async musicControl(isPlay, musicId, musicLink) {
if(isPlay) { // 音乐播放
if(!musicLink) {
let musicLinkData = await request('/song/url', {id: musicId})
musicLink = musicLinkData.data[0].url
this.setData({
musicLink
})
}


this.backgroundAudioManager.src = musicLink
this.backgroundAudioManager.title = this.data.song.name
} else { // 暂停音乐
this.backgroundAudioManager.pause()
}
},

进度条区域静态页面搭建

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 进度条区域 -->
<view class="progressControl">
<text>00:00</text>
<!-- 总进度条 -->
<view class="progressControl__barControl">
<!-- 实时进度条 -->
<view class="progressControl__barControl__currtentTimeBar">
<!-- 小圆球 -->
<view class="progressControl__barControl__currtentTimeBar__circle"></view>
</view>
</view>
<text>03:10</text>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* 进度条控制区域 */
.progressControl {
display: flex;
position: absolute;
bottom: 200rpx;
width: 640rpx;
height: 80rpx;
line-height: 80rpx;

}

.progressControl__barControl {
position: relative;
width: 450rpx;
height: 4rpx;
margin: auto;
background-color: rgba(0,0,0,.4);
}
.progressControl__barControl__currtentTimeBar {
position: absolute;
top: 0;
left: 0;
width: 100rpx;
height: 4rpx;
background-color: red;
z-index: 1;
}
.progressControl__barControl__currtentTimeBar__circle {
position: absolute;
top: -4rpx;
right: -12rpx;
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #fff;
}

音乐总时长格式化显示

npm i moment

构建时的提示,不影响使用

image-20221110203633034

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取音乐详情的回调函数
async getMusicInfo(musicId) {
let song = await request('/song/detail', {ids: musicId})

// song.songs[0].dt 单位是ms
let durationTime = moment(song.songs[0].dt).format('mm:ss')
this.setData({
song: song.songs[0],
durationTime
})
wx.setNavigationBarTitle({
title: this.data.song.name,
});
},
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 进度条区域 -->
<view class="progressControl">
<text>{{currentTime}}</text>
<!-- 总进度条 -->
<view class="progressControl__barControl">
<!-- 实时进度条 -->
<view class="progressControl__barControl__currtentTimeBar">
<!-- 小圆球 -->
<view class="progressControl__barControl__currtentTimeBar__circle"></view>
</view>
</view>
<text>{{durationTime}}</text>
</view>

image-20221110204052490

音乐实时播放时间格式化显示

BackgroundAudioManager.onTimeUpdate(function listener) | 微信开放文档 (qq.com)

监听背景音频播放进度更新事件,只有小程序在前台时会回调

1
2
3
4
5
6
7
8
9
// onload
// 监听音乐实时播放的进度
this.backgroundAudioManager.onTimeUpdate(() => {
// console.log('总时长:', this.backgroundAudioManager.duration)
// console.log('实时时长', this.backgroundAudioManager.currentTime)
let currentTime = moment(this.backgroundAudioManager.currentTime * 1000).format('mm:ss') // 单位是s,要转换成ms
this.setData({
currentTime
})

进度条动态展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 监听音乐实时播放的进度
this.backgroundAudioManager.onTimeUpdate(() => {
// console.log('总时长:', this.backgroundAudioManager.duration)
// console.log('实时时长', this.backgroundAudioManager.currentTime)
let currentTime = moment(this.backgroundAudioManager.currentTime * 1000).format('mm:ss') // 单位是s,要转换成ms
let currentWidth = this.backgroundAudioManager.currentTime/this.backgroundAudioManager.duration * 450 // 比例计算,450是总长度

this.setData({
currentTime,
currentWidth
})


})
1
2
<!-- 实时进度条 -->
<view class="progressControl__barControl__currtentTimeBar" style="width: {{currentWidth + 'rpx'}}">

音乐播放结束自动播放下一首

BackgroundAudioManager.onEnded(function listener) | 微信开放文档 (qq.com)

关联页面

关联主页和歌曲详情页

1
2
3
4
5
6
// 跳转至recommendSong页面的回调
toRecommendSong() {
wx.navigateTo({
url: '/pages/recommendSong/recommendSong'
})
}

index.wxml

1
2
<view class="navItem" bindtap="toRecommendSong">

作业

单曲循环,随机播放

  • 随机播放
    • 要给到随机播放的标识,生成可选值范围内的随机下标,并且不能随机到当前歌曲
  • 单曲循环
    • 播放完后,再重新播放当前音乐

搜索页面

search头部及热搜榜静态页面搭建

新建page,并配置标题

1
2
3
4
{
"usingComponents": {},
"navigationBarTitleText": "发现音乐"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!--pages/search/search.wxml-->
<view class="searchContainer">
<!-- 头部搜索区域 -->
<view class="header">
<view class="header__searchInput">
<text class="iconfont icon-search header__searchInput__icon"></text>
<input type="text" placeholder="搜索歌曲" class="header__searchInput__input" placeholder-class="placeholder" />
</view>
<text class="header__cancel">取消</text>
</view>

<!-- 热搜榜 -->
<view class="hotContainer">
<view class="hotContainer__title">热搜榜</view>
<!-- 热搜列表 -->
<view class="hotContainer__hotList">
<view class="hotContainer__hotList__hotItem">
<text class="hotContainer__hotList__hotItem__order">1</text>
<text class="hotContainer__hotList__hotItem__text">偏爱</text>
</view>

<view class="hotContainer__hotList__hotItem">
<text class="hotContainer__hotList__hotItem__order">1</text>
<text class="hotContainer__hotList__hotItem__text">偏爱</text>
</view> <view class="hotContainer__hotList__hotItem">
<text class="hotContainer__hotList__hotItem__order">1</text>
<text class="hotContainer__hotList__hotItem__text">偏爱</text>
</view> <view class="hotContainer__hotList__hotItem">
<text class="hotContainer__hotList__hotItem__order">1</text>
<text class="hotContainer__hotList__hotItem__text">偏爱</text>
</view> <view class="hotContainer__hotList__hotItem">
<text class="hotContainer__hotList__hotItem__order">1</text>
<text class="hotContainer__hotList__hotItem__text">偏爱</text>
</view> <view class="hotContainer__hotList__hotItem">
<text class="hotContainer__hotList__hotItem__order">1</text>
<text class="hotContainer__hotList__hotItem__text">偏爱</text>
</view>

</view>
</view>
</view>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/* pages/search/search.wxss */
.searchContainer {
padding: 0 20rpx;
}
.header {
display: flex;
height: 60rpx;
padding: 10rpx 0;
line-height: 60rpx;
}
.header__searchInput {
flex: 1; /*右侧固定长度,左侧自适应*/
background-color: rgba(237,237,237,.3);
border-radius: 30rpx;
}
.header__searchInput__icon {
position: absolute;
left: 15rpx;
}
.header__searchInput__input {
margin-left: 50rpx;
height: 60rpx;
}
.placeholder {
/* color: #d43c33; */
font-size: 28rpx;
}
.header__cancel {
width: 100rpx;
text-align: center;
}

/* 热搜榜 */
.hotContainer__title {
height: 80rpx;
line-height: 80rpx;
border-bottom: 1px solid #eee;
font-size: 28rpx;
}

.hotContainer__hotList {
display: flex;
flex-wrap: wrap;
}
.hotContainer__hotList__hotItem {
width: 50%;
height: 80rpx;
line-height: 80rpx;
font-size: 26rpx;
}
.hotContainer__hotList__hotItem__order {
margin: 0 10rpx;
}

效果:

image-20221110220421626

热搜榜、placeholder数据动态展示

1
2
3
4
5
6
7
8
9
// 获取初始化数据
async getInitData() {
let placeHolderData = await request('/search/default')
let hostListData = await request('/search/hot/detail')
this.setData({
placeholderContent: placeHolderData.data.showKeyword,
hotList: hostListData.data
})
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!--pages/search/search.wxml-->
<view class="searchContainer">
<!-- 头部搜索区域 -->
<view class="header">
<view class="header__searchInput">
<text class="iconfont icon-search header__searchInput__icon"></text>
<input type="text" placeholder="{{placeholderContent}}" class="header__searchInput__input" placeholder-class="placeholder" />
</view>
<text class="header__cancel">取消</text>
</view>

<!-- 热搜榜 -->
<view class="hotContainer">
<view class="hotContainer__title">热搜榜</view>
<!-- 热搜列表 -->
<view class="hotContainer__hotList">
<view class="hotContainer__hotList__hotItem" wx:for="{{hotList}}" wx:key="searchWord">
<text class="hotContainer__hotList__hotItem__order">{{index + 1}}</text>
<text class="hotContainer__hotList__hotItem__text">{{item.searchWord}}</text>
<image class="hotContainer__hotList__hotItem__iconImage" wx:if="{{item.iconUrl}}" src="{{item.iconUrl}}"></image>
</view>
</view>
</view>
</view>

1
2
3
4
5
6
.hotContainer__hotList__hotItem__iconImage {
width: 30rpx;
height: 25rpx;
margin-left: 10rpx;
}

关键词模糊匹配搜索数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// pages/search/search.js
import request from '../../utils/request.js'
let isSend = false


// 表单项内容发生改变的回调
handleInputChange(event) {
console.log(event)
// 更新searchContent的状态
if(event.detail.value == '') return
this.setData({
searchContent: event.detail.value.trim()
})
if(isSend) return
isSend = true
this.getSearchList()
// 函数节流
setTimeout(() => {
isSend = false
}, 3000)

},
async getSearchList() {
// 发请求获取关键字模糊匹配数据
let searchListData = await request('/search', {keywords: this.data.searchContent, limit: 10})
// console.log(searchListData)
this.setData({
searchList: searchListData.result.songs
})
},

待优化:isSend放data配置项里不行,得放全局

删除输入时,最后剩一个空串也会发一次(已优化)

搜索列表动态显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 表单项内容发生改变的回调
handleInputChange(event) {
console.log(event)
// 更新searchContent的状态
if(!event.detail.value) {
this.setData({
searchList: [] // 置空配合wx:if
})
return
}
this.setData({
searchContent: event.detail.value.trim()
})
if(isSend) return
isSend = true
this.getSearchList()
// 函数节流
setTimeout(() => {
isSend = false
}, 3000)

},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<block wx:if='{{searchList.length}}'>
<!-- 搜索内容展示 -->
<view class="showSearchContent">
<view class="showSearchContent__content">搜索内容:{{searchContent}}</view>
<view class="showSearchContent__list">
<view class="showSearchContent__list__item" wx:for="{{searchList}}" wx:key="id">
<text class="iconfont icon-search"></text>
<text class="showSearchContent__list__item__content">{{item.name}}</text>

</view>
</view>
</view>
</block>
<block wx:else>
<!-- 热搜榜 -->
<view class="hotContainer">
<view class="hotContainer__title">热搜榜</view>
<!-- 热搜列表 -->
<view class="hotContainer__hotList">
<view class="hotContainer__hotList__hotItem" wx:for="{{hotList}}" wx:key="searchWord">
<text class="hotContainer__hotList__hotItem__order">{{index + 1}}</text>
<text class="hotContainer__hotList__hotItem__text">{{item.searchWord}}</text>
<image class="hotContainer__hotList__hotItem__iconImage" wx:if="{{item.iconUrl}}" src="{{item.iconUrl}}"></image>
</view>
</view>
</view>
</block>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.showSearchContent__content {
height: 80rpx;
line-height: 80rpx;
font-size: 24rpx;
color: #d43c33;
border-bottom: 1rpx solid #d43c33;
}
.showSearchContent__list__item {
height: 80rpx;
line-height: 80rpx;
display: flex;
}
.showSearchContent__list__item__content {
flex: 1;
margin-left: 20rpx;
border-bottom: 1px solid #eee;
font-size: 26rpx;
}

历史记录静态页面

  • 静态页面
    • 和热搜榜是同时显示的
  • 点击可以清空历史记录
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 历史记录 -->
<view class="history">
<view class="history__title">历史记录</view>
<view class="history__item">你好</view>
<view class="history__item">你好</view>
<view class="history__item">你好</view>
<view class="history__item">你好</view>
<view class="history__item">你好</view>
<view class="history__item">你好</view>
<view class="history__item">你好</view>
<!-- 删除 -->
<view class="history__icon iconfont icon-delete_light"></view>
</view>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 搜索历史 */
.history {
position: relative;
display: flex;
flex-wrap: wrap;
line-height: 50rpx;
font-size: 28rpx;
}
.history__title {
height: 50rpx;
}
.history__item {
height: 50rpx;
font-size: 26rpx;
background-color: #ededed;
margin: 0 0 10rpx 20rpx;

padding: 0 30rpx;
border-radius: 20rpx;
}
.history__icon {
position: absolute;
bottom: 10rpx;
right: 15rpx;
font-size: 36rpx;
}

image-20221112073329146

添加搜索历史记录

添加历史记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
  onLoad(options) {
this.getInitData()
this.getSearchHistory()
},



// 获取本地搜索记录
getSearchHistory() {
let historyList = wx.getStorageSync('searchHistory')
if(historyList) {
this.setData({
historyList
})
}
},

async getSearchList() {
let {searchContent, historyList} = this.data
let searchListData = await request('/search', {keywords: searchContent, limit: 10})
// console.log(searchListData)
this.setData({
searchList: searchListData.result.songs
})
// 添加搜索记录
if(historyList.indexOf(searchContent) !== -1) {
historyList.splice(historyList.indexOf(searchContent), 1)
}
historyList.unshift(searchContent)

this.setData({
historyList
})
// 搜索记录需要存到本地
wx.setStorageSync('searchHistory', historyList)
},




1
2
3
<view class="history__title">历史记录</view>
<view class="history__item" wx:for="{{historyList}}" wx:key="item">{{item}}</view>

清除输入

1
2
3
4
5
6
7
// 清空搜索内容
clearSearchContent() {
this.setData({
searchContent: '',
searchList: []
})
},
1
2
input type="text" placeholder="{{placeholderContent}}" class="header__searchInput__input" placeholder-class="placeholder" bindinput="handleInputChange" value="{{searchContent}}" />
<text class="header__searchInput__clear iconfont icon-qingkong" bindtap="clearSearchContent"></text>

删除历史记录

弹出询问框,wx.showModal(Object object) | 微信开放文档 (qq.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 清空历史记录
deleteHistory() {
wx.showModal({
// title: '清空',
content: '确定清空全部历史记录?',
success: (res) => {
if(res.confirm) {
// 清空data中的historyList
this.setData({
historyList: []
})
// 移除本地缓存
wx.removeStorageSync('searchHistory')
}
}
})
},
1
2
<text class="header__searchInput__clear iconfont icon-qingkong" bindtap="clearSearchContent" hidden="{{!searchContent}}"></text>

关联搜索页面和视频页

1
2
3
4
5
toSearch() {
wx.navigateTo({
url: '/pages/search/search'
})
},
1
2
<view class="search" bindtap="toSearch">搜索音乐</view>

小程序语法

数据绑定

WXML | 微信开放文档 (qq.com)

页面.js文件的data配置项中初始化数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// pages/index/index.js
Page({

/**
* 页面的初始数据
*/
data: {
msg: '初始化数据'
},

/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {

},

/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {

},

/**
* 生命周期函数--监听页面显示
*/
onShow() {

},

/**
* 生命周期函数--监听页面隐藏
*/
onHide() {

},

/**
* 生命周期函数--监听页面卸载
*/
onUnload() {

},

/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {

},

/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {

},

/**
* 用户点击右上角分享
*/
onShareAppMessage() {

}
})

控制台中AppData菜单可以看到,小程序页面中用到的数据都来自这里

image-20221015094904328

使用模板语法使用数据

1
<text class="userName">{{msg}}</text>

image-20221015094944879

修改msg状态:this.setData,this代表当前页面的实例对象

1
2
3
4
5
6
onLoad(options) {
console.log(this)
this.setData({
msg: '修改之后的数据'
})
},

在非自身的回调函数中,修改也是同步的

1
2
3
4
5
6
7
8
9
onLoad(options) {
console.log(this.msg)
setTimeout(() => {
this.setData({
msg: '修改之后的数据'
})
},2000)
},

小结:

  • 小程序:

    • data中初始化数据

    • 修改数据:this.setData()

      • 修改数据的行为始终是同步的
    • 数据流:

      • 单向:Model => View
  • Vue:

    • data中初始化数据
    • 修改数据:this.key = value
    • 数据流:
      • Vue是单向数据流:Model => View
      • Vue中实现了双向数据绑定
  • React:

    • state中初始化数据状态
    • 修改数据:
      • 自身钩子函数中(componentDidMount)异步的
      • 非自身的钩子函数中(定时器的回调)同步的
    • 数据流:
      • 单向:Model => View

小程序中的数据代理用的是Object.defineProperty()

事件绑定

事件 | 微信开放文档 (qq.com)

1.bind绑定:事件绑定不会阻止冒泡事件向上冒泡

1
2
3
<view bindtap="handleTap">
<text></text>
</view>

2.catch绑定:事件绑定可以阻止冒泡事件向上冒泡

1
2
3
<view catchtap="handleTap">
<text></text>
</view>

自定义事件写在*.js中,与data评级

1
2
3
handleTap() {
console.log('hello world')
},

路由跳转

wx.navigateTo(Object object) | 微信开放文档 (qq.com)

页面配置

app.json

1
2
3
4
5
6
7
8
9
10
11
{
"pages": [
"pages/index/index",
"pages/logs/index"
],
"window": {
"navigationBarBackgroundColor": "#fff",
"navigationBarTextStyle": "black",
"navigationBarTitleText": "小程序"
}
}

设置跳转

1
2
3
4
5
6
handleTap() {
console.log('hello world')
wx.navigateTo({
url: '/pages/logs/index', // 这里要加根路径/
})
},

image-20221015154910766

wx.redirectTo(Object object)的效果

1
2
3
4
5
6
handleTap() {
console.log('hello world')
wx.redirectTo({
url: '/pages/logs/index',
})
},

image-20221015155028533

左上角不再是返回上一页,而是返回主页

跳转的各个方法的区别,在于关不关闭当前页面,和页面的生命周期有关,后面会讲到

如果需要单独配置某个页面的导航,可以在自己文件下的json文件中设置

pages/logs/index.json

1
2
3
4
{
"usingComponents": {},
"navigationBarTitleText": "日志"
}

局部配置的优先级,高于全局

生命周期

生命周期 | 微信开放文档 (qq.com)

五个生命周期,如图:

  • 第一次onLoad和OnShow时,逻辑层的数据还没来呢
  • onLoad、onReady等执行次数,取决于路由跳转是选择的api
  • 使用wx.redirectTo跳转路由时,会关闭当前页面,执行一次onUnload回调
  • 如果使用的是wx.navigateTo跳转路由,页面不会销毁,切换页面时执行onHide回调

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
console.log(this.msg)
setTimeout(() => {
this.setData({
msg: '修改之后的数据'
})
},2000)
},

/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {

},

/**
* 生命周期函数--监听页面显示
*/
onShow() {

},

/**
* 生命周期函数--监听页面隐藏
*/
onHide() {

},

/**
* 生命周期函数--监听页面卸载
*/
onUnload() {

},

条件渲染

条件渲染 | 微信开放文档 (qq.com)

1
2
3
<view wx:if="{{length > 5}}"> 1 </view>
<view wx:elif="{{length > 2}}"> 2 </view>
<view wx:else> 3 </view>

列表渲染

列表渲染 | 微信开放文档 (qq.com)

wx:for

在组件上使用 wx:for 控制属性绑定一个数组,即可使用数组中各项的数据重复渲染该组件。

默认数组的当前项的下标变量名默认为 index,数组当前项的变量名默认为 item

wx:key

如果列表中项目的位置会动态改变或者有新的项目添加到列表中,并且希望列表中的项目保持自己的特征和状态(如 input 中的输入内容,switch 的选中状态),需要使用 wx:key 来指定列表中项目的唯一的标识符。

wx:key 的值以两种形式提供

  1. 字符串,代表在 for 循环的 array 中 item 的某个 property,该 property 的值需要是列表中唯一的字符串或数字,且不能动态改变。
  2. 保留关键字 *this 代表在 for 循环中的 item 本身,这种表示需要 item 本身是一个唯一的字符串或者数字。

可以自定义index和item变量名

使用 wx:for-item 可以指定数组当前元素的变量名,

使用 wx:for-index 可以指定数组当前下标的变量名;

模板使用

模板 | 微信开放文档 (qq.com)

定义模板

1
2
3
4
5
6
7
<!--template/template01/template01.wxml-->
<!-- 定义模板 -->
<template name="myTmp">
<view>
<view class="title">自定义模板</view>
</view>
</template>

使用模板

1
2
3
4
5
6
7
<!--test/test.wxml-->
<import src="/template/template01/template01.wxml" />
<view class="testContainer">
<view>测试使用模板</view>
<!-- 测试模板-->
<template is="myTmp"></template>
</view>

样式也要在对应的样式文件中引入

定义样式

1
2
3
4
/* template/template01/template01.wxss */
.title {
color: red;
}

使用样式

1
2
3
/* test/test.wxss */
/* 引入模板样式 */
@import '/template/template01/template01.wxss';

可以给模板传递数据

1
2
3
4
5
6
7
8
9
10
11
<!--template/template01/template01.wxml-->
<!-- 定义模板 -->
<template name="myTmp">
<view>
<view class="title">自定义模板</view>
</view>
<view class="userInfo">
<text class="userInfo__name">姓名:{{userName}}</text>
<text class="userInfo__age">年龄:{{useAge}}</text>
</view>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
// test/test.js
Page({

/**
* 页面的初始数据
*/
data: {
userInfo: {
userName: 'sai',
useAge: 18
}
},
})
1
2
3
4
5
6
7
<!--test/test.wxml-->
<import src="/template/template01/template01.wxml" />
<view class="testContainer">
<view>测试使用模板</view>
<!-- 测试模板-->
<template is="myTmp" data="{{...userInfo}}"></template>
</view>

image-20221112210003771

小程序API

小程序重点知识汇总

小程序本地存储

小程序前后端交互

小程序页面通信

小程序自定义组件

基本使用

如何创建自定义组件

(1)在项目根目录中,鼠标右键创建 components 文件夹
(2)右击components文件夹,创建item文件夹
(3)右击item文件夹,点击新建Component,输入item
(3)回车,自动生成四个小程序文件js json wxml wxss

自定义组件的使用(局部引入)

在页面xxx.json UsingComponent中注册,是以键值对的形式,前面的键就是我们创建的组件标签名,后面是url路径

image-20221225093051542

插槽

单个插槽

在小程序中,默认每个自定义组件中只允许使用一个 进行占位,这种个数上的限制叫做单个插槽。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 组件的封装者 -->

<view>

<view>这里是组件的内部结构</view>

<!-- 对于不确定的内容,可以使用<slot> 进行占位,具体的内容由组件的使用者决定-->

<slot></slot>

</view>

1
2
3
4
5
6
7
8

<my-test4>

<!-- 这部分内容将被放置为组件 <slot> 的位置上 -->

<view>这是通过插槽填充的内容</view>

</my-test4>

多个插槽

在小程序的自定义组件中,需要使用多 插槽时,可以在组件的 .js 文件中,通过如下方式进行启用。

1
2
3
4
5
6
7
8
9
Component({

options: {

multipleSlots: true // 在组件定义时的选项中启用多个 slot 支持

}

})

可以在组件的 .wxml 中使用多个 <slot> 标签,以不同的 name 来区分不同的插槽。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<view>

<!-- name 为 before 的第一个 slot 插槽-->

<slot name="before"></slot>

<view>这里是组件的内部结构</view>

<!-- name 为 after 的第二个 slot 插槽-->

<slot name="after"></slot>

</view>

使用

1
2
3
4
5
6
7
8
<my-test4>

<view slot="before">这是插入到组件 slot name="before"中的内容</view>

<view slot="after">这是插入到组件 slot name="after"中的内容</view>

</my-test4>

参数传递

1.在使用组件时传递自定义属性,如下:

1
<PubTitle myTitle="学生作品"></PubTitle>

2.在PubTitle自定义组件的js文件中,接收该自定义属性,如:

1
2
3
4
5
6
7
8
properties: {
//myTitle就是自定义的属性名,需要指定type(String,Number,Array,Objact)
myTitle:{
type:String,
value:""
}
},

3.在PubTitle.wxml中渲染从前端传递来的属性,如:

1
2
3
4
5
<view class="pubTitle">
<view class="txt">{{myTitle}}</view>
<navigator class="more">更多 ></navigator>
</view>

小程序获取用户基本信息

小程序获取用户唯一标识openId

wx.login(Object object) | 微信开放文档 (qq.com)

小程序登录 | 微信开放文档 (qq.com)

img

说明

  1. 调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 获取用户openId的回调
    handleGetOpenId() {
    // 1.获取登录凭证,wx.login()
    wx.login({
    success: (res) => {
    console.log(res)
    let code = res.code
    // 2.将登录凭证发送给服务器

    }
    })
    },

    image-20221112211834182

  2. 调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 、 用户在微信开放平台帐号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台帐号) 和 会话密钥 session_key

    • 后台需要额外提供一个接口,和微信的服务器通信

      server.js

      1
      2
      3
      4
      5
      6
      7
      // 后台接口时4.8.2版本,在server.js文件的182行新增路由
      // 注册获取用户唯一标识的接口
      app.use('/getOpenId', (req, res, next) => {
      let code = req.query.code
      console.log('code', code)
      res.send('测试数据')
      })

      test.js

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      // 获取用户openId的回调
      handleGetOpenId() {
      // 1.获取登录凭证,wx.login()
      wx.login({
      success: async (res) => {
      console.log(res)
      let code = res.code
      // 2.将登录凭证发送给服务器,后台需要额外提供一个接口,和微信的服务器通信
      let result = await request('/getOpenId', {code})
      console.log(result)
      }
      })
      },

      image-20221112214122106

      拿到code后继续完善,小程序后台获取appId和app,发请求给微信的服务器,auth.code2Session | 微信开放文档 (qq.com)

      server.js

      • 安装fly.js,一个多平台的网络请求库,类似axiosnpm i flyio

      • node中引入,并向微信服务器发送请求,拿到包含openid

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        // 注册获取用户唯一标识的接口
        app.use('/getOpenId', async (req, res, next) => {
        let code = req.query.code
        console.log('code', code)
        let appId = 'wxaff67bc5012b8d5b',
        appSecret = '8881533a8cb2de57b9b1f7135769b7b4'; // 小程序开发者设置中获取
        let url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`
        let result = await fly.get(url)
        if(result.data) {
        let openId = JSON.parse(result.data).openid
        console.log(openId)
        }
        res.send('测试数据')
        })

之后开发者服务器可以根据用户标识来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。

拿到用户openId后,一方面要存到数据库,另一方面要返回给客户端(使用jsonwebtoken包,对返回的openId加密)

安装jsonwebtokennpm i jsonwebtoken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// server.js
const jwt = require('jsonwebtoken')


// 注册获取用户唯一标识的接口
app.use('/getOpenId', async (req, res, next) => {
let code = req.query.code
// console.log('code', code)
let appId = 'wxaff67bc5012b8d5b',
appSecret = '8881533a8cb2de57b9b1f7135769b7b4';
let url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${code}&grant_type=authorization_code`
let result = await fly.get(url)

if(result.data) {
let openId = JSON.parse(result.data).openid
console.log(openId)
// 一方面要存到数据库,另一方面要返回给客户端(返回的openId要加密)
let person = {
name: 'sai',
age: 18,
openId
}
// 对用户的数据加密
let token = jwt.sign(person, 'myMiniProgram') // 自定义秘钥
console.log(token)
res.send(token)
}
})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//  client.js - 小程序
// 拿到token后存储本地,发送请求时header携带token
const _token = JSON.stringify(token)
wx.setStorageSync('jwt', _token)

// 用的时候可以再用 JSON.parse 处理一下存储在 Storage 里的 Token 数据。
const _jwt = wx.getStorageSync('jwt')
const jwt = JSON.parse(_jwt)

// 服务端签发过来的 Token ,除了 Token 本身,还有一些用户相关的信息,比如头像,名字,邮件等等。我们可以直接在小程序的页面上利用这些数据。
wx.setJWT(response.data)
wx.switchTab({
url: '/pages/index/index'
})

注意事项

  1. 会话密钥 session_key 是对用户数据进行 加密签名 的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥。(和 openId 一起返回的字段)
  2. 临时登录凭证 code 只能使用一次

小程序分包流程

分包加载 | 微信开放文档 (qq.com)

微信客户端 6.6.0,基础库 1.7.3 及以上版本开始支持。开发者工具请使用 1.01.1712150 及以上版本,可点此下载

某些情况下,开发者需要将小程序划分成不同的子包,在构建时打包成不同的分包,用户在使用时按需进行加载。

在构建小程序分包项目时,构建会输出一个或多个分包。每个使用分包小程序必定含有一个主包。所谓的主包,即放置默认启动页面/TabBar 页面,以及一些所有分包都需用到公共资源/JS 脚本;而分包则是根据开发者的配置进行划分。

在小程序启动时,默认会下载主包并启动主包内页面,当用户进入分包内某个页面时,客户端会把对应分包下载下来,下载完成后再进行展示。

目前小程序分包大小有以下限制:

  • 整个小程序所有分包大小不超过 20M
  • 单个分包/主包大小不能超过 2M

对小程序进行分包,可以优化小程序首次启动的下载时间,以及在多团队共同开发时可以更好的解耦协作。

具体使用方法请参考:

使用分包(常规分包)

使用分包 | 微信开放文档 (qq.com)

案例:

新建songPackage/pages子包文件夹,把歌曲相关的两个文件夹放入其中(这两个页面不需要一开始加载),注意原来的引入路径需要修改

image-20221113095018071

app.json中添加subpackages字段,并修改pages字段,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"pages": [
"pages/index/index",
"pages/search/search",
"pages/login/login",
"pages/video/video",
"pages/personal/personal",
"template/template01/template01",
"test/test"
],
"subpackages": [
{
"root": "songPackages",
"pages": [
"pages/recommendSong/recommendSong",
"pages/songDetail/songDetail"
]
}
],

同理,我们把测试页面也单独放一个包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"pages": [
"pages/index/index",
"pages/search/search",
"pages/login/login",
"pages/video/video",
"pages/personal/personal",
"template/template01/template01"
],
"subpackages": [
{
"root": "songPackage",
"pages": [
"pages/recommendSong/recommendSong",
"pages/songDetail/songDetail"
]
},
{
"root": "testPackage",
"pages": [
"test/test"
]
}
],

分包之后,查看微信开发者工具的基本信息

image-20221113095620523

分包过后,我们写在js中的各种逻辑跳转的路径,需要修改一下,要加一层根目录文件夹(建议后期优化时,类似封装axios额外再封装一层请求)

独立分包

独立分包 | 微信开放文档 (qq.com)

独立分包是小程序中一种特殊类型的分包,可以独立于主包和其他分包运行。从独立分包中页面进入小程序时,不需要下载主包。当用户进入普通分包或主包内页面时,主包才会被下载。

开发者可以按需将某些具有一定功能独立性的页面配置到独立分包中。当小程序从普通的分包页面启动时,需要首先下载主包;而独立分包不依赖主包即可运行,可以很大程度上提升分包页面的启动速度。

一个小程序中可以有多个独立分包。

开发者通过在app.jsonsubpackages字段中对应的分包配置项中定义independent字段声明对应分包为独立分包。

特点:

  • 独立分包可单独访问分包的内容,不需要下载主包
  • 独立分包不能依赖主包或者其它包的内容,比如iconfont、全局的样式等,需要在自己包单独引入

使用场景:

  • 通常某些页面和当前小程序的其它页面关联不大的时候,可进行独立分包
  • 如:临时加的广告页或活动页

分包预下载

开发者可以通过配置,在进入小程序某个页面时,由框架自动预下载可能需要的分包,提升进入后续分包页面时的启动速度。对于独立分包,也可以预下载主包。

使用preloadRule字段(和pages字段同级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"subpackages": [
{
"root": "songPackage",
"pages": [
"pages/recommendSong/recommendSong",
"pages/songDetail/songDetail"
]
},
{
"root": "testPackage",
"pages": [
"test/test"
],
"name": "test"
}
],
"preloadRule": {
"pages/index/index": { // 进入首页时,预下载以下两个包
"packages": [
"songPackage", // 通过root字段找到
"test" // 通过name字段找到
]
}
},

预加载主包

应用场景:独立分包的时候

1
2
3
4
"preloadRule": {
"pages/independent/independent": {
"packages": ["__APP__"]
}

小程序转发分享

小程序支付流程

前提:必须是企业账号

流程要注册填写各种信息,法人要录视频声明,对公账号打钱(1毛钱也行),需要将小程序发布,成功发布后才能申请支付

(目前我们用的是网易云的数据,发布不了的)

流程

图示

image-20221113103729991

  1. 用户在小程序客服端下单(包含用户及商品信息)

  2. 小程序客户端发送下单支付请求给商家服务器

  3. 商家服务器同微信服务器对接获取唯一标识 openID

  4. 商家服务器根据 openId 生成商户订单(包含商户信息)

  5. 商家服务器发送请求调用统一下单 API 获取预支付订单信息

​ a) 接口地址: https://api.mch.weixin.qq.com/pay/unifiedorder

  1. 商家对预支付信息签名加密后返回给小程序客户端

​ a) 签名方式: MD5

​ b) 签名字段:小程序 ID, 时间戳, 随机串,数据包,签名方式

​ c) 参考地址 :https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=7_7&index=3

  1. 用户确认支付(鉴权调起支付)

​ a) API: wx.requestPayment()

  1. 微信服务器返回支付结果给小程序客户端

  2. 微信服务器推送支付结果给商家服务器端

https://pay.weixin.qq.com/wiki/doc/api/wxa/wxa_api.php?chapter=7_3&index=1

小程序上线环境准备

域名

购买域名(开发版暂不需要备案)

配置@记录和www记录

image-20221113115119637

部署https

腾讯云上部署免费HTTPS

SSL证书免费申请提醒

尊敬的腾讯云用户,您好!

您的腾讯云账号(账号 ID:10**8047,昵称:**) 购买的TrustAsia TLS RSA CA证书(年限:1年,证书ID:xGolouYI)现已创建成功。可访问 证书管理控制台 进行查看。域名验证方式可参考 域名验证指引 。证书颁发后需要安装至服务器,您可根据 最佳实践 中安装范例进行配置操作。

【腾讯云】尊敬的用户,您的域名 *****.cn 的 TrustAsia TLS RSA CA(1年)证书已审核通过并成功颁发。登录控制台查看证书:https://mc.tencent.com/g9algEWM 。证书部署可参考文档:https://mc.tencent.com/ZKGAc9go 。您的腾讯云账号(账号ID:10**8047,昵称:**)。

下载证书:

image-20221113114520379

找到服务器nginx位置,进行配置

SSL 证书 Nginx 服务器 SSL 证书安装部署-证书安装-文档中心-腾讯云 (tencent.com)

备案

国内需要备案,配置成功后,过一会儿如果https协议头访问不了,试试http协议头,然后会提示备案

需要在域名注册72小时后才可备案

配置nginx反向代理nodejs服务

前言

Node.js自身能作为web服务器用,但是如果要在一台机器上开启多个Node.js应用该如何做呢?有一种答案就是使用nginx做反向代理。反向代理在这里的作用就是,当代理服务器接收到请求,将请求转发到目的服务器,然后获取数据后返回。

步骤样例

一、正常使用node.js开启web服务

1
2
3
4
5
6
7
var http = require('http');
http.createServer(function (request, response) {
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end('hello world
');
}).listen(1337);
console.log('Server running at http://127.0.0.1:1337/');

二、为域名配置nginx

1
2
3
4
[root@iZ25lzba47vZ vhost]# ls
default.conf node.ruanwenwu.cn.conf test.ruanwenwu.conf www.tjzsyl.com.conf
laravel.ruanwenwu.cn.conf wss.ruanwenwu.cn.conf www.tjzsyl.com.conf.bak
[root@iZ25lzba47vZ vhost]# pwd

node.ruanwenwu.cn.conf:

1
2
3
4
5
6
7
server{
listen 80;
server_name node.ruanwenwu.cn;
location / {
proxy_pass http://127.0.0.1:1337;
}
}

实际配置

新增一个server

1
2
3
4
5
6
7
8
9
10
server {
listen 8090;
server_name 1.***.78;
location / {
# root html; # root和proxy_pass不会同时生效,注释掉
# index index.html index.htm;
proxy_pass http://localhost:3000;
}
}

小程序中的config.js

1
2
3
4
5
6
// 服务器配置相关
export default {
// host: 'http://localhost:3000',
// host: 'http://1.***.78:3000',
host: 'http://1.***.78:8090', // 反向代理
}