网页疯狂自动刷新,发生了什么?业务:我传了一张两亿像素的图片而已

杨鹏

一、背景

在一个普通的工作日,BUG反馈群突然发来一个视频。视频中显示,我们的H5应用在打开某个下发的资料时,加载图片的过程中陷入了不断刷新的死循环。这个问题直接影响了用户体验,也引发了我们的深入调查。

二、问题分析

收到反馈后,我立即在浏览器中打开该资料进行测试。虽然网页没有出现无限刷新的现象,但加载速度明显变慢,操作也非常卡顿。查看控制台和网络请求后,并未发现任何错误提示。然而,在对比加载其他资料时,这些问题并未出现,网页运行正常。因此,可以判断是某篇资料导致的性能问题,而不是网络或环境方面的问题。

1.性能分析

遇到性能问题时,第一步肯定是使用工具分析具体原因。我们使用 Safari 浏览器的时间线工具录制了性能数据。我的电脑是 MacBook Pro M3 芯片(16GB 内存),从中可以看到,平均 CPU 利用率竟然达到了 89.7%,并且主线程的大部分性能都用于渲染。由此我们可以推测,网页卡顿的问题大概率出现在浏览器的渲染过程中。

2.问题根源

我们的资料是通过后台富文本配置的,为了深入排查,我检查了问题资料的 HTML 元素,特别关注了页面中的富文本配置的内容。在此过程中,我惊讶地发现,这张图片的分辨率高达 4505px × 60615px,是一张超大像素的图片。通过对比其他资料中的图片,发现它们的分辨率明显较低,因此可以初步排除是其他因素引发的问题,最终确定是这张超大分辨率图片导致了浏览器性能瓶颈,进而引发了不断刷新的现象。

那么,不断刷新的原因是什么呢?在尝试过不同浏览器后,我们对这个问题有了些许线索:不同浏览器对于错误的处理行为是不同的。

  • 谷歌浏览器:在谷歌浏览器中,浏览器会提示崩溃。
  • Safari 浏览器:在 Safari 浏览器中,浏览器会提示该网页重复出现问题。
  • 钉钉内嵌浏览器:在我们的复现场景中,钉钉内嵌的浏览器则会导致页面不断刷新。

大家可以尝试使用不同的浏览器查看错误处理的方式,这对于选择合适的浏览器也有一定参考意义。

三、两亿像素图片如何被渲染的

为了进一步理解问题,我们先了解一下浏览器的渲染过程。按照浏览器渲染的时间顺序,一个网页从获取资源到最终展示在屏幕上,通常会经历以下几个子阶段:

  • 构建 DOM 树 :浏览器从网络或磁盘中获取HTML文档,并将其转换为DOM树。该树表示HTML文档的层级关系。
  • 样式合成 :浏览器将获取到的 CSS文件经过标准化、继承和层叠之后计算出最终的形成一个styleSheets表,也有另一种说法叫做CSSOM树
  • 布局阶段 :根据 DOM树和样式信息计算页面中每个可见元素的位置和大小,形成布局树,包括滚动条、文字换行等。
  • 分层:根据布局树将页面划分为多个图层,方便独立渲染和优化性能。
  • 绘制:渲染线程将图层拆成一个个绘制指令,最后集合成一个绘制列表。
  • 分块 :将图层进一步划分为小块(tiles),提升渲染效率。
  • 光栅化:将矢量图形的每个小块转化为像素图,生成最终的位图。
  • 合成 :将多个图层和小块按照正确的顺序合成,调用OpenGL(意为"开放图形库",可以在不同操作系统、不同编程语言间适配2D,3D矢量图的渲染。)生成最终的屏幕显示内容。

1.首次进入页面

当浏览器解析HTML时,遇到<img>标签便会创建相应的 DOM 节点,同时开始加载图片资源。加载图片时,浏览器会尝试根据CSS样式计算图片的渲染大小。然而,HTML的解析和样式计算是同步进行的,而图片资源加载通常被标记为低优先级,因此是异步完成的。

