程式碼修煉之路-木桶佈局

飢人谷前端發表於2017-12-18

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

  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 {
    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()  
複製程式碼

以上程式碼邏輯很簡單:

  1. 當使用者輸入內容時,節流,並且傳送事件"search"
  2. 當使用者滾動頁面時,節流,檢測是否滾動到頁面底部,如果是則發起事件"bottom"
  3. 當視窗尺寸變化時,節流,發起事件"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個事件。流程如下:

  1. 當監聽到"search"時,獲取第一頁資料,把頁數設定為1,傳送事件"load_first"並附上資料
  2. 當監聽到"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()
複製程式碼

對於佈局模組來說考慮流程很簡單,就是從事件源拿資料自己去做佈局,流程如下:

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

完整程式碼在這裡

以上程式碼實現了邏輯解耦,每個模組僅有單一職責原則,如果新增更能擴充套件性也很強。

如果你喜歡這篇文章或者覺得有用,點個贊給個鼓勵。

相關文章