纳米级拆解网易云音乐-开篇-走马灯

1,162 阅读5分钟

      三月过去了好几周,这天终于丢下了寒冬腊月的影子,慢慢开始热了起来,晴天真好,可以看到故事里的小黄花,又可以抖擞抖擞精神开始折腾起来了.

      每天上班的第一件事情就是打开网易云音乐(WIN PC版本),找个歌单开始听歌,再打开VSCODE,不停地码码码...某天码累了小憩了一会,点了点云音乐,不经意发现它简直是一个神奇的存在,到处都是各式各样的组件,有菜单,跑马灯,卡片,pageHeader,滑动条...,业务代码写累了就容易就这样,会琢磨着做点有趣的事情,好吧,那我就开始做一个自己的网易云音乐吧!

      万事开头难,难上加难就是上来就啃一块硬骨头.

技术栈说明

前端: Typescript / React / umijs / sass / ant-design/icons
后端: Java / SpringBoot / Mybatis / MySQL

走马灯Carousel

之所以选它做第一个组件除了它有点难度之外,还因为这家伙天天在我眼前滚滚滚,我没法不注意到它. - -! 走马灯
因为是C端的产品,所以相比于antd的走马灯,云音乐的走马灯更加花哨,卡片之间存在一定的重叠,而且过度比较舒服平滑.

元素1,卡片

