一次绕路:用 Hono 静态文件更新小程序里的 Markdown

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

我以前从没认真想过,后端可以直接把 .md 当静态文件发给小程序。

项目里有几份协议,最初放在 Taro client 源码中,通过 ?raw 导入:

import agreement from "@/assets/agreement.md?raw";

这样写很省事,构建时 Markdown 会变成字符串,组件拿到后直接渲染。麻烦也很明确:协议改一个字,都要重新构建、提交审核并发布小程序。文档内容和客户端版本绑在了一起。

我想解决这个限制,却先把事情绕远了。

我一开始想得太多

第一个方案是把 Markdown 放进对象存储。接着自然冒出了更多问题:固定地址会不会被缓存,是否要增加 manifest,协议要不要做版本记录,对象存储域名是否已经加入小程序请求白名单,失败时该读缓存还是内置副本。

这些问题不是假的,只是当时还没有这些需求。协议由开发者维护,更新时可以正常部署服务端,文件也很小。我为了一个“改文案不用发 client”的目标,提前设计了一套文档发布系统。

后来我把文件移到 server,又做了两个 JSON 接口:

{
  "content": "Markdown 正文"
}

client 为此增加了动态路径请求、JSON 解包和额外类型。方案能运行,但还是别扭。Markdown 本来就是文本文件,我却先把它塞进 JSON,再从 JSON 中取出来。

真正简单的做法是让 Hono 直接提供静态文件。

先把真实需求重新说一遍

这个功能实际只需要满足几件事:

  • 小程序启动时取得当前协议的请求路径。
  • 用户打开协议弹窗时再下载 Markdown。
  • 修改协议只部署 server,不重新发布 client。
  • 新服务端继续保留旧 client 使用的配置接口。

不需要在线编辑,不需要独立的文档版本系统,也没有让非开发人员上传协议的需求。需求说清楚后,对象存储和 JSON 正文接口都可以删掉。

最后的请求过程很短:

小程序启动
  -> GET /config
  -> 保存协议 Markdown 路径

用户打开协议
  -> GET /config/documents/agreement.md
  -> 按文本渲染

用 Hono/Bun 直接返回 Markdown

Hono 的 Bun 适配器提供了 serveStatic。假设文件放在下面的位置:

server/
  public/
    documents/
      feature-guide.md
      user-terms.md

路由可以直接映射到 public

import { serveStatic } from "hono/bun";

app.use(
  "/config/documents/*",
  serveStatic({
    root: "./public",
    mimes: { md: "text/markdown" },
    rewriteRequestPath: (path) => path.replace(/^\/config/, ""),
  }),
);

rewriteRequestPath 把请求路径 /config/documents/feature-guide.md 转成 /documents/feature-guide.md,最终读取 public/documents/feature-guide.md

这里有个小坑。在我使用的 Hono 4.12.9 中,.md 没有命中默认 MIME 映射,第一次测试返回的是:

Content-Type: application/octet-stream

增加 mimes 后,响应变成:

Content-Type: text/markdown; charset=utf-8

文件内容没有变化,但明确的文本类型更符合这个接口的用途。

config 只告诉 client 去哪里取文件

启动配置不需要携带 Markdown 正文,只返回路径:

{
  "version": "1.0.0",
  "documents": {
    "featureGuide": "/config/documents/feature-guide.md",
    "userTerms": "/config/documents/user-terms.md"
  }
}

原来首页已经会在进入小程序后请求配置。如果再增加一个只包含协议路径的启动接口,首次进入就会产生两次配置请求。因此我把首页配置和协议路径合并到 /config,client 启动和首页共用同一个请求 Promise:

let configRequest: Promise<AppConfig> | null = null;

export function loadAppConfig() {
  if (!configRequest) {
    configRequest = requestAppConfig();
  }
  return configRequest;
}

应用启动时调用一次,首页渲染时再调用也只会拿到同一个 Promise。旧 client 仍然访问 /config/home,所以服务端保留旧接口,新 client 不再使用它。

这类兼容应该放在服务端。发布顺序也很直接:先部署新 server,再发布新 client。

Taro 打开弹窗时请求文本

协议没有必要在启动时一并下载。启动配置只保存路径,弹窗打开后再请求:

const response = await Taro.request<string>({
  url: agreementUrl,
  method: "GET",
  dataType: "text",
  responseType: "text",
});

const markdown = response.data;

同一次小程序运行中可以缓存这个请求,避免重复打开弹窗时再次下载。加载失败就显示重试状态,在正文没有加载完成前不允许确认协议。这些都是界面状态,不需要再引入一套持久化文档缓存。

Bun 构建不会替你复制静态文件

serveStatic 读取运行时文件系统。bun build 成功,只能说明服务端代码打包成功,不代表 public 已经进入部署产物。

如果容器原来只复制 dist

COPY ./dist /app/dist

还要把静态目录复制进去:

COPY ./dist /app/dist
COPY ./public /app/public

否则本地开发环境能访问 Markdown,容器里却会得到 404。这个错误和 Hono 路由无关,只是目标文件根本不在镜像中。

怎么验证

先检查 config 是否返回了文件路径:

curl.exe http://127.0.0.1:3000/config

再直接检查 Markdown 响应:

curl.exe -I http://127.0.0.1:3000/config/documents/feature-guide.md

预期结果至少包括:

HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8

最后还要查看微信开发者工具的 Network 面板,确认启动时只有一次 config 请求,协议请求是在弹窗打开后才出现。类型检查和服务端构建都不能替代这一步。

这次绕路留下的经验

我以前会把“运行时可更新”直接联想到对象存储、版本和缓存。现在看来,这几个词并不属于同一个需求。

如果文件需要后台上传、独立发布、跨服务共享或由 CDN 承担流量,对象存储很合适。这里只是希望 Markdown 不再跟着小程序发版,服务端静态目录已经够用。

静态文件不是临时方案。文件本来就是 Markdown,HTTP 直接返回 Markdown,反而比 JSON 包装更准确。真正应该先问的是:现有框架能不能直接提供这种资源?这次如果一开始查一下 Hono 的 Bun 适配器,后面的很多限制都不会出现。

参考资料