HTTP缓存:强缓存和协商缓存

85 阅读8分钟

什么是强缓存?

强缓存:直接拿走不用问

  • 场景:朋友说:"这本书你可以随便看 1 个月,期间不用问我"

  • 过程

    1. 浏览器:查本地缓存(书架)

    2. 发现资源在有效期内(书没到1个月)

    3. 直接使用缓存(看书),不联系服务器(不打扰朋友)

  • 特征

    • 速度快(不用沟通)

简单来说我向朋友借了本书,他规定了借阅时间,在此期间,如果我想要看这本书,就可以到书架(浏览器)上拿,不需要和朋友说。
浏览器第一次向服务端发送请求,拿到一张图片,服务端说明了可以让浏览器进行缓存(Cache storage),并规定了缓存时间,那么在这段时间内,浏览器不需要再次向服务端请求这张图片,而是直接取缓存取就好了。

如何实现?

以下是一个简单的 Node.js 静态文件服务器,监听 3000 端口。浏览器访问 http://localhost:3000 或带 URL(如 /index.html),服务端从 www 目录查找对应文件。如果请求的是目录,自动返回目录下的 index.html。文件存在时,返回 200 状态码、设置 MIME 类型和 1 天缓存,并通过流传输文件内容;文件不存在时,返回 404 错误页面。前后端未分离,前端内容由静态文件直接在浏览器渲染。

// 引入 Node.js 内置的 http 模块,用于创建 HTTP 服务器
const http = require('http')
// 引入 Node.js 内置的 path 模块,用于处理文件路径
const path = require('path')
// 引入 Node.js 内置的 fs 模块,用于文件操作(如读取、检查文件是否存在)
const fs = require('fs')
// 引入 mime 第三方模块,用于根据文件扩展名获取对应的 Content-Type
const mime = require('mime')

// 创建 HTTP 服务器,定义请求处理函数,req 是请求对象,res 是响应对象
const server = http.createServer((req, res) => {
    // 根据请求的 URL,生成服务器上文件的绝对路径,www 是静态文件目录
    let filePath = path.resolve(__dirname, path.join('www', req.url))
    // 同步检查请求的文件或目录是否存在
    if (fs.existsSync(filePath)) {
        // 获取文件的详细信息(如大小、修改时间等)
        const stat = fs.statSync(filePath)
        // 如果请求的是目录
        if (stat.isDirectory()) {
            // 将路径更新为目录下的 index.html 文件
            filePath = path.resolve(filePath, 'index.html')
        }
        // 再次检查文件(index.html 或原始文件)是否存在
        if (fs.existsSync(filePath)) {
            // 解析文件路径,获取文件扩展名(如 .html、.jpg)
            const { ext } = path.parse(filePath)
            res.writeHead(200, {
                'Content-Type': mime.getType(ext),
            })
            const fileStream = fs.createReadStream(filePath)//不是一次性读取,而是流式读取,每次读取一部分,然后返回给前端
            fileStream.pipe(res)
        }else {
            // 如果 index.html 不存在,返回 404 状态码
            res.writeHead(404, { 'Content-Type': 'text/html;charset=utf-8' })
            // 发送简单的 404 错误页面
            res.end('<h1>404 not found</h1>')
            }
    } else {
        // 如果文件或目录不存在,返回 404 状态码
        res.writeHead(404, { 'Content-Type': 'text/html;charset=utf-8' })
        // 发送简单的 404 错误页面
        res.end('<h1>404 not found</h1>')
    }
})

// 启动服务器,监听 3000 端口
server.listen(3000, () => {
    // 服务器启动后打印提示信息
    console.log('server is running at http://localhost:3000')
})

如果要实现强缓存,则直接在请求头中加入'Cache-Control': 'max-age=86400'即可,'max-age=86400表示缓存的时间,以秒为单位。

res.writeHead(200, {
    'Content-Type': mime.getType(ext),
    'Cache-Control': 'max-age=86400',//缓存时间,单位是秒
    //缓存到浏览器的Application中的 Cache Storage
})

强缓存虽然简单,减少了服务器请求,但缺点是可能返回过时资源、缺乏灵活性、带宽效率较低和缓存控制困难。协商缓存通过验证确保资源新鲜度,适合动态或频繁更新的内容。

什么是协商缓存?

协商缓存:先确认再使用

  • 场景:朋友说:"借书前先问问我,如果 书没变 你就继续看旧版"

  • 过程

  1. 浏览器:携带"书籍信息"(书签)问服务器

  2. 服务器:检查资源是否变化

  3. 结果:

    • 没变化 → 返回 304 Not Modified(继续看旧书)

    • 有变化 → 返回 200 + 新资源(给新书)

  • 特征
  1. 必须通信(总要问一句)

  2. 更精准(保证内容最新)

简单来说,就是浏览器每次要资源时都会请求服务器,而服务器会判断浏览器请求的文件资源是否修改,如果修改了就返回新的资源给浏览器,如果没有修改则叫浏览器去缓存中取(非强制)。
协商缓存有两种实现方式:

  1. 使用Last-Modified和If-Modified-Since
  2. 使用ETag和If-None-Match

Last-Modified和If-Modified-Since

