ant-design-vue 源码解析之 modal

980 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情

Modal 组件包含了多个细分的组件,在解析的时候便拆分成多个组件进行分析。如果组件引用到非组件内的方法,文章中只会写出该方法实现的功能而不会将源码粘贴出来进行详细分析。当然,由于篇幅有限,个人能力也有不足之处,所以只会截取部分代码进行分析。源码可在 GitHub 中查看,详细的文档可在 ant-design-vue 官网查看。

第一部分 Modal 组件

  1. 主体结构
export default defineComponent({
  compatConfig: { MODE: 3 },
  name: 'AModal',
  inheritAttrs: false,
  props: initDefaultProps(modalProps(), {  // 创建 props,其中 modalProps 函数里面包含一个 props 对象。
    width: 520,
    transitionName: 'zoom',
    maskTransitionName: 'fade',
    confirmLoading: false,
    visible: false,
    okType: 'primary',
  }),
  setup(props, { emit, slots, attrs }) {
  }
})

这里 props 的声明类似于 button 组件的源码,前文已经解析过了,这里不再赘述。

  1. setup 主体内容
const [locale] = useLocaleReceiver('Modal');
const { prefixCls, rootPrefixCls, direction, getPopupContainer } = useConfigInject('modal', props);

useLocaleReceiver 方法可以获取到 defaultLocaleData 和注入的 localeData,组件中使用到的是 okText 和 cancelText

  1. 渲染函数底部插槽
const handleCancel = (e: MouseEvent) => { // 取消后触发事件
  emit('update:visible', false);
  emit('cancel', e);
  emit('change', false);
};

const handleOk = (e: MouseEvent) => {  // 确定后触发事件
  emit('ok', e);
};

const renderFooter = () => {
  const {
    okText = slots.okText?.(),
    okType,
    cancelText = slots.cancelText?.(),
    confirmLoading,
  } = props;
  return (
    <>
      <Button onClick={handleCancel} {...props.cancelButtonProps}>
        {cancelText || locale.value.cancelText}
      </Button>
      <Button
        {...convertLegacyProps(okType)}
        loading={confirmLoading}
        onClick={handleOk}
        {...props.okButtonProps}
      >
        {okText || locale.value.okText}
      </Button>
    </>
  );
};

convertLegacyProps 可以转换遗留的 props,如果 type 是 danger 返回 {danger:true},否则返回 {type}

第二部分 DialogWrap 组件

  1. 主要的功能

if (getContainer === false) {
  return (
    <Dialog
      {...dialogProps}
      getOpenCount={() => 2} // 不对 body 做任何操作。。
      v-slots={slots}
    ></Dialog>
  );
}

// Destroy on close will remove wrapped div
if (!forceRender && destroyOnClose && !animatedVisible.value) {
  return null;
}
return (
  <Portal
    visible={visible}
    forceRender={forceRender}
    getContainer={getContainer}
    v-slots={{
      default: (childProps: IDialogChildProps) => {
        dialogProps = {
          ...dialogProps,
          ...childProps,
          afterClose: () => {
            afterClose?.();
            animatedVisible.value = false;
          },
        };
        return <Dialog {...dialogProps} v-slots={slots}></Dialog>;
      },
    }}
  />
);

该组件的核心逻辑在渲染函数中,从代码可以看出分成了三种情况。getContainer 方法是 false 的时候直接渲染 Dialog 组件,!forceRender && destroyOnClose && !animatedVisible.value 为 true 的时候返回 null,否则返回 Portal 组件包裹的 Dialog 组件

第三部分 Dialog 组件

  1. 渲染函数分析
