React全家桶構建一款Web音樂App實戰(九):皮膚切換

code_mcx發表於2018-02-07

這一節是這款React Web音樂App實戰的最後一節:皮膚切換功能。皮膚切換是Web音樂App中一個與核心無關的功能,加入這個功能可以為應用增添不少趣味性

實現思路

實現皮膚切換功能的大致原理就是將樣式提取出來作為一份單獨的樣式,給需要做皮膚切換的dom元素新增上這些樣式,切換樣式的時候替換指定樣式屬性值,再動態插入到DOM中,使CSS樣式生效

準備

在做這個功能前,需要新加入一些小圖示,新增的小圖示在下圖紅色方框中標出

React全家桶構建一款Web音樂App實戰(九):皮膚切換

在第一節已經制作了一份字型圖示檔案和樣式,這次加入新的圖示需要使用svg圖片重新來製作新的字型圖示檔案和樣式,和第二節一樣使用icomoon這個網站來製作,詳細步驟見第一節字型圖示製作

字型圖示製作完成後會有一份icomoon.zip包,解壓後將裡面fonts目錄下的4個檔案重新命名為icomusic,然後進入到專案中將src/assets/stylus/fonts目錄下面的4個檔案替換成剛剛重新命名的4個字型圖示檔案。回到src/assets/stylus下面的font.styl中,開啟解壓後的目錄中的style.css,複製裡面的.icon-開頭的所有樣式,替換掉font.styl中.icon-開頭的樣式

提取樣式

在做皮膚切換功能前先將需要切換的樣式提取出來。這裡因為是所有功能做完之後再做皮膚切換功能,所以提取樣式是非常繁瑣的過程。以下是列舉出所有需要提取樣式的地方

檔案位置 樣式名稱
app.styl .app,.app-header,.music-tab,.active
recommend/recommend.styl .title,.album-wrapper,.album-name
album/album.styl .music-album,.album-wrapper,.song-name,.song-singer,.album-title
ranking/ranking.styl .ranking-wrapper,.ranking-title,.index,.singer
ranking/rankinginfo.styl .ranking-info,.ranking-wrapper,.song-name,.song-singer,.ranking-title
singer/singer.styl .music-singer,.singer-wrapper,.song-name,.song-singer
singer/singerlist.styl .nav,a.choose,.singer-name
search/search.styl .search-box,.search-input,.title,.hot-item,.album-wrapper .song,.singer,.song-wrapper .song
play/miniplayer.styl .mini-player,.player-img,.singer,.player-right,.filter:after

將以上樣式中的colorbackground-color屬性註釋掉

實現皮膚切換的做法有很多種。可能你見過將不同的樣式寫在一份css檔案裡面,多少種皮膚就有多少份css檔案,使用某種皮膚的時候將其引入,這種做法重複定義的屬性太多,大量的css屬性冗餘,複用性太差。還有可能你見過寫一份樣式檔案,樣式屬性的值使用指定字元佔位,需要切換皮膚的時候請求這個檔案,拿到樣式文字後,替換掉佔位的字元為實際的屬性值,然後插入到HTML DOM中,這種做法每次切換皮膚的時候都要傳送一次請求

這裡將利用字串模板返回一個樣式文字字串,樣式屬性值使用物件的屬性佔位,將css屬性值定義成物件屬性值,在切換皮膚的時候傳入指定的物件。在util目錄下新建skin.js,先定義用來儲存樣式屬性值的物件skin,和一個返回樣式文字的方法getSkinStyle

const skin = {};

skin.coolBlack = {
    appColor: "#DDDDDD",
    appBgColor: "#212121",
    /* 首頁header */
    appHeaderColor: "#FFD700",
    appHeaderBgColor: "transparent",
    /* 首頁tab */
    tabColor: "#DDDDDD",
    tabBgColor: "transparent",
    /* 最新專輯 */
    albumColor: "rgba(221, 221, 221, 0.7)",
    albumNameColor: "#FFFFFF",
    /* 排行榜 */
    rankingWrapperBgColor: "#333333",
    rankingSingerColor: "rgba(221, 221, 221, 0.7)",
    /* 搜尋 */
    searchBgColor: "#212121",
    searchBoxBgColor: "#333333",
    searchBoxWrapperBgColor: "#212121",
    searchTitleColor: "#FFD700",
    searchHotColor: "#DDDDDD",
    searchHotBorderColor: "transparent",
    searchResultBorderColor: "transparent",
    /* 詳情 */
    detailBgColor: "#212121",
    detailSongColor: "#FFFFFF",
    detailSingerColor: "rgba(221, 221, 221, 0.7)",
    /* mini播放器 */
    miniPlayerBgColor: "#333333",
    miniImgBorderColor: "rgba(221, 221, 221, 0.3)",
    miniProgressBarBgColor: "rgba(0, 0, 0, 0.3)",
    miniRightColor: "#FFD700",
    miniSongColor: "#FFFFFF",
    activeColor: "#FFD700"
};