浏览器的渲染机制会优先保证页面的快速可见性(First Paint)。所以在第一次加载的时候,因为图片还没加载完成无法得知图片的真实宽高的,浏览器会先为其预留默认占位大小。

2.绘制

在绘制阶段,渲染线程将页面的每个图层拆解为绘制指令。

开发者的图层工具中,我们可以看到图层以及相应的渲染指令,在渲染那张两亿像素的图片中,其他的切割和绘制线条指令使用的时间在 0.1 微秒到 2 毫秒不等,但是那张两亿像素的图片绘制指令的执行时间相比于其他指令来说已经是惊人的 78 毫秒了。

3.分块

在分块阶段,通过safari 浏览器的时间线工具我们可以看到,在图片加载完成后,合成线程将图片分成几千到几十万像素不等的小块进行。

4.光栅化

光栅化阶段是将分块的矢量图形转化为屏幕上的像素数据:

  • 每个小块单独处理并通过插值算法(如最近邻插值、双线性插值)调整分辨率。
  • 处理后的光栅数据上传至 GPU 的纹理内存,供最终显示使用。

在处理两亿像素图片时,浏览器会使用相应的解码算法将压缩的图片解码为位图(Bitmap)。这一步涉及大量的解码计算,尤其对于两亿像素图片,对 CPU 和 GPU 的计算需求极高。如果在性能较弱的手机设备上运行,可能会导致显著的卡顿,甚至页面崩溃。

5.图片加载完成

当图片加载完成,浏览器会触发页面的重新布局。在本案例中,富文本内容中的<img>元素的宽度被富文本编辑器设置为 100%,而父元素的宽度也为 100%。由于图片未显式指定宽高,浏览器在初次渲染时会按照默认行为显示图片的原始尺寸。因此,在渲染过程中,页面可能会短暂地显示几帧图片的原始尺寸。随后,随着父元素的宽度被计算确定,浏览器会将图片调整为父元素的 100% 适配容器的宽度。

四、解决方案

经过排查,我们已经明确问题的根本原因是浏览器直接渲染这张超大分辨率图片,导致浏览器在渲染过程中消耗过多资源,从而引发了网页的重复刷新。因此,我们的解决方案主要集中在优化图片的加载和渲染过程,以避免浏览器因处理超大图片而产生性能瓶颈。

1.上传前校验

在富文本上传图片的时候,会将图片文件通过接口上传给后端,后端返回预览链接。为了避免因图片尺寸过大而导致的页面卡顿或浏览器崩溃,所以我们需要在图片上传的流程中加入尺寸校验机制。

a. 前端校验

我们以常用的wangEditor5富文本编辑器为例,在自定义上传回调中,通过FileReaderImage读取上传的图片文件,对图片进行校验。

  • FileReader:FileReader 是 HTML5 中提供的一个对象,允许我们在客户端读取文件内容。它通常与 <input type="file"> 元素配合使用,能够读取用户选择的文件,并提供不同格式的读取方式(如文本、数据URL、二进制字符串等)。
  • Image:Image 是 JavaScript 提供的一个构造函数,通常用于在网页中动态创建和操作图像。它允许你加载图像并获取图像的相关信息(如宽度、高度等),并能够在页面中动态插入图像元素。
