likes
comments
collection
share

Element Plus - Message组件源码解析学习

作者站长头像
站长
· 阅读数 13

本文参加了由公众号@若川视野 发起的每周源码共读活动,      点击了解详情一起参与。

这是源码共读的第28期 | # vue react 小程序 message 组件

前言

平常业务开发多多少少都会用到如 Element-uiElement PLusAnt design 之类的组件库进行开发, 但是对于其中的实现都是不得其解.

如果出现需要自己手动封装或者二次封装的场景,了解其中的原理对于开发类似的功能来说也能如鱼得水.

出于学习的目的,我会着重将核心实现 和 具体流程 进行讲解,后面会再实现一个丐版Message组件供大家理解

源代码

Elment-plus Message

入口

element-plus\packages\components\message\index.ts

import { withInstallFunction } from '@element-plus/utils'

import Message from './src/method'

export const ElMessage = withInstallFunction(Message, '$message')
export default ElMessage

export * from './src/message'

withInstallFunction 函数

这个函数干的事情很简单,就是进行组件的注册

export const withInstallFunction = <T>(fn: T, name: string) => {
  ;(fn as SFCWithInstall<T>).install = (app: App) => {
    ;(fn as SFCInstallWithContext<T>)._context = app._context
    app.config.globalProperties[name] = fn
  }

  return fn as SFCInstallWithContext<T>
}

将其代码简化后再看就很好理解了

import Message from './src/method'
export const withInstallFunction = (fn, name) => {
    fn.install = (app) => {
    fn._context = app._context
    app.config.globalProperties[name] = fn
  }
  return fn
}

Message 的默认导出函数 包装install方法,用于Vue.use的组件注册

我们在代码中的import {Elmessage} from 'element-plus 获取的 也是这一处的导出

method.ts

element-plus\packages\components\message\src\method.ts

这个是我们的组件创建的核心文件, 也是我们在代码中调用ElMessage()的核心逻辑,它分为以下几个主要函数:

  1. normalizeOptions 处理用户传递的对象配置,和默认的参数进行合并,并且确定最终组件插入到页面上的位置,它的关键代码:
if (!normalized.appendTo) {
    //如果没传这个属性  则默认插到body
    normalized.appendTo = document.body
  } else if (isString(normalized.appendTo)) {
    //如果声明了字符 则在页面上去找这个元素
    let appendTo = document.querySelector<HTMLElement>(normalized.appendTo)

    // should fallback to default value with a warning
    //如果 声明的元素不存在 则警告并插到 body  兜底处理
    if (!isElement(appendTo)) {
      debugWarn(
        'ElMessage',
        'the appendTo option is not an HTMLElement. Falling back to document.body.'
      )
      appendTo = document.body
    }
    normalized.appendTo = appendTo
  }
  1. closeMessage 用于移除组件的实例,并且关闭对应组件在页面的显示
const closeMessage = (instance: MessageContext) => {
  const idx = instances.indexOf(instance)
  if (idx === -1) return
  //存在的话 从数组中移除这个实例
  instances.splice(idx, 1)
  //取出 close事件执行
  const { handler } = instance
  handler.close()
}
  1. message 核心函数,用于调用normalizeOptions 函数得到最终配置,调用createMessage 创建组件在页面上显示并将实例添加进instances
  2. createMessage 获取message.vue 组件 创建虚拟节点并传递配置到props,最终挂载到页面上显示

这里核心要理清楚message 以及 createMessage 函数

createMessage函数

//取出全局下的zIndex  每次创建实例都会 自增一次 保证层级不重复
  const { nextZIndex } = useZIndex()
  const id = `message_${seed++}`
  //取出用户定义的 关闭回调
  const userOnClose = options.onClose
  //容器  包裹 message组件
  const container = document.createElement('div')
  //格式化所有 属性, zIndex取 默认索引+1
  const props = {
    ...options,
    zIndex: nextZIndex() + options.zIndex,
    id,
    //vue组件内的transition before-leave钩子会触发这个函数 去移除实例并 隐藏组件
    onClose: () => {
      userOnClose?.()
      //先执行关闭回调 再执行 message的关闭和实例的移除
      //这里的instance 再还没被执行之前是未定义的, 等它被实际调用时 会去拿下面的instance
      closeMessage(instance)
    },
    //vue组件内的transition after-leave钩子会通过emit触发这个函数 将容器的DOM置空
    onDestroy: () => {
      render(null, container)
    },
  }

