我以前从没认真想过,后端可以直接把 .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 适配器,后面的很多限制都不会出现。