一、今天要干啥

今天聊个实战话题:Web 侧怎么做多设备适配。

之前咱们一直在搞 HarmonyOS 原生应用的多设备适配,用断点、媒体查询、响应式组件那一套。但有些场景得用 Web,比如混合开发、H5 页面、跨平台应用,这时候咋整?

其实 Web 侧的多设备适配跟 HarmonyOS 原生侧思路差不多,核心都是断点划分 + 响应式布局。但 Web 有自己的特色:相对单位更多、媒体查询更灵活、还能用 JavaScript 动态计算。

这篇文章咱们就把 Web 侧的多设备适配能力捋一遍,从相对单位到媒体查询,从宫格布局到自定义弹窗,把实战中用到的方案都整理出来。

二、开干(边做边讲)

2.1 相对单位:响应式开发的基础

Web 开发里控制元素尺寸,得用 CSS 的单位。单位分两类:绝对单位和相对单位。

绝对单位就是像素(px),这玩意儿是个固定值,不管屏幕多大、父元素多大,它都不变。适合那些尺寸固定的元素,比如图标、固定宽度的按钮。

相对单位就不一样了,它会根据其他元素或窗口尺寸动态变化。这就是响应式开发的核心。

常用的相对单位有四种:百分比(%)、em、rem、vw/vh。

百分比这玩意儿最常用,相对于父元素的尺寸。比如父元素宽度 400px,子元素设置 50%,那就是 200px。这东西在响应式设计里用得特别多,让元素大小跟着父元素调整。

.parent {
  width: 400px;
}
.child {
  width: 50%; /* 200px */
}

em 相对于当前元素的字体大小。如果当前元素字体大小没设置,就继承父元素的。比如父元素字体 16px,子元素设置 1.5em,那就是 24px。这玩意儿适合做文本相关的尺寸控制,调整字体大小就能改布局。

p {
  font-size: 16px;
}
span {
  font-size: 1.5em; /* 24px */
}

rem 相对于根元素(html)的字体大小。跟 em 类似,但更稳定,因为所有 rem 都基于同一个根元素。全局调整只要改 html 的字体大小就行。

html {
  font-size: 16px;
}
p {
  font-size: 1rem; /* 16px */
}
span {
  font-size: 1.5rem; /* 24px */
}

vw/vh 这玩意儿最厉害,直接相对于视窗(浏览器窗口)。vw 是窗口宽度,vh 是窗口高度。比如窗口宽度 1920px,设置 100vw 就是 1920px。适合那些需要撑满窗口的场景,比如弹窗遮罩层。

.overlay {
  width: 100vw; /* 等于视窗宽度 */
  height: 100vh; /* 等于视窗高度 */
}

有个事儿得说一下:CSS 里的 px 单位会自动通过设备像素比换算,这让 px 在视觉效果上跟 HarmonyOS 的 vp 单位一样。这个特性消除了设备物理像素的差异,Web 应用迁移到 HarmonyOS 就更方便了。

2.2 媒体查询:断点适配的核心

媒体查询这玩意儿允许你根据设备特性(屏幕尺寸、分辨率、方向等)应用不同的样式规则。这就是 Web 侧断点适配的核心。

在 Web 页面适配 HarmonyOS 侧"一次开发,多端部署"时,横纵向断点对应的尺寸范围要跟 HarmonyOS 侧推荐的断点划分范围保持一致。不过有个细节得注意:Web 侧区分纵向断点用宽高比,HarmonyOS 侧用高宽比。别搞混了。

来看个例子:

@media (840px<=width) {
  .article {
    font-size: 20px;
  }
}

@media (320px<=width<600px) and (min-aspect-ratio: 1/1.2) and (max-aspect-ratio: 1/0.8) {
  .article {
    font-size: 14px;
  }
}

第一段代码:视口宽度不小于 840px 时,article 元素字体大小变成 20px。