let getSkinStyle = (skin) => {
    if (!skin) {
        return "";
    }
    return `
    .skin-app {
      color: ${skin.appColor};
      background-color: ${skin.appBgColor};
    }
    .skin-app-header {
      color: ${skin.appHeaderColor};
      background-color: ${skin.appHeaderBgColor};
    }
    .skin-music-tab {
      color: ${skin.tabColor};
      background-color: ${skin.tabBgColor};
    }
    .skin-recommend-title {
      color: ${skin.activeColor};
    }
    .skin-album-wrapper {
      color: ${skin.albumColor};
    }
    .skin-album-wrapper .album-name {
      color: ${skin.albumNameColor}
    }
    .skin-ranking-wrapper {
      background-color: ${skin.rankingWrapperBgColor};
    }
    .skin-ranking-wrapper .ranking-title {
      color: ${skin.albumNameColor};
    }
    .skin-ranking-wrapper .singer {
      color: ${skin.rankingSingerColor};
    }
    .skin-music-singers .choose {
      color: ${skin.activeColor} !important;
      border: 1px solid ${skin.activeColor} !important;
    }
    .skin-search {
      background-color: ${skin.searchBgColor};
    }
    .skin-search .title {
      color: ${skin.searchTitleColor};
    }
    .skin-search .hot-item {
      border: 1px solid ${skin.searchHotBorderColor};
      color: ${skin.searchHotColor};
      background-color: ${skin.searchBoxBgColor};
    }
    .skin-search-box {
      background-color: ${skin.searchBoxBgColor};
    }
    .skin-search-box input {
      color: ${skin.appColor};
    }
    .skin-search-box-wrapper {
      background-color: ${skin.searchBoxWrapperBgColor};
    }
    .skin-search-result .singer {
      color: ${skin.albumColor};
    }
    .skin-search-result .singer-wrapper .singer {
      color: ${skin.appColor};
    }
    .skin-search-result .singer-wrapper .info {
      color: ${skin.albumColor};
    }
    .skin-detail-wrapper {
      background-color: ${skin.detailBgColor};
    }
    .skin-detail-wrapper .song-name {
      color: ${skin.detailSongColor};
    }
    .skin-detail-wrapper .song-singer {
      color: ${skin.detailSingerColor};
    }
    .skin-mini-player {
      background-color: ${skin.miniPlayerBgColor};
    }
    .skin-mini-player .player-img {
      border: 2px solid ${skin.miniImgBorderColor};
    }
    .skin-mini-player .progress-bar {
      background-color: ${skin.miniProgressBarBgColor} !important;
    }
    .skin-mini-player .progress {
      background-color: ${skin.miniRightColor} !important;
    }
    .skin-mini-player .player-right {
      color: ${skin.miniRightColor};
    }
    .skin-mini-player .song {
      color: ${skin.miniSongColor};
    }
    .skin-mini-player .singer {
      color: ${skin.detailSingerColor};
    }
    .music-album, .ranking-info, .music-singer {
      background-color: ${skin.detailBgColor};
    }
    .nav-link.active {
      color: ${skin.activeColor} !important;
      border-bottom: 2px solid ${skin.activeColor};
    }
  `;
};
複製程式碼

skin.coolBlack中的顏色值是從上述表格中的樣式中提取出來的

編寫一個把樣式插入到HTML DOM中的方法setSkinStyle

let setSkinStyle = (skin) => {
    let styleText = getSkinStyle(skin);
    let oldStyle = document.getElementById("skin");
    const style = document.createElement("style");
    style.id = "skin";
    style.type = "text/css";
    style.innerHTML = styleText;
    oldStyle ? document.head.replaceChild(style, oldStyle) : document.head.appendChild(style);
};
複製程式碼

在skin.js中呼叫setSkinStyle並傳入skin.coolBlack

// 設定皮膚
setSkinStyle(skin.coolBlack);
複製程式碼

最後匯出skin物件和setSkinStyle,後續使用

export {skin, setSkinStyle}
複製程式碼

