React/Next.js关闭Headless UI Dialog后页面滚动冻结排查
问题场景
在React/Next.js项目中,用户点击图片时会弹出模态框/对话框展示图片对应详情,但关闭对话框后页面滚动功能冻结:页面元素可正常交互,但无法向下滚动访问其他页面内容。初步判断异常由弹窗组件未正确重置页面滚动条状态导致。
关联代码
对话框组件实现
import React, { Fragment, useEffect } from 'react' import { Dialog, Transition } from '@headlessui/react' import { XIcon } from '@heroicons/react/solid' import { useRecoilState } from 'recoil' import { openState } from '../../../atoms/modalAtom' const product = { name: "Women's Basic Tee", price: '$32', rating: 3.9, reviewCount: 512, href: '#', imageSrc: 'https://tailwindui.com/img/ecommerce-images/product-page-01-featured-product-shot.jpg', imageAlt: "Back of women's Basic Tee in black.", colors: [ { name: 'Black', bgColor: 'bg-gray-900', selectedColor: 'ring-gray-900' }, { name: 'Heather Grey', bgColor: 'bg-gray-400', selectedColor: 'ring-gray-400' }, ], sizes: [ { name: 'XXS', inStock: true }, { name: 'XS', inStock: true }, { name: 'S', inStock: true }, { name: 'M', inStock: true }, { name: 'L', inStock: true }, { name: 'XL', inStock: true }, { name: 'XXL', inStock: false }, ], } export function DesignQuickView ({design}) { const [open, setOpen] = useRecoilState(openState) // 兜底重置滚动样式逻辑可加在这里 // useEffect(() => { // if (!open) { // document.body.style.removeProperty('overflow') // document.body.style.removeProperty('padding-right') // } // }, [open]) return ( <Transition.Root show={open} as={Fragment}> <Dialog as="div" className="relative z-10" onClose={() => setOpen(false)}> <Transition.Child as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100" leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0" > <div className="hidden fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity md:block" /> </Transition.Child> <div className="fixed z-10 inset-0 overflow-y-auto"> <div className="flex items-stretch md:items-center justify-center min-h-full text-center md:px-2 lg:px-4"> {/* 用于辅助模态框居中的占位元素 */} <span className="hidden md:inline-block md:align-middle md:h-screen" aria-hidden="true"> ​ </span> <Transition.Child as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0 translate-y-4 md:translate-y-0 md:scale-95" enterTo="opacity-100 translate-y-0 md:scale-100" leave="ease-in duration-200" leaveFrom="opacity-100 translate-y-0 md:scale-100" leaveTo="opacity-0 translate-y-4 md:translate-y-0 md:scale-95" > <Dialog.Panel className="flex text-base text-left transform transition w-full md:max-w-2xl md:px-4 md:my-8 lg:max-w-4xl"> <div className="w-full relative flex items-center bg-white px-4 pt-14 pb-8 overflow-hidden shadow-2xl sm:px-6 sm:pt-8 md:p-6 lg:p-8"> <button type="button" className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 sm:top-8 sm:right-6 md:top-6 md:right-6 lg:top-8 lg:right-8" onClick={() => setOpen(false)} > <span className="sr-only">Close</span> <XIcon className="h-6 w-6" aria-hidden="true" /> </button> <div className="w-full grid grid-cols-1 gap-y-8 gap-x-6 items-start sm:grid-cols-12 lg:items-center lg:gap-x-8"> <div className="aspect-w-2 aspect-h-3 rounded-lg bg-gray-100 overflow-hidden sm:col-span-4 lg:col-span-5"> <img src={design.url} alt={design.name} className="object-center object-cover" /> </div> <div className="sm:col-span-8 lg:col-span-7"> <h2 className="text-xl font-medium text-gray-900 sm:pr-12">Hoodie</h2> <section aria-labelledby="information-heading" className="mt-1"> <h3 id="information-heading" className="sr-only"> Product information </h3> <p className="font-medium text-gray-900">{product.price}</p> </section> <section aria-labelledby="options-heading" className="mt-8"> <h3 id="options-heading" className="sr-only"> Product options </h3> <form> {/* 颜色选择器 */} <div> <h4 className="text-sm font-medium text-gray-900">Color</h4> </div> {/* 尺码选择器 */} <div className="mt-8"> <div className="flex items-center justify-between"> <h4 className="text-sm font-medium text-gray-900">Size</h4> <a href="#" className="text-sm font-medium text-indigo-600 hover:text-indigo-500"> Size guide </a> </div> </div> </form> </section> </div> </div> </div> </Dialog.Panel> </Transition.Child> </div> </div> </Dialog> </Transition.Root> ) } export default DesignQuickView
模态框调用代码
const Design = ({design}) => { const [open, setOpen] = useRecoilState(openState) return( // 图片卡片容器 <div> <div> <img className="inline-block h-14 w-14 rounded-md" src={design.url} alt="Custom design" onClick={()=>setOpen(true)} /> </div> <DesignQuickView design={design}/> </div> ) } export default Design
排查方向
- 检查body标签残留样式:打开浏览器开发者工具选中
<body>标签,关闭弹窗后查看是否残留overflow: hidden、padding-right这类弹窗注入的锁滚动样式。Headless UI的Dialog组件默认会在打开时给body加这类样式禁止背景滚动,关闭时自动移除,移除失败就会导致滚动冻结。 - 检查全局状态一致性:确认关闭弹窗时
setOpen(false)是否正常执行,有没有其他组件并行修改openState全局状态,导致Dialog组件未触发卸载逻辑,滚动锁没有被清理。 - 检查多实例冲突:当前写法会在每一个Design卡片中都渲染一个DesignQuickView实例,如果页面存在多个Design项,就会有多个Dialog组件同时监听同一个open状态、同时操作body的滚动样式,很容易出现状态竞争导致清理逻辑失效。
- 检查动画卸载配置:确认Transition组件没有配置强制常驻属性(如
unmount={false}),避免弹窗关闭后DOM节点未卸载,滚动锁一直生效。
修复方案
- 解决多实例冲突:将DesignQuickView组件移到页面根层级(如全局布局组件、_app.js中),保证全局只渲染一个Dialog实例。点击卡片时只需要更新全局open状态、传入当前选中的design数据即可,从根源避免多实例操作滚动样式的冲突。
- 增加样式兜底重置:在Dialog组件中监听open状态变化,当弹窗关闭时手动清除body上的锁滚动样式,参考代码已经注释在组件逻辑中。
- 校验状态更新逻辑:如果存在路由切换、ESC键关闭、点击遮罩关闭等多种关闭弹窗的场景,要确认所有场景都正确将
openState置为false,避免状态不一致。
内容的提问来源于stack exchange,提问作者rohit
相关产品推荐
相关产品推荐