第二段代码:视口宽度在 320px 到 600px 之间,宽高比在 1/1.2 到 1/0.8 之间,符合手机上下分屏的小窗口,字体大小变成 14px。

媒体查询能干的事儿不少,咱们来看两个常见场景。

场景一:修改字体大小

这个场景用媒体查询设置不同断点下的字体大小,实现响应式布局。以常见的 sm、md、lg 为例:

.title {
  font-size: 14px;
}
@media (320px<=width<600px) {
  .title {
    font-size: 16px;
  }
}
@media (600px<=width<840px) {
  .title {
    font-size: 18px;
  }
}
@media (840px<=width) {
  .title {
    font-size: 20px;
  }
}

这段代码的意思:

  • 默认字体大小 14px
  • sm 断点(320-600px)时,字体变成 16px
  • md 断点(600-840px)时,字体变成 18px
  • lg 断点(840px 以上)时,字体变成 20px

这样就能在不同屏幕尺寸上有不同的字体大小,阅读体验更好。

场景二:修改图片宽度

这个场景用媒体查询设置不同断点下的图片宽度,实现响应式布局:

.cover {
  width: 100px;
  height: 100px;
}
@media (320px<=width<600px) {
  .cover {
    width: 120px;
    height: 120px;
  }
}
@media (600px<=width<840px) {
  .cover {
    width: 160px;
    height: 160px;
  }
}
@media (840px<=width) {
  .cover {
    width: 240px;
    height: 240px;
  }
}

这段代码的意思:

  • 默认图片尺寸 100px
  • sm 断点时,图片变成 120px
  • md 断点时,图片变成 160px
  • lg 断点时,图片变成 240px

这样就能在不同屏幕尺寸上有不同的元素尺寸,显示效果更合适。

2.3 窗口事件:动态计算的补充

有些场景媒体查询和相对单位都不够用,得用 JavaScript 动态计算。

window 对象提供了 resize 事件,窗口大小变化时触发。可以用 window.innerWidth 获取窗口宽度,window.innerHeight 获取窗口高度,然后动态调整布局。

来看个例子,等比例修改字体大小:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Responsive Font Size on Resize</title>

  <style>
    html {
        font-size: 16px;
    }
    .content {
        padding: 20px;
    }
    .content h1,
    .content p {
        margin: 0 0 1em;
    }
  </style>

</head>

<body>
<div class="content">
  <h1>Responsive Font Size Example</h1>

  <p>Resize the window to see the font size change.</p>

</div>

</body>

</html>

<script>
  const root = document.documentElement;
  const initialScale = window.innerWidth / 1920;
  root.style.fontSize = `${initialScale * 16}px`;
  // Listen for window size change events
  window.addEventListener('resize', () => {
      const newScale = window.innerWidth / 1920;
      root.style.fontSize = `${newScale * 16}px`;
  });
</script>

这段代码的意思:

  • 初始时,根据窗口宽度计算一个缩放比例(窗口宽度 / 1920)
  • 根据缩放比例设置根元素的字体大小(缩放比例 * 16px)
  • 窗口大小变化时,重新计算缩放比例并更新字体大小

这样就能实现字体大小随窗口宽度等比例变化,适配效果更灵活。

2.4 宫格布局:网格排列的利器

CSS 提供了 grid 布局,跟 HarmonyOS 的栅格布局类似,把网页内容划分成网格,通过组合不同网格做出各种布局。

宫格布局有几个关键概念得搞清楚:

容器和项目:采用网格布局的区域叫容器,容器内部采用网格定位的子元素叫项目。

行与列:水平区域叫行,垂直区域叫列。

行间距与列间距:两行或两列之间的空白区域。

使用宫格布局的步骤:

第一步:设置容器属性,把容器的 display 属性设置为 grid。

第二步:确定元素的排列方式,包括列宽、行高和间距。

来看个例子,两行三列,列宽和行高都是 100px,行列间距 20px:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Demo</title>

</head>

