背完"DNS→TCP→DOM→渲染"只算第一层。真正的分水岭是缓存优先级、阻塞机制、合成层划分,以及主线程和合成线程的分工。五层追问,答到第三层算合格。
一、流水线是底线,但面试官不会只问流水线
"DNS 解析 → TCP 连接 → HTTP 请求 → 解析 HTML → 构建 DOM 树 → 渲染页面。"背下这串三分钟够了,面完大概率换来一句"基础还行"。
面试官接着问:从回车到像素上屏,中间有多少坑、多少优先级、多少浏览器自己的策略?四年经验的人卡住,不稀奇。
这题从来不是一条背出来的流水线,它是浏览器原理的第一道分水岭。我拆成五层说。
流水线之外,才是真正值得聊的:
- 地址栏里敲的可能是搜索词 。浏览器先做 URL 合法性判断------有没有 scheme、有没有空格、是不是
localhost这类特殊主机名。不合法就丢给默认搜索引擎。所以localhost:3000能直接开,react hooks不会。 - 回车后的前置动作:中断当前页面的加载、按站点复用 Renderer 进程、把请求交给网络线程。
- Omnibox 的预测行为:DNS 预解析、preconnect、甚至预渲染,都可能在你按下回车之前就发生了。
第一层的胜负手不是背流程,是知道每一步跑在哪个线程上、谁先谁后。
二、网络层:四年经验最容易露怯的地方
DNS 缓存:hosts 永远最大
浏览器 DNS 缓存 → 系统 hosts 与 DNS 缓存 → 路由器 → 本地 DNS → 递归查询。写代码时只需要记两件事:hosts 优先级高于一切 DNS 缓存 ;dns-prefetch 只解决域名解析,不解决 TCP 和 TLS。
握手什么时候能省掉
同域名有可用连接时,TCP 握手直接省掉(HTTP/1.1 keep-alive、HTTP/2 多路复用)。TLS 会话复用(session ticket、0-RTT)能跳过完整握手。换域、换端口、连接空闲超时,都得重来。
强缓存与协商缓存:冲突时听谁的
| 维度 | 强缓存 | 协商缓存 |
|---|---|---|
| 相关 header | Cache-Control: max-age、Expires |
ETag、If-None-Match、Last-Modified |
| 是否发请求 | 不发 | 发,服务端返回 304 |
| 优先级 | Cache-Control 覆盖 Expires |
ETag 精度高于 Last-Modified |
| 典型事故 | max-age 设太长,发版用户拿旧包 | 集群多机 ETag 不一致,永远命不中 |
几个常被答错的点:no-cache 不是不缓存,是每次必须校验;no-store 才是完全不存;max-age=0, must-revalidate 表达的是同一个意思但语义更明确。时间戳格式的 Expires 还依赖客户端时钟,本地时间不准就废了。
301 / 302 的缓存副作用
301 会被浏览器长期缓存,而且经常不进 Network 面板,Chrome 里清了常规缓存也未必干净。线上域名迁移最惨的事故基本都来自这里。
302 默认不缓存(除非显式带 Cache-Control),临时活动页用它,永久迁移才考虑 301,而且要清楚它不可逆。
同域并发:HTTP/1.1 六条,HTTP/2 别分片
HTTP/1.1 下 Chrome 是 6 条并发连接/域名,这是域名分片(domain sharding)存在的原因。HTTP/2 改成多路复用,并发限制变成流数量,这时候再做域名分片反而有害------每个域名都要重新握手,还会打散优先级。
三、解析与加载:真功夫全在这里
script 到底阻塞了什么
HTML 解析器遇到不带属性的 <script> 会停下 DOM 构建,去下载并执行------因为它可能 document.write,解析器不敢赌。
| 属性 | 下载 | 执行时机 | 阻塞解析 | 顺序保证 |
|---|---|---|---|---|
<script> |
同步 | 下载完立即 | 是 | 文档顺序 |
defer |
异步 | DOMContentLoaded 之前 | 否 | 文档顺序 |
async |
异步 | 下载完立即,可能仍在解析中 | 否 | 不保证 |
type="module" 默认就是 defer 行为。内联脚本不参与 defer/async 语义。
CSS 不阻塞解析,但能卡死渲染
CSS 不阻塞 HTML 解析,但阻塞渲染树构建------没有样式就没法算布局。更隐蔽的是CSS 会间接卡住后面的 script :脚本可能调 getComputedStyle,浏览器必须等样式就绪。所以一个慢 CSS 能把首屏白屏时间拉得很长。
preload / prefetch / preconnect 的本质
preload:当前导航马上要用,高优先级,落内存缓存。必须写as,否则可能下载两次。prefetch:下一次导航可能用,低优先级,落 HTTP 缓存。preconnect/dns-prefetch:只建连接或解析域名,不下载资源。
有个坑:重定向场景下 preload 会失效 。资源被 302 到另一个 URL 时,按原地址发起的 preload 就白费了,还多打一次请求。跨域 preload 的 crossorigin 不匹配,缓存也对不上号。
图片和字体,谁阻塞首屏
图片不阻塞 DOM 解析,也不阻塞首屏绘制(除非它在关键路径上撑开布局)。字体默认会阻塞文本渲染 ,font-display: auto 有约 3 秒的 block 期;swap 先渲染兜底字体再换;optional 可能干脆不换。
中文站点还要注意 unicode-range 拆分------不拆的话,一个中文字体包轻松几 MB,字体没到,首屏文字全是空白。
四、渲染流水线:改哪个属性,触发哪一步
主流程:Parse → Style → Layout → Paint → Composite。关键在于哪一步会被哪类改动触发。
| 操作 | Layout | Paint | 仅 Composite |
|---|---|---|---|
改 width、top、margin |
触发 | 触发 | 否 |
改 color、background、box-shadow |
不触发 | 触发 | 否 |
改 transform、opacity |
不触发 | 不触发 | 是 |
transform 和 opacity 能走 GPU 合成,是因为它们不需要重算几何和像素填充,合成器直接对已有图层做矩阵变换和透明混合就行。
will-change: transform 会提前把元素提升为独立合成层,但别乱加。每个合成层都吃显存,滥用会造成层爆炸,移动端尤其明显,动画结束后应该移除。
z-index 只有在元素创建了层叠上下文时才有意义:position 非 static 且 z-index 非 auto,或有 transform、filter、opacity < 1 等。调层级调不出来,八成是父元素无意中开了新上下文,子元素的 z-index 只在父上下文里比大小。
content-visibility: auto 值得单独记一笔:
css
.card {
content-visibility: auto;
contain-intrinsic-size: 0 300px;
}
它跳过屏幕外内容的渲染,保留占位,配合 contain-intrinsic-size 防止滚动条抖动。和 display: none(不渲染不占位)、visibility: hidden(占位不绘制)是三种不同语义,长列表优化里它的性价比最高。
五、主线程与合成线程:谁在干活,谁在卡
到了这一层,问题变成"哪些活是主线程干的,哪些交给合成线程"。
主线程 :JS 执行、样式计算、布局、绘制指令生成、事件分发。 合成线程:分块、光栅化调度、合成、提交 GPU。
事件循环插在哪个环节?浏览器把渲染机会放在任务队列清空之后、微任务清空之后。所以一段长时间同步 JS 会把渲染整个卡死------requestAnimationFrame 里做重活一样掉帧,它保证的是"在下一帧渲染前执行",不是"不占用渲染时间"。
图片解码会阻塞 :大图解码默认在主线程,滚动时容易掉帧。img.decode() 或者 createImageBitmap 可以提前解码。
像素对齐问题 :left: 0.5px、缩放遗留的非整数 transform,会让文字被重新光栅化,看起来发虚。动画结束时记得把 transform 归零。
LCP 对应整条链路的最末端------最大的内容元素完成绘制并上报。想优化它,就是把前面所有环节一起压:TTFB、资源加载、渲染阻塞、绘制。
如果只剩三十秒
这样答:
从地址栏判断到像素上屏,这条链有网络、缓存、解析、渲染、合成五段,每段都有自己的优先级和阻塞规则。真正决定首屏体验的,是 CSS 与主线程的争用,以及图层能不能交给合成线程独立跑。
写在最后
浏览器原理这类问题,最怕的不是不会,是"以为自己会"。能从地址栏一路聊到 GPU 像素输出的工程师,和只会背流水线的工程师,差距不在记忆量,在排查线上问题时能不能想到那一层。
你在真实项目里遇到过最诡异的浏览器行为是什么?我印象最深的是某次 301 被缓存了半年,测试环境怎么改配置都没用。评论区聊聊。
有用的话点个赞。