likes
comments
collection
share

【20230528】一些比较常见的前端功能的实现

作者站长头像
站长
· 阅读数 14

最近赋闲在家,感觉编码能力也在退化。不过总也得给自己找点事情做,不然会烂掉。于是把一些比较常见的前端功能自己尝试着去实现一遍,也算是一种学习的途径。

轮播图

轮播图在前端开发中算是一个很常见的需求了,当然我们可以选择用Swiper,不过自己尝试去写一个轮播图,对编码能力提升还是很有帮助的。

其底层逻辑也很简单,将所有图片拼在一起形成内部图片整体,外层容器仅展示一张图片的区域(设置宽高然后overflow: hidden),内部图片整体相对外层容器定位(left: - width,其中width为轮播图宽度,也就是一张照片的宽度),切换图片实际就是改变图片整体在父元素中的定位。为了实现左右轮播,在每次动画结束后还要将图片整体中的图片内容进行调整。下面是具体实现需要注意的点:

  • 假设有五张照片,分别为0 1 2 3 4,则图片区域的数据应为六项:0 1 2 3 4 0其中第二项数据是当前展示的图片。这种设计是为了左右切换的流畅。

  • 每次触发切换,实际上做了以下几件事情:

    • 内部图片整体移动到对应图片,使其在显示区域显示

    • 调整图片数据:举个例子,初始图片区域数据为0 1 2 3 4 0当我们切换到第二张照片,图片区域最终状态应当是:1 2 3 4 0 1,即当前数据为第二项数据,且序列首尾数据相同。实现上述需求的代码表述如下(dataSource为图片链接数组):

      const newData = [...dataSource.slice(index),...dataSource.slice(0,index)]
      newData.unshift(newData[newData.length - 1])
      
    • 定位复原:将内部图片的定位复原到原来位置(left: - width),此时显示区域显示的图片刚好是对的

  • 换页一般会有两种模式:左右翻页和根据页码翻页。实际上左右翻页也是根据页码翻页的一种。举个例子: 当前图片区域数据为1 2 3 4 0 1,有以下几种情况要考虑:

    • 翻到第2个图片,由于当前就是第二个图片,所以不用翻

    • 翻到第1个图片,此时图片区域整体只需要向右移动一个width

    • 翻到除第12页的其余图片,此时图片区域整体需要向左移动,比如我们要翻到第4页,而第4页在图片区域数据中的索引为3,当前数据在图片区域数据中的索引为1,所以需要向左移动(3 - 1) * width

  • 切换动画的实现也有两种方式:

    • CSS实现:利用animation@keyframe

    • JS实现:dom操作结合requestAnimationFrame

下面是一个利用React实现的简易轮播图例子(使用JS实现切换动画):

import { useEffect, useRef, useState } from "react";
import { imgArr as _imgArr } from "../mock";

const _mockData = [
    "https://img0.baidu.com/it/u=1797544775,2912350681&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800",
    "https://img1.baidu.com/it/u=2396404004,3078543528&fm=253&fmt=auto&app=120&f=JPEG?w=1422&h=800",
    "https://img2.baidu.com/it/u=551125832,420285419&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500",
    "https://img1.baidu.com/it/u=1304255642,2961408783&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500",
];

