前言
最近闲来无事就看看在github仓库中之前的代码,发现六年前写过一个瀑布流的js, 当时用的是本地的图片资源实现的,仔细看了看发现如果使用图片链接加载时在高度获取上有问题,所以打算重新开个坑实现一个瀑布流的插件开源,实现了之后才发现这里面的东西还不少,所以这里分享记录一下, 大家可以跟着我的思路走一遍, 如果只是想看怎么发布npm可从发布开始看起。
这里贴一个源码地址,可以去看看全部的实现代码,也可以通过 npm i waterfall-flow-js
下载使用该插件。 点击查看使用文档
实现瀑布流插件
瀑布流,又称瀑布流式布局。 是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部,适合宽度相等但是高度不一致的布局
实现思路
明确一下我们需要实现的功能
- 布局:我们采用flex 弹性布局,大概实现思路就是根据 容器元素的宽/图片的宽 计算出有多少列,接着生成列的
div
,图片只需要根据他们的列高度,找到最矮的做一个图片元素追加即可。 - 参数确定: 容器,图片链接的数组, 宽度, options参数配置(间距,是否需要监听变化做二次加载等配置...)
- 如果容器的尺寸发生变动,那我们需要把布局重新排列一次。
- 需要支持追加图片的功能, 因为有的使用者需要分页来加载布局。
代码实现
明确思路之后我们就可以开始写代码了,先写一个大概的实现函数 waterfallFlowLayout 同时把它导出,在函数中判断传入的参数是否缺失,以及如果resize配置为true那么就开启元素尺寸监听, 同时返回追加数据的函数以及停止监听的函数,这里需要注意的点就是this的指向。
scss
function waterfallFlowLayout({ el, imgWidth, imgs, options }) {
if (!el || !imgs || !imgWidth) {
throw new Error('缺少必要参数, 瀑布流创建失败')
}
const flow = new FlowLayout({ el, imgWidth, imgs, options })
// 如果resize配置为true那么就开启元素尺寸监听
if (options.resize) {
flow.resize()
}
return {
unResize: options.resize ? flow.unResize.bind(flow) : null,
appendData: flow.appendData.bind(flow),
}
}
export default waterfallFlowLayout
FlowLayout类的实现
接着就是实现 FlowLayout 这个类了,大概想一下它应该具备的功能, 有追加数据的appendData
, 有监听的 resize
以及停止监听的unResize
,同时它应该还有一个渲染方法,把图片生成为瀑布流布局。 OK, 大概理清思路之后,我们开始实现这个类
constructor
首先看constructor
中我们做了什么:
- 我们拿到了所有的入参(dom元素,图片数组, 图片宽度,配置)
- 接着我们配置一些默认值,间距我们配置
6px
免得图片贴在一起不好看 - 我们需要监听元素,所以先定义一个参数
resizeObserver
保存监听器 - 监听元素需要比较初始元素的宽度看看是否发生变化,所以我们也需要一个值保存一下初始宽
- 一个用于保存所有图片节点的数据,因为我们需要等图片加载好了之后才排列,我们使用图片预加载实现,所以保存一下图片的节点
- 我们需要保存每列的高度,比较最矮的列把图片放上去,这里拿一个数组保存每一列的高度
- 接着我们初始化信息,由于列的生成仅仅需要在第一次以及容器元素尺寸发生改变时重新计算,所以这里就放到初始化隔开
- 最后就是渲染了,首先我们需要预加载,加载之后才渲染生成瀑布流
kotlin
class FlowLayout {
constructor({ el, imgWidth, imgs, options }) {
this.el = el
this.imgWidth = imgWidth
this.imgs = []
this.options = options || {}
if (this.options.gap === undefined) this.options.gap = 6
this.resizeObserver = null
this.initWidth = 0
this.imgNodes = []
this.columnHeight = []
this.initLayout()
this.preLoadImg(imgs).finally(() => {
this.renderNode(imgs)
})
}
}
initLayout
OK,首先我们把能想到的变量都先准备好了,接着逐步开始实现需求, 首先是initLayout
这个函数。
我们先把容器给清空, 同时把保存的数据给初始化准备重新加载布局, 按照我们开始构想的, 我们需要容器元素是一个弹性盒子, 它下面所有的div都是一样宽的, 我们先设置一下样式,接着继续往下就是生成列了, 把列以及间距设置好, 把生成的 node 节点append 到容器元素,这样初始化就完成了
ini
let columns = parseInt(this.el.clientWidth / this.imgWidth)
this.el.innerHTML = ''
this.initWidth = 0
this.imgNodes = []
this.imgs = []
this.columnHeight = []
this.el.style.display = 'flex'
this.el.style.alignItems = 'flex-start'
this.columnHeight = []
for (let index = 0; index < columns; index++) {
this.columnHeight.push(0)
const box = document.createElement('div')
box.style.flex = '1 1 0%'
box.style.textAlign = 'center'
box.style.padding = `0 ${this.options.gap}px`
this.el.appendChild(box)
}
preLoadImg
我们的图片加载函数应该返回一个promise
, 为什么要等图片都获取到了才追加到相应的div原因我们已经说过了,因为图片节点没加载出来是高度撑不起来, 这时我们根据这个高度布局是不对的,所以我们生成img标签,同时给一些样式,首先是每列图片之间,我们默认给了个6px的间距,然后就是图片宽度了, 由于我们计算列的方式是容器宽-传入的图片宽,那么就可能发生这种情况(容器宽是999px, 但是图片宽传了200, 我们需要考虑这种情况,如果这样的话列间距就太大了,不好看,所以我们给一个配置 autofill,如果使用者不确定容器宽可以开启这个,我们会把图片自动撑满列宽,这样的间距就能保持一个好看的效果, 至于这里宽度减去12是因为我们列间距给了6,两边就是12,所以我们图片需要相应的把这个间距给扣除掉),我们在每个图片加载失败或者成功回调中判断,是否已经全部加载好了,加载完了之后就执行渲染函数 renderNode
javascript
const _this = this
const len = this.imgs.length
this.imgs = [].concat(this.imgs, imgs)
return new Promise((resolve) => {
imgs.map((item, i) => {
const index = len + i
_this.imgNodes[index] = ''
let img = new Image()
img.src = item
img.style.width = this.options.autoFill
? '100%'
: this.imgWidth - 12 + 'px'
img.style.marginBottom = '6px'
img.onload = function () {
_this.imgNodes[index] = img
if (_this.imgNodes.indexOf('') === -1) {
resolve()
}
}
img.onerror = function () {
_this.imgNodes[index] = null
if (_this.imgNodes.indexOf('') === -1) {
resolve()
}
}
})
})
renderNode
在实现render函数时我们需要考虑两点, 追加数据时我们也是调用的这个函数,所以不能只考虑第一次加载的情况,我们参数接受一个需要加载的图片数组, 循环这个数组, 也就是本次需要加载的数据有多少条,那么我们怎么确定使用 imgNodes
中的哪个节点呢, 我们只需要把 imgNodes
数组长度 - 本次需要加载的数组长度即可,假设一次传入20条数据, 第一次的 imgNodes
和传入的imgs 长度都是20,相减为0, 第一次加载就不影响,第二次追加数据时, 在图片节点生成后imgNodes
的长度就是40了, 而传入的数据为20, 那我们就从20这个下标开始append图片数据,这样就没问题了, 接着我们在每次循环中拿到columnHeight
中最小的数字,确定了需要把这次的图片放在哪个列里面,放好之后更新一下columnHeight
的值确保每次都能拿到最矮的列, 循环结束之后这时我们的瀑布流就实现好了, 这里别忘了保存一下容器的宽度,我们需要这个值用于比对尺寸的变化, 最后考虑到由于我们插件需要等图片加载完才排列,使用者可能需要给个loading啥的,这里给个配置,onload函数,让使用者可以在全部排列完做点什么事,比如关闭loading啥的
kotlin
renderNode(imgs) {
for (let i = 0; i < imgs.length; i++) {
const index = this.imgNodes.length - imgs.length + i
const node = this.imgNodes[index]
const minHeight = Math.min(...this.columnHeight)
const minIndex = this.columnHeight.indexOf(minHeight)
this.el.querySelectorAll('div')[minIndex].appendChild(node)
const nowHeight = this.el.querySelectorAll('div')[minIndex].clientHeight
this.columnHeight[minIndex] = nowHeight
}
this.initWidth = this.el.clientWidth
if (this.options?.onload) this.options.onload()
}
追加数据
由于我们之前写的时候已经把追加数据的情况都考虑到了,那么追加的时候就很简单了, 继续调用加载以及渲染方法就行
kotlin
appendData(data) {
this.preLoadImg(data).finally(() => {
this.renderNode(data)
})
}
元素尺寸监听 >>> ResizeObserver
在元素尺寸变化的监听上,我开始打算使用window.resize
,但是考虑到这样会覆盖掉之前用于的resize事件,同时也可能被覆盖掉,由于我们是一个通用插件,应该尽量避免这种情况,所以我使用了 ResizeObserverAPI去监听容器元素的变化
ResizeObserver
接口的作用就是监视 Element
内容盒或边框盒或者 SVGElement
边界尺寸的变化,它通过new ResizeObserver()
构造函数来获取一个新的 ResizeObserver
对象, 接着调用该对象上的 observe()
函数开始监听,这个函数接受两个函数,第一个为需要监听的dom,第二个参数是一个配置对象, 目前只有box这个参数,用于指定监听的盒模型。对这个API感兴趣的可以点击去MDN详细了解一下。
我们使用 ResizeObserver 监听宽度的变化, 这里我们写了个防抖函数, 用开始的宽度减去最后停止的元素宽度判断是否需要重新加载,如果需要重新加载的话,我们就开始执行,如我们开始所说只会在初始化以及元素尺寸变化时调用,这里准确的说应该是我们发现列的数量变了才需要重新渲染,不然我们是不需要重新渲染的,所以我们这里判断一下计算后的列是否变了,变了才继续执行,第一次进来列是0, 所以会计算列后开始渲染, 后续如果尺寸变了,我们就比对新尺寸的列是否和当前的列数量一致即可,再往下就是确定了列不一致,需要重新渲染,同时把停止监听的函数也给写好
kotlin
resize() {
let timer = null
this.resizeObserver = new ResizeObserver((entries) => {
let { width } = entries[0].contentRect
if (this.initWidth > 0) {
if (Math.abs(this.initWidth - width) > 10) {
if (timer !== null) clearTimeout(timer)
timer = setTimeout(() => {
let columns: number = parseInt(
(this.el.clientWidth / this.imgWidth).toString()
)
if(columns === this.columnHeight.length) return
const imgs = this.imgs
this.initLayout()
this.preLoadImg(imgs).finally(() => {
this.renderNode(imgs)
})
}, 500)
}
}
})
this.resizeObserver.observe(this.el, { box: 'border-box' })
}
unResize() {
this.resizeObserver.unobserve(this.el)
}
完成
至此我们功能就全部实现了,我们可以自己手动去试一试功能是否满足需求,在确定没问题之后我们就可以发布到npm上给其他人使用了
发布到npm
在讲解具体发布的流程前,先介绍一下关于 package.json
这个文件的作用以及一些它的配置说明,方便大家更好的明白如何发布一个npm的包
package.json
package.json
相信大家都不陌生了,所以我就介绍一些npm发布时需要注意的配置
-
version
:项目版本, 每次重新发布的话需要加版本号 -
author
:作者,它的值是你在npm网站的有效账户名,遵循"账户名<邮件>"的规则 -
description
:项目描述,是一个字符串。它可以帮助人们在使用npm search时找到这个包 -
keywords
:项目关键字,是一个字符串数组。它可以帮助人们在使用npm search时找到这个包 -
private
:是否私有,设置为 true 时,npm 拒绝发布 -
license
:开源说明,一般用MIT就可以了 -
bugs
:bug 提交地址 -
repository
:项目仓库地址 -
dependencies
:生产环境下,项目运行所需依赖 -
devDependencies
:开发环境下,项目所需依赖 -
bin
:内部命令对应的可执行文件的路径 -
type
:当type字段指定值为module 则采用ESModule规范,不指定也行,这里会自动进行类型推断,即 .mjs 的文件都按照es模块来处理, .cjs的文件都按照commonjs模块来处理 -
main
:项目默认执行文件,比如 require('webpack');就会默认加载 lib 目录下的 webpack.js 文件,如果没有设置,则默认加载项目跟目录下的 index.js 文件 -
module
:是以 ES Module(也就是 ES6)模块化方式进行加载,因为早期没有 ES6 模块化方案时,都是遵循 CommonJS 规范,而 CommonJS 规范的包是以 main 的方式表示入口文件的,为了区分就新增了 module 方式,但是 ES6 模块化方案效率更高,所以会优先查看是否有 module 字段,没有才使用 main 字段 -
browserslist
:供浏览器使用的版本列表 -
files
:被项目包含的文件名数组,它的优先级会大于.npmignore
,.gitignore
,通常默认在ignore
中忽略的问题是不会被传到npm的,但是如果该文件包含在files数组中,那么这个文件就会被传到npm,这点需要注意
了解完这些,我们就可以开始正式去发布到npm了,其实我们这个包直接就 npm init
然后配置好入口文件就可以直接发布了, 但是我们尽量还是写规范一些, 首先 npm create vite一个项目,选择 Vanilla, 选择TypeScript, 如下图
接着我们把我们的首先函数按照TS的方式写一遍,这里过程不再赘述,需要的可以自行去源码地址看看,写好之后我们创建一个vite.config.ts
文件,做一下打包配置,将打好的包输出为dist文件夹,指定构件库
php
import { defineConfig } from 'vite'
export default defineConfig({
build: {
outDir: 'dist',
target: 'es2020',
lib: {
entry: 'src/main.ts',
formats: ['es', 'cjs'],
},
},
})
配置好打包之后执行npm run build
,就可以看到项目多了个 dist
文件夹,这个就是我们要发布到npm上的最终文件了, 代码准备就绪接着就是去配置package.json
了,其他的根据项目自行配置, 重要的是下面这三个,在package.json
中我们有讲解, main是commonjs
规范的文件地址, module是es的文件地址, type的话, 配不配都行,会自动推断,但是我们还是配置一下
json
"main": "dist/waterfall-flow.cjs",
"module": "dist/waterfall-flow-js.js",
"type": "module",
配置完成之后就可以登陆 npm 开始发布了, 如果不知道是否登录或者当前登录的账号,可以使用指令npm whoami
查看,如果没有登录 npm login
登录即可, 登录后使用 npm publish
发布, 这里最容易遇到的问题就是包名已经有了, 这时候我们换一个包名称即可。
文末
大家虽然知道了怎么发布npm包,但是大家不要随意去发布一些无意义的包,因为删除已发布的包 npm unpublish
条件十分苛刻。