Last-Modified表示文件最后一次的修改时间
实现思路:服务器在响应头上添加last-modified属性,值为文件最后一次更新的时间,浏览器拿到后,下一次请求则会在请求头上加入if-modified-since属性,值为上次请求时文件最后一次修改的时间,服务器拿到后则将其与文件再次最后修改的时间进行比较,如果不相同,则返回新的资源以及新的Last-Modified给浏览器,如果相同则返回304状态码,告诉浏览器自己的缓存找
以下为部分代码

if (fs.existsSync(filePath)) {//再次判断访问的文件是否存在
        const { ext } = path.parse(filePath)//获取文件的后缀名
        const stat=fs.statSync(filePath)

        //使用Last-Modified和If-Modified-Since
        const timeStamp = req.headers['if-modified-since']
        //获取前端请求头中的'if-modified-since'字段
        let status = 200
        if (timeStamp && Number(timeStamp) === stat.mtimeMs) {
            //如果前端请求头中的'if-modified-since'字段与文件的最后修改时间相同,
            // 说明文件未被修改,直接返回304状态码,告诉前端资源未被修改,浏览器会直接从缓存中获取资源
            status = 304//迫使浏览器从缓存中获取资源
        }
        res.writeHead(status, {
            'Content-Type': mime.getType(ext),
            'Cache-Control': 'max-age=86400',//缓存时间,单位是秒
            //缓存到浏览器的Application中的 Cache Storage
            'last-modified':stat.mtimeMs,//文件最后修改时间,告诉前端图片更新了,
            // 前端再次请求时,会在请求头中添加'if-modified-since':stat.mtimeMs,
        })
        if (status === 200) {
            //一次性将文件全部读完
            const fileStream = fs.createReadStream(filePath)//不是一次性读取,而是流式读取,每次读取一部分,然后返回给前端
            fileStream.pipe(res)
        } else {//如果状态码是304,说明文件未被修改,什么都不返回
            res.end()
        }
   }else {
    res.writeHead(404, { 'Content-Type': 'text/html;charset=utf-8' })
    res.end('<h1>404 not found</h1>')
}

但是这种方式会出现一个问题,如果该文件第一次被修改后,感觉不适合,第二次又被修改回来了,那这样浏览器服务器还是会认为该文件修改了,所以给浏览器返回新的资源,但其实该文件并没有修改,这岂不是多此一举。那么我们就考虑用第二种方式:ETag和If-None-Match。

ETag和If-None-Match

实现思路:借助node.js的第三方模块checksum,生成每次更新文件的校验和(通常是哈希值),作为文件指纹(ETag),服务器将其添加到响应头,每次浏览器请求资源时,请求头中带有If-None-Match属性,值为上一次生成的文件指纹,服务器那其与这次生成的文件指纹(ETag)进行比较,如果不相同则返回新的资源以及新的ETag给浏览器,如果相同则返回304状态码,告诉浏览器取缓存中取。
以下为部分代码

// 再次检查文件(index.html 或原始文件)是否存在
if (fs.existsSync(filePath)) {
    // 解析文件路径,获取文件扩展名(如 .html、.jpg)
    const { ext } = path.parse(filePath)
    // 再次获取文件详细信息,确保使用最新的文件元数据
    const stat = fs.statSync(filePath)

    // 计算文件的校验和(ETag),异步操作
    checksum.file(filePath, (err, sum) => {
        // 创建文件的读取流,用于流式传输文件内容
        const resStream = fs.createReadStream(filePath)
        // 将校验和包装为带引号的字符串,符合 ETag 格式(如 "abc123")
        sum = `"${sum}"`
        // 检查客户端请求头中的 If-None-Match 是否与文件的 ETag 匹配
        if (req.headers['if-none-match'] === sum) {
            // 如果匹配,说明文件未修改,返回 304 状态码
            res.writeHead(304, {
                // 设置响应内容的 MIME 类型,如 text/html
                'Content-Type': mime.getType(ext),
                // 设置缓存有效期为 86400 秒(1 天)
                'Cache-Control': 'max-age=86400',
                // 返回文件的 ETag 值
                'etag': sum
            })
            // 结束响应,不发送文件内容,浏览器将使用缓存
            res.end()
        } else {
            // 如果不匹配,说明文件已修改或首次请求,返回 200 状态码
            res.writeHead(200, {
                // 设置响应内容的 MIME 类型
                'Content-Type': mime.getType(ext),
                // 设置缓存有效期为 86400 秒(1 天)
                'Cache-Control': 'max-age=86400',
                // 返回文件的 ETag 值
                'etag': sum
            })
            // 将文件内容通过流传输到客户端
            resStream.pipe(res)
        }
    })
}
} else {
// 如果文件或目录不存在,返回 404 状态码
res.writeHead(404, { 'Content-Type': 'text/html;charset=utf-8' })
// 发送简单的 404 错误页面
res.end('<h1>404 not found</h1>')
}

小结:

特性强缓存协商缓存
优点- 减少请求,资源获取快(max-age=86400 1 天内无请求) - 实现简单,代码量少- 确保资源新鲜(验证 ETagLast-Modified) - 节省带宽(304 响应) - 灵活性高
缺点- 可能返回过时资源(如 index.html 更新后仍用缓存) - 灵活性差(统一 1 天缓存)- 增加请求开销(每次验证) - ETag 计算开销大(checksum.file) - 实现复杂
代码实现Cache-Control: max-age=86400ETagchecksum.file)或 Last-Modifiedstat.mtimeMs
适用场景适合不频繁更新的资源(如图片、CSS)适合频繁更新的资源(如 index.html