本节代码对应 GitHub 分支: chapter10

仓库传送门 (opens new window)

首先开发 redux 数据层。

# axios 请求准备

在 api/request.js 中加入:

export const getHotKeyWordsRequest = () => {
  return axiosInstance.get (`/search/hot`);
};

export const getSuggestListRequest = query => {
  return axiosInstance.get (`/search/suggest?keywords=${query}`);
};

export const getResultSongsListRequest = query => {
  return axiosInstance.get (`/search?keywords=${query}`);
};

# redux 层开发

# 1. 声明初始化 state

//reducer.js
import * as actionTypes from './constants';
import { fromJS } from 'immutable';

const defaultState = fromJS ({
  hotList: [], // 热门关键词列表
  suggestList: [],// 列表,包括歌单和歌手
  songsList: [],// 歌曲列表
  enterLoading: false
})

# 2. 定义 constants

//constants.js
export const SET_HOT_KEYWRODS = "search/SET_HOT_KEYWRODS";
export const SET_SUGGEST_LIST = 'search/SET_SUGGEST_LIST';
export const SET_RESULT_SONGS_LIST = 'search/SET_RESULT_SONGS_LIST';
export const SET_ENTER_LOADING = 'search/SET_ENTER_LOADING';

# 3. 定义 reducer 函数

export default (state = defaultState, action) => {
  switch (action.type) {
    case actionTypes.SET_HOT_KEYWRODS:
      return state.set ('hotList', action.data);
    case actionTypes.SET_SUGGEST_LIST:
      return state.set ('suggestList', action.data);
    case actionTypes.SET_RESULT_SONGS_LIST:
      return state.set ('songsList', action.data);
    case actionTypes.SET_ENTER_LOADING:
      return state.set ('enterLoading', action.data);
    default:
      return state;
  }
}

# 4. 编写具体的 action

逻辑都非常简单,直接放出代码:

//actionCreators.js

import { SET_HOT_KEYWRODS, SET_SUGGEST_LIST, SET_RESULT_SONGS_LIST, SET_ENTER_LOADING } from './constants';
import { fromJS } from 'immutable';
import { getHotKeyWordsRequest, getSuggestListRequest, getResultSongsListRequest } from './../../../api/request';

const changeHotKeyWords = (data) => ({
  type: SET_HOT_KEYWRODS,
  data: fromJS (data)
});

const changeSuggestList = (data) => ({
  type: SET_SUGGEST_LIST,
  data: fromJS (data)
});

const changeResultSongs = (data) => ({
  type: SET_RESULT_SONGS_LIST,
  data: fromJS (data)
});

export const changeEnterLoading = (data) => ({
  type: SET_ENTER_LOADING,
  data
});

export const getHotKeyWords = () => {
  return dispatch => {
    getHotKeyWordsRequest ().then (data => {
      // 拿到关键词列表
      let list = data.result.hots;
      dispatch (changeHotKeyWords (list));
    })
  }
};
export const getSuggestList = (query) => {
  return dispatch => {
    getSuggestListRequest (query).then (data => {
      if (!data) return;
      let res = data.result || [];
      dispatch (changeSuggestList (res));
    })
    getResultSongsListRequest (query).then (data => {
      if (!data) return;
      let res = data.result.songs || [];
      dispatch (changeResultSongs (res));
      dispatch (changeEnterLoading (false));// 关闭 loading
    })
  }
};

# 5. 将相关变量导出

//index.js
import reducer from './reducer'
import * as actionCreators from './actionCreators'
import * as constants from './constants'

export { reducer, actionCreators, constants };

# 组件连接 Redux

首先,需要将 Search 下的 reducer 注册到全局 store,在 src 目录下的 store/reducer.js 中。(注意,这个操作非常重要,当时因为这个问题调整了很久,后来打开 redux-devtools 中才猛然发现。)

import { combineReducers } from "redux-immutable";
import { reducer as recommendReducer } from "../application/Recommend/store/index";
import { reducer as singersReducer } from "../application/Singers/store/index";
import { reducer as rankReducer } from "../application/Rank/store/index";
import { reducer as albumReducer } from "../application/Album/store/index";
import { reducer as singerInfoReducer } from "../application/Singer/store/index";
import { reducer as playerReducer } from "../application/Player/store/index";
import { reducer as searchReducer } from "../application/Search/store/index";

export default combineReducers ({
  recommend: recommendReducer,
  singers: singersReducer,
  rank: rankReducer,
  album: albumReducer,
  singerInfo: singerInfoReducer,
  player: playerReducer,
  search: searchReducer,
});

现在在 Search/index.js 中,准备连接 Redux。 增加代码:

import { connect } from 'react-redux';