javascript 复制代码
editorConfig.MENU_CONF['uploadImage'] = {
  // 自定义上传回调
  async customUpload(file, insertFn) {
    try {
      // 创建一个 FileReader 对象,用于读取文件内容
      const reader = new FileReader();
      // 使用 Promise 包装 FileReader 的异步操作
      const fileData = await new Promise((resolve, reject) => {
        // 当文件读取成功时,调用 resolve 并传入读取的结果
        reader.onload = (e) => resolve(e.target.result);
        // 当文件读取失败时,调用 reject 并传入错误信息
        reader.onerror = (err) => reject(err);
        // 以 Data URL 的形式读取文件内容
        reader.readAsDataURL(file);
      });

      // 创建一个 Image 对象,用于加载图片
      const img = new Image();
      // 使用 Promise 包装 Image 的异步加载操作
      const imgLoad = await new Promise((resolve, reject) => {
        // 当图片加载成功时,检查图片尺寸
        img.onload = () => {
          // 获取图片宽高
          const { width, height } = img;

          // 校验图片尺寸是否超过 2000x2000
          if (width > 2000 || height > 2000) {
            // 如果超过,调用 reject 并传入错误信息
            reject(new Error('图片尺寸超过 2000x2000'));
          } else {
            // 如果尺寸合适,调用 resolve
            resolve();
          }
        };
        // 当图片加载失败时,调用 reject 并传入错误信息
        img.onerror = (err) => reject(err);
        // 设置图片的 src 属性为文件数据
        img.src = fileData;
      });

      // 模拟上传时间,等待 1 秒
      await new Promise((resolve) => setTimeout(resolve, 1000));
      // 调用 insertFn 函数插入图片,传入文件数据、文件名和文件数据
      insertFn(fileData, file.name, fileData);
      // 上传成功的操作...
      console.log('上传成功');
    } catch (error) {
      // 上传失败的操作...
      console.error('上传失败', error);
    }
  },

  // ...其他可能需要的回调函数
};
// ...将editorConfig配置到wangEditor的实例中

参考文档:www.wangeditor.com/v5/menu-con...

b. 后端校验

除了在前端直接处理图片的尺寸校验,我们也可以选择将图片上传到后端后,再由后端进行尺寸校验。如果图片尺寸不符合预期,后端可以返回错误信息,告知前端图片上传失败。这种方法的好处在于能够避免浏览器处理超大图片时带来的性能问题,同时也能更灵活地控制上传流程,确保图片符合业务需求。

但是,后端校验也会存在着一些缺点:

  • 增加服务器负担:每次上传图片时,后端需要处理图片的校验工作,这会额外增加服务器的计算和存储压力,尤其是在高并发情况下,可能会影响服务器性能和响应速度。
  • 用户体验差:图片需要先上传到服务器,再进行校验和返回结果,这会导致上传过程的延迟,尤其对于较大的图片文件,这种延迟会更加明显,影响用户体验。

2.通过阿里云参数对图片进行在线处理

我们的图床托管在阿里云,因此可以使用数据处理(x-oss-process)对图片进行在线处理。

例如使用缩放功能x-oss-process=image/resize 在我们的图片链接上添加缩放,确保图片在渲染前就是已经优化后的图片。这一方案同样也可以减少渲染负担。

参考链接:如何缩放图片_对象存储(OSS)-阿里云帮助中心

但是这种方案的缺点在于,图床中任然会存在不符合规范的图片(如尺寸过大或格式不合适),会占用图床的存储资源。而且,图像处理的参数依赖于图床服务是否支持自动处理和参数化,若图床不支持或处理能力有限,则可能导致图片加载不如预期。

3.分块懒加载

假如,业务说,我就是要渲染一张这么大像素的图片呢?当然,我们也有办法解决------分块懒加载。

  • 图像分块
    在分块懒加载中,一张超大图像会被分割成多个小块(称为chunk)。每个小块包含图像的一部分,通常根据设定的分块大小来决定每个小块的宽高。分块后,整个大图像就变成了一个个小的矩形区域。
  • 按需加载(懒加载)
    与传统的图片加载方式不同,分块懒加载会根据用户当前的可视区域(视口)动态加载图片块。只有用户滚动到特定区域时,相关的小块才会被加载。这种方式避免了在页面加载时一次性加载所有的图片数据,从而节省了带宽并优化了性能。

实现步骤

