全站动效记录 04:一个 fixed 弹层为什么会跑偏
摄影和书法详情弹层不是单纯尺寸问题,而是被页面入场动画容器改变了定位参照。最后用 portal 把弹层移出页面动画树。
这次的问题看起来像弹窗尺寸没调好:摄影详情不居中,书法详情直接像卡住了。
但真正的原因不是弹窗宽高,也不是图片加载。问题出在层级。
是哪次更新引入的
看 git 历史,直接触发点是这条链路:
| 提交 | 时间 | 作用 |
|---|---|---|
3e0d7aa | 2026-04-13 | 新增书法页和 CalligraphyModal,弹层放在 CalligraphyGallery 内部 |
4b3ea35 | 2026-05-04 | 新增摄影页和 PhotoModal,弹层放在 PhotoGallery 内部 |
5a4b8fa | 2026-05-25 | 去掉摄影、书法列表内部逐项入场,减少路由切换卡顿 |
0d19818 | 2026-05-25 | 新增 PageEnter,把摄影和书法页面内容包进本地入场动画 |
a44cb5f | 2026-05-26 | 修了弹层尺寸、滚动和书法数据空值,但没有碰到根因 |
4d50f86 | 2026-05-26 | 把两个详情弹层改成 body portal,定位问题消失 |
所以 bug 不是一开始就有的。
一开始弹层虽然写在 Gallery 组件里面,但外层没有会改变 fixed 定位的动画容器。它还能按视口居中。
真正的问题出现在 0d19818。这次为了让博客、摄影、书法、关于页面不要闪现,新增了 PageEnterItem:
hidden: { opacity: 0, y: 18, filter: "blur(4px)" }
visible: { opacity: 1, y: 0, filter: "blur(0px)" }然后摄影页变成这样:
<PageEnterItem>
<PhotoGallery photos={photos} />
</PageEnterItem>书法页也是同样结构:
<PageEnterItem>
<CalligraphyGallery items={items} />
</PageEnterItem>这时弹层仍然在 Gallery 组件内部。
为什么 fixed 没有固定到视口
以前我默认以为:
<div className="fixed inset-0" />就一定是相对浏览器视口。
实际不是。浏览器里有些属性会让元素形成新的定位参照。这里踩到的是 transform 和 filter。
Framer Motion 的 y: 18 会生成 transform。filter: "blur(4px)" 又进一步让这个容器变成一个特殊层。它的子孙元素里即使写了 position: fixed,也可能不再按整个视口定位,而是被这个祖先容器限制住。
所以摄影弹层看起来跑到了页面内容区域附近。
书法页更像“卡死”,是因为遮罩出现了,body 滚动也被锁住了,但真正的详情框没有按视口位置出来。用户看到的是页面被盖住,却没有可操作的弹窗。
原来的方案
原来的结构是:
function PhotoGallery() {
const [selectedPhoto, setSelectedPhoto] = useState(null);
return (
<>
<PhotoWall onSelect={setSelectedPhoto} />
<PhotoModal photo={selectedPhoto} />
</>
);
}书法也是同样思路:
function CalligraphyGallery() {
const [selectedItem, setSelectedItem] = useState(null);
return (
<>
<CalligraphyGrid onSelect={setSelectedItem} />
<CalligraphyModal item={selectedItem} />
</>
);
}这个方案的优点是状态很近。点击哪张图,Gallery 自己保存当前选中项,弹层也在同一个组件里渲染。
缺点是它把“状态归属”和“DOM 层级”绑在一起了。只要 Gallery 外面被加上动画、滤镜、裁切或滚动容器,弹层就会跟着被困进去。
现在的方案
现在保留状态位置,但把弹层的 DOM 移出去:
const portalRoot = typeof document === "undefined" ? null : document.body;
const modalContent = (
<AnimatePresence>{photo && <DetailModal />}</AnimatePresence>
);
if (!portalRoot) return null;
return createPortal(modalContent, portalRoot);这就是 React Portal。
它不会改变 React 的状态关系。PhotoGallery 仍然可以控制 selectedPhoto,CalligraphyGallery 仍然可以控制 selectedItem。
但弹层真正生成的 DOM 不再放在 Gallery 下面,而是直接挂到 document.body。这样 fixed inset-0 又回到它应该服务的对象:整个浏览器视口。
为什么第一次修没有修对
a44cb5f 那次修的是可见问题:
- 摄影详情框高度太大,改成了可滚动容器。
- 操作按钮容易出界,固定在详情栏底部。
- 书法详情读取
item?.materials.label这类嵌套字段时可能遇到空值,改成先判断item。 - 图片没有真实
src时不再挂载Next Image。
这些都是对的,但它们只修了弹层内部。
当时没有意识到 fixed 层本身已经不是相对视口了。所以摄影仍然不居中,书法仍然像卡住。
后面加的回归测试把边界补上了:
assert.match(modal, /import \{ createPortal \} from "react-dom";/);
assert.match(modal, /document\.body/);
assert.match(modal, /return createPortal/);这类测试不验证动画好不好看,只验证弹层不能再回到页面动画树里。
以后怎么避免
有几类 UI 不适合直接放在会动的页面容器里:
- modal
- lightbox
- toast
- dropdown
- tooltip
只要它需要按视口定位,或者需要盖住整个页面,就应该优先考虑 portal。
页面入场动画可以继续用 PageEnter。它只负责内容出现。弹层是另一层交互,不应该被页面动画包住。
小结
这次 bug 的本质是边界混了。
原来的方案把弹层当成 Gallery 的一部分。这个结构在普通页面里没问题,但一旦外层加了 transform/filter 动效,fixed 就不再可靠。
现在的方案把状态留在 Gallery,把弹层 DOM 放到 body。状态归状态,层级归层级。
这也是这次最大的教训:动效不是只影响视觉,它也会改变浏览器的布局和定位规则。

