移动端目录和本地热更新

移动端目录和本地热更新

记录一次被 IELTS 长文逼出来的小改造:移动端目录抽屉,以及模板和 Markdown 的本地热更新。

今天这次改动,是被那篇 IELTS Task 1 模板整理逼出来的。

文章太长了。桌面端还好,右侧有 sticky 目录,想跳哪一节都很快;一到手机上,目录直接隐藏,只能靠手指一路滚。写学习笔记时还不明显,真正想查某个模板句的时候就很烦。

所以先补了一个移动端目录。

目录抽屉

一开始想过底部弹出,类似 action sheet。试了一下不太合适:目录太长,底部弹窗会把正文挡得很满,而且感觉更像“临时操作”,不像文章结构导航。

后来改成左侧滑入,像一个阅读侧栏,这个方向舒服很多。

入口放在左下角。右下角已经有“回到顶部”和“返回”,再塞一个按钮会很挤。左下角刚好空着,也比较像辅助工具入口。

关闭按钮也纠结了一会儿。

最早放在右上角,手指从左下角点开后又要去上面关,不顺手。后来放左下角,用叉号,但看起来像在关弹窗,也有点丑。最后放到目录面板右下角,用一个向左箭头,意思就是“收起侧栏”。这个最贴近现在的交互。

模板上没有复用桌面目录,而是单独渲染一份移动端目录:

<aside class="post-toc">...</aside>

<button class="mobile-toc-toggle" type="button">...</button>
<div class="mobile-toc" aria-hidden="true">
	<button class="mobile-toc-backdrop" type="button"></button>
	<nav class="mobile-toc-panel" aria-label="文章目录">
		<ul>...</ul>
		<button class="mobile-toc-close" type="button">...</button>
	</nav>
</div>

这样桌面 sticky 目录和移动端抽屉互不影响。JS 里再把 active 状态同步一下就行。

动画这点小事

第一版用 hidden 控制打开关闭,能用,但关闭时不够顺。问题是元素直接从显示树里拿掉了,动画很容易被截断。

后面改成常驻 overlay,默认不响应事件,只在打开时加 .is-open

.mobile-toc {
	position: fixed;
	inset: 0;
	pointer-events: none;
}

.mobile-toc.is-open {
	pointer-events: auto;
}

.mobile-toc-panel {
	transform: translate3d(-104%, 0, 0);
	transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}

.mobile-toc.is-open .mobile-toc-panel {
	transform: translate3d(0, 0, 0);
}

这版顺很多。打开和关闭都只是 transform,浏览器处理起来也稳定。

顺手修掉正文消失

调这篇长文时还发现一个老问题:文章详情页的 <article> 整体带了 .reveal

短文看不出来。长文滚到中段时,IntersectionObserver 可能一直不认为整篇 article “进入视口”,于是它就保持:

opacity: 0;

最后页面就变成:右侧目录正常,左侧正文没了。

这个其实不应该发生。正文是核心内容,不能依赖动画来决定是否显示。于是把文章详情页 article 上的 .reveal 去掉了。卡片、列表这类装饰性入场动画可以保留,正文不参与。

然后又改了本地热更新

移动端目录来回调了几轮,每次改 post.html 都要停服务、重新 go run。调 UI 的时候这件事很打断。

原因很明确:模板和文章都是 embed.FS

//go:embed templates/*.html templates/pages/*.html templates/partials/*.html
var templateFS embed.FS

//go:embed posts
var postsFS embed.FS

线上这样很好,Lambda 包也干净。但本地开发时,运行中的进程拿到的是编译时快照,文件改了它也不知道。

这里没有必要上 watcher,也不想引入额外工具。我的需求只是:改模板、改 Markdown,刷新页面能看到。

所以做了一个很小的分支:

  • ENV=local 时,用 os.DirFS 从磁盘读。
  • 其他环境继续用 embed.FS
  • 模板渲染时重新 parse。
  • 内容查询时重新 load Markdown。

启动处按环境切:

if cfg.Env == "local" {
	renderer, err = views.NewDev("internal/views")
} else {
	renderer, err = views.New()
}

if cfg.Env == "local" {
	contentStore, err = content.NewDev("internal/content")
} else {
	contentStore, err = content.New()
}

模板层长这样:

func New() (*Renderer, error) {
	return newFromFS(templateFS, false)
}

func NewDev(root string) (*Renderer, error) {
	return newFromFS(os.DirFS(root), true)
}

文章层同理:

func New() (*Store, error) {
	return newFromFS(postsFS, false)
}

func NewDev(root string) (*Store, error) {
	return newFromFS(os.DirFS(root), true)
}

中间踩了一个小坑:热更新时临时创建 fresh renderer,不能继续带 reload=true。否则 Page() 会一层层递归创建 fresh renderer,页面请求直接卡住。

验证

验证方式很土,但有效。

先不重启服务,临时改 post.html,加一个测试标记,页面立刻能看到。

再不重启服务,临时改 IELTS 文章标题,页面标题也立刻变化。

然后把临时标记都撤掉,跑测试:

go test ./...

通过。

现在本地开发的边界比较清楚:

  • 模板、Markdown、CSS、JS:刷新页面生效。
  • Go 代码、路由、配置结构:还是要重启。

对这个小站来说够用了。写文章和调页面时少一次重启,就是少一次打断。