响应式布局及其相关知识介绍

231 阅读16分钟

引子

布局视口,理想视口,初始包含块等概念都是什么意思,如何正确的区分,如何使用响应式布局呢?meta标签是什么意思呢,各种宽高相关的 API有什么区别,如何实现虚拟滚动呢?这篇文章会尽量理清这些容易让人困惑的概念~

几个概念

首先对于视口, CSS像素,物理像素等概念做一个简单的介绍~

屏幕尺寸|分辨率|像素密度

  • 屏幕尺寸:屏幕对角线的长度(单位为英寸)
  • 屏幕分辨率:屏幕在宽高上的物理像素数量,区别于图片的分辨率,图片分辨率是指图像在宽高上每英寸有多少个像素点
  • 屏幕像素密度:显示器在宽高上单位长度的像素数量

CSS像素|物理像素|设备像素比

  • CSS像素(逻辑像素):设备独立像素(device independent pixel),可以通过window.screen.width以及window.screen.height获取浏览器的CSS像素宽高

CSS像素的单位是px,我们说它是一个相对单位,相对性体现在在同一个设备上或不同设备之间每1px代表的物理像素是变化的

px的含义因上下文不同而不同:

  • CSS中: CSS像素(逻辑单位),相对单位,跨设备统一
  • 图像尺寸中: 位图像素(物理像素),是固定的像素网格
  • canvas / video: 通常是设备像素或可配置单位

那么对于一个图像,我们将100px x 100px的图像(物理像素)设置为width:100px,这个px是CSS单位

  • 在普通屏幕(DPR=1):图片原图大小 = 显示大小,没问题
  • 在 Retina 屏(DPR=2):需要提供 2x 图像资源,否则图片会被拉伸模糊
    • DPR=2 => 1 个 CSS 像素 = 2×2 个设备像素(4 倍区域)
    • 所以定义的 width: 100px 实际会占据 200×200 设备像素, 把图片“拉伸”到更大的空间去显示 →所以会变模糊!
    • 可以选择对于Retina屏提供更高分辨率的图,称为 2x 图

这个 logo@2x.png 的真实尺寸是 200×200 像素,但显示仍然是 100px 宽高,浏览器在 Retina 屏幕上用 200×200 的图像内容显示成 100×100 的逻辑尺寸,就能高分清晰、不模糊

  • 物理像素(设备像素):屏幕真正显示图像的最小单位,指的是手机或电脑屏上的发光点,手机的物理分辨率指的是一块屏幕横竖有多少物理像素排列
  • 设备像素比:每个 CSS 像素占多少设备像素,如 DPR=2 1 个 CSS 像素 = 2×2 设备像素(不缩放的情况下)

浏览器缩放 = 改变 CSS 像素 和 设备像素之间的映射比例

浏览器本来的 DPR 可能是 1(1 CSS px = 1 设备 px),把页面放大 200%,实际上就是让 1 个 CSS 像素 = 2 个设备像素, 于是页面看起来大了(因为内容需要更多真实像素来渲染)

布局视口|理想视口|视觉视口

在PC端,视口(viewport)指的是浏览器的可视区域,它的宽度和浏览器的宽度是一致的,不包含滚动条、工具栏等浏览器自身的UI。在相对单位中的vw 和 vh 就是相对于这里的视口,JavaScript 或媒体查询时用它判屏幕尺寸

初始包含块是<html>元素的包含块,是所有元素布局的根源块盒子,大小,位置默认是由"视口"决定的,受到<meta>的影响,所有绝对定位,百分比布局都要基于它计算

布局视口和meta标签

布局视口

布局视口是浏览器用来排版和布局页面的**「虚拟画布」**,它可能不同于设备实际的屏幕宽度,尤其是在移动端。利用meta标签可以改变布局视口~ meta标签

1. 没有设置 meta viewport 的情况(早期移动浏览器行为)

