代码修炼之路-木桶布局

这篇文章我们主要做三件事

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"事件时,清空页面内容,使用暂存的数据重新布局

完整代码在这里

以上代码实现了逻辑解耦,每个模块仅有单一职责原则,如果新增更能扩展性也很强。

如果你喜欢这篇文章或者觉得有用,点个赞给个鼓励。

时间: 2024-11-09 02:59:29

代码修炼之路-木桶布局的相关文章

scala akka 修炼之路6(scala函数式柯里化风格应用场景分析)

胜败兵家事不期,包羞忍耻是男儿--斗牛士fighting,fighting,fighting... 小象学习和使用scala也一段时间了,最初小象学习scala主要为了学习spark生态,但是深入学习scala的一些特性后,深深被scala函数式和面向对象的风格所折服,不得不赞美设计这门语言的设计者.小象大学阶段在使用MATLAB做数据分析和自动化设计时,就非常喜欢使用MATLAB的命令行和面向矩阵运算的风格编写分析代码:喜欢使用java编写层次化和清晰的模块接口,而这些Scala语言设计中都有

程序员修炼之路

0.前言:在路上,再上路 在前言<程序员修炼之路-(0)前言:在路上, 再上路>中已经对知识体系进行了梳理,主要分为问题定义.算法与数据结构.系统平台API.编程语言实现.代码设计.测试验证等等.目前集中精力编写算法与数据结构部分,这可能也是最艰难的一部分.其他部分之前零零散散有所涉及,之后再慢慢补充. 1.计算机数学 离散数学与具体数学. 2.算法分析设计 对于这一部分的内容编排,主要以<算法设计与分析基础>.<算法>以及LeetCode算法题中的问题分类为横向,以&

架构师修炼之路

国内我们对架构师,项目经理,开发经理或者是技术总监这类职业定位普遍不都不清晰,很多的情况是“能者多劳”,一人身兼数职.达尔文的理论在我们的行业是绝对适用的,我从进入这个行业开始我就不甘于成为淘汰者,而我也由心地热爱着这个行业很年前我就立志要成为架构师(当年流行叫:系统分析员 )这目标进发.回首这10几年的磨练,我总结了一下一名合格的架构师应该具备哪一些方面的能力以及怎么才能得到这些能力 编码能力 架构师是一个职业,是一种经历了各种磨练与长年开发经验积累出来的.另外我一直认为:不会编码的架构师不是

大数据修炼之路

大数据修炼之路 大数据Spark是用Scala语言编写架构,因此,认真学习Scala是首选.Scala 是一种面向对象编程和函数式编程的多范式编程语言,和java有些不同又比java强大.下载了<Programming in Scala>,慢慢啃,虽然原汁原味,但是毕竟英文理解能力有限,有些非常专业的特性讲解还是一知半解,领悟不到要点,要参考中文版,这样啃一段又返回去看对应中文版加深理解,但是进度非常慢,甚是苦恼,有幸在51CTO上看到了王家林全免费的<Scala深入浅出实战初级入门经典

程序员修炼之路-(0)目录

前言:<程序员修炼之路-(0)前言:在路上, 再上路> 在前言中已经对知识体系进行了梳理,主要分为问题定义.算法与数据结构.系统平台API.编程语言实现.代码设计.测试验证等等.目前集中精力编写算法与数据结构部分,这可能也是最艰难的一部分,之后再考虑补充其他部分. 对于这一部分的内容编排,主要以<算法设计与分析基础>.<算法>以及LeetCode算法题中的问题分类为横向,以<算法设计与分析基础>中罗列的经典设计技巧为纵向,贯穿每一小节.因为传统算法书籍的结构

任务四十五:多功能相册之木桶布局

面向人群: 有一定HTML.CSS.JavaScript基础 难度: 困难 重要说明 百度前端技术学院的课程任务是由百度前端工程师专为对前端不同掌握程度的同学设计.我们尽力保证课程内容的质量以及学习难度的合理性,但即使如此,真正决定课程效果的,还是你的每一次思考和实践. 课程多数题目的解决方案都不是唯一的,这和我们在实际工作中的情况也是一致的.因此,我们的要求不仅仅是实现设计稿的效果,更是要多去思考不同的解决方案,评估不同方案的优劣,然后使用在该场景下最优雅的方式去实现.那些最终没有被我们采纳的

Vue木桶布局插件

????公司最近在重构,使用的是Vue框架.涉及到一个品牌的布局,因为品牌的字符长度不一致,所以导致每一个的品牌标签长短不一.多行布局下就会导致每行的品牌布局参差不齐,严重影响美观.于是就有了本篇的木桶布局插件. 木桶布局的实现是这样分步骤的: 首先对要填放的内容进行排序,筛选出每一行的元素. 再对每一行元素进行修整,使其美观对齐. 分步骤 一.根据需要选出每行的元素 ????首先获取我们需要的元素.和我们目标容器的宽度. Vue组件容器: <template> <div ref=&qu

一个女大学生的代码学习之路(二)

首先说一下,写这种文章是由于我在四月四日晚上,在手动搭建自己的第一个ssh项目的时候,遇到了一个配置的问题,怎么解决也弄不好,当时是四号晚上九点,我看了一眼表,我就想两个小时之内,我要是能搞定就算行了,但是其实,我搞到三点才OK(凌晨),那时候已经是五号了,转天是一家子去扫墓的时候,结果我居然以这种一个理由没有去,理由是我太累了么?我只是就是搭了一个架子,就是由于我的包太混乱了,导致不兼容,所以tomcat总也不启动,你可能认为好笑,这么简单一个问题怎么就费这多多时间呢,但是作为一个刚接触三框架

软件工程(C编码实践篇)课程总结——我的第一次代码实战之路

课程内容来自网易云课堂-软件工程(C编码实践篇)--中国科学技术大学孟宁老师主讲! 作者:魏红 魏红<软件工程(C编码实践篇)>MOOC课程作业http://mooc.study.163.com/learn/USTC-1000002006 一.心得体会 在写这篇博客时,我的心情不是想象中的如释重负,而是感慨和激动.作为一名理科生,代码对于我来说,陌生.遥远.不曾触及,所以当我开始接触到实验,需要自己动手的时候,我就崩溃了.哪怕是最简单的hello world小程序,我都折腾了好久,第一次的实验