你觉得 React 里最难搞的部分是啥?是重新渲染那块?是上下文那块?是那个 portal 吗?还是那个并发特性?
不了。
React 最难的部分在于它周围的所有 非React 东西。对于上述那些东西“如何工作?”这个问题,答案很简单:只需要按照算法一步步来,并做好记录。结果将是明确且一致的(只要你正确地追踪每一个步骤)。这纯粹是科学和事实。
但是关于“怎么才算‘好’?”或者“实现……(某事)的正确方式是?”还有“我该用现成库还是自己写解决方案?”这些问题,最准确的答案是“因情况而异”。这答案虽对,但最不实用。
我想为新文章找到更好的东西。但由于这些问题没有简单的答案或通用的解决方案,这篇文章更像是我思考过程的梳理,而不是“这就是答案,每次都这样做。”希望它仍然有用。
那么,要将一个功能从想法变为可生产的解决方案需要什么?让我们试着实现一个简单的模态窗口,看看。这有什么复杂的地方吗? 😄
第一步:从最简单的方案开始
让我们从有时被称为“峰值”(或“尖峰”)的最简单的实现开始,这种实现可以帮助我们探索解决方案并收集进一步的需求。我知道我要实现的是一个模态对话框,让我们假设我已经有这样一个漂亮的设计。
对话基本上就是屏幕上的一个组件,比如点击按钮时就会出现。这就是我要开始的地方。
export default function Page() { const [isOpen, setIsOpen] = React.useState(false); return ( <> <button onClick={() => setIsOpen(true)}> 点击这里 </button> {isOpen ? ( <div className="dialog">此处内容</div> ) : null} </> ); }
一个表示状态的标志或变量,一个响应点击的按钮,以及当状态为真时将要显示的对话框。对话框还应该有一个“关闭”动作。
<button className="close-button" onClick={() => setIsOpen(false)} > 关闭 </button>
它还有一个“背景层”——一个可点击的半透明 div,覆盖在内容之上,点击它可以关闭模态框。
<div className="backdrop" // 后景层 onClick={() => setIsOpen(false)} // 点击时关闭弹出层 ></div>
大家一起:
export default function Page() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}> 点我 </button> {isOpen ? ( <> <div className="backdrop" onClick={() => setIsOpen(false)} ></div> <div className="dialog"> <button className="close-button" onClick={() => setIsOpen(false)} > 关闭 </button> </div> </> ) : null} </> ); }
我通常也会尽早添加一些不错的样式元素。看到我正在实现的功能在屏幕上呈现出预期的外观,这有助于我的思路。此外,它还能指导功能的布局,这正是这个对话框将会经历的过程。
让我们快速给 backdrop
加个 CSS —— 很简单,就是一个半透明的背景,设置在 position: fixed
的 div 上,覆盖整个屏幕。
.backdrop { background: rgba(0, 0, 0, 0.3); position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
这个对话稍微有趣些,因为它需要居中显示。当然,用 CSS 实现这一点的方法有很多种,但我最喜欢且最简单的方法是这样:
.dialog { position: fixed; /* 固定定位 */ left: 50%; /* 左边距为50% */ top: 50%; /* 顶边距为50% */ transform: translate(-50%, -50%); /* 使对话框相对于屏幕中心定位 */ } /* .dialog 样式:固定在屏幕中央 */
我们使用固定定位来摆脱布局限制,将左和顶部偏移50%以将div置于中间附近,然后通过transform
属性将其向相反方向移动50%。left
和top
值将相对于整个视口来计算,而transform
将相对于div本身的尺寸来调整,因此无论div自身宽度或屏幕宽度如何变化,它都会居中显示。
这一步中的最后一步是为对话框本身及其“关闭”按钮进行适当的样式设置。这里不列出代码,这些样式的具体细节并不重要,可以参考一下例子。
现在我已经有了这个特性的初步版本,是时候让它变得真正有用的了。要做到这一点,我们需要详细理解我们正在尝试解决什么问题以及这个问题是为谁解决的。从技术角度来看,我们应当在编写任何代码之前就理解这一点,所以很多时候,这一步应该是第一步。
这是需要尽快实现的原型的一部分,只需向投资者展示一次然后就再也不用了吗?还是说这是你要发布到npm并开源的通用库的一部分?或者这是你所在拥有5000人的公司会使用的设计系统的一部分?或者这只是你所在只有3人的小型团队内部使用的工具的一部分?或者你在像TikTok这样的公司工作,这个对话是只在移动端使用的web应用的一部分?或者你所在的代理公司只为客户政府开发应用?
回答这些问题后,就能指明接下来编码的方向了。
如果只是用来一次的原型,可能就够用了。
如果它要作为库的一部分开源出来,它需要一个非常好的通用API,能够让世界各地的任何开发人员轻松使用和理解,还需要大量的测试和完善的文档。
设计系统中的一部分对话需要遵循公司的设计规范,并且可能不允许引入外部依赖到仓库中。因此,你可能需要从零开始实现很多事情,而不是安装 npm install new-fancy-tool
。
为政府工作的代理机构的对话可能需要是最易懂且合规的。否则,该代理机构可能会失去政府的合同从而破产。
就这样一直下去。
为了本文,让我们假设该对话是现有大型商业网站正在重设计的一部分,该网站每天都有来自世界各地的成千上万的用户访问。重设计目前只有一份包含该对话的设计,其余内容还在进行中。
其余的部分会稍后完成,设计师们现在很忙,他们还有很多工作要做。我是负责重新设计并持续维护网站的长期团队的一员,不是只负责单一项目的外部承包商。
在这种情况下,仅凭一张图片并了解我们公司的目标就足以让我做出合理的推测并完成对话的90%。剩下的10%可以在之后进行微调。
根据上面的信息,我可以做出以下假设:
现在我知道了要求并且有了合理的想法,我可以开始做这个实际的对话组件了。首先,从这段代码开始。
export default function 页() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}> 点我 </button> {isOpen ? ( <> <div className="backdrop" onClick={() => setIsOpen(false)} ></div> <div className="dialog"> <button className="close-button" onClick={() => setIsOpen(false)} > 关闭 </button> </div> </> ) : null} </> ); }
我必须将对话部分提取成一个可重用的组件——有许多对话相关的功能需要实现。
const ModalDialog = ({ onClose }) => { return ( <> <div className="backdrop" onClick={onClose}></div> <div className="dialog"> <button className="close-button" onClick={onClose}> 关闭 </button> </div> </> ); };
对话框将包含一个 onClose
属性 - 当点击“关闭”按钮或背景时,这会通知父组件。父组件将保持状态并像这样继续渲染对话框:
如下所示:
export default function 页面组件() { const [isOpen, setIsOpen] = useState(false); // isOpen表示模态对话框是否打开 return ( <> <button onClick={() => setIsOpen(true)}> 点击我 </button> {isOpen ? ( <ModalDialog onClose={() => setIsOpen(false)} /> // 关闭模态对话框时的回调 ) : null} </> ); // 页面组件负责显示一个按钮,点击按钮后显示模态对话框,点击对话框的关闭按钮后隐藏对话框 }
我们现在再来看看设计吧,再好好想想对话,怎么样?
很明显,对话框中会有一个带有操作按钮的“底部”或“底栏”部分。按钮数量和对齐方式会有很多变化,比如一个、两个或三个按钮,对齐方式可以是左对齐或右对齐,按钮间可能有空隙等。此外,这个对话框不包含标题部分,但很有可能它会有——带有标题的对话框是一个非常常见的模式。这里肯定有一个内容区域,内容完全随机——从简单的确认文本到表单,再到交互式的体验,甚至是非常长的“条款和条件”滚动文本,没人会去看。
最后是 尺寸。设计中的对话框非常小,只是一个确认对话框。较大的表单或较长的文本无法适应在其中。因此,根据我们在步骤2中收集的信息,可以安全地假设需要更改对话框的大小。目前,考虑到设计团队可能有设计规范,我们可以预期会提供三种尺寸的选择:‘小’、‘中’和‘大’。
这意味着我们在 ModalDialog
上需要一些 props,其中 footer
和 header
是普通的接受 ReactNode
的 props
,size
是一个字符串的联合类型,而主要内容区域将通过 children
属性传递。
type ModalDialogProps = { onClose: () => void; footer?: ReactNode; header?: ReactNode; children: ReactNode; size: 'small' | 'medium' | 'large'; }; const ModalDialog = ({ onClose, size, header, footer, children, }: ModalDialogProps) => { // 在这里调整大小 const className = `dialog ${size}`; return ( <> <div className="backdrop" onClick={onClose}></div> <div className={className}> <button className="close-button" onClick={onClose}> Close </button> {header} {children} {footer} </div> </> ); };
我们将通过从 props 获取的额外 className
来控制弹出窗口的尺寸。但实际上,这高度依赖于仓库中所使用的样式解决方案。
然而,在这种变体中,对话过于灵活——几乎可以放置任何内容。例如,在页脚中,大多数情况下,我们只期望看到一两个按钮,不会有更多。而且这些按钮在网站的各个位置需要保持一致的布局。我们需要一个容器来统一它们的排列:
<div className="footer">{页脚}</div>
关于内容也一样——至少需要添加一些边距,并且还需要支持滚动。头部的文字可能还需要一些样式调整。因此,布局会变成这样:
const ModalDialog = ({ onClose, size, header, footer, children, }) => { const className = `modal ${size}`; return ( <> <div className="背景" onClick={onClose}></div> <div className={className}> <button className="关闭" onClick={onClose}> 关闭 </button> <div className="标题">{header}</div> <div className="内容区">{children}</div> <div className="底部">{footer}</div> </div> </> ); };
但不幸的是,我们无法保证这一点。但很有可能在某个时候,有人想要在页脚里除了按钮之外还有其他内容。或者某些对话需要有一个背景为实色的标题栏。有时候,也可能不需要内边距。
我在这里要表达的是,我们将来可能需要为头部、内容和尾部部分进行样式设计。这意味着我们可能需要比我们预期的还要早地为这些部分设计样式。
当然,我们也可以通过 props 来传递这些配置。这样做在某些情况下是可以接受的。但对于这样漂亮的对话框重设计,我们可以做得更棒。例如,headerClassName
、contentClassName
和 footerClassName
这样的 props,对于某些情况来说,这样做是可以的,但对于像这样的重设计,我们可以做得更好。
一个解决这个问题的非常巧妙的方法是将我们的头部部分、内容部分和底部部分拆分为独立的组件,比如这样:
const DialogFooter = ({ children }) => { // 这是一个对话框底部组件,用于渲染子元素。 return <div className="footer">{children}</div>; }
将 ModalDialog
代码恢复到未使用包装器的状态:
const ModalDialog = ({ onClose, size, header, footer, children, }) => { const className = `dialog ${size}`; return ( <> <div className="背景层" onClick={onClose}></div> <div className={className}> <button className="关闭按钮" onClick={onClose}> 关闭对话框 </button> {header ? header : '标题'} {children ? children : '内容'} {footer} </div> </> ); };
这样的话,在主应用程序中,如果我想为对话框部分的设计使用默认设计,我会用那些小部件来实现这一点。
export default function 页面组件() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}> 点击这里 </button> {isOpen ? ( <ModalDialog onClose={() => setIsOpen(false)} header={<DialogHeader>对话框标题</DialogHeader>} footer={<DialogFooter>对话框底部</DialogFooter>} size="medium" > <DialogContent>内容区</DialogContent> </ModalDialog> ) : null} </> ); }
而且如果我想拥有完全自定义的东西的话,我会创建一个新的组件,带有自己定义的样式,而不会去修改 ModalDialog
本身:
export default function 页面组件() { const [isOpen, setIsOpen] = useState(false); return ( <> ... <模态框 onClose={() => setIsOpen(false)} header={<自定义头部>标题</自定义头部>} footer={<自定义尾部>底部</自定义尾部>} size="中等" > <内容>内容</内容> </模态框> ... </> ); }
事实上,我甚至不再需要 header
和 footer
这两个属性了。我可以直接把 DialogHeader
和 DialogFooter
当作子组件传递,简化 ModalDialog
,并获得一个同样灵活却设计更一致的接口,从而保持同样的灵活性和一致性设计。
父组件会看起来像这样:
export default function 页面组件() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}> 点击我 </button> {isOpen ? ( <模态对话框 onClose={() => setIsOpen(false)} size="medium" > <对话框标题>标题</对话框标题> <对话框内容>内容</对话框内容> <对话框页脚>底部</对话框页脚> </模态对话框> ) : null} </> ); }
如下所示,对话的API将是这样的:
export const ModalDialog = ({ onClose, size, children, }) => { const className = `对话框 ${size}`; return ( <> <div className="backdrop" onClick={onClose}></div> <div className={className}> <button className="close" onClick={onClose}> 关闭 </button> {children} </div> </> ); }; export const 对话框底部 = ({ children }) => { return <div className="footer">{children}</div>; }; export const 对话框头部 = ({ children }) => { return <div className="header">{children}</div>; }; export const 对话框内容 = ({ children }) => { return <div className="content">{children}</div>; };
到目前为止,我对它挺满意的。它足够灵活,可以根据设计需要随意扩展,而且也很直观合理,轻松在整个应用中实现一致的用户界面。
下面是一个互动示例:
现在模态框的API已经做得差不多了,是时候解决我实现的这个明显的设计缺陷了。如果你读过我的文章,你可能已经大声地喊:“你在干嘛???不断重新渲染!!” 😅 当然,你说得没错:
export default function 组件() { const [isOpen, setIsOpen] = useState(false); return ( <> <button onClick={() => setIsOpen(true)}> 点击这里 </button> {isOpen ? ( <ModalDialog onClose={() => setIsOpen(false)} size="中等" > <DialogHeader>对话框标题</DialogHeader> <DialogContent>内容区</DialogContent> <DialogFooter>底部栏</DialogFooter> </ModalDialog> ) : null} </> ); }
这里的 Page
组件具有状态。每当模态框开或关时,状态都会发生变化,这将导致整个组件及其内部内容重新渲染一次。虽然“过早优化是万恶之首”,确实,在实际测量性能之前不要进行优化,但在这种情况下,我们可以放心地忽略这条常规建议。
有两个原因。首先,我确定应用程序中会散布许多模态对话框。这不是一个只会被隐藏一次的功能,也不会被任何人使用。因此,有人可能会误用API,在不应该出现状态的地方放置状态,这种情况通过这种API出现的概率相当高。其次,预防这种重新渲染问题其实并不需要太多时间和精力。只需要花1分钟的时间,我们就可以完全不用担心性能问题了。
我们只需要把状态封装起来,并引入“无父级组件”的概念。
export const 模态对话框 = (props) => { const [isOpen, 设置是否打开] = useState(false); return ( <> {isOpen ? ( <基础模态对话框 {...props} onClose={() => 设置是否打开(false)} /> ) : null} </> ); }; // 关闭时设置isOpen为false
这里的 BaseModalDialog
也就是我们之前用的那个对话框组件,我只是改了它的名字。
然后传递一个用来触发弹窗的组件作为 trigger
属性:
export const ModalDialog = ({ // 添加 prop 屬性 trigger, ...props }) => { // 初始化 isOpen 狀態 const [isOpen, setIsOpen] = useState(false); return ( <> // 觸發器渲染 <span onClick={() => setIsOpen(!isOpen)}>點擊切換 isOpen 狀態</span> {isOpen ? ( <BaseModalDialog {...props} onClose={() => setIsOpen(false)} /> ) : null} </> ); };
页面看起来会像这样
export default function Page() { return ( <> <-- 其他页面相关内容 --> <ModalDialog trigger={<button>点击我</button>} size="medium" > <DialogHeader>头部</DialogHeader> <DialogContent>对话内容</DialogContent> <DialogFooter>对话底部</DialogFooter> </ModalDialog> </> ); }
从 Page
中移除状态,避免潜在危险的重复渲染。
这样的 API 应该能覆盖大约 95% 的应用场景,因为大多数时候,用户需要点击某个东西来触发对话框。在一些特殊情况下,比如通过快捷键或作为引导流程的一部分,对话框需要独立显示时,我仍然可以手动处理状态并使用 BaseModalDialog。
ModalDialog
组件的 API 从 React 的角度来看,已经相当稳固,但还有许多工作要做。考虑到我在第 2 步中确定的关键功能,我还需要解决几个问题。
问题1:我把trigger
包在了额外的span
里——这在某些情况下可能会破坏页面布局。我需要想办法去掉这个包裹。
问题2:如果我在创建新堆叠上下文的元素内渲染对话框,模态可能会出现在一些元素下面。我需要把它渲染在一个Portal中,而不是像现在这样直接渲染在布局里。
问题3:当前键盘访问体验很差。当一个正确实现的模态对话框弹出时,焦点应该自动跳转到对话框内。当对话框关闭时,焦点应该返回触发对话框的元素。当对话框打开时,焦点应该被锁定在对话框内,对话框外的元素不应获得焦点。按ESC键可以关闭对话框。目前这些功能都还没有实现。
问题1和问题2虽然有点烦人,但能很快解决。相比之下,问题3手动解决起来简直就是噩梦。而且,这个问题肯定已经有人解决了,每个对话框恐怕都需要这种功能。
当我面对巨大的自我痛苦和一个看起来已经解决了的问题时,我通常会去找现成的库。
鉴于我已经做了所有准备工作,现在选一个合适的就变得很容易了。
我可以使用 Ant Design 或 Material UI 这样的现成 UI 组件库里的对话框。但如果重新设计中不使用这些库,将它们的设计调整得符合我的需要反而会带来更多麻烦,而不是解决问题。所以在这种情况下,这个方案直接就被排除了。
我可以使用像 Radix 或 React Aria 这样的“无界面”UI 库。这些库实现了对话框的状态管理、触发机制等,并且还处理了可访问性问题,但将设计权交给了消费者。在查看它们的 API 时,我发现我需要确认它们是否允许我手动控制对话框的状态,以便在需要手动触发对话框的情况下进行调整(它们确实允许这样做)。
如果出于某种原因无法使用无头模式的库,我会试着用一个能处理焦点陷阱的库。
为了这篇文章,让我们假设我可以用任何想要的库。在这种情况下,我会选择用Radix,因为它非常易用。它的对话框API 在这里查看,看起来与我之前实现的非常相似,因此,重构应该不会太复杂。
我们需要稍微做一点更改,将对话框本身的API进行调整:
export const ModalDialog = ({ trigger, size, ...props }) => { const className = `模态对话框 ${size}`; return ( <Dialog.Root {...props}> <Dialog.Trigger asChild>{trigger}</Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay className="背景" /> <Dialog.Content className={className}> {props.children} <Dialog.Close asChild> <button className="关闭" aria-label="关闭"> <CloseIcon /> </button> </Dialog.Close> </Dialog.Content> </Dialog.Portal> </Dialog.Root> ); };
基本上和以前差不多,只是现在用的是 Radix 原语,而不是到处都是 div 标签。
未受控制的对话使用根本没有改变。
export default function 页面() { return ( <> <Dialog 触发={ <button> 打开非标准对话框 </button> } 尺寸="中" > <标题>标题</标题> <内容>内容</内容> <底部> <button>确认</button> <button>否认</button> </底部> </Dialog> </> ); }
并且控制对话的方式略有不同——我需要把它改为传递 props,而不是根据条件来渲染。
export default function Page() { // 仍然可以控制状态,如有需要 const [isOpenStandard, setIsOpenStandard] = useState(false); return ( <> <button onClick={() => setIsOpenStandard(true)}> 打开标准对话框 </button> <ModalDialog size="medium" // 只需在这里传入状态 open={isOpenStandard} // 并在这里监听状态变化 onOpenChange={() => { setIsOpenStandard(false); }} > <DialogHeader>对话框标题</DialogHeader> <DialogContent>对话框内容</DialogContent> <DialogFooter> <button>确认</button> <button>拒绝</button> </DialogFooter> </ModalDialog> </> ); }
下面有个例子,你可以看看,试试用键盘来操作。一切都能用键盘顺利操作,真是太酷了。
另外,Radix 还有一个额外的好处,它解决了 Portal 的问题,并且它不会将触发器包裹在 span 中。不再有需要解决的边缘情况,因此,我可以继续进行最后的步骤。
这个功能还没有完成!😅哈哈,现在的对话已经相当不错了,所以我暂时不会对它做太大改动。但它还有几个地方需要改进,才能算是真正解决了我的需求。
一:如果他们还没有,设计者们首先让我做的事情之一,就是为对话框打开时的这种微妙的动画做好准备并记住React中的动画。
两:我需要给弹窗添加 max-width
和 max-height
属性,这样在小屏幕上它仍然看起来不错。还要想想它在非常大的屏幕上会是什么样子。
三:我需要和设计师谈谈在移动端这个对话框应该如何设计。他们可能让我做一个几乎占满屏幕的滑入面板,不管对话框本身的大小如何。
四 :我至少需要引入 对话标题
和 对话描述
组件——Radix 会要求我们为了无障碍访问使用它们。
第五点 :测试!对话会留下来,由其他人继续维护,在这种情况下,基本上来说,必须写测试。
还有很多小细节我现在想不起来了,以后可能会想起来。更不用说实际设计对话内容了。
再聊聊我的一些想法
当你把上面的“dialog”替换为“SomeNewFeature”(某个新功能),这就是我用来实现几乎所有新功能的基本算法。
快速完成解决方案的“Spike” → 收集功能需求 → 使其运行 → 提高性能 → 完善 → 臻于完美。
对于像实际对话这样的东西,我已经做了数百次了,我会在10秒内在脑子里想好第一步,然后马上开始第二步。
对于非常复杂且未知的事情来说,第一步可能更耗时,可能需要立即探索不同的解决方案和库。
有些东西并非完全未知,只是我们需要做的常规任务,可以跳过第一步,因为没什么可探索的。
尤其是在“敏捷”环境中,进展更像是螺旋形的,需求逐步提供,经常变化,我们常常需要回到前两个步骤。
希望这种类型的文章对你有用!💪🏼 如果你想看更多这样的内容,或者更喜欢通常的“如何工作”之类的内容,让我知道。
期待听听这个过程在你们心里是怎么样的情况 😅
本文最初发布于https://www.developerway.com/posts/hard-react-questions-and-modal-dialog。该网站上有更多类似的文章哦。😉
看看这本 React 高级书籍 ,让你的 React 技能更上一层楼。
订阅通讯 ,连接我 或关注我推特 获取新文章的推送.
再说一个最后的事情:如果你最近要开始一个新的项目,但还没有设计师,也没有时间打磨设计体验——我最近花了很多时间开发了一个新的UI组件库来解决这个问题。它集成了Radix和Tailwind,自带暗模式、无障碍支持和移动端适配功能,包括上面提到的完美的模态对话框! 😅
试试看: https://www.buckets-ui.com/