本文是系列文章的一部分:框架实战指南 - 基础知识
您是否曾经启动您最喜欢的应用程序,单击操作按钮,然后轰隆一声,应用程序弹出一个有关您的交互的窗口?
例如,您可能单击“删除”按钮,然后会看到“您确定要删除该文件吗?”的弹出窗口。
这些被称为“模态窗口”,尽管令许多开发人员苦恼,但它们仍然被广泛用于各种应用程序中,作为吸引用户注意力的一种方法。
您可能会惊讶地发现,尽管它们无处不在,但实施起来却颇具挑战性。
然而,您可能不会惊讶地发现这些模态框非常常见,以至于React、Angular 和 Vue 中都有一个 API 可以使模态框更易于实现,这个 API 几乎专门用于这些类型的模态框组件。
这个 API 叫什么?Portals。
为什么我们需要为这个用例提供专用 API?CSS。
模态框的问题;CSS 堆叠上下文
让我们构建我们在所选框架中看到的“删除文件”模式:
<!-- Modal.vue --><template> <div> <div class="modal-container"> <h1 class="title">Are you sure you want to delete that file?</h1> <p class="body-text"> Deleting this file is a permanent action. You’re unable to recover this file at a later date. Are you sure you want to delete this file? </p> <div class="buttons-container"> <button class="cancel">Cancel</button> <button class="confirm">Confirm</button> </div> </div> </div></template>
现在我们有了模态框,让我们构建一个本书中一直在构建的文件夹应用的小版本。这个版本的应用应该展示模态框、页眉和版权页脚:
<!-- App.vue --><script setup>import Header from "./Header.vue";import Body from "./Body.vue";import Footer from "./Footer.vue";</script><template> <div> <Header /> <Body /> <Footer /> </div></template>
<!-- Header.vue --><script setup>import FolderIcon from "./FolderIcon.vue";import DeleteIcon from "./DeleteIcon.vue";</script><template> <div class="header-container"> <span class="icon-container"> <FolderIcon /> </span> <span class="header-title">Main folder</span> <span class="auto"></span> <button class="icon-btn"> <DeleteIcon /> </button> </div></template>
<!-- Body.vue --><script setup>import FolderIcon from "./FolderIcon.vue";const files = Array.from({ length: 10 }, (_, i) => i);</script><template> <ul class="list-container"> <li class="list-item" v-for="fileIdx of files"> <FolderIcon /> <span>File number {{ fileIdx + 1 }}</span> </li> </ul></template>
<!-- Footer.vue --><template> <div class="footer-container">Copyright 2022</div></template>
<!-- DeleteIcon.vue --><template> <svg viewBox="0 0 20 21"> <path d="M9 8V16H7.5L7 8H9Z" fill="currentColor" /> <path d="M12.5 16L13 8H11V16H12.5Z" fill="currentColor" /> <path d="M8 0C7.56957 0 7.18743 0.27543 7.05132 0.683772L6.27924 3H1C0.447715 3 0 3.44772 0 4C0 4.55228 0.447715 5 1 5H2.56055L3.38474 18.1871C3.48356 19.7682 4.79471 21 6.3789 21H13.6211C15.2053 21 16.5164 19.7682 16.6153 18.1871L17.4395 5H19C19.5523 5 20 4.55228 20 4C20 3.44772 19.5523 3 19 3H13.7208L12.9487 0.683772C12.8126 0.27543 12.4304 0 12 0H8ZM12.9767 5C12.9921 5.00036 13.0076 5.00036 13.0231 5H15.4355L14.6192 18.0624C14.5862 18.5894 14.1492 19 13.6211 19H6.3789C5.85084 19 5.41379 18.5894 5.38085 18.0624L4.56445 5H6.97694C6.99244 5.00036 7.00792 5.00036 7.02334 5H12.9767ZM11.6126 3H8.38743L8.72076 2H11.2792L11.6126 3Z" fill="currentColor" /> </svg></template>
<!-- FolderIcon.vue --><template> <svg viewBox="0 0 20 16"> <path d="M20 14C20 15.1046 19.1046 16 18 16H2C0.895431 16 0 15.1046 0 14V2C0 0.895431 0.89543 0 2 0H11C11.7403 0 12.3866 0.402199 12.7324 1H18C19.1046 1 20 1.89543 20 3V14ZM11 4V2H2V14H18V6H13C11.8954 6 11 5.10457 11 4ZM13 3V4H18V3H13Z" fill="currentColor" /> </svg></template>
body { margin: 0; padding: 0;}.header-container { display: flex; align-items: center; gap: 0.5rem; padding: 8px 12px; border: 2px solid #f5f8ff; background: white; color: #1a42e6; position: fixed; top: 0; left: 0; width: 100%; box-sizing: border-box; z-index: 1;}.header-title { font-family: "Roboto", sans-serif; font-weight: bold;}.auto { margin: 0 auto;}.icon-btn,.icon-container { box-sizing: border-box; background: none; border: none; color: #1a42e6; border-radius: 0.5rem; height: 24px; width: 24px; display: flex; align-items: center; justify-content: center; padding: 4px;}.icon-btn svg { width: 100%;}.icon-btn:hover { background: rgba(26, 66, 229, 0.2);}.icon-btn:active { background: rgba(26, 66, 229, 0.4); color: white;}.list-container { list-style: none; display: flex; flex-direction: column; gap: 0.25rem; margin: 0; margin-top: 2.5rem; padding: 1rem;}.list-item { padding: 0.5rem 1rem; display: flex; align-items: center; gap: 1rem; color: #1a42e6; font-family: "Roboto", sans-serif; border-radius: 0.5rem;}.list-item:hover { background: rgba(245, 248, 255, 1);}.list-item svg { width: 24px;}.footer-container { font-family: "Roboto", sans-serif; position: relative; z-index: 2; background: white; color: #1a42e6; padding: 8px 12px; border: 2px solid #f5f8ff;}
太棒了!看起来不错。现在,让我们添加从Header组件打开对话框的功能。
为此,我们将:
- 将我们的
Modal组件添加到我们的Header组件中 Modal根据用户是否点击了删除图标来添加一些状态以进行条件渲染
<!-- Header.vue --><script setup>import FolderIcon from "./FolderIcon.vue";import DeleteIcon from "./DeleteIcon.vue";import Modal from "./Modal.vue";import { ref } from "vue";const shouldShowModal = ref(false);function showModal() { shouldShowModal.value = true;}</script><template> <div class="header-container"> <Modal v-if="shouldShowModal" /> <span class="icon-container"> <FolderIcon /> </span> <span class="header-title">Main folder</span> <span class="auto"></span> <button class="icon-btn" @click="showModal()"> <DeleteIcon /> </button> </div></template>
但是等等...当我们渲染应用程序并打开对话框时,为什么它看起来像是在组件下面Footer?!
笔记
如果您正在运行上面嵌入的代码,则可能需要在新选项卡中打开它并尝试调整窗口大小才能看到错误。
这是为什么呢?毕竟,Modal有一个z-index的99,而Footer只有一个z-index的2!
虽然“为什么此示例中的模态渲染位于页脚下方”的详细答案中提到了堆叠上下文,但简短的回答是“数字越大z-index并不总能保证您的元素始终位于顶部”。
虽然这两个链接都指向同一个地方,但我担心这可能仍然是一个太微妙的暗示,让人无法去阅读我写的那篇解释这种行为发生原因的文章
z-index。
为了解决这个问题,我们将使用本章开头提到的 React、Angular 和 Vue 内置的 JavaScript API:Portals。
什么是 JavaScript 门户?
JavaScript Portal 背后的基本思想建立在我们在第一章中介绍的组件等概念之上。
想象一下,您有一组代表我们刚刚构建的小应用程序的组件:
在这个组件布局中,Modal显示在Footer组件下方。发生这种情况的原因是被困在了“CSS 堆叠上下文”Modal之下。
让我们简化图表并看看我的意思;
在这里,我们可以看到,尽管Modal被分配了一个z-index,99但它被困在 之下Header,而 是z-index的一个1。Modal无法逃脱这种封装的z-index绘制顺序,因此Footer显示在顶部。
理想情况下,为了解决这个问题,我们希望将其移动Modal到 HTML 中的 之后Footer,如下所示:
Modal但是,如果不将组件移到组件外部,我们怎样才能做到这一点呢Header?
这就是 JavaScript 门户发挥作用的地方。门户允许你在 DOM 树中与组件树不同的位置渲染组件的 HTML。
也就是说,您的框架组件将像左侧的树一样布局,但将像右侧的平面结构一样呈现。
让我们看看如何自己构建这些门户。
使用本地门户
虽然这不是使用门户的最有用的例子,但让我们看看如何使用门户将 UI 的一部分传送到同一组件的另一部分:
Vue 可能拥有所有门户 API 中最小的:您使用内置Teleport组件并使用输入告诉它您希望它呈现到哪个 HTML 元素to。
<!-- App.vue --><script setup>import { ref } from "vue";const portalContainerEl = ref(null);</script><template> <div style="height: 100px; width: 100px; border: 2px solid black"> <div ref="portalContainerEl"></div> </div> <div v-if="portalContainerEl"> <Teleport :to="portalContainerEl">Hello, world!</Teleport> </div></template>
我们需要
v-if此代码中的来确保portalContainerEl已经被渲染并且准备好投射内容。
值得一提的是,这不是门户最有用的例子,因为如果我们在同一个组件内,我们可以自由地移动元素,完全控制组件。
现在我们知道了如何在组件中应用门户,让我们看看如何将门户应用为整个应用程序的根源。
应用程序范围的门户
在本地门户中,我们可以看到门户的实现依赖于将元素引用设置为变量。这告诉我们应该在哪里渲染门户的内容。
虽然这种方法有效,但它并没有解决门户最初要解决的问题,即重叠堆叠上下文。
如果有一种方法可以为我们应用程序的所有组件提供一个变量,那么我们就可以有办法解决应用程序中的堆叠上下文问题......
等等,Corbin,我们有办法把所有变量都提供给应用的其余部分!我们之前学习了如何在应用的根目录下使用依赖注入来实现这一点!
好主意,热心的读者!我们开始吧。
再次,Vue 的简单 API 方法通过其provideAPI 的配对可见一斑,该 API 托管要呈现门户的位置变量,以及其TeleportAPI,可实现门户的使用。
<!-- App.vue --><script setup>import { ref, provide } from "vue";import Child from "./Child.vue";const portalContainerEl = ref(null);provide("portalContainerEl", portalContainerEl);</script><template> <div style="height: 100px; width: 100px; border: 2px solid black"> <div ref="portalContainerEl"></div> </div> <Child /></template>
<!-- Child.vue --><script setup>import { inject } from "vue";const portalContainerEl = inject("portalContainerEl");</script><template> <div v-if="portalContainerEl"> <Teleport :to="portalContainerEl">Hello, world!</Teleport> </div></template>
我们的门户现在应该能够呈现我们在应用程序内绘制的所有其他内容!
HTML 范围的门户
如果您的应用程序中仅使用 React、Angular 或 Vue,那么您可以相当安全地使用应用程序范围的门户,而不会出现任何重大问题……但大多数应用程序不仅仅使用React、Angular 或 Vue。
请考虑以下情形:
您的任务是在您的营销网站上实现一个聊天覆盖系统。该系统旨在帮助用户在遇到困难时联系客服代表。
他们希望 UI 看起来像这样:
虽然您可以自行构建,但这样做通常成本高昂。您不仅需要构建自己的聊天用户界面,还需要构建供客服代表使用的后端登录系统、他们之间的服务器通信等等。
幸运的是,名为“UnicornChat”的服务恰好解决了这个难题!
UnicornChat 并不存在,但存在许多类似的服务。本文中提到的“UnicornChat”纯属虚构,但基于一些致力于解决此问题的真实公司。我将演示的 API 通常与这些公司实际提供的服务非常相似。
UnicornChat 通过script向您的 HTMLhead标签添加标签与您的应用集成:
<!-- This is an example and does not really work --><script src="https://example.com/unicorn-chat.min.js"></script>
它会帮你处理好其他所有事情!它会在标签末尾添加一个按钮<body>,如下所示:
<body> <div id="app"><!-- Your React app here --></div> <div id="unicorn-chat-contents"><!-- UnicornChat UI here --></div></body>
这太棒了,它立即解决了您的问题......或者您是这么认为的。
当 QA 测试您的应用程序时,他们会发现一个您从未见过的全新错误;UnicornChat UI 绘制在您的文件删除确认对话框之上。
这是因为您的 React 应用程序的内容在 UnicornChat UI 之前呈现,因为 UnicornChat 代码位于div您的 React 容器之后div。
我们该如何解决这个问题?通过将门户内容放置body在 UnicornChat UI 之后。
虽然我们之前已经将 传递ref给Teleport的to属性,但我们可以使用元素的字符串来使用 进行查询document.querySelector。
这意味着我们可以将其传递"body"给我们的Teleport组件并让它在 DOM 主体的末尾呈现门户内容。
<!-- Child.vue --><script setup></script><template> <Teleport to="body">Hello, world!</Teleport></template>
<!-- App.vue --><script setup>import Child from "./Child.vue";</script><template> <!-- Even though it's rendered first, it shows up last because it's being appended to <body> --> <Child /> <div style="height: 100px; width: 100px; border: 2px solid black"></div></template>
现在,当您再次测试该问题时,您会发现您的模式位于 UnicornChat UI 上方。
挑战
如果我们回顾元素参考章节的代码挑战,您可能还记得我们的任务是创建一个工具提示组件:
我们之前为这个挑战编写的代码运行良好,但它有一个主要缺陷;它不会显示z-index在堆叠上下文中具有更高位置的其他元素之上。
为了解决这个问题,我们需要将工具提示包装在门户中并将其呈现在标签的末尾body:
<!-- App.vue --><script setup>import { ref, onUnmounted } from "vue";const buttonRef = ref();const mouseOverTimeout = ref(null);const tooltipMeta = ref({ x: 0, y: 0, height: 0, width: 0, show: false,});const onMouseOver = () => { mouseOverTimeout.value = setTimeout(() => { const bounding = buttonRef.value.getBoundingClientRect(); tooltipMeta.value = { x: bounding.x, y: bounding.y, height: bounding.height, width: bounding.width, show: true, }; }, 1000);};const onMouseLeave = () => { tooltipMeta.value = { x: 0, y: 0, height: 0, width: 0, show: false, }; clearTimeout(mouseOverTimeout.current);};onUnmounted(() => { clearTimeout(mouseOverTimeout.current);});</script><template> <div style=" height: 100px; width: 100%; background: lightgrey; position: relative; z-index: 2; " ></div> <div style=" z-index: 1; position: relative; padding-left: 10rem; padding-top: 2rem; " > <Teleport to="body" v-if="tooltipMeta.show"> <div :style="` z-index: 9; display: flex; overflow: visible; justify-content: center; width: ${tooltipMeta.width}px; position: fixed; top: ${tooltipMeta.y - tooltipMeta.height - 16 - 6 - 8}px; left: ${tooltipMeta.x}px; `" > <div :style="` white-space: nowrap; padding: 8px; background: #40627b; color: white; border-radius: 16px; `" > This will send an email to the recipients </div> <div :style="` height: 12px; width: 12px; transform: rotate(45deg) translateX(-50%); background: #40627b; bottom: calc(-6px - 4px); position: absolute; left: 50%; zIndex: -1; `" ></div> </div> </Teleport> <button ref="buttonRef" @mouseover="onMouseOver()" @mouseleave="onMouseLeave()" > Send </button> </div></template>