一次主页重构后的交互复盘:3D 背景、触控板和首屏性能

生命在于折腾·折腾 · 昨天 · 9 人浏览

这次主页重构看起来只是把首页做得更“酷”一点:3D 星轨背景、轨道式导航、玻璃态面板、PWA 缓存、音乐播放器、状态卡片。真正做完以后才发现,视觉只是表层,难点其实在几个很具体的工程问题上:滚动到底应该归谁处理,触控板的细碎 delta 怎么变成可控的导航,弹层打开后为什么还会带着底层页面一起动,以及一个 Three.js 背景该不该压进首屏包。

这篇记录不写效果展示,主要复盘几个踩坑点。


1. 3D 首页最容易犯的错:把滚动当成普通滚动

传统页面的滚动是连续的,用户滚多少,页面就滚多少。3D 场景式首页不一样。它的“滚动”往往不是文档流滚动,而是把输入转换成一个场景进度,比如:

sceneProgress = 0 // 首页
sceneProgress = 1 // 状态舱
sceneProgress = 2 // 第一个链接节点

这个模型的好处是清晰:摄像机、DOM 卡片、导航点都可以围绕同一个 progress 做插值。问题也很明显:如果把触控板每一次微小的 wheel delta 都直接加到 progress 上,页面会变得非常难控。

鼠标滚轮一次通常是明确的一步,而触控板是一串很密的细碎事件。把它们等价处理,就会出现两个问题:

  • 用户只是轻轻滑了一下,镜头却慢慢漂出去;
  • 用户想停在当前节点,惯性滚动又把页面推到下一屏。

所以这类首页更适合“阈值触发”,而不是“连续跟随”。

我的处理方式是把滚轮增量累积起来,超过阈值后只推进一屏,并加一个短暂锁定时间:

const WHEEL_THRESHOLD = 90
const STEP_LOCK_MS = 430

wheelDelta += e.deltaY

if (Math.abs(wheelDelta) >= WHEEL_THRESHOLD) {
  stepScene(wheelDelta > 0 ? 1 : -1)
  wheelDelta = 0
}

这看起来没有连续插值那么“顺滑”,但使用体验反而更稳定。首页导航的本质不是阅读长文,而是在几个节点之间移动。对节点式页面来说,“可预测”比“每个像素都响应”更重要。


2. 弹层必须拥有自己的滚动边界

这次最典型的 bug 是:首页三个快捷按钮点开档案弹层后,在弹层里下滑,触发的却是首页场景切换。

根因不是 CSS,而是事件归属。弹层里的 wheel / touchmove 没有被拦截,事件继续冒泡到了绑定在 window 上的场景滚动控制器。于是用户以为自己在滚弹层,实际是在推进首页镜头。

这种交互非常破坏信任,因为用户的手势目标和页面响应不一致。

修复思路很直接:弹层打开后,滚动事件应该止步于弹层;弹层内部可滚区域用自己的 overflow-y: auto,并通过 overscroll-behavior: contain 阻止滚动链外溢。

<div class="box-overlay" @wheel.stop @touchmove.stop>
  <div class="box" @wheel.stop @touchmove.stop>
    <div class="content">
      ...
    </div>
  </div>
</div>
.content {
  overflow-y: auto;
  overscroll-behavior: contain;
}

还有一个细节:场景滚动控制器本身也应该知道当前是否有全局浮层打开。即使某个浮层漏了 .stop,底层也应该能拒绝处理:

if (store.boxOpenState || store.setOpenState || store.backgroundShow) return

前端里很多“玄学滚动问题”,本质上都是事件所有权没有划清楚。


3. PWA 不是加上插件就完事

主页用了 PWA 缓存,构建阶段会生成 sw.jsregisterSW.js。这类功能在线上很好用,但也容易带来两个实际问题。

第一个是兼容性。不是所有访问环境都支持 Service Worker,也不是所有环境都允许注册 Service Worker。如果代码直接写:

navigator.serviceWorker.addEventListener('controllerchange', handler)

在不支持的环境里,入口脚本会直接抛错。对于单页应用来说,入口脚本抛错就可能是白屏。更稳的写法是:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.addEventListener('controllerchange', handler)
}

第二个是缓存更新。部署后用户看到旧资源,不一定是 nginx 没更新,也可能是旧 Service Worker 还在接管页面。线上排查时要同时看:

  • index.html 是否引用了新 hash 的资源;
  • 新 hash 的 JS/CSS 是否能 200;
  • 浏览器里的 Service Worker 是否已经更新;
  • 是否需要强刷或等待自动更新。

PWA 的问题不难,但它会把“部署成功”和“用户实际看到新版本”拆成两件事。


4. Three.js 不应该默认进首屏入口包

3D 背景是这次视觉效果的核心,但 Three.js 体积不小。如果它和 Vue 入口、Element Plus、播放器一起打进主包,首屏会被拖慢。

这类资源更适合异步加载。页面先加载基本结构,3D 背景组件再动态 import:

const AmbientField = defineAsyncComponent(() => import('@/components/AmbientField.vue'))

更进一步,组件内部的 Three.js 也可以动态加载:

let THREE

onMounted(async () => {
  THREE = await import('three')
  buildScene()
})

这样拆完以后,入口 JS 从接近 900KB 降到了 150KB 左右。Three.js 仍然是一个大包,但它变成了“需要 3D 背景时再加载”的独立异步资源,而不是所有用户进入页面都必须先下载。

这里的核心不是追求构建报告里的数字好看,而是让资源加载顺序符合用户实际路径:先可见、再精致;先可用、再动效。


5. 不要让视觉设计压过操作模型

这次主页的视觉目标是“轨道、星图、信号舱”,但交互目标其实很朴素:

  • 用户能稳定停在某个场景;
  • 用户点开弹层后,注意力留在弹层里;
  • 用户使用触控板时,不需要小心翼翼地控制手指;
  • 页面更新后,不会因为 SW 或缓存让人误判部署失败;
  • 首屏不因为 3D 效果变得迟钝。

一个页面“高级”不在于元素有多少,而在于输入和反馈之间是否可预期。尤其是个人主页这种入口型页面,用户不会花很多时间学习你的交互规则。第一次滚动不舒服,第一次点开弹层不受控,页面就已经输了。


6. 这次调整后的几个结论

这次重构后,我给自己留下几个比较实用的规则:

  1. 节点式场景不要直接消费触控板连续 delta,应该做阈值和步进。
  2. 弹层、抽屉、菜单这类覆盖层必须拥有滚动边界。
  3. 全局事件监听要能卸载,也要能根据应用状态拒绝处理。
  4. PWA 代码必须做能力判断,缓存更新要纳入部署验证。
  5. Three.js、播放器、设置面板这类重模块应尽量异步加载。
  6. 构建警告不能只靠提高阈值掩盖,先确认大包是否已经离开首屏路径。

最后还是那句话:交互不是动效,交互是控制感。

一个 3D 首页可以很炫,但如果滚动不听话,它就只是一个会动的麻烦。

博客 前端体验 性能优化 交互设计
Theme Jasmine by Kent Liao 京ICP备2023023335号-2京公网安备11010802044340号