单图
1.带圆角的卡片(width:542px;height:196px).
2.右下角有一个 独家/首发 之类的红色角落标(高28,宽不定,颜色:#EC4141,注意:网易产品的主色调都是这个红色,后续涉及到主红色我们直接调用这个颜色值)
3.点击卡片触发某个事件

元素2,横排点阵

点阵

元素3, 按钮

按钮

接下来我们逐个实现各个小组件,最后再配上动画效果

1.卡片

其实组件参数是比较简单的,看下我们的组件类型

1.1 组件类型

interface CardProps {
  onClick?: () => void; //回调点击事件
  text?: string;//卡片右下角的文字
  className?: string;
}

1.2 组件代码

const Card: React.FC<CardProps> = ({ children, onClick, text, className }) => {
  const classes = classnames('carousel-card-container', className);
  return (
    <div className={classes} onClick={onClick}>
      {React.cloneElement(children as React.ReactElement, {
        style: { width: '100%', height: '100%' },
      })}
      <div className="carousel-card-text">{text}</div>
    </div>
  );
};
export default Card;

1.3 样式

.carousel-card-container {
  position: relative;
  border-radius: 8px;
  overflow: hidden;
}

.carousel-card-text {
  position: absolute;
  bottom: 0;
  right: 0;
  height: 22px;
  padding: 4px 8px;
  background: $primary-color; //$primary-color是一个全局的sass变量,代表主色 网易红:#EC4141
  color: #fff;
  font-size: 12px;
  line-height: 14px;
  border-top-left-radius: 8px;
  border-bottom-right-radius: 8px;
}

1.4 注意点

1.利用cloneElement对children附加的属性width和height是为了让后续的子元素宽高适应父元素. 比如:

<Card><img src="http://example.com/xxx.png"/></Card>

这时img无论多少像素都会被压缩至适应父元素的值

1.5 效果图

<Card text="独家">
  <img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8835bba0ccdc4fd58a92c29f6af17580~tplv-k3u1fbpfcp-zoom-1.image"/>
</Card>

好像还可以 效果图

元素2 点阵

2.1 一个点

通过仔细观察归纳如下:
1.状态有2种,被选中时,红色, 非选中时,灰色
2.支持鼠标hover时回调事件
归纳完毕,我们代码部分依旧是配置先行

2.2 组件类型

interface DotProps {
  isSelected?: boolean;
  isHover?: (hover: boolean) => void;
  style?: React.CSSProperties;
}

2.3 组件代码

const Dot: React.FC<DotProps> = ({ isSelected, isHover, style }) => {
  const classes = classnames('carousel-dot-container', {
    'carousel-dot-selected': isSelected,
  });
  return (
    <div
      className={classes}
      style={style}
      onMouseEnter={() => isHover?.(true)}
      onMouseLeave={() => isHover?.(false)}
    />
  );
};

export default Dot;

2.4 样式代码

.carousel-dot-container{
  width: 6px;
  height: 6px;
  border-radius: 50%; //把正方形加上大圆角,变成原型
  margin: 0 4px 0 4px; //用以之后点阵的间隙
  background-color: #E6E6E6;
}

.carousel-dot-selected {
  background-color:$primary-color //依旧是网易红
}

2.5 点阵

先归纳如下: 1.输入点阵数量 2.当前被选中的点 3.hover某个点后回调修改被选中的点

2.6 组件类型

interface DotsProps {
  count: number;
  current: number;
  onChange?: (index: number) => void;
  style?: React.CSSProperties;
}

2.7 组件代码

const Dots: React.FC<DotsProps> = ({ count, current, onChange }) => {
  return (
    <div className="carousel-dots-container">
      {new Array(count).fill(0).map((curr, index) => {
        return (
          <Dot
            key={index}
            isSelected={current === index}
            isHover={() => onChange?.(index)}
          />
        );
      })}
    </div>
  );
};

export default Dots;

2.8 样式代码

// 点阵样式
.carousel-dots-container {
  display: flex;
  justify-content:center;
}

2.9 效果图

import React, { useState } from 'react'
import Dots from '@/components/carousel/dots/Dots'
const Test = ()=>{
  const [current,setCurrent] = useState(0)
  return (
    <div>
      <Dots count={9} current={current} onChange={i=>setCurrent(i)}/>
    </div>
  )
}

export default Test

点阵图

3 按钮

按钮组件是最简单的部分了,在这里我们把向左和向右按钮以一个基础组件的两个状态来表示

3.1 组件代码

import React from 'react';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import classnames from 'classnames';
interface ButtonProps {
  onClick?: () => void;
  placement: 'left' | 'right';
  className?: string;
}

const Button: React.FC<ButtonProps> = ({ onClick, placement, className }) => {
  const classes = classnames(className, 'carousel-button-container');
  return (
    <div className={classes} onClick={() => onClick?.()}>
      {placement === 'left' ? (
        <LeftOutlined className="carousel-button-icon" />
      ) : (
        <RightOutlined className="carousel-button-icon" />
      )}
    </div>
  );
};
export default Button;

3.2 样式代码

.carousel-button-container{
  height: 30px;
  width: 30px;
  border-radius: 50%;
  background: $grey-7; //$grey-X是一个灰阶系列色,代表不同深浅的灰色
  opacity: 0.5;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
}

.carousel-button-icon {
  font-size: 12px;
  color: $grey-5;
  font-weight: 600;
  &:hover,&.hover {
    color: $grey-1;
  }
}

3. 走马灯赋能

到这里我们已经把走马灯的三个基础组件卡片/点阵/按钮实现了,接下来到了重头戏,即如何布局,如何实现这个切换动画?

3.1 动画分解

整个动画切换的过程很快,大概0.5s完成一帧,经过录屏后0.25倍速+逐帧分析,我们归纳该动画的细节,具体的分解过程我就不多说了,直接看重点:
1.整个跑马灯的前台图片共三张,左|中|右,其中左和右分别有一定程度的缩放(css3中可以用scale去实现x,y的缩放,不需要手动去修改元素的大小),中间图z-index优先级最高,始终在最前方,覆盖左右两图.
2.除此之外的图片全部堆叠在中间图的后方被隐藏,堆叠时需要缩放,缩放值和左右图一样.
3.整个正向的切换逻辑如下:
当前帧
左 - 中 - 右
下一帧
左进卡堆最下方,中切换到左, 右切换到中,卡堆最上方的卡片切换到右
4.切换间隔4s左右
5.鼠标hover到跑马灯上时切换暂停,鼠标hover点阵时,直接切换hover点阵对应的卡片

3.2 组件代码

组件代码本身没有太难的点,只需要注意到只是利用了getClasses动态生成了各张卡片的className

import React from 'react';
import Card from './card/Card';
import Dots from './dots/Dots';
import Button from './button/Button';
import classnames from 'classnames';
interface CarouselProps {
  cards: Array<{ text: string; img: string }>;
  className?: string
}
const Carousel: React.FC<CarouselProps> = ({ cards,className }) => {
  const [current, setCurrent] = React.useState(0); //当前卡片索引值
  const countRef = React.useRef(0);
  countRef.current = current;
  const [isHover, setHover] = React.useState(false); //鼠标是否hover到跑马灯上
  const useHoverRef = React.useRef(false);
  useHoverRef.current = isHover;
  const getClasses = (index: number) => {
    const isLeft =
      index === current - 1 || (current === 0 && index === cards.length - 1);
    const isRight =
      index === current + 1 || (current === cards.length - 1 && index === 0);
    const isCurrent = current === index;
    return classnames('carousel-card-size',className, {
      'carousel-card-dock': !(isLeft || isRight || isCurrent),
      'carousel-card-middle': isCurrent,
      'carousel-card-left': isLeft,
      'carousel-card-right': isRight,
    });
  };
  if (cards.length < 3) {
    throw new Error('走马灯元素不得少于3个');
  }

  const step = (directon: 1 | -1, source: 'manual' | 'auto') => {
    if (useHoverRef.current && source === 'auto') {
      return;
    }
    if (directon === 1) {
      if (countRef.current === cards.length - 1) {
        setCurrent(0);
      } else {
        setCurrent((current) => current + 1);
      }
    }
    if (directon === -1) {
      if (countRef.current === 0) {
        setCurrent(cards.length - 1);
      } else {
        setCurrent((current) => current - 1);
      }
    }
  };

  //设置定时滚动,滚动间隔4s
  React.useEffect(() => {
    const timer = setInterval(() => {
      step(1, 'auto');
    }, 4000);
    return () => clearInterval(timer);
  }, []);
  return (
    <div
      className="carousel-container"
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
    >
    <div className="carousel-cards">
      {cards.map((card, index) => (
        <Card key={index} text={card.text} className={getClasses(index)}>
          <img src={card.img} />
        </Card>
      ))}
      {isHover ? (
        <Button
          placement="left"
          className="carousel-button-left"
          onClick={() => step(-1, 'manual')}
        />
      ) : null}
      {isHover ? (
        <Button
          placement="right"
          className="carousel-button-right"
          onClick={() => step(1, 'manual')}
        />
      ) : null}
      </div>
      <div className="carousel-bottom-dots">
        <Dots
          count={cards.length}
          current={current}
          onChange={(i) => setCurrent(i)}
        />
      </div>
    </div>
  );
};

export default Carousel;

3.3 样式代码

从上面的动画分解看,主要存在四种类型的卡片 左|中|右|卡堆,分别以 carousel-card-left,carousel-card-middle,carousel-card-right,carousel-card-dock 重点关注几个属性:
z-index实现堆叠顺序
transform实现缩放,分别缩放0.82
transform-origin转移元素中心,否则左右两边的卡片向默认中心缩放时会产生一个空隙
calc动态计算left位置
transition实现动画

在看完下方的css代码后你可能会注意到为什么右边的卡片(carousel-card-right)定位用了计算left的方式而不是简单的right:0,这是因为transition只能对相同的可度量的css属性产生过渡动画,left和right同时出现时transition无效,在这里我们只能让所有卡片都用left或者都用right进行定位.

$carousel-card-width: 542px !default;
@import './button/style'; //引入子组件样式代码
@import './card/style';
@import './dots/style';
.carousel-container{
  width: 100%;
  position: relative;
  height: 250px;
}

.carousel-cards {
  height: 196px;
  .carousel-card-size {
    width: $carousel-card-width;
    height: 196px;
  }
  
  .carousel-card-middle {
    z-index: 99;
    position: absolute;
    left: calc(50% - #{$carousel-card-width/2}); 
    bottom: 0;
    top: 0;
    margin: auto;
    transition: all 0.5s;
  }
  
  .carousel-card-left {
    position: absolute;
    z-index: 98;
    top: 0;
    bottom: 0;
    left: 0;
    margin-top: auto;
    margin-bottom: auto;
    transform: scale(0.82,0.82);//卡片缩放
    transform-origin: 0% 50%; //左边卡片的元素中心迁移到最左方
    transition: all 0.5s;
  }
  
  .carousel-card-right {
    position: absolute;
    z-index: 98;
    top: 0;
    bottom: 0;
    left: calc(100% - #{$carousel-card-width}); 
    margin-top: auto;
    margin-bottom: auto;
    transform: scale(0.82,0.82);
    transform-origin: 100% 50%;//右边卡片的元素中心迁移到最右方
    transition: all 0.5s;
  }
  
  .carousel-card-dock {
    position: absolute;
    left: calc(50% - #{$carousel-card-width/2});
    bottom: 0;
    top: 0;
    margin: auto;
    transition: all 0.5s;
    transform: scale(0.82,0.82);
  }
  
  .carousel-button-left {
    position: absolute;
    left: 15px;
    bottom: 0;
    top: 0;
    margin: auto;
    z-index: 100;
  }
  
  .carousel-button-right {
    position: absolute;
    right: 15px;
    bottom: 0;
    top: 0;
    margin: auto;
    height: 30px;
    z-index: 100;
  }
}


.carousel-bottom-dots {
  position: absolute;
  width: 100%;
  bottom: 0;
  text-align: center;
}

3.4 效果图

import React, { useState } from 'react'
import Carousel from '@/components/carousel/Carousel'
const Test = ()=>{
  const carouselCards = [
    {text:'独家',img:'/resourcebed/picture?md5=9062dc80bb47556c3565efa18f1dcc32'},
    {text:'独家',img:'/resourcebed/picture?md5=5624a16a955d3f52088afe8b5eb5bb12'},
    {text:'新碟首发',img:'/resourcebed/picture?md5=98cfacc930a291d70d72eb1b447fe2b0'},
    {text:'MV首发',img:'/resourcebed/picture?md5=d6d853c35dd5d0fffec8a7509a13c070'},
    {text:'独家',img:'/resourcebed/picture?md5=2b97b3ec46feb33141d313e5379d8fd8'},
    {text:'新歌首发',img:'/resourcebed/picture?md5=ae05e981386806b0dde1f848f7333937'},
    {text:'独家',img:'/resourcebed/picture?md5=cfc889c49b92312bcb26fe8fc7e1e896'},
    {text:'新歌首发',img:'/resourcebed/picture?md5=6e9bdc761b49f6dc95bfda35b1981636'},
    {text:'独家',img:'/resourcebed/picture?md5=54298ef6444d6fa77584922e883ac5ca'}
  ]
  return (
    <div>
      <Carousel cards={carouselCards}/> 
    </div>
  )
}

export default Test

走马灯

结语

感谢看完文章.
其实在写这篇文章的时候,整个拆解工作已经做了很多了,此时几乎已经把整个云音乐所有可用的组件都抽象出来,正在写后台的CRUD,后面陆续要把一些接口对上.等整个工作完成后,我将考虑如何把这个组件库给独立出来,当然这又是另外一个不小的挑战~~
最后,第一次在掘金写文章,如有不当,烦请指正,多谢!

仓库地址和demo地址

GITHUB
DEMO