<style>
  .container {
    display: grid;
    gap: 20px;
    grid-template-columns: 100px 100px 100px;
    grid-template-rows: 100px 100px;
  }
  .container .grid-item {
    background-color: #f6fdf5;
    text-align: center;
    line-height: 100px;
  }
</style>

<body>
<div class="container">
  <div class="grid-item">1</div>

  <div class="grid-item">2</div>

  <div class="grid-item">3</div>

  <div class="grid-item">4</div>

  <div class="grid-item">5</div>

  <div class="grid-item">6</div>

</div>

</body>

</html>

元素个数少的时候,可以逐个书写列宽,比如 grid-template-columns: 100px 100px 100px。但元素个数多的时候,比如十列,这样写可读性就差了。

这时候可以用 repeat() 函数简化书写。这个函数接收两个参数:第一个是重复次数,第二个是要重复的值。

比如上面的代码可以改成:

grid-template-columns: repeat(3, 100px);

效果一样,但代码更简洁。

宫格布局可以结合媒体查询实现不同设备上的最佳体验。通过设置不同断点的排列方式,达到在不同屏幕尺寸上显示不同效果的目的。

来看个例子:

sm 断点下,宫格以 4 列显示,行间距 12px:

@media (320px<=width<600px) {
  .grid-functions {
    grid-template-columns: repeat(4, 48px);
    row-gap: 12px;
  }
}

md 断点下,宫格以 6 列显示,行间距 20px:

@media (600px<=width<840px) {
  .grid-functions {
    grid-template-columns: repeat(6, 48px);
    row-gap: 20px;
  }
}

lg 断点下,宫格以 8 列显示,行间距 24px:

@media (840px<=width) {
  .grid-functions {
    grid-template-columns: repeat(8, 48px);
    row-gap: 24px;
  }
}

这样就能在不同断点下自动调整宫格的列数和间距,适配效果很好。

2.5 自定义弹窗:尺寸适配的细节

大尺寸设备上,弹窗得更大,不然内容太小看不清。通过媒体查询设置不同断点下的弹窗尺寸。

来看个例子:

sm 断点下,弹窗尺寸 328px * 344px:

@media (320px<=width<600px) {
  .custom-dialog {
    width: 328px;
    height: 344px;
  }
}

md 断点下,弹窗尺寸 360px * 378px:

@media (600px<=width<800px) {
  .custom-dialog {
    width: 360px;
    height: 378px;
  }
}

lg 断点下,弹窗尺寸 393px * 412px:

@media (800px<=width) {
  .custom-dialog {
    width: 393px;
    height: 412px;
  }
}

有个细节得注意:不仅弹窗尺寸要响应式适配,弹窗内容也得适配。弹窗内容高度定制,没法提供统一的适配方式,得根据内容自己想办法。

2.6 轮播布局:动态展示的技巧

轮播布局就是轮播图,多张图片轮流播放。原生 Web 没提供直接实现轮播图的组件,得用技巧或第三方组件库。

轮播布局的适配关键点有三个:

控制轮播元素的尺寸:用媒体查询和断点,或窗口事件,设置每个断点下的轮播图尺寸样式。

控制轮播元素的间距:根据排列方式选择方法。用 flex 布局时,推荐用 gap 属性定义间距;其他情况用 margin 属性。

控制每次轮播的位移距离:根据实现方案选择。用 translateX() 时,控制每次增加的步长;用绝对定位时,根据对应的位移属性控制。

来看个 React 实现的轮播图:

