移动端目录和本地热更新
记录一次被 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 代码、路由、配置结构:还是要重启。
对这个小站来说够用了。写文章和调页面时少一次重启,就是少一次打断。