如何在 React 中创建 Spotify 音乐搜索应用

2025-05-25

如何在 React 中创建 Spotify 音乐搜索应用

介绍

在本文中,您将使用 Spotify 音乐 API 创建一个完全响应的 Spotify 音乐搜索应用程序。

通过创建此应用程序,您将学习

  1. 如何使用 Spotify API 提供 OAuth 身份验证
  2. 如何搜索专辑、艺术家和播放列表
  3. 通过美观的用户界面展示详细信息
  4. 直接从列表中播放歌曲
  5. 如何向应用添加加载更多功能
  6. 如何为专辑、艺术家和播放列表添加和维护单独的加载更多功能

等等。

您可以在下面的视频中看到最终工作应用程序的现场演示

要使用 Redux Toolkit 和 React Router 6 构建此应用程序,请查看本课程

初始设置

使用以下方法创建新项目create-react-app

create-react-app spotify-music-search-app
Enter fullscreen mode Exit fullscreen mode

项目创建完成后,删除文件夹中的所有文件文件夹中src创建index.jsstyles.css文件src。同时,在文件夹中创建actions、、、、文件夹componentsimagesreducersrouterstoreutilssrc

安装必要的依赖项:

yarn add axios@0.19.2 bootstrap@4.5.2 lodash@4.17.19 prop-types@15.7.2 react-bootstrap@1.3.0 redux@4.0.5 react-redux@7.2.1 react-router-dom@5.2.0 redux-thunk@2.3.0
Enter fullscreen mode Exit fullscreen mode

打开并从这里styles.css添加内容

创建初始页面

Header.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
const Header = () => {
  return <h1 className="main-heading">Spotify Music Search</h1>;
};
export default Header;
Enter fullscreen mode Exit fullscreen mode

RedirectPage.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
const RedirectPage = () => {
 return <div>Redirect Page</div>;
};
export default RedirectPage;   
Enter fullscreen mode Exit fullscreen mode

Dashboard.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
const Dashboard = () => {
 return <div>Dashboard Page</div>;
};
export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

Home.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
import { connect } from 'react-redux';
import { Button } from 'react-bootstrap';
import Header from './Header';
const Home = (props) => {
  return (
    <div className="login">
      <Header />
      <Button variant="info" type="submit">
        Login to spotify
      </Button>
    </div>
  );
};
export default connect()(Home);
Enter fullscreen mode Exit fullscreen mode

NotFoundPage.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
import { Link } from 'react-router-dom';
import Header from './Header';
const NotFoundPage = () => {
  return (
    <React.Fragment>
      <Header />
      Page not found. Goto <Link to="/dashboard">Home Page</Link>
    </React.Fragment>
  );
};
export default NotFoundPage;
Enter fullscreen mode Exit fullscreen mode

AppRouter.js在文件夹内创建一个新文件router,内容如下:

import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Home from '../components/Home';
import RedirectPage from '../components/RedirectPage';
import Dashboard from '../components/Dashboard';
import NotFoundPage from '../components/NotFoundPage';
class AppRouter extends React.Component {
  render() {
    return (
      <BrowserRouter>
        <div className="main">
          <Switch>
            <Route path="/" component={Home} exact={true} />
            <Route path="/redirect" component={RedirectPage} />
            <Route path="/dashboard" component={Dashboard} />
            <Route component={NotFoundPage} />
          </Switch>
        </div>
      </BrowserRouter>
    );
  }
}
export default AppRouter;
Enter fullscreen mode Exit fullscreen mode

在这里,我们使用库为主页、仪表板页面、未找到页面和重定向页面等各种页面设置了路由react-router-dom

albums.js在文件夹内创建一个新文件reducers,内容如下:

const albumsReducer = (state = {}, action) => {
  switch (action.type) {
    default:
      return state;
  }
};
export default albumsReducer;
Enter fullscreen mode Exit fullscreen mode

artists.js在文件夹内创建一个新文件reducers,内容如下:

const artistsReducer = (state = {}, action) => {
  switch (action.type) {
    default:
      return state;
  }
};
export default artistsReducer;
Enter fullscreen mode Exit fullscreen mode

playlist.js在文件夹内创建一个新文件reducers,内容如下:

const playlistReducer = (state = {}, action) => {
  switch (action.type) {
    default:
      return state;
  }
};
export default playlistReducer;
Enter fullscreen mode Exit fullscreen mode

在所有上述 Reducer 中,我们都设置了默认状态。随着应用的进展,我们将添加更多 switch case。

store.js在文件夹内创建一个新文件store,内容如下:

import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import albumsReducer from '../reducers/albums';
import artistsReducer from '../reducers/artists';
import playlistReducer from '../reducers/playlist';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

const store = createStore(
  combineReducers({
    albums: albumsReducer,
    artists: artistsReducer,
    playlist: playlistReducer
  }),
  composeEnhancers(applyMiddleware(thunk))
);

export default store;
Enter fullscreen mode Exit fullscreen mode

