React全家桶构建一款Web音乐App实战(八):搜索功能开发

1,654 阅读7分钟

这一节开发搜索功能,搜索功能将使用QQ音乐的搜索接口,获取搜索结果数据然后利用前几节使用的歌手、专辑、获取歌曲文件地址接口做跳转或者播放处理

接口数据抓取

1.热搜

使用chrome浏览器打开手机调试模式,输入QQ音乐手机端网址:m.y.qq.com,进入后点击热搜,然后点击Network,红色方框中就是热搜发的请求

点击请求链接,选择Preview查看返回的数据内容,其中hotkey中就是所有热搜的关键词

2.搜索

在页面搜索输入框中输入搜索的内容,按回车键,红色方框中就是搜索的请求

点开请求的链接,在Preview中查看返回的数据内容

其中song就是搜索结果相关的歌曲,zhida就是搜索结果相关的歌手、歌单或专辑,具体区分看里面的type字段值

接口具体说明请看QQ音乐api梳理热搜搜索接口

接口请求方法

在api目录下面的config.js中加入接口url配置

config.js

const URL = {
    ...
    /*热搜*/
    hotkey: "https://c.y.qq.com/splcloud/fcgi-bin/gethotkey.fcg",
    /*搜索*/
    search: "https://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp"
};

在api下面新建search.js,编写接口请求方法

search.js

import jsonp from "./jsonp"
import {URL, PARAM, OPTION} from "./config"

export function getHotKey() {
    const data = Object.assign({}, PARAM, {
        g_tk: 5381,
        uin: 0,
        platform: "h5",
        needNewCode: 1,
        notice: 0,
        _: new Date().getTime()
    });

    return jsonp(URL.hotkey, data, OPTION);
}

export function search(w) {
    const data = Object.assign({}, PARAM, {
        g_tk: 5381,
        uin: 0,
        platform: "h5",
        needNewCode: 1,
        notice: 0,
        zhidaqu: 1,
        catZhida: 1,
        t: 0,
        flag: 1,
        ie: "utf-8",
        sem: 1,
        aggr: 0,
        perpage: 20,
        n: 20,
        p: 1,
        w,
        remoteplace: "txt.mqq.all",
        _: new Date().getTime()
    });

    return jsonp(URL.search, data, OPTION);
}

接下来根据搜索返回结果创建专辑和歌手对象函数,在model目录下面的album.jssinger.js中分别编写以下两个方法

album.js

export function createAlbumBySearch(data) {
    return new Album(
        data.albumid,
        data.albummid,
        data.albumname,
        `http://y.gtimg.cn/music/photo_new/T002R68x68M000${data.albummid}.jpg?max_age=2592000`,
        data.singername,
        ""
    );
}

singer.js

export function createSingerBySearch(data) {
    return new Singer(
        data.singerid,
        data.singermid,
        data.singername,
        `http://y.gtimg.cn/music/photo_new/T001R68x68M000${data.singermid}.jpg?max_age=2592000`
    );
}

搜索页开发

先为Search组件编写容器组件Search,以便操作状态管理中的数据。在container目录下新建Search.js,代码如下

import {connect} from "react-redux"
import {showPlayer, changeSong, setSongs} from "../redux/actions"
import Search from "../components/search/Search"

const mapDispatchToProps = (dispatch) => ({
    showMusicPlayer: (show) => {
        dispatch(showPlayer(show));
    },
    changeCurrentSong: (song) => {
        dispatch(changeSong(song));
    },
    setSongs: (songs) => {
        dispatch(setSongs(songs));
    }
});

export default connect(null, mapDispatchToProps)(Search)

在App.js中将Search组件修改为容器组件

//import Search from "./search/Search"
import Search from "../containers/Search"

回到components下search中的Search.js。在search组件的constructor中定义以下几个state

constructor(props) {
    super(props);

    this.state = {
        hotKeys: [],
        singer: {},
        album: {},
        songs: [],
        w: "",
        loading: false
    };
}

hotKeys存放热搜接口的关键字列表,singer存放歌手对象数据,album存放专辑对象数据,songs存放歌手列表,w对应搜索输入框中的内容

导入Loading和Scroll组件

import Scroll from "@/common/scroll/Scroll"
import Loading from "@/common/loading/Loading"

render方法代码如下

