PhotoGallery技术改造

最近离职后除了准备面试,多了许多时间对以往的前端个人项目进行改造。PhotoGallery就是其中一个个人很喜欢,但由于技术原因没做到完美的例子。最近准备抽出时间进行完善。

PhotoGallery是一个使用瀑布流展示电影海报以及花絮(当然都是个人比较心水的)的展示型页面。所有的电影图片也是从大一就开始收集的,一直囤积在人人上。页面的诸多功能是根据个人爱好设计的,如

  • 根据标签搜索
  • 相似图片
  • 基于tag的推荐等

总体来说,就是一个展示、介绍、推荐电影的地方。内容上还是很不错的。但是,去年寒假码代码时,前端技术还有待提高,很多地方写得并不严谨甚至比较丑陋。功能上也有些影响体验必须解决的痛点。大概有下面这些

  • 首屏渲染时间糟糕,这是因为图片过多(个人看的太多),又使用了Vue。同时Vue这种MVVM框架和精细化DOM操作一山不容二虎,因此,结合懒加载,效果依然不理想
  • 图片的时序排布并不自然,当时图省事,使用纯CSS方案实现瀑布流,牺牲了图片排序。图片只能从上到下再从左到右排序,和正常的阅读顺序并不一致。同时,最老的图片在最前,也不合理
  • 新增图片困难,这是由于github.io的纯静态的限制,当时采用了静态图片+meta存数据的方式来实现,后面看了电影再往里加图步骤繁琐,没有人性化的办法
  • 样式老气,细节粗糙
  • 本地调试困难,只使用了gulp来压缩js,css和json代码(代码少,不需要打包),不是全家桶脚手架,本地调试困难,且不能使用ES6语法
  • 代码语法和风格上不严谨,考虑结合在公司的规矩规范

针对上面大大小小几点,以及实际情况(比如只能使用github.io),考虑像下面这样优化

瀑布流布局实现方式待优化

放弃使用column-count的方案。原因有二:

  1. 排布顺序是从上到下,再从左到右,和日常经验相悖。类似地,使用flex的方案也不行
  2. 本身和懒加载的设计兼容性并不好,懒加载的新图片会导致整个页面的布局完全改变。类似地,使用grid的方案也不适合

因此考虑借鉴张鑫旭大神的方案,综合CSS和JS实现懒加载的滚动式瀑布流布局。

首先,根据屏幕宽度设置合理的列数,再逐列插入5张新图片,作为初始情况,同时,使用flex布局,设置flex-growjustify-content等属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Wall = {
// ...
data() {
return {
columns: Math.floor(document.body.clientWidth / columnWidth),
lastFlag: Math.floor(document.body.clientWidth / columnWidth) * 5 - 1
}
},
// ...
computed: {
items() {
// ...
},
itemsForColumns() {
let ret = Array.apply(null, Array(this.columns)).map(() => []);
this.items.slice(0, this.lastFlag + 1).forEach((item, i) => {
ret[i % this.columns].push(item);
})
// 每列先只加载5个
return ret;
}
},
// ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
#photos {
display: flex;
flex-flow: row wrap;
align-items: flex-start;
justify-content: space-around;
width: 100%;
}
.wall-column {
display: inline-block;
width: 250px;
margin: 0 8px;
vertical-align: top;
}

之后,监听可能会改变布局的所有情况,在我这个场景下,大概有三种:

  • 滚动(scroll)事件
  • 缩放事件(resize)事件
  • 筛选图片,在改变筛选条件,会导致图片数目的变化

下面分情况解决之。

resize时

监听windowresize事件,当最后一列的位置变化时,意味着布局已经改变,需要触发重排。可以看到上面的itemForColumns中依赖columnslastFlag两个状态。这里我们利用MVVM框架的优势,维护这两个值,就可以让Vue帮我们完成重排这样的繁琐操作。如下,当columns改变时,才会触发重绘。

1
2
3
4
5
window.addEventListener("resize", e => {
this.columns = Math.floor(document.body.clientWidth / columnWidth);
// 已经展示过的图片就不要隐藏了
this.lastFlag = Math.max(this.columns * 5 - 1, this.lastFlag);
});

筛选图片时

同理,通过关键词筛选图片时,改变了传入Wall的prop factor。会同步更新依赖factoritem,触发重排。有一点有注意的是,**lastFlag需要重新开始累加**。