移动设备默认用宽虚拟视口(如980px)渲染网页(即:默认的布局视口宽度),由于默认情况下,移动设备的浏览器会假设网页是为 PC 编写的,所以会导致针对小屏的媒体查询(如max-width: 640px)基于虚拟宽度判断,无法触发。此时写 div { width: 100%; },它是相对于 ICB(980px)计算的,导致页面在小屏上横向超出。

2. 设置了 meta viewport 之后

<meta name="viewport" content="width=device-width, initial-scale=1.0">

通过 <meta name="viewport" content="width=device-width"> 将虚拟视口设为设备实际宽度(如375px),媒体查询即可基于真实屏幕尺寸生效,确保移动端优化样式正常应用。同时,由于ICB被设置了正确的值,百分比和定位可以正确计算生效了

这是一个典型的meta标签的写法:

<!-- 设置布局视口为设备宽度,并禁止缩放 -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
  • width=600 or width=device-width(让视口宽度等于设备的 CSS 逻辑像素宽度), 这里设置的取值会定义 vw 单位的计算基准
    • 一般没有特殊情况都推荐写 width=device-width
  • initial-scale:控制首次加载页面时的缩放级别
  • ......
视觉视口

用户此刻屏幕上可见的网页区域 用户进行 缩放操作(放大/缩小) 时,视觉视口会发生变化:

  • 缩小页面:视觉视口变大(能看到更多页面内容)
  • 放大页面:视觉视口变小(看到的内容更少)
理想视口

理想视口就是开发者希望页面最终呈现的那个视口宽度(一般等于设备宽度),用于实现无缩放、自然适配屏幕的最佳浏览体验。 主要是理解布局视口,后面两个了解即可~

相关API

在了解了相关的概念之后,我们对于宽高相关的API做一个简单的介绍,这些API大部分我们经常看到,但是很难做出合适的区分~

1.vw | vh | innerHeight | outerHeight

1.1 vw | vh
  • vw = 1% of the width of the viewport size.
  • vh = 1% of the height of the viewport size. 注意,视口只代表当前"可视区域",而不是整个页面的长度。

举个例子,也就是说:

  • 如果你的页面高度是 3000px,但视口高度只有 800px,
  • 那么 100vh 始终是 800px,不管页面多长。

这也是为什么说 vh 不受内容长度影响。同时如果动态缩放视口的话,这个视口也会动态变化的

1.2 window.innerHeight | window.outerHeight
  • window.innerHeight 视口高度
  • window.outerHeight 整个浏览器窗口高度(包括工具栏、地址栏、边框等)

1.3 window.pageXOffset | window.pageYOffset(window.scrollX | window.scrollY)

window.pageYOffset 和 window.pageXOffset: 这两个属性都属于window对象, 用于获取整个页面的垂直和水平滚动距离,这两个属性的值和 scrollYscrollX 的值一样,通常用于计算页面的滚动位置。

console.log(window.pageYOffset); // 当前页面的垂直滚动距离
console.log(window.pageXOffset); // 当前页面的水平滚动距离

console.log(window.scrollY); // 当前页面的垂直滚动距离
console.log(window.scrollX); // 当前页面的水平滚动距离
1.4 window.screen.width | window.screen.height
  • window.screen.width => 设备整个屏幕的宽度(包括浏览器外部,比如状态栏)
  • window.screen.height => 设备整个屏幕的高度(也是物理像素)

2. clientHeight | offsetHeight | scrollHeigh

