这几年疫情反复不断,距离上一次我拿起相机甚至可以追溯到两年前,实在是泪目。既然不能出去拍照,那只能继续宅着敲代码度日了,于是就有了这个在线相册的小项目,用来方便自己放上一些觉得还不错的摄影作品,当然,也可以记录与展示一些生活照片。
前言
这是一个纯前端的项目,不需要开发后端,没有数据库,只需要把照片丢进去,Git提交一下站点就出来了,就很简单。这篇文章中我会介绍整个项目的开发历程,看完你将收获:
- 一个 Vue3 + Node 快速生成漂亮的在线相册的项目
- 实现瀑布流、懒加载、Node解码图片、缩略图生成、元数据读取、提取颜色等技巧
- 快速集成一个音乐播放器,同步网易云歌单
如何部署
仓库地址:https://github.com/palxiao/fast-album
- fork项目,clone到本地,运行
npm run pre
安装依赖 - 把你的照片放至
resource
目录中 - 修改
config.json
配置(如有必要),运行npm run start
,自动完成部署
如何开启 Pages 说明
仓库开启了 Pages 功能才可以在线访问,我们可以使用 Github 或 Gitee 的 Pages 服务。
- GithubPages 开启方法:
- Gitee 码云 Pages 开启方法:
注意:码云开启Pages需要手持身份证照片实名,审核大概要 一个工作日时间
个人体验下来 Github 的访问速度比码云更快,当然有被墙的风险,我是绑了自己的域名并加了一层腾讯云免费CDN,应该还是比较稳定的。另外码云不会自动更新网站,要手动进仓库操作,并且还不能自定义域名,居然要收钱。。关键访问速度本就一般。。小了呀。
项目运行起来后大概是这个样子:
网速慢图片多挂着不管就行,提交好了Github会自动部署,部署完毕commit记录旁边会有个绿色的打钩,网站就可以访问了~
点此查看我的在线相册:m.palxp.com (手机访问效果最佳)
接下来我将从零开始,讲解我是如何一步步开发完成这个小项目的,Let's Go~
准备工作
开始前,我们需要做点微小的工作,项目结构不复杂,根目录view为前端工程目录,我们先在view目录中创建一个 Vite + Vue3 的项目,即便你没有接触过Vue3,访问Vite或Vue官网看看文档应该很快就能创建一个基础项目了。
好了,现在把照片放在resources目录,后续我们只要在电脑上对这个目录中图片进行管理,重新运行即可更新在线相册,这也就不必开发后台管理照片了。
首先创建一个index.js
文件使用NodeJs来遍历resources,我们要获得路径名称、图片大小等有用信息(使用 image-size 这个库可以快速获取图片大小),然后输出成JSON文件保存,最后将图片复制到目录view/public/
中:
const fs = require('fs')
const path = require('path')
const sizeOf = require('image-size')
const basePath = path.resolve('resources')
const jsonPath = path.resolve('view/src/assets/data/datalist.json')
const picsData = []
fs.readdir(basePath, async function (err, files) {
//遍历读取到的文件列表
for (let i = 0; i < files.length; i++) {
const filename = files[i]
const filedir = path.join(basePath, filename)
//根据文件路径获取文件信息,返回一个fs.Stats对象
const stats = await fs.statSync(filedir)
if (stats.isFile()) {
let dimensions = { url: filename, ...sizeOf(filedir) }
if (dimensions.width) {
cp(filedir, path.resolve(`view/public/${filename}`)) // 复制图片
picsData.push({ ...dimensions })
}
}
}
// 解析完毕,生成json
fs.writeFileSync(jsonPath, JSON.stringify(picsData))
})
// 复制文件
function cp(from, to) {
fs.writeFileSync(to, fs.readFileSync(from))
}
运行node index.js
,这样我们就可以开始编写前端项目了,把JSON文件引入即是图片列表数组,为了摆放好这些图片,我们先来写写图片列表的样式布局。
瀑布流排版
瀑布流是一种经典的图片排列模式,其特点是等宽不等高,在保持原图比例下显示布局。
目前并没有纯CSS可以完美实现的瀑布流方法,常见的多列布局(multi-columns)实际效果非常差强人意,它会得到如下的一个竖排布局效果,在实际应用中会感觉图片是乱序排列的。
其实用JS实现瀑布流也并不难,我们使用绝对定位布局,由于图片的宽度是平均分布的,只需要计算出图片的高度及left
、top
定位对应设置到图片元素上即可:
<template>
<div id="list">
<div :style="{ position: 'absolute', width: `${img.w}px`, height: `${img.h}px`, left: `${img.left}px`, top: `${img.top}px` }" v-for="(img, i) in list" :index="i" :key="'img' + i">
<img ............ />
</div>
</div>
</template>
我们用变量columnNums
表示有多少列,gap
表示图片间隔,容器总宽度可以由当前的DOM往父级查询parentNode.offsetWidth
来获取,那么图片在布局中的宽高以及left
值就可以计算出来了,而高度则用一个数组columnHeights
来储存,有多少列就往里存多少个元素,随着图片列表的循环一直累加取出计算就可以得到每张图片的top
定位了,简单几行代码搞定:
let columnNums = 2 // 有多少列
const gap = 8 // 图片之间的间隔
const columnHeights = [] // 列的高度
function waterfall(data) { // data为图片数组
const columnHeights: any = [] // 列的高度
let { offsetWidth: pW } = document.getElementById('list').parentNode.offsetWidth
pW -= gap * (columnNums - 1) // 总体宽度数值等于减去间隔
const newList = JSON.parse(JSON.stringify(data))
for (let i = 0; i < newList.length; i++) {
let index = i % columnNums
const item = newList[i]
item.w = pW / columnNums // 图片宽度
item.h = item.height * (pW / columnNums / item.width) // 图片高度
item.left = index * (pW / columnNums + gap) // 定位
item.top = columnHeights[index] + gap || 0 // 定位
columnHeights[index] = isNaN(columnHeights[index]) ? item.h : item.h + columnHeights[index] + gap // 记录列高度
}
return newList
}
两列效果: | 改成三列: |
---|---|
这时候虽然瀑布流的样式已经出来了,但拉到列表底部就会发现瘸腿了:
经过1.024
秒的思索,我马上发现了问题所在,上面的代码仅仅只是把图片按左右的顺序依次往下排列,而每张图片高度是不一样的,这就导致出现尾部空白的现象,解决的办法也很简单,每次找出最短的那一列来插入图片即可,我们已经将高度都存在了columnHeights
这个数组中,通过往Math.min()
传入解构数组得到最小值,再用indexOf
得到下标,就可以知道下一张图片该插入哪一列了,修改上面方法中的最后一行代码:
function waterfall(data) { // data为图片数组
// ...........
// columnHeights[index] = isNaN(columnHeights[index]) ? item.h : item.h + columnHeights[index] + gap
// TODO: 上面这行代码改为找出最短列计算高度
if (isNaN(columnHeights[index])) {
columnHeights[index] = item.h
} else {
index = columnHeights.indexOf(Math.min(...columnHeights))
item.left = index * (pW / columnNums + gap)
item.top = columnHeights[index] + gap || 0
columnHeights[index] = item.h + columnHeights[index] + gap
}
}
以上就是实现瀑布流排版的全部核心代码,可以根据实际情况进行扩展,比如通过window.onresize
监听窗口宽度变化动态改变列数量重新排列等。
书架流排版
这个图片排版样式比较少见,但同样是等比例显示图片,区别于瀑布流的是图片不等宽,但同一行图片等高,所以我把它命名为书架流,看着是不是很像整齐排列在书架上的书本:
虽然看起来似乎是等高不等宽,但实际上每行高度并不都是一样的,因此我们需要一个阈值来决定每行高度可以被允许的上限,与瀑布流一样的是,列表整体宽度是已知的,所以核心是计算每行图片的高度,我们先来看看如何实现这个算法。
抽象问题用简单的数学问题描述往往更容易解决。假设某行存在2张图片,已知的实际宽高分别为w1
、h1
和w2
、h2
,而在列表中的相对宽高我们则设为w1'
、h1'
和w2'
、h2'
,接着设列表总体宽高为W
和H
,已知的W
为列表父级div的宽度,我们的目的就是求这个H
的值。
此时由于在同一行中图片等高,于是有:h1'
=h2'
=H
又因为图片的比例不变,于是有:w1'
/h1'
= w1
/h1
,代入上面的式子可得:
w1'
/H
= w1
/h1
(同理得到另一个式子:w2'
/H
= w2
/h2
)
所以上面推导的两个式子可以得出图片在行内的宽度分别为:
而总体宽度为图片宽度相加:
代入可得到:
到这里我们已经可以轻松推导出计算高度H
的方法了:
上面推导过程我是在纸上完成的,回到代码中,我们可以使用递归来操作图片数组,得到一组计算好宽高的新数组,这里我设计了一个工厂函数factory
以及计算函数calculate
,计算函数核心就是利用上面的公式求图片高度,而工厂函数则是用来输出每一行的图片数组,通过判断计算的高度如果超出阈值,就继续增加这一行的图片(一个隐藏的事实是,该行图片越多高度肯定就会越小),如果高度在我们设置的阈值之内那么就将这些图片"打包"返回,在handleList
函数中会拼成一个二维数组,最后拍平就得到我们要的数据:
const gap = 8 // 图片之间的间隔
let limitWidth = document.getElementById('list').parentNode.offsetWidth // 宽度限制,列表父级div宽度
const list = JSON.parse(JSON.stringify(data)) // data为原始图片数组
const newList = await createNewArr(list)
async function createNewArr(list) {
const standardHeight = 180 // 高度阈值
const neatArr = [] // 整理后的数组
function factory(cutArr) {
return new Promise((resolve) => {
const lineup = list.shift()
if (!lineup) {
resolve({ height: calculate(cutArr), list: cutArr })
return
}
cutArr.push(lineup)
const finalHeight = calculate(cutArr)
if (finalHeight > standardHeight) { // 如果计算超出阈值,就继续加入图片
resolve(factory(cutArr))
} else {
resolve({ height: finalHeight, list: cutArr })
}
})
}
function calculate(cutArr) {
let cumulate = 0
for (const iterator of cutArr) {
cumulate += iterator.width / iterator.height
}
return (limitWidth - gap * (cutArr.length - 1)) / cumulate // 实际宽度需要减去图片间隔
}
async function handleList() {
const { list: newList, height } = await factory([list.shift()])
neatArr.push( newList.map((x) => { x.w = (x.width / x.height) * height; x.h = height; return x }))
if (list.length > 0) {
await handleList()
}
}
await handleList()
return neatArr.flat()
}
图片预览与查看
前面我们已经写完了图片列表的布局,接下来我们还需要点击能单独放大查看图片,并且可以支持图片缩放与移动来观察细节,实现这些操作的关键点在于 CSS3 中的 transform
变换。而实现PC上的点击、移动,H5的手势操作,则离不开DOM事件监听:例如鼠标移动事件对应 mousemove
,移动端触摸移动则对应 touchmove
,而在本项目中我们不做两套适配,将仅通过指针事件(pointEvent
)进行多端统一的事件监听。
这一部分展开来讲篇幅不小,所以我又用 原生JS实现了一遍并把完整的过程和思路都写在了这篇文章中:《 原生JS手写一个优雅的图片预览功能,带你吃透背后原理》
获取照片元数据
即使你使用手机拍照,并不关心照片拍摄时的设备与其它参数,你最起码也应该知道这张照片是在何时拍摄的,而且照片的时间也是我们排序图片的一个重要依据。前面我们用NodeJs读取照片源文件时,尽管通过fs.stat
可以获取文件的创建/修改时间,但这并不能代表实际拍摄时间,此时我们就要通过 Exif (Exchangeable image file format) 来获得照片中记录的数据。
照片如果经过一些美图app的处理,元数据会被抹掉,在PS、LR等专业修图软件中导出成片时,也别忘了勾选保留照片元数据,另外微信传图(即使选择原图)也会丢失元数据,这类软件是出于保护隐私考虑。
在网页项目中我们可以直接引入exif.js
读取照片元数据,不过在本项目中,图片会先经过一个处理阶段,所以我们直接在Node中解析好数据:
安装一下 exif
这个库:npm install exif
const ExifImage = require('exif').ExifImage
new ExifImage({ image: filedir }, async function (error, exifData) {
if (!error) {
const { ImageWidth: width, ImageHeight: height, ModifyDate } = exifData.image // 获取到图片一些数据
let datetime = exifData.exif.DateTimeOriginal || ModifyDate // 元数据
// TODO: 解析出来的格式不标准,转化成我们可以使用的:
datetime && (result.datetime = datetime.split(' ')[0].replace(/:/g, '-') + ' ' + datetime.split(' ')[1].slice(0, 8))
if (JSON.stringify(exifData.gps) === '{}') {
// 定位属于隐私信息
}
}
})
这一步没太多好讲的,利用插件解析出来整理好需要的数据就可以了,只需要注意不要丢失照片文件的原始数据否则读取不到。
Node解码图片
在网页中通常我们可以利用HTML5提供的Canvas来获得解码图片的能力,但在NodeJs环境中使用Canvas会比较麻烦,所以这里推荐 images 这个轻量级编解码库,处理图片的速度还是蛮快的,不过我在 MacOX 中安装最新版本会报错,只能锁定3.2.3
这个版本(如果你的网络情况不好导致安装失败,推荐使用 pnpm 安装)
为什么要解码图片呢?如果你经常接触移动端H5开发,应该碰到过图像翻转问题,因为手机照片通常会以原始的拍摄方向展示,可能你在手机上看着没什么问题,但是直接链接到网页上显示就会发现图像翻转了,所以我们需要手动翻转图像到正确位置并重新编码图像。
回到我们的项目中,配合 image-size 可以快速获得照片的方向值,判断如果方向偏移,那么直接使用 images 这个库编码保存,图像会自动翻转到正确方向。
const sizeOf = require('image-size')
const images = require('images')
const filedir = '' // <- 图像地址
const dimensions = sizeOf(filedir)
if ([6, 8, 3].includes(dimensions.orientation)) {
// TODO:通过解码写入来复制图片,判断方向是否正确。
images(filedir).save(.......) // 写入到新的地址
}
通过 images 重新编码的照片,元数据会被抹除,所以我们也可以配合前面 exif 检查照片如果存在 GPS 对象,就不直接复制照片而是重新编码照片以此消除隐私信息,当然相对的处理速度就会变慢。
Node生成缩略图
照片原始图像并不小,如果直接显示在列表中肯定是不行的,所以我们要在列表中显示缩略图,在实际点击某张图片时再请求原始图。
通过上面的图片编解码库,我们也可以方便地生成缩略图了:
// filedir: 图像地址,thumbSize: 压缩后图像宽度,quality: 压缩质量
images(filedir).size(thumbSize).save(.....), { quality: 70 })
以压缩到目标宽度500,质量70%为例,可以看到压缩率还是不错的:
生成预载占位颜色
尽管我们有了缩略图,加载图片列表速度大大提升了,但请求到显示图片一样需要时间,为了避免加载完成前的白屏,我们还可以用颜色来充当占位,保证DOM结构渲染完立即正常显示页面。
我们简单编写一个组件来取代 img 标签显示图片,当图片未加载完成时则显示一个颜色,注意这里的 img 不能用if
隐藏,应该让它存在DOM当中,否则onLoad
回调不会触发:
<template>
<div class="img">
<img v-show="!loading" :src="src" @load="loadDone" />
<div v-if="loading" class="color" :style="{ background: data.color }" />
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
props: {
src: {},
data: {},
},
setup(props) {
const loading = ref(true)
const loadDone = () => {
loading.value = false
}
watch(() => props.src, () => {
loading.value = true
})
return { loading, loadDone }
},
})
</script>
<style scoped>
.img {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
}
.img > img {
display: block;
width: 100%;
height: 100%;
}
.color {
width: 100%;
height: 100%;
border-radius: 4px;
animation: breathe 600ms ease-out infinite alternate;
}
/* 呼吸效果 */
@keyframes breathe {
0% { opacity: 0.8 }
100% { opacity: 1 }
}
</style>
如果随机生成个颜色占位,就显得太不专业了,我们可以用 colorthief 这个库来提取图片的主题色:
const ColorThief = require('colorthief')
ColorThief.getColor(image, quality).then((color) => {
rgbToHex(color) // color 为图片主颜色,格式为三原色数组
}).catch((err) => {})
参数解释:
image
: 在Node中运行时,这个参数为图像的路径。
quality
: 是一个可选参数,必须是值为1或更大的Integer,默认值为10。这个数字决定了在下一个采样之前跳过多少像素。数值越大,返回值的速度越快。
得到的颜色为数组(代表RGB三原色),我们可以转换成16进制颜色:
const rgbToHex = (rgb) => '#' + rgb.map((x) => {
const hex = x.toString(16)
return hex.length === 1 ? '0' + hex : hex
}).join('')
实际使用的过程中发现,在NodeJs中不仅处理速度很慢,还容易因为内存不足等问题发生崩溃,打开 colorthief 的包发现,其依赖的是 get-pixels 这个库解码图像,这是一个纯 JavaScript 实现的库,性能非常低,所以我这里没有直接使用原图来提取主题色,而是使用了前面我们用 images 解码图像生成的缩略图来作为提取颜色的图片,这样就保证了速度与稳定性。
懒加载
图片的加载到目前已经很流畅,但由于我们一进来就渲染了全部图片,这样图片太多时体验肯定会变差,作为一个有追求的页面仔,我们当然希望的是用户滑动/滚动到哪里,哪里才会显示图片,也就是常说的懒加载,那么如何实现呢?通常我们会想到利用页面滚动事件监听,上面写列表样式时我们已经可以得到图片的大小和位置,结合scrollTop
与浏览器窗口高度就不难判断图片是否在当前窗口中了,利用这个原理我还可以实现滚动时变换时间的效果:
但是这里图片懒加载我使用另一种方式实现:Intersection Observer,这是一个浏览器原生API,可以用于异步观察目标元素与其祖先元素或顶级文档视窗是否交叉的方法,简单讲就是可以监听一个元素是否出现在视窗当中,就这么简单粗暴,该API其实已经提出很久了,所以不用太担心兼容性问题。
onMounted(async () => {
await nextTick()
observer()
})
function observer() {
const observer = new IntersectionObserver((entries) => {
entries.forEach((item) => {
if (item.isIntersecting) {
// TODO: 换上真实的图片链接
observer.unobserve(item.target) // 停止监听该节点
}
})
}) //不传options参数,默认根元素为浏览器视口
document.querySelectorAll('.img-box').forEach((div) => observer.observe(div)) // 遍历监听所有图片DOM节点
}
实际效果如下所示:
来点音乐
只有图片太单调,来点音乐吧~进入网易云网页版,登录后打开控制台,点击进入“我的音乐”中,此时找到playlist
这个接口,就可以看到你的歌单啦,比如我这里创建了一个叫"PhotoGallery"的歌单,找到它的歌单id
,等下就把它搬进相册当中。
接下来我们使用 NeteaseCloudMusicApi 这个库,它利用CSRF伪造请求头来调用网易云官方API,通过它我们可以轻松获取到歌单数据以及播放音乐的链接了,虽然它需要部署在服务端,但是没关系,作者提供了 Vercel 部署的配置,仓库里的README.MD
文件有详细部署说明,这里就不过多赘述了,总之 Vercel 是国外一个部署前端应用的云平台,我们把node项目部署上去就可以直接使用了。
// api.js
import fetch from '@/utils/axios'
// 文档地址:https://binaryify.github.io/NeteaseCloudMusicApi/#/
// 获取歌曲播放链接
export const getUrl = (params: Type.Object = {}) => fetch(MUSIC_URL + '/song/url', params, 'get')
// 通过 id 获取歌单详情
export const getList = (params: Type.Object = {}) => fetch(MUSIC_URL + '/playlist/detail?id=5183094117', params, 'get')
Vercel 网址在国内被墙不能直接访问,不过我们可以通过配置 CNAME解决,如果你有自己的域名可以 fork一下玩玩,没有也没关系,本项目已经配置好了我的域名,不过稳定性不敢保证~
为了方便与美观,我们直接使用 APlayer 作为播放器界面,由于不是必要插件,所以我通过JS动态引入来使用,就不通过npm安装了。
// deferLoader.js 异步加载脚本方法
export default (type, url) => {
return new Promise((resolve) => {
const link_element = document.createElement(type)
if (type === 'script') {
link_element.setAttribute('src', url)
} else if (type === 'link') {
link_element.setAttribute('rel', 'stylesheet')
link_element.setAttribute('href', url)
}
document.head.appendChild(link_element)
link_element.onload = function () {
resolve()
}
})
}
这个组件很简单,调用接口,通过 id
获取歌单详情,然后通过歌单中的歌曲id获取到歌曲播放地址的url
,不过需要注意,由于我们的宗旨是搭建免费网站,缺少服务端,此方式因为没有登录状态,获取到的歌单曲目只会显示 10 首,但是也够用了(总不能把网易云账号密码写进前端项目里吧)
<template>
<div id="aplayer"></div>
</template>
<script>
import { defineComponent, onMounted, nextTick } from 'vue'
import * as api from './api'
import loader from '@/utils/widgets/deferLoader'
export default defineComponent({
setup() {
onMounted(async () => {
const ids = []
const listObj = {}
const { data: resList } = await api.getList()
for (const x of resList.playlist.tracks) {
ids.push(x.id)
listObj[x.id] = { name: x.name, artist: x.ar[0] ? x.ar[0].name : '', cover: x.al.picUrl }
}
let { data: audio } = await api.getUrl({ id: ids + '', realIP: '116.25.146.177' }) // ip是随便填的,不填会无法访问接口
audio = audio.data.map((x: any) => {
return Object.assign({ url: x.url }, listObj[x.id])
})
// 如果接口请求成功,下面开始启动播放器
await load() // 下载插件
await nextTick()
const APlayer = window.APlayer
new APlayer({
container: document.getElementById('aplayer'),
fixed: true, // 播放器会吸附在底部,没有兼容iphone黑边,所以下面补了临时处理
autoplay: true,
audio,
})
})
async function load() {
await loader('script', 'https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.js')
await loader('link', 'https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.css')
}
},
})
</script>
<style>
.aplayer.aplayer-fixed .aplayer-body {
bottom: calc(constant(safe-area-inset-bottom));
bottom: calc(env(safe-area-inset-bottom));
}
</style>
效果大概就是这样,非常朴实无华:
你在打码的时候又喜欢听些什么歌呢?
结尾
虽然只是闲暇之余随手做的小项目,主要是为了方便整理相机拍摄的照片,显示一些参数什么的,可能本身没有太多亮点,但回顾也有不少前端知识与技巧,姑且作为实战分享写下这篇文章,当然如果你喜欢这个项目,也可以快速制作自己的免费在线相册,分享你的美图和摄影佳作吧!
项目地址:https://github.com/palxiao/fast-album
我的在线相册:m.palxp.com (手机访问效果最佳)
以上就是文章的全部内容,感谢看到这里,希望对你有所帮助或启发!创作不易,如果觉得文章写得不错,可以点赞收藏支持一下,也欢迎关注,我会更新更多实用的前端知识与技巧。我是 茶无味de一天(公众号: 品味前端),期待与你共同成长~