export const Swiper = ({
    width = 600,
    height = 300,
    imgArr = _mockData,
}: any) => {
    const [renderItems, setRenderItems] = useState<any[]>([]);
    const [dataSource, setDataSource] = useState<
        {
            imgSrc: string;
            index: number;
        }[]
    >([]);
    /** 当前索引(用于切换页面) */
    const [currentIndex, setCurrentIndex] = useState<number>(0)
    /** 图片容器的Ref */
    const boxRef = useRef<HTMLDivElement | null>(null);
    /** 切换过程不允许被打断 */
    const changing = useRef<boolean>(false);

    /** 根据新的目标元素索引,更新图片渲染数组 */
    function updateRenderItems(targetIndex: number) {
        const _renderItems = [
            ...dataSource.slice(targetIndex),
            ...dataSource.slice(0, targetIndex),
        ];
        _renderItems.unshift(_renderItems[_renderItems.length - 1]);
        setRenderItems(_renderItems);
    }

    /** 切换到指定索引的图片 */
    function changeByIndex(targetIndex = 0, speed = 2   ) {
        if (!boxRef.current || changing.current || currentIndex === targetIndex) return;
        setCurrentIndex(targetIndex);
        changing.current = true;
        const boxEle = boxRef.current;
        let targeLeftPosition = 0;
        for (let i = 0; i < renderItems.length; i++) {
            if (renderItems[i].index === targetIndex) {
                targeLeftPosition = -width - (i - 1) * width;
                break;
            }
        }
        let count = width;
        function changePage() {
            if (boxEle.style.left == `${targeLeftPosition}px`) {
                updateRenderItems(targetIndex);
                return requestAnimationFrame(() => {
                    boxEle.style.left = `-${width}px`;
                    changing.current = false;
                });
            }
            if (targeLeftPosition > -width) count -= width / (speed * 20);
            else count += width / (speed * 20);
            boxEle.style.left = `-${count}px`;
            requestAnimationFrame(changePage);
        }
        requestAnimationFrame(changePage);
    }
    useEffect(() => {
        const _imgArr = imgArr.map((item: string, index: number) => ({
            index,
            imgSrc: item,
        }));
        setDataSource(_imgArr);
        if (_imgArr.length > 1)
            setRenderItems([_imgArr[_imgArr.length - 1], ..._imgArr]);
        else setRenderItems([_imgArr[0], ..._imgArr, _imgArr[0]]);
    }, [imgArr]);
    return (
        <>
            <div
                style={{
                    width,
                    height,
                    position: "relative",
                    overflow: "hidden",
                }}
            >
                <div
                    style={{
                        position: "absolute",
                        left: -width,
                        display: "flex",
                    }}
                    ref={boxRef}
                >
                    {renderItems.map((item) => (
                        <div style={{ width, height }}>
                            <img src={item.imgSrc} style={{ width: "100%", height }} alt="" />
                        </div>
                    ))}
                </div>
                <div style={{ position: 'absolute', width, height: 40, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', columnGap: 10 }}>
                    {
                        dataSource.map((_, index) => <div style={{ width: 10, height: 10, cursor: 'pointer', background: index === currentIndex ? '#2e8dff' : 'white', borderRadius: '50%' }} onClick={() => {
                            changeByIndex(index);
                        }}></div>)
                    }
                </div>
            </div>
            <button onClick={() => { changeByIndex((dataSource.length + currentIndex - 1) % dataSource.length); }}>上一页</button>
            <button onClick={() => { changeByIndex((currentIndex + 1) % dataSource.length); }}>下一页</button>
        </>
    );
};


图片懒加载实现

所谓图片懒加载,即当图片未进入可视区域时则不加载,到进入可视区域时候才加载。

我们可以使用IntersectionObserver来对当前图片dom是否进入视口进行监听,关于该API对各版本浏览器的兼容情况可见:"IntersectionObserver" | Can I use... Support tables for HTML5, CSS3, etc。可以看到这个方法的兼容性并不是很友好,对于老版本浏览器这里也有相对应的polyfill方案:GitHub - GoogleChromeLabs/intersection-observer: A polyfill for IntersectionObserver

要封装一个懒加载的图片组件,我们需要做以下的工作:

  • 使用IntersectionObserver进行监测,如果图片没进入可视区域,则渲染占位符;如果图片进入可视区域,则渲染图片;

  • 为了加载流畅,可以预留一定的偏移量(部分未进入视口的图片也可以进行加载);

  • 组件卸载时,对应的observer要取消掉,防止内存泄漏;

下面是基于React对图片懒加载组件的简单封装,在原生<img/>标签基础上增加了懒加载能力,使用层面上和原生img组件一致:

import React, { useEffect, useRef, useState } from "react";
interface LazyImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { }
const fallbackSrc = "data:image/png;base64,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"
const LazyImage: React.FC<LazyImageProps> = (props) => {
  const { src = "", alt, ...rest } = props;
  const [imageSrc, setImageSrc] = useState("");
  const imageRef = useRef<HTMLImageElement>(null);
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setImageSrc(src || "");
          observer.unobserve(imageRef.current!);
        }
      },
      { rootMargin: "0px 0px 100px 0px" }
    );
    if (imageRef.current) {
      observer.observe(imageRef.current);
    }
    return () => {
      if (imageRef.current) {
        observer.unobserve(imageRef.current);
      }
    };
  }, [src]);
  return <div ref={imageRef} style={{ width: '100%' }}>
    {
      !imageSrc ? <img src={fallbackSrc} alt={alt} style={{ width: '100%', height: 200 }} /> : <img src={imageSrc || fallbackSrc} alt={alt} {...rest} />
    }
  </div>;
};
export default React.memo(LazyImage);


实现瀑布流

瀑布流的实现一般在APP和小程序中比较常见,面试也偶尔会问到。下面提供几个实现的思路:

  • columns-count + columns-gap:该方法是瀑布流方案中最容易实现的一种,代码大致如下:

    .masonry {
        column-count: 3;
        column-gap: 0;
    }
    

    不过顺序是竖向的,我们往往会希望横向排列,所以一般不用这种。

  • 多列flex :父容器设置flex,子容器设置<mark>flex</mark>:1保证每列等宽,然后把图片丢到每一列里面去就可以了。由于数据中的一些图片比较长,一些图片比较短,我们可能需要进行计算,即每次把图片插入长度最短的列。