const Banner = () => {
  const banner = [
    { id: "001", url: "assets/banner01.png" },
    { id: "002", url: "assets/banner02.png" },
    { id: "003", url: "assets/banner03.png" },
    { id: "004", url: "assets/banner04.png" },
  ];

  const [currentIndex, setCurrentIndex] = useState(1);
  const [currentDot, setCurrentDot] = useState(0);
  const [width, setWidth] = useState<number>(0);
  const [singleOffset, setSingleOffset] = useState<number>(0);
  const [initOffset, setInitOffset] = useState<number>(0);
  const [gap, setGap] = useState(16);
  const [animate, setAnimate] = useState("transform 0.5s ease");
  const [dotVisible, setDotVisible] = useState(false);
  const wrapperRef = useRef<HTMLDivElement>(null);
  const totalItems = banner.length;

  useEffect(() => {
    const updateLayout = () => {
      const winWidth = window.innerWidth;
      if (winWidth < 600) {
        setGap(0); // sm 断点下元素间距
        setWidth(winWidth - 32); // sm 断点下元素宽度
        setSingleOffset(winWidth - 32); // sm 断点下单次位移
        setInitOffset(0); // sm 断点下初始偏移
        setDotVisible(true);
      } else if (winWidth < 840) {
        setGap(12); // md 断点下元素间距
        setWidth((winWidth - 48 - gap) / 2); // md 断点下元素宽度
        setSingleOffset(width + gap); // md 断点下单次位移
        setInitOffset(24); // md 断点下初始偏移
        setDotVisible(false);
      } else {
        setGap(16); // lg 断点下元素间距
        setWidth((winWidth - 250 - gap) / 2); // lg 断点下元素宽度
        setSingleOffset(width + gap); // lg 断点下单次位移
        setInitOffset(125); // lg 断点下初始偏移
        setDotVisible(false);
      }
    };

    updateLayout();
    window.addEventListener("resize", updateLayout);
    return () => window.removeEventListener("resize", updateLayout);
  }, [gap, width]);

  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentIndex((prev) => prev + 1);
      setCurrentDot((p) => (p + 1) % banner.length);
    }, 3000);
    return () => clearInterval(interval);
  });

  useEffect(() => {
    if (currentIndex === totalItems + 1) {
      setTimeout(() => {
        setAnimate("none");
        setCurrentIndex(1);
        setTimeout(() => {
          setAnimate("transform 0.5s ease");
        }, 50);
      }, 550);
    }
  }, [currentIndex, totalItems]);

  return (
    <div className="banner-container">
      <div
        className="banner-wrapper"
        ref={wrapperRef}
        style={{
          transform: `translateX(-${currentIndex * singleOffset - initOffset}px)`,
          transition: animate,
          gap: `${gap}px`,
        }}
      >
        {[banner[banner.length - 1], ...banner, ...banner].map(
          (item, index) => (
            <div
              style={{
                width,
              }}
              key={`${item.id}-${index}`}
              className="banner-item"
            >
              <img src={item.url} alt={`banner-${item.id}`} />
            </div>

          )
        )}
      </div>

      {dotVisible ? (
        <div className="swiper-dot">
          {banner.map((item, index) => (
            <div
              key={item.id}
              className={`dot${currentDot === index ? " dot-active" : ""}`}
            ></div>

          ))}
        </div>

      ) : (
        <></>
      )}
    </div>

  );
};

export default Banner;

这段代码的意思:

  • 根据窗口宽度动态计算轮播图的宽度、间距、位移距离
  • sm 断点下,元素间距 0px,元素宽度为窗口宽度减 32px,显示指示点

  • md 断点下,元素间距 12px,元素宽度为窗口宽度减 48px减间距再除 2

  • lg 断点下,元素间距 16px,元素宽度为窗口宽度减 250px减间距再除 2

  • 每 3 秒自动轮播一次
  • 使用 translateX 控制位移

这样就能在不同断点下实现不同的轮播效果,适配很灵活。

三、踩了哪些坑

3.1 相对单位选错了

一开始我总觉得百分比最好用,啥都用百分比。后来发现有些场景百分比不合适。

比如弹窗遮罩层,用百分比会有问题。父元素可能不是整个窗口,遮罩层就撑不满窗口。这时候得用 vw/vh,直接相对于视窗。

再比如文本相关的尺寸,用百分比也不好控制。调整父元素字体大小,子元素的文本大小不会跟着变。这时候得用 em 或 rem。

