代碼修鍊之路-木桶布局

這篇文章我們主要做三件事

1. 講解木桶布局的原理

2. 把這個效果做成個UI 精美、功能完善的小項目

3. 通過這個項目,演示如何去思考、如何去優化代碼

本文首發於 知乎專欄-前端學習指南

木桶布局原理

假設我們手裡有20張照片,這些照片可以在保持寬高比的情況下進行放大或者縮小。選定一個基準高度比如200px

  • 拿第1張照片,鎖定寬高比高度壓縮到200px,放到第一行
  • 拿第2張照片,高度壓縮到200px,放到第一行,圖片1的後面
  • ...
  • 拿第5張照片,高度壓縮到200px,放到第一行。oh,不好,空間不夠,放不下了
  • 把前面水平依次排放好的4個圖片當成一個整體,等比拉伸,整體寬度正好撐滿容器
  • 第5張照片從下一行開始,繼續...

以上,就是木桶布局的原理。

木桶布局項目

但現實場景遠比僅實現基本效果的DEMO更複雜,以 500px 官網 和 百度圖片 為例,主要考慮以下情況

  • 圖片從伺服器通過介面非同步獲取
  • 要結合懶載入實現滾動載入更多圖片
  • 當屏幕尺寸發生變化後需要重新布局

為了讓產品功能更強大我們還需要加入即時檢索功能,用戶輸入關鍵字即可立即用木桶布局的方式展示搜索到底圖片,當頁面滾動到底部時會載入更多數據,當調整瀏覽器尺寸時會重新渲染,效果在這裡。下圖是效果圖

大家一起來理一理思路,看如何實現:

  1. 輸入框綁定事件,當輸入框內容改變時,向介面發送請求獲取數據
  2. 得到數據後使用木桶布局的方式渲染到頁面上
  3. 當滾動到底部時獲取新的頁數對應的數據
  4. 得到數據後繼續渲染到頁面上
  5. 當瀏覽器窗口變化時,重新渲染

按照這個思路,我們可以勉強寫出效果,但肯定會遇到很多惱人的細節,比如

  1. 當用戶輸入內容時,如果每輸入一個字元就發送請求,會導致請求太多,如何做節流?
  2. 對於單次請求的數據,在使用木桶布局渲染時最後一行數據如何判斷、如何展示?
  3. 對於後續滾動非同步載入的新的數據,如何布局到頁面?特別是如何處理與上一次請求渲染到頁面上的最後一行數據的銜接?
  4. 當屏幕尺寸調整時,如何處理?是清空重新獲取數據?還是使用已有數據重新渲染?
  5. 數據到來的時機和用戶操作是否存在關聯?如何處理?比如上次數據到來之前用戶又發起新的搜索
  6. ......

當這些細節處理完成之後,我們會發現代碼已經被改的面目全非,邏輯複雜,其他人(可能包括明天的自己)很難看懂。

優化代碼

我們可以換一種思路,使用一些方法讓代碼解耦,增強可讀性和擴展性。最常用的方法就是使用「發布-訂閱模式」,或者說叫「事件機制」。發布訂閱模式的思路本質上是:對於每一個模塊,聽到命令後,做好自己的事,做完後發個通知

第一,我們先實現一個事件管理器

class Event {n static on(type, handler) {n return document.addEventListener(type, handler)n }n static trigger(type, data) {n return document.dispatchEvent(new CustomEvent(type, {n detail: datan }))n }n}n// useagenEvent.on(search, e => {console.log(e.detail)})nEvent.trigger(search, study frontend in jirengu.com)n

如果對 ES6不熟悉,可以先看看語法介紹參考這裡,大家也可以使用傳統的模塊模式來寫參考這裡。當然,我們還可以不藉助瀏覽器內置的CustomEvent,手動寫一個發布訂閱模式的事件管理器,參考這裡 。

第二,我們來實現交互模塊