在介绍了window对象身上几个常见的属性之后,接下来介绍元素身上几个常见的属性. 首先区分是 clientHeight、offsetHeight、以及scrollHeight这三个概念,在overflow看到一个回答(what-is-offsetheight-clientheight-scrollheight)很形象,这里直接粘过来了~

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #MainDIV {
        border: 5px solid red;
      }
    </style>
  </head>
  <body>
    <button id="offset">offsetHeight & offsetWidth</button>
    <button id="client">clientHeight & clientWidth</button>
    <button id="scroll">scrollHeight & scrollWidth</button>

    <div
      id="MainDIV"
      style="margin: auto; height: 200px; width: 400px; overflow: auto"
    >
      <div style="height: 400px; width: 500px; overflow: hidden"></div>
    </div>
    <script>
      function whatis(propType) {
        var mainDiv = document.getElementById("MainDIV");
        if (window.sampleDiv == null) {
          var div = document.createElement("div");
          window.sampleDiv = div;
        }
        div = window.sampleDiv;
        var propTypeWidth = propType.toLowerCase() + "Width";
        var propTypeHeight = propType + "Height";

        var computedStyle = window.getComputedStyle(mainDiv, null);
        var borderLeftWidth =
          computedStyle.getPropertyValue("border-left-width");
        var borderTopWidth = computedStyle.getPropertyValue("border-top-width");

        div.style.position = "absolute";
        div.style.left =
          mainDiv.offsetLeft +
          Math.round(parseFloat(propType == "client" ? borderLeftWidth : 0)) +
          "px";
        div.style.top =
          mainDiv.offsetTop +
          Math.round(parseFloat(propType == "client" ? borderTopWidth : 0)) +
          "px";
        div.style.height = mainDiv[propTypeHeight] + "px";
        div.style.lineHeight = mainDiv[propTypeHeight] + "px";
        div.style.width = mainDiv[propTypeWidth] + "px";
        div.style.textAlign = "center";
        div.innerHTML =
          propTypeWidth +
          " X " +
          propTypeHeight +
          "( " +
          mainDiv[propTypeWidth] +
          " x " +
          mainDiv[propTypeHeight] +
          " )";

        div.style.background = "rgba(0,0,246,0.5)";
        document.body.appendChild(div);
      }
      document.getElementById("offset").onclick = function () {
        whatis("offset");
      };
      document.getElementById("client").onclick = function () {
        whatis("client");
      };
      document.getElementById("scroll").onclick = function () {
        whatis("scroll");
      };
    </script>
  </body>
</html>

上面可以直接看出三者的大概区别,下面具体来了解以下[client] [offset] 以及[scroll]相关的API

2.1 clientWidth、clientHeight、clientLeft、clientTop
 
    <style>
      .clientDOM {
        width: 200px;
        height: 200px;
        background-color: rgba(0, 0, 246, 0.5);
        border-top: 2px solid red;
        border-left: 4px solid red;
        border-right: 2px solid red;
        border-bottom: 2px solid red;
        padding: 10px;
        overflow: auto;
      }
      .inner {
        height: 400px;
      }
    </style>
   
    <div class="clientDOM">
      <div class="inner"></div>
    </div>
 
属性描述举例
clientWidth元素的可见内容区域的宽度,不包括滚动条、边框、外边距,但包括内边距获取内容区域的宽度(不包括滚动条和边框)
clientHeight元素的可见内容区域的高度,不包括滚动条、边框、外边距,但包括内边距获取内容区域的高度(不包括滚动条和边框)
clientLeft元素左边框的宽度获取左边框的宽度
clientTop元素上边框的宽度获取上边框的宽度

总结:client相关的属性关注的内容区域

2.2 offsetWidth、offsetHeight、offsetLeft、offsetTop

打开控制台可以看见,实际上先减去滚动条的区域,然后在计算Padding内的实际内容区域的,也就意味着如果我们本来设置的宽高一样,由于滚动条的存在,宽度实际上是被压缩的,我们需要增加宽度,或采取其他处理方式~

     * {
        padding: 0;
        margin: 0;
      }

      .offsetDOM {
        position: relative;
        left: 20px;
        top: 10px;
        width: 200px;
        height: 200px;
        background-color: rgba(0, 0, 246, 0.5);
        border: 2px solid red;
        padding: 10px;
        overflow: auto;
      }
      .inner {
        height: 400px;
      }
      
    <div class="offsetDOM">
      <div class="inner">inner content xxxxxxxx xxxxxxxx</div>
    </div>