在这里,我们创建了一个将所有 Reducer 组合在一起的 Redux 存储,以便我们可以从AppRouter.js文件中定义的任何组件访问存储数据。

现在,打开src/index.js文件并在其中添加以下内容:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store/store';
import AppRouter from './router/AppRouter';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.css';

ReactDOM.render(
  <Provider store={store}>
    <AppRouter />
  </Provider>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

在这里,我们添加了一个Provider组件,它将把 redux 存储传递给组件中声明的所有路由AppRouter

现在,通过从终端运行以下命令来启动 React App:

yarn start
Enter fullscreen mode Exit fullscreen mode

当您访问http://localhost:3000/ 上的应用程序时,您将看到以下屏幕

登录屏幕

添加登录身份验证功能

现在,让我们添加登录功能。要使用 App 登录 Spotify 帐户,您需要三样东西:client_idauthorize_urlredirect_url

要获得该信息,请导航至此处并登录 Spotify 开发者帐户(如果您没有帐户,请注册)。

登录后,您将看到类似于以下屏幕的页面来创建应用程序。

帐户仪表板

单击CREATE AN APP绿色按钮并输入应用程序名称和描述,然后单击CREATE按钮。

创建应用程序

记下生成的客户端 ID。

客户端 ID

然后点击EDIT SETTINGS按钮。输入http://localhost:3000/redirect作为 的值Redirect URIs,然后点击按钮ADD,然后SAVE滚动一下鼠标点击按钮。

编辑设置

.env现在,在项目根目录中创建一个名称相同的新文件,并在其中添加以下详细信息。

REACT_APP_CLIENT_ID=your_client_id
REACT_APP_AUTHORIZE_URL=https://accounts.spotify.com/authorize
REACT_APP_REDIRECT_URL=http://localhost:3000/redirect
Enter fullscreen mode Exit fullscreen mode

这里,

  • REACT_APP_AUTHORIZE_URL将用于显示授权弹出窗口以从您的应用访问您的 Spotify 帐户。
  • REACT_APP_REDIRECT_URL将是用户成功授权后重定向到的 URL。
  • 每个变量都以REACT_APP_so开头,因此Create React App会自动在对象中添加这些变量,process.env以使其在应用程序中可访问。

确保将.env文件添加到.gitignore文件内部,这样它就不会被添加到 git,因为它包含不应公开的私人信息

请注意,变量的值必须与上面显示的屏幕截图REACT_APP_REDIRECT_URL输入的值相匹配,否则应用程序将无法运行。Redirect URIsEdit settings

现在,打开src/components/Home.js并将onClick处理程序添加到登录按钮

<Button variant="info" type="submit" onClick={handleLogin}>
  Login to spotify
</Button>
Enter fullscreen mode Exit fullscreen mode

并添加handleLogin函数

const {
  REACT_APP_CLIENT_ID,
  REACT_APP_AUTHORIZE_URL,
  REACT_APP_REDIRECT_URL
} = process.env;

const handleLogin = () => {
  window.location = `${REACT_APP_AUTHORIZE_URL}?client_id=${REACT_APP_CLIENT_ID}&redirect_uri=${REACT_APP_REDIRECT_URL}&response_type=token&show_dialog=true`;
};
Enter fullscreen mode Exit fullscreen mode

更新后的Home.js文件将如下所示:

import React from 'react';
import { connect } from 'react-redux';
import { Button } from 'react-bootstrap';
import Header from './Header';
const Home = (props) => {
  const {
    REACT_APP_CLIENT_ID,
    REACT_APP_AUTHORIZE_URL,
    REACT_APP_REDIRECT_URL
  } = process.env;
  const handleLogin = () => {
    window.location = `${REACT_APP_AUTHORIZE_URL}?client_id=${REACT_APP_CLIENT_ID}&redirect_uri=${REACT_APP_REDIRECT_URL}&response_type=token&show_dialog=true`;
  };
  return (
    <div className="login">
      <Header />
      <Button variant="info" type="submit" onClick={handleLogin}>
        Login to spotify
      </Button>
    </div>
  );
};
export default connect()(Home);
Enter fullscreen mode Exit fullscreen mode

现在,通过yarn start从终端运行命令来启动您的应用程序并验证登录功能

登录认证

如您所见,一旦我们单击AGREE按钮,我们就会被重定向到RedirectPage组件,Spotify 会自动将access_tokentoken_type和添加expires_in到我们的重定向 URL,如下所示

http://localhost:3000/redirect#access_token=BQA4Y-o2kMSWjpRMD5y55f0nXLgt51kl4UAEbjNip3lIpz80uWJQJPoKPyD-CG2jjIdCjhfZKwfX5X6K7sssvoe20GJhhE7bHPaW1tictiMlkdzkWe2Pw3AnmojCy-NzVSOCj-aNtQ8ztTBYrCzRiBFGPtAn-I5g35An10&token_type=Bearer&expires_in=3600
Enter fullscreen mode Exit fullscreen mode
  • access_token是一个 Bearer 令牌,稍后您将把它添加到对 Spotify API 发出的每个请求中。
  • expires_in指定令牌的过期时间,3600默认为秒,即1小时。过期后,您需要重新登录。

添加搜索功能

现在,我们可以访问令牌,我们需要将其存储在某个地方,以便我们可以将其用于每个 API 请求。

functions.js在文件夹中创建一个新文件,其名称src/utils包含以下内容:

import axios from 'axios';
export const getParamValues = (url) => {
  return url
    .slice(1)
    .split('&')
    .reduce((prev, curr) => {
      const [title, value] = curr.split('=');
      prev[title] = value;
      return prev;
    }, {});
};
export const setAuthHeader = () => {
  try {
    const params = JSON.parse(localStorage.getItem('params'));
    if (params) {
      axios.defaults.headers.common[
        'Authorization'
      ] = `Bearer ${params.access_token}`;
    }
  } catch (error) {
    console.log('Error setting auth', error);
  }
};
Enter fullscreen mode Exit fullscreen mode

在这里,我们添加了,

  • getParamValues函数将存储access_tokentoken_typeexpires_in值在一个对象中,如下所示:
{
 access_token: some_value,
 token_type: some_value,
 expires_in: some_value
}
Enter fullscreen mode Exit fullscreen mode
  • setAuthHeader函数将添加access_token到每个axiosAPI 请求中

打开RedirectPage.js文件并将其替换为以下内容:

import React from 'react';
import _ from 'lodash';
import { getParamValues } from '../utils/functions';
export default class RedirectPage extends React.Component {
  componentDidMount() {
    const { setExpiryTime, history, location } = this.props;
    try {
      if (_.isEmpty(location.hash)) {
        return history.push('/dashboard');
      }
      const access_token = getParamValues(location.hash);
      const expiryTime = new Date().getTime() + access_token.expires_in * 1000;
      localStorage.setItem('params', JSON.stringify(access_token));
      localStorage.setItem('expiry_time', expiryTime);
      history.push('/dashboard');
    } catch (error) {
      history.push('/');
    }
  }
  render() {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

在这里,我们添加了一个componentDidMount生命周期方法来访问 URL 参数并将其存储在本地存储中。我们getParamValues通过传递 URL 中可用的值来调用该函数location.hash

expires_in值以秒 ( &expires_in=3600) 为单位,因此我们将其乘以1000,然后将其添加到当前时间的毫秒数,将其转换为毫秒

const expiryTime = new Date().getTime() + access_token.expires_in * 1000;
Enter fullscreen mode Exit fullscreen mode

因此expiryTime将包含令牌生成时间后一小时的毫秒数(因为 expires_in 是 3600)。

constants.js在文件夹中创建一个新文件utils,内容如下:

export const SET_ALBUMS = 'SET_ALBUMS';
export const ADD_ALBUMS = 'ADD_ALBUMS';
export const SET_ARTISTS = 'SET_ARTISTS';
export const ADD_ARTISTS = 'ADD_ARTISTS';
export const SET_PLAYLIST = 'SET_PLAYLIST';
export const ADD_PLAYLIST = 'ADD_PLAYLIST';
Enter fullscreen mode Exit fullscreen mode

result.js在文件夹内创建一个新文件actions,内容如下:

import {
  SET_ALBUMS,
  ADD_ALBUMS,
  SET_ARTISTS,
  ADD_ARTISTS,
  SET_PLAYLIST,
  ADD_PLAYLIST
} from '../utils/constants';
import { get } from '../utils/api';
export const setAlbums = (albums) => ({
  type: SET_ALBUMS,
  albums
});
export const addAlbums = (albums) => ({
  type: ADD_ALBUMS,
  albums
});
export const setArtists = (artists) => ({
  type: SET_ARTISTS,
  artists
});
export const addArtists = (artists) => ({
  type: ADD_ARTISTS,
  artists
});
export const setPlayList = (playlists) => ({
  type: SET_PLAYLIST,
  playlists
});
export const addPlaylist = (playlists) => ({
  type: ADD_PLAYLIST,
  playlists
});
export const initiateGetResult = (searchTerm) => {
  return async (dispatch) => {
    try {
      const API_URL = `https://api.spotify.com/v1/search?query=${encodeURIComponent(
        searchTerm
      )}&type=album,playlist,artist`;
      const result = await get(API_URL);
      console.log(result);
      const { albums, artists, playlists } = result;
      dispatch(setAlbums(albums));
      dispatch(setArtists(artists));
      return dispatch(setPlayList(playlists));
    } catch (error) {
      console.log('error', error);
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

api.js在文件夹内创建一个新文件utils,内容如下:

import axios from 'axios';
import { setAuthHeader } from './functions';

export const get = async (url, params) => {
  setAuthHeader();
  const result = await axios.get(url, params);
  return result.data;
};

export const post = async (url, params) => {
  setAuthHeader();
  const result = await axios.post(url, params);
  return result.data;
};
Enter fullscreen mode Exit fullscreen mode

Authorization在这个文件中,我们使用 axios 进行 API 调用,但在此之前,我们通过调用函数在 Header中添加 access_token setAuthHeader

Loader.js在文件夹内创建一个新文件components,内容如下:

import { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
const Loader = (props) => {
  const [node] = useState(document.createElement('div'));
  const loader = document.querySelector('#loader');

  useEffect(() => {
    loader.appendChild(node).classList.add('message');
  }, [loader, node]);

  useEffect(() => {
    if (props.show) {
      loader.classList.remove('hide');
      document.body.classList.add('loader-open');
    } else {
      loader.classList.add('hide');
      document.body.classList.remove('loader-open');
    }
  }, [loader, props.show]);

  return ReactDOM.createPortal(props.children, node);
};
export default Loader;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们创建了一个加载器组件,它将使用背景覆盖层显示加载消息。我们使用ReactDOM.createPortal方法来创建加载器。

要将加载器添加到页面,请打开public/index.html文件并在带有 id 的 div 后添加加载器 divroot

您的index.html页面主体现在将如下所示:

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
  <div id="loader" class="hide"></div>
  <!--
    This HTML file is a template.
    If you open it directly in the browser, you will see an empty page.
    You can add webfonts, meta tags, or analytics to this file.
    The build step will place the bundled scripts into the <body> tag.
    To begin the development, run `npm start` or `yarn start`.
    To create a production bundle, use `npm run build` or `yarn build`.
  -->
</body>
Enter fullscreen mode Exit fullscreen mode

默认情况下,加载器将被隐藏,因此我们添加了该类,hide而在显示加载器时我们将删除该类hide

SearchForm.js在文件夹内创建一个新文件components,内容如下:

import React, { useState } from 'react';
import { Form, Button } from 'react-bootstrap';
const SearchForm = (props) => {
  const [searchTerm, setSearchTerm] = useState('');
  const [errorMsg, setErrorMsg] = useState('');
  const handleInputChange = (event) => {
    const searchTerm = event.target.value;
    setSearchTerm(searchTerm);
  };
  const handleSearch = (event) => {
    event.preventDefault();
    if (searchTerm.trim() !== '') {
      setErrorMsg('');
      props.handleSearch(searchTerm);
    } else {
      setErrorMsg('Please enter a search term.');
    }
  };
  return (
    <div>
      <Form onSubmit={handleSearch}>
        {errorMsg && <p className="errorMsg">{errorMsg}</p>}
        <Form.Group controlId="formBasicEmail">
          <Form.Label>Enter search term</Form.Label>
          <Form.Control
            type="search"
            name="searchTerm"
            value={searchTerm}
            placeholder="Search for album, artist or playlist"
            onChange={handleInputChange}
            autoComplete="off"
          />
        </Form.Group>
        <Button variant="info" type="submit">
          Search
        </Button>
      </Form>
    </div>
  );
};
export default SearchForm;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们添加了一个搜索框,并根据输入值更新组件的状态。

SearchResult.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
import _ from 'lodash';
import AlbumsList from './AlbumsList';
const SearchResult = (props) => {
  const { result, setCategory, selectedCategory } = props;
  const { albums, artists, playlist } = result;
  return (
    <React.Fragment>
      <div className="search-buttons">
        {!_.isEmpty(albums.items) && (
          <button
            className={`${
              selectedCategory === 'albums' ? 'btn active' : 'btn'
            }`}
            onClick={() => setCategory('albums')}
          >
            Albums
          </button>
        )}
        {!_.isEmpty(artists.items) && (
          <button
            className={`${
              selectedCategory === 'artists' ? 'btn active' : 'btn'
            }`}
            onClick={() => setCategory('artists')}
          >
            Artists
          </button>
        )}
        {!_.isEmpty(playlist.items) && (
          <button
            className={`${
              selectedCategory === 'playlist' ? 'btn active' : 'btn'
            }`}
            onClick={() => setCategory('playlist')}
          >
            PlayLists
          </button>
        )}
      </div>
      <div className={`${selectedCategory === 'albums' ? '' : 'hide'}`}>
        {albums && <AlbumsList albums={albums} />}
      </div>
    </React.Fragment>
  );
};
export default SearchResult;
Enter fullscreen mode Exit fullscreen mode

在文件夹内从此处images添加图像并命名为music.jpeg

如果任何专辑、艺术家或播放列表的图像不存在,我们将使用此图像作为默认图像。

AlbumsList.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
import { Card } from 'react-bootstrap';
import _ from 'lodash';
import music from '../images/music.jpeg';
const AlbumsList = ({ albums }) => {
  return (
    <React.Fragment>
      {Object.keys(albums).length > 0 && (
        <div className="albums">
          {albums.items.map((album, index) => {
            return (
              <React.Fragment key={index}>
                <Card style={{ width: '18rem' }}>
                  <a
                    target="_blank"
                    href={album.external_urls.spotify}
                    rel="noopener noreferrer"
                    className="card-image-link"
                  >
                    {!_.isEmpty(album.images) ? (
                      <Card.Img
                        variant="top"
                        src={album.images[0].url}
                        alt=""
                      />
                    ) : (
                      <img src={music} alt="" />
                    )}
                  </a>
                  <Card.Body>
                    <Card.Title>{album.name}</Card.Title>
                    <Card.Text>
                      <small>
                        {album.artists.map((artist) => artist.name).join(', ')}
                      </small>
                    </Card.Text>
                  </Card.Body>
                </Card>
              </React.Fragment>
            );
          })}
        </div>
      )}
    </React.Fragment>
  );
};
export default AlbumsList;
Enter fullscreen mode Exit fullscreen mode

yarn start现在,通过运行命令启动应用程序

API 响应

如您所见,当我们搜索任何内容时,Spotify API 的响应都会显示在控制台中。因此,我们能够成功从 Spotify 访问音乐数据。

在用户界面上显示专辑

现在,我们将在 redux 存储中添加响应,以便我们可以在 UI 上显示它。

打开src/reducers/albums.js文件并将其替换为以下内容:

import { SET_ALBUMS, ADD_ALBUMS } from '../utils/constants';
const albumsReducer = (state = {}, action) => {
  const { albums } = action;
  switch (action.type) {
    case SET_ALBUMS:
      return albums;
    case ADD_ALBUMS:
      return {
        ...state,
        next: albums.next,
        items: [...state.items, ...albums.items]
      };
    default:
      return state;
  }
};
export default albumsReducer;
Enter fullscreen mode Exit fullscreen mode

现在,yarn start再次运行该命令并检查应用程序

专辑

如您所见,当我们搜索时,redux store 会更新,并将结果显示在 UI 上。让我们了解一下此功能的代码。

Dashboard.js文件中,我们调用用户点击搜索按钮时触发的initiateGetResult内部函数。handleSearch

如果您检查文件initiateGetResult中的函数,我们会通过将搜索文本作为查询参数传递actions/result.js到 URL 来发出 API 调用https://api.spotify.com/v1/search

export const initiateGetResult = (searchTerm) => {
  return async (dispatch) => {
    try {
      const API_URL = `https://api.spotify.com/v1/search?query=${encodeURIComponent(
        searchTerm
      )}&type=album,playlist,artist`;
      const result = await get(API_URL);
      console.log(result);
      const { albums, artists, playlists } = result;
      dispatch(setAlbums(albums));
      dispatch(setArtists(artists));
      return dispatch(setPlayList(playlists));
    } catch (error) {
      console.log('error', error);
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

一旦我们得到结果,我们就会setAlbums通过从结果中获取专辑来调用动作生成器函数。

dispatch(setAlbums(albums));
Enter fullscreen mode Exit fullscreen mode

setAlbums函数如下所示:

export const setAlbums = (albums) => ({
  type: SET_ALBUMS,
  albums
});
Enter fullscreen mode Exit fullscreen mode

在这里,我们返回类型为 的操作SET_ALBUMS。因此,一旦操作被调度,albumsReducerfromreducers/albums.js文件就会被调用,对于匹配的SET_ALBUMSswitch 案例,我们会从 reducer 返回传递的专辑,以便 redux 存储将使用专辑数据进行更新。

case SET_ALBUMS:
      return albums;
Enter fullscreen mode Exit fullscreen mode

由于我们已经使用方法将Dashboard组件(Dashboard.js)连接到 redux 存储connect,因此组件使用该mapStateToProps方法获取更新的 redux 存储数据,并将结果传递给SearchResult组件

const { albums, artists, playlist } = props;
const result = { albums, artists, playlist };
<SearchResult
  result={result}
  setCategory={setCategory}
  selectedCategory={selectedCategory}
/>
Enter fullscreen mode Exit fullscreen mode

SearchResult组件中,数据作为 prop 传递给AlbumsList组件

<div className={`${selectedCategory === 'albums' ? '' : 'hide'}`}>
  {albums && <AlbumsList albums={albums} />}
</div>
Enter fullscreen mode Exit fullscreen mode

在组件内部AlbumsList,我们使用 Array 方法遍历每张专辑map并在 UI 上显示数据。

在用户界面上显示艺术家和播放列表

ArtistsList.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
import { Card } from 'react-bootstrap';
import _ from 'lodash';
import music from '../images/music.jpeg';
const ArtistsList = ({ artists }) => {
  return (
    <React.Fragment>
      {Object.keys(artists).length > 0 && (
        <div className="artists">
          {artists.items.map((artist, index) => {
            return (
              <React.Fragment key={index}>
                <Card style={{ width: '18rem' }}>
                  <a
                    target="_blank"
                    href={artist.external_urls.spotify}
                    rel="noopener noreferrer"
                    className="card-image-link"
                  >
                    {!_.isEmpty(artist.images) ? (
                      <Card.Img
                        variant="top"
                        src={artist.images[0].url}
                        alt=""
                      />
                    ) : (
                      <img src={music} alt="" />
                    )}
                  </a>
                  <Card.Body>
                    <Card.Title>{artist.name}</Card.Title>
                  </Card.Body>
                </Card>
              </React.Fragment>
            );
          })}
        </div>
      )}
    </React.Fragment>
  );
};
export default ArtistsList;
Enter fullscreen mode Exit fullscreen mode

PlayList.js在文件夹内创建一个新文件components,内容如下:

import React from 'react';
import { Card } from 'react-bootstrap';
import _ from 'lodash';
import music from '../images/music.jpeg';
const PlayList = ({ playlist }) => {
  return (
    <div>
      {Object.keys(playlist).length > 0 && (
        <div className="playlist">
          {playlist.items.map((item, index) => {
            return (
              <React.Fragment key={index}>
                <Card style={{ width: '18rem' }}>
                  <a
                    target="_blank"
                    href={item.external_urls.spotify}
                    rel="noopener noreferrer"
                    className="card-image-link"
                  >
                    {!_.isEmpty(item.images) ? (
                      <Card.Img variant="top" src={item.images[0].url} alt="" />
                    ) : (
                      <img src={music} alt="" />
                    )}
                  </a>
                  <Card.Body>
                    <Card.Title>{item.name}</Card.Title>
                    <Card.Text>
                      <small>By {item.owner.display_name}</small>
                    </Card.Text>
                  </Card.Body>
                </Card>
              </React.Fragment>
            );
          })}
        </div>
      )}
    </div>
  );
};
export default PlayList;
Enter fullscreen mode Exit fullscreen mode

现在,打开SearchResult.js文件并在旁边AlbumsList添加ArtistsListPlayList组件

<div className={`${selectedCategory === 'albums' ? '' : 'hide'}`}>
  {albums && <AlbumsList albums={albums} />}
</div>
<div className={`${selectedCategory === 'artists' ? '' : 'hide'}`}>
  {artists && <ArtistsList artists={artists} />}
</div>
<div className={`${selectedCategory === 'playlist' ? '' : 'hide'}`}>
  {playlist && <PlayList playlist={playlist} />}
</div>
Enter fullscreen mode Exit fullscreen mode

另外,导入文件顶部的组件

import ArtistsList from './ArtistsList';
import PlayList from './PlayList';
Enter fullscreen mode Exit fullscreen mode

打开src/reducers/artists.js文件并将其替换为以下内容:

import { SET_ARTISTS, ADD_ARTISTS } from '../utils/constants';
const artistsReducer = (state = {}, action) => {
  const { artists } = action;
  switch (action.type) {
    case SET_ARTISTS:
      return artists;
    case ADD_ARTISTS:
      return {
        ...state,
        next: artists.next,
        items: [...state.items, ...artists.items]
      };
    default:
      return state;
  }
};
export default artistsReducer;
Enter fullscreen mode Exit fullscreen mode

打开src/reducers/playlist.js文件并将其替换为以下内容:

import { SET_PLAYLIST, ADD_PLAYLIST } from '../utils/constants';
const playlistReducer = (state = {}, action) => {
  const { playlists } = action;
  switch (action.type) {
    case SET_PLAYLIST:
      return playlists;
    case ADD_PLAYLIST:
      return {
        ...state,
        next: playlists.next,
        items: [...state.items, ...playlists.items]
      };
    default:
      return state;
  }
};
export default playlistReducer;
Enter fullscreen mode Exit fullscreen mode

现在,yarn start再次运行该命令并检查应用程序

填充数据

如您所见,艺术家和播放列表也填充了数据。

奏乐

此外,如果您单击任意图像,您可以播放如上所示的专辑、艺术家或播放列表中的音乐。

添加加载更多功能

现在,让我们添加一个加载更多按钮来加载更多专辑、艺术家和播放列表的数据。

打开文件并在结束标签SearchResult.js之前添加加载更多按钮</React.Fragment>

{!_.isEmpty(result[selectedCategory]) &&
 !_.isEmpty(result[selectedCategory].next) && (
  <div className="load-more" onClick={() => loadMore(selectedCategory)}>
    <Button variant="info" type="button">
      Load More
    </Button>
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

从 props 中解构loadMore函数并Button导入react-bootstrap

import { Button } from 'react-bootstrap';
const SearchResult = (props) => {
const { loadMore, result, setCategory, selectedCategory } = props;
Enter fullscreen mode Exit fullscreen mode

打开Dashboard.js文件并添加loadMore函数

const loadMore = async (type) => {
  const { dispatch, albums, artists, playlist } = props;
  setIsLoading(true);
  switch (type) {
    case 'albums':
      await dispatch(initiateLoadMoreAlbums(albums.next));
      break;
    case 'artists':
      await dispatch(initiateLoadMoreArtists(artists.next));
      break;
    case 'playlist':
      await dispatch(initiateLoadMorePlaylist(playlist.next));
      break;
    default:
  }
  setIsLoading(false);
};
Enter fullscreen mode Exit fullscreen mode

并将loadMore函数作为 prop 传递给SearchResult组件

return (
  <React.Fragment>
    <Header />
    <SearchForm handleSearch={handleSearch} />
    <Loader show={isLoading}>Loading...</Loader>
    <SearchResult
      result={result}
      loadMore={loadMore}
      setCategory={setCategory}
      selectedCategory={selectedCategory}
    />
  </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

打开actions/result.js文件并在文件末尾添加以下函数

export const initiateLoadMoreAlbums = (url) => {
  return async (dispatch) => {
    try {
      console.log('url', url);
      const result = await get(url);
      console.log('categoriess', result);
      return dispatch(addAlbums(result.albums));
    } catch (error) {
      console.log('error', error);
    }
  };
};
export const initiateLoadMoreArtists = (url) => {
  return async (dispatch) => {
    try {
      console.log('url', url);
      const result = await get(url);
      console.log('categoriess', result);
      return dispatch(addArtists(result.artists));
    } catch (error) {
      console.log('error', error);
    }
  };
};
export const initiateLoadMorePlaylist = (url) => {
  return async (dispatch) => {
    try {
      console.log('url', url);
      const result = await get(url);
      console.log('categoriess', result);
      return dispatch(addPlaylist(result.playlists));
    } catch (error) {
      console.log('error', error);
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Dashboard.js并在顶部的文件中导入这些函数

import {
  initiateGetResult,
  initiateLoadMoreAlbums,
  initiateLoadMorePlaylist,
  initiateLoadMoreArtists
} from '../actions/result';
Enter fullscreen mode Exit fullscreen mode

现在,运行yarn start命令并检查加载更多功能

加载更多

您可以在此分支中找到截至此处的代码


会话超时时重定向到登录页面

现在,我们已经完成了应用程序的功能。让我们添加代码,以便在访问令牌过期时自动重定向到登录页面并显示会话已过期的消息。这是因为,如果会话已过期,则 API 调用将失败,但用户直到打开 devtool 控制台查看错误信息时才会发现。

如果你还记得的话RedirectPage.js,我们在文件中添加了expiry_time以下代码到本地存储中

const expiryTime = new Date().getTime() + access_token.expires_in * 1000;
localStorage.setItem('expiry_time', expiryTime);
Enter fullscreen mode Exit fullscreen mode

现在,让我们用它来识别何时重定向到登录页面。

打开AppRouter.js文件并将其替换为以下内容:

import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Home from '../components/Home';
import RedirectPage from '../components/RedirectPage';
import Dashboard from '../components/Dashboard';
import NotFoundPage from '../components/NotFoundPage';
class AppRouter extends React.Component {
  state = {
    expiryTime: '0'
  };
  componentDidMount() {
    let expiryTime;
    try {
      expiryTime = JSON.parse(localStorage.getItem('expiry_time'));
    } catch (error) {
      expiryTime = '0';
    }
    this.setState({ expiryTime });
  }
  setExpiryTime = (expiryTime) => {
    this.setState({ expiryTime });
  };
  isValidSession = () => {
    const currentTime = new Date().getTime();
    const expiryTime = this.state.expiryTime;
    const isSessionValid = currentTime < expiryTime;

    return isSessionValid;
  };
  render() {
    return (
      <BrowserRouter>
        <div className="main">
          <Switch>
            <Route path="/" component={Home} exact={true} />
            <Route path="/redirect" component={RedirectPage} />
            <Route path="/dashboard" component={Dashboard} />
            <Route component={NotFoundPage} />
          </Switch>
        </div>
      </BrowserRouter>
    );
  }
}
export default AppRouter;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们添加了一个expiryTime默认初始化的状态变量0,并且在componentDidMount方法中,我们expiry_time从本地存储中读取值并将其分配给状态。

我们还添加了setExpiryTimeisValidSession函数,以便我们可以在其他组件中使用它。

现在,打开RedirectPage.js文件并在调用之前history.push('/dashboard');添加以下代码行

setExpiryTime(expiryTime);
Enter fullscreen mode Exit fullscreen mode

但是要调用这个函数,我们需要将其作为 prop 传递给RedirectPage组件。

如果你检查组件的渲染方法AppRouter,它看起来像这样:

render() {
  return (
    <BrowserRouter>
      <div className="main">
        <Switch>
          <Route path="/" component={Home} exact={true} />
          <Route path="/redirect" component={RedirectPage} />
          <Route path="/dashboard" component={Dashboard} />
          <Route component={NotFoundPage} />
        </Switch>
      </div>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

因此,要将setExpiryTime函数作为 prop 传递给RedirectPage组件,我们需要将其转换为渲染 prop 模式。

因此,将下面的代码行

<Route path="/redirect" component={RedirectPage} />
Enter fullscreen mode Exit fullscreen mode

到此代码:

<Route
  path="/redirect"
  render={(props) => (
    <RedirectPage
      isValidSession={this.isValidSession}
      setExpiryTime={this.setExpiryTime}
      {...props}
    />
  )}
/>
Enter fullscreen mode Exit fullscreen mode

在这里,我们将setExpiryTime,isValidSession函数作为 prop 传递,同时还将自动传递给 Route 的 props 分散开来,比如location, history

现在,打开Dashboard.js文件并解构道具并将handleSearch功能更改为:

const { isValidSession, history } = props;
const handleSearch = (searchTerm) => {
  if (isValidSession()) {
    setIsLoading(true);
    props.dispatch(initiateGetResult(searchTerm)).then(() => {
      setIsLoading(false);
      setSelectedCategory('albums');
    });
  } else {
    history.push({
      pathname: '/',
      state: {
        session_expired: true
      }
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

另外,将loadMore函数更改为:

const loadMore = async (type) => {
  if (isValidSession()) {
    const { dispatch, albums, artists, playlist } = props;
    setIsLoading(true);
    switch (type) {
      case 'albums':
        await dispatch(initiateLoadMoreAlbums(albums.next));
        break;
      case 'artists':
        await dispatch(initiateLoadMoreArtists(artists.next));
        break;
      case 'playlist':
        await dispatch(initiateLoadMorePlaylist(playlist.next));
        break;
      default:
    }
    setIsLoading(false);
  } else {
    history.push({
      pathname: '/',
      state: {
        session_expired: true
      }
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

将组件返回的 JSX 更改Dashboard为:

return (
  <React.Fragment>
    {isValidSession() ? (
      <div>
        <Header />
        <SearchForm handleSearch={handleSearch} />
        <Loader show={isLoading}>Loading...</Loader>
        <SearchResult
          result={result}
          loadMore={loadMore}
          setCategory={setCategory}
          selectedCategory={selectedCategory}
          isValidSession={isValidSession}
        />
      </div>
    ) : (
      <Redirect
        to={{
          pathname: '/',
          state: {
            session_expired: true
          }
        }}
      />
    )}
  </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

另外,Redirect在顶部导入组件:

import { Redirect } from 'react-router-dom';
Enter fullscreen mode Exit fullscreen mode

打开SearchResult.js文件并在返回 JSX 之前添加以下代码:

if (!isValidSession()) {
  return (
    <Redirect
      to={{
        pathname: '/',
        state: {
          session_expired: true
        }
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

另外,解构isValidSession来自的道具并Redirect从中添加组件react-router-dom

现在,打开该Home.js文件并将其替换为以下内容:

import React from 'react';
import { Alert } from 'react-bootstrap';
import { connect } from 'react-redux';
import { Button } from 'react-bootstrap';
import Header from './Header';
import { Redirect } from 'react-router-dom';
const Home = (props) => {
  const {
    REACT_APP_CLIENT_ID,
    REACT_APP_AUTHORIZE_URL,
    REACT_APP_REDIRECT_URL
  } = process.env;
  const handleLogin = () => {
    window.location = `${REACT_APP_AUTHORIZE_URL}?client_id=${REACT_APP_CLIENT_ID}&redirect_uri=${REACT_APP_REDIRECT_URL}&response_type=token&show_dialog=true`;
  };
  const { isValidSession, location } = props;
  const { state } = location;
  const sessionExpired = state && state.session_expired;

  return (
    <React.Fragment>
      {isValidSession() ? (
        <Redirect to="/dashboard" />
      ) : (
        <div className="login">
          <Header />
          {sessionExpired && (
            <Alert variant="info">Session expired. Please login again.</Alert>
          )}
          <Button variant="info" type="submit" onClick={handleLogin}>
            Login to spotify
          </Button>
        </div>
      )}
    </React.Fragment>
  );
};
export default connect()(Home);
Enter fullscreen mode Exit fullscreen mode

这里,我们编写了代码,/dashboard如果会话有效则重定向到页面,否则重定向到登录页面。同时显示会话已过期的消息,以便用户了解页面重定向到登录页面的原因。

{sessionExpired && (
  <Alert variant="info">Session expired. Please login again.</Alert>
)}
Enter fullscreen mode Exit fullscreen mode

现在,打开AppRouter.js文件并将isValidSession函数传递给HomeDashboard路由。

render() {
  return (
    <BrowserRouter>
      <div className="main">
        <Switch>
          <Route
            path="/"
            exact={true}
            render={(props) => (
              <Home isValidSession={this.isValidSession} {...props} />
            )}
          />
          <Route
            path="/redirect"
            render={(props) => (
              <RedirectPage
                isValidSession={this.isValidSession}
                setExpiryTime={this.setExpiryTime}
                {...props}
              />
            )}
          />
          <Route
            path="/dashboard"
            render={(props) => (
              <Dashboard isValidSession={this.isValidSession} {...props} />
            )}
          />
          <Route component={NotFoundPage} />
        </Switch>
      </div>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

会话超时后,您将看到以下屏幕。

会话已过期

您可以在此分支中找到截至此处的代码

结论

现在,您已完成使用 React 创建 Spotify 音乐搜索应用。您可以在此处找到此应用的完整源代码。

不要忘记订阅我的每周新闻通讯,其中包含精彩的提示、技巧和文章,直接发送到您的收件箱

文章来源:https://dev.to/myogeshchavan97/how-to-create-a-spotify-music-search-app-in-react-328m
PREV
轻松破解你的下一次 JavaScript/React 面试的资源列表
NEXT
通过构建这些出色的应用程序成为一名全栈开发人员