在程式執行的時候需要把這些樣式插入到HTML DOM中,所以在Root.js中匯入skin.js

import "../util/skin"
複製程式碼

接下來把提取出來的樣式新增到各個元件中的標籤上,下表列出來樣式新增的位置

元件位置 元素 需要新增的樣式
App.js div.app
div.app-header
div.music-tab
.skin-app
.skin-app-header
.skin-music-tab
recommend/Recommend.js div.album-wrapper
h1.title
.skin-album-wrapper
album/Album.js div.album-wrapper .skin-detail-wrapper
ranking/Ranking.js div.ranking-wrapper .skin-ranking-wrapper
ranking/RankingInfo.js div.ranking-wrapper .skin-detail-wrapper
singer/SingerList.js div.music-singers .skin-music-singers
singer/Singer.js div.singer-wrapper .skin-detail-wrapper
search/Search.js div.music-search
div.search-box-wrapper
div.search-box
div.search-result
.skin-search
.skin-search-box-wrapper
.skin-search-box
.skin-search-result
play/MiniPlayer.js div.mini-player

應用上以上樣式後,如果沒有問題整體外觀和之前會相差無幾

自定義皮膚

除了以上的預設皮膚外,再定義幾種樣式。先定義一個芒果顏色做皮膚色,另外再使用酷狗、網易、QQ音樂三大音樂播放器的主色做皮膚色,接下來擴充套件skin物件,在skin.js中加入以下程式碼

  1. 芒果黃
skin.mangoYellow = {
    appColor: "#333333",
    appBgColor: "#F8F8FF",
    appHeaderColor: "#FFFFF0",
    appHeaderBgColor: "#FFA500",
    tabColor: "rgba(0, 0, 0, .7)",
    tabBgColor: "#FFFFFF",
    albumColor: "rgba(0, 0, 0, 0.6)",
    albumNameColor: "#333333",
    rankingWrapperBgColor: "#FFFFFF",
    rankingSingerColor: "rgba(0, 0, 0, 0.5)",
    searchBgColor: "#FFFFFF",
    searchBoxBgColor: "#FFFFFF",
    searchBoxWrapperBgColor: "#F8F8FF",
    searchTitleColor: "rgba(0, 0, 0, .7)",
    searchHotColor: "#000000",
    searchHotBorderColor: "rgba(0, 0, 0, .7)",
    searchResultBorderColor: "#E5E5E5",
    detailBgColor: "#F8F8FF",
    detailSongColor: "#000000",
    detailSingerColor: "rgba(0, 0, 0, 0.6)",
    miniPlayerBgColor: "#FFFFFF",
    miniImgBorderColor: "#EEEEEE",
    miniProgressBarBgColor: "rgba(0, 0, 0, 0.1)",
    miniRightColor: "#FFD700",
    miniSongColor: "#333333",
    activeColor: "#FFA500"
};
複製程式碼
  1. 酷狗藍
skin.kuGouBlue = Object.assign({}, skin.mangoYellow, {
  appHeaderBgColor: "#2CA2F9",
  activeColor: "#2CA2F9",
  searchTitleColor: "#2CA2F9",
  miniRightColor: "#2CA2F9"
});
複製程式碼
  1. 網易紅
skin.netBaseRed = Object.assign({}, skin.mangoYellow, {
  appHeaderBgColor: "#D43C33",
  activeColor: "#D43C33",
  searchTitleColor: "#D43C33",
  miniRightColor: "#D43C33"
});
複製程式碼
  1. QQ綠
skin.qqGreen = Object.assign({}, skin.mangoYellow, {
  appHeaderBgColor: "#31C27C",
  activeColor: "#31C27C",
  searchTitleColor: "#31C27C",
  miniRightColor: "#31C27C"
});
複製程式碼

以上物件中,後三個繼承自mangoYellow物件,然後將不同屬性值覆蓋,可以很好的減少了相同樣式的冗餘

皮膚切換實現

為了實現皮膚切換,需要編寫一個皮膚中心元件,皮膚中心從App元件中的選單列表進入

在App.js中新增constructor,並初始化控制顯示選單的state屬性menuShow

constructor(props) {
    super(props);

    this.state = {
        menuShow: false
    };
  }
複製程式碼

header.app-header元素中新增圖示i.icon-et-more,並新增點選事件處理,點選後將menuShow設定為true

<header className="app-header skin-app-header">
  <i className="icon-et-more app-more" onClick={() => {this.setState({menuShow: true});}}></i>
  <img src={logo} className="app-logo" alt="logo" />
  <h1 className="app-title">Mango Music</h1>
