这篇文章上次修改于 504 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

前段时间公司项目又开始了新的一轮功能迭代,其中有一处需求涉及到移动端的重大 UI 调整,原先此处采用了 Ant Design 的 Drawer 组件,实现了一个从底部弹出的面板,用于展示设置项目,而现在这里变成了全屏的,且设计方面和原有的差别较大。

遇到这种情况想要解决问题无非只有两种方式,自己编写一个新的组件,或者再另外使用 Ant Design 的 Drawer 组件二开一个,使用 CSS 覆盖的方式调整成 UI 效果图的样子。而这一次我并没有选择第一种方式来做,因为覆盖组件库的默认样式有几个特别麻烦的问题:

难以逃脱的覆盖样式

编写时,你需要从浏览器的开发工具里分析原有的 DOM 结构和对应的 CSS 样式,再根据实际情况来编写 CSS 重置样式,覆盖或新增 CSS 规则。这个过程就比较繁琐,尤其是那种需要 Hover 状态来显示的组件,你还没定位上去就有可能从真实 DOM 上消失掉了。如果提供了 visible 一类的属性,还需要再调整一下对应代码之后再继续。

还有一部分出现在你预料以外的效果,像是文章开篇提到的 Drawer 组件,它有一个半透明的灰色覆盖层,这个样式你也得单独做好覆盖的处理。这种情况的绝大多数原因都是因为你只是 UI 组件库的使用者,并不能从根本上明白人家为何要那样写,即便存在着对你来说看似是不太合理的设计(代码实现的方面)。

并且升级组件库对于这样的项目来说简直就是灾难级别的,大多数人遇到这种情况都会选择重构部分/全部代码。但如果仅仅是后台类项目,对 UI 设计上没有较为刁钻要求的情况下,我们不需要过多的修改重置它们的内置样式,这种情况下升级组件库的影响就会相对较小了。

自己编写组件看似要困难许多,但实际上一些交互简单的组件完全就能够自己轻松实现出来。只是可能存在一些问题(性能优化、交互细节、过渡动画等),其中过渡动画就让不少人望而却步。

于是我就来提一下我在近期的一个实现,既能够为 React 组件提供一定的性能优化,又可以增加过渡动画的一个最简方案。

原理分析

如果使用过 jQuery + BootStrap 编写页面,你一定很熟悉这样的代码:

<!-- Button trigger modal -->
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">
  Launch demo modal
</button>

<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h1 class="modal-title fs-5" id="exampleModalLabel">Modal title</h1>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        ...
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
        <button type="button" class="btn btn-primary">Save changes</button>
      </div>
    </div>
  </div>
</div>

没错,这就是其 官方文档 的实现,你需要将一个完整的 DOM 结构放置在 HTML 文档里,把业务需要的内容放置在 .modal-body 里面,通过后端输出或是 Ajax 的方式将数据展示出来。

这种设计最不好的点,就是该 Modal 里面的功能在用户未使用的情况下,真实 DOM 也会存在于页面上,由于 jQuery 写前端代码都是按需执行操作(Svelte:像做外科手术一样更新 DOM),不需要像 React 和 Vue 那样去递归 Diff 虚拟 DOM,所以这种使用方式是不会存在太多性能问题的。(真要性能不好,早期这样设计的网页都没法用了)

那么使用 React 的 Ant Design 是怎么对这种情况做出性能优化的呢,其实很好甄别出来,就是你会发现使用到 Modal Drawer 一类的组件,在其默认 visible 状态为 false 的时候,组件实际上并不会完全挂载到真实 DOM 里面。

直到你第一次将 visible 状态改为 true 激活组件显示出来,才会让 React 挂载完整的真实 DOM。

原生方式实现

如果我们不考虑这种性能优化的问题,想要实现一个带出现动画的弹窗非常容易。只需要像 BootStrap 那样整一个完整的结构出来,使用 visible 控制组件的激活状态,通过这个激活状态操作父的 CSS 类名(一般是 activetrigged 一类的),再结合 CSS 的 transitiontransform 属性就能整出不少花了~