这一部分代码都比较好理解, 首先是取 递增的 zIndex值, 处理多个Message之间的层叠问题。 并且取出用户的关闭回调,最终定义了props 对象

其内部还增加了 onClose 以及 onDestroy 方法,这两个函数在组件内的 动画离开钩子前后都会被触发

这里要再说明下 render(null, container)的作用, 它在onClose 被执行后,紧接着组件内的动画钩子会执行 onDestroy 函数

render 渲染器函数接收两个参数,第一个参数为新的VNode节点,第二个参数是Container容器节点, 它会拿新的VNode节点和 Container容器节点内的VNode节点进行新旧比对,更新Container它内部的VNode节点

onDestroy 最终做的事情就是将容器内的 所有Message实例 清空,即页面上的HTML节点进行移除, 那么这里的这两个函数的作用就是:

  1. 执行用户的关闭回调
  2. 执行实例的删除以及组件隐藏
  3. 移除页面上的DOM 释放内存

先记得这些,后面再看message.vue 组件内的实现就可以理解这里函数的作用

const vnode = createVNode(
    MessageConstructor,
    props,
    isFunction(props.message) || isVNode(props.message)
      ? {
          default: isFunction(props.message)
            ? props.message
            : () => props.message,
        }
      : null
  )
  //没传递指向的话  默认是null
  vnode.appContext = context || message._context
  //render 函数会将 vnode作为新节点 和 contaiiner中的vnode作为旧节点 进行 对比更新, 这里通常是首次挂载
  //这里将message组件的VNODE通过render函数 渲染到 container中, 但container作为DOM容器用于承载 message组件但实际挂载时我们不会用到它
  render(vnode, container)
  //最终当组件执行close 移除了instance 和 组件的隐藏  之后会触发 onDestroy 会将container中的DOM销毁
  // instances will remove this item when close function gets called. So we do not need to worry about it.
  appendTo.appendChild(container.firstElementChild!)
  //执行到这里的时候 页面上就已经挂载Message组件了

MessageConstructor 即message组件本身, 通过Vue暴露的createVNode 函数,传递之前得到props对象传递到组件内部使用

需要注意第三个参数会处理 props对象中 不为文本类型的message值, 非文本的值会传递到 组件内的slot 插槽去响应。 因为你可以看到它最终的结果是一个 {default:xxx} 的对象,组件内使用了默认插槽去使用它

后面通过render 函数将其渲染到容器内,最终插入到了页面上

const vm = vnode.component!

  //handler 暴露close方法 调用后 会改变组件内的 visible属性值
  //exposed 即被手动暴露出来的属性或者方法
  const handler: MessageHandler = {
    // instead of calling the onClose function directly, setting this value so that we can have the full lifecycle
    // for out component, so that all closing steps will not be skipped.
    close: () => {
      vm.exposed!.visible.value = false
    },
  }
  //最终返回 instance实例 包含自增的ID, 组件渲染的VNode, 组件的实例component,handler方法,组件内部要接收的props
  const instance: MessageContext = {
    id, //自增的id
    vnode, //组件创建的 虚拟节点
    vm,  //组件本身
    handler, //包含 close方法的 handler
    props: (vnode.component as any).props,  //组件传递的props
  }

  return instance

message函数

message函数对应的就是 我们代码中 ElMessage函数 的调用

//判断浏览器环境
  if (!isClient) return { close: () => undefined }
  //默认没有max  如果有在全局配置或者  config-provider 注入到所有组件并配置了这个值 则生效
  //创建的 message 实例超出上限则不再创建
  if (isNumber(messageConfig.max) && instances.length >= messageConfig.max) {
    return { close: () => undefined }
  }
  //normalizeOptions 处理传进来的配置和默认配置进行合并 并确定message是插到body还是哪里
  const normalized = normalizeOptions(options)
  //如果用户定义 合并消息相同的message 并且当前实例数量大于0
  if (normalized.grouping && instances.length) {
    //寻找所有的实例 属性message 和 传入的 message一致的实例
    const instance = instances.find(
      ({ vnode: vm }) => vm.props?.message === normalized.message
    )
    //如果找到了消息相同的 取新传进来的 类型 并且自增自身的重复属性,并返回实例的handler
    if (instance) {
      instance.props.repeatNum += 1
      instance.props.type = normalized.type
      return instance.handler
    }
  }