<div class={[`${prefixCls}-root`, rootClassName]} {...pickAttrs(props, { data: true })}>
  <Mask
    prefixCls={prefixCls}
    visible={mask && visible}
    motionName={getMotionName(prefixCls, maskTransitionName, maskAnimation)}
    style={{
      zIndex,
      ...maskStyle,
    }}
    maskProps={maskProps}
  />
  <div
    tabIndex={-1}
    onKeydown={onWrapperKeyDown}
    class={classNames(`${prefixCls}-wrap`, wrapClassName)}
    ref={wrapperRef}
    onClick={onWrapperClick}
    role="dialog"
    aria-labelledby={title ? ariaIdRef.value : null}
    style={{ zIndex, ...wrapStyle, display: !animatedVisible.value ? 'none' : null }}
    {...wrapProps}
  >
    <Content
      {...omit(props, ['scrollLocker'])}
      style={style}
      class={className}
      v-slots={slots}
      onMousedown={onContentMouseDown}
      onMouseup={onContentMouseUp}
      ref={contentRef}
      closable={closable}
      ariaId={ariaIdRef.value}
      prefixCls={prefixCls}
      visible={visible}
      onClose={onInternalClose}
      onVisibleChanged={onDialogVisibleChanged}
      motionName={getMotionName(prefixCls, transitionName, animation)}
    />
  </div>
</div>;

从源码可以看出,整体结构非常清晰。有一个 div 包含 Mask 组件和另一个 div ,而另一个 div 里面还包含 Content 组件。

pickAttrs 方法用于挑选出符合条件的 props ,这里用来获取以data-开头的 props 。

getMotionName 方法在没有 transitionName 但是有 animationName 则得到 ${prefixCls}-${animationName},否则获取到 transitionName

omit 方法遍历 props ,剔除存在的 scrollLocker 属性

  1. onWrapperKeyDown 方法分析
const onWrapperKeyDown = (e: KeyboardEvent) => {
  if (props.keyboard && e.keyCode === KeyCode.ESC) {
    e.stopPropagation();
    onInternalClose(e);
    return;
  }

  // keep focus inside dialog
  if (props.visible) {
    if (e.keyCode === KeyCode.TAB) {
      contentRef.value.changeActive(!e.shiftKey);
    }
  }
};

const onInternalClose = (e: MouseEvent | KeyboardEvent) => {
  props.onClose?.(e);
};

如果设置了 keyboard 属性,用户按下 ESC 键后即可关闭 modal 弹框。而如果 modal 打开的时候按下 TAB 键即可即可切换激活的内容。

  1. onWrapperClick 方法分析
const onWrapperClick = (e: MouseEvent) => {
  if (!props.maskClosable) return null;
  if (contentClickRef.value) {
    contentClickRef.value = false;
  } else if (wrapperRef.value === e.target) {
    onInternalClose(e);
  }
};

鼠标点击页面后,如果 maskClosable 为 false 则不做任何处理,如果点击到内容区域则将 contentClickRef 设置为 false,如果点击到弹框外部区域则关闭弹框。

第四部分 Mask 组件

  1. 渲染组件
return () => {
  const { prefixCls, visible, maskProps, motionName } = props;
  const transitionProps = getTransitionProps(motionName);
  return (
    <Transition {...transitionProps}>
      <div v-show={visible} class={`${prefixCls}-mask`} {...maskProps} />
    </Transition>
  );
};

该组件整体比较简单,其中 getTransitionProps 方法将 motionName 组合成过渡类名。

第五部分 Content 组件

  1. 渲染函数部分
let headerNode: any;
if (title) {
  headerNode = (
    <div class={`${prefixCls}-header`}>
      <div class={`${prefixCls}-title`} id={ariaId}>
        {title}
      </div>
    </div>
  );
}

let closer: any;
if (closable) {
  closer = (
    <button type="button" onClick={onClose} aria-label="Close" class={`${prefixCls}-close`}>
      {closeIcon || <span class={`${prefixCls}-close-x`} />}
    </button>
  );
}

const content = (
  <div class={`${prefixCls}-content`}>
    {closer}
    {headerNode}
    <div class={`${prefixCls}-body`} style={bodyStyle} {...bodyProps}>
      {slots.default?.()}
    </div>
    {footerNode}
  </div>
);

...

<Transition
    {...transitionProps}
    onBeforeEnter={onPrepare}
    onAfterEnter={() => onVisibleChanged(true)}
    onAfterLeave={() => onVisibleChanged(false)}
>
    {visible || !destroyOnClose ? (
    <div
        {...attrs}
        ref={dialogRef}
        v-show={visible}
        key="dialog-element"
        role="document"
        style={[contentStyleRef.value, attrs.style as CSSProperties]}
        class={[prefixCls, attrs.class]}
        onMousedown={onMousedown}
        onMouseup={onMouseup}
    >
        <div tabindex={0} ref={sentinelStartRef} style={sentinelStyle} aria-hidden="true" />
        {modalRender ? modalRender({ originVNode: content }) : content}
        <div tabindex={0} ref={sentinelEndRef} style={sentinelStyle} aria-hidden="true" />
    </div>
    ) : null}