在日常开发中用的比较多的应该是offsetTop: 表示该元素相对于其 最近的已定位祖先元素(即具有position属性为relative, absolute, fixed或sticky的祖先元素)垂直偏移量(距离)。如果该元素没有已定位的祖先元素,则offsetTop会返回相对于文档 的顶部的距离

举个例子:

属性描述举例
offsetWidth元素的总宽度,包含内容、内边距和边框,但不包括外边距获取元素的可见宽度(包括边框,不包括外边距)
offsetHeight元素的总高度,包含内容、内边距和边框,但不包括外边距获取元素的可见高度(包括边框,不包括外边距)
offsetLeft元素左边缘相对于最近定位祖先元素的左边缘的距离(包括滚动偏移)获取元素相对于其定位祖先元素水平位置
offsetTop元素上边缘相对于最近定位祖先元素的上边缘的距离(包括滚动偏移)获取元素相对于其定位祖先元素垂直位置
2.3 scrollWidth、scrollHeight、scrollLeft、scrollTop
属性描述举例
scrollWidth元素内容的实际宽度(包括溢出不可见部分),单位是像素该元素是个滚动元素,横向被其他元素撑开的时候,实际上获取的滚动块的宽度
scrollHeight元素内容的实际高度(包括溢出不可见部分),单位是像素该元素是个滚动元素,纵向被其他元素撑开的时候,实际上获取的滚动块的长度
scrollLeft元素左侧已被卷去的宽度,单位是像素。表示当前水平滚动条滚动了多少距离
scrollTop元素顶部已被卷去的高度,单位是像素。表示当前垂直滚动条滚动了多少距离。
 <style>
      * {
        padding: 0;
        margin: 0;
      }

      .scrollDOM {
        position: relative;
        left: 180px;
        top: 80px;
        width: 200px;
        height: 200px;
        background-color: rgba(0, 0, 246, 0.5);
        border: 2px solid red;
        padding: 10px;
        overflow: auto;
      }
      .inner {
        width: 250px;
        height: 400px;
      }
</style>
  
<body>
    <div class="scrollDOM">
      <div class="inner">inner content xxxxxxxx xxxxxxxx</div>
    </div>
    <script>
      const divEl = document.querySelector(".scrollDOM");
      const debounceFn = (fn, delay) => {
        let timer = null;
        function _debounce(...args) {
          if (timer) {
            clearTimeout(timer)
            timer = null
          }
          timer = setTimeout(() => {
            fn(...args)
          }, delay)
        }
        return _debounce
      }
      const debounceScrollFn = debounceFn(() => {
        console.log("divEl.scrollWidth:", divEl.scrollWidth);
        console.log("divEl.scrollHeight:", divEl.scrollHeight);
        console.log("divEl.scrollTop:", divEl.scrollTop);
        console.log("divEl.oscrollLeft:", divEl.scrollLeft);
      }, 200)
      divEl.addEventListener("scroll", debounceScrollFn);
    </script>
</body>

3. getBoundingCLientRect() | 交叉观察器

接下来是两个判断元素可见性/元素交集的API

3.1 getBoundingCLientRect()

getBoundingCLientRect()是一个DOM元素的方法,返回元素的大小及其相对于视口的位置,它返回一个包含元素位置和尺寸的DOMRect对象。这个方法适用于获取元素在页面中的相对位置,尤其在滚动的时候 该DOMRect对象包含以下属性:

此外,还有widthheight(包含padding和border)

  • 注意getBoundingClientRect() 返回的坐标是相对于视口的,如果需要它相对于文档(即包括滚动偏移),可以加上 window.scrollYwindow.scrollX
