[11ty] 处理资产文件的 CDN 缓存问题

/ dousha99

最近在尝试改善这套博客主题的可访问性,这意味着我会经常需要调整样式和脚本。每次调整都得登录 CDN 后台重置资源文件的缓存,实在太麻烦了。有没有更简单的方法?

你戳了戳 Bird

dousha99:

rt, send help

Bird:

很简单,你只要给资源文件带上查询参数就可以了,比如加个版本号啥的

Bird:

你的页面中只需要指定不同的版本号,自然就不需要手动刷新 CDN 缓存了

Bird:

我希望你没有自作聪明地去改 CDN 的缓存策略去忽略查询参数,不过最好检查一下有没有谁替你改了

dousha99:

那岂不是我每改动一下 CSS 就得改动一下 HTML 里的查询参数?

Bird:

不想动手的话就得多动脑

Bird:

这个参数可以是一个时间戳,也可以是一个版本号,甚至可以是一个随机数

Bird:

至于怎么选择,以及怎么让 11ty 替你干活,就看你的具体用例了

Bird:

someone's calling me. toodles

Bird 将状态设置为「会议中」

好吧,自己动手!

最符合直觉的设计参数自然是自增的版本号,比如 ?v=1. 当然,这么做就会落入到之前的需要手动修改的问题里。所以我们需要想办法自动地生成设计参数。

一个比较理想的方案是使用文件本身的摘要值,这样有改动的文件会生成一个新的参数;而之前没有改动的文件就会保留原有的参数。这样一来,我们既不会因为每次小改动都导致 CDN 需要重新拉取全部的资源文件;也不需要手动指定哪些文件的查询参数需要修改。

在 11ty 下,不同语言的模板之间可以通过 renderFile 指令混用。考虑到计算文件的摘要值会不可避免地用到 JavaScript, 我们就需要用到 11ty 的 JS 模板了。

新建一个文件 lib.11ty.cjs 作为生成对应 HTML 片段的模板。

从 NodeJS 中调取 crypto 来计算摘要值

const { createHash } = require('crypto')

function calculateHashValue(str) {
  const hash = createHash('sha256');
  hash.update(str);
  return hash.digest('hex');
}

为了 URL 简短一些,我们可以只取摘要值开头的 8 位数:

function calculateShortHash(str) {
  return calculateHashValue(str).substring(0, 8);
}

再从 NodeJS 中调取 fs 来读文件内容:

const { readFileSync, existsSync } = require('fs');

function calculateFileShortHash(path) {
  if (!path) {
    return ''
  }

  if (!existsSync(path)) {
    return ''
  }

  return calculateShortHash(readFileSync(path));
}

在我的 11ty 结构中,CSS 等资产文件是放在工程目录的 assets/ 文件夹内,并直接复制到产物目录中的。这使得我可以直接写 assets/css/style.css 来在 HTML 和 11ty 编译时指代同一份文件,不需要做额外的转换。所以生成 URI 的函数也十分好写:

function generateLibraryIncludeUri(path) {
  return `${path}?v=${calculateFileShortHash(path)}`
}

然后就是比较狂野的 JS/HTML 混成时间:

function importStyleSheet(path) {
  return `<link rel="stylesheet" href="/${generateLibraryIncludeUri(path)}">`
}

function importScript(path) {
  return `<script src="/$generateLibraryIncludeUri(path)}></script>`
}

11ty 的 JavaScript 模板通过 render 函数返回渲染结果,我们只需要返回组装好的 HTML 字符串即可:

const stylesheets = [ 'assets/css/style.css', 'assets/css/icon.css', /* ... */ ]
const scripts = [ 'assets/js/jquery.min.js', 'assets/js/script.js', /* ... */ ]

function render() {
  const styleString = stylesheets.map(importStyleSheet).join('')
  const scriptString = scripts.map(importScript).join('')
  
  return `${styleString}${scriptString}`
}

module.exports = render;
module.exports.default = render;
// ^ 别忘了导出渲染函数

在其他模板文件里即可使用,比如在 Liquid 模板下就可以按如下语法调用:

{% renderFile 'lib.11ty.js' %}

需要注意到:renderFile 的路径是相对于工程本身的,而不是相对于 _include 目录的。

条件导入

之前选择 11ty 的一个爽点就是可以通过文件头指定哪些库是必要的,尤其是比较沉的库(比如 Graphviz 或者 abc.js)就不需要每个页面都得加载。

但是 JavaScript 模板由于是通过 renderFile 调用的,不会自动收到文件头数据。我们需要将文件头整理一下再转发过去。

eleventy.config.js 中新建一个 Liquid 函数:

export default function (eleventyConfig) {
  eleventyConfig.addLiquidFilter('allData', function () {
    return this.context.getAll()
  })
}

然后修改模板中的调用方式:

{% assign renderData = page | allData %}
{% renderFile 'lib.11ty.js', renderData %}

再修改一下 lib.11ty.js 来接收这个数据:

function render(data) {
  // data 将包含文件头中的数据,比如 data.viz 对应文件头中 viz 的值
}

这样,我们就可以根据文件头的信息动态地引入资产文件了:

const conditionalScripts = [
  {
    predicate: (data) => !!data.viz,
    path: 'assets/js/viz-standalone.js',
  },
  {
    predicate: (data) => !!data.viz,
    path: 'assets/js/viz-invoker.js',
  },
]

function renderConditionalScript(data) {
  return conditionalScripts
    .filter((item) => item.predicate(data))
    .map(({ path }) => importScript(path))
    .join('')
}

不过……

Bird 将状态设置为「空闲」

Bird:

这样做其实会有一个问题

Bird:

也不能算是问题吧,只能说是一种必然

Bird:

因为你的内容页仍然缓存在 CDN 里,所以即使你修改了资产文件,它们也不会立刻应用到既有的页面上

Bird:

所以在必要的情况下(比如修了什么恶性 bug 之后),你还是需要手动清理全站缓存

Bird:

或者等页面缓存过期

dousha99:

那绕了这么一大圈,不还是白给

Bird:

可以考虑让 CI 去清理缓存

Bird:

不过就目前你的架构来看,单纯地在 CI 构建完成后清理缓存是不行的

Bird:

应当是你的源站服务器更新好容器之后,再从 CDN 上清理缓存

Bird:

话说你一个静态博客为啥要设计这么复杂的架构啊

dousha99:

因为我需要在不同的电脑上写东西?

dousha99:

所以肯定得用 git

dousha99:

所以自然就会通过 git 触发 CI 构建

dousha99:

Jenkins 太沉了所以就用了 Actions, 那么自然就会上 Docker 之类的各种东西

dousha99:

许多实现并不是设计指派的,而是因为需求变化而自然演进出来的

Bird:

这玩意能跑起来本身就是计算机魔法的奇迹

dousha99:

why, thank you!

Bird:

It's not a compliment.

Bird:

这中间主要是需要等一个异步的 Docker 镜像更新太麻烦

Bird:

如果你能同步地部署这个站点,那么就可以把缓存清理集成到 CI 里面

dousha99:

i'll try to figure that out

正在加载评论……

发表评论

您的评论将由管理员审核后方可公开显示。

Your comments will be submitted to a human moderator and will only be shown publicly after approval.

/