切页回来组件消失了?一个浏览器渲染机制引起的容器高度坍塌 bug

问题发生的背景是一个横向滚动的 scroll-view 列表,在第一次进入渲染该列表组件没问题,但切页回来组件就莫名消失了,这里其实是高度坍塌成 0 了。

错误代码

vue 复制代码
<template>
  <scroll-view class="option-list" scroll-x scroll-with-animation :show-scrollbar="false">
    <view
      class="option-text"
      v-for="(option, index) in options"
      :id="xxx"
      :key="xxx"
    >
      <!-- ... -->
    </view>
  </scroll-view>
</template>

<style lang="scss" scoped>
.option-list {
  width: 100%;
  white-space: nowrap;
  //...
}
.option-text {
  display: inline-block;
  //...
}
</style>

原因分析

Uni-app 中,scroll-view 不像 <view> 那样内容多了会自动撑开,它需要一个明确的尺寸约束,否则高度可能就是 0,或者被父元素挤得没空间。

列表项全是 inline-block 元素,横向排列用 white-space: nowrap 让子元素都脱离块级流,整个 scroll-view 的高度完全依赖浏览器去测量这些内联内容。

页面切走再切回来时,原生 scroll-view 不会重新测量里面的内联内容,而内联元素的高度本来就要靠布局引擎测量,它不量了,所以高度就塌成 0 了。


什么叫让子元素脱离块级流?

普通的块级元素都会遵循垂直排列,即一个块级盒子占一整行。

如果脱离了块级流排列,元素就不会再占据原来的空间位置,位置和尺寸由其他的定位机制来决定。

常见的方式除了以上这种父级容器 white-space: nowrap + 子元素 display: inline-block 以外,常见的脱离块级流排列方法还有以下几种。

  1. 父元素 display: contents
  2. 子元素都设置 float 属性。
  3. 父元素设为 flexgrid 布局。
  4. 父元素相对定位作为锚点,子元素绝对定位脱离文档流。

为什么 scroll-view 重新渲染时不会重新测量高度?

在布局引擎中,每个节点都有一个脏标记,表示是否需要重新测量布局。

Vuecomputed 机制就和这个十分相似,数据改变时,不立即重新计算,而是标记为脏数据,等下次访问再重新计算。

浏览器的布局引擎在元素尺寸改变时,不会立刻重排(重新计算布局),而是把重排任务推入渲染帧队列里等待执行。

(如果此时 JS 中突然执行了对元素 offsetHeightclientHeight 等布局属性的访问,浏览器发现有脏数据 => 立即中断 JS 执行 => 立刻原地执行重排 => 返回数值给 JS,这个就是"强制回流"。)

当页面切回时,引擎会遍历节点树,只有被打上脏标记的节点才会重新测量子元素。

scroll-view 的高度来自外部约束,比如 height: 500pxflex: 1

切回时,这个外部约束的值完全没有变化,scroll-view 节点不会被标记为脏,引擎就不会去重新遍历计算子元素布局。

所以解决办法就是给 scroll-view 添加显式高度,不依赖内容撑开,就可以解决页面切回时高度坍塌的问题。

如果容器内部是普通块级元素撑起的高度,比如 <view>,就不会有高度坍塌问题,因为它的高度必须由子元素决定,即使父容器自身不脏,引擎在计算父容器高度时,也会强制向下遍历计算子元素高度,因为不问就算不出父容器自己的高度。


那为什么容器内部是行内块(inline-block)元素撑起的高度就会导致高度坍塌呢?

块级元素的高度 = 所有子元素高度之和。引擎要算出父容器高度,必须主动遍历所有子元素。

inline-block 的高度 != 自己的子元素高度之和。它在一个内联格式化上下文(IFC)中,最终高度受当前行的 line-heightfont-sizevertical-align 以及同行其他元素的共同影响。引擎无法单独算出它的精确高度,必须等整行的行内盒子 Line Box (文本、span、img等)计算完成才能确定。

所以当页面切回时,scroll-view 的外部高度约束没变 => 自身没被标记为脏 => 引擎跳过了 inline-block 子元素的整行布局计算 => scroll-view 高度坍塌。


这里贴一张 Event Loop 流程图:

flowchart TD A[浏览器事件循环 Event Loop] --> B[1. 从宏任务队列取一个任务<br>setTimeout / 点击事件 / 网络回调] B --> C[2. 执行该宏任务里的所有同步 JS 代码] C --> C1[&#34;console.log(1)&#34;] C1 --> C2[&#34;el.style.width = '100px'<br>← 贴脏标记&#34;] C2 --> C3[&#34;const h = el.offsetHeight<br>← 💥 强制重排(插队)&#34;] C3 --> C4[&#34;console.log(2)&#34;] C4 --> D[3. 清空微任务队列<br>Promise.then / MutationObserver] D --> E{4. 是否需要渲染?<br>有样式变化?有动画?<br>达到 16.6ms 一帧?} E -->|否| F[跳过渲染] F --> A E -->|是| G[5. 渲染帧队列开始执行] G --> G1[a. 执行所有 requestAnimationFrame 回调] G1 --> G2[b. 计算样式 Recalc Style] G2 --> G3[c. 布局/重排 Layout<br>检查脏标记<br>有→执行重排 / 无→跳过] G3 --> G4[d. 绘制 Paint] G4 --> G5[e. 合成 Composite] G5 --> H[6. 屏幕显示<br>GPU 提交画面] H --> A

纯文本流程图:

text 复制代码
┌─────────────────────────────────────────────────────────────┐
│                    浏览器事件循环(Event Loop)              │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
        ┌─────────────────────────────────────────┐
        │  1. 从宏任务队列(Task Queue)取一个任务  │
        │     例如:setTimeout、点击事件、网络回调   │
        └─────────────────────────────────────────┘
                              │
                              ▼
        ┌─────────────────────────────────────────┐
        │  2. 执行该宏任务里的所有同步 JS 代码      │
        │     ┌─────────────────────────────┐     │
        │     │  console.log(1)              │     │
        │     │  el.style.width = '100px'   │     │ ← 贴脏标记
        │     │  const h = el.offsetHeight  │     │ ← 💥 强制重排(插队)
        │     │  console.log(2)              │     │
        │     └─────────────────────────────┘     │
        └─────────────────────────────────────────┘
                              │
                              ▼
        ┌─────────────────────────────────────────┐
        │  3. 清空微任务队列(Microtask Queue)    │
        │     执行所有的 Promise.then、MutationObserver │
        └─────────────────────────────────────────┘
                              │
                              ▼
        ┌─────────────────────────────────────────┐
        │  4. 是否需要渲染?(浏览器决定)          │
        │     - 有样式变化?                       │
        │     - 有动画?                          │
        │     - 是否达到 16.6ms 一帧?            │
        └─────────────────────────────────────────┘
                              │
                    是        ▼        否
              ┌──────────────────────────────┐   ┌─────────┐
              │  5. 【渲染帧队列】开始执行     │   │ 跳过渲染 │
              │  ┌──────────────────────────┐ │   │ 回到步骤1 │
              │  │ a. requestAnimationFrame │ │   └─────────┘
              │  │    (所有 rAF 回调)      │ │
              │  ├──────────────────────────┤ │
              │  │ b. 计算样式(Recalc Style)│ │
              │  ├──────────────────────────┤ │
              │  │ c. 布局/重排(Layout)    │ │ ← 检查脏标记
              │  │   如果有脏标记 → 执行重排  │ │   如果没有 → 跳过
              │  ├──────────────────────────┤ │
              │  │ d. 绘制(Paint)          │ │
              │  ├──────────────────────────┤ │
              │  │ e. 合成(Composite)      │ │
              │  └──────────────────────────┘ │
              └──────────────────────────────┘
                              │
                              ▼
        ┌─────────────────────────────────────────┐
        │  6. 屏幕显示(GPU 提交画面)             │
        └─────────────────────────────────────────┘
                              │
                              ▼
                   回到步骤 1(下一个宏任务)

相关推荐
iaku42 分钟前
Prompt 不是玄学:写给前端的 Prompt 工程指南
前端·人工智能
爱丶不疚42 分钟前
在 dsh 仓库里扒到的宝藏工作流:详解 .agents/notes 决策沉淀系统
前端·agent·vibecoding
喜欢睡觉1 小时前
从"送花"讲懂 JavaScript:对象、数据类型与代理模式
前端
渣波1 小时前
NestJS 企业级后端架构实战:从核心代码到工程化思维的深度重构
前端·typescript·nestjs
BreezeJiang1 小时前
别再背工厂模式了:NestJS 第一行代码就是它的工业级落地
前端·javascript
光影少年1 小时前
RN 常见性能问题:JS卡顿、UI卡顿、桥接通信耗时
前端·react native·react.js
liuxiaocheng1 小时前
文本生成的进阶:generateText / streamText 里迟早会撞上的东西
前端·后端·ai编程
渣波1 小时前
深度解析工厂模式:从蜜雪冰城到 NestFactory,彻底搞懂“创建与使用分离”
前端·javascript
蔓越莓1 小时前
打包工具:编译器ESBuild
前端·面试