const rect = element.getBoundingClientRect();
const docTop = rect.top + window.scrollY; // 相对于文档顶部
const docLeft = rect.left + window.scrollX; // 相对于文档左侧
3.2 交叉观察器

交叉观察器利用IntersectionObserver来实现一种异步观察两个元素之间交集的方式,这里涉及到observer相关的api,后续会单独介绍~

响应式设计

在了解了各种知识之后,我们来看看响应式设计。在《深入解析CSS》中谈到,响应式设计的第一原则是移动优先,首先考虑移动端布局,移动版设计就是内容的设计。做响应式设计时,一定要确保HTML包含了各种屏幕尺寸所需的全部内容。

移动优先

响应式设计的第一原则是 移动优先(Mobile First) 。这一理念要求开发者在设计和编码时,首先考虑移动端的布局与交互,然后再通过媒体查询(Media Queries)逐步适配更大尺寸的设备。

Tips: 做响应式设计时,一定要确保HTML包含了各种屏幕尺寸所需的全部内容。你可以对每个屏幕尺寸应用不同的CSS,但是它们必须共享同一份HTML。

在完成移动端的设计之后,一个重要的细节是添加视口的meta标签,加上这个标签之后,相当于告诉移动设置已经适配了小屏设备

媒体查询

媒体查询大家都比较熟悉,这里分享几个写媒体查询的tips:

  • 在媒体查询里更适合用em, em是基于浏览器默认字号的(通常是16px)

  • min-width和max-width是目前用得最广泛的媒体特征,但还有一些别的媒体特征,如下所示。

    • (min-height: 20em)—匹配高度大于等于20em的视口
    • (max-height: 20em)—匹配高度小于等于20em的视口
    • (orientation: landscape)—匹配宽度大于高度的视口
    • (orientation: portrait)—匹配高度大于宽度的视口
    • 其他可以查看 @media
  • 最后一个媒体查询的选项是媒体类型(media type)。常见的两种媒体类型是screen和print

    • 在需要的时候才会去考虑,但还是有必要思考用户是否想要打印网页的
    •         @media print {
                  * {
                    color: black ! important;
                    background: none ! important;
                  }
             }
      
  • 容器查询(@container)基于容器宽度,可以做到组件级响应式,可感知所在容器尺寸,这个在特定情况下用得到

流式布局

  • 流式布局是容器宽度随视口变化而自动调整的布局方式。
  • 相对固定宽度布局(如 width: 800px),流式布局避免了小屏下的横向滚动。
  • 常使用百分比宽度、auto 外边距、弹性盒子(Flexbox)等技术实现。

以小屏为基础设计(移动优先),用媒体查询控制不同屏宽的样式(媒体查询),让元素宽度随视口或容器变化自动适应(流式布局)——这是响应式设计的底层逻辑。

虚拟滚动

在做了前面这么多铺垫之后,让我们来看看虚拟滚动以及它的实现机制~

长列表滚动是性能优化里面一个常见的命题,这里对于长列表滚动的优化主要是分页 滚动,无限滚动以及虚拟滚动三种实现方式,这里主要讨论虚拟滚动和无限滚动两种实现方式

懒加载

利用scrollHeight和scrollTop可以先加载一些内容,等到触底(或者距离底部某一段距离再触发后续的加载)

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
        .container {
            height: 505px;
            width: 50%;
            background-color: rgba(0, 0, 246, 0.5);
            overflow-y: scroll;
            border: 1px solid black;
        }
        .item {
            border-bottom: 1px solid black;
            height: 50px;
            text-align: center;
            line-height: 50px;
        }
    </style>
  </head>
  <body>
    <div class="container">
        <div class="item">1</div>
        <div class="item">2</div>
        <div class="item">3</div>
        <div class="item">4</div>
        <div class="item">5</div>
        <div class="item">...</div>
    </div>

    <script>
        let count = 1
        const container = document.querySelector('.container'); 
        container.addEventListener("scroll", () => {
            const offset = container.scrollHeight - container.scrollTop;
            // console.log('offset',offset);
            const delta = 50;
            if (offset <= (500+delta)) {
                const newDom = document.createElement('div')
                newDom.setAttribute('class', 'item')
                newDom.innerText = `new DOM ${count++}`
                container.appendChild(newDom)
            }
        })
    </script>
  </body>