</header>
複製程式碼

樣式如下

App.styl

.app-header
    height: 55px
    line-height: 55px
    /*color: #FFD700*/
    text-align: center
    position: relative
    .app-more
      position: absolute
      top: 15px
      left: 15px
      font-size: 20px
複製程式碼

在components目錄下新建setting目錄,然後新建Menu.jsmenu.styl

Menu.js

import React from "react"
import {CSSTransition} from "react-transition-group"

import "./menu.styl"

class Menu extends React.Component {
    constructor(props) {
        super(props);
    }
    close = () => {
        this.props.closeMenu();
    }
    render() {
        return (
            <div>
                <CSSTransition in={this.props.show} timeout={300} classNames="fade"
                       onEnter={() => {
                           this.refs.bottom.style.display = "block";
                       }}
                       onExited={() => {
                           this.refs.bottom.style.display = "none";
                       }}>
                    <div className="bottom-container" onClick={this.close}  ref="bottom">
                        <div className="bottom-wrapper">
                            <div className="item">
                                皮膚中心
                            </div>
                            <div className="item-close" onClick={this.close}>
                                關閉
                            </div>
                        </div>
                    </div>
                </CSSTransition>
            </div>
        );
    }
}

export default Menu
複製程式碼

menu.styl請在原始碼中檢視

在App.js中匯入Menu元件

import MusicMenu from "./setting/Menu"
複製程式碼

放置在如下位置,並傳遞showcloseMenu兩個props,其中show用來控制Menu元件的顯示和隱藏動畫,closeMenu傳遞給Menu,當點選取消或背景遮罩時關閉自身

<Router>
  <div className="app skin-app">
    ...
    <MusicPlayer/>
    <MusicMenu show={this.state.menuShow}
               closeMenu={() => {this.setState({menuShow: false});}} />
  </div>
</Router>
複製程式碼

我們把當前皮膚的key值交給Redux,在Skin元件中列出所有的皮膚,將Redux中儲存的key對應的皮膚打上對鉤的標記,點選單個皮膚可以設定當前皮膚。給皮膚新增Redux屬性skin,actionType,action和reducer

actionTypes.js

export const SET_SKIN = "SET_SKIN";
複製程式碼

actions.js

export function setSkin(skin) {
	return {type:ActionTypes.SET_SKIN, skin};
}
複製程式碼

reducers.js

const initialState = {
    skin: "coolBlack",
    ...
};

//設定皮膚
function skin(skin = initialState.skin, action) {
	switch (action.type) {
		case ActionTypes.SET_SKIN:
			return action.skin;
		default:
			return skin;
	}
}

...

const reducer = combineReducers({
    skin,
    ...
});
複製程式碼

在setting目錄下新建Skin.js和skin.styl

import React from "react"
import {CSSTransition} from "react-transition-group"

import "./skin.styl"

class Skin extends React.Component {
    constructor(props) {
        super(props);
        this.skins = [
            {key: "mangoYellow", name: "芒果黃", color: "#FFD700"},
            {key: "coolBlack", name: "炫酷黑", color: "#212121"},
            {key: "kuGouBlue", name: "酷狗藍", color: "#2CA2F9"},
            {key: "netBaseRed", name: "網易紅", color: "#D43C33"},
            {key: "qqGreen", name: "QQ綠", color: "#31C27C"}
        ]
    }
    render() {
        return (
            <CSSTransition in={this.props.show} timeout={300} classNames="pop"
                           onEnter={() => {
                               this.refs.skin.style.display = "block";
                           }}
                           onExited={() => {
                               this.refs.skin.style.display = "none";
                           }}>
                <div className="music-skin" ref="skin">
                    <div className="header">
                        皮膚中心
                        <span className="cancel" onClick={() => {this.props.close();}}>取消</span>
                    </div>
                    <div className="skin-title">推薦皮膚</div>
                    <div className="skin-container">
                        {
                            this.skins.map(skin => (
                                <div className="skin-wrapper" key={skin.key}>
                                    <div className="skin-color" style={{backgroundColor: skin.color, boxShadow: `0 0 3px ${skin.color}`}}>
                                        <i className="icon-right" style={{display: skin.key === this.props.currentSkin ? "" : "none"}}></i>
                                    </div>
                                    <div>{skin.name}</div>
                                </div>
                            ))
                        }
                    </div>
                </div>
            </CSSTransition>
        );
    }
}

export default Skin
複製程式碼