关于瀑布流的性能优化,有以下几个方面可以考虑:

  • 图片懒加载:没有进入可视区域的图片先不加载,等到进入可视区域再加载(参考前面的懒加载方案)

  • 虚拟滚动:瀑布流的需求往往伴随着海量的dom,这种场景下做虚拟优化是可行的,不过比较复杂

下面是一个简单的瀑布流组件的实现(不含虚拟滚动,因为写不出来):

import { useRef } from "react";
import { useEffect, useState } from "react";
import LazyImage from "./LazyImage";

export default ({ maxColumnsNum = 6, imgArr = [] }: {
    maxColumnsNum: number,
    imgArr: string[]
}) => {
    const [dataSource, setDataSource] = useState<any[][]>([]);
    const columnsLengthArr = useRef<number[]>([]);
    const latestDataSource = useRef<typeof dataSource>([]);
    const currentColumnsNum = useRef<number>();
    const renderedCount = useRef<number>(0);
    const currentContainerWidth = useRef();
    const containerRef = useRef<any>(null);
    function updateData(newData: typeof dataSource) {
        latestDataSource.current = [...newData];
        renderedCount.current++;
        setDataSource(newData);
    }
    function initImg(currentIndex = 0, reRender = false) {
        const columnsNum = currentColumnsNum.current;
        if (currentIndex === 0) {
            columnsLengthArr.current = new Array(columnsNum).fill(0);
            renderedCount.current = 0;
            latestDataSource.current.length = 0;
        }
        if (currentIndex === imgArr.length) return;
        if (latestDataSource.current.length === 0) {
            const newData = new Array(columnsNum).fill(0).map(() => [] as string[]);
            newData[0].push(imgArr[currentIndex]);
            updateData(newData);
        } else {
            const newData = [...latestDataSource.current];
            let minLengthIndex = 0;
            for (let i = 0; i < newData.length; i++) {
                if (newData[i].length === 0) {
                    newData[i].push(imgArr[currentIndex]);
                    updateData(newData);
                    return;
                }
                minLengthIndex =
                    columnsLengthArr.current[minLengthIndex] > columnsLengthArr.current[i]
                        ? i
                        : minLengthIndex;
            }
            newData[minLengthIndex].push(imgArr[currentIndex]);
            updateData(newData);
        }
        if (reRender)
            requestAnimationFrame(() => {
                initImg(renderedCount.current);
            });
    }
    function updateColumnsHeight(
        e: React.SyntheticEvent<HTMLImageElement, Event>,
        index: number
    ) {
        columnsLengthArr.current[index] += e.currentTarget.clientHeight;
        requestAnimationFrame(() => {
            initImg(renderedCount.current);
        });
    }
    function calculateColumns() {
        if (!containerRef.current) return;
        const containerWidth = containerRef.current.clientWidth;
        if (currentContainerWidth.current === containerWidth) return false;
        currentContainerWidth.current = containerWidth;
        if (containerWidth < 600) {
            if (currentColumnsNum.current === 2) return false;
            currentColumnsNum.current = 2;
        } else if (containerWidth < 900) {
            if (currentColumnsNum.current === 3) return false;
            currentColumnsNum.current = 3;
        } else {
            if (currentColumnsNum.current === maxColumnsNum) return false;
            currentColumnsNum.current = maxColumnsNum;
        }
        return true
    }
    useEffect(() => {
        const handleResize = () => {
            if (calculateColumns()) initImg(0, true);
        };
        window.addEventListener("resize", handleResize);
        return () => window.removeEventListener("resize", handleResize);
    }, []);
    useEffect(() => {
        calculateColumns();
        initImg(0, true);
    }, [imgArr]);
    return (
        <div style={{ width: "100%", overflow: "auto" }}>
            <div style={{ display: "flex", columnGap: 5 }} ref={containerRef}>
                {dataSource.map((item, index) => {
                    return (
                        <div key={index} style={{ flex: 1 }}>
                            {item.map((_item) => (
                                <LazyImage
                                    onLoad={(e) => updateColumnsHeight(e, index)}
                                    src={_item}
                                    alt=""
                                    style={{ width: "100%", maxHeight: 300, objectFit: "cover" }}
                                />
                            ))}
                        </div>
                    );
                })}
            </div>
        </div>
    );
};

上面的代码有几个值得注意的点:

  • 本例子采用多列flex的方案,为了每次都能将图片插入长度较短的列之后,需要一张一张图片渲染,图片渲染完触发onLoad获取图片的高度进行计算列的高度之后,开始下一张图片的渲染

  • 本例子对窗口大小进行监听,当窗口变化时,会进行重新渲染(列数减少)

  • 为了保证每次调用requestAnimationFrame过程获取的值是最新的,使用ref对一些渲染信息做缓存