rem计算与渲染顺序 页面初次渲染宽度异常

记录一个app内嵌h5页面初始加载卡顿时偶现的渲染问题(页面由窄变宽),主要是rem计算和页面渲染顺序导致的

补充老blog

从输入url到页面显示都经历了什么 | 珠这里需要把 <script> 是否暂停渲染改改,是有的会阻塞渲染有的不会,分情况的

问题描述

  • 已知前提: vue3项目,页面通过webview内嵌于app中,头部和足部都是终端控制,中间部分是h5页面内容
  • 问题场景: 网络卡顿情况下,偶现的概率灰出现加载页面过程中先展示灰色窄长条然后才展示正常宽度
    异常与正常页面差异图

原因猜想

  • 这种窄条的样子倒是和设备宽度极大(远超过移动端设备宽度)的样子有点像,大概率是rem计算的问题

关键 :script 的阻塞与并行区别

  • 问题的根源就在于 HTML/CSS 解析 与 JS执行 的顺序并不只受到先后顺序影响, <script type="module">(等价于<script defer>)不阻塞 HTML / CSS 解析, 导致浏览器在执行 setRem() 之前, HTML 和 CSS 就可能已经解析并开始参与页面渲染了
    • 所以问题出在这:
      • 看起来顺序: index.htmlid="app" 元素 初始化 -> main.tssetRem再挂载App.vue 相关 CSS 样式(html, body, #app样式)
      • 实际顺序: 拼接一起以后的执行顺序是 :
        1
        解析 HTML 构建 DOM 树 -> 加载 CSS 构建 CSS对象模型 -> 合成 渲染树 绘制到屏幕上 -> 渲染中遇到 JS 停止渲染去加载 JS
        • 也就是说实际上App.vue里的样式在main.tssetRem执行前就有概率先渲染了,导致rem还是默认值16px
  • 需要做的是把script中关于rem计算的写成阻塞页面渲染的<script>,确保这段 JS 计算优先于初始的页面渲染

需要知道

浏览器的加载顺序(简化版)

  1. 解析 HTML → 构建 DOM 树
  2. 遇到 <link><style> → 构建 CSSOM(CSS 对象模型)
  3. DOM + CSSOM 合成渲染树 → 绘制到屏幕上
  4. 遇到 <script>
    • defer / async / type="module" → 并行 HTML 解析 、加载 & 执行 JS
    • 普通<script>(非 defer / async) → 暂停解析 HTML → 加载 & 执行 JS → 继续解析

浏览器的加载与渲染是“并行 + 阻塞”的关系

浏览器在遇到不同资源时,会做不同的处理:

类型 行为 是否阻塞 HTML 解析
<link rel="stylesheet"> 异步加载,但在渲染前必须等 CSSOM 构建完成 ✅ 阻塞渲染(Render Blocker)
<script>(无 defer / async 停止 HTML / CSS 解析,立即加载并执行 JS ✅ 阻塞解析(Parse Blocker)与渲染
<script defer> 等 DOM 解析完再执行 JS ❌ 不阻塞
<script async> 加载完成就立即执行 JS,时机不确定 ❌ 不阻塞
<script type="module"> 类似defer ❌ 不阻塞

问题代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link href="/favicon.ico" rel="icon" type="image/svg+xml"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no"/>
<title></title>
</head>
<body>
<div id="app"></div>
<script src="/src/main.ts" type="module"></script>
</body>
</html>
1
2
3
4
5
6
7
8
// main.ts
import App from './App.vue'; // 👈 这里执行时,App.vue 的样式就已注入 <style>
import { createApp } from 'vue'
// ...
setRem(); // 👈 这里先计算 rem
// ...
const app = createApp(App); // <-- 1. 创建应用实例
app.mount('#app'); // // <-- 2. 挂载应用(这一步非常关键)
1
2
3
4
5
6
7
8
9
10
11
12
13
// App.vue
<style lang="scss" >
html,
body {
background: #000;
}
#app {
margin: 0 auto;
min-height: 100vh;
width: 7.5rem;
background-size: 100%;
background: #F5F5F5;
}

问题产生的流程概述

  • 需要知道的完整启动过程:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    1️⃣ index.html 解析阶段

    浏览器开始解析 HTML
    ├─ 创建 <html>、<body>、#app 等 DOM 节点
    ├─ 遇到 <link> → 异步加载 CSS
    ├─ 遇到 <script type="module" src="/src/main.ts">
    │ ⤷ 不阻塞解析(defer-like),继续解析 HTML
    └─ #app 已存在于 DOM(后面样式可先找到DOM进行渲染)

    2️⃣ main.ts 加载与执行(在 HTML 解析完成后)

    import App.vue
    ├─ 执行 App.vue 内部 import(样式模块)
    │ ⤷ 向 <head> 注入 <style> 标签
    │ ⤷ 样式立即进入 CSSOM,等待参与渲染
    └─ App 组件本身的逻辑代码加载完毕,但还没创建与渲染

    浏览器此时状态
    ├─ DOM 树已构建完成
    ├─ CSSOM 部分或全部构建完成
    ├─ Render Tree 可能已生成(根据默认 font-size 进行布局)
    └─ 有概率触发首帧绘制(默认 rem = 16px)

    执行 setRem()
    ├─ 修改 html 的 font-size(比如 rem = 50px)
    ├─ 如果此时浏览器已绘制首帧,则触发回流 (reflow)
    ├─ 若能在 CSS 解析前执行,可避免首屏抖动(rem 16px->50px 导致的窄屏->宽屏)
    └─ (最佳实践:提前放在 index.html <head> 中执行)

    4️⃣ 执行 createApp(App)
    ├─ 创建 Vue 应用实例
    ├─ 不再注入样式(已注入)
    └─ 准备渲染树

    5️⃣ 执行 app.mount('#app')
    ├─ 定位到现有的 #app 节点
    ├─ 清空其内部内容
    ├─ 通过 Virtual DOM 渲染出组件结构
    ├─ 触发 App.vue 的 setup()、render() 执行
    └─ 启动响应式系统,生成实际 DOM 节点

    6️⃣ 浏览器再次合成渲染树
    ├─ DOM(Vue 渲染结果) + CSSOM(含 App.vue 样式)
    ├─ Recalculate Style → Layout → Paint → Composite
    └─ 绘制出最终 Vue 首屏内容

项目中发生的事情

  1. index.html 阶段
    • 浏览器先解析 <html>、<body> 等结构
    • 构建出一个空的 #app 容器
    • 此时没有 Vue 应用,CSS(包括来自 App.vue 编译出的样式)尚未加载
  2. main.ts 执行
    • 由于 Vite 会把入口 JS 用 <script type="module" src="..."> 的形式加载,
      所以在 DOM 基本就绪后,它开始执行:setRem(); createApp(App).mount('#app')
      这段 JS 不会阻塞 HTML / CSS 解析 与 页面渲染
    • setRem() 修改了根元素的 font-size
    • createApp(App) 加载组件树(包括 App.vue 的样式)
  3. 关键点:App.vue 的样式加载时机
    • 注意: 浏览器在渲染过程中是 “边解析边绘制”
      • App.vueimport 时就已经将 CSS 注入到 <style> 标签中,并不是等到createApp(App) 创建了一个 应用实例(内存中对象)/ app.mount('#app') 创建 根组件实例 App.vue 时才注入样式
      • 而 DOM 树 中更是已经存在 html / body / #app
      • 所以有概率出现 JS 脚本还没跑完时(还没执行到 setRem()),HTML 和 CSS 解析出默认字体大小的首屏就先渲染在页面上
    • 所以第一帧的样式用的是默认 16px 的 rem
      • 结果:#app.width = 7.5 × 16px = 120px
      • 浏览器立刻绘制这 120px 宽的灰条在中间
    • 然后 setRem() 再修改 font-size,浏览器触发一次 reflow(回流),视觉上就像是“样式在 setRem 之前先执行”了一样(即所谓“首屏抖动”)
      • 计算完毕后把 html.style.fontSize 设置为正确值(比如 50px
        - 页面重新布局 → 7.5rem = 375px
        - 灰条从中间“撑开”到全宽,看起来像闪了一下

拓展了解

  • createApp(App) 创建了一个 应用实例(内存中对象),不会执行 App.vue 的代码
    • 真正触发 App.vue 初始化的是 app.mount()
    • app.mount('#app') 做了什么:
      • 在内存中创建一个 根组件实例,这个根组件就是你传入的 App.vue
      • 开始执行 App.vuesetup() / data / computed / watch
      • 调用 render(),生成虚拟 DOM
      • 把虚拟 DOM 渲染成真实 DOM
      • 替换掉 <div id="app"></div> 的内容

场景复现

  • main.ts 里设置一个延时,可以通过延时模拟初始化缓慢的问题
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // main.ts
    import App from './App.vue';
    import { createApp } from 'vue'

    // 模拟初始化慢
    setTimeout(() => {
    setRem();
    }, 5000); // 延迟 5000ms 执行

    const app = createApp(App);
    app.mount('#app');
  • 复现出来的灰色北京条宽度确实如此,打印出的初始字体大小确实是16px,所以灰色宽度是120px

解决方案

setRem() 尽早执行,且确保阻塞 HTML / CSS 的解析,避免在页面渲染以后才处理rem

1
2
3
4
5
6
7
8
9
10
11
<!-- index.html -->
<head>
<script>
// 在任何 CSS 加载前执行
(function setRem() {
const baseSize = 100;
const scale = document.documentElement.clientWidth / 375;
document.documentElement.style.fontSize = baseSize * Math.min(scale, 2) + 'px';
})();
</script>
</head>

然后 main.ts 就不用再执行一次了。这样能确保:

所有 Vue 组件的 rem 计算基础在页面渲染之前就已经设定好。


业界常见的“首屏自适应初始化”优化思路

淘宝 flexible、postcss-px2rem、lib-flexible、vw 脚本等都在 head 阶段 执行。

比如淘宝 flexible 的源码里,第一行就是:

1
2
3
4
5
6
(function flexible (window, document) {
var docEl = document.documentElement;
var dpr = window.devicePixelRatio || 1;
var rem = docEl.clientWidth / 10;
docEl.style.fontSize = rem + 'px';
})(window, document);

它和我这项目的逻辑几乎一样,只是放在了 最前面


总结

要想样式初始化时 rem 就正确生效,必须让 setRem() 在任何 Vue 应用启动和 CSS 渲染前执行(最好放在 <head> 里且用单纯的 <script> 阻塞 HTML / CSS 解析与页面渲染)

执行位置 渲染阶段 是否闪烁 原因
<head> 在 CSS 加载前 ✅ 不闪烁 font-size 在第一次布局前确定
main.ts 在 Vue 启动前但 CSS 可能已解析 ⚠️ 有概率闪烁 浏览器已绘制过旧布局,修改后要重排
Vue 内部组件 🚫 太晚 🚫 无意义 早已渲染错误