</html>

IntersectionObserver + div 占位

<script>
        let count = 1
        const container = document.querySelector('.container'); 
        const items = Array.from(container.children) || [];
        items.forEach(item => {
            const intersectionObserver = new IntersectionObserver(function (entries, observer) {
                if (!entries[0].isVisible) {
                    entries[0].target.backup = entries[0].target.innerHTML || entries[0].target.backup;
                    entries[0].target.innerHTML = '';
                    return;
                }
                entries[0].target.innerHTML =
                entries[0].target.backup || entries[0].target.innerHTML;
            }, {
                threshold: [0, 1],
                root:container,
                trackVisibility: true,
                delay: 100
            })

            intersectionObserver.observe(item);
        })
    </script>

通过这种方式IntersectionObserver为元素添加交互观察器,有交叉就显示内容同时缓存,没有的话就清空内容。可以监听最后一个元素的可见性来实现无限滚动,没有dom插入删除,结构相对来说较为稳定

数据截断

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
        .container {
            height: 405px;
            width: 50%;
            background-color: rgba(0, 0, 246, 0.5);
            overflow-y: scroll;
            border: 1px solid black;
        }
        .item {
            border-bottom: 1px solid black;
            height: 50px;
            text-align: center;
            line-height: 50px;
        }
    </style>
  </head>
  <body>
    <div class="container">
        <div class="item">1</div>
        <div class="item">2</div>
        <div class="item">3</div>
        <div class="item">4</div>
        <div class="item">5</div>
        <div class="item">6</div>
        <div class="item">7</div>
        <div class="item">8</div>
        <div class="item">9</div>
        <div class="item">10</div>
    </div>

    <script>
        const data = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20];
        const container = document.querySelector('.container');
        function addList(data, container) {
            data.forEach(item => {
                const dom = document.createElement('div');
                dom.setAttribute('class', 'item');
                dom.innerHTML = item;
                container.appendChild(dom)
            });
        }

       container.addEventListener('scroll', () => {
            const scrollTop = container.scrollTop;

            // 上边空白的高度
            const topHeight = scrollTop;
            const startIndex = Math.max(Math.ceil(topHeight / 40) - 2, 0);
            const endIndex = startIndex + Math.ceil(500 / 40);

            const show = data.slice(startIndex, endIndex + 1);

            // 计算下边剩余的隐藏区域高度
            const dataHeight = data.length * 40;
            const bottomHeight = dataHeight - 500 - scrollTop;

            const topDom = document.createElement('div');
            const bottomDom = document.createElement('div');
            topDom.style.height = topHeight + 'px';
            bottomDom.style.height = bottomHeight + 'px';

            // 还没到底
            if (bottomHeight > -100) {
                // 清空
                container.innerHTML = '';
                container.appendChild(topDom);
                addList(show, container);
                container.appendChild(bottomDom);
            }
            });
        window.onload = function() {
            addList(data, container)
        }
    </script>
  </body>
</html>

动态创建上下div块占位,渲染可视区域内的data数据截断

小结

  • 算是对于一直困扰我的一些概念简答整理了一下,很多地方依然可能不是很完备,只是目前尽我的能力完善了,不足之处欢迎批评指正。
  • 从五月初动笔至今,自己拖拖拉拉写了很久,期间经历了几次面试的失败,一度很失落。幸运收到了一家Offer,思来想去还是放弃打算回到实习的公司。大环境不好,当然自己能力也是不足,希望多动笔总结多实战提升自己的能力,加油啦~

参考文献