经验就是:得根据场景选择合适的相对单位,别一股脑全用百分比。

3.2 媒体查询断点搞混了

Web 侧和 HarmonyOS 侧的断点划分范围要保持一致,但有个细节容易搞混:纵向断点的宽高比定义不一样。

Web 侧区分纵向断点用宽高比,HarmonyOS 侧用高宽比。

比如手机上下分屏的小窗口,Web 侧判断条件是宽高比在 1/1.2 到 1/0.8 之间:

@media (320px<=width<600px) and (min-aspect-ratio: 1/1.2) and (max-aspect-ratio: 1/0.8) {
  ...
}

HarmonyOS 侧判断条件是高宽比在某个范围内,这就不一样了。

我一开始没注意这个细节,导致纵向断点的判断条件写错了,适配效果不对。后来仔细看了文档才搞明白。

3.3 弹窗内容没适配

我一开始只适配了弹窗的尺寸,没适配弹窗的内容。结果在大屏设备上,弹窗变大了,但内容还是那么小,看着特别别扭。

后来才知道,弹窗内容也得适配。弹窗内容高度定制,没法提供统一的适配方式,得根据内容自己想办法。

比如弹窗里的图片,得用媒体查询调整尺寸;弹窗里的文本,得用媒体查询调整字体大小;弹窗里的按钮,得用媒体查询调整间距。

这个细节不注意,用户体验就差了。

3.4 轮播图位移距离算错了

轮播图的位移距离得根据元素宽度和间距动态计算。我一开始直接用固定的位移距离,没考虑元素宽度变化。

结果在不同断点下,轮播图的位移距离不对,轮播效果就乱了。

后来改成根据元素宽度加间距动态计算位移距离,效果就对了:

setSingleOffset(width + gap);

这个细节得注意:位移距离必须跟元素宽度和间距匹配,不然轮播效果就错乱。

3.5 窗口 resize 事件没清理

用 JavaScript 监听窗口 resize 事件时,得在组件卸载时清理事件监听,不然会出现意想不到的问题。

我一开始没清理,导致页面切换后,resize 事件还在触发,控制台一堆错误。

后来加了清理逻辑:

window.addEventListener("resize", updateLayout);
return () => window.removeEventListener("resize", updateLayout);

问题就解决了。

四、最终成果

Web 侧的多设备适配方案整理出来了,核心能力有三种:

相对单位:百分比、em、rem、vw/vh,根据场景选择合适的单位。

媒体查询:断点划分 + 响应式布局,设置不同断点下的样式规则。

窗口事件:resize 事件 + 动态计算,用 JavaScript 动态调整布局。

这三种能力可以组合使用,适配效果更灵活。

布局实战方案有三个:

宫格布局:grid 布局 + 媒体查询,设置不同断点下的列数和间距。

自定义弹窗:媒体查询设置不同断点下的弹窗尺寸,内容也得适配。

轮播布局:窗口事件动态计算元素宽度、间距、位移距离,实现不同断点下的轮播效果。

这些方案都是实战中验证过的,可以直接用。

五、经验教训

Web 侧的多设备适配跟 HarmonyOS 原生侧思路差不多,但细节不一样。核心都是断点划分 + 响应式布局,但 Web 有自己的特色:相对单位更多、媒体查询更灵活、还能用 JavaScript 动态计算。

相对单位的选择得根据场景,别一股脑全用百分比。媒体查询的断点划分范围要跟 HarmonyOS 侧保持一致,但纵向断点的宽高比定义不一样,得注意。弹窗的内容也得适配,别只适配弹窗尺寸。轮播图的位移距离得根据元素宽度和间距动态计算,别用固定值。窗口 resize 事件得在组件卸载时清理,不然会出现意想不到的问题。

这些细节不注意,适配效果就差了。注意了,适配效果就好。

Web 侧的多设备适配能力挺强的,用好这些能力,就能实现"一次开发,多端部署"的效果。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