问题发生的背景是一个横向滚动的 scroll-view 列表,在第一次进入渲染该列表组件没问题,但切页回来组件就莫名消失了,这里其实是高度坍塌成 0 了。
错误代码
<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 以外,常见的脱离块级流排列方法还有以下几种。
- 父元素
display: contents。 - 子元素都设置
float属性。 - 父元素设为
flex或grid布局。 - 父元素相对定位作为锚点,子元素绝对定位脱离文档流。
为什么 scroll-view 重新渲染时不会重新测量高度?
在布局引擎中,每个节点都有一个脏标记,表示是否需要重新测量布局。
Vue 的 computed 机制就和这个十分相似,数据改变时,不立即重新计算,而是标记为脏数据,等下次访问再重新计算。
浏览器的布局引擎在元素尺寸改变时,不会立刻重排(重新计算布局),而是把重排任务推入渲染帧队列里等待执行。
(如果此时 JS 中突然执行了对元素 offsetHeight 或 clientHeight 等布局属性的访问,浏览器发现有脏数据 => 立即中断 JS 执行 => 立刻原地执行重排 => 返回数值给 JS,这个就是“强制回流”。)
当页面切回时,引擎会遍历节点树,只有被打上脏标记的节点才会重新测量子元素。
而 scroll-view 的高度来自外部约束,比如 height: 500px 或 flex: 1。
切回时,这个外部约束的值完全没有变化,scroll-view 节点不会被标记为脏,引擎就不会去重新遍历计算子元素布局。
所以解决办法就是给 scroll-view 添加显式高度,不依赖内容撑开,就可以解决页面切回时高度坍塌的问题。
如果容器内部是普通块级元素撑起的高度,比如 <view>,就不会有高度坍塌问题,因为它的高度必须由子元素决定,即使父容器自身不脏,引擎在计算父容器高度时,也会强制向下遍历计算子元素高度,因为不问就算不出父容器自己的高度。
那为什么容器内部是行内块(inline-block)元素撑起的高度就会导致高度坍塌呢?
块级元素的高度 = 所有子元素高度之和。引擎要算出父容器高度,必须主动遍历所有子元素。
inline-block 的高度 != 自己的子元素高度之和。它在一个内联格式化上下文(IFC)中,最终高度受当前行的 line-height、font-size、vertical-align 以及同行其他元素的共同影响。引擎无法单独算出它的精确高度,必须等整行的行内盒子 Line Box (文本、span、img等)计算完成才能确定。
所以当页面切回时,scroll-view 的外部高度约束没变 => 自身没被标记为脏 => 引擎跳过了 inline-block 子元素的整行布局计算 => scroll-view 高度坍塌。
这里贴一张 Event Loop 流程图:
纯文本流程图:
┌─────────────────────────────────────────────────────────────┐│ 浏览器事件循环(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(下一个宏任务)如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时



