Skip to content

缓存配置和解析渲染

缓存配置和页面渲染

一、 HTTP缓存:强缓存 vs 协商缓存(怎么配、在哪配)

面试官会直接问:“给你一个新项目,你怎么配置静态资源缓存?” —— 你只需亮出这张表加一段 Nginx 配置,逻辑就极其清晰。

  • 强缓存Cache-Control):直接从磁盘/内存读,不发请求。JS/CSS/图片(带指纹)用 max-age=31536000, immutable(一年)。
  • 协商缓存ETag / Last-Modified):发请求问服务器是否过期,没过期返回 304。index.html 必须用 no-cache, must-revalidate

Nginx 配置示例(直接说给面试官听)

nginx
# 静态资源(带 hash 的 JS/CSS,如 main.a1b2c3.js)—— 强缓存
location ~* \.(js|css|png|jpg|svg|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# HTML 文件 —— 协商缓存(必须回源校验)
location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
}

面试话术:“强缓存和协商缓存是配合关系。强缓存命中就不发请求;强缓存过期或没有强缓存,才走协商缓存对比 ETag,决定是返回 304 还是新资源。”

二、 SPA 的 index.html 为什么不能强缓存?(核心痛点)

面试官会追问:“SPA 项目中,index.html 用什么缓存策略?为什么?

  • 结论绝对不能用强缓存! 必须用 Cache-Control: no-cache(协商缓存)或 max-age=0
  • 原因:SPA 只有一个 index.html 入口。如果它被强缓存,用户浏览器会一直使用旧版 index.html,引用的依然是旧版 JS/CSS(main.old.js),但服务器可能早已删除旧文件(因为 new.js 文件名变了),导致页面直接白屏或报错 404。

最佳实践

  1. index.htmlCache-Control: no-cache, must-revalidate(每次都回源验证)。
  2. JS/CSS 文件:带上 内容哈希(Content Hash),如 main.a1b2c3.js,设置强缓存一年。文件变了,哈希就变了,URL 就变了,用户自然请求新文件。

解释Nginx配置

第一步:先看“匹配规则”(location 是啥?)

配置块 1:匹配静态资源(图片、JS、CSS) location ~* .(js|css|png|jpg|svg|woff2)$ { ... }

逐词翻译(人话版):

  • location:告诉 Nginx,“当用户请求某个网址时,我要匹配这个网址”。
  • ~*:两个符号组合。
    • ~ 表示“使用正则表达式匹配”(高级模糊匹配)。
    • * 表示“不区分大小写”(JSjs 都认)。
  • \.:在正则里,. 代表任意字符,这里加了个反斜杠 \,表示“我要匹配一个真实的点号”(比如文件名里的 main.js 中间那个点)。
  • (js|css|png|...):竖线是“或”的意思。匹配这些后缀名。
  • $:表示“结尾”。意思是网址必须以这些后缀结尾(防止匹配到 main.js.bak 这种奇怪文件)。

这块配置生效的场景:当浏览器请求 https://xxx.com/static/main.a1b2c3.jslogo.png 时,就会命中这个规则,进入这个大括号 {} 里执行指令。


第二步:命中静态资源后,执行什么指令?
expires 1y;
add_header Cache-Control "public, immutable";
  • expires 1y;

    • 这是设置 HTTP 响应头里的 Expires 字段(老规范)。
    • 意思就是告诉浏览器:“这个东西一年后才过期”。在一年内,浏览器问都不会问你(服务器),直接拿本地缓存用。
  • add_header Cache-Control "public, immutable";

    • 这是设置 HTTP/1.1 的 Cache-Control 字段(新规范,优先级高于 Expires)。
    • public:意思是“中间节点(比如 CDN、公司代理网关)也可以缓存这份文件”,这样离用户更近,加载更快。
    • immutable(不变量):这是最强硬的指令。意思是“这个文件永远不变!”(因为带了 hash,如 main.a1b2c3.js,变了一点 hash 就变了,文件名就变了,所以确实不会变)。浏览器收到这个指令,连“检验是否过期”的请求都彻底免了,绝对不跟服务器通信。

第三步:配置块 2(HTML 文件)
location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
}
  • 匹配规则:只要请求以 .html 结尾(比如 index.htmlabout.html),就执行这个块里的指令。

  • add_header Cache-Control "no-cache, must-revalidate";

    • no-cache:名字有误导性,它不是“不缓存”。它的意思是:“每次必须去服务器验证一下(发个请求问问服务器:我的版本是不是最新的?)”。如果服务器说“你是最新的”(304),再读缓存;如果说“不是最新的”(200),再拉新文件。
    • must-revalidate:增强指令。意思是“一旦过期,必须去源服务器(Nginx/后端)验证,不能随便找个中间代理去验证”。

第四步:实战推演(为什么要这么配?结合你的 SPA)

面试官如果问“为什么 HTML 不设置强缓存?”,你就这样解释:

(指着配置) 你们看静态资源(main.a1b2c3.js)因为文件名里有 a1b2c3 这个哈希值(版本号),代码变了,哈希值就变了,文件名就变成 main.d4e5f6.js 了。浏览器发现请求的 URL 变了,自然就会去拿新文件,所以原来的旧 main.a1b2c3.js 哪怕缓存一年也没事。