// 组件代码

// 映射 Redux 全局的 state 到组件的 props 上
const mapStateToProps = (state) => ({
  hotList: state.getIn (['search', 'hotList']),
  enterLoading: state.getIn (['search', 'enterLoading']),
  suggestList: state.getIn (['search', 'suggestList']),
  songsCount: state.getIn (['player', 'playList']).size,
  songsList: state.getIn (['search', 'songsList'])
});

// 映射 dispatch 到 props 上
const mapDispatchToProps = (dispatch) => {
  return {
    getHotKeyWordsDispatch () {
      dispatch (getHotKeyWords ());
    },
    changeEnterLoadingDispatch (data) {
      dispatch (changeEnterLoading (data))
    },
    getSuggestListDispatch (data) {
      dispatch (getSuggestList (data));
    },
  }
};
// 将 ui 组件包装成容器组件
export default connect (mapStateToProps, mapDispatchToProps)(React.memo (Search));

# 组件对接真实数据

首先在组件中取出 redux 中的数据:

import { getHotKeyWords, changeEnterLoading, getSuggestList } from './store/actionCreators';
import { connect } from 'react-redux';
import { Container, ShortcutWrapper, HotKey } from './style';
import Scroll from '../../baseUI/scroll';

const {
  hotList,
  enterLoading,
  suggestList: immutableSuggestList,
  songsCount,
  songsList: immutableSongsList
} = props;

const suggestList = immutableSuggestList.toJS ();
const songsList = immutableSongsList.toJS ();

const {
  getHotKeyWordsDispatch,
  changeEnterLoadingDispatch,
  getSuggestListDispatch,
  getSongDetailDispatch
} = props;

我们接下来要做三件事情:

  1. 当搜索框为空,展示热门搜索列表
  2. 当搜索框有内容时,发送 Ajax 请求,显示搜索结果
  3. 点击搜索结果,分别进入到不同的详情页中

第一步,当搜索框为空时:

//Search 组件内
const renderHotKey = () => {
  let list = hotList ? hotList.toJS (): [];
  return (
    <ul>
      {
        list.map (item => {
          return (
            <li className="item" key={item.first} onClick={() => setQuery (item.first)}>
              <span>{item.first}</span>
            </li>
          )
        })
      }
    </ul>
  )
};
//Container 组件中添加
<ShortcutWrapper show={!query}>
  <Scroll>
    <div>
      <HotKey>
        <h1 className="title"> 热门搜索 </h1>
        {renderHotKey ()}
      </HotKey>
    </div>
  </Scroll>
</ShortcutWrapper>

对应的 style.js:

export const ShortcutWrapper = styled.div`
  position: absolute;
  top: 40px;
  bottom: 0;
  width: 100%;
  display: ${props => props.show ? "":"none"};
`

export const HotKey = styled.div`
  margin: 0 20px 20px 20px;
  .title {
    padding-top: 35px;
    margin-bottom: 20px;
    font-size: ${style ["font-size-m"]};
    color: ${style ["font-color-desc-v2"]};
  }
  .item {
    display: inline-block;
    padding: 5px 10px;
    margin: 0 20px 10px 0;
    border-radius: 6px;
    background: ${style ["highlight-background-color"]};
    font-size: ${style ["font-size-m"]};
    color: ${style ["font-color-desc"]};
  }
`

当组件初次渲染时,我们发送 Ajax 请求拿到热门列表。

useEffect (() => {
  setShow (true);
  // 用了 redux 缓存,不再赘述
  if (!hotList.size)
    getHotKeyWordsDispatch ();
}, []);

现在就能成功地看到热门标签了,而且点击标记,搜索框的内容也能跟着改变。

第二步,搜索框有内容时:

在 handleQuery 中加入下面的逻辑。

const handleQuery = (q) => {
  //...
  if (!q) return;
  changeEnterLoadingDispatch (true);
  getSuggestListDispatch (q);
}

然后分别渲染歌单、歌手和单曲列表。

// 顺便引入 Loading
import Loading from './../../baseUI/loading/index';

const renderSingers = () => {};
const renderAlbum = () => {};
const renderSongs = () => {};

{/* 紧接在热门列表后面 */}
{/* 下面为搜索结果 */}
<ShortcutWrapper show={query}>
  <Scroll onScorll={forceCheck}>
    <div>
      {renderSingers ()}
      {renderAlbum ()}
      {renderSongs ()}
    </div>
  </Scroll>
</ShortcutWrapper>
{ enterLoading? <Loading></Loading> : null }

对于歌单而言:

// 注意引入相应组件
import LazyLoad, {forceCheck} from 'react-lazyload';
import { List, ListItem } from './style';

