Skip to content

calc() 的求值机制与响应式模式

这个属性值怎么算出来的

CSS 属性值的解析有 4 个阶段:specified → computed → used → actual。calc() 的百分比在 computed value 阶段才被解析——不是解析阶段。

text
CSS 属性值解析:
  specified value(指定值)
    → 级联后的声明值,可能包含 calc()、百分比、var()
  computed value(计算值)
    → 百分比被解析为绝对值,calc 表达式被求值
    → 这是 calc() 工作的阶段
  used value(使用值)
    → 布局完成后,auto / fr 等被转换为实际像素
  actual value(实际值)
    → 受精度限制后的最终值(如小数点舍入)

这意味着同一个 calc(100% - 32px) 在不同的包含块宽度下,computed value 不同:

text
包含块 1200px → calc(100% - 32px) → 1168px
包含块 800px  → calc(100% - 32px) → 768px
包含块 360px  → calc(100% - 32px) → 328px

calc() 本身就是响应式的——不需要媒体查询,不需要 JS。浏览器在每次包含块尺寸变化时重新求值。

语法约束的来源

两个容易出错的地方,根因都在 CSS 的词法分析器(tokenizer)和类型系统,不是实现 bug。

运算符空白

css
width: calc(100% - 32px);   /* ✅ tokenizer 正确切分 */
width: calc(100%-32px);     /* ❌ 整个被当作一个 dimension token */

CSS tokenizer 的规则:100% 是一个 dimension token(百分号是单位标识符),-32px 是一个带负号的 dimension token。中间没有空白时,tokenizer 把 100%-32px 当作 100% 后面紧跟 -32px 的一个未定义序列——整个表达式无效。加号和减号两侧必须有空白,乘号和除号没有这个限制(calc(2*16px) 合法),但为了可读性也建议加。

乘除的类型限制

CSS 类型系统不支持复合单位。px²px·em 这类单位在 CSS 中不存在——calc(16px * 16px) 无效。乘法的一个操作数、除法的分母必须是无单位的数字

css
font-size: calc(1.2 * 16px);      /* ✅ 数字 × 长度 = 长度 */
width: calc(100% / 3);             /* ✅ 百分比 / 数字 = 百分比 */
margin: calc((100vw - 1200px) / 2); /* ✅ 长度 / 数字 = 长度 */

padding: calc(100px / 16px);       /* ❌ 长度 / 长度 = 无单位数字,不是长度 */
margin: calc(16px * 16px);         /* ❌ 长度 × 长度 = 无意义 */

百分比基数的上下文

calc() 中百分比的"100% 指什么"取决于属性,不取决于表达式本身:

css
width: calc(100% - 32px);               /* 100% = 包含块 content width */
height: calc(100% - 32px);              /* 100% = 包含块 height(需要显式定义)*/
margin-left: calc(10% - 16px);          /* 100% = 包含块 width */
padding-top: calc(10% + 8px);           /* 100% = 包含块 width,不是 height */
transform: translateX(calc(50% + 16px)); /* 100% = 元素自身 width */
line-height: calc(1.5 * 100%);          /* 100% = 元素自身 font-size */

margin-top / padding-top 的百分比基于包含块 width 而非 height,这是规范层面的设计——避免循环依赖。如果 margin-top: 10% 基于 height,而 height 依赖于内容高度(子元素撑开),而子元素的 margin-top 又依赖父元素 height,形成死锁。基于 width 则不存在这个循环:width 通常由视口或父元素显式宽度决定,不依赖于 margin。


几个用法

约束容器

css
.container {
  width: min(100% - 32px, 1200px);
  margin-inline: auto;
}

min()max() 内部支持混合单位,本质上是自带 calc() 的比较函数。在没有 min() 的年代,同样的效果需要两个嵌套元素或媒体查询。

流畅缩放

css-tricks 上的"fluid typography"公式:

text
result = min + (max - min) × (100vw - vMin) / (vMax - vMin)

clamp() 约束在 [min, max] 区间内:

css
:root {
  --fluid-font: clamp(
    16px,
    16px + (20 - 16) * (100vw - 320px) / (1200 - 320),
    20px
  );
}

viewport 320px → 16px;760px → 18px;1200px → 20px;超过 1200px 被 clamp() 封顶。

calc() 把截距和斜率编码在一个表达式里。浏览器在 viewport 变化时重新求值——每秒可能 60 次(resize 拖拽时),但这是 C++ 层的数值计算,不经过 JS 引擎。

