Vue3瀑布流布局实现
介绍
以下是使用
Vue3
、TypeScript
实现的瀑布流,采用hook
方式。如果这篇文章启发到你,你可以尝试使用class
重构一下实现,加深理解。废话不多说,直接上代码!
未完成的功能(待更新):
1. 断点响应 🚀
2. 数据懒加载
3. Loading加载层效果
4. 拖拽自定义布局
思路
- 获取容器的宽度,根据列计算出每一个
item
的宽度,并初始化每一项的宽。- 根据列数,将第一行
column
个排列(先排一行),并将这一行的每一个元素的高度存储到colHeight
中。- 找出
colHeight
中高度最小的一个,并获取最小高度值和索引值。索引值和宽度得出left
,最小高度和间距得到top
,最后排列一个item
。- 重复步骤3。
准备
为了代码更简洁
优雅
一点,所以必须要封装几个工具函数,以及类型的定义
/**
* @description: 获取元素样式
* @param {HTMLElement} element 元素
* @param {string} styleName 样式名称
* @return {any}
*/
export const getStyle = (element: HTMLElement, styleName: string): any => {
return getComputedStyle ? getComputedStyle(element).getPropertyValue(styleName) : element.style[styleName as any];
}
/**
* @description: 设置元素样式
* @param {HTMLElement} el 元素
* @param {string} key 样式名称 | 样式对象
* @param {string} value 样式值
* @return {void}
*/
export const injectStyle = (el: HTMLElement, key: string | CSSProperties, value?: string):void => {
if(typeof key === 'string') {
el.style[key as any] = value;
}else {
Object.keys(key).forEach((k) => {
el.style[k as any] = key[k];
})
}
}
/**
* @description: 设置元素属性
* @param {HTMLElement} el 元素
* @param {string} key 属性名称 | 属性对象
* @param {string} value 属性值
* @return {void}
*/
export const injectProp = (el: HTMLElement, key: string | CSSProperties, value?: string):void => {
if(typeof key === 'string') {
el.setAttribute(key, value);
}else {
Object.keys(key).forEach((k) => {
el.setAttribute(k, key[k]);
})
}
}
export interface IUseWaterfallOptionsBreakpoint {
point: number // 断点
column: number // 列数
gap: number // 间隙
}
export interface IUseWaterfallOptions {
container: string | HTMLElement | null // 容器
column: number // 列数
gap: number // 间隙
isResize: boolean // 是否使用resize监听
isLazy: boolean // 是否使用懒加载
isLoadingLayer: boolean // 是否使用loading层
resizeDelay: number // resize延迟时间
resizeTimer: number // resize定时器
scrollDelay: number // scroll延迟时间 懒加载使用
scrollTimer: number // scroll定时器 懒加载使用
leftAnimateDelay: number // 左侧动画延迟时间
topAnimateDelay: number // 上侧动画延迟时间
animateEase: import('vue').CSSProperties['animation-timing-function'] // 动画缓动函数
breakpoint: Partial<IUseWaterfallOptionsBreakpoint> & Pick<IUseWaterfallOptionsBreakpoint, 'point'>[] // 断点
}
export interface IUseWaterfallResult {
raw: Partial<IUseWaterfallOptions>,
colWidth: number, // 列宽
colHeight: number[], // 列高度
children: HTMLCollection, // 子元素集合
getColWidth: () => number, // 获取列宽
}
实现
export const useWaterfall = (options: Partial<IUseWaterfallOptions>): IUseWaterfallResult => {
if (!options.container) throw new Error('Waterfall:container is required!');
const defaultOpts: Partial<IUseWaterfallOptions> = {
column: 4,
gap: 20,
isResize: true,
resizeDelay: 500,
leftAnimateDelay: 300,
topAnimateDelay: 300,
animateEase: 'ease'
}
// options保留默认设置和用户参数设置
options = { ...defaultOpts, ...options };
// opts用于动态更改设置
const opts: Partial<IUseWaterfallOptions> = reactive({
...JSON.parse(JSON.stringify(options)), ...defaultOpts
})
let children: HTMLCollection = null; // 子元素集合
let colWidth = 0; // 列宽
let containerObserver: MutationObserver = null; // 容器监测
const colHeight: number[] = []; // 列高度
/**
* @description 获取列宽
* @return {number} 列宽
*/
const getColWidth = (): number => {
const containerWidth = parseInt(getStyle(opts.container as HTMLElement, 'width'), 10);
const { column, gap } = opts;
return (containerWidth - (column - 1) * gap) / column;
}
onMounted(() => {
if (typeof opts.container === 'string') {
opts.container = document.querySelector(opts.container) as HTMLElement;
}
colWidth = getColWidth();
const containerStyle: CSSProperties = {
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
boxSizing: 'content-box',
paddingBottom: `${opts.gap}px`,
}
const itemStyle: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
transition: `left ${opts.leftAnimateDelay}ms ${opts.animateEase}, top ${opts.topAnimateDelay}ms ${opts.animateEase}`,
zIndex: 1,
width: `${colWidth}px`,
}
children = opts.container.children;
injectStyle(opts.container, containerStyle);
Array.from(children).forEach((item: any, index: number) => {
injectStyle(item, itemStyle);
injectProp(item, 'data-loaded', 'false');
injectProp(item, 'data-index', index.toString());
});
})
/**
* @description 初始化布局
* @param {object} o - 参数
* @param {boolean} o.force - 是否强制执行 默认false 为true时会重新计算整个瀑布流
* @return {void}
*/
const initLayout = (
o: { force: boolean; column?: number; gap?: number } = { force: false }): void => {
const { force, column = opts.column, gap = opts.gap } = o;
// 强制执行,表示重新计算整个瀑布流
if (force) colHeight.length = 0;
colWidth = getColWidth();
let top = 0;
let left = 0;
for (let i = 0; i < children.length; i++) {
const el = children[i] as HTMLElement;
// eslint-disable-next-line no-continue
if (el.dataset.loaded === 'true' && !force) continue;
injectStyle(el, 'width', `${colWidth}px`); // 需要预先设置宽度,高度才能被正确计算
// eslint-disable-next-line no-use-before-define
setTimeout(() => layout(el, i), 300);
}
function layout(el: HTMLElement, i: number) {
const height = el.clientHeight || parseInt(getStyle(el, 'height'), 10);
if (i >= column) {
const minIndex = colHeight.indexOf(Math.min(...colHeight));
top = colHeight[minIndex] + gap;
left = minIndex * colWidth + gap * minIndex;
colHeight[minIndex] = top + height;
} else {
colHeight[i] = height;
top = 0;
left = i * colWidth + gap * i;
}
injectStyle(el, {
position: 'absolute',
top: `${top}px`,
left: `${left}px`,
})
injectProp(el, 'data-loaded', 'true');
injectStyle(opts.container as HTMLElement, 'height', `${Math.max(...colHeight)}px`);
}
}
/**
* @description: 处理响应宽度断点
* @return {void}
*/
const processBreakpoint = (): void => {
if (opts.breakpoint.length) {
opts.breakpoint.forEach(item => {
if (window.innerWidth <= item.point) {
opts.column = item.column || options.column;
opts.gap = item.gap || options.gap;
}
})
// 如果当前宽度比断点最大值还大,就恢复默认
if (window.innerWidth > opts.breakpoint[0].point) {
opts.column = options.column;
opts.gap = options.gap;
}
}
}
/**
* @description: 监测容器的子项,处理新增项
* @return {void}
*/
const processContainerObserver = (): void => {
containerObserver = new MutationObserver(() => {
initLayout();
});
containerObserver.observe(opts.container as HTMLElement, {
subtree: false,
attributes: false,
characterData: false,
childList: true
});
}
/**
* @description resize事件处理函数
* @return {void}
*/
const handleResize = (): void => {
clearTimeout(opts.resizeTimer);
opts.resizeTimer = setTimeout(() => {
processBreakpoint();
initLayout({ force: true });
}, opts.resizeDelay);
}
onMounted(() => {
// 等待300ms,保证元素完全渲染
setTimeout(() => {
processContainerObserver();
if (opts.breakpoint.length) processBreakpoint();
initLayout();
if (opts.isResize) window.addEventListener('resize', handleResize);
}, 300);
})
onUnmounted(() => {
if (opts.isResize) window.removeEventListener('resize', handleResize);
if(containerObserver) containerObserver.disconnect();
});
return {
raw: opts,
colWidth,
colHeight,
children,
getColWidth,
}
}
使用
useWaterfall({
containerClass: 'y-waterfall__container',
});