我们可以思考一下,我们需要将一张大图分块懒加载,我们需要怎么做:

  1. 获取图片原始宽高:在渲染前获取图片的实际尺寸。
  2. 设定分块大小:确定每个分块的尺寸,并计算出需要的横向和纵向分块数量。
  3. 裁切图片:在渲染前裁切图片,使每个小块的尺寸符合设定的分块大小。
  4. 分块坐标:遍历计算出的分块数量,生成每个分块的坐标信息。
  5. 懒加载 :使用 <img> 标签渲染每个分块,并通过 loading="lazy" 属性实现懒加载。

其中,渲染前获取图片信息和裁切是通过浏览器技术无法支持的,因为我都还没渲染出来,我怎么能拿到一个网络图片的信息呢?所以我们同样需要使用到阿里云的数据处理(x-oss-process)对图片进行在线处理。

x-oss-process=image/info

通过在图片URL中添加info参数的方式,会返回图片的基本信息,例如图片大小、格式、图片高度以及图片宽度等。

json 复制代码
{
  "FileSize": {"value": "21839"},
  "Format": {"value": "jpg"},
  "FrameCount": {"value": "1"},
  "ImageHeight": {"value": "267"},
  "ImageWidth": {"value": "400"},
  "ResolutionUnit": {"value": "1"},
  "XResolution": {"value": "1/1"},
  "YResolution": {"value": "1/1"}
}

参考链接:如何获取图片的EXIF信息_对象存储(OSS)-阿里云帮助中心

x-oss-process=image/crop

自定义裁剪功能可以根据自己的需要在原图的基础上裁切出需要的图片。

我们此次用到的参数有下面几个:

参数 描述 取值范围
w 指定裁剪宽度。 [0,图片宽度]默认为最大值。
h 指定裁剪高度。 [0,图片高度]默认为最大值。
x 指定裁剪起点横坐标(默认左上角为原点)。 [0,图片边界]
y 指定裁剪起点纵坐标(默认左上角为原点)。 [0,图片边界]

参考链接:通过自定义裁剪获取符合指定大小的OSS图片_对象存储(OSS)-阿里云帮助中心

具体实现

特别需要注意的是,在计算图片的边界情况时,需要计算边界的实际宽高,而不是直接使用固定的分块大小。这样可以避免出现渲染重复内容的问题。

ini 复制代码
import { useEffect, useState } from "react";
import "./App.css";
import axios from "axios";

function App() {
  const [imageInfo, setImageInfo] = useState({
    width: 0,
    height: 0,
  });
  const [chunks, setChunks] = useState<{ x: number; y: number }[]>([]);

  // 分块大小 可以根据需求调整
  const CHUNK_SIZE = 500;

  useEffect(() => {
    async function fetchImageInfo() {
      const res = (
        await axios.get(
          `xxxx.png?x-oss-process=image/info` //替换成某个阿里云托管图片的 url
        )
      )?.data;

      const info = {
        width: res.ImageWidth.value,
        height: res.ImageHeight.value,
      };
      setImageInfo(info);

      // 根据设定的分块大小(CHUNK_SIZE),计算需要的横向和纵向分块数量。
      // 使用 Math.ceil() 确保即使最后一块不足 CHUNK_SIZE 也会被覆盖。
      const horizontalChunks = Math.ceil(info.width / CHUNK_SIZE);
      const verticalChunks = Math.ceil(info.height / CHUNK_SIZE);

      // 创建一个空数组来存储分块信息
      const newChunks = [];
      // 遍历纵向分块
      for (let y = 0; y < verticalChunks; y++) {
        // 遍历横向分块
        for (let x = 0; x < horizontalChunks; x++) {
          // 将每个分块的坐标添加到数组中
          newChunks.push({ x, y });
        }
      }
      setChunks(newChunks);
    }
    fetchImageInfo();
  }, []);

  return (
    <div
      className="app"
      style={{
        position: "relative",
        // 设置容器的大小为图片的尺寸
        width: imageInfo?.width || "100%",
        height: imageInfo?.height || "100%",
        margin: "0 auto",
        overflow: "auto",
      }}
    >
      {chunks.map(({ x, y }) => {
        // 计算实际的宽度,取分块大小和剩余宽度的最小值
        const actualWidth = Math.min(
          CHUNK_SIZE,
          imageInfo.width - x * CHUNK_SIZE
        );
        // 计算实际的高度,取分块大小和剩余高度的最小值
        const actualHeight = Math.min(
          CHUNK_SIZE,
          imageInfo.height - y * CHUNK_SIZE
        );

        return (
          <img
            key={`${x}-${y}`}
            loading="lazy"
            src={`xxxx.png?x-oss-process=image/crop,x_${
              x * CHUNK_SIZE
            },y_${y * CHUNK_SIZE},w_${actualWidth},h_${actualHeight}`}
            style={{
              position: "absolute",
              left: x * CHUNK_SIZE,
              top: y * CHUNK_SIZE,
              width: actualWidth,
              height: actualHeight,
              // 设置分块的边框为红色,方便查看分块
              border: "1px solid red",
            }}
            alt={`chunk-${x}-${y}`}
            onError={(e) => console.error("加载失败:", e)}
            onLoad={() => console.log(`加载分块成功: ${x}-${y}`)}
          />
        );
      })}
    </div>
  );
}