1
2
3
4
5
6
watch: {
items() {
// 设置了筛选条件后,lastFlag需要重新开始累加
this.lastFlag = Math.floor(document.body.clientWidth / columnWidth) * 5 - 1;
}
}

scroll时

页面滚动时,需要加入新的图片到column中,我们要做的只是更改lastFlag即可,Vue会帮我们自动完成依赖lastFlagitemForColumns更新。重点在,我们如何知道lastFlag应该增加到多少。

我们回看下itemForColumns的逻辑,可以发现新增的图片是循环摆放的。这里额外说一句,尽管新图片放在最短列是最合理的,但是工程上并不合算(一是Vue下做这么精细的DOM操作不合适,二是获知最短列意味着DOM操作已经发生,即会有频繁的回流和重绘,这会影响渲染时间)。我们循环考虑每一列的最底部位置,如果在视口内,将图片更新到该列,直到所有列底部都在视口外。直到图片加载完毕。

另外,在实践时还发现一个问题,handleScroll里更改了lastFlag后,Vue本身有batch的优化,会在microtask栈空后,才会进行耗时的DOM操作。循环添加图片时,需要通过setTimeout异步完成,避免误判,在一次递归中加载了所有图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
handleScroll(top) {
if (this.items.length <= this.lastFlag) {
return;
}
let delta = -1;
for (let i = 0; i < this.columns; i++) {
let col = document.getElementById(`wall-${i}`);
if (col && col.offsetTop + col.clientHeight < top + (window.innerHeight || document.documentElement.clientHeight)) {
delta = i;
}
}
if (!!++delta) {
this.lastFlag += delta;
// 直到所有列下沿都不在视口内,
// 同时,设置时延,保证DOM操作完成后再继续handleScroll
setTimeout(() => { this.handleScroll(top); }, 0);
}
}

新增图片困难

由于github.io是纯静态的页面,我并没有后台环境,这个痛点短期内只能缓解不能根除。不过后面考虑将所有图片迁移到图床上,毕竟把图片数据也存在github上感觉还是……有点怪怪的。日后新增图片应该还是通过上传图片,更新meta.json的形式完成。

目前已将所有图片迁移到图床上,图床选择上参考了知乎上的推荐,使用七牛云存储,在个人实名认证后,免费部分有每月10G国内和国外下载流量,100万次GET和PUT请求次数,和10G存储空间。同时,它还提供对图片的压缩等管理,尽量减少流量。

迁移之后,仓库体积大大减小。之后日常更新时,图片单独上传,根据外链固定前缀得到最终路径。

meta.json的更新上,考虑自己写一个工具,根据新看的电影生成新的content。

已完成自动生成meta.json小工具,原理很简单,就不再介绍了。

细节美化

点比较细碎。整体借鉴了material design的思想。

影片详细信息的遮罩

考虑使用100%的遮罩,同时禁止背景滚动的形式展示图片的详细信息。起初打算用js去实现,后来发现下面的两点使得方案并不简单

  • scroll事件不能被cancel,这意味着不能打断默认的滚动行为
  • 从Mouse,Keyboard,Touch相关触发scroll事件的事件劫持滚动行为倒是可以,不过要监听的事件太多

只好作罢,通过纯CSS的方式,弹出浮层时,为body指定noscroll的类名。让浮层的overflow属性为scroll即可,同时设置浮层position属性为fixed即可。

1
2
3
4
5
6
7
8
#display {
position: fixed;
top: 0;
left: 0;
/*...*/
z-index: 100;
overflow-y: scroll;
}

使用缓动函数改进回到开头

这里使用定义域和值域都是[0,1]easeInOutCubic函数。

1
const easeInOutCubic = t => (t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1);

有了缓动函数后,使用requestAnimationFrame即可高效率地绘制JS动画。这里封装了一个animate函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const animate = (obj, prop, end, time, ease) => {
if (!obj || !obj[prop] || time < 100) {
return;
}
let start = obj[prop],
k = end - start,
timer = null,
tick = timestamp => {
if (timer === null) {
timer = timestamp;
}
let progress = timestamp - timer;
obj[prop] = start + ease(progress / time) * k;
if (progress < time) {
requestAnimationFrame(tick);
}
};

requestAnimationFrame(tick);
}