let album = this.state.album;
    let singer = this.state.singer;
    return (
        <div className="music-search">
            <div className="search-box-wrapper">
                <div className="search-box">
                    <i className="icon-search"></i>
                    <input type="text" className="search-input" placeholder="搜索歌曲、歌手、专辑"
                           value={this.state.w}/>
                </div>
                <div className="cancel-button" style={{display: this.state.w ? "block" : "none"}}>取消</div>
            </div>
            <div className="search-hot" style={{display: this.state.w ? "none" : "block"}}>
                <h1 className="title">热门搜索</h1>
                <div className="hot-list">
                    {
                        this.state.hotKeys.map((hot, index) => {
                            if (index > 10) return "";
                            return (
                                <div className="hot-item" key={index}>{hot.k}</div>
                            );
                        })
                    }
                </div>
            </div>
            <div className="search-result skin-search-result" style={{display: this.state.w ? "block" : "none"}}>
                <Scroll ref="scroll">
                    <div>
                        {/*专辑*/}
                        <div className="album-wrapper" style={{display:album.id ? "block" : "none"}}>
                            ...
                            <div className="right">
                                <div className="song">{album.name}</div>
                                <div className="singer">{album.singer}</div>
                            </div>
                        </div>
                        {/*歌手*/}
                        <div className="singer-wrapper" style={{display:singer.id ? "block" : "none"}}>
                            ...
                            <div className="right">
                                <div className="singer">{singer.name}</div>
                                <div className="info">单曲{singer.songnum} 专辑{singer.albumnum}</div>
                            </div>
                        </div>
                        {/*歌曲列表*/}
                        {
                            this.state.songs.map((song) => {
                                return (
                                    <div className="song-wrapper" key={song.id}>
                                        ...
                                        <div className="right">
                                            <div className="song">{song.name}</div>
                                            <div className="singer">{song.singer}</div>
                                        </div>
                                    </div>
                                );
                            })
                        }
                    </div>
                    <Loading title="正在加载..." show={this.state.loading}/>
                </Scroll>
            </div>
        </div>
    );

完整代码和search.styl请在源码中查看

在组件挂载完成后调用获取热搜关键词的方法,先导入两个之前写好的接口的方法、接口CODE码常量、歌手、专辑和歌曲模型类

import {getHotKey, search} from "@/api/search"
import {CODE_SUCCESS} from "@/api/config"
import * as SingerModel from "@/model/singer"
import * as AlbumModel from "@/model/album"
import * as SongModel from "@/model/song"

componentDidMount方法代码如下

componentDidMount() {
    getHotKey().then((res) => {
        console.log("获取热搜:");
        if (res) {
            console.log(res);
            if (res.code === CODE_SUCCESS) {
                this.setState({
                    hotKeys: res.data.hotkey
                });
            }
        }
    });
}

编写一个获取搜索结果的方法,传入搜索关键字做为参数

search = (w) => {
	this.setState({w, loading: true});
	search(w).then((res) => {
		console.log("搜索:");
		if (res) {
			console.log(res);
			if (res.code === CODE_SUCCESS) {
				let zhida = res.data.zhida;
				let type = zhida.type;
				let singer = {};
				let album = {};
				switch (type) {
					//0:表示歌曲
					case 0:
						break;
					//2:表示歌手
					case 2:
						singer = SingerModel.createSingerBySearch(zhida);
						singer.songnum = zhida.songnum;
						singer.albumnum = zhida.albumnum;
						break;
					//3: 表示专辑
					case 3:
						album = AlbumModel.createAlbumBySearch(zhida);
						break;
					default:
						break;
				}
				let songs = [];
				res.data.song.list.forEach((data) => {
					if (data.pay.payplay === 1) { return }
					songs.push(SongModel.createSong(data));
				});
				this.setState({
					album: album,
					singer: singer,
					songs: songs,
					loading: false
				}, () => {
					this.refs.scroll.refresh();
				});
			}
		}
	});
}

在上述代码中,搜索接口返回的type字段分别有不同的值,当值为0时,不做处理。当值为2时创建歌手对象。当值为3时创建专辑对象,最后调用setState方法修改state触发组件更新。在发送请求前将输入框的值更新为传递过来的参数w,同时显示Loading组件

在React中,可变的状态通常保存在组件的状态属性中,并且只能用 setState()方法进行更新,这里对于表单元素输入框要把它写成“受控组件”形式,受控组件就是React负责渲染表单的组件然后控制用户后续输入时所发生的变化。相应的,其值由React控制的输入表单元素,做法就是给input输入框添加onChange事件,值发生变化是调用setState更新

编写一个处理change事件的方法,改变输入框对于的we状态属性值,这里要把singer、album和songs置空否则输入内容的时候上一次搜索的结果会显示

handleInput = (e) => {
    let w = e.currentTarget.value;
    this.setState({
        w,
        singer: {},
        album: {},
        songs: []
    });
}

给input绑定change事件

<input type="text" className="search-input" placeholder="搜索歌曲、歌手、专辑"
       value={this.state.w}
       onChange={this.handleInput}/>

给取消按钮添加点击事件,将所有状态属性置空