skin.styl程式碼請在原始碼中檢視

上訴程式碼在constructor中定義5中皮膚物件,用key屬性標識某一個皮膚,這個key值對應到Redux中的skin,name和color對應皮膚名稱和皮膚主色。在containers目錄下新建Skin.js將Skin包裝成容器元件

import {connect} from "react-redux"
import {setSkin} from "../redux/actions"
import Skin from "../components/setting/Skin"

const mapStateToProps = (state) => ({
    currentSkin: state.skin
});

const mapDispatchToProps = (dispatch) => ({
    setSkin: (skin) => {
        dispatch(setSkin(skin));
    }
});

export default connect(mapStateToProps, mapDispatchToProps)(Skin)
複製程式碼

在Menu元件中匯入Skin容器元件,然後新增一個state屬性skinShow控制Skin顯示或隱藏,同時編寫一個改變skinShow的方法showSetting

匯入Skin

import Skin from "../../containers/Skin"
複製程式碼

constructor初始化skinShow

constructor(props) {
    super(props);
    this.state = {
        skinShow: false
    };
}
複製程式碼

showSetting方法中先關閉當前頁面,然後將skinShow設定為true或false

showSetting = (status) => {
    this.close();
    // menu關閉後開啟設定
    setTimeout(() => {
        this.setState({
            skinShow: status
        });
    }, 300);
}
複製程式碼

SKin元件放置在如下位置,傳入show控制顯示和隱藏,close方法用來關閉皮膚中心頁面

<div>
    <CSSTransition in={this.props.show} timeout={300} classNames="fade"
       ...
    </CSSTransition>
    <Skin show={this.state.skinShow} close={() => {this.showSetting(false);}} />
</div>
複製程式碼

給皮膚中心新增點選事件,點選後呼叫showSetting,顯示皮膚中心頁面

<div className="bottom-wrapper">
    <div className="item" onClick={() => {this.showSetting(true);}}>
        皮膚中心
    </div>
    ...
</div>
複製程式碼

回到Skin元件中,給皮膚新增點選事件,點選後將當前皮膚的key傳入,呼叫util下skin.js中匯出的setSkinStyle方法設定皮膚,然後將皮膚設定到Redux的狀態屬性中儲存,再呼叫props中的close方法關閉頁面

skin.js

import {skin, setSkinStyle} from "../../util/skin"
複製程式碼
setCurrentSkin = (key) => {
    // 設定皮膚
    setSkinStyle(skin[key]);
    this.props.setSkin(key);
    // 關閉當前頁面
    this.props.close();
}
複製程式碼
<div className="skin-wrapper" onClick={() => {this.setCurrentSkin(skin.key);}} key={skin.key}>
    ...
</div>
複製程式碼

皮膚持久化儲存

做完皮膚切換功能後,每次重新整理或重新進入頁面上次設定的皮膚會切換為預設的黑色,我們想讓上一次設定的皮膚在重新整理或重新進入時都是一樣的,這時需要將皮膚的key值儲存到本地

在util目錄下的storage.js中新增兩個方法

let localStorage = {
    setSkin(key) {
        window.localStorage.setItem("skin", key);
    },
    getSkin() {
        let skin = window.localStorage.getItem("skin");
        return !skin ? "coolBlack" : skin;
    },
    ...
}
複製程式碼

在reducers.js將skin寫死的預設值從localStorage中獲取,設定skin的reducer方法中將皮膚的key儲存到localStorage中

const initialState = {
    skin: localStorage.getSkin(),  //皮膚
    ...
};

//設定皮膚
function skin(skin = initialState.skin, action) {
    switch (action.type) {
        case ActionTypes.SET_SKIN:
            localStorage.setSkin(action.skin);
            return action.skin;
        default:
            return skin;
    }
}
複製程式碼

然後在util下的skin.js中將呼叫setSkinStyle(skin.coolBlack)中的skin.coolBlack換成從localStorage中獲取

import localStorage from "./storage"

...

setSkinStyle(skin[localStorage.getSkin()]);
複製程式碼

效果

React全家桶構建一款Web音樂App實戰(九):皮膚切換

總結

本節主要內容是切換皮膚的功能,實現的原理總的來說就是提取樣式,切換的時候替換樣式,再插入到HTML DOM中,實現的方式有多種,這裡主要選取一種比較合適的方式來做。

本系列所有章節到此結束

本章節程式碼在chapter9分支

完整專案地址:github.com/code-mcx/ma…

體驗地址:code-mcx.github.io/mango-music

相關文章