这里主要看 grouping 部分,这里判断了 是否要合并相同消息的Message,如果开启了这个属性则 直接返回这个消息的实例,并让这个实例属性 repeatNum 自增, 它的自增会触发 组件内的 watch 函数 重新开启组件的定时器

const instance = createMessage(normalized, context) //创建实例

  //将实例push进我们的数组
  instances.push(instance)
  //返回实例的 handler方法

  //到这里就清楚了 import {ElMessage} from 'element-plus' 那取到的message方法, 调用该函数并传递配置属性
  //我们还可以手动去调用 ElMessage.closeAll() 方法,它会遍历所有实例去调用 close方法
  //返回一个包含close方法的对象,它会将组件内的 visable设为 false隐藏
  //剩下的就是 message vue组件内部的运行逻辑了
  return instance.handler

创建实例,将实例添加进 instances这个 浅层响应式的数组中,并将 handler 返回, 所以你如果调用函数之后,会得到一个包含close函数的对象

其他函数

//messageTypes 即[]['success', 'info', 'warning', 'error'], 它默认会去遍历所有type 然后 给 message函数 去挂载对应type的函数
//type函数最终逻辑 就是调用了一遍message函数然后返回一个message实例, 那么我们就可以通过 ElMessage.success() 这样的形式去指定创建的消息类型
messageTypes.forEach((type) => {
  message[type] = (options = {}, appContext) => {
    const normalized = normalizeOptions(options)
    return message({ ...normalized, type }, appContext)
  }
})

这里就是通过循环依次通过message函数的调用,给message添加对应Type 类型的属性, 所以你会发现可以通过这种形式去调用:

ElMessage.success('我成功了')
ElMessage.warning('我警告了')
ElMessage.info('我信息了')

instancne.ts

export const instances: MessageContext[] = shallowReactive([])

export const getInstance = (id: string) => {
  const idx = instances.findIndex((instance) => instance.id === id)
  const current = instances[idx]
  let prev: MessageContext | undefined
  if (idx > 0) {
    prev = instances[idx - 1]
  }
  return { current, prev }
}

export const getLastOffset = (id: string): number => {
  const { prev } = getInstance(id)
  if (!prev) return 0
  return prev.vm.exposed!.bottom.value
}

用于存储实例的instances 变量, 以及 用于获取实例 和 上一个实例的 两个函数,用于在创建了多个Message组件的情况下,当前组件获取上一个实例的 组件高度和偏移值,让当前Message的CSS属性 Top值偏移到对应的位置

vm.exposed 中记录了 组件内手动暴露出去的变量和方法,vm即之前 在创建实例时取出的vnode.component. 可以在组件内看到这一段代码:

defineExpose({
  visible,
  bottom,
  close,
})

message.ts

该文件主要是用到Message组件相关的所有类型声明,以及默认配置等

//...
export const messageDefaults = mutable({
  customClass: '',
  center: false,
  dangerouslyUseHTMLString: false,
  duration: 3000,
  icon: undefined,
  id: '',
  message: '',
  onClose: undefined,
  showClose: false,
  type: 'info',
  offset: 16,
  zIndex: 0,
  grouping: false,
  repeatNum: 1,
  appendTo: isClient ? document.body : (undefined as never),
} as const)
//...

message.vue

 <transition
    :name="ns.b('fade')"
    @before-leave="onClose"
    @after-leave="$emit('destroy')"
  >
  <div
      v-show="visible"
      :id="id"
      ref="messageRef"
      :class="[
        ns.b(),
        { [ns.m(type)]: type && !icon },
        ns.is('center', center),
        ns.is('closable', showClose),
        customClass,
      ]"
      :style="customStyle"
      role="alert"
      @mouseenter="clearTimer"
      @mouseleave="startTimer"
    >
    
   //...
   //unplugin-vue-macros 宏 用于设置组件名
defineOptions({
  name: 'ElMessage',
})

const props = defineProps(messageProps)
defineEmits(messageEmits)

组件的离开动画会执行 onClose 以及$emit('destroy') ,它们来自我们 定义的props以及 emits中, 展开去看messagePropsmessageEmits 可以看到这些关键定义:

//message.ts
export const messageProps = buildProps({
 //...
  onClose: {
    type: definePropType<() => void>(Function),
    required: false,
  },
 //...
} as const)
export type MessageProps = ExtractPropTypes<typeof messageProps>
//emits
export const messageEmits = {
  destroy: () => true,
}
export type MessageEmits = typeof messageEmits