<div className="cancel-button" style={{display: this.state.w ? "block" : "none"}}
 onClick={() => this.setState({w:"", singer:{}, album:{}, songs:[]})}>取消</div>

当点击热搜关键词时,调用search方法,并且传入当前点击的关键字调用搜索接口进行搜索

handleSearch = (k) => {
    return () => {
        this.search(k);
    }
}
<div className="hot-item" key={index}
     onClick={this.handleSearch(hot.k)}>{hot.k}</div>

搜索结果处理

接下来处理搜索结果内容的点击,搜索结果分两个方式展示,如下两个图

第一张图最上面是搜索结果中的歌手信息,第二张图最上面是搜索结果中的专辑信息,两张图最下面是搜索的歌曲列表。点击歌手跳转到歌手详情,点击专辑跳转到专辑详情,这里使用之前写好的Singer和Album组件

专辑见第四节,歌手见第七节

给Search组件增加歌手和专辑两个子路由,导入Route、Singer和Album容器组件

import {Route} from "react-router-dom"
import Album from "@/containers/Album"
import Singer from "@/containers/Singer"

放置在如下位置

<div className="music-search">
    ...
    <Route path={`${this.props.match.url + '/album/:id'}`} component={Album} />
    <Route path={`${this.props.match.url + '/singer/:id'}`} component={Singer} />
</div>

给.album-wrapper和.singer-wrapper元素添加点击事件

<div className="album-wrapper" style={{display:album.id ? "block" : "none"}}
     onClick={this.handleClick(album.mId, "album")}>
    ...
</div>
<div className="singer-wrapper" style={{display:singer.id ? "block" : "none"}}
     onClick={this.handleClick(singer.mId, "singer")}>
    ...
</div>
handleClick = (data, type) => {
    return (e) => {
        switch (type) {
            case "album":
                //跳转到专辑详情
                this.props.history.push({
                    pathname: `${this.props.match.url}/album/${data}`
                });
                break;
            case "singer":
                //跳转到歌手详情
                this.props.history.push({
                    pathname: `${this.props.match.url}/singer/${data}`
                });
                break;
            case "song":
                break;
            default:
                break;
        }
    }
}

上诉代码点击专辑或歌手跳转到相应的路由组件

继续处理歌曲点击,导入获取歌曲vkey函数

import {getSongVKey} from "@/api/song"
handleClick = (data, type) => {
    return (e) => {
        ...
        case "song":
            getSongVKey(data.mId).then((res) => {
                if (res) {
                    if(res.code === CODE_SUCCESS) {
                        if(res.data.items) {
                            let item = res.data.items[0];
                            data.url =  `http://dl.stream.qqmusic.qq.com/${item.filename}?vkey=${item.vkey}&guid=3655047200&fromtag=66`;

                            this.props.setSongs([data]);
                            this.props.changeCurrentSong(data);
                        }
                    }
                }
            });
            break;
        ...
    }
}

给.song-wrapper元素绑定点击事件

<div className="song-wrapper" key={song.id} onClick={this.handleClick(song, "song")}>
    ...
</div>

点击歌曲修改Redux中的歌曲和歌曲列表,触发Play组件播放点击的歌曲

复制第5节的initMusicIcostartMusicIcoAnimation两个函数,然后在componentDidMount中调用initMusicIco

import ReactDOM from "react-dom"
import {getTransitionEndName} from "@/util/event"
this.initMusicIco();

handleClick函数中当type参数等于song时调用startMusicIcoAnimation启动动画

handleClick = (data, type) => {
    return (e) => {
        ...
        case "song":
            this.startMusicIcoAnimation(e.nativeEvent);
            getSongVKey(data.mId).then((res) => {
                ...
            });
            break;
        ... 
    }
}

音符下落动画具体请看歌曲点击音符下落动画

经过运行点击歌曲发现出现以下异常

这是因为Play.js中写了以下代码,这里本来是兼容手机端有些浏览器第一次无法播放的问题,后来测试发现以下代码不存在第一次也可以自动播放。因为React所有的事件都统一由document代理,然后分发到具体绑定事件的对象上去,document接收点击事件后浏览器便知道用户触摸了屏幕,此时调用play()方法就可以正常进行播放

componentDidUpdate() {
    //兼容手机端canplay事件触发后第一次调用play()方法无法自动播放的问题
    if (this.isFirstPlay === true) {
        this.audioDOM.play();
        this.isFirstPlay = false;
    }
}

Play组件中的以上代码已在这一节中删除

总结

这一节开发了搜索页面,主要利用搜索接口,在搜索不同的结果做不同的处理。搜索结果页面使用了前几节已经开发好的页面

完整项目地址:github.com/dxx/mango-m…

本章节代码在chapter8分支