這篇文章我們主要做三件事
-
講解木桶佈局的原理
-
把這個效果做成個UI 精美、功能完善的小專案
-
通過這個專案,演示如何去思考、如何去優化程式碼
木桶佈局原理
假設我們手裡有20張照片,這些照片可以在保持寬高比的情況下進行放大或者縮小。選定一個基準高度比如200px
- 拿第1張照片,鎖定寬高比高度壓縮到200px,放到第一行
- 拿第2張照片,高度壓縮到200px,放到第一行,圖片1的後面 ...
- 拿第5張照片,高度壓縮到200px,放到第一行。oh,不好,空間不夠,放不下了
- 把前面水平依次排放好的4個圖片當成一個整體,等比拉伸,整體寬度正好撐滿容器
- 第5張照片從下一行開始,繼續... 以上,就是木桶佈局的原理。
木桶佈局專案
但現實場景遠比僅實現基本效果的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()
複製程式碼
以上程式碼邏輯很簡單:
- 當使用者輸入內容時,節流,並且傳送事件"search"
- 當使用者滾動頁面時,節流,檢測是否滾動到頁面底部,如果是則發起事件"bottom"
- 當視窗尺寸變化時,節流,發起事件"resize" 需要注意上述程式碼中 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個事件。流程如下:
- 當監聽到"search"時,獲取第一頁資料,把頁數設定為1,傳送事件"load_first"並附上資料
- 當監聽到"bottom"時,根據資料判斷資料是否載入完了。如果載入完了傳送"load_over"事件;否則把頁數自增,載入資料,傳送"load_more"事件並附上資料
第四、我們來實現佈局模組
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()
複製程式碼
對於佈局模組來說考慮流程很簡單,就是從事件源拿資料自己去做佈局,流程如下:
- 當監聽到"load_first"事件時,把頁面內容清空,然後使用資料重新去佈局
- 當監聽到"load_more"事件時,不清空頁面,直接使用資料去佈局
- 當監聽到"load_over"事件時,單獨處理最後一行剩下的元素 當監聽到"resize"事件時,清空頁面內容,使用暫存的資料重新佈局
完整程式碼在這裡
以上程式碼實現了邏輯解耦,每個模組僅有單一職責原則,如果新增更能擴充套件性也很強。
如果你喜歡這篇文章或者覺得有用,點個贊給個鼓勵。