后面直接使用animate(document.body, "scrollTop", 0, 1000, easeInOutCubic)就可以圆滑地上移了。

移动端的优化

  • 使用媒体查询,在屏幕宽度更改时,隐藏一些元素
  • 在UA为移动端设备时,给出提醒

loading样式

在改变筛选条件时,设置loading样式提升用户体验。通过积累onload的计数和初始加载图片值进行对比,在达到该值时清除遮罩。

1
2
3
4
5
6
7
8
9
10
// ...
loadedCount(newCount) {
if (newCount >= Math.min(this.lastFlag + 1, this.items.length)) {
console.log("全部加载完成");
this.isHidden = true;
} else {
// console.log("Loading...");
}
}
// ...

杂项

  • 导航条交互优化
  • 导航条部分设置阴影,更改部分字体颜色和背景色
  • 修改触发分类方式,由click改为mousemove
  • 修改tab的样式
  • 将vue和lodash的js文件下载到本地,避免CDN失效的问题(之前已经遇到过一次),增加可靠性
  • 搜索条件不区分大小写

本地调试困难

因为代码较少,也只有一个文件,用不着webpack这样的全套解决方案。小巧易用的gulp就够了。针对我们需要的ES6转码,替换minify方案,本地调试等需要,都有对应的gulp插件解决问题。

gulp-babel

使用babel来转码,gulp-babel依赖babel-core@6或以上版本,同时设置presetes2015或ES7相关版本时也需要下载对应module。

我只需要es2015即可。

1
npm install --save-dev babel-core gulp-babel babel-preset-es2015

React和ES7的各阶段可以像下面这样选择安装

1
2
3
4
5
$ npm install --save-dev babel-preset-react
$ npm install --save-dev babel-preset-stage-0
$ npm install --save-dev babel-preset-stage-1
$ npm install --save-dev babel-preset-stage-2
$ npm install --save-dev babel-preset-stage-3

其他工具

  • **gulp-uglify**,压缩代码
  • gulp-rename,为压缩出的js重命名
  • **gulp-webserver**,开启本地服务,方便本地调试

上面这些插件按照文档操作即可,坑比较少,使用webserver时的gulp.src()入参通常为./,指以当前目录为服务器根目录。

最后还需要加一个watch,方便在调试时的修改能同步转码压缩。像下面这样

1
2
3
4
5
gulp.task('watch', function() {
gulp.watch('assets/src/*.js', ['js']);
gulp.watch('assets/src/*.css', ['css']);
gulp.watch('assets/src/*.json', ['json']);
})

最后整个gulpfile.js是下面的样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
var gulp = require('gulp'),
babel = require('gulp-babel'),
uglify = require('gulp-uglify'),
rename = require('gulp-rename'),
cleanCSS = require('gulp-clean-css'),
jsonminify = require('gulp-jsonminify'),
webserver = require('gulp-webserver');

gulp.task('js', function () {
return gulp.src('assets/src/*.js')
.pipe(babel({
presets: ['es2015']
}))
.pipe(uglify())
.pipe(rename({ suffix: '.min' }))
.pipe(gulp.dest('dist'));
});

gulp.task('css', function () {
return gulp.src(['assets/src/*.css'])
.pipe(cleanCSS({compatibility: 'ie8'}))
.pipe(gulp.dest('dist'));
});

gulp.task('json', function () {
return gulp.src('assets/src/meta.json')
.pipe(jsonminify())
.pipe(gulp.dest('dist'))
});

gulp.task('webserver', function() {
gulp.src('./')
.pipe(webserver({
livereload: true,
directoryListing: true,
open: true
}));
});

gulp.task('watch', function() {
gulp.watch('assets/src/*.js', ['js']);
gulp.watch('assets/src/*.css', ['css']);
gulp.watch('assets/src/*.json', ['json']);
})

gulp.task('dev', ['default', 'webserver', 'watch']);
gulp.task("default", ['json', 'css', 'js']);

代码优化

从略。HTML和CSS部分参照以往写的建议即可。除此以外,优化了下面的部分:

  • 删除了为兼容移动端额外使用的touchend事件,增加viewport的meta标签,消除移动端chrome浏览器点击300ms延时情况
  • 由于引入了babel,删除了兼容ES6语法的自己写的polyfill部分
  • 使用fetch API请求json
  • 优化导航条点击事件处理相关的代码
  • 减少图片数目和json体积

参考