const renderAlbum = () => {
  let albums = suggestList.playlists;
  if (!albums || !albums.length) return;
  return (
    <List>
      <h1 className="title"> 相关歌单 </h1>
      {
        albums.map ((item, index) => {
          return (
            <ListItem key={item.accountId+""+index}>
              <div className="img_wrapper">
                <LazyLoad placeholder={<img width="100%" height="100%" src={require ('./music.png')} alt="music"/>}>
                  <img src={item.coverImgUrl} width="100%" height="100%" alt="music"/>
                </LazyLoad>
              </div>
              <span className="name"> 歌单: {item.name}</span>
            </ListItem>
          )
        })
      }
    </List>
  )
};

style.js 中的 List 和 ListItem 如下:

export const List = styled.div`
  display: flex;
  margin: auto;
  flex-direction: column;
  overflow: hidden;
  .title {
    margin:10px 0 10px 10px;
    color: ${style ["font-color-desc"]};
    font-size: ${style ["font-size-s"]};
  }
`;
export const ListItem = styled.div`
  box-sizing: border-box;
  display: flex;
  flex-direction: row;
  margin: 0 5px;
  padding: 5px 0;
  align-items: center;
  border-bottom: 1px solid ${style ["border-color"]};
  .img_wrapper {
    margin-right: 20px;
    img {
      border-radius: 3px;
      width: 50px;
      height: 50px;
    }
  }
  .name {
    font-size: ${style ["font-size-m"]};
    color: ${style ["font-color-desc"]};
    font-weight: 500;
  }
`;

这里进入的是一个全新的路由。但是我们可以复用 Album 组件,在 routes/index.js 增加:

//...
// 增加 album 路由,用来显示歌单
{
  path: "/album/:id",
  exact: true,
  key: "album",
  component: Album
},
{
  path: "/search",
  exact: true,
  key: "search",
  component: Search
}
//...

对于歌手而言:

const renderSingers = () => {
  let singers = suggestList.artists;
  if (!singers || !singers.length) return;
  return (
    <List>
      <h1 className="title"> 相关歌手 </h1>
      {
        singers.map ((item, index) => {
          return (
            <ListItem key={item.accountId+""+index}>
              <div className="img_wrapper">
                <LazyLoad placeholder={<img width="100%" height="100%" src={require ('./singer.png')} alt="singer"/>}>
                  <img src={item.picUrl} width="100%" height="100%" alt="music"/>
                </LazyLoad>
              </div>
              <span className="name"> 歌手: {item.name}</span>
            </ListItem>
          )
        })
      }
    </List>
  )
};

对于单曲列表:

// 引入代码
import { SongItem } from './style';
import { getName } from '../../api/utils';

