React Fragment 添加事件监听?

React Fragment 支持不添加额外节点的情况下对子元素进行分组, 在文档上中 Fragment 的 Props 只有 key, 我们给 Fragment 添加任何事件监听例如 onClick 都是没有效果的.

<Fragment onClick={() => alert('click from fragment')}>
  <button type="button">yes</button>
  <button type="button">no</button>
</Fragment>

通常情况下我们会把 Fragment 的事件监听分配给各个子节点:

const onClick = () => alert('click from fragment');
return (
  <Fragment>
    <button type="button" onClick={onClick}>
      yes
    </button>
    <button type="button" onClick={onClick}>
      no
    </button>
  </Fragment>
);

不过这种写法不太优雅, 特别是子节点自身就有同名事件监听的情况下:

const onClick = () => alert('click from fragment');
const onClickYes = () => {
  alert('click from yes');
  onClick();
};
const onClickNo = () => {
  alert('click from no');
  onClick();
};
return (
  <Fragment>
    <button type="button" onClick={onClickYes}>
      yes
    </button>
    <button type="button" onClick={onClickNo}>
      no
    </button>
  </Fragment>
);

React 仓库有一个讨论以上情况的 issue, 遗憾的是 React 官方暂没有打算在 Fragment 上添加事件监听, 不过有一个讨论引起了我的注意:

issue 里提及了 display contents
issue 里提及了 display contents

于是在 MDN 上查阅了 display: contents:

display: contents
display: contents

简单来说, 一个 display=contents 的节点, 自身表现相当于 display=none 但是子节点会照常渲染, 我们看个例子:

<div
  class="parent"
  style="width: 200px; height: 200px; border: 1px solid black;"
>
  <div class="child" style="color: blue;">child</d>
  <div class="child" style="color: red;">child</div>
</div>

上面的 html 渲染结果会是这个样子:

如果我们给 parent 添加 display: contents 就会取消渲染, 但是 child 正常渲染:

display_contents

display=contents 虽然不会渲染但是保留了其他特性, 包括事件监听, 根据这个特点, 我们可以用来模拟 Fragment:

<div
  style={{ display: 'contents' }}
  onClick={() => alert('click from fragment')}
>
  <button type="button" onClick={() => alert('click from yes')}>
    yes
  </button>
  <button type="button" onClick={() => alert('click from no')}>
    no
  </button>
</div>

这样实现的 Fragment 虽然达到了不可见的效果, 不过依然存在 DOM 结构中, 在一些特殊场景下依然会导致问题, 比如官方文档中 table 的例子:

Warning: validateDOMNesting
Warning: validateDOMNesting

所以 display: contents 并不能很好地解决这个问题.

封装组件

React Fragment 是 16.2 推出的, 在这之前, 我一直用 react-aux 实现 Fragment 的功能:

<div>
  <Aux>
    <div>a</div>
    <span>b</span>
  </Aux>
</div>
/**
 * 输出真实 DOM
 * <div>
 *   <div>a</div>
 *   <span>b</span>
 * </div>
 */

它的原理很简单, 利用 React 自身对 children 合并的处理:

// https://github.com/gajus/react-aux/blob/master/src/ReactAux.js
module.exports = function Aux(props) {
  return props.children;
};

同时, React 自身提供了对 children 的遍历方法 Children.map, 所以是不是可以在 react-aux 的基础上对 children 进行遍历操作注入事件监听?

测试多次后得到了以下组件:

function Fragment(props) {
  const { children, ...rest } = props;
  return Children.map(children, (child) => {
    const externalProps = {};
    Object.keys(rest).forEach((propName) => {
      /**
       * 只需要处理 on 开头的 props
       * 忽略非事件监听
       */
      if (propName.startsWith('on')) {
        externalProps[propName] = (event) => {
          /**
           * 模拟事件捕获/冒泡
           * 捕获先触发 Fragment 的事件监听, 后子节点
           * 冒泡先触发子节点的事件监听, 后 Frgment
           */

          /**
           * 如果子节点自身有同名事件监听
           * 按照捕获或者冒泡的顺序触发
           * 需要注意把 event 传递
           */
          const emitChild = () => {
            if (child.props[propName]) {
              child.props[propName](event);
            }
          };

          const emit = (capture) => {
            if (!capture) {
              emitChild();
            }

            /**
             * 触发 Fragment 的事件监听
             * 同样地把 event 传递
             */
            rest[propName](event);

            if (capture) {
              emitChild();
            }
          };

          emit();
        };
      }
    });

    /**
     * 克隆原有的子节点
     * 改写原来的 props
     */
    return cloneElement(child, {
      ...child.props,
      ...externalProps,
    });
  });
}

值得注意的是事件处理方法有 捕获冒泡 两种方式, 需要额外处理 Fragment 和子节点的触发顺序. 此外, 如果通过 child.props.onClick = x 这样的方式改写 props 是不会生效的, 所以需要利用 React 节点的克隆方法 cloneElement 改写 props.

下面例子中, Fragment 的事件处理会在子节点身上触发, 捕获和冒泡的触发顺序也正确, 以及支持嵌套:

fragment_with_event_listener

当然, 没有特殊需求的情况还是建议使用 React 提供的 Fragment, 毕竟上面的封装也会导致一部分的性能损失.

进一步阅读

使用 Discussions 讨论 Github 上编辑 分享到 X