Position: fixed。避开溢出问题
position还有另一个值可以使元素逃脱普通文档流:fixed。它和absolute类似,它的定位不是相对于最近的一个有position属性的元素,而是相对于整个视窗。对于对话框这样要相对于整个视窗来居中的场景,使用fixed是最好的。
同理,因为它是相对于整个视窗来定位的,这个定位方法也允许我们逃出溢出陷阱。所以,理论上,这个属性还可以用于提示框。
然而,即使是position: fixed,也无法逃脱堆叠上下文(Stacking Context)。谁也无法逃脱。它就像一个黑洞:一旦形成,其引力范围内的一切都消失了。谁也逃不出来。
如果我把灰div的z-index设置为1,红divz-index设置为2.
代码示例: advanced-react.com/examples/13…
position: fixed的另一个问题是,这个定位也不是永远相对于视窗的。实际上,它是相对于所谓的包含块(Containing Block)进行定位的。这个是经常发生的。
堆叠上下文(Stacking Context)在真实的应用中
好吧,这些讨论虽然有趣,但是太理论了。在真实的应用中,堆叠上下文(Stacking Context)会影响应用吗?当然,而且这是很常见的。
最有可能受影响的是各类动画元素,或者像header、侧边栏这类 “粘性” 区块。在这些场景下,我们很可能不得不设置 position 和 z-index ,或者使用 translate 。而这些操作都会创建一个新的堆叠上下文。
随便打开几个你喜欢的、带有 “粘性” 元素或动画效果的热门网站,打开 Chrome 开发者工具,在 DOM 树中找一个较深层级的区块,将其定位设置为 fixed 并赋予一个较高的 z-index 值,然后稍微移动一下它的位置。为了好玩,我查看了 Facebook、爱彼迎(Airbnb)、Gmail、OpenAI 和领英(LinkedIn)这几个网站。其中有三个网站的主区域就像个 “陷阱”:在这个区域内,任何设置了 position: fixed 且 z-index: 9999 的区块都会显示在粘性头部的下方。
只有一个方法可以逃离这个陷阱:确保这个对话框并没有渲染在生成了这个 *堆叠上下文(Stacking Context)*的DOM元素内。在没有React时,我们会把对话框插入到body标签或者应用的根div:
const modalDialog = ... // get the dialog where the buttion is clicked
document.getElementByClassName('body')[0].applendChild(modalDialog);
在React中,我们只要使用Portal,就可以逃离 堆叠上下文(Stacking Context)了!
React Portal 如何解决 这个 问题
让我们用另一个例子来复现这个问题。
让我们实现一个简单的应用:实现一个有position: sticky的header,一个在左边的可以折叠的导航栏,和一个对话框。
const App = () => {
const [isVisible, setIsVisible] = useState(false);
return (
<>
<div className="header"></div>
<div className="layout">
<div className="sidebar">// some links here</div>
<div className="main">
<button onClick={() => setIsVisible(true)}>
show more
</button>
{isVisible && <ModalDialog />}
</div>
</div>
</>
);
};
我们的header需要是黏连的,我们会这样设置样式:
.header {
position: sticky;
}
如果我们这个导航栏能够折叠的丝滑一些,我们要这样设置样式:
.main {
transition: all 0.3s ease-in;
}
.sidebar {
transition: all 0.3s ease-in;
}
之后,添加控制导航栏的状态:
const App = () => {
// hold navigation state here
const [isNavExpanded, setIsNavExpanded] = useState(true);
return (
<>
<div className="header"></div>
<div className="layout">
<div
className="sidebar"
// translate the nav to the left if collapsed, and back
style={{
transform: isNavExpanded
? 'translate(0, 0)'
: 'translate(-300px, 0)'
}}
>
...
</div>
<div
className="main"
// translate the main to the left if nav is collapsed, and back
style={{
transform: isNavExpanded
? 'translate(0, 0)'
: 'translate(-300px, 0)'
}}
>
{/* main here */}
</div>
</div>
</>
);
};
这段代码看着不错。但是,当我上下滑动时,header就消失了。这很容易解决,围棋设置z-index为2即可。
代码示例: advanced-react.com/examples/13…
但还有一个问题:对话框功能并不完善。它应该被定位在屏幕的正中间,但并不是。当我打开对话框,再滑动屏幕时,它展示在header的下方。我们又遇见了 堆叠上下文(Stacking Context) 陷阱。
我们可以使用React的createPortal函数。它接收两个参数:
- 想要渲染的内容
- 想要渲染的位置
import { createPortal } from 'react-dom';
const App = () => {
return (
<>
... // the rest of the code with the button
{isVisible &&
createPortal(
<ModalDialog />,
document.getElementById('root'),
)}
</>
);
};
如此一来,我们就避开了这个陷阱。就是这样,“陷阱” 不复存在了!从开发者体验的角度来看,我们仍然是将对话框和按钮 “一起渲染” 的。但最终,对话框会被放置在 id="root" 的元素内部。如果你打开 Chrome 开发者工具,就会看到它正好位于该元素的最底部。
代码示例: advanced-react.com/examples/13…
现在,代码按照预期运行了。
但是这样做的结果是什么?会引起重新渲染吗?对React生命周期、事件,Context有什么影响?很简单。React 中的传送门,规则如下:
- 发生在React依然发生在React
- React无法控制的地方,其行为由DOM来操控。
这到底意味着什么?
React生命周期,重新渲染,Context 与 传送门(Portals)
在React视角,这个对话框不过是<ModalDialog />组件所渲染的一个子树。如果我触发了App的重新渲染,App里所有的组件都会重新渲染,包括对话框(前提是对话框已经打开了)。
如果App被卸载了,对话框也会被卸载。
如果我想拦截模态框内发生的点击事件,“主” div 上的 onClick 事件处理函数就可以做到这一点。这里的 “点击” 事件属于合成事件,因此它们会在 React 树中 “冒泡”,而不是在常规的 DOM 树中冒泡。对于 React 所管理的任何合成事件来说,情况都是如此。
代码示例: advanced-react.com/examples/13…
CSS,原生 JS,表单提交 与 Portals
从DOM的视角来看,这个对话框不再是“main”app的一部分了。所以,其关于DOM的部分会发生变化。
这个对话框依赖于“main”app 所继承的样式,将会失效果。
// won't work with portalled modal
.main .dialog {
background: red;
}
如果你依赖原生事件的传导,这也将失效。
const App = () => {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
el.addEventListener("click", () => {
// trying to catch events, originated in the portalled modal
// not going to work!!
});
}, []);
// the rest of the app
return <div ref={ref} ... />
}
应该你希望通过parentElement方法来获取对话框的父属性,parentElement会返回rootdiv,而不是“main” app。同样的事情,会发生在其他原生DOM API上。
最后,来说说
元素上的 onSubmit 事件。这是最容易被忽视的一点。它感觉起来和 onClick 事件差不多,但实际上,submit 事件并非由 React 管理 [23]。它是原生 API 和 DOM 元素相关的内容。如果我用 标签包裹应用的主要部分,那么点击对话框内的按钮不会触发 “提交” 事件!从 DOM 的角度来看,这些按钮位于表单之外。如果你想在对话框内设置一个表单,并希望依赖 onSubmit 回调函数,那么 标签也应该放在对话框内。代码示例: advanced-react.com/examples/13…
知识概要
position: absolute的绝对,是相对于其有定位的父组件而言的。position: fixed的固定,是相对于整个视窗,前提是没有生产新的包含块。position: absolute的元素在overflow: hidden(溢出隐藏)的元素内部会被裁剪。position: absolute的元素可以避免 overflow: hidden 带来的溢出问题,但它们无法摆脱堆叠上下文的影响。- 堆叠上下文是由
position、z-index,translate等属性引起的。 - 使用Portals,我们可以轻松渲染一些元素,而不用担心堆叠上下文带来的影响。
- 当使用Portals,有这些规则:发生在React的事情依旧发生在React;React以外的事情则遵守DOM的规则。