const renderSongs = () => {
  return (
    <SongItem style={{paddingLeft: "20px"}}>
      {
        songsList.map (item => {
          return (
            <li key={item.id}>
              <div className="info">
                <span>{item.name}</span>
                <span>
                  { getName (item.artists) } - { item.album.name }
                </span>
              </div>
            </li>
          )
        })
      }
    </SongItem>
)

SongItem 对应的样式代码:

export const SongItem = styled.ul`
  >li {
    display: flex;
    height: 60px;
    align-items: center;
    .index {
      width: 60px;
      height: 60px;
      line-height: 60px;
      text-align: center;
    }
    .info {
      box-sizing: border-box;
      flex: 1;
      display: flex;
      height: 100%;
      padding: 5px 0;
      flex-direction: column;
      justify-content: space-around;
      border-bottom: 1px solid ${style ["border-color"]};
      >span:first-child {
        color: ${style ["font-color-desc"]};
      }
      >span:last-child {
        font-size: ${style ["font-size-s"]};
        color: #bba8a8;
      }
    }
  }
`

对应的 music.png 和 singer.png 占位图片已经放在仓库中,有需要可以去仓库拷贝一份。

这三者的逻辑虽然有点复杂,但是难度并不大。这里就不过多的拆解,大家将代码过一遍,能够理解每一步做的什么事情即可。

第三步,点击结果,进入到各自的详情页。

在 renderSingers 方法中:

<ListItem key={item.accountId+""+index} onClick={() => props.history.push (`/singers/${item.id}`)}>

在 renderAlbum 方法中:

<ListItem key={item.accountId+""+index} onClick={() => props.history.push (`/album/${item.id}`)}>

在 renderSongs 方法中:

<li key={item.id} onClick={(e) => selectItem (e, item.id)}>

而 selectItem 定义如下:

const selectItem = (e, id) => {

}

重点来了!现在歌单和歌手详情页都能正确跳转,后面的逻辑当然能走的通了,剩下的就是如何处理单曲的问题。我们希望点击单曲后能够直接播放,那么首先需要 将选中的单曲加入到播放列表中。顺便提一句,网易云给到的搜索单曲的接口中数据并不完整,需要我们拿到 id 再重新获取具体的单曲数据,然后再添加到播放列表中。

axios 请求部分:

export const getSongDetailRequest = id => {
  return axiosInstance.get (`/song/detail?ids=${id}`);
};

关于歌曲的逻辑属于播放器部分,因此我们转到 Player/store/actionCreators.js 中来编写:

import { getSongDetailRequest } from '../../../api/request';
import { INSERT_SONG } from './constants';

export const insertSong = (data) => ({
  type: INSERT_SONG,
  data
});

export const getSongDetail = (id) => {
  return (dispatch) => {
    getSongDetailRequest (id).then (data => {
      let song = data.songs [0];
      dispatch (insertSong ( song));
    })
  }
}

同目录 constants.js 中添加:

export const INSERT_SONG = 'player/INSERT_SONG';

然后再 reducer 编写具体的 insert 逻辑:

export default (state = defaultState, action) => {
  switch (action.type) {
    //...
    case actionTypes.INSERT_SONG:
      return handleInsertSong (state, action.data);
    default:
      return state;
  }
}

handleInsertSong 的逻辑还是比较复杂的,我们单独拎出来拆解:

const handleInsertSong = (state, song) => {
  const playList = JSON.parse (JSON.stringify (state.get ('playList').toJS ()));
  const sequenceList = JSON.parse (JSON.stringify (state.get ('sequencePlayList').toJS ()));
  let currentIndex = state.get ('currentIndex');
  // 看看有没有同款
  let fpIndex = findIndex (song, playList);
  // 如果是当前歌曲直接不处理
  if (fpIndex === currentIndex && currentIndex !== -1) return state;
  currentIndex++;
  // 把歌放进去,放到当前播放曲目的下一个位置
  playList.splice (currentIndex, 0, song);
  // 如果列表中已经存在要添加的歌,暂且称它 oldSong
  if (fpIndex > -1) {
    // 如果 oldSong 的索引在目前播放歌曲的索引小,那么删除它,同时当前 index 要减一
    if (currentIndex > fpIndex) {
      playList.splice (fpIndex, 1);
      currentIndex--;
    } else {
      // 否则直接删掉 oldSong
      playList.splice (fpIndex+1, 1);
    }
  }
  // 同理,处理 sequenceList
  let sequenceIndex = findIndex (playList [currentIndex], sequenceList) + 1;
  let fsIndex = findIndex (song, sequenceList);
  // 插入歌曲
  sequenceList.splice (sequenceIndex, 0, song);
  if (fsIndex > -1) {
    // 跟上面类似的逻辑。如果在前面就删掉,index--; 如果在后面就直接删除
    if (sequenceIndex > fsIndex) {
      sequenceList.splice (fsIndex, 1);
      sequenceIndex--;
    } else {
      sequenceList.splice (fsIndex + 1, 1);
    }
  }
  return state.merge ({
    'playList': fromJS (playList),
    'sequencePlayList': fromJS (sequenceList),
    'currentIndex': fromJS (currentIndex),
  });
}

现在插入的逻辑可以在 Search 组件中运用了。

const mapDispatchToProps = (dispatch) => {
  return {
    //...
    getSongDetailDispatch (id) {
      dispatch (getSongDetail (id));
    }
  }
};

在组件中:

const selectItem = (e, id) => {
  getSongDetailDispatch (id);
}

现在点击单曲后,歌曲就能正常播放啦!

由于没有加上音符组件,因此这里不会有音符坠落的动画,加上去也非常简单。

import MusicalNote from '../../baseUI/music-note';
import { useRef } from 'react';
// 组件内部
const musicNoteRef = useRef ();

// 返回的 JSX
// Container 标签中加入
<MusicalNote ref={musicNoteRef}></MusicalNote>

然后在 selectItem 方法中加入一行代码就 OK:

const selectItem = (e, id) => {
  getSongDetailDispatch (id);
  musicNoteRef.current.startAnimation ({x:e.nativeEvent.clientX, y:e.nativeEvent.clientY});
}

当然,还剩下一个小小的 bug,事实上 Container 还是会遮盖住 miniPlayer。

之前专门修复了不少这样的 bug, 现在贴上代码:

//Search/index.js
<Container play={songsCount}>

style.js 中:

export const Container = styled.div`
  //...
  bottom: ${props => props.play > 0 ? "60px": 0};
  //...
`

搜索模块现在就开发完毕了。总体来说,还是非常复杂的一个组件。希望大家好好消化一下,对自己是一个很好的锻炼。

阅读全文