1. 为什么要用 JS 设置 rem,而不是纯 CSS
先说结论:纯 CSS 也可以做 rem 适配,但做不彻底。rem 这个单位的本质是“相对于根元素 html 的 font-size”,所以你只需要把 html 的 font-size 改掉,整个页面里所有用 rem 做单位的元素都会跟着等比缩放。问题在于,CSS 怎么知道当前设备的屏幕宽度是多少?靠媒体查询。媒体查询的问题在于它是断点式的,比如 375px、414px、768px、1920px,你在断点之间只能取一个近似值,永远做不到“屏幕多宽,根字号就精确对应多宽”。
JS 就不一样了。JS 可以直接读取document.documentElement.clientWidth,拿到当前视口的真实宽度,然后按设计稿的宽高比实时算出根字号。这样一来,不管你的屏幕是 320px 还是 1920px,甚至是 2560px 的带鱼屏,页面里的 rem 元素都会严格等比缩放,中间没有任何断点跳变的感觉。
另一个选 JS 而不是纯 CSS 的原因是,PC 端和移动端的适配逻辑其实不一样,纯 CSS 很难在一套代码里同时兼顾两种场景。移动端的经典做法是“按 750 设计稿等比缩放”,而 PC 端的常见做法是“按设计稿宽度等比放大,但要限制最大宽度和最小宽度,防止内容拉伸变形”。这个差异用 CSS 写会非常绕,用 JS 也就十几行代码。
我几年前接手过一个后台管理系统,设计稿是 1920 宽,但用户的显示器从 1366 到 2560 都有,早期用媒体查询写了七八个断点,结果每次改版都要反复调断点,后来全部推翻换成 JS 动态设置 rem,整个适配层就干净了。所以这篇文章的核心就一句话:用 JS 统一管理根字号,把适配逻辑从 CSS 里捞出来。
2. PC 端设置 rem 的核心思路与 JS 实现
2.1 PC 端 rem 适配的基本逻辑
PC 端设置 rem 和移动端最大的不同在于:移动端设计稿宽度是固定的 750 或 375,而 PC 端设计稿宽度五花八门,常见的从 1440 到 1920 都有。更麻烦的是,PC 端的浏览器窗口是可以随意resize的,用户不会像手机那样固定在一个宽度。
PC 端 rem 适配的核心公式是:
根字号 = (当前视口宽度 / 设计稿宽度) * 基准字号举个例子,设计稿宽度 1920,基准字号 100px。当视口宽度是 1920 时:
根字号 = (1920 / 1920) * 100 = 100px当视口宽度是 1366 时:
根字号 = (1366 / 1920) * 100 ≈ 71.15px这样一来,如果设计稿里某个盒子宽度是 300px,你在代码里写成3rem,它在 1920 宽下渲染为 300px,在 1366 宽下渲染为 213.45px,比例完全一致。
很多人会问:为什么基准字号要选 100?因为 100 好算。设计稿里 1px 对应 0.01rem,设计稿里 320px 对应 3.2rem,心算都能算出来,开发效率高。你选 10 也行,选 16 也行,但 100 是最好口算的。
2.2 处理 PC 端的最大宽度和最小宽度
PC 端不能像移动端那样无脑等比缩放,原因是你在 2560 的显示器上如果还按比例放大,整个页面的字会大到离谱。同样,在 1366 的笔记本上如果按比例缩小到 71px 根字号,有些加了min-width的限制的组件会先崩掉。
所以 PC 端 rem 适配必须加两个限制:最大根字号和最小根字号。我的习惯是:
const maxFontSize = 100; // 1920 设计稿下根字号上限 100px const minFontSize = 75; // 对应 1440px 视口宽度逻辑是:视口宽度超过设计稿宽度时,根字号不再增大,保持 100px;视口宽度小于某个阈值(比如 1440)时,根字号不再缩小,保持 75px。
这样设计业务上有个好处:内容区域不会因为屏幕太宽而拉得无边无际,也不会因为屏幕太窄而挤成一团。配合容器的max-width: 1920px; margin: 0 auto;,整个页面在超宽屏上呈现的是居中的固定宽度布局,而不是无脑拉伸。
2.3 PC 端不想全站 rem?可以只让内容区缩放
有一种更精细的做法,也是我在实际项目里用得比较多的:全局布局还是用 px,只有内容区域(比如大屏图表区、表格区)使用 rem。思路是给内容区设置一个固定的设计稿宽度基准,然后用 JS 计算缩放比例,通过transform: scale()来实现内容的整体缩放,外层再包一层占位容器撑起高度。
这个方案和 rem 方案并不冲突,反而验证了一个道理:适配方案没有银弹,核心是找准“变化的是什么、不变的是什么”。rem 解决的是长度单位的等比换算,transform: scale()解决的是整个容器视觉等比缩放。两种手段都离不开 JS 对当前视口宽度的实时感知。
3. 移动端设置 rem 的经典方案与完整代码
3.1 移动端 rem 适配的经典比例逻辑
移动端的设计稿宽度一般就是 750(二倍图)或 375(一倍图)。不管你拿到的设计稿是哪种,核心逻辑只有一句话:把设计稿的总宽度映射到设备的物理像素宽度,然后按比例算出根字号。
假设设计稿是 750 宽,设备逻辑宽度是 375px(比如 iPhone 13 的 CSS 像素宽度):
根字号 = (375 / 750) * 100 = 50px假设设备逻辑宽度是 412px(许多国产安卓机):
根字号 = (412 / 750) * 100 ≈ 54.93px设备越宽,根字号越大,页面里的所有 rem 元素同步放大,最终视觉上就是“设计稿等比缩放”。
这里有个容易踩坑的点:为什么有些项目要处理devicePixelRatio?因为 750 宽的设计稿通常是在 2 倍屏下出的图,如果你直接按 375 逻辑宽度做 rem,图片的视觉效果其实是宽的,但因为 CSS 像素和物理像素的换算关系,实际显示会偏小。更严谨的做法是把根字号计算与window.devicePixelRatio联动,但这个不是所有项目都必须的,具体取决于你的设计稿是标注了物理像素还是 CSS 像素。我建议的做法是:先和设计确认设计稿的基准是物理像素还是逻辑像素,再决定要不要乘 dpr。
3.2 移动端 rem 设置的三个必备环节
移动端 rem 适配只做根字号计算是不够的,你至少还需要考虑三个环节,少了任何一个都容易出问题。
第一个是viewport 配置。必须有<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">这样的基础配置,否则页面在手机上会先按默认的 980px 渲染一遍再缩放,你的 rem 计算拿到的clientWidth根本不准。
第二个是移动端触摸屏的缩放行为。很多 Android 浏览器在双击时会触发页面缩放,如果你没有显式禁用user-scalable=no,用户误触后整个 rem 布局会被浏览器的缩放机制破坏掉。当然,这个设置对无障碍访问不友好,所以你也可以用touch-action: manipulation来禁止双击缩放,同时保留系统字体缩放的入口。
第三个是根字号的动态更新。移动端和 PC 端不一样,手机上浏览器的工具栏显示/隐藏会导致视口高度变化,但宽度一般不变。所以移动端的 resize 监听主要是防横竖屏切换,代码上建议同时监听resize和orientationchange两个事件。
3.3 移动端 rem 设置的标准 JS 实现
下面这段代码是我在多个移动端项目里复用过的基础版本,思路清晰,改动成本低:
(function (doc, win) { const designWidth = 750; // 设计稿宽度 const baseFontSize = 100; // 基准字号,方便计算 function setRem() { const clientWidth = doc.documentElement.clientWidth; if (!clientWidth) return; // 如果屏幕宽度超过设计稿,一般也保持最大宽度,防止页面无限放大 const width = Math.min(clientWidth, designWidth); const fontSize = (width / designWidth) * baseFontSize; doc.documentElement.style.fontSize = fontSize + 'px'; } win.addEventListener('resize', function () { clearTimeout(tid); const tid = setTimeout(setRem, 300); }, false); win.addEventListener('pageshow', function (e) { if (e.persisted) { clearTimeout(tid); const tid = setTimeout(setRem, 300); } }, false); setRem(); })(document, window);这段代码做了三件核心事:
- 读取
document.documentElement.clientWidth,这是当前视口的逻辑宽度,不受浏览器缩放影响。 - 计算根字号并直接写到
<html>的style.fontSize上。注意这里用的是html元素本身,不是body,因为 rem 的参考基准就是html的字体大小。 - 在
resize和pageshow(浏览器前进/后退缓存页恢复)时重新计算,保证切换横竖屏或跳过缓存后页面依然是正确的比例。
我在代码里做了 300ms 的防抖,因为真机上 resize 会高频触发,每次都去改html的font-size会引发大面积重排。防抖后只有停止改变尺寸了才去计算一次,体验会顺滑很多。
4. PC 端与移动端 rem 设置方案合并的实操过程
4.1 同一套代码兼容 PC 和移动端的关键点
如果你维护的是一个需要同时跑在 PC 浏览器和手机浏览器上的项目,最省事的方案是写一个通用函数,内部通过navigator.userAgent或视口宽度判断当前是 PC 还是移动端,然后走不同的适配分支。
我建议的判定逻辑很简单:优先用pointer: coarse媒体查询来判断是否支持粗指针(触摸设备),其次是视口宽度小于等于某个阈值(比如 768)时按移动端逻辑处理。为什么不用userAgent?因为 iPad 的userAgent非常容易伪装,而且现在很多 Windows 触屏笔记本,userAgent完全和 PC 一样,但实际上是触摸设备,只靠 UA 判断会踩坑。
4.2 合并方案的完整示例代码
下面这个函数我在一个实时监控大屏项目里用过,同时兼容 PC 端和移动端浏览器访问:
(function (win, doc) { // 配置信息:可按具体项目调整 const CONFIG = { pc: { designWidth: 1920, // PC 设计稿宽度 maxFontSize: 100, // 根字号上限 minFontSize: 72 // 根字号下限,防止过小 }, mobile: { designWidth: 750, // 移动端设计稿宽度 maxFontSize: 100 } }; function isMobile() { // 优先看触摸能力,其次看尺寸 const hasTouch = 'ontouchstart' in win || navigator.maxTouchPoints > 0; const narrow = doc.documentElement.clientWidth <= 768; return hasTouch && narrow; } function setRootFontSize() { const clientWidth = doc.documentElement.clientWidth; if (!clientWidth) return; const mobile = isMobile(); const config = mobile ? CONFIG.mobile : CONFIG.pc; const designWidth = config.designWidth; let fontSize = (clientWidth / designWidth) * 100; if (!mobile) { fontSize = Math.min(fontSize, CONFIG.pc.maxFontSize); fontSize = Math.max(fontSize, CONFIG.pc.minFontSize); } else { fontSize = Math.min(fontSize, CONFIG.mobile.maxFontSize); } doc.documentElement.style.fontSize = fontSize.toFixed(2) + 'px'; } let timer = null; win.addEventListener('resize', function () { clearTimeout(timer); timer = setTimeout(setRootFontSize, 200); }, false); win.addEventListener('pageshow', function (e) { if (e.persisted) { clearTimeout(timer); timer = setTimeout(setRootFontSize, 200); } }, false); setRootFontSize(); })(window, document);这段代码把所有钥匙逻辑浓缩在一个函数里。PC 端和移动端用同一个入口,但走不同的配置和不同的边界策略。PC 端走最小/最大字号限制,移动端只做上限保护,防止 5K 屏手机把根字号放得过大。
4.3 动效与 rem 搭配的一个实践建议
你可能遇到过一个问题:使用 rem 适配后,某些 CSS 动画(比如位移、放大)会糊。原因其实不在 rem 本身,而在于 rem 适配后根字号大多不是整数,比如 71.15px,元素尺寸和位移值就带了小数位,浏览器合成动画时要对非整数像素做抗锯齿处理,性能相对变差。
我的经验是:给根字号计算结果保留两位小数,并且尽量让基准字号为 100,这样大多数常见宽度下算出来的根字号误差很小。另外,动画涉及的变换如果用transform: translateX(rem),会比left: rem的性能好很多,因为transform走的是合成器线程,不触发重排。
4.4 实际联调时如何验证适配效果
我这里有一个笨但有效的方法:用浏览器开发者工具的设备模拟器,分别切几个关键尺寸看效果。PC 端至少要过三档:1366、1440、1920。移动端至少要过四档:320、375、414、768。
验证的核心指标是:设计稿里的某个固定元素,比如一个 300px 宽的卡片,在所有尺寸下视觉上都保持大约 300/750 或 300/1920 的比例。你可以直接在控制台执行getComputedStyle(document.documentElement).fontSize看根字号是否在预期范围内。
5. 设置 rem 时的常见问题与排查技巧实录
这几类问题我都在实际项目里遇到过,列出来供你排查时参照。
5.1 rem 写成 px 了怎么办
最常见的翻车现场是:根字号设置成功了,但组件里部分样式还是写死了 px,特别是border、box-shadow、letter-spacing这类属性。这些写成 px 通常问题不大,因为 1px 的边框在大多数屏幕上差异不明显,但如果你把宽度、高度、padding、margin写死成 px,那 rem 缩放时这些元素就会“不老实”,有的跟着缩,有的不跟着缩,布局直接乱掉。
排查思路是:在浏览器控制台执行下面这段代码,它会找出所有包含 px 的样式规则,你逐个看哪些是布局类属性:
const styles = document.styleSheets; for (let sheet of styles) { try { for (let rule of sheet.cssRules) { if (rule.style && rule.style.cssText.includes('px')) { const rules = rule.style.cssText.match(/[a-z-]+: [0-9.]+px/g); if (rules) console.log(rule.selectorText, rules); } } } catch (e) { // 跨域样式表会抛错,忽略 } }5.2 字号计算出现 > 100px 或极小值
这是边界条件没控制好。PC 端 4K 屏的clientWidth可能到 3840,不设上限的话根字号会变成 200px,整个页面放大一倍,视觉完全失控。反过来,老的安卓机或小折叠屏clientWidth才 320,根字号会算出来 42px 左右,设计稿里 100px 的字会缩到没法看。所以我在合并方案里特意加了minFontSize和maxFontSize,这属于经验值,你可以根据实际项目落地。
5.3 图片的宽高和 rem 比例对不上
这个坑出在设备像素比上。移动端设计稿如果是 750 宽二倍图,图片标注的宽高是物理像素。你写img { width: 3.5rem },算下来渲染宽度是 350px 的一半即 175px 逻辑像素,但物理像素是 350px,这是正常的。问题往往出在部分安卓机的devicePixelRatio是 2.75、3.5 这类非整数,这时用固定的 750 基准会导致部分图片过锐或模糊。
处理办法是按 dpr 缩放根字号。我给你一个思路:计算clientWidth * window.devicePixelRatio作为新的“视口宽度”,然后再除以设计稿宽度去算字号。但注意,如果这样做,你的viewport的initial-scale也要对应设置为1 / dpr,否则会出现页面被放大、点不中按钮的问题。这个方案改动较大,建议只在图片精度要求高的项目里用。
5.4 移动端横竖屏切换后 rem 不刷新
某些 Android 浏览器的resize事件在横竖屏切换时不会触发,或者触发得很慢。这时orientationchange事件就派上用场了。如果你用的是我上面给的合并方案,需要确保两个事件都被监听。另外还有一个冷门场景:部分浏览器对pageshow事件的理解不一致,它有些时候不会在普通跳转后触发,但在前进/后退缓存恢复时一定会触发,所以不能漏掉。
我给你的建议是写一个setRootFontSize()函数,然后在resize、orientationchange、pageshow三种情况下都调用它。配合防抖,不会产生性能问题。
5.5 Chrome 的字体最小字号限制打断缩放
Chrome 浏览器有个默认设置:最小字体大小是 12px。如果你的根字号经过计算后在极端小屏上低于 12px,Chrome 会强制把html的font-size显示成 12px,导致整个页面缩放比例失真。虽然现在很多浏览器已经放宽了这个限制,但老版本的浏览器还是会遇到。
解决方案有两种:一是用-webkit-text-size-adjust: 100%降低影响;二是保证根字号的下限不小于 12px。你可以用下面这段代码在控制台快速验证当前页面的实际根字号:
console.log(parseFloat(getComputedStyle(document.documentElement).fontSize));5.6 iframe 内嵌页面的 rem 计算
做后台系统时经常遇到 iframe 内嵌看板的情况。iframe 里的页面如果也用 rem 适配,它拿到的clientWidth是 iframe 自身的宽度,而不是父页面的宽度,这其实是正确的。但如果你在 iframe 里用了window.parent的尺寸来计算,页面在某些布局模式下会误判。
我的经验是:iframe 内的 rem 适配应该只依赖自身窗口尺寸,除非你有强烈的跨页面联动需求。否则父页面调整侧边栏宽度导致 iframe 尺寸变化时,iframe 页面里的 rem 计算会乱。如果有联动需求,需要在父页面通过postMessage把新的宽度传给 iframe,而不是在 iframe 内部去读父级的getBoundingClientRect()。
6. 根字号计算后的单位换算与开发技巧
6.1 rem 和 px 的快速换算方法
设定基准字号为 100 后,最爽的体验就是换算的心智负担基本为零:
- 设计稿标注 16px,代码就写
0.16rem - 设计稿标注 100px,代码就写
1rem - 设计稿标注 375px,代码就写
3.75rem
如果是 750 宽设计稿,但团队代码想用 375 作为设计基准,也只需要在换算时统一除以 2。比如设计稿标注 300px,代码写1.5rem,实际渲染宽度是 150px——这个你开发时一定要心里有数,不然视觉走查时会觉着元素偏小。
如果团队统一用基准字号 16,虽然数值是整数但换算心智负担重,不建议。你自己做无所谓,带团队做适配一定选 100。
6.2 在 SCSS/LESS 中封装 px 转 rem 的工具函数
既然 JS 里能做换算,CSS 预处理器里也可以封装一个函数,这样写样式时就完全不用手动换算,直接写设计稿的 px,编译时自动转 rem。
以 SCSS 为例:
$design-width: 750; $base-font-size: 100; @function px2rem($px) { @return ($px / $base-font-size) * 1rem; } .box { width: px2rem(300px); height: px2rem(150px); }编译出来的结果是:
.box { width: 3rem; height: 1.5rem; }这类工具的要点是:它和 JS 里的设计稿宽度、基准字号必须保持同一个值。如果你 SCSS 里写$design-width: 750,JS 里写designWidth=375,那两者全部对不上,页面直接崩。
6.3 使用 CSS 变量做局部适配
如果你希望某些组件在移动端和 PC 端有不同的渲染比例(比如移动端更紧凑、PC 更宽松),可以在根字号方案之上叠加一层 CSS 变量。JS 在判断到是移动端时,给html加一个>:root { --card-padding: 2rem; } [data-device="mobile"] { --card-padding: 1.2rem; }
基础长度用 rem 跟随全局缩放,组件的局部间距用 CSS 变量做差值修正。这个技巧实际开发中非常有用,尤其是响应式改版阶段,不需要动 JS 逻辑,只改样式变量就能快速调整。
7. 从这套 rem 方案还能扩展什么
如果你的项目已经跑通了 PC 端和移动端 rem 适配,下一步可以自然延伸到几个更有价值的方向。
第一个方向是大屏可视化适配。大屏通常运行在固定分辨率的显示器或拼接屏上,逻辑上和 PC 端 rem 适配一致,但多了“拼接缝补偿”和“安全边距”的问题。你可以基于 rem 方案,叠加一个transform: scale()来适配非标准分辨率,保证内容不被裁切。
第二个方向是局部容器 rem 缩放。组件库或复杂业务页面中,可能有某个卡片列表、图表容器固定要按自身的宽度而不是整页宽度进行 rem 变换。这种场景下,你可以把根字号计算从全局改成局部:在某个容器内临时调整 font-size,子元素依然用 rem。但要注意,CSS 的 rem 永远只认html的 font-size,所以局部缩放实际上要靠em而不是rem。如果你既想保留 rem 的全站一致性,又想局部缩放,就得用内联样式动态改了。
第三个方向是子页面与设计稿的配合。我经历过的项目里,设计团队有时会把 PC 设计稿和移动端设计稿的基准宽度定得不一样,比如 PC 是 1920,移动是 750。这个时候,你需要和设计约定:所有间距、字号、圆角都尽量用整数像素,避免奇奇怪怪的 3.333px,否则换算后会有严重的精度损失。这个看起来是规范问题,但在 rem 适配的项目里特别重要,因为它直接影响 CSS 渲染的物理像素对齐。
第四个方向是服务端渲染场景的适配。如果你的项目用了服务端渲染,JS 设置 rem 会面临一个“首屏闪烁”的问题:服务端返回的 HTML 里没有根字号,浏览器要先渲染一版默认字号,再执行 JS 设置根字号,用户会看到页面跳动一下。解决办法是在<head>里内联一小段 JS,来尽早设置根字号。但尽量减少内联脚本的大小,并且避免阻塞渲染。常用的做法是:
<script> (function (doc, win) { var clientWidth = doc.documentElement.clientWidth; var fontSize = (clientWidth / 1920) * 100; doc.documentElement.style.fontSize = fontSize + 'px'; })(document, window); </script>这段代码放在<head>的 CSS 之前,虽然内联脚本会有一定的阻塞,但因为这个脚本只做一次计算和一次样式写入,代价极小,收益是首屏没有闪烁。
我个人在实际操作中的体会是,rem 适配方案最大的价值不是“像素级还原”,而是把“适配”这个关注点从杂乱无章的 CSS 断点里抽出来,变成一段可以用逻辑验证的 JavaScript。只要设计稿基准、视口宽度获取、事件监听这三个环节不出错,PC 和移动端的比例问题就解决了一大半。最后再分享一个小技巧:把设置根字号的代码封装成独立模块放出来,不要只在某个页面里局部定义,因为适配这件事在项目生命周期里一定会被反复调用。