</Transition>

这里主要使用 Transition 组件包裹,如果 visible || !destroyOnClose 为 true 的时候会显示主体的内容。通过判断 modalRender 是否有值来显示内容 content,而 content 包含 closer,headerNode 和 footerNode。

  1. onPrepare 函数
const onPrepare = () => {
  nextTick(() => {
    if (dialogRef.value) {
      const elementOffset = offset(dialogRef.value);
      transformOrigin.value = props.mousePosition
        ? `${props.mousePosition.x - elementOffset.left}px ${
            props.mousePosition.y - elementOffset.top
          }px`
        : '';
    }
  });
};

offset 函数获取 dialogRef.value 相对顶层对象的偏移值 elementOffset,把鼠标当前位置和 elementOffset 的相对位置作为 transformOrigin 的值。

第六部分 Modal.method()

这个方法包含五种形式,分别是 Modal.success(),Modal.error(),,Modal.info(),Modal.confirm(),Modal.warning(),这里以 Modal.confirm() 为例。

  1. confirm 组件
Modal.confirm = function confirmFn(props: ModalFuncProps) {
  return confirm(withConfirm(props));
};

export function withConfirm(props: ModalFuncProps):  ModalFuncProps {     // withConfirm 返回配置
  return {
    icon: () => <ExclamationCircleOutlined />,
    okCancel: true,
    ...props,
    type: 'confirm',
  };
}

const confirm = (config: ModalFuncProps) => {
  const container = document.createDocumentFragment();
  let currentConfig = {
    ...omit(config, ['parentContext', 'appContext']),
    close,
    visible: true,
  } as any;
  let confirmDialogInstance = null;
  function destroy(...args: any[]) {
    if (confirmDialogInstance) {
      // destroy
      vueRender(null, container as any);
      confirmDialogInstance.component.update();
      confirmDialogInstance = null;
    }
    const triggerCancel = args.some(param => param && param.triggerCancel);
    if (config.onCancel && triggerCancel) {
      config.onCancel(...args);
    }
    for (let i = 0; i < destroyFns.length; i++) {
      const fn = destroyFns[i];
      if (fn === close) {
        destroyFns.splice(i, 1);
        break;
      }
    }
  }

  function close(this: typeof close, ...args: any[]) {
    currentConfig = {
      ...currentConfig,
      visible: false,
      afterClose: () => {
        if (typeof config.afterClose === 'function') {
          config.afterClose();
        }
        destroy.apply(this, args);
      },
    };
    update(currentConfig);
  }
  function update(configUpdate: ConfigUpdate) {
    if (typeof configUpdate === 'function') {
      currentConfig = configUpdate(currentConfig);
    } else {
      currentConfig = {
        ...currentConfig,
        ...configUpdate,
      };
    }
    if (confirmDialogInstance) {
      Object.assign(confirmDialogInstance.component.props, currentConfig);
      confirmDialogInstance.component.update();
    }
  }

  const Wrapper = (p: ModalFuncProps) => {
    const global = globalConfigForApi;
    const rootPrefixCls = global.prefixCls;
    const prefixCls = p.prefixCls || `${rootPrefixCls}-modal`;
    return (
      <ConfigProvider {...(global as any)} notUpdateGlobalConfig={true} prefixCls={rootPrefixCls}>
        <ConfirmDialog {...p} rootPrefixCls={rootPrefixCls} prefixCls={prefixCls}></ConfirmDialog>
      </ConfigProvider>
    );
  };
  function render(props: ModalFuncProps) {
    const vm = createVNode(Wrapper, { ...props }); // 创建 VNode
    vm.appContext = config.parentContext || config.appContext || vm.appContext;
    vueRender(vm, container as any);
    return vm;
  }

  confirmDialogInstance = render(currentConfig);
  destroyFns.push(close);
  return {
    destroy: close,
    update,
  };
};

