第十三章 React portal 以及 为什么需要它 【下】

183 阅读7分钟

Position: fixed。避开溢出问题

position还有另一个值可以使元素逃脱普通文档流:fixed。它和absolute类似,它的定位不是相对于最近的一个有position属性的元素,而是相对于整个视窗。对于对话框这样要相对于整个视窗来居中的场景,使用fixed是最好的。

同理,因为它是相对于整个视窗来定位的,这个定位方法也允许我们逃出溢出陷阱。所以,理论上,这个属性还可以用于提示框。

然而,即使是position: fixed,也无法逃脱堆叠上下文(Stacking Context)。谁也无法逃脱。它就像一个黑洞:一旦形成,其引力范围内的一切都消失了。谁也逃不出来。

如果我把灰div的z-index设置为1,红divz-index设置为2.

image.png

代码示例: advanced-react.com/examples/13…

position: fixed的另一个问题是,这个定位也不是永远相对于视窗的。实际上,它是相对于所谓的包含块(Containing Block)进行定位的。这个是经常发生的。

堆叠上下文(Stacking Context)在真实的应用中

好吧,这些讨论虽然有趣,但是太理论了。在真实的应用中,堆叠上下文(Stacking Context)会影响应用吗?当然,而且这是很常见的。

最有可能受影响的是各类动画元素,或者像header、侧边栏这类 “粘性” 区块。在这些场景下,我们很可能不得不设置 positionz-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 带来的溢出问题,但它们无法摆脱堆叠上下文的影响。
  • 堆叠上下文是由positionz-indextranslate等属性引起的。
  • 使用Portals,我们可以轻松渲染一些元素,而不用担心堆叠上下文带来的影响。
  • 当使用Portals,有这些规则:发生在React的事情依旧发生在React;React以外的事情则遵守DOM的规则。