首页 / 资讯中心 / 文章详情

Flex+vh实现头尾固定中间滚动布局,彻底告别白屏

Flex+vh实现头尾固定中间滚动布局,彻底告别白屏 ★ FEATURED ARTICLE
这几年做前端后台项目最常被产品经理提的一句话就是“上面导航别动下面操作栏别动中间列表给我滚。”听着是个再简单不过的布局需求但我见过太多团队在这上面翻车要么页面刚打开就白屏一片要么头尾高度对不上要么中间内容一多直接把底部挤出屏幕。后来我把方案收敛到了Flex vh这个组合上才算是把这类“头尾固定、中间滚动”的布局彻底焊死。这篇东西不绕弯子直接讲清楚为什么这个方案能打、怎么用、以及那些“看着没问题却白屏”的坑到底是怎么踩出来的。这项工作适合所有写后台管理系统、聊天工具、移动端内嵌页、甚至是大屏看板的前端同学不管你是刚接触布局不久的新手还是已经被各种height: 100%折磨过的老手这套思路都能让你少走不少弯路。1. “钉死头尾”这个需求到底难在哪里先说清楚我说的场景一个页面顶部是导航栏或标题栏底部是操作按钮或底栏中间一块内容区域内容超出时只让中间这块自己滚动头和尾永远固定在可视区内。这套交互在移动端 App 里几乎无处不在聊天窗口、订单确认页、表单分步页、评论区浮层全是这个结构。Web 端也常见后台管理系统的页面框架左侧菜单、顶部 Header、底部状态栏中间的内容区独立滚动。总之这是一个高频到不能再高频的基础需求。但就是这么个基础需求成了“白屏重灾区”。原因很简单浏览器默认的页面滚动是整页滚动你想让某一块“局部滚动”本质上是在和浏览器的默认行为作对。这个“作对”的过程一旦处理不好页面就会以各种奇怪的方式给你脸色看——中间内容区塌陷成一片白、底部被顶出屏幕、滚动时头和尾跟着一起跑。1.1 传统方案的三种死法我刚开始写前端那阵处理这种布局的主流思路有这么几套各有各的死法。第一种死法height: 100% 滑铁卢。想的是“让中间区域高度占满剩余空间”于是给中间容器写height: 100%。但height: 100%的百分比参照物是父容器的高度而父容器又得参照它的父容器一直追到html和body。只要这条父子链上任何一个节点没显式设置高度百分比直接失效中间区域变成height: auto内容多少就多高。内容一多整个页面被撑高头尾跟着跑滚动条出现在body上而不是中间那块内容区。第二种死法position: fixed 全家桶。头部fixed顶部、底部fixed底部中间留padding-top和padding-bottom。这套在页面结构简单的时候能跑但fixed是相对视口定位的一旦出现键盘弹出、横竖屏切换、浏览器工具栏收起展开头尾的元素就跟着视觉窗口乱跳。更麻烦的是中间区域的高度你得手工计算——底座高度 头高度算错一个像素内容区底部就被吃掉一截。第三种死法calc() 高度硬算。height: calc(100vh - 64px - 48px)把头和底的高度写死看代码的人确实能一眼看懂。但头尾一旦变成响应式比如某个页面上头高度从 64px 变成 80px你就得去所有用到这个 calc 的地方同步改数字。项目大了以后这种“魔法数字”散落各处改一处漏一处是常态。这三种方案的核心问题其实都指向同一件事你没有让浏览器自己处理“剩余空间”而是试图手动算出这个剩余空间。手动算就会有误差、有耦合、有漏改。而Flexbox的出现恰好就是用来解决“剩余空间分配”的——既然布局框架把这块能力给你了你不去用它反而拿着计算器自己敲那不是自找麻烦是什么。1.2 为什么 vh 是那个绕不开的参照系Flexbox解决的是“剩余空间怎么分”但你得先告诉它“总空间有多大”。这个总空间就是视口高度。在 CSS 里vh单位直接代表视口高度100vh就是当前视口高度的百分之百。这里就是vh和height: 100%最本质的区别height: 100%是相对父级计算的它依赖整条祖先链都给出明确高度而vh是直接相对视口的它不需要任何父级配合不管你的 DOM 嵌套多深100vh拿到的都是“当前眼睛看到的窗口高度”。这一下就把那个最头疼的问题解决了——什么html、body高度链路什么父级要不要写height: 100%统统不用管。你只需要一个容器说自己就是100vh高然后把容器内部交给Flexbox去分配。不过我后来也见过一些人把vh用过头随手就给body写height: 100vh结果内容超过一屏时页面滚不动了中间内容全被截断。这是另一个极端——你把“视口高度”当成了“内容高度”那就等于亲手把滚动能力锁死了。记住一点vh管的是“容器和视口一样高”在这个容器里你再让某个子区域独立滚动逻辑上才成立。2. 破局第一步弄懂 vh 的脾气秉性vh这个单位看起来简单字面意思就是“视口高度的 1%”但实际用起来还是有几个点得分清楚。很多白屏问题不是因为你不会写100vh而是你在这个单位上踩了没注意的边角。2.1 vh 的精度和计算规则严格来说1vh 视口高度的 1%。桌面浏览器里这个值是稳定的——窗口拉多大视口就多大vw、vh跟着等比例变。所以 PC 端用100vh做一个满屏容器只要你没有把滚动条算漏基本不会出问题。这里要说一个细节PC 端滚动条是占位的。默认情况下当内容溢出出现垂直滚动条时这个滚动条会占据视口宽度的一部分Windows 下一般 1517px。这时候如果你写了width: 100vw它的宽度是“视口宽度 滚动条宽度”页面就会出现一条横向的滚动条或白边。这个问题在vh布局里不常遇到因为你主要用它控高度但如果你顺手把宽度也写成100vw白边就来了。宽度的事交给width: 100%处理比较稳。移动端的vh则要复杂一截。手机上浏览器的地址栏是会收起的——你往下滚动地址栏缩上去往上滚动地址栏又弹出来。这意味着“视口高度”本身是动态的地址栏占用期间视口变小收起之后视口变大。老版本的 iOS Safari15.4 之前对vh的处理策略是“取当前视口高度变化时实时更新”导致页面在滚动过程中高度不断重算布局跟着跳。后来规范里加了svh小视口高度、lvh大视口高度和dvh动态视口高度来细拆这个问题现在新版本浏览器基本都支持了。所以在移动端项目里我现在的做法是默认写height: 100vh做兜底然后再补一行height: 100dvh给支持动态视口的浏览器。前者保证老浏览器不崩后者保证新浏览器不跳。如果你是完全面向移动端的项目直接上100dvh是更顺滑的选择。2.2 height: 100vh 和 min-height: 100vh 怎么选这是个很实际的问题很多人分不清。区别一句话就能讲明白height: 100vh是强制容器高度等于视口高度不管你内容有多少容器就 100vh 高。内容多了跑出容器那就得靠容器内部的滚动机制处理。min-height: 100vh是容器至少 100vh 高内容多了容器可以长高跟着内容走。在“头尾固定中间滚动”这个场景里你要的是前者——因为你不是让整个页面长高而是让中间那块区域内部消化内容。但有一种情况我会用min-height: 100vh页面的整体结构不要求内部滚动只是希望背景铺满一屏、内容居中分栏。比如登录页、引导页内容不一定有多高我希望它至少占满一屏内容多了就能自然往下长这时候用min-height更合适。你把这两个用反了就会出现“内容一多就被裁掉”或者“本来想要内部滚动却整页滚”的诡异现象。还有一个小坑vh在移动端会随地址栏变化导致容器高度跳动如果页面里恰好有定位到底部的元素就会看到底栏上下闪烁。用dvh能缓解因为动态视口会实时反映当前真实视口。但如果你要兼容老设备就得接受这个闪烁或者用 JS 做一次兜底修正——这个话题后面细说。2.3 Flex 布局的“剩余空间分配”到底是怎么回事Flexbox的核心能力是“分配空间”但分配不是拍脑袋分的它有几条规则你得心里有数。默认情况下一个flex容器内的子项沿主轴排列。主轴是flex-direction决定的row是水平方向column是垂直方向。我们要做“头、中、底”结构主轴自然就是垂直方向也就是flex-direction: column。此时三个子项的排列规则是这样的默认所有子项的flex-grow都是 0意思是“我不主动要求多占空间”所以子项高度由自身内容决定。flex-shrink默认都是 1意思是“空间不够时我可以被压缩”。flex-basis默认是auto意思是“以自身内容大小为基准”。你要做的就是让中间那块唯一的子项主动吃掉所有剩余空间同时让头和尾不被压缩。对应到 CSS中间内容区flex: 1 1 0%也就是简写的flex: 1。它等价于“flex-grow: 1flex-shrink: 1flex-basis: 0%”意思是基准为 0然后吃掉所有剩余空间。头尾区域flex-shrink: 0或者直接给flex: 0 0 auto告诉 Flexbox“我的大小由内容决定你压不动我”。这里最容易翻车的一个细节就是默认情况下Flex 子项不会被压缩吗会。如果中间区域内容非常多而你没有设置flex-shrink: 0Flexbox 会把另外两个子项头尾也给压缩了。表现形式就是你滚动中间列表时头部和底部在视觉上“变矮了”或者干脆被推出屏幕。所以一个稳妥的骨架里头尾两块的flex-shrink: 0是和中间的flex: 1同等重要的。少了任何一个整个布局看起来都会不对劲。3. 完整骨架一份可以直接抄走的 Flex vh 实现理论说再多不如直接给一个能跑的东西。下面这份代码是我在日常项目里用得最多的基础骨架你可以直接复制到空白 HTML 文件里跑起来看效果。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title头尾固定中间滚动骨架/title style * { box-sizing: border-box; } html, body { margin: 0; height: 100%; } .app { height: 100vh; display: flex; flex-direction: column; } .header { flex-shrink: 0; height: 56px; background: #4a90d9; color: #fff; display: flex; align-items: center; padding: 0 16px; } .content { flex: 1; min-height: 0; overflow-y: auto; background: #f5f7fa; padding: 16px; } .footer { flex-shrink: 0; height: 48px; background: #fff; display: flex; align-items: center; justify-content: center; } /style /head body div classapp header classheader顶部导航/header main classcontent !-- 这里放你的列表或内容 -- /main footer classfooter底部操作栏/footer /div /body /html这套骨架里有三个细节我着重要讲一下因为它们就是“看着能用但用起来容易出幺蛾子”的关键点。3.1 为什么中间一定要写 min-height: 0很多从文档里抄布局代码的人会漏掉.content上的min-height: 0。漏掉之后会发生什么Flex 子项有一个默认的min-height: auto意思是“我的最小高度至少是我内容的自然高度”。也就是说中间内容区的内容再多、再高它在 Flex 布局中也会试图把自己撑开而不是被压缩成可滚动区域。结果就是中间部分无限长把整个.app容器都撑破了100vh的限制失效整个页面滚动条出现在html上。min-height: 0就是告诉浏览器“允许这个容器压缩到比内容更小。”只有在这个前提下overflow-y: auto才会和flex: 1配合起来形成“占满剩余空间但内部内容超出时自己滚”的效果。这个细节是我排查过无数次白屏和布局错乱后才彻底记住的。很多页面头尾固定看起来“偶尔正常、偶尔白屏”大概率就是这两个条件里至少一个没满足。3.2 头尾高度用固定值还是内容自适应在上面的骨架里我给头尾写了固定height。但实际项目中头尾的高度未必是固定不变的比如头部里有一个换行的标题、底部有几个不同尺寸的按钮。这时你不用写死高度直接去掉height让内容撑开加上flex-shrink: 0就好.header, .footer { flex-shrink: 0; padding: 16px; }这样头尾高度完全由内容决定中间区域自动自适应剩余空间。你会看到这就是flex比calc()优雅的地方——头尾高度变了中间区域不用重新计算浏览器自己就把剩余空间重新分配了。你做响应式、做换肤、做文案变长都不用回来改布局代码。3.3 外层要不要再套一层 html 或 body我见过不少同学把height: 100vh直接写在了body上然后body本身是flex容器。这在大多数浏览器里也能跑。但我更推荐的做法是body 只负责清掉 margin让一个内部容器比如.app承载高度和 Flex 布局。原因有两个。第一body在语义上承载的是整个页面的根结构如果以后某个公共脚本或第三方库需要往 body 上追加全局样式容易跟你的布局逻辑打架。第二包一层容器后你想要“双滚动布局”比如一块区域横向滚动、一块区域纵向滚动时嵌套结构扩展起来更容易——本来就是在外层容器里再塞一个 flex 容器的事。当然项目里如果确实只用一个全屏页面根节点直接写 body 也不是不行我只能说少一层包装少一个不确定性你排查问题的时候也更清爽。4. 变体场景不止是“头—中—底”这套玩法还能怎么延展基础骨架跑通之后你会发现自己手里像多了一把万能钥匙。这个“外层视口高度 Flex 分配 内部滚动”的组合能延展出不少实用变体。我把工作中最常见的几种列出来方便你直接对号入座。4.1 左右分栏 中间主内容滚动后台管理系统最常见的是这种结构左侧菜单固定右侧顶部 Header 固定右侧下方的内容区滚动。实现思路跟上面如出一辙只是方向从column变成了两个嵌套容器div classapp !-- 左侧菜单 -- aside classsidebar菜单/aside !-- 右侧主体 -- div classmain header classmain-header头部/header div classmain-content内容区/div /div /divCSS 里只要把.app设为display: flex; flex-direction: row; height: 100vh;.sidebar设flex-shrink: 0; width: 220px;.main设flex: 1; display: flex; flex-direction: column; min-width: 0;.main-content再走一遍flex: 1; min-height: 0; overflow-y: auto;的组合就完成了。这里有个容易漏的点嵌套的 flex 容器子项的min-width: 0和min-height: 0同样重要。外层是横向排列时子项默认min-width: auto如果里面有很长的内容比如一个超长的英文单词、一张宽表格子项就会拒绝被压缩把外层容器撑破。写min-width: 0才能让右侧主体乖乖保持在剩余空间内。4.2 聊天窗口左右两个滚动区互不干扰对话框是更极端一点的场景左边联系人列表要滚右边消息列表也要滚但头和底都固定。这本质上就是前面两种结构的组合——外层用横向 flex 分左右左栏内部纵向滚动右栏内部再嵌套头、消息列表、输入框三层。写起来也不复杂关键点只有一个每一个需要滚动的区域都必须在自己独立的容器里设置overflow-y: auto并且它自己的父链上每一层都要有明确的高度约束。换句话说滚动容器别跨层别想着“让爷爷滚动来带动孙子”那就乱套了。4.3 底部滚动到底的“吸底”妙用还有一种常见需求底部按钮栏跟着内容区滚动但在滚动到内容区底部之前它一直固定在视口底部滚动到列表底部时它与列表内容“拼接”在一起。这在移动端订单详情页、服务协议页特别常见。柔性方案其实很简单底部按钮不放外层 Flex 里而是放进滚动容器.content内部作为列表内容的最后一个块main classcontent div classlist……长列表……/div div classaction-bar底部按钮/div /main这样按钮就跟着内容走内容不满一屏时按钮自然在页面底部因为内容区本身撑满了剩余空间内容超出时按钮会滚到最后才显现。这个“吸底”效果不是直接用 Flex 管出来的而是在“外部结构固定、内部滚动”的框架下做了一层内容排列很多新人想破头要搞的“吸底按钮”其实一行固定逻辑都不用写。4.4 移动端内嵌页的安全区适配如果页面要跑在 iPhone 这种带刘海屏的设备上底部还有个home indicator区域直接用固定高度的footer可能被手势区遮挡。这时候要用安全区.footer { flex-shrink: 0; padding-bottom: env(safe-area-inset-bottom); }env(safe-area-inset-bottom)在支持安全区规范的环境下会给出底部避险区域的实际像素值加上之后底部栏就不会顶到 Home Indicator 上。头部如果也涉及刘海可以用padding-top: env(safe-area-inset-top)不过一般头部在刘海下方不太需要。现在浏览器对这两个环境变量的支持已经很普及了移动端页面基本可以无脑加上。我在做移动端项目时还会顺手在viewport里加一句meta nameviewport contentwidthdevice-width, initial-scale1.0, viewport-fitcover没有viewport-fitcover的话某些 iOS 版本的env()安全区变量取不到效果页面会被系统切边。5. 白屏排查实录三个我亲手踩过、看着毫无破绽却白屏的案例我前面讲了很多正确做法但真正让人成长的其实是错误案例。下面这三个白屏问题都是我在实际项目里踩过坑、花了不少时间排查出来的。我把完整链路写出来希望能帮你少绕几圈。5.1 案例一height: 100% 的“祖传”代码导致中间内容区整个塌陷现象打开页面顶部和底部都正常但头部下面的内容区一片空白滚动鼠标滚轮没有任何反应页面高度似乎只有头 底那么大。排查过程我先在 DevTools 里选中内容区发现它的computed height是 0。再看它的父级.app高度也是 0——但.app明明写了height: 100%。继续往上看body和html的高度是auto。原来这个项目的全局样式里有一条html, body { height: auto; }是一位前辈为了解决另一个页面的滚动问题新增的。这下好了html高度不是显式值body的height: 100%直接失效.app的height: 100%也跟着失效。整条百分比高度链断在起点。修复把.app的高度从height: 100%改成height: 100vh。因为vh不依赖祖先链即使html、body的高度是auto100vh也能拿到真实视口高度。这个案例完美解释了为什么我一再强调“用vh而不是100%”作为根容器高度——它天生不受父级连带影响。5.2 案例二移动端键盘弹起中间区域被“顶飞”露出白底现象一个表单页面顶部有标题栏底部有“提交”按钮中间是若干输入项。在 iOS 上点击输入框时键盘弹起底部按钮被顶到键盘上方但中间区域的白屏突然变多或者说整个布局上下跳动得厉害。排查过程我的第一反应是键盘弹起导致window.innerHeight变化而100vh跟着变化这是一个“看起来理所当然”的行为。但实际在 iOS 的 Safari 里vh在键盘弹起时的行为在不同版本上很不一致——有些版本视口高度不变键盘直接遮挡视口有些版本视口高度缩小vh重算导致布局闪动。我最终没有在“让vh跟随键盘变化”这件事上继续纠缠而是把表单容器改成“键盘弹起时不重排”的策略用position: fixed处理关键操作按钮或者把底部提交按钮放进内容滚动区内部让键盘弹出时它自然地随输入区域滚动。这样至少不会出现“底部按钮顶上来、中间留一片白”的割裂感。这个坑的启示是vh是视口单位但移动端的“视口”本身是个动态概念。如果你的页面要接受键盘输入别依赖vh来帮你处理键盘弹起后的空间重排用一个更结构化的方案比如滚动容器内自然排版会稳得多。5.3 案例三外层滚动被“留白”骗了内容区其实是好的现象页面能滚但滚动条出现在整个页面级别而不是中间内容区。头部和底部跟着滚动条一起滚动一滚就滚出屏幕。用户截图反馈说“中间的内容区压根没有滚起来全是白屏”但实际上内容是存在的只是被滚到视口外了。排查过程打开 DevTools发现html元素上有一条竖滑滚动条内容区.content上反而没有。我再选中.contentoverflow-y: auto是生效的但它自身高度并没有被限制住——因为它的父级.app是height: 100vh而.content里的内容高度超过了.app的剩余空间且.content没有设置min-height: 0。它的min-height: auto让它撑得比父级还高于是.content本身没有出现滚动条而是连同父级一起被顶出去了最终滚动发生在html上。修复给.content加上min-height: 0就好。这个我前面已经反复强调了但这里我想说它“反复出问题”是有原因的——它太隐蔽了。你写flex: 1的时候很难想到min-height: 0这个不起眼的声明才是整个布局成立的关键。而且 DevTools 里你不会一眼看出 Flex 子项的计算高度被内容撑爆只有把“元素 滚动行为 布局上下文”放在一起看才能找到问题根源。5.4 排查白屏的标准“望闻问切”流程经历这么几次之后我整理了一套自己的排查流程基本能解决九成以上的 Flex / vh 布局问题先看根容器.app的computed height是不是等于视口高度。如果不是检查height: 100vh有没有被覆盖以及有没有全局样式把html、body的高度链切断。看头尾两块元素有没有被压缩。选中 Header如果它的实际高度小于你设置的高度说明flex-shrink没有归零。看中间内容区有没有min-height: 0。没有的话优先补这一句大部分“内容撑破父容器”的问题会立刻解决。看滚动条到底出现在哪个元素上。DevTools 里把鼠标悬停在html上如果它出现滚动条说明滚动行为发生在根节点你的局部滚动没有生效。用移动端模拟器测试一遍键盘弹起、地址栏收起、横竖屏切换。这一步在 PC 上没法完全复现但至少能提前暴露一部分vh相关问题。这套流程走完你会发现“白屏”这个词背后其实没有那么多玄学九成都是高度链路断裂或滚动层级错乱。6. 进阶细节滚动条样式、性能和一些“顺手做”的优化骨架能跑了坑也排了最后讲几个让页面“更丝滑”的细节。毕竟标题里写着的“丝”不能只停留在布局能跑这个层面。6.1 滚动容器的滚动条样式优化默认的滚动条样式在 Windows 上是又宽又丑的那种在 Mac 上则是自动隐藏的。做后台系统时中间内容区的滚动条一直在那里会占掉 17px 左右的宽度布局看着也不精致。我一般会做一层轻量定制.content::-webkit-scrollbar { width: 6px; } .content::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 3px; } .content::-webkit-scrollbar-track { background: transparent; }这套在 Chrome、Edge 上效果统一配合border-radius看起来比系统默认滚动条细腻不少。不过要注意::-webkit-scrollbar会改变滚动条占用的布局空间宽度变小后内容区的可用宽度会跟着变化不要因为这一改导致内容换行。6.2 为什么不要用 JS 监听 resize 来算高度很多人在没有思路的时候会走这条路window.innerHeight减去头和底的高度然后把结果赋给中间内容区。有些项目跑得好好的看起来也没毛病但它有一个致命问题首屏渲染前JS 还没执行中间区域高度未知显示为空或默认高度很容易出现白屏闪烁。尤其是在慢网络和客户端资源受限的场景下这个白屏时间会非常明显。而纯 CSS 的vh Flex方案没有任何 JS 依赖——浏览器在第一次绘制时就知道所有布局信息不存在“JS 跑完前的高度空窗”。性能上也完全不用操心因为布局是浏览器原生能力不需要你监听 resize 事件、不需要触发布局计算。你只要想清楚一个场景如果你的页面要无限地适配各种窗口大小唯一需要做的就是在 CSS 里把结构写对剩下的浏览器全包了。6.3 老浏览器的兜底虽然现在vh的兼容性已经很好但如果你的项目还需要支持很老的内置浏览器比如 Android 4.4 自带浏览器、老版 WebViewvh可能会失效。我的兜底方案是两层.app { height: 100%; height: 100vh; }先写height: 100%再写height: 100vh。不识别vh时height: 100vh会被当成无效声明丢弃保留height: 100%作为降级。但这里要记住height: 100%需要html、body高度链完整所以降级环境下你需要把html, body { height: 100%; }也写上。这样新浏览器用精确的vh老浏览器靠百分比链也算都有活路。如果你还在用100vh做居中容器也可以加一行兜底比如.app { min-height: 480px; /* 兜底即使 vh 失效至少能保证容器不是 0 */ height: 100vh; }注意这只是一个“下限兜底”不是完美方案——老浏览器上不会有真正的“视口满屏”效果但至少不会白屏。6.4 一点个人经验我做了这么多年前端用到最后发现这种“头尾固定中间滚动”的基础布局考验的从来不是你会不会写 CSS而是你对 CSS 几个基本概念的掌握深度百分比参照的是谁、vh参照的是谁、Flex 的剩余空间是怎么分配的、min-height的默认行为是什么。把这些底层逻辑理清楚才谈得上拒绝白屏、保持丝滑。现在再看到网上各种“10 行代码搞定头尾固定布局”的帖子我已经不是去羡慕效率而是下意识会问一句它有没有写min-height: 0Flex 子项有没有防压缩滚动区域是不是真的只在中间那层这几个问题答清楚了才是真的能稳定上线的方案。
阅读完成 · 觉得有帮助?
咨询建站