但是 index.html 没有哈希值。只要我发版改了代码,index.html 里引用的 JS 路径就会从 main.old.js 变成 main.new.js。如果我把 index.html 也设置成 expires 1y,用户一年内都不会请求新的 index.html,他的浏览器会一直拿着旧 index.html,试图加载 main.old.js,但服务器早就把 main.old.js 删了,页面直接白屏报错

所以 index.html 必须设成 no-cache,每次都要去服务器看一眼:我是不是最新的?”


第五步:怎么确认它生效了?

你可以补一句:“生产环境上线后,我会打开浏览器开发者工具的 Network(网络)面板,看一眼 index.html 的请求头(Response Headers)。如果看到 Cache-Control: no-cache,并且状态码是 304(说明走协商缓存),那就证明配置生效了。而静态资源(JS/CSS)如果看到 Cache-Control: public, immutable,并且状态码是 200 (from disk cache),说明强缓存生效了。”

带 hash 值的文件名

不是所有项目都“必须”有这两套配置,但这套“带 hash 的资源永久缓存 + index.html 协商缓存”的组合,是现代前端工程化项目的性能优化最佳实践。绝大多数正式上线的项目都会采用这个方案。

至于带 hash 值的文件名,它并不是手动修改的,而是由构建工具(如 Webpack、Vite)在打包时自动生成并注入的。下面我详细解释它是怎么实现的。

一、 带 hash 的文件名是怎么实现的?

这全靠构建工具在打包时的“文件指纹(File Fingerprinting)”功能。它的工作流程如下:

  1. 读取文件内容:构建工具会读取你的源代码文件。
  2. 计算哈希值:它基于文件的内容,通过一个哈希算法计算出一串唯一的哈希值。只要文件内容变了一个字符,生成的哈希值就完全不同
  3. 重命名文件:工具会按照配置,将输出文件命名为 原文件名.哈希值.后缀 的格式。
  4. 自动更新引用:最关键的一步!工具会自动更新 index.html 中对该资源的引用路径。例如,<script src="main.js"> 会被自动替换为 <script src="main.a1b2c3.js">
二、 代码里是怎么配置的?(面试重点)

根据你使用的构建工具,配置方式略有不同。

1. Webpack 项目

在你的 webpack.config.js 文件中,通过 output 配置项里的 [contenthash] 占位符来实现。

javascript
// webpack.config.js
module.exports = {
  // ...
  output: {
    // 在文件名中使用 [contenthash] 占位符
    // 这样打包后就会生成类似 main.a1b2c3d4.js 的文件
    filename: '[name].[contenthash].js',
    // 对于通过代码分割(Code Splitting)生成的 chunk 文件也同样处理
    chunkFilename: '[name].[contenthash].chunk.js',
  },
};

[contenthash] 是最佳实践,因为它只基于文件自身的内容生成哈希,内容不变,哈希就不变。

2. Vite 项目

Vite 在生产构建(npm run build)时,默认就会对静态资源文件名进行哈希处理。如果你需要自定义,可以在 vite.config.js 中配置:

javascript
// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 自定义 JS 文件名格式,[hash] 占位符会被替换
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        // 自定义 CSS/图片等静态资源文件名
        assetFileNames: 'assets/[name].[hash].[ext]'
      }
    }
  }
});

可以看到,Vite 和 Webpack 的原理是一样的,都是通过占位符让构建工具自动生成带哈希的文件名。

三、 面试“连招”话术

你可以这样把这个问题讲给面试官听:

  1. “缓存配置是前端性能优化的标准实践,但不是所有项目都必须有。对于需要长期维护和迭代的正式项目,这套配置是必需的。”
  2. “带 hash 的文件名是由 Webpack 或 Vite 这类构建工具自动生成的,我们不需要手动修改。”
  3. “以 Webpack 为例,只需在 output 配置中用 [contenthash] 占位符,它就会基于文件内容计算 hash。index.html 中的引用路径也会被构建工具自动更新。”
  4. “这样,文件内容变了,hash 变了,URL 就变了,浏览器就会认为是全新的资源,从而完美解决了版本更新后的缓存问题。”

三、 输入 URL 到页面渲染的“完整链条”(重点阐述渲染流程)

  • 网络层:DNS 解析 -> TCP/TLS 握手 -> 发送 HTTP 请求 -> 接收响应。
  • 解析层:HTML 解析成 DOM 树(遇到 <script> 可能阻塞),CSS 解析成 CSSOM 树(CSS 阻塞渲染,但不阻塞 DOM 解析)。
  • 渲染层(核心!):DOM + CSSOM 合并为 渲染树(Render Tree) -> 布局(Layout)(计算几何信息) -> 绘制(Paint)(填充像素) -> 合成(Composite)(GPU 图层合并)。

四、 对“具体元素”来说,布局和渲染做了什么?(深水区)