使用 render 函数创建有 Wrapper 函数的渲染函数,ConfigProvider 属于另外一个组件,用于增加全局配置,这里不进行详述。另外把 close 方法保存下来,该方法有 update 方法来修改配置的,另外还有 destroy 方法用于清除destroyFns。下面会分析 ConfirmDialog 组件。

  1. ConfirmDialog 组件
function renderSomeContent(someContent: any) {
  if (typeof someContent === 'function') {
    return someContent();
  }
  return someContent;
}

...

<Dialog
  prefixCls={prefixCls}
  class={classString}
  wrapClassName={classNames({ [`${contentPrefixCls}-centered`]: !!centered }, wrapClassName)}
  onCancel={e => close({ triggerCancel: true }, e)}
  visible={visible}
  title=""
  footer=""
  transitionName={getTransitionName(rootPrefixCls, 'zoom', props.transitionName)}
  maskTransitionName={getTransitionName(rootPrefixCls, 'fade', props.maskTransitionName)}
  mask={mask}
  maskClosable={maskClosable}
  maskStyle={maskStyle}
  style={style}
  bodyStyle={bodyStyle}
  width={width}
  zIndex={zIndex}
  afterClose={afterClose}
  keyboard={keyboard}
  centered={centered}
  getContainer={getContainer}
  closable={closable}
  closeIcon={closeIcon}
  modalRender={modalRender}
  focusTriggerAfterClose={focusTriggerAfterClose}
>
  <div class={`${contentPrefixCls}-body-wrapper`}>
    <div class={`${contentPrefixCls}-body`}>
      {renderSomeContent(icon)}
      {title === undefined ? null : (
        <span class={`${contentPrefixCls}-title`}>{renderSomeContent(title)}</span>
      )}
      <div class={`${contentPrefixCls}-content`}>{renderSomeContent(content)}</div>
    </div>
    <div class={`${contentPrefixCls}-btns`}>
      {cancelButton}
      <ActionButton
        type={okType}
        actionFn={onOk}
        close={close}
        autofocus={autoFocusButton === 'ok'}
        buttonProps={okButtonProps}
        prefixCls={`${rootPrefixCls}-btn`}
      >
        {okText}
      </ActionButton>
    </div>
  </div>
</Dialog>;

使用 renderSomeContent 函数显示 icon,title 和 content,另外包含 ActionButton 组件用于显示组件中的按钮

  1. ActionButton 组件
const handlePromiseOnOk = (returnValueOfOnOk?: PromiseLike<any>) => {
  const { close } = props;
  if (!isThenable(returnValueOfOnOk)) {
    return;
  }
  loading.value = true;
  returnValueOfOnOk!.then(
    (...args: any[]) => {
      if (!isDestroyed.value) {
        loading.value = false;
      }
      close(...args);
      clickedRef.value = false;
    },
    (e: Error) => {
      // Emit error when catch promise reject
      // eslint-disable-next-line no-console
      console.error(e);
      // See: https://github.com/ant-design/ant-design/issues/6183
      if (!isDestroyed.value) {
        loading.value = false;
      }
      clickedRef.value = false;
    },
  );
};

const onClick = (e: MouseEvent) => {
  const { actionFn, close = () => {} } = props;
  if (clickedRef.value) {
    return;
  }
  clickedRef.value = true;
  if (!actionFn) {
    close();
    return;
  }
  let returnValueOfOnOk;
  if (props.emitEvent) {
    returnValueOfOnOk = actionFn(e);
    if (props.quitOnNullishReturnValue && !isThenable(returnValueOfOnOk)) {
      clickedRef.value = false;
      close(e);
      return;
    }
  } else if (actionFn.length) {
    returnValueOfOnOk = actionFn(close);
    // https://github.com/ant-design/ant-design/issues/23358
    clickedRef.value = false;
  } else {
    returnValueOfOnOk = actionFn();
    if (!returnValueOfOnOk) {
      close();
      return;
    }
  }
  handlePromiseOnOk(returnValueOfOnOk);
};

该组件的核心处理逻辑在于 onClick 事件,执行 actionFn 函数的时候会使用 handlePromiseOnOk 函数来包裹处理。这样,当用户点击的时候会触发 loading,处理完后关闭 loading,从而防止中间出现用户频繁点击按钮造成问题。

第七部分 Modal.method()

Modal 组件整体来说是比较复杂的组件。整片文章使用了6个部分来讲。