.drawer {
  transition: transform .3s;
  transform: translateX(100%);

  &.active {
    transform: translateX(0);
  }
}

像这样的 CSS 过渡代码,就能实现出一个带从右往左运动出现动画的 Drawer 组件。具体效果可以看 这里,代码开源在项目 Code Snippets 里面。

改写成 React 组件

将如上 Demo 改写成 React 组件也并非一件难事,只需要根据组件的 visible 属性控制即可(这里为了简单化,未使用 createPortal 的方式分离渲染的父节点)

// 父组件
function Parent() {
  const [visible, setVisible] = useState(false);

  return (
    <Drawer visible={visible}>
      <p>Drawer Displayed!</p>
    </Drawer>
  );
}
// 子组件
function Drawer({ visible, children }) {
  return (
    <div className={clsx(styles.drawer, visible && styles.active)}>
      {children}
    </div>
  );
}

这样就实现了带动画的基础抽屉 React 组件了。

性能优化

前面已经提到,实现该抽屉 React 组件性能优化的方式就是让页面首次展示时,visible 属性为 false 时,不挂载虚拟 DOM 为真实 DOM,只在 visible 首次变为 true 时才渲染,自然就节省了一部分性能开销。那我是如何实现的呢?

import clsx from "clsx";
import { useState, useEffect } from "react";
import styles from "./Drawer.module.less";

interface DrawerProps {
  visible: boolean;
  children: React.ReactNode;
  onClose: () => void;
}

// 一个从右边出现的抽屉
function Drawer({ visible, children }: DrawerProps) {
  const [opened, setOpened] = useState(visible);

  useEffect(() => {
    if (visible) {
      setOpened(true);
    }
  }, [visible]);

  if (!opened && !visible) {
    return null;
  }

  return (
    <div className={clsx(styles.drawer, visible && styles.active)}>
      {children}
    </div>
  );
}

export default Drawer;

通过使用一个 opened 状态单独缓存一次打开状态,就能让抽屉组件在从未使用的情况下不进行渲染了,可以说是非常的巧妙。

细心的你应该发现了,这种实现方式会导致前面准备的 CSS 过渡动画没有了。这是为什么呢?

想一想,React 首次将组件渲染成真实 DOM 的时候,是不是 visible 状态已经是 true 了?

回到前面的 原生实现,如果我们将 .drawer 的类名默认修改成 drawer active,刷新页面是不是一样的效果(DOM 一显示就已经是 激活状态)呢?

想要让动画出现,必须让 .drawer 在首次渲染出来的时候不携带 active 类名,因此我们需要一种方式让它延迟处理。正如我的日记《粤康码黄了 / 手写抽屉组件与性能优化》所提到的猜想,使用 requestAnimationFrame 或者是 setTimeout 的方式就能够解决这个问题。

// 一个从右边出现的抽屉
function Drawer({ visible, children }: DrawerProps) {
  const [opened, setOpened] = useState(visible);
  const [active, setActive] = useState(visible);


  useEffect(() => {
    if (visible) {
      setOpened(true);
    }

    // 延迟外部状态,使得组件挂载上去了才设置,使过渡效果生效
    requestAnimationFrame(() => {
      setActive(visible);
    });
  }, [visible]);

  if (!opened && !visible) {
    return null;
  }

  return (
    <div className={clsx(styles.drawer, active && styles.active)}>
      {children}
    </div>
  );
}

通过增加一个 active 状态,在 visible 状态改变的时候顺带执行一个异步函数,就能近乎完美的解决问题!以上方式,既兼顾到了性能,也用了最小的成本实现出了抽屉组件出现的动画效果!

最后感谢你读完本篇文章,如果对文章内容有疑问或发现部分错误信息,欢迎在评论指出!更多有趣的开发日常故事,欢迎关注「保罗的小窝」!