网易云音乐 PC 项目实践
一、 项目简介
1. 项目介绍
-
项目使用到的技术栈
-
CSS
使用Flex
进行布局 - 配置路径别名使用:
carco
- 项目路由使用:
react-router
来管理 - 使用
react-router-config
集中式路径映射表管理 - 使用
styled-components
+普通的css
编写样式 - 使用
axios
发送网络请求 - 项目全面拥抱
React Hooks
- 项目组件库使用:
ant design
- 使用
immutable
对项目reducer
中state
进行管理 - 使用
redux-immtable
对根目录reducer
中state
进行管理 - 项目使用
redux-thunk
中间件 - 使用
propType
校验props
类型及默认值 - 使用
react-transition-group
添加过渡动画效果 - 项目中的优化: 函数式组件全部采用
memo
、路由懒加载、函数防抖
-
-
项目的目标
- 使用
React
全家桶开发网易云音乐PC网站
- 使用
2. 适合人群及收获
-
适合人群:
-
适合想了解一个项目的大致流程
-
或者是学习了
React 全家桶
但是缺乏React项目经验- (如果有的知识点不了解,可以参考以前React系列文章)
- (最好了解一点
Node
)
-
-
收获:
- 如何设计音乐播放器组件,歌词解析等
- 项目目录的结构划分,大型项目的
state
管理 - 项目的大致流程,如何进行性能优化等等
3. 页面效果和功能展示
推荐/新碟上架/榜单
路由切换
歌曲评论
排行榜
播放器
歌曲切换(随机、顺序、单曲循环)
歌曲搜索
-
新增:键盘事件↓ & 函数防抖
-
ctrl+k
搜索框获取焦点 & 唤醒搜索下拉框 -
esc
取消焦点 & 下拉框 -
enter
进入歌曲搜索详情
-
歌曲搜索详情列表
- 在搜索框中按下
回车
即可,搜索列表基本功能实现
4. 项目源码及API接口
-
????项目Github地址
-
???? API接口文档 如果觉得项目还不错的话 ????,就给个 ⭐ 鼓励一下吧
5. 项目规范
-
项目规范:项目中有一些开发规范和代码风格 (也可以按照自己的习惯)
-
src下文件夹名称统一小写、多个单词以连接符(
-
)连接;组件文件及文件夹采用驼峰命名 -
JavaScript
变量名称采用小驼峰标识,常量全部使用大写字母,组件采用大驼峰; -
CSS采用普通
CSS
和styled-component
结合来编写- 全局采用普通
CSS
、局部采用styled-component
- 全局采用普通
-
整个项目不再使用
class
组件,统一使用函数式组件,并且全面拥抱Hooks
; -
所有的函数式组件,为了避免不必要的渲染,全部使用
memo
进行包裹; -
组件内部的状态,使用
useState
、useReducer
;业务数据全部放在redux
中管理; -
函数组件内部基本按照如下顺序编写代码:
- 组件内部
state
管理; -
redux
的hooks
代码; - 其他组件
hooks
代码; - 其他逻辑代码;
- 返回JSX代码;
- 组件内部
-
redux
代码规范如下:- 每个模块有自己独立的
reducer
,通过combineReducer
进行合并; - 异步请求代码使用
redux-thunk
,并且写在actionCreators
中; -
redux
直接采用redux hooks
方式编写,不再使用connect
;
- 每个模块有自己独立的
-
-
其他规范在项目中根据实际情况决定和编写;
6. React devtools图标隐藏(了解即可)
- 在开发项目实战之前: 我们打开网易云音乐官网,会发现网易云官网为什么看不到
react devtools
插件的标记 (了解即可,不重要) - React devtools图标隐藏
//index.js最上面--在生产环境
// disable react-dev-tools for this project
if (typeof window.__REACT_DEVTOOLS_GLOBAL_HOOK__ === "object") {
for (let [key, value] of Object.entries(window.__REACT_DEVTOOLS_GLOBAL_HOOK__)) {
window.__REACT_DEVTOOLS_GLOBAL_HOOK__[key] = typeof value == "function" ? ()=>{} : null;
}
}
二、 项目初始化
1. 前言-vscode&chrome插件(可选)
-
如果已经安装过了可以选择跳过,以下都是可选的,当然不安装也没问题
-
为了更便捷的开发项目,推荐安装以下
vscode
插件-
ESLint
: 代码风格检查工具,帮助我们规范代码书写 -
vscode-styled-components
: 在编写styled-components
中语法高亮显示和样式组件的 - path-alias: 别名路径有对应的智能提示
-
ES7 React/Redux/GraphQL/React-Native snippets
: 代码片段
-
-
chrome
插件-
Redux DevTools: 方便调试
redux
数据 -
FeHelper: 对服务器返回的
json
数据进行美化
-
Redux DevTools: 方便调试
2. 项目目录划分
- 使用
create-react-app
脚手架初始化项目结构:create-react-app music163_xxx
- 目录结构也可以按照自己习惯的结构来划分
│─src
├─assets 存放公共资源css和图片
├─css 全局css
├─img
├─common 公共的一些常量、数据
├─components 公共组件
├─pages 路由映射组件
├─router 前端路由配置
├─service 网络配置和请求
└─store 全局的store配置
└─utils 封装的工具函数
└─hooks 自定义hook
3. 项目初始化
1. css重置及公共css抽取
-
安装:
yarn add normalize.css
-
在assets/css/reset.css文件中引入
@import "~normalize.css";
并编写自己需要的全局css覆盖normalize
中不需要的css
@import "~normalize.css";
/* 样式的重置 */
body, html, h1, h2, h3, h4, h5, h6, ul, ol, li, dl, dt, dd, header, menu, section, p, input, td, th, ins {
padding: 0;
margin: 0;
}
ul, ol, li {
list-style: none;
}
a {
text-decoration: none;
color: #666;
}
a:hover {
color: #666;
text-decoration: underline;
}
i, em {
font-style: normal;
}
input, textarea, button, select, a {
outline: none;
border: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
img {
border: none;
vertical-align: middle;
}
//...更多的直接看项目
- 在index.js中导出
import "./assets/css/reset.css";
2. 项目配置的修改
- 安装:
`yarn add @craco/craco`
`yarn add babel-plugin-import`
`yarn add antd`
- 根目录下新建:craco.config.js
const path = require("path");
//dir为当前文件(craco-根路径)路径,__dirname为下方resolve()传递过来的路径,然后拼接
const resolve = (dir) => path.resolve(__dirname, dir);
module.exports = {
//antd样式按需加载
babel: {
plugins: [
[
"import",
{
libraryName: "antd",
libraryDirectory: "es",
style: "css",
},
],
],
},
//配置@别名,代替根目录下的src文件路径
webpack: {
alias: {
"@": resolve("src"),
},
},
};
3. 项目路由配置
- 安装:
yarn add react-router-dom@5.2.0
- 路由统一配置:
yarn add react-router-config
- 在src的router文件夹下开始编写统一路由配置
import Discover from "@/pages/Discover";
import Friends from "@/pages/Friends";
import Mine from "@/pages/Mine";
const routes = [
{
path: "/",
exact: true,
component: Discover,
},
{
path: "/discover",
component: Discover,
},
{
path: "/friends",
component: Friends,
},
{
path: "/mine",
component: Mine,
},
];
export default routes;
4. AppHeader组件
1. styled-components编写css
- 安装:
yarn add styled-components
- 新建style.js-引入:
import styled from "styled-components"
- 然后开始编写css
- 注意:Header选项卡数据放在公共组件
common
文件夹的local-data.js
文件下,直接导入遍历即可 - 注意路由用
react-router-config
,配置路由映射表
5. AppFooter组件
1. styled-components编写css
- 直接编写结构与样式即可,没啥好说的
- 在App.js文件中展示汇总展示组件,其中路由组件放在头部与尾部中间动态展示
// 导入的第三方库
import React, { memo } from "react";
import { renderRoutes } from "react-router-config";
// 导入自己定义的工具
import routes from "./router";
// 导入组件
import AppFooter from "@/components/AppFooter";
import AppHeader from "@/components/AppHeader";
export default memo(function App() {
return (
<div>
<AppHeader />
{renderRoutes(routes)}
<AppFooter />
</div>
);
});
6. axios的使用
- 需要用到后端数据,安装axios :
yarn add axios
- 在service文件夹下二次封装axios,然后将其导出到需要的文件下
- 之后为每个需要请求的模块单独创建一个文件,在其中书写发起请求的方法,导出函数
7. redux的使用
- 需要用到redux管理数据,安装:
yarn add redux react-redux redux-thunk
- 在src/store中建立reducer.js文件及index.js文件
- reducer.js文件用于汇总各个reducer,,之后导出到store/index.js的文件中
- index.js文件用于创建store及各项配置
- 各个组件之中创建store文件夹用于管理各个组件之间的reducer并导出到src文件夹下的reducer文件中
- 注意:别忘了在App.js文件中用provider包裹在最外层
import { Provider } from "react-redux";
import store from "./store/index";
export default memo(function App() {
return (
<div>
<Provider store={store}>
...
</Provider>
</div>
);
});
8. 使用 useSelector useDispatch 替代connect
import React, { memo, useEffect } from "react";
import { useDispatch, useSelector, shallowEqual } from "react-redux";
import { getTopBannerAction } from "./store/actionCreators";
function Recommend(props) {
// 使用hooks代替传统redux,获取数据和进行操作
// 返回Redux store中对dispatch函数的引用。你可以根据需要使用它
const dispatch = useDispatch();
// 从redux的store对象中提取数据(state)。
const { topBanners } = useSelector(
(state) => ({
topBanners: state.recommend.topBanners,
}),
shallowEqual
);
// console.log(banners);
useEffect(() => {
dispatch(getTopBannerAction());
}, [dispatch]);
return <div>Recommend:{topBanners.length}</div>;
}
export default memo(Recommend);
// 传统react-redux
// import React, { memo, useEffect } from "react";
// import { connect } from "react-redux";
// import { getTopBannerAction } from "./store/actionCreators";
// function Recommend(props) {
// const { getBanners, topBanners } = props;
// // 调用映射到props的getBanners方法
// useEffect(() => {
// getBanners();
// }, [getBanners]);
// return <div>Recommend:{topBanners.length}</div>;
// }
// // 将下方的state与方法映射到props中
// const mapStateToProps = (state) => {
// return {
// topBanners: state.recommend.topBanners,
// };
// };
// const mapDispatchToProps = (dispatch) => {
// return {
// getBanners: () => {
// dispatch(getTopBannerAction());
// },
// };
// };
// export default connect(mapStateToProps, mapDispatchToProps)(memo(Recommend));
9. 数据可变性问题--Immutable.js
-
Immutable.js中文文档
-
在React开发中,我们总是会强调数据的不可变性:
- 无论是类组件中的state,还是redux中管理的state;
- 事实上在整个JavaScript编码过程中,数据的不可变性都是非常重要的;
-
数据的可变性引发的问题(案例):
- 我们明明没有修改obj,只是修改了obj2,但是最终obj也被我们修改掉了;
- 原因非常简单,对象是引用类型,它们指向同一块内存空间,两个引用都可以任意修改;
-
有没有办法解决上面的问题呢?
- 进行对象的拷贝即可:Object.assign或扩展运算符
const info = {
name: "why",
age: 20,
};
const obj = { ...info };
const obj2 = info;
info.name = "kobe";
console.log(obj.name); // why
console.log(obj2.name); // kobe
- 这种对象的浅拷贝有没有问题呢?
- 从代码的角度来说,没有问题,也解决了我们实际开发中一些潜在风险;
- 从性能的角度来说,有问题,如果对象过于庞大,这种拷贝的方式会带来性能问题以及内存浪费;
- 有人会说,开发中不都是这样做的吗?
- 从来如此,便是对的吗
- 为了解决上面的问题,出现了Immutable对象的概念:
- Immutable对象的特点是只要修改了对象,就会返回一个 新的对象,旧的对象不会发生改变;
- 但是这样的方式就不会浪费内存了吗?
- 为了节约内存,又出现了一个新的算法:Persistent Data Structure(持久化数据结构或一致性数据结构);
- 当然,我们一听到持久化第一反应应该是数据被保存到本地或 者数据库,但是这里并不是这个含义:
- 用一种数据结构来保存数据;
- 当数据被修改时,会返回一个对象,但是新的对象会尽可 能的利用之前的数据结构而不会对内存造成浪费;
- 如何做到这一点呢?结构共享。
会返回一个新的 Immutable 对象。Immutable 实现的原理是 Persistent Data Structure(持久化数据结构) ,也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immutable 使用了Structural Sharing(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。请看下面动画:
实例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/immutable/3.8.2/immutable.min.js"></script>
<script>
const im = Immutable;
// Map的使用
const info = {
name: "kobe",
age: 30,
friend: {
name: "james",
age: 25,
},
};
const infoIM = im.Map(info);
const obj = infoIM;
// 修改后返回一个新对象,新对象的值改变,原对象的值不便
const infoIM2 = infoIM.set("name", "why");
console.log(obj.get("name")); //kobe
console.log(infoIM.get("name")); //kobe
console.log(infoIM2.get("name")); //why
// List的使用;
const names = ["abc", "cba", "nba"];
// const arr = names;
// names[0] = "why";
// console.log(arr); //也会发生改变
const namesIM = im.List(names);
const arrIM = namesIM.set(0, "why");
console.log(namesIM.get(0));
console.log(arrIM.get(0));
//注意:Map只能进行浅层转换,将最外层对象转换成Immutable.js,而fromJS能进行深层转换,无论嵌套几层
const zgc = {
name: "kobe",
age: 30,
friend: {
name: "james",
age: 25,
},
};
const zgcIM1 = im.Map(zgc);
const zgcIM2 = im.fromJS(zgc);
console.log(zgcIM1.get("friend"));
console.log(zgcIM2.get("friend"));
</script>
</body>
</html>
在项目中使用ImmutableJS:
yarn add immutable
在每个模块的reducer.js中引入immutable
:
//ImmutableJS方法
import * as actionTypes from "./constants";
import { Map } from "immutable";
const defaultState = Map({
topBanners: [],
});
function reducer(state = defaultState, action) {
switch (action.type) {
case actionTypes.CHANGE_TOP_BANNERS:
return state.set("topBanners", action.topBanners);
default:
return state;
}
}
export default reducer;
// 普通方法
// import * as actionTypes from "./constants";
// const defaultState = {
// topBanners: [],
// };
// function reducer(state = defaultState, action) {
// switch (action.type) {
// case actionTypes.CHANGE_TOP_BANNERS:
// return { ...state, topBanners: action.topBanners };
// default:
// return state;
// }
// }
// export default reducer;
yarn add redux-immutable
在store/reducer.js中:
// import { combineReducers } from "redux";
//reducer联合方法combineReducers改为从redux-immutable中导入
import { combineReducers } from "redux-immutable";
import { reducer as recommendReducer } from "../pages/Discover/c-pages/Recommend/store";
//合并
const reducer = combineReducers({
recommend: recommendReducer,
});
export default reducer;
读取:
// 从redux的store对象中提取数据(state)。
const { topBanners } = useSelector(
(state) => ({
// topBanners: state.recommend.topBanners,//普通读取数据方法
// topBanners: state.get("recommend").get("topBanners"), //ImmutableJS读取数据方法
topBanners: state.getIn(["recommend", "topBanners"]), //简写
}),
shallowEqual
);
10. Discover-Recommend组件(发现音乐--推荐模块)
1.构建文件结构及子目录
- Discover组件选项卡数据放在公共组件
common
文件夹的local-data.js
文件下,直接导入遍历构建文件结构 - 新建style.js,然后开始编写css
- 配置子路由映射表,并通过props引入
- 在Discover文件夹下创建新目录,放入其子组件
2. 完成轮播图组件-TopBanner
- 在Discover/c-pages/Recommend(推荐)文件夹下创建新目录,放入其子组件
- 采用
antd
库中的Carousel
完成轮播图,并重写了其圆点样式
3. ThemeHeaderRec头部模块的封装
- 使用时只需要导入模块并且传入title与keywords就行,用props接收
- 另外设置了类型校验与默认参数,防止keywords为undefined时报错
4. HotRecommend热门推荐模块完成
- 在components/SongsCover封装组件,且使用了在utils/Rec-format.js中封装了两个方法
- redux获取和读取数据,并遍历SongsCover组件
5. NewAlbum新碟上架模块完成
- 在components/AlbumCover封装组件,且使用了在utils/Rec-format.js中封装的方法
- redux获取和读取数据,并遍历AlbumCover组件
6. RecommendRanking排行模块完成
- 在components/TopRanking封装组件,且使用了在utils/Rec-format.js中封装的方法
- redux获取和读取数据,并使用TopRanking组件三次
- 整个背景只是一张大背景图
7. 本页面其余模块完成
- 用户登录模块只完成了UI
- 入驻歌手模块是从后端请求的数据
- 热门主播模块是写死在common/local-data.js中的数据
- 入驻歌手模块与热门主播模块用了同一个头部封装--components/ThemeHeaderSmall
11. 云音乐播放工具栏的实现
- 在pages下面新建Player/AppPlayerBar文件夹
- 在pages下面新建Player/store文件夹
- 其中滑动条是antd组件库的Slider组件,其原本样式被精灵图覆盖
- 播放音乐用的
<audio/>
标签 - 播放和暂停根据状态动态改变
12. 歌曲展示页面
- 在pages/Player/index.jsx中搭建展示页面
- 接收search参数,得到当前歌曲id
yarn add url-parse
import { qs } from "url-parse"; //要引入
const { search } = props.location;
const { id } = qs.parse(search);
13. 播放歌曲的逻辑
-
添加歌曲到播放列表:
- 当用户点击了某个歌曲, 得到当前歌曲详情(ids)
- 如果该歌曲已存在于播放列表,获取该歌曲在播放列表的索引,修改当前播放歌曲的索引,修改当前播放的歌曲,然后音乐播放器会播放这一首歌曲。
- 如果当前歌曲不存在,请求该歌曲数据,将歌曲添加到播放列表的最后,获取到这个最后的index,修改当前播放歌曲的索引 ,修改当前播放的歌曲,然后音乐播放器会播放这一首歌曲。
-
记录当前的播放顺序:
sequence: 0, // 0 循环 1 随机 2 单曲
//点击按钮切换播放顺序
const changeSequence = () => {
let Sequence = sequence + 1;
if (Sequence > 2) {
Sequence = 0;
}
dispatch(changeSequenceAction(Sequence));
};
- 两种方式实现歌曲播放顺序切换(项目中用第二种):
- 单曲循环
- 再建立一个播放列表,取播放歌曲的时候从这里取,此播放列表的数据只取原列表当前循环的这一首
- 直接当前歌曲的索引不变
- 随机播放
- 再建立一个播放列表,取播放歌曲的时候从这里取 ,此播放列表的数据在从原列表复制时打乱顺序
- 用随机数
- 顺序播放
- 再建立一个播放列表,取播放歌曲的时候从这里取 ,此播放列表的数据就是原列表的数据
- 直接当前歌曲的索引+1
- 单曲循环
- 歌曲播放的切换逻辑
// 点击左右按钮,切换(<- ->)播放歌曲
export const changeCurrentIndexAndSongAction = (tag) => {
return (dispatch, getState) => {
const playList = getState().getIn(["player", "playList"]);
const sequence = getState().getIn(["player", "sequence"]);
let currentSongIndex = getState().getIn(["player", "currentSongIndex"]);
// 得到新索引
switch (sequence) {
case 1: //随机播放
// 随机播放时,得到一个随机索引值
let randomIndex = getRandomNumber(playList.length);
// 如果得到的索引值等于当前索引,重新得到一个新的索引
while (randomIndex === currentSongIndex) {
randomIndex = getRandomNumber(playList.length);
}
currentSongIndex = randomIndex;
break;
default:
//其他情况,即单曲或者循环播放时(这两种情况点击左右切换按钮都会切换歌曲)
currentSongIndex = currentSongIndex + tag;
if (currentSongIndex >= playList.length) {
currentSongIndex = 0;
}
if (currentSongIndex < 0) {
currentSongIndex = playList.length - 1;
}
}
const currentSong = playList[currentSongIndex];
dispatch(changeCurrentSongAction(currentSong));
dispatch(changeCurrentSongIndexAction(currentSongIndex));
};
};
- 歌曲对应歌词的请求与解析
- 从后端获取歌词
- 解析歌词函数 [00:31.160]如果场景里出现一架钢琴 ==>{time: 16280, content: 'あんなに愛した君がいない'}
const parseExp = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
export function parseLyric(lyricString) {
const lineStrings = lyricString.split("\n");
const lyrics = [];
for (let line of lineStrings) {
if (line) {
const result = parseExp.exec(line);
// console.log(result);
// 如果这一次没有匹配到,则跳过这一次进行下一次匹配
if (!result) continue;
const time1 = result[1] * 60 * 1000;
const time2 = result[2] * 1000;
const time3 = result[3].length === 3 ? result[3] * 1 : result[3] * 10;
const time = time1 + time2 + time3;
// replace方法:用后面的值取代前面的值,trim:去掉空格
const content = line.replace(parseExp, "").trim();
const lineObj = { time: time, content: content };
lyrics.push(lineObj);
}
}
return lyrics;
}
- 根据当前时间找到并展示歌词
// 获取当前播放的歌词
let i = 0;
// 遍历,如果当前播放的时间小于当前项歌词的时间,break跳出循环,
// 当前记录的i减去1即为当前播放的歌词
for (i; i < lyricList.length; i++) {
let lyricItem = lyricList[i];
if (currentTime < lyricItem.time) {
break;
}
}
if (currentLyricIndex !== i - 1) {
dispatch(changeCurrentLyricIndexAction(i - 1));
const content = lyricList[i - 1]?.content;
message.open({
key: "lyric",
content: content,
duration: 0,
className: "lyric-class",
});
}
14. 路由懒加载
//正常
// import Discover from "@/pages/Discover";
// import Recommend from "@/pages/Discover/c-pages/Recommend";
// import Ranking from "@/pages/Discover/c-pages/Ranking";
// import Songs from "@/pages/Discover/c-pages/Songs";
// import Djradio from "@/pages/Discover/c-pages/Djradio";
// import Artist from "@/pages/Discover/c-pages/Artist";
// import Album from "@/pages/Discover/c-pages/Album";
// import Friends from "@/pages/Friends";
// import Mine from "@/pages/Mine";
// import NotFound from "@/components/NotFound";
// import Song from "@/pages/Player";
//懒加载
const Discover = React.lazy(() => import("@/pages/Discover"));
const Recommend = React.lazy((_) =>
import("@/pages/Discover/c-pages/Recommend")
);
const Ranking = React.lazy((_) => import("@/pages/Discover/c-pages/Ranking"));
const Songs = React.lazy((_) => import("@/pages/Discover/c-pages/Songs"));
const Djradio = React.lazy((_) => import("@/pages/Discover/c-pages/Djradio"));
const Artist = React.lazy((_) => import("@/pages/Discover/c-pages/Artist"));
const Album = React.lazy((_) => import("@/pages/Discover/c-pages/Album"));
const Friends = React.lazy((_) => import("@/pages/Friends"));
const Mine = React.lazy((_) => import("@/pages/Mine"));
const NotFound = React.lazy((_) => import("@/components/NotFound"));
const Song = React.lazy((_) => import("@/pages/Player"));
同时在App组件中用Suspense
包裹路由组件,fallback中是一个组件,在加载时调用
import React, { memo, Suspense } from "react";
<Suspense fallback={<div>...loading</div>}>
{renderRoutes(routes)}
</Suspense>
14. 弹窗歌词轮播页面
在Player/AppPlayPanel中完成该模块
注:该模块只完成了歌词轮播与歌曲展示,其余项(删除,收藏等你可以自行完成)
yarn add classnames
- PlayHeader 头部
- PlayList 播放列表
- LyricPanel 歌词轮播 用ref得到右边的歌词滚动模块 调用该方法实现歌词滚动
// 实现歌词的滚动
export function scrollTo(element, to, duration) {
if (duration <= 0) return;
var difference = to - element.scrollTop;
var perTick = (difference / duration) * 10;
setTimeout(function () {
element.scrollTop = element.scrollTop + perTick;
if (element.scrollTop === to) return;
scrollTo(element, to, duration - 10);
}, 10);
}
element为ref得到的元素,to为当前歌词的总高度(每一句歌词heght为32px),设置duration=300,如果duration>0,当前元素的滚动高度
推荐阅读
-
网易云音乐怎么看听歌次数 网易云音乐听歌排行查询步骤介绍
-
什么是网易云音乐黑胶唱片故事_网易云音乐黑胶唱片故事功能介绍
-
网易云音乐表白翻译器怎么玩_网易云音乐表白翻译器玩法教程
-
腾讯云+社区技术沙龙丨数字经济浪潮驱动下的高效智能运维--张荣:"腾讯云智能运维(AIOps)项目实践 腾讯云是一个很好的工作场所。 |腾讯云智能运维(AIOps)项目实践
-
云原生 API 网关 - 开源项目 Hango 网关设计与实践
-
网易云音乐官方搜索界面
-
网易云音乐一键迁移功能怎么用_网易云音乐一键迁移虾米歌单功能使用方法
-
网易云音乐 PC 项目实践
-
Linux 下安装原酒并使用原酒安装微信、网易云音乐等软件
-
获取网易云音乐开放接口 api 的推荐歌单