面试官会盯着一个元素问细节,这里别慌:

  • 布局(Layout/Reflow):计算这个元素在屏幕上的确切几何信息(宽、高、xy、边距、边框)。它是递归的——父元素布局完,子元素才计算。
  • 绘制(Paint):将布局算好的几何信息,填充颜色、文本、阴影、边框,生成一个个像素点。
  • 合成(Composite):把不同的绘制层(比如 transform: translateZ(0) 会触发新建层)交给 GPU 合成最终屏幕图像。

五、 display: none 的灵魂拷问(是区分“会用”和“懂原理”的分水岭)

面试官:“display: none 的元素,会被构建到渲染树(Render Tree)里吗?

  • 第一层回答:“不会。渲染树是由 DOM 树CSSOM 树 合并时生成的,display: none 的元素会被过滤掉,它不占据任何空间,也不触发布局和绘制。”
  • 第二层回答(进阶):“但它依然存在于 DOM 树中! 只是被‘渲染树’踢出去了。通过 document.querySelector 依然可以拿到它。它的子元素也会被一同过滤,即便子元素显式写了 display: block,因为父元素被移除了。”
  • 终极对比(体现系统认知)
    • display: none:不在渲染树,不占位,不触发渲染。DOM 操作后触发回流
    • visibility: hidden:在渲染树,占位(几何空间还在),不可见,触发绘制。
    • opacity: 0:在渲染树,占位,完全绘制(只是透明),触发合成(硬件加速)。

终极话术:“在我们 WeLink 的会话列表中,未读消息的气泡红点,我用 visibility: hidden 控制,因为它占位,切换时不会导致旁边的文字左右跳动;而整个子列表的懒加载,我会用 display: none,因为它彻底脱离文档流,能减少首屏 Layout 计算量。”

解释display: none

  • DOM 树里有它document.querySelector 能拿到,JS 能操作)。
  • 渲染树(Render Tree)里绝对没有它

一、 先画两条“生产线”(区别在哪里)
  • DOM 树(Document Object Model):这是 “HTML 结构说明书”。浏览器读取 HTML 标签后,生成这棵树,它记录了页面有什么元素以及元素之间的层级关系(父子、兄弟)。它是 JS 操作的基础
  • 渲染树(Render Tree):这是 “装修施工图”。这棵树只包含**“需要画在屏幕上的东西”**。它是 DOM 树和 CSSOM(样式规则)结合后,过滤掉那些不需要展示的节点后生成的。

二、 display: none 在这两条线上的命运

1. 在 DOM 树上(存在) 浏览器解析 HTML 时,遇到了这个标签(比如 <div style="display:none">),它会直接把这个节点挂在 DOM 树上。所以你可以用 document.getElementById 找到它,也可以修改它的 className

2. 在渲染树上(被“踢出局”) 当浏览器准备画页面时,它会遍历 DOM 树,逐个检查节点的样式。当它看到这个节点是 display: none 时,它会直接**“无视”它及其所有子孙后代**,不会把它们复制到渲染树里。


三、 我们“关注”的是“有还是没有”?(面试官最想听的逻辑)

既然它不渲染,那我们关注什么?关注成本(Cost)

渲染树里没有它,意味着:

  1. 它不占位:它没有宽高,不会把旁边的文字挤开。
  2. 它不参与布局(Layout/Reflow):浏览器在计算所有元素几何位置时,完全跳过它。它就像不存在一样。
  3. 它不触发绘制(Paint):GPU 不会给它分配任何像素。
  4. 它的子节点也被连坐:哪怕子节点写了 display: block,只要父级是 none,整个子树都会被从渲染树中移除。

那么,既然不渲染,我们还在乎它吗?在乎!—— 在乎它的“JS 操作成本”和“回流成本”:

  • 它在 DOM 树里,占用内存(JS 堆内存)。如果 DOM 节点极其庞大,即使隐藏,也会占用内存。
  • 当你把 display: none 改成 display: block,这一步会触发 回流(Reflow)。因为渲染树里突然多了一大块节点出来,浏览器需要重新计算布局,这个代价很大。

四、 面试官必追的“对比三连击”(你顺便背下来)

面试官紧接着就会问:“那 visibility: hidden 呢?”

你的回答(直接封神):

display: none物理移除,它在渲染树中彻底消失,不占位,兄弟元素会挤上来占它的位置。

visibility: hidden光学隐身,它依然存在于渲染树中,宽高位置都还在(占着茅坑),只是不画像素,所以它下面的元素不会移动。而且它的子元素如果设为 visibility: visible,是可以显示出来的,但 display: none 的子元素永远显示不了。”


五、 终极“一句总结”(面试时作为收尾)

“面试官,所以我的理解是:display: none 元素在逻辑结构上(DOM 树)有,但在物理显示上(渲染树)无。

我们关注它,主要是关注两点:第一,它是 JS 可寻址的,我们可以操作它;第二,它是 布局计算零成本的,但 切换显示时成本极高(会引发大面积回流)。因此,在 WeLink 这种长列表场景,我会用 visibility: hidden 来占位隐藏占位图,而用 display: none 来彻底移除复杂的图表组件以释放内存。”

最近更新