export default App;

通过这种分块懒加载的方式,我们显著提高了渲染超大图片的性能。因为我们只渲染用户当前视口内可见的部分,其他部分则通过懒加载的方式动态加载。这样不仅能减少初始加载时间,还能显著降低对设备性能的需求,尤其是在移动设备上,能够有效避免因图片过大导致的卡顿或崩溃问题。

五、总结

此次问题的根源在于浏览器渲染机制对超大像素图片的处理。当图片分辨率超出设备性能的承载范围时,渲染阶段的计算量急剧增加,导致浏览器崩溃甚至页面进入无限刷新的死循环。通过对浏览器渲染流程的分析,我们发现,图片的加载、重排和光栅化等阶段是性能瓶颈的关键所在。尤其是在光栅化阶段,浏览器需要将超大图像转化为位图,这一过程对CPU和GPU的计算资源需求非常高,进一步加重了性能负担。

这个案例提醒我们,在设计和开发过程中,必须时刻关注性能瓶颈,尤其是在涉及大规模资源(如图片、视频等)的加载和渲染时,更应该小心谨慎。我们应当预见到潜在的性能问题,采用优化手段(如分块加载、懒加载、图像压缩等)来确保应用在各种设备上的流畅体验。此外,及时的性能分析和工具使用(如浏览器的性能分析工具)能够帮助我们迅速定位问题,并采取针对性措施进行优化。总之,只有充分考虑性能因素,才能在保证功能实现的同时,避免应用出现卡顿或崩溃等影响用户体验的情况。

相关推荐
泡泡茶壶_lemon6 小时前
React setState后发生了什么?
react.js
lxyzcm8 小时前
【新年特辑】使用 React + TypeScript 开发新年祝福网页
前端·react.js·typescript
高山我梦口香糖16 小时前
[react]小技巧, ts如何声明点击事件的类型
react.js·typescript
zb811 天前
使用 next-intl 在 Next.js 应用中开启国际化
react.js·next.js
小李老笨了1 天前
React组件化开发
前端·javascript·react.js
市民中心的蟋蟀1 天前
第八章 React Context 与 性能 下
前端·javascript·react.js
小满zs1 天前
React第二十章(useMemo)
前端·javascript·react.js
存梨1 天前
2024 年终总结
前端·react.js
咔咔库奇1 天前
【react】常见的性能优化 1
前端·react.js·性能优化
️○-1 天前
React之从0开始(3)
前端·javascript·react.js