这篇文章我们主要做三件事
1. 讲解木桶布局的原理
2. 把这个效果做成个UI 精美、功能完善的小项目
3. 通过这个项目,演示如何去思考、如何去优化代码
本文首发于 知乎专栏-前端学习指南
假设我们手里有20张照片,这些照片可以在保持宽高比的情况下进行放大或者缩小。选定一个基准高度比如200px
以上,就是木桶布局的原理。
但现实场景远比仅实现基本效果的DEMO更复杂,以 500px 官网 和 百度图片 为例,主要考虑以下情况
为了让产品功能更强大我们还需要加入即时检索功能,用户输入关键字即可立即用木桶布局的方式展示搜索到底图片,当页面滚动到底部时会加载更多数据,当调整浏览器尺寸时会重新渲染,效果在这里。下图是效果图
大家一起来理一理思路,看如何实现:
按照这个思路,我们可以勉强写出效果,但肯定会遇到很多恼人的细节,比如
当这些细节处理完成之后,我们会发现代码已经被改的面目全非,逻辑复杂,其他人(可能包括明天的自己)很难看懂。
我们可以换一种思路,使用一些方法让代码解耦,增强可读性和扩展性。最常用的方法就是使用「发布-订阅模式」,或者说叫「事件机制」。发布订阅模式的思路本质上是:对于每一个模块,听到命令后,做好自己的事,做完后发个通知
第一,我们先实现一个事件管理器
class Event { static on(type, handler) { return document.addEventListener(type, handler) } static trigger(type, data) { return document.dispatchEvent(new CustomEvent(type, { detail: data })) } } // useage Event.on('search', e => {console.log(e.detail)}) Event.trigger('search', 'study frontend in jirengu.com')
如果对 ES6不熟悉,可以先看看语法介绍参考这里,大家也可以使用传统的模块模式来写参考这里。当然,我们还可以不借助浏览器内置的CustomEvent,手动写一个发布订阅模式的事件管理器,参考这里 。
第二,我们来实现交互模块
class Interaction { constructor() { this.searchInput = document.querySelector('#search-ipt') this.bind() } bind() { this.searchInput.oninput = this.throttle(() => { Event.trigger('search', this.searchInput.value) }, 300) document.body.onresize = this.throttle(() => Event.trigger('resize'), 300) document.body.onscroll = this.throttle(() => { if (this.isToBottom()) { Event.trigger('bottom') } },3000) } throttle(fn, delay) { let timer = null return () => { clearTimeout(timer) timer = setTimeout(() => fn.bind(this)(arguments), delay) } } isToBottom() { return document.body.scrollHeight - document.body.scrollTop - document.documentElement.clientHeight < 5 } } new Interaction()
以上代码逻辑很简单:
需要注意上述代码中 Class 的写法 和 箭头函数里 this 的用法,这里不做过多讲解。还需要注意代码中节流函数 throttle 的实现方式,以及页面是否滚动到底部的判断 isToBottom,我们可以直接读代码来理解,然后自己动手写 demo 测试。
第三,我们来实现数据加载模块
class Loader { constructor() { this.page = 1 this.per_page = 10 this.keyword = '' this.total_hits = 0 this.url = '//pixabay.com/api/' this.bind() } bind() { Event.on('search', e => { this.page = 1 this.keyword = e.detail this.loadData() .then(data => { console.log(this) this.total_hits = data.totalHits Event.trigger('load_first', data) }) .catch(err => console.log(err)) }) Event.on('bottom', e => { if(this.loading) return if(this.page * this.per_page > this.total_hits) { Event.trigger('load_over') return } this.loading = true ++this.page this.loadData() .then(data => Event.trigger('load_more', data)) .catch(err => console.log(err)) }) } loadData() { return fetch(this.fullUrl(this.url, { key: '5856858-0ecb4651f10bff79efd6c1044', q: this.keyword, image_type: 'photo', per_page: this.per_page, page: this.page })) .then((res) => { this.loading = false return res.json() }) } fullUrl(url, json) { let arr = [] for (let key in json) { arr.push(encodeURIComponent(key) + '=' + encodeURIComponent(json[key])) } return url + '?' + arr.join('&') } } new Loader()
因为加载首页数据与加载后续数据二者的流程是有差异的,所有对于 Loader 模块,我们根据定义了3个事件。流程如下:
第四、我们来实现布局模块
class Barrel { constructor() { this.mainNode = document.querySelector('main') this.rowHeightBase = 200 this.rowTotalWidth = 0 this.rowList = [] this.allImgInfo = [] this.bind() } bind() { Event.on('load_first', e => { this.mainNode.innerHTML = '' this.rowList = [] this.rowTotalWidth = 0 this.allImgInfo = [...e.detail.hits] this.render(e.detail.hits) }) Event.on('load_more', e => { this.allImgInfo.push(...e.detail.hits) this.render(e.detail.hits) }) Event.on('load_over', e => { this.layout(this.rowList, this.rowHeightBase) }) Event.on('resize', e => { this.mainNode.innerHTML = '' this.rowList = [] this.rowTotalWidth = 0 this.render(this.allImgInfo) }) } render(data) { if(!data) return let mainNodeWidth = parseFloat(getComputedStyle(this.mainNode).width) data.forEach(imgInfo => { imgInfo.ratio = imgInfo.webformatWidth / imgInfo.webformatHeight imgInfo.imgWidthAfter = imgInfo.ratio * this.rowHeightBase if (this.rowTotalWidth + imgInfo.imgWidthAfter <= mainNodeWidth) { this.rowList.push(imgInfo) this.rowTotalWidth += imgInfo.imgWidthAfter } else { let rowHeight = (mainNodeWidth / this.rowTotalWidth) * this.rowHeightBase this.layout(this.rowList, rowHeight) this.rowList = [imgInfo] this.rowTotalWidth = imgInfo.imgWidthAfter } }) } layout(row, rowHeight) { row.forEach(imgInfo => { var figureNode = document.createElement('figure') var imgNode = document.createElement('img') imgNode.src = imgInfo.webformatURL figureNode.appendChild(imgNode) figureNode.style.height = rowHeight + 'px' figureNode.style.width = rowHeight * imgInfo.ratio + 'px' this.mainNode.appendChild(figureNode) }) } } new Barrel()
对于布局模块来说考虑流程很简单,就是从事件源拿数据自己去做布局,流程如下:
完整代码在这里
以上代码实现了逻辑解耦,每个模块仅有单一职责原则,如果新增更能扩展性也很强。
最近太忙很久没写文章了,如果你喜欢这篇文章或者觉得有用,点个赞给个鼓励。
饥人谷一直致力于培养有灵魂的编程者,打造专业有爱的国内前端技术圈子。如造梦师一般帮助近千名不甘寂寞的追梦人把编程梦变为现实,他们以饥人谷为起点,足迹遍布包括facebook、阿里巴巴、百度、网易、京东、今日头条、大众美团、饿了么、ofo在内的国内外大小企业。 了解培训课程:加微信 xiedaimala03,官网:https://jirengu.com
本文作者:饥人谷若愚老师