vw + 固定偏移

css
.section {
  padding-inline: calc(16px + 2.5vw);
}
/* 320px viewport → 24px; 1440px → 52px */

替代了在每个断点重复定义 padding 的写法。流畅但不可控——2.5vw 是全局斜率,无法针对特定断点微调。如果需要 480px 处 padding 恰好 32px 而 768px 处恰好 40px,单靠一个线性公式做不到,需要断点介入。


和自定义属性的组合

CSS 自定义属性可以在 calc() 中参与运算。属性值变化时不需要重新解析整个表达式——浏览器内部存储的是表达式树,只更新变了的分支:

text
calc(var(--a) + var(--b))
  → CSSMathSum(
      CSSVariableReferenceValue('--a'),
      CSSVariableReferenceValue('--b')
    )
  → --a 从 10px 变为 20px,只重新求值 --a 分支

配合媒体查询修改变量值:

css
:root { --columns: 1; }
@media (min-width: 640px)  { --columns: 2; }
@media (min-width: 1024px) { --columns: 3; }

.grid-item {
  width: calc((100% - (var(--columns) - 1) * var(--spacing)) / var(--columns));
}

变量改变了 --columns--spacing,calc() 表达式本身不变。这是把响应式逻辑从"每个断点重写 CSS 规则"转移到"改变参数,规则不变"——和编程中"数据驱动"同构。


性能

哪些 calc 触发 layout

css
transform: translateX(calc(50% + 16px));  /* composite 层,不触发 layout */
width: calc(100% - 32px);                 /* 触发 layout,但范围限于当前元素 */
padding: calc(16px + 2.5vw);             /* 依赖 vw → resize 时触发 layout */

浏览器的 C++ 层(style engine)执行 calc 求值,不经过 V8/JS 引擎。单个 calc 的求值开销在微秒级。真正的瓶颈是数量

vw calc 的累积效应

一个页面如果有 500 个元素各自使用 calc(x + y * 100vw),在 120Hz 显示器上每帧预算 8.3ms。假设每个 calc 求值 2μs,500 个 = 1ms——占帧预算的 12%,还没算 layout 本身的开销。

解决方法——把重复的 calc 提成自定义属性:

css
:root { --padding-x: calc(16px + 2.5vw); }
.item { padding: var(--padding-x); }

变量求值一次,500 个元素引用同一个计算结果。layout 阶段 500 个元素的宽度计算仍然需要,但 calc 表达式的求值只发生一次。


边界

grid/flex 做了的事

css
width: calc((100% - 48px) / 3);           /* calc 模拟等分 */
grid-template-columns: repeat(3, 1fr);     /* grid 原生等分 */

grid/flex 的布局算法在浏览器内核中实现(C++),性能优于 CSS 层面的 calc 计算。而且 gap 属性让间距计算完全不需要参与表达式——一行 gap: 24px 替代 - 48px) / 3

calc() 不能用的地方

颜色:calc(rgb(255,0,0) + rgb(0,0,255)) 无效。CSS 类型系统不支持颜色运算。用 color-mix(in srgb, red, blue)

媒体查询:@media (min-width: calc(768px + 1px)) 在规范上不允许。媒体查询需要的值在解析阶段就必须是确定的。

transition 动画:transition: width 0.3scalc(50%-32px)calc(100%-64px) 之间跳变而非平滑过渡。浏览器不能对 calc 表达式中的不同单位组合插值——先把两端分别求值为 computed value(像素),再在像素值之间做线性插值。这实际上意味着 calc 在 transition 中可以先求值为确定值再参与动画,但如果百分比基数和固定偏移同时变化,中间帧不保证平滑。

除零

calc(100px / 0) —— 不会 NaN,不会 Infinity。CSS 值系统没有这两个概念。除数为 0 时整个 calc 表达式被视为无效值(invalid at computed-value time),属性回退到继承值或初始值。和 JS 的 x / 0 → Infinity0 / 0 → NaN 完全不同。


env(safe-area-inset-*) 配合 calc() 是移动端适配刘海屏/底部指示条的正确方式,不是替代方案,是唯一不需要 JS 的方案:

css
.bottom-bar {
  padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}

env() 的第二个参数是 fallback——在不支持 safe-area 的浏览器或没有刘海屏的设备上使用 0px,padding-bottom 回退到 16px。