class Interaction {n constructor() {n this.searchInput = document.querySelector(#search-ipt)n this.bind()n }n bind() {n this.searchInput.oninput = this.throttle(() => {n Event.trigger(search, this.searchInput.value)n }, 300)n document.body.onresize = this.throttle(() => Event.trigger(resize), 300)n document.body.onscroll = this.throttle(() => {n if (this.isToBottom()) {n Event.trigger(bottom)n }n },3000)n }n throttle(fn, delay) {n let timer = nulln return () => {n clearTimeout(timer)n timer = setTimeout(() => fn.bind(this)(arguments), delay)n }n }n isToBottom() {n return document.body.scrollHeight - document.body.scrollTop - document.documentElement.clientHeight < 5n }n}nnew Interaction() n

以上代碼邏輯很簡單:

  1. 當用戶輸入內容時,節流,並且發送事件"search"
  2. 當用戶滾動頁面時,節流,檢測是否滾動到頁面底部,如果是則發起事件"bottom"
  3. 當窗口尺寸變化時,節流,發起事件"resize"

需要注意上述代碼中 Class 的寫法 和 箭頭函數里 this 的用法,這裡不做過多講解。還需要注意代碼中節流函數 throttle 的實現方式,以及頁面是否滾動到底部的判斷 isToBottom,我們可以直接讀代碼來理解,然後自己動手寫 demo 測試。

第三,我們來實現數據載入模塊

class Loader {n constructor() {n this.page = 1n this.per_page = 10n this.keyword = n this.total_hits = 0n this.url = //pixabay.com/api/n this.bind()n }n bind() {n Event.on(search, e => {n this.page = 1n this.keyword = e.detailn this.loadData()n .then(data => {n console.log(this)n this.total_hits = data.totalHitsn Event.trigger(load_first, data)n })n .catch(err => console.log(err))n })n Event.on(bottom, e => {n if(this.loading) returnn if(this.page * this.per_page > this.total_hits) {n Event.trigger(load_over)n returnn }n this.loading = truenn ++this.pagen this.loadData()n .then(data => Event.trigger(load_more, data))n .catch(err => console.log(err))n })n }n loadData() {n return fetch(this.fullUrl(this.url, {n key: 5856858-0ecb4651f10bff79efd6c1044,n q: this.keyword,n image_type: photo,n per_page: this.per_page,n page: this.pagen }))n .then((res) => {n this.loading = falsen return res.json()n })n }n fullUrl(url, json) {n let arr = []n for (let key in json) {n arr.push(encodeURIComponent(key) + = + encodeURIComponent(json[key]))n }n return url + ? + arr.join(&)n }n}nnew Loader()n

因為載入首頁數據與載入後續數據二者的流程是有差異的,所有對於 Loader 模塊,我們根據定義了3個事件。流程如下:

  1. 當監聽到"search"時,獲取第一頁數據,把頁數設置為1,發送事件"load_first"並附上數據
  2. 當監聽到"bottom"時,根據數據判斷數據是否載入完了。如果載入完了發送"load_over"事件;否則把頁數自增,載入數據,發送"load_more"事件並附上數據

第四、我們來實現布局模塊

class Barrel {n constructor() {n this.mainNode = document.querySelector(main)n this.rowHeightBase = 200n this.rowTotalWidth = 0n this.rowList = []n this.allImgInfo = []nn this.bind()n }n bind() {n Event.on(load_first, e => {n this.mainNode.innerHTML = n this.rowList = []n this.rowTotalWidth = 0n this.allImgInfo = [...e.detail.hits]n this.render(e.detail.hits)n })nn Event.on(load_more, e => {n this.allImgInfo.push(...e.detail.hits)n this.render(e.detail.hits)n })nn Event.on(load_over, e => {n this.layout(this.rowList, this.rowHeightBase)n })nn Event.on(resize, e => {n this.mainNode.innerHTML = n this.rowList = []n this.rowTotalWidth = 0n this.render(this.allImgInfo)n })n }n render(data) {n if(!data) returnn let mainNodeWidth = parseFloat(getComputedStyle(this.mainNode).width)n data.forEach(imgInfo => {n imgInfo.ratio = imgInfo.webformatWidth / imgInfo.webformatHeightn imgInfo.imgWidthAfter = imgInfo.ratio * this.rowHeightBasenn if (this.rowTotalWidth + imgInfo.imgWidthAfter <= mainNodeWidth) {n this.rowList.push(imgInfo)n this.rowTotalWidth += imgInfo.imgWidthAftern } else {n let rowHeight = (mainNodeWidth / this.rowTotalWidth) * this.rowHeightBasen this.layout(this.rowList, rowHeight)n this.rowList = [imgInfo]n this.rowTotalWidth = imgInfo.imgWidthAftern }nn })n }nn layout(row, rowHeight) {n row.forEach(imgInfo => {n var figureNode = document.createElement(figure)n var imgNode = document.createElement(img)n imgNode.src = imgInfo.webformatURLn figureNode.appendChild(imgNode)n figureNode.style.height = rowHeight + pxn figureNode.style.width = rowHeight * imgInfo.ratio + pxn this.mainNode.appendChild(figureNode)n })n }n}nnnew Barrel()n

對於布局模塊來說考慮流程很簡單,就是從事件源拿數據自己去做布局,流程如下:

  1. 當監聽到"load_first"事件時,把頁面內容清空,然後使用數據重新去布局
  2. 當監聽到"load_more"事件時,不清空頁面,直接使用數據去布局
  3. 當監聽到"load_over"事件時,單獨處理最後一行剩下的元素
  4. 當監聽到"resize"事件時,清空頁面內容,使用暫存的數據重新布局

完整代碼在這裡

以上代碼實現了邏輯解耦,每個模塊僅有單一職責原則,如果新增更能擴展性也很強。

最近太忙很久沒寫文章了,如果你喜歡這篇文章或者覺得有用,點個贊給個鼓勵。

學習交流前端,盡在飢人谷,每周免費視頻、免費公開課、推薦文章、互助答疑...。Q 群:566475505, 暗號:前端


推薦閱讀:

勇敢冒險比數據分析更有價值
IPv6 真的可以比 IPv4 提高 1000 倍網速?
【絕密檔案】如何做好新產品的運營方案?
我們這個時代有哪些真正的佛學大師?

TAG:前端开发 | 前端工程师 | 互联网 |