Flex 自适应布局:告别 calc,用一个 height-auto 搞定
背景
在 Vue 项目里,经常遇到这种布局:

html
<div class="page" style="height: 100%">
<div class="top">顶部,内容多少不确定</div>
<div class="bottom">
<div class="right">
<div class="right-top">顶部</div>
<div class="right-bottom">底部,内容高度确定</div>
</div>
<div class="left">
<div class="left-top">顶部,内容多少不确定</div>
<div class="left-bottom">底部</div>
</div>
</div>
</div>
需求很朴素:已知一部分区域的高度,让另一部分吃掉剩余空间。
过去大家的做法是 calc:
css
.right-top { height: calc(100% - 60px); }
.left-bottom { height: calc(100% - 40px); }
问题显而易见:
- 高度写死,改一处要跟着改多处
- 嵌套层级一多,
calc里套calc - 内容不确定时根本没法算
其实用 Flex,几行 CSS 就能解决。核心思路是:父级声明自己是 flex 容器,需要自适应的子级用 flex: 1 去吃掉剩余空间。
一、CSS 方案
1. 核心规则
css
/* 父级:flex 容器 + 允许收缩 */
.parent {
display: flex;
flex-direction: column; /* 分配高度时必须是 column */
min-height: 0; /* 关键:允许子项收缩 */
}
/* 需要吃掉剩余空间的子级 */
.height-auto {
flex: 1 1 0;
min-height: 0;
overflow: auto; /* 内容溢出时可滚动,视情况加 */
}
只需要记住这句话:
父级
display:flex+ 方向 +min-height:0,自适应子级flex:1 1 0+min-height:0。
2. 为什么需要 min-height: 0
这是最容易踩的坑。
Flex 子项默认 min-height: auto,意思是"我的最小高度不能小于我的内容高度"。于是当子项内容很长时,它会拒绝收缩,撑破父级,导致 flex: 1 完全失效。
显式写 min-height: 0 就是告诉浏览器:允许我缩到比内容还小 ,这样 flex: 1 才能真正生效。
同理,横向分宽度时也要写 min-width: 0。
3. 回到开头的例子
css
/* 页面纵向排列 */
.page {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
/* bottom 吃掉 page 的剩余高度,同时自己是个横向容器 */
.bottom {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: row;
}
/* 左右两栏平分宽度,各自纵向排列 */
.bottom > .left,
.bottom > .right {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* 各个"高度自适应的区域" */
.right-top,
.left-bottom {
flex: 1 1 0;
min-height: 0;
overflow: auto;
}
对应 HTML 稍微调整一下,给需要自适应的元素加上语义化的类:
html
<div class="page">
<div class="top">顶部,内容多少不确定</div>
<div class="bottom height-auto">
<div class="right">
<div class="right-top height-auto">顶部</div>
<div class="right-bottom">底部,内容高度确定</div>
</div>
<div class="left">
<div class="left-top">顶部,内容多少不确定</div>
<div class="left-bottom height-auto">底部</div>
</div>
</div>
</div>
关键约定:谁有 height-auto,谁就是那个"吃掉剩余空间"的角色。
其余同类元素(.top、.bottom、.left、.right、.right-bottom、.left-top)只承担容器职责,高度由内容决定。
4. 完整可运行示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flex 自适应布局</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
}
body {
padding: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
}
/* ============ 1. 页面:纵向 flex 容器 ============ */
.page {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
gap: 8px;
background: #fff;
border-radius: 8px;
padding: 12px;
}
/* ============ 2. bottom:吃掉 page 的剩余高度,同时自己是横向容器 ============ */
.bottom {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: row;
gap: 8px;
}
/* ============ 3. 左右两栏:平分宽度,各自纵向排列 ============ */
.bottom > .left,
.bottom > .right {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
background: #fafafa;
border-radius: 6px;
padding: 8px;
}
/* ============ 4. 通用规则:谁有 height-auto,谁吃掉剩余空间 ============ */
.height-auto {
flex: 1 1 0;
min-height: 0;
overflow: auto;
}
/* ============ 5. 视觉样式(可忽略,仅用于演示) ============ */
.top,
.right-top,
.right-bottom,
.left-top,
.left-bottom {
padding: 12px;
background: #e8f0fe;
border-radius: 6px;
border: 1px solid #c6dafc;
color: #1a56db;
font-size: 14px;
}
.right-bottom,
.left-bottom {
background: #fef3c7;
border-color: #fcd34d;
color: #92400e;
}
</style>
</head>
<body>
<div class="page">
<div class="top">顶部,内容多少不确定</div>
<div class="bottom height-auto">
<div class="right">
<div class="right-top height-auto">
顶部(自适应)<br />
内容再多也不会撑破布局,超出会滚动。
</div>
<div class="right-bottom">底部,内容高度确定</div>
</div>
<div class="left">
<div class="left-top">顶部,内容多少不确定</div>
<div class="left-bottom height-auto">
底部(自适应)<br />
内容再多也不会撑破布局,超出会滚动。
</div>
</div>
</div>
</div>
</body>
</html>
5. 用 SCSS 减少重复
项目里层级多的时候,用 mixin 收敛一下:
css
// ==================== mixins ====================
@mixin flex-col {
display: flex;
flex-direction: column;
min-height: 0;
}
@mixin flex-row {
display: flex;
flex-direction: row;
min-height: 0;
}
@mixin height-auto {
flex: 1 1 0;
min-height: 0;
overflow: auto;
}
// ==================== 页面 ====================
.page {
height: 100%;
@include flex-col;
}
.bottom {
@include flex-row;
@include height-auto;
}
.bottom > .left,
.bottom > .right {
flex: 1 1 0;
min-width: 0;
@include flex-col;
}
// 通用:谁有 height-auto 谁吃掉剩余空间
.height-auto {
@include height-auto;
}
6. 注意事项
| 场景 | 说明 |
|---|---|
| 父级方向 | 要分高度 用 column,要分宽度 用 row,别搞反 |
| 最外层 | 必须有确定高度(100% / 100vh),否则整条链没有高度来源 |
| 嵌套 | 中间层要同时承担"子"和"父"两个角色,两套属性都要写 |
| 溢出 | overflow: auto 会裁剪下拉、弹层,有此类内容时改成 visible |
| 横向 | 分宽度的场景记得加 min-width: 0 |
7. 局限性
CSS 方案已经能覆盖 95% 的场景,剩下的 5% 通常是:
- 第三方组件内部写死了高度,flex 插不进去
- 计算规则很特殊,比如"和某个兄弟节点高度相等"或"减掉某个固定值"
- 需要在运行时动态判断
这时候就需要 JS 兜底。
二、JS 指令方案
思路:写一个 v-auto-height 指令,用 ResizeObserver 监听父级和兄弟节点,把剩余高度写到元素上。
1. 指令实现 src/directives/autoHeight.js
js
/**
* v-auto-height
* 让元素的高度自动等于父级剩余高度。
* 适用于第三方组件写死高度、flex 无法介入的场景。
*
* 用法:
* <div v-auto-height>...</div>
* <div v-auto-height="{ gap: 16, offset: 2, exclude: [someEl] }">...</div>
*/
export const vAutoHeight = {
mounted(el, binding) {
const parent = el.parentElement
if (!parent) return
// 保存原样式,卸载时恢复
el._prevStyle = {
height: el.style.height,
minHeight: el.style.minHeight,
}
const options = binding.value || {}
const gap = options.gap || 0
const offset = options.offset || 0
const exclude = new Set(options.exclude || [])
const calc = () => {
const siblings = [...parent.children].filter(
(child) => child !== el && !exclude.has(child)
)
const used = siblings.reduce((sum, child) => {
const style = getComputedStyle(child)
return (
sum +
child.offsetHeight +
parseFloat(style.marginTop || 0) +
parseFloat(style.marginBottom || 0)
)
}, 0)
const h = parent.clientHeight - used - gap - offset
el.style.height = Math.max(h, 0) + 'px'
el.style.minHeight = '0'
}
const ro = new ResizeObserver(calc)
ro.observe(parent)
;[...parent.children].forEach((c) => ro.observe(c))
const mo = new MutationObserver(calc)
mo.observe(parent, { subtree: true, childList: true, attributes: true })
el._ro = ro
el._mo = mo
el._bindingValue = binding.value
calc()
},
updated(el, binding) {
el._bindingValue = binding.value
el._ro?.disconnect()
el._mo?.disconnect()
vAutoHeight.mounted(el, binding)
},
unmounted(el) {
el._ro?.disconnect()
el._mo?.disconnect()
if (el._prevStyle) {
el.style.height = el._prevStyle.height
el.style.minHeight = el._prevStyle.minHeight
}
},
}
2. 全局注册 src/main.js
js
import { createApp } from 'vue'
import App from './App.vue'
import { vAutoHeight } from './directives/autoHeight'
const app = createApp(App)
app.directive('auto-height', vAutoHeight)
app.mount('#app')
3. 使用
html
<!-- 基础用法 -->
<div class="bottom" v-auto-height>
底部,吃掉剩余高度
</div>
<!-- 带参数 -->
<div class="bottom" v-auto-height="{ gap: 16, offset: 2 }">
底部,吃掉剩余高度
</div>
4. 指令不适合干什么
- 不适合做简单的高度自适应。明明三行 CSS 就能解决,非要引一个指令,纯属自找麻烦。
- 不适合高频变化的布局 。每次
resize都要读offsetHeight,会触发重排,性能比 Flex 差一截。 - 容易和第三方组件的内部样式打架 。指令写
height,组件自己也在写height,谁赢看加载顺序,很脆弱。
所以我的建议是:
能用 Flex 就用 Flex,实在不行才上 JS 指令。
指令的正确使用场景是兜底------比如某个第三方弹窗组件内部强制 height: 400px,你没法改它源码,那就用指令在外部重新算一遍。
三、总结
一套口诀带走:
| 角色 | 需要写的属性 |
|---|---|
| 父级(纵向) | display:flex; flex-direction:column; min-height:0; |
| 父级(横向) | display:flex; flex-direction:row; min-height:0; |
| 自适应子级 | flex:1 1 0; min-height:0;(横向时补 min-width:0) |
| 最外层 | 必须有确定高度,100% 或 100vh |
记住这句话:
谁设置了
height-auto,谁就吃掉剩余空间;它的所有祖先节点,从外到内都要是 flex 容器并允许收缩。
Flex 方案零 JS、零计算、自动重排,覆盖 95% 场景。JS 指令只在 Flex 插不进去的地方当兜底用。
优先用第一套 CSS 方案。