【20230528】一些比较常见的前端功能的实现
最近赋闲在家,感觉编码能力也在退化。不过总也得给自己找点事情做,不然会烂掉。于是把一些比较常见的前端功能自己尝试着去实现一遍,也算是一种学习的途径。
轮播图
轮播图在前端开发中算是一个很常见的需求了,当然我们可以选择用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
-
翻到除第
1
,2
页的其余图片,此时图片区域整体需要向左移动,比如我们要翻到第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
对一些渲染信息做缓存
转载自:https://juejin.cn/post/7237828040281276472