它们则是在我们在 message 中 调用createMessage 函数创建实例时传递的 props的两个方法, 对应会做 移除实例和隐藏组件以及 移除页面上DOM的操作。

const messageRef = ref<HTMLDivElement>()
const visible = ref(false)
const height = ref(0)

let stopTimer: (() => void) | undefined = undefined

//根据type类型 决定 message中的 图标类型
const badgeType = computed<BadgeProps['type']>(() =>
  props.type ? (props.type === 'error' ? 'danger' : props.type) : 'info'
)
//生成对应type类型 即 success warning...  的  icon字符的类名
const typeClass = computed(() => {
  const type = props.type
  return { [ns.bm('icon', type)]: type && TypeComponentsMap[type] }
})
//根据类型决定图标组件或者 用户传递指定图标的组件名
const iconComponent = computed(
  () => props.icon || TypeComponentsMap[props.type] || ''
)

//getLastOffset(instance.ts) 会取出当前实例的上一个创建的实例 如果没有就取0 的偏移值 有的话 取上一个实例暴露的bottom值
const lastOffset = computed(() => getLastOffset(props.id))
//offset 取的是 用户传递的偏移值 +  上一个实例的bottom, 没有的话  默认偏移值是16 (message.ts messageDefaults对象)
const offset = computed(() => props.offset + lastOffset.value)
//bottom取的是 height值 + 偏移值
const bottom = computed((): number => height.value + offset.value)

//偏移值  层级
const customStyle = computed<CSSProperties>(() => ({
  top: `${offset.value}px`,
  zIndex: props.zIndex,
}))

这一块主要说明下 lastOffset、offset、bottom, 关键就是bottom ,它会被暴露出去用于在创建下一个Message组件时获取对应的偏移值, 它的计算公式是: 偏移值 + Message组件的高度

这里还需要关注的就是 对于height 的计算,它的变化从这个函数产生

//https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver
//ResizeObserver监听元素的内容的尺寸变化,触发变化会赋值到 height变量上触发更新
useResizeObserver(messageRef, () => {
  height.value = messageRef.value!.getBoundingClientRect().height
})

没有细看useResizeObserver内部的实现,但是MDN有关于 ResizeObserver 这个API的文档, 主要是监视的DOM元素 产生内容大小变化。 所以这里也是干这个事情,Message组件的高度尺寸发生变化变化时 赋值给 height

//再组件实例挂载后  根据用户传递或者默认的duration 决定close调用
function startTimer() {
  if (props.duration === 0) {
    return
  }
  // hook会返回一个 stop函数用于关闭定时器的执行 它会赋值到全局上的stopTimer变量
  ;({ stop: stopTimer } = useTimeoutFn(() => {
    close()
  }, props.duration))
}

function clearTimer() {
  stopTimer?.()
}

//隐藏组件的显示
function close() {
  visible.value = false
}
//监听 esc按下 触发close事件
function keydown({ code }: KeyboardEvent) {
  if (code === EVENT_CODE.esc) {
    // press esc to close the message
    close()
  }
}
//组件DOM挂载后 开启定时器 并显示容器
onMounted(() => {
  startTimer()
  visible.value = true
})

组件挂载后,调用startTimer 函数,并且让组件显示.这里的逻辑很好理解,根据props的duration值,决定多久后隐藏组件,一旦隐藏显示后,就会触发 动画钩子,即前面说过的 onClose 和 onDestory事件

这里使用了 useTimeoutFn ,它返回一个stop属性,这里赋值给了stopTimer 变量,在clearTimer函数中会调用它,很好理解 就是取消定时器,所以你接着看到这段watch函数代码的调用

watch(
  () => props.repeatNum,
  () => {
    clearTimer()
    startTimer()
  }
)

监听props中的repeatNum,前面有提到过它意味着如果开启了 grouping这个属性,如果创建实例的文本内容出现重复的情况下,会直接使用这个重复的实例,并让实例的 repeatNum属性自增。

所以这里就很好理解,用户开启了grouping属性,一旦出现重复内容的实例,不会创建新的实例,并被这个watch触发,执行关闭定时器再开启定时器的操作.

实现

不考虑细节以及CSS的实现,把主要核心的多实例、节点创建、Render函数使用 等实现出来

组件

<template>
	<Transition @before-leave="onClose" @after-leave="$emit('destroy')">
		<div
			class="message"
			v-show="visible"
			:id="id"
			ref="messageRef"
			:style="customStyle"
		>
			<p>{{ message }}</p>
		</div>
	</Transition>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from "vue";
import { getLastOffset, messageProps } from "./instance";

const messageRef = ref<HTMLDivElement>();
const props = defineProps(messageProps);

defineEmits(["destroy"]);

const visible = ref<boolean>(true);
const height = ref(0);

const lastOffset = computed(() => getLastOffset(props.id));
const offset = computed(() => 16 + lastOffset.value);
const bottom = computed(() => height.value + offset.value);
const customStyle = computed(() => {
	return {
		top: `${offset.value}px`,
	};
});

onMounted(() => {
	setTimeout(() => {
		visible.value = false;
	}, 3000);
	nextTick(() => {
		height.value = messageRef.value!.getBoundingClientRect().height;
	});
});

defineExpose({
	visible,
	bottom,
});
</script>
<style scoped>
.message {
	position: fixed;
	left: 50%;
	transform: translateX(-50%);
	background-color: #f0f9eb;
	color: #73c34b;
	padding: 6px 24px 6px 24px;
	border-radius: 8px;
}
.v-enter-active,
.v-leave-active {
	transition: opacity 0.5s ease;
}

.v-enter-from,
.v-leave-to {
	opacity: 0;
}
</style>

method.ts

import messageConstructor from "./message.vue";
import { createVNode, render } from "vue";
import { instances } from "./instance";

let seed = 1;

const createMessage = (message: string) => {
	let id = `Harexs-${seed++}`;
	const container = document.createElement("div");

	const props = {
		id,
		message,
		onClose: () => {
			instances.splice(instances.indexOf(instance) >>> 0, 1);
		},
		onDestroy: () => {
			render(null, container);
		},
	};
	const vnode = createVNode(messageConstructor, props, null);
	render(vnode, container);
	document.body.appendChild(container.firstElementChild!);

	const vm = vnode.component!;

	const handler = {
		close: () => {
			vm.exposed!.visible.value = false;
		},
	};

	const instance = {
		id,
		vnode,
		vm,
		handler,
		props,
	};
	return instance;
};

const message = (message: string) => {
	const instance = createMessage(message);
	instances.push(instance);
	return instance.handler;
};

message.closeAll = () => {
	instances.forEach((instance) => {
		instance.handler.close();
	});
};

export default message;

instance.ts

import type { MessageType } from "./types";
import { shallowReactive } from "vue";

export const instances: MessageType[] = shallowReactive([]);

export const getInstance = (id: string) => {
	const idx = instances.findIndex((instance) => instance.id === id);
	const current = instances[idx];
	let prev;
	if (idx > 0) {
		prev = instances[idx - 1];
	}
	return {
		prev,
		current,
	};
};

export const getLastOffset = (id: string) => {
	const { prev } = getInstance(id);
	if (!prev) return 0;
	return prev.vm.exposed!.bottom.value;
};

export const messageProps = {
	id: {
		type: String,
		default: () => "",
	},
	message: {
		type: String,
		default: () => "",
	},
	onClose: {
		type: Function,
		require: false,
	},
};

types.ts

import { VNode, ComponentInternalInstance } from "vue";

export type MessageType = {
	id: string;
	handler: { close: () => void };
	vnode: VNode;
	vm: ComponentInternalInstance;
	props: { onDestroy: () => void };
};

使用

import Message from "./components/message/method";
const handler = () => {
	Message("This is a message,Yes!");
};

总结

再来梳理下主要流程:

  1. 执行method.ts 中的 message函数, 它会调用normalizeOptions 函数去将用户的传递的对象和默认参数进行合并操作,并根据 grouping属性 以及当前实例的数量 决定是走创建新实例的逻辑,还是使用原有的实例 并将其repeatNum属性自增

  2. 如果进入创建新实例逻辑,调用createMessage 函数,创建VNode渲染到页面上, 然后将实例添加进instances 中, 最后返回一个包含 close 函数的对象

至此就分析完毕了,但TS类型还是不能看的太明白,感觉我的TS基础太薄弱了,平时也用的特别少。 其他都梳理了一遍,以此为契机按照这个节奏 把其他组件的实现也去梳理学习一下,还要加强TS的学习, 不管是在Vue的使用 还是组件开发上都 大有裨益!