1.前言
目前大多数博客没有完整的实现element-plus组件的Message组件,这里我将详细的分析Message的源码,以及完整实现Message组件所有功能,并且会分析Message组件中使用到的icon、badge组件(技术有限,欢迎讨论)
< 请注意看代码中的注释,都是我加上的 >
2.解析Message组件源码
首先我们需要去github将项目clone到我们的本地地址,方面我们进行查看。
git clone https://github.com/element-plus/element-plus.git
message组件的核心逻辑,从instance.ts、method.ts、message.ts、message.vue四个文件进行分析,也会介绍文件中使用到的hooks(useSpacename、useGlobalComponentSetting)、message组件和其他的组件有点不同、采用了动态添加dom的方式进行挂载到组件上。
1.message-message.ts源码
源码:elementplus—message组件**
源码
**`(message.ts),定义message组件上的所有属性,其他部分参数类型,api方法类型
//export const definePropType = <T>(val: any): PropType<T> => val
//export const iconPropType = definePropType<string | Component>([String, Object, Function])
//export const mutable = <T extends readonly any[] | Record<string, unknown>>(val: T) =>
// val as Mutable<typeof val>
//export type Mutable<T> = { -readonly [P in keyof T]: T[P] }
import {
buildProps, //将整个element组件的props进行规则化
definePropType,//定义一个返回值为PropType的类型的函数
iconPropType,//返回一个icon的类型函数
isClient, //是否是客户端
mutable,//将对象中所有属性定义成可变属性
} from '@element-plus/utils'
import type { AppContext, ExtractPropTypes, VNode } from 'vue'
import type { Mutable } from '@element-plus/utils'
import type MessageConstructor from './message.vue'
//message组件默认的四种类型
export const messageTypes = ['success', 'info', 'warning', 'error'] as const
export type messageType = typeof messageTypes[number]
//全局配置组件中,可以同时显示消息最大数量
export interface MessageConfigContext {
max?: number
}
//mutable函数将messageDefaults中得属性转换成可修改的属性
//messageDefaults是message组件的默认属性值
export const messageDefaults = mutable({
customClass: '',
center: false,
dangerouslyUseHTMLString: false,
duration: 3000,
icon: undefined,
id: '',
message: '',
onClose: undefined,
showClose: false,
type: 'info',
plain: false,
offset: 16,
zIndex: 0,
grouping: false,
repeatNum: 1,
appendTo: isClient ? document.body : (undefined as never),
} as const)
//buildProps函数将所有传入的props对象添加上合理的类型,为了更好的处理props对象类型
export const messageProps = buildProps({
/**
* @description custom class name for Message
*/
customClass: {
type: String,
default: messageDefaults.customClass,
},
/**
* @description whether to center the text
*/
center: {
type: Boolean,
default: messageDefaults.center,
},
/**
* @description whether `message` is treated as HTML string
*/
dangerouslyUseHTMLString: {
type: Boolean,
default: messageDefaults.dangerouslyUseHTMLString,
},
/**
* @description display duration, millisecond. If set to 0, it will not turn off automatically
*/
duration: {
type: Number,
default: messageDefaults.duration,
},
/**
* @description custom icon component, overrides `type`
*/
icon: {
type: iconPropType,
default: messageDefaults.icon,
},
/**
* @description message dom id
*/
id: {
type: String,
default: messageDefaults.id,
},
/**
* @description message text
*/
message: {
//将type类型转换成vue中的PropType类型,message的值可以是字符串、VNode和返回VNode的函数
type: definePropType<string | VNode | (() => VNode)>([
String,
Object,
Function,
]),
default: messageDefaults.message,
},
/**
* @description callback function when closed with the message instance as the parameter
*/
onClose: {
type: definePropType<() => void>(Function),
default: messageDefaults.onClose,
},
/**
* @description whether to show a close button
*/
showClose: {
type: Boolean,
default: messageDefaults.showClose,
},
/**
* @description message type
*/
type: {
type: String,
values: messageTypes,
default: messageDefaults.type,
},
/**
* @description whether message is plain
*/
plain: {
type: Boolean,
default: messageDefaults.plain,
},
/**
* @description set the distance to the top of viewport
*/
offset: {
type: Number,
default: messageDefaults.offset,
},
/**
* @description input box size
*/
zIndex: {
type: Number,
default: messageDefaults.zIndex,
},
/**
* @description merge messages with the same content, type of VNode message is not supported
*/
grouping: {
type: Boolean,
default: messageDefaults.grouping,
},
/**
* @description The number of repetitions, similar to badge, is used as the initial number when used with `grouping`
*/
repeatNum: {
type: Number,
default: messageDefaults.repeatNum,
},
} as const)
//自动推断出messageProps中属性的类型
export type MessageProps = ExtractPropTypes<typeof messageProps>
//子组件发送的事件
export const messageEmits = {
destroy: () => true,
}
//emit类型
export type MessageEmits = typeof messageEmits
//message组件实例类型
export type MessageInstance = InstanceType<typeof MessageConstructor>
//将messageProps中的属性去除id属性并合并appendTo根元素属性,并转成可选属性
export type MessageOptions = Partial<
Mutable<
Omit<MessageProps, 'id'> & {
appendTo?: HTMLElement | string
}
>
>
//消息组件的参数
export type MessageParams = MessageOptions | MessageOptions['message']
//规则化参数的类型
export type MessageParamsNormalized = Omit<MessageProps, 'id'> & {
/**
* @description set the root element for the message, default to `document.body`
*/
appendTo: HTMLElement
}
//去除type的message组件参数
export type MessageOptionsWithType = Omit<MessageOptions, 'type'>
//用于定义调用message的api方法的传入的参数,可以是对象也可以是message属性的值
export type MessageParamsWithType =
| MessageOptionsWithType
| MessageOptions['message']
//定义关闭message组件函数类型
export interface MessageHandler {
/**
* @description close the Message
*/
close: () => void
}
//合并closeAll和close函数类型
export type MessageFn = {
(options?: MessageParams, appContext?: null | AppContext): MessageHandler
closeAll(type?: messageType): void
}
//定义四种api方法类型
export type MessageTypedFn = (
options?: MessageParamsWithType, //这是message组件的api参数
appContext?: null | AppContext
) => MessageHandler
//message中所有的api函数
export interface Message extends MessageFn {
success: MessageTypedFn
warning: MessageTypedFn
info: MessageTypedFn
error: MessageTypedFn
}
2.message-method.ts源码
将传给组件的参数做出处理,主要是统一传入的参数是一整个对象****还是message**属性类型的值,判断是否传入appendTo属性,如果有则用传的值生成dom作为message根元素,如果没有则用document.body作为值。
生成自增id,配置props和vue构造函数来创建虚拟dom并挂载到新创建的div元素上,并重置虚拟dom的上下文。将生成的新dom添加到appendTo元素后面。最后返回一个instance实例。(id,handler,vm,props:vnode.component.props)。
判断如果有相同的message消息文字则检验是否需要合并消息,需要则显示badge合并条数且返回一个handler关闭函数。其他情况下添加到instances数组里,并返回handler函数。
定义关闭全部message组件函数,定义message组件api方法。
import { createVNode, render } from 'vue'
import {
debugWarn,
isClient,
isElement,
isFunction,
isNumber,
isString,
isVNode,
} from '@element-plus/utils'
import { messageConfig } from '@element-plus/components/config-provider'
import MessageConstructor from './message.vue'
import { messageDefaults, messageTypes } from './message'
import { instances } from './instance'
import type { MessageContext } from './instance'
import type { AppContext } from 'vue'
import type {
Message,
MessageFn,
MessageHandler,
MessageOptions,
MessageParams,
MessageParamsNormalized,
messageType,
} from './message'
let seed = 1
// TODO: Since Notify.ts is basically the same like this file. So we could do some encapsulation against them to reduce code duplication.
const normalizeOptions = (params?: MessageParams) => {
//如果只传了message的内容,将参数赋值给message ElMessage('ss'),否则返回一个完整参数
const options: MessageOptions =
!params || isString(params) || isVNode(params) || isFunction(params)
? { message: params }
: params
const normalized = {
...messageDefaults,//取参数和默认值的并集,目的是给规则后的参数加上appendTo属性
...options,
}
//根路径的处理
//判断是否传入了appendTo,如果没有就默认body, //如果传入了字符串就判断是否是元素,如果不是就警告并默认body
if (!normalized.appendTo) {
normalized.appendTo = document.body
} else if (isString(normalized.appendTo)) {
let appendTo = document.querySelector<HTMLElement>(normalized.appendTo)
// should fallback to default value with a warning
if (!isElement(appendTo)) {
debugWarn(
'ElMessage',
'the appendTo option is not an HTMLElement. Falling back to document.body.'
)
appendTo = document.body
}
normalized.appendTo = appendTo
}
return normalized as MessageParamsNormalized
}
//关闭对应的message
const closeMessage = (instance: MessageContext) => {
const idx = instances.indexOf(instance)
if (idx === -1) return
instances.splice(idx, 1)
const { handler } = instance
handler.close()
}
//创建message组件,并将虚拟dom渲染到真实dom上,并返回一个
const createMessage = (
{ appendTo, ...options }: MessageParamsNormalized,
context?: AppContext | null
): MessageContext => {
const id = `message_${seed++}`
const userOnClose = options.onClose
const container = document.createElement('div')
//重新组合所有的参数并赋值到props
const props = {
...options,
// now the zIndex will be used inside the message.vue component instead of here.
// zIndex: nextIndex() + options.zIndex
id,
onClose: () => {
userOnClose?.() //执行onClose回调函数
closeMessage(instance)
},
// clean message element preventing mem leak
onDestroy: () => {
// since the element is destroy, then the VNode should be collected by GC as well
// we do not want cause any mem leak because we have returned vm as a reference to users
// so that we manually set it to false.
render(null, container)
},
}
//创建虚拟节点,并将虚拟节点渲染到container上
const vnode = createVNode(
MessageConstructor, //将message.vue定义好的模板构造函数,为了美化message组件的
props,
isFunction(props.message) || isVNode(props.message)
? {
default: isFunction(props.message)
? props.message
: () => props.message,
}
: null
)
//重置虚拟节点的上下文
vnode.appContext = context || message._context
render(vnode, container)
// instances will remove this item when close function gets called. So we do not need to worry about it.
//将message组件挂载到appendTo根目录下
appendTo.appendChild(container.firstElementChild!)
const vm = vnode.component!
//为了保证完整的生命周期,不直接调用onClose函数,而是设置这个值,以便我们可以拥有完整的组件生命周期,所以所有的关闭步骤都不会被跳过。
const handler: MessageHandler = {
close: () => {
vm.exposed!.visible.value = false
},
}
//返回这个实例
const instance: MessageContext = {
id,
vnode,
vm,
handler,
props: (vnode.component as any).props,
}
return instance
}
//组件多次被创建时,相同内容时将内容合并次数累加,将instance追加到instances数组里,返回一个close方法,message类型满足MessageFn,Message类型
const message: MessageFn &
Partial<Message> & { _context: AppContext | null } = (
options = {},
context
) => {
if (!isClient) return { close: () => undefined }
if (isNumber(messageConfig.max) && instances.length >= messageConfig.max) {
return { close: () => undefined }
}
const normalized = normalizeOptions(options)
//合并相同消息并显示累加后的数字
if (normalized.grouping && instances.length) {
const instance = instances.find(
({ vnode: vm }) => vm.props?.message === normalized.message
)
if (instance) {
instance.props.repeatNum += 1
instance.props.type = normalized.type
return instance.handler
}
}
const instance = createMessage(normalized, context)
instances.push(instance)
return instance.handler
}
//定义四个api方法 message.success() message.error() .....
messageTypes.forEach((type) => {
message[type] = (options = {}, appContext) => {
const normalized = normalizeOptions(options)
return message({ ...normalized, type }, appContext)
}
})
//定义关闭全部message函数
export function closeAll(type?: messageType): void {
for (const instance of instances) {
if (!type || type === instance.props.type) {
instance.handler.close()
}
}
}
message.closeAll = closeAll
message._context = null
export default message as Message
3.messge-message.vue源码
动态计算message组件离窗口的距离,是否显示关闭图标、文字居中、背景色是否为纯色、css的index层级
显示message消息文字,是否是直接显示,还是以html片段显示。下面将详细介绍vue中使用的钩子
<template>
<transition //fz-message-fade
:name="ns.b('fade')" //ns.b()方法是用来生成类名的,具体代码可以去查看 //useGlobalComponentSettings函数
@before-leave="onClose"
@after-leave="$emit('destroy')"
>
<div
v-show="visible"
:id="id"
ref="messageRef"
:class="[
ns.b(),
{ [ns.m(type)]: type },
ns.is('center', center),
ns.is('closable', showClose),
ns.is('plain', plain),
customClass,
]"
:style="customStyle"
role="alert"
@mouseenter="clearTimer" //鼠标移入组件一直清除定时器,一直保持显示message
@mouseleave="startTimer" //移出时重新开始计时关闭message
>
<el-badge
v-if="repeatNum > 1"
:value="repeatNum"
:type="badgeType" //根据传入的type 动态生成badge中的type
:class="ns.e('badge')"
/>
<el-icon v-if="iconComponent" :class="[ns.e('icon'), typeClass]">
<component :is="iconComponent" /> //动态计算icon,如果有icon图标传入就显示,没有根据type匹配默认四种图标
</el-icon>
<slot>
<p v-if="!dangerouslyUseHTMLString" :class="ns.e('content')">
{{ message }} //渲染消息文字
</p>
<!-- Caution here, message could've been compromised, never use user's input as message -->
<p v-else :class="ns.e('content')" v-html="message" /> //可以传入html片段字符串
</slot>
<el-icon v-if="showClose" :class="ns.e('closeBtn')" @click.stop="close">
<Close />
</el-icon>
</div>
</transition>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useEventListener, useResizeObserver, useTimeoutFn } from '@vueuse/core'
import { TypeComponents, TypeComponentsMap } from '@element-plus/utils'
import { EVENT_CODE } from '@element-plus/constants'
import ElBadge from '@element-plus/components/badge'
import { useGlobalComponentSettings } from '@element-plus/components/config-provider'
import { ElIcon } from '@element-plus/components/icon'
import { messageEmits, messageProps } from './message'
import { getLastOffset, getOffsetOrSpace } from './instance'
import type { BadgeProps } from '@element-plus/components/badge'
import type { CSSProperties } from 'vue'
const { Close } = TypeComponents
defineOptions({
name: 'ElMessage',
})
const props = defineProps(messageProps)
defineEmits(messageEmits)
//下面有钩子的详细介绍
const { ns, zIndex } = useGlobalComponentSettings('message')
const { currentZIndex, nextZIndex } = zIndex
const messageRef = ref<HTMLDivElement>()
const visible = ref(false)
const height = ref(0)
let stopTimer: (() => void) | undefined = undefined
const badgeType = computed<BadgeProps['type']>(() =>
props.type ? (props.type === 'error' ? 'danger' : props.type) : 'info'
)
const typeClass = computed(() => {
const type = props.type
return { [ns.bm('icon', type)]: type && TypeComponentsMap[type] }
})
const iconComponent = computed(
() => props.icon || TypeComponentsMap[props.type] || ''
)
//获取到当前message组件上一个message离视口的距离
const lastOffset = computed(() => getLastOffset(props.id))
//根据计算获取到每个message组件距离视口的距离
const offset = computed(
() => getOffsetOrSpace(props.id, props.offset) + lastOffset.value
)
//记录上一个的offset的距离
const bottom = computed((): number => height.value + offset.value)
//赋值message的偏移量
const customStyle = computed<CSSProperties>(() => ({
top: `${offset.value}px`,
zIndex: currentZIndex.value,
}))
function startTimer() {
if (props.duration === 0) return
//useTimeoutFn() 函数将stop返回值赋值给stopTimer,返回start(开始定时器),stop(停止定时器),isPending(定时器是否再运行)
;({ stop: stopTimer } = useTimeoutFn(() => {
close()
}, props.duration))
}
function clearTimer() {
stopTimer?.()
}
function close() {
visible.value = false
}
//按下esc关闭message
function keydown({ code }: KeyboardEvent) {
if (code === EVENT_CODE.esc) {
// press esc to close the message
close()
}
}
onMounted(() => {
startTimer()
nextZIndex()
visible.value = true
})
//当reapetNum发送改变重新开始渲染message组件
watch(
() => props.repeatNum,
() => {
clearTimer()
startTimer()
}
)
useEventListener(document, 'keydown', keydown)
//获取组件的高度
useResizeObserver(messageRef, () => {
height.value = messageRef.value!.getBoundingClientRect().height
})
defineExpose({
visible,
bottom,
close,
})
</script>
4.hooks-useGlobalComponentSetting – useSpacename
useGlobalComponentSetting
函数和useSpacename
钩子函数详细介绍。
- useSpacename用于生成组件的各种class类名。
- useGlobalComponentSetting用于处理全局的ns、local、zIndex和size。
4.1useSpacename
钩子函数解析:
用来生成各种vue模板中使用的class类。
import { computed, getCurrentInstance, inject, ref, unref } from 'vue'
import type { InjectionKey, Ref } from 'vue'
export const defaultNamespace = 'el'
const statePrefix = 'is-'
//生成对应的class类字符串
const _bem = (
namespace: string,
block: string,
blockSuffix: string,
element: string,
modifier: string
) => {
let cls = `${namespace}-${block}`
if (blockSuffix) {
cls += `-${blockSuffix}`
}
if (element) {
cls += `__${element}`
}
if (modifier) {
cls += `--${modifier}`
}
return cls
}
//定义namespace依赖注入的键
export const namespaceContextKey: InjectionKey<Ref<string | undefined>> =
Symbol('namespaceContextKey')
//获取更优先的namespace命名空间
export const useGetDerivedNamespace = (
namespaceOverrides?: Ref<string | undefined>
) => {
//如果有传值namespace为传来的值,如果没传值,判断是否有当前实例,获取键下所对应的namespace,默认值为defaultNamespace
const derivedNamespace =
namespaceOverrides ||
(getCurrentInstance()
? inject(namespaceContextKey, ref(defaultNamespace))
: ref(defaultNamespace))
//返回一个最优先获取的namespace或者默认值
const namespace = computed(() => {
return unref(derivedNamespace) || defaultNamespace
})
return namespace
}
export const useNamespace = (
block: string,
namespaceOverrides?: Ref<string | undefined>
) => {
const namespace = useGetDerivedNamespace(namespaceOverrides)
//对应上面的_bem函数,useNamespace('message').b('xx') =>el-message-xx
const b = (blockSuffix = '') =>
_bem(namespace.value, block, blockSuffix, '', '')
//useNamespace('message').e('content') =>el-message__content
const e = (element?: string) =>
element ? _bem(namespace.value, block, '', element, '') : ''
//useNamespace('message').m('success') =>el-message--success
const m = (modifier?: string) =>
modifier ? _bem(namespace.value, block, '', '', modifier) : ''
//useNamespace('message').be('xx','content') =>el-message-xx__content
const be = (blockSuffix?: string, element?: string) =>
blockSuffix && element
? _bem(namespace.value, block, blockSuffix, element, '')
: ''
//useNamespace('message').em('content','success') =>el-message__content--success
const em = (element?: string, modifier?: string) =>
element && modifier
? _bem(namespace.value, block, '', element, modifier)
: ''
//useNamespace('message').bm('xx','success') =>el-message-xx--success
const bm = (blockSuffix?: string, modifier?: string) =>
blockSuffix && modifier
? _bem(namespace.value, block, blockSuffix, '', modifier)
: ''
//useNamespace('message').be('xx','content','success') =>el-message-xx__content--success
const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
blockSuffix && element && modifier
? _bem(namespace.value, block, blockSuffix, element, modifier)
: ''
//namespace('message').is('plain',true)=> is-plain
const is: {
(name: string, state: boolean | undefined): string
(name: string): string
} = (name: string, ...args: [boolean | undefined] | []) => {
const state = args.length >= 1 ? args[0]! : true
return name && state ? `${statePrefix}${name}` : ''
}
// for css var
// --el-xxx: value;
const cssVar = (object: Record<string, string>) => {
const styles: Record<string, string> = {}
for (const key in object) {
if (object[key]) {
styles[`--${namespace.value}-${key}`] = object[key]
}
}
return styles
}
// with block
//--el-block-xx:value
const cssVarBlock = (object: Record<string, string>) => {
const styles: Record<string, string> = {}
for (const key in object) {
if (object[key]) {
styles[`--${namespace.value}-${block}-${key}`] = object[key]
}
}
return styles
}
const cssVarName = (name: string) => `--${namespace.value}-${name}`
const cssVarBlockName = (name: string) =>
`--${namespace.value}-${block}-${name}`
return {
namespace,
b,
e,
m,
be,
em,
bm,
bem,
is,
// css
cssVar,
cssVarName,
cssVarBlock,
cssVarBlockName,
}
}
export type UseNamespaceReturn = ReturnType<typeof useNamespace>
4.2.useGlobalComponentSetting
钩子函数解析:
第一个函数签名 useGlobalConfig用来获取特定配置项:这个函数签名可以传入一个键和一个默认可选值来获取全局配置中的配置项。
//const theme = useGlobalConfig('theme', 'light'); // theme 是 Ref<string> 类型
//console.log(theme.value); // 输出: "light"(如果全局配置中没有定义 'theme')
const globalConfig: ConfigProviderContext = {
theme: 'dark',
language: 'fr',
pageSize: 20,
};
const theme = useGlobalConfig('theme', 'light'); // theme 是 Ref<string> 类型
console.log(theme.value); // 输出: "dark"
第二个函数签名useGlobalConfig用来获取整个的配置对象
const config = useGlobalConfig(); // config 是 Ref<ConfigProviderContext> 类型
console.log(config.value); // 输出: { theme: "dark", language: "fr", pageSize: 20 }
const globalConfig = ref<ConfigProviderContext>() //ConfigProviderContext全局配置项的props类型
<第一个函数签名>
export function useGlobalConfig<
K extends keyof ConfigProviderContext,
D extends ConfigProviderContext[K]
>(
key: K,
defaultValue?: D
): Ref<Exclude<ConfigProviderContext[K], undefined> | D>
<第二个函数签名>
export function useGlobalConfig(): Ref<ConfigProviderContext>
//具体的函数实现,有键就获取键对应的配置项,无键就获取全部配置项
export function useGlobalConfig(
key?: keyof ConfigProviderContext,
defaultValue = undefined
) {
const config = getCurrentInstance()
? inject(configProviderContextKey, globalConfig)
: globalConfig
if (key) {
return computed(() => config.value?.[key] ?? defaultValue)
} else {
return config
}
}
export function useGlobalConfig<
K extends keyof ConfigProviderContext,
D extends ConfigProviderContext[K]
>(
key: K,
defaultValue?: D
): Ref<Exclude<ConfigProviderContext[K], undefined> | D>
export function useGlobalConfig(): Ref<ConfigProviderContext>
export function useGlobalConfig(
key?: keyof ConfigProviderContext,
defaultValue = undefined
) {
const config = getCurrentInstance()
? inject(configProviderContextKey, globalConfig)
: globalConfig
if (key) {
return computed(() => config.value?.[key] ?? defaultValue)
} else {
return config
}
}
//useGlobalComponentSettings 函数为组件提供了统一的全局设置,确保组件在整个应用中具有一致的配置和行为。
// for components like `ElMessage` `ElNotification` `ElMessageBox`.
export function useGlobalComponentSettings(
block: string,
sizeFallback?: MaybeRef<ConfigProviderContext['size']>
) {
const config = useGlobalConfig()
//获取命名空间
const ns = useNamespace(
block,
computed(() => config.value?.namespace || defaultNamespace)
)
//获取语言环境配置
const locale = useLocale(computed(() => config.value?.locale))
const zIndex = useZIndex(
computed(() => config.value?.zIndex || defaultInitialZIndex)
)
//全局尺寸大小
const size = computed(() => unref(sizeFallback) || config.value?.size || '')
provideGlobalConfig(computed(() => unref(config) || {})) //注册所有的全局配置数据
return {
ns,
locale,
zIndex,
size,
}
}
import { shallowReactive } from 'vue'
import type { ComponentInternalInstance, VNode } from 'vue'
import type { Mutable } from '@element-plus/utils'
import type { MessageHandler, MessageProps } from './message'
export type MessageContext = {
id: string
vnode: VNode
handler: MessageHandler
vm: ComponentInternalInstance
props: Mutable<MessageProps>
}
//instances不需要数组里面的对象进行响应式所以使用shallowReactive()
export const instances: MessageContext[] = shallowReactive([])
//获取当前的instance实例的上一个和下一个实例
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 }
}
//获取已存在的instance离视口的总高度
export const getLastOffset = (id: string): number => {
const { prev } = getInstance(id)
if (!prev) return 0
return prev.vm.exposed!.bottom.value
}
//每个instance初始的距离也接受传值
export const getOffsetOrSpace = (id: string, offset: number) => {
const idx = instances.findIndex((instance) => instance.id === id)
return idx > 0 ? 16 : offset
}
3.Message组件的具体实现
我先向你介绍项目中的Message组件使用到的scss文件,里面包含组件内scss的mixin、function使用。(如果不会scss的,建议去查找一下相关资料)
scss实现
elementplus组件的极致css类封装,只能感叹一声实在是优雅。
elementplus中的组件scss文件都在packages\theme-chalk\src目录下。
1.mixins/config.scss
全局使用到的变量名
$namespace: 'fz' !default;
$common-separator: '-' !default;
$modifier-separator: '--' !default;
$element-separator: '__' !default;
$state-prefix: 'is-' !default;
2.mixins/function.scss
用来生成css变量名、校验css类名是否包含(is这个前缀、-- 这个分割符、: 这个伪类标识)和生成一个字符串。
@use './config.scss' as *; //导入全局变量文件
//将$selector转成字符串,且截取从2,到倒数第二个字符并将其返回
@function selectorToString($selector) {
$selector: inspect($selector);
$selector: str-slice($selector, 2, -2);
@return $selector;
}
//判断$selector中是否包含$modifier-separator变量
@function containsModifier($selector) {
$selector: selectorToString($selector);
@if str-index($selector, $modifier-separator) {
@return true;
} @else {
@return false;
}
}
//判断$selector中是否包含is这个前缀
@function containWhenFlag($selector) {
$selector: selectorToString($selector);
@if str-index($selector, '.' + $state-prefix) {
@return true;
} @else {
@return false;
}
}
//判断类中是否包含伪类
@function containPseudoClass($selector) {
$selector: selectorToString($selector);
@if str-index($selector, ':') {
@return true;
} @else {
@return false;
}
}
@function hitAllSpecialNestRule($selector) {
@return containsModifier($selector) or containWhenFlag($selector) or containPseudoClass($selector);
}
//var()定义变量
@function joinVarName($list) {
$name: '--' + $namespace;
@each $item in $list {
@if $item != '' {
$name: $name + '-' + $item;
}
}
@return $name;
}
// getCssVarName('button', 'text-color') => '--fz-button-text-color'
@function getCssVarName($args...) {
@return joinVarName($args);
}
//getCssVar('button','text-color') var(--fz-button-text-color)
@function getCssVar($args...) {
@return var(#{joinVarName($args)});
}
3.common/var.scss
定义所有类名所对应的值。定义它们所对应的变量
@use 'sass:map';
@use 'sass:math';
@use '../mixins/function.scss' as *;
$types: primary, success, warning, danger, error, info;
//Color
$colors: () !default;
$colors: map.deep-merge(
(
'white': #ffffff,
'black': #000000,
'primary': (
'base': #409eff
),
'success': (
'base': #67c23a
),
'warning': (
'base': #e6a23c
),
'danger': (
'base': #f56c6c
),
'error': (
'base': #f56c6c
),
'info': (
'base': #909399
)
),
$colors
);
$color-white: map.get($colors, 'white') !default;
$color-black: map.get($colors, 'black') !default;
$color-primary: map.get($colors, 'primary', 'base') !default;
$color-success: map.get($colors, 'success', 'base') !default;
$color-warning: map.get($colors, 'warning', 'base') !default;
$color-danger: map.get($colors, 'danger', 'base') !default;
$color-error: map.get($colors, 'error', 'base') !default;
$color-info: map.get($colors, 'info', 'base') !default;
@mixin set-color-mix-level($type, $number, $mode: 'light', $mix-color: $color-white) {
$colors: map.deep-merge(
(
$type: (
'#{$mode}-#{$number}':
mix($mix-color, map.get($colors, $type, 'base'), math.percentage(math.div($number, 10)))
)
),
$colors
) !global;
}
// $colors.primary.light-i
// --fz-color-primary-light-i
// 10% 53a8ff
// 20% 66b1ff
// 30% 79bbff
// 40% 8cc5ff
// 50% a0cfff
// 60% b3d8ff
// 70% c6e2ff
// 80% d9ecff
// 90% ecf5ff
@each $type in $types {
@for $i from 1 through 9 {
@include set-color-mix-level($type, $i, 'light', $color-white);
}
}
$text-color: () !default;
$text-color: map.merge(
(
'primary': #303133,
'regular': #606266,
'secondary': #909399,
'placeholder': #a8abb2,
'disabled': #c0c4cc
),
$text-color
);
$font-size: () !default;
$font-size: map.merge(
(
'extra-large': 20px,
'large': 18px,
'medium': 16px,
'base': 14px,
'small': 13px,
'extra-small': 12px
),
$font-size
);
// Background
$bg-color: () !default;
$bg-color: map.merge(
(
'': #ffffff,
'page': #f2f3f5,
'overlay': #ffffff
),
$bg-color
);
$border-color: () !default;
$border-color: map.merge(
(
'': #dcdfe6,
'light': #e4e7ed,
'lighter': #ebeef5,
'extra-light': #f2f6fc,
'dark': #d4d7de,
'darker': #cdd0d6
),
$border-color
);
// Border
$border-width: 1px !default;
$border-style: solid !default;
$border-color-hover: getCssVar('text-color', 'disabled') !default;
$border-radius: () !default;
$border-radius: map.merge(
(
'base': 4px,
'small': 2px,
'round': 20px,
'circle': 100%
),
$border-radius
);
// Box-shadow
$box-shadow: () !default;
$box-shadow: map.merge(
(
'': (
0px 12px 32px 4px rgba(0, 0, 0, 0.04),
0px 8px 20px rgba(0, 0, 0, 0.08)
),
'light': (
0px 0px 12px rgba(0, 0, 0, 0.12)
),
'lighter': (
0px 0px 6px rgba(0, 0, 0, 0.12)
),
'dark': (
0px 16px 48px 16px rgba(0, 0, 0, 0.08),
0px 12px 32px rgba(0, 0, 0, 0.12),
0px 8px 16px -8px rgba(0, 0, 0, 0.16)
)
),
$box-shadow
);
//transition-duration
$transition-duration: () !default;
$transition-duration: map.merge(
(
'': 0.3s,
'fast': 0.2s
),
$transition-duration
);
// Badge
// css3 var in packages/theme-chalk/src/badge.scss
$badge: () !default;
$badge: map.merge(
(
'bg-color': getCssVar('color-danger'),
'radius': 10px,
'font-size': 12px,
'padding': 6px,
'size': 18px
),
$badge
);
//message
$message: () !default;
$message: map.merge(
(
'bg-color': getCssVar('color', 'info', 'light-9'),
'border-color': getCssVar('border-color-lighter'),
'padding': 11px 15px,
'close-size': 16px,
'close-icon-color': getCssVar('text-color-placeholder'),
'close-hover-color': getCssVar('text-color-secondary')
),
$message
);
4.mixins/mixins.scss
用于生成块类名(常再定义一个组件的祖先类名中使用)、根据判断生成同级的类名并还原类名中的嵌套、生成同级的带有
--
分割符的类名、生成带有&.is_前缀的类名。
@use './function.scss' as *;
@use './config.scss' as *;
$E: null !default;
$B: null !default;
//定义块类 @include('button'){color:red;font-size:14px} .fz-button{ color:red;font-size:14px }
@mixin b($block) {
$B: $namespace + $common-separator + $block !global;
.#{$B} {
@content;
}
}
// 根据块级命名 $B 和元素分隔符 $element-separator 生成特定格式的选择器字符串。
// 检查是否符合某个特殊的嵌套规则,如果符合则嵌套原始选择器,否则直接使用生成的选择器。
// 包含传入的内容块 @content。
//@include e(content){
// padding: 0;
// font-size: 14px;
// line-height: 1;
// &:focus {
// outline-width: 0;
// }
//}
// __content{padding:0;font-size:14px;line-height:1;&:focus{outline-width:0}
@mixin e($element) {
$E: $element !global;
$selector: &;
$currentSelector: '';
@each $unit in $element {
$currentSelector: #{$currentSelector + '.' + $B + $element-separator + $unit + ','};
}
@if hitAllSpecialNestRule($selector) {
@at-root {
#{$selector} {
#{$currentSelector} {
@content;
}
}
}
} @else {
@at-root {
#{$currentSelector} {
@content;
}
}
}
}
// 根据块级命名 $B 和元素分隔符 $element-separator 生成特定格式的选择器字符串。
// 包含传入的内容块 @content。, @include m(success,primary){ xx:xx }=>&--success,&--primary{xx:xx}
@mixin m($modifier) {
$selector: &;
$currentSelector: '';
@each $unit in $modifier {
$currentSelector: #{$currentSelector + $selector + $modifier-separator + $unit + ','};
}
@at-root {
#{$currentSelector} {
@content;
}
}
}
//全局生成&.is_#${state}的类名
@mixin when($state) {
@at-root {
&.#{$state-prefix + $state} {
@content;
}
}
}
5.mixins/_var.scss
给所有定义的全局类名变量赋值。
颜色
、类型
、某个变量
@use 'sass:map';
@use './function.scss' as *;
@use '../common/var.scss' as *;
// @include css-var-from-global(('button', 'text-color'), ('color', $type))
// --el-button-text-color: var(--el-color-#{$type});
@mixin css-var-from-global($var, $gVar) {
$varName: joinVarName($var);
$gVarName: joinVarName($gVar);
#{$varName}: var(#{$gVarName});
}
//@include set-css-var-value('color-white',$color-white)
//--fz-color-white:#fff;
@mixin set-css-var-value($name, $value) {
#{joinVarName($name)}: #{$value};
}
// @include set-css-var-type('color', 'primary', $map);
// --fz-color-primary: #{map.get($map, 'primary')}; //用于给某个变量的某个类型赋值
@mixin set-css-var-type($name, $type, $variables) {
#{getCssVarName($name, $type)}: #{map.get($variables, $type)};
}
//@include set-css-color-type($color,$type) //type='success'
//--fz-color-success:#map.get($colors, success, 'base')
//--fz-color-success-light-3:#{map.get($colors, $type, 'light-3')}
//--fz-color-success-light-5:#{map.get($colors, $type, 'light-5')}
//--fz-color-success-light-7:#{map.get($colors, $type, 'light-7')}
//--fz-color-success-light-8:#{map.get($colors, $type, 'light-8')}
//--fz-color-success-light-9:#{map.get($colors, $type, 'light-9')}
@mixin set-css-color-type($colors, $type) {
@include set-css-var-value(('color', $type), map.get($colors, $type, 'base'));
@each $i in (3, 5, 7, 8, 9) {
@include set-css-var-value(
('color', $type, 'light', $i),
map.get($colors, $type, 'light-#{$i}')
);
}
// @include set-css-var-value(('color', $type, 'dark-2'), map.get($colors, $type, 'dark-2'));
}
//include set-css-color-rgb('primary')
//--fz-color-primary-rgb:64,158,255;
@mixin set-css-color-rgb($type) {
$color: map.get($colors, $type, 'base');
@include set-css-var-value(('color', $type, 'rgb'), #{red($color), green($color), blue($color)});
}
//@include set-component-css-var('message',$message)
// $message: () !default;
// $message: map.merge(
// (
// 'bg-color': getCssVar('color', 'info', 'light-9'),
// 'border-color': getCssVar('border-color-lighter'),
// 'padding': 11px 15px,
// 'close-size': 16px,
// 'close-icon-color': getCssVar('text-color-placeholder'),
// 'close-hover-color': getCssVar('text-color-secondary'),
// ),
// $message
// );
//--fz-bg-color:var(--fz-color-info-light-9);
//--fz-border-color:var(--fz-border-color-lighter);
//--fz-padding:11px 15px;
//--fz-close-size:16px;
//--fz-close-icon-color:var(--fz-text-color-placeholder);
//--fz-close-hover-color:var(--fz-text-color-secondary);
@mixin set-component-css-var($name, $variables) {
@each $attribute, $value in $variables {
@if $attribute == 'default' {
#{getCssVarName($name)}: #{$value};
} @else {
#{getCssVarName($name, $attribute)}: #{$value};
}
}
}
6.mixins/base.scss
最后再定义根元素下,定义变量名所对应的值
@use './mixins/mixins.scss' as *;
@use './mixins/var' as *;
@use '../assets/common/var.scss' as *;
:root {
@include set-css-var-value('color-white', $color-white);
@include set-css-var-value('color-black', $color-black);
// get rgb
@each $type in (primary, success, warning, danger, error, info) {
@include set-css-color-rgb($type);
}
// Typography
@include set-component-css-var('font-size', $font-size);
// @include set-component-css-var('font-family', $font-family);
@include set-css-var-value('font-weight-primary', 500);
@include set-css-var-value('font-line-height-primary', 24px);
// z-index --fz-index-#{$type}
// @include set-component-css-var('index', $z-index);
// --fz-border-radius-#{$type}
@include set-component-css-var('border-radius', $border-radius);
// Transition
// refer to this website to get the bezier motion function detail
// https://cubic-bezier.com/#p1,p2,p3,p4 (change px as your function parameter)
@include set-component-css-var('transition-duration', $transition-duration);
}
// for light
:root {
color-scheme: light;
// --fz-color-#{$type}
// --fz-color-#{$type}-light-{$i}
@each $type in (primary, success, warning, danger, error, info) {
@include set-css-color-type($colors, $type);
}
// color-scheme
// Background --fz-bg-color-#{$type}
@include set-component-css-var('bg-color', $bg-color);
// --fz-text-color-#{$type}
@include set-component-css-var('text-color', $text-color);
// --fz-border-color-#{$type}
@include set-component-css-var('border-color', $border-color);
// Box-shadow
// --fz-box-shadow-#{$type}
@include set-component-css-var('box-shadow', $box-shadow);
// Border
@include set-css-var-value('border-width', $border-width);
@include set-css-var-value('border-style', $border-style);
@include set-css-var-value('border-color-hover', $border-color-hover);
@include set-css-var-value(
'border',
getCssVar('border-width') getCssVar('border-style') getCssVar('border-color')
);
// Svg
@include css-var-from-global('svg-monochrome-grey', 'border-color');
}
7.message.scss
@use '../../../assets/mixins/mixins.scss' as *;
@use '../../../assets/mixins/var' as *;
@use '../../../assets/common/var.scss' as *;
@use '../../../assets/mixins/function.scss' as *;
@use '../../../assets/mixins/config.scss' as *;
//-----------------------------------------------------------//
//下面的mixin生成的类名如下
// .fz-message {
// --fz-message-bg-color: var(--fz-color-info-light-9);
// --fz-message-border-color: var(--fz-border-color-lighter);
// --fz-message-padding: 11px 15px;
// --fz-message-close-size: 16px;
// --fz-message-close-icon-color: var(--fz-text-color-placeholder);
// --fz-message-close-hover-color: var(--fz-text-color-secondary);
// width: fit-content;
// max-width: calc(100% - 32px);
// box-sizing: border-box;
// border-radius: var(--fz-border-radius-base);
// border-width: var(--fz-border-width);
// border-style: var(--fz-border-style);
// border-color: var(--fz-message-border-color);
// position: fixed;
// left: 50%;
// top: 20px;
// transform: translateX(-50%);
// background-color: var(--fz-message-bg-color);
// transition: opacity var(--fz-transition-duration), transform 0.4s, top 0.4s;
// padding: var(--fz-message-padding);
// display: flex;
// align-items: center;
// gap: 8px;
// }
// .fz-message.is-center {
// justify-content: center;
// }
// .fz-message.is-plain {
// background-color: var(--fz-bg-color-overlay);
// border-color: var(--fz-bg-color-overlay);
// box-shadow: var(--fz-box-shadow-light);
// }
// .fz-message p {
// margin: 0;
// }
// .fz-message--success,
// .fz-message--info,
// .fz-message--warning,
// .fz-message--error {
// --fz-message-bg-color: var(--fz-color-success-light-9);
// --fz-message-border-color: var(--fz-color-success-light-8);
// --fz-message-text-color: var(--fz-color-success);
// --fz-message-bg-color: var(--fz-color-info-light-9);
// --fz-message-border-color: var(--fz-color-info-light-8);
// --fz-message-text-color: var(--fz-color-info);
// --fz-message-bg-color: var(--fz-color-warning-light-9);
// --fz-message-border-color: var(--fz-color-warning-light-8);
// --fz-message-text-color: var(--fz-color-warning);
// --fz-message-bg-color: var(--fz-color-error-light-9);
// --fz-message-border-color: var(--fz-color-error-light-8);
// --fz-message-text-color: var(--fz-color-error);
// }
// .fz-message--success .fz-message__content,
// .fz-message--info .fz-message__content,
// .fz-message--warning .fz-message__content,
// .fz-message--error .fz-message__content {
// color: var(--fz-message-text-color);
// overflow-wrap: break-word;
// }
// .fz-message--success .fz-message-icon--success,
// .fz-message--info .fz-message-icon--info,
// .fz-message--warning .fz-message-icon--warning,
// .fz-message--error .fz-message-icon--error {
// color: var(--fz-message-text-color);
// }
// .fz-message__badge {
// position: absolute;
// top: -8px;
// right: -8px;
// }
// .fz-message__content {
// padding: 0;
// font-size: 14px;
// line-height: 1;
// }
// .fz-message__content:focus {
// outline-width: 0;
// }
// .fz-message__closeBtn {
// cursor: pointer;
// color: var(--fz-message-close-icon-color);
// font-size: var(--fz-message-close-size);
// }
// .fz-message__closeBtn:focus {
// outline-width: 0;
// }
// .fz-message__closeBtn:hover {
// color: var(--fz-message-close-hover-color);
// }
//---------------------------------------------//
@include b(message) {
@include set-component-css-var('message', $message);
}
@include b(message) {
width: fit-content;
max-width: calc(100% - 32px);
box-sizing: border-box;
border-radius: getCssVar('border-radius-base');
border-width: getCssVar('border-width');
border-style: getCssVar('border-style');
border-color: getCssVar('message', 'border-color');
position: fixed;
left: 50%;
top: 20px;
transform: translateX(-50%);
background-color: getCssVar('message', 'bg-color');
transition:
opacity getCssVar('transition-duration'),
transform 0.4s,
top 0.4s;
padding: getCssVar('message', 'padding');
display: flex;
align-items: center;
gap: 8px;
svg {
height: 1em;
width: 1em;
}
@include when(center) {
justify-content: center;
}
@include when(plain) {
background-color: getCssVar('bg-color', 'overlay');
border-color: getCssVar('bg-color', 'overlay');
box-shadow: getCssVar('box-shadow-light');
}
p {
margin: 0;
}
@each $type in (success, info, warning, error) {
@include m($type) {
@include css-var-from-global(('message', 'bg-color'), ('color', $type, 'light-9'));
@include css-var-from-global(('message', 'border-color'), ('color', $type, 'light-8'));
@include css-var-from-global(('message', 'text-color'), ('color', $type));
.#{$namespace}-message__content {
color: getCssVar('message', 'text-color');
overflow-wrap: break-word;
}
}
& .#{$namespace}-message-icon--#{$type} {
color: getCssVar('message', 'text-color');
}
}
.#{$namespace}-message__badge {
position: absolute;
top: -8px;
right: -8px;
}
@include e(content) {
padding: 0;
font-size: 14px;
line-height: 1;
&:focus {
outline-width: 0;
}
}
& .#{$namespace}-message__closeBtn {
cursor: pointer;
color: getCssVar('message', 'close-icon-color');
font-size: getCssVar('message', 'close-size');
&:focus {
outline-width: 0;
}
&:hover {
color: getCssVar('message', 'close-hover-color');
}
}
}
.#{$namespace}-message-fade-enter-from,
.#{$namespace}-message-fade-leave-to {
opacity: 0;
transform: translate(-50%, -100%);
}
ts实现
1.message.ts
import type { Component, VNode, ExtractPropTypes, AppContext } from 'vue'
import { definePropType } from '@/utils/props/runtime'
export const iconPropType = definePropType<string | Component>([String, Object, Function])
export const mutable = <T extends readonly any[] | Record<string, unknown>>(val: T) =>
val as Mutable<typeof val>
export type Mutable<T> = { -readonly [P in keyof T]: T[P] }
export const messageTypes = ['info', 'success', 'warning', 'error'] as const
export type MessageType = (typeof messageTypes)[number]
export interface MessageConfigContext {
max?: number
}
export const messageProps = {
/**
* @description 为message自定义class类名
*/
customClass: {
type: String,
default: ''
},
/**
* @description 是否将message组件文字居中
*/
center: {
type: Boolean,
default: false
},
/**
* @description 是否将html 插入到message组件中
*/
dangerouslyUseHTMLString: {
type: Boolean,
default: false
},
/**
* @description 组件展示时间
*/
duration: {
type: Number,
default: 3000
},
/**
* @description 自定义组件提示icon
*/
icon: {
icon: iconPropType,
default: 'info'
},
/**
* @description 组件的id
*/
id: {
type: String,
default: ''
},
/**
* @description message组件的消息内容
*/
message: {
type: definePropType<string | VNode | (() => VNode)>([String, Object, Function]),
default: ''
},
/**
* @description 传入这个参数时,在组件关闭前调用这个回调函数里面的内容
*/
onClose: {
type: definePropType<() => void>(Function),
default: undefined
},
/**
* @description 是否展示关闭message icon
*/
showClose: {
type: Boolean,
default: false
},
/**
* @description 组件的四种type类型 ['info','error','warning','success']
*/
type: {
type: String,
values: ['info', 'error', 'warning', 'success'],
default: 'info'
},
/**
* @description 组件的背景色是否为纯色
*/
plain: {
type: Boolean,
default: false
},
/**
* @description 组件离窗口的偏移量
*/
offset: {
type: Number,
default: 16
},
/**
* @description dom层级
*/
zIndex: {
type: Number,
default: 0
},
/**
* 是否合并message消息
*/
grouping: {
type: Boolean,
default: false
},
/**
* @description 合并相同消息的数字
*/
repeatNum: {
type: Number,
default: 0
}
} as const
export type MessageProps = ExtractPropTypes<typeof messageProps>
export const messageEmits = {
destory: () => true
}
export type MessageOptions = Partial<
Mutable<
Omit<MessageProps, 'id'> & {
appendTo?: HTMLElement | string
}
>
>
export type MessageParams = MessageOptions | MessageOptions['message']
export type MessageParamsNormalized = Omit<MessageProps, 'id'> & {
/**
* @description 设置组件的根组件,默认是documen.body
*/
appendTo: HTMLElement
}
export interface MessageHandler {
close: () => void
}
export type MessageFn = {
(options: MessageParams, context?: AppContext | null): MessageHandler
closeAll(type?: MessageType): void
}
export type MessageTypeFn = (options: MessageParams, context?: AppContext | null) => MessageHandler
export interface Message extends MessageFn {
success: MessageTypeFn
warning: MessageTypeFn
error: MessageTypeFn
info: MessageTypeFn
}
2.method.ts
import { createVNode, isVNode, render, type AppContext } from 'vue'
import {
messageTypes,
type Message,
type MessageFn,
type MessageHandler,
type MessageParams,
type MessageType,
type MessageParamsNormalized
} from './message'
import { isElement, isString, isFunction } from '@/utils/types'
import { instances, type MessageContext } from './instance'
import MessageConstructor from './index.vue'
import type { MessageOptions } from 'element-plus'
let seed = 1
//规格化参数
const normalizeOptions = (params?: MessageParams) => {
const options =
!params || isString(params) || isVNode(params) || isFunction(params)
? ({ message: params } as MessageOptions)
: (params as MessageOptions)
const normalized = {
appendTo: document.body || (undefined as never),
...options
}
if (!normalized.appendTo) {
normalized.appendTo = document.body
} else if (isString(normalized.appendTo)) {
let appendTo = document.querySelector<HTMLElement>(normalized.appendTo as any)
if (!isElement(appendTo)) {
appendTo = document.body
}
normalized.appendTo = appendTo
}
return normalized as MessageParamsNormalized
}
const createMessage = (
{ appendTo, ...options }: MessageParamsNormalized,
context?: AppContext | null
) => {
const id = `message_${seed++}`
const container = document.createElement('div')
const props = {
...options,
id,
onClose: () => {
closeMessage(instance)
options.onClose?.()
},
onDestroy: () => {
render(null, container)
}
}
const vnode = createVNode(
MessageConstructor,
props,
isFunction(props.message) || isVNode(props.message)
? { default: isFunction(props.message) ? props.message : () => props.message }
: null
)
vnode.appContext = context || null
render(vnode, container)
const vm = vnode.component!
appendTo.append(container.firstElementChild!)
const handler: MessageHandler = {
close: () => {
vm.exposed!.visible.value = false
}
}
const instance = {
id,
handler,
vm,
vnode,
props: (vnode.component as any).props
}
return instance
}
const message: MessageFn & Partial<Message> = (options = {}, context) => {
const normalized = normalizeOptions(options)
if (normalized.grouping && instances.length) {
const instance = instances.find(({ vnode: vm }) => vm.props?.message === normalized.message)
if (instance) {
instance.props.repeatNum += 1
instance.props.type = normalized.type as string
return instance.handler
}
}
const instance = createMessage(normalized, context)
instances.push(instance)
return instance.handler
}
const closeMessage = (instance: MessageContext) => {
const idx = instances.indexOf(instance)
if (idx === -1) return
instances.splice(idx, 1)
const { handler } = instance
return handler.close
}
const closeAll = (type?: MessageType) => {
for (const instance of instances) {
if (!type || type == instance.props.type) {
instance.handler.close()
}
}
}
messageTypes.forEach((type) => {
message[type] = (options = {}, appContext) => {
const normalized = normalizeOptions(options)
return message({ ...normalized, type }, appContext)
}
})
message.closeAll = closeAll
export default message as Message
3.instance.ts
import { createVNode, isVNode, render, type AppContext } from 'vue'
import {
messageTypes,
type Message,
type MessageFn,
type MessageHandler,
type MessageParams,
type MessageType,
type MessageParamsNormalized
} from './message'
import { isElement, isString, isFunction } from '@/utils/types'
import { instances, type MessageContext } from './instance'
import MessageConstructor from './index.vue'
import type { MessageOptions } from 'element-plus'
let seed = 1
//规格化参数
const normalizeOptions = (params?: MessageParams) => {
const options =
!params || isString(params) || isVNode(params) || isFunction(params)
? ({ message: params } as MessageOptions)
: (params as MessageOptions)
const normalized = {
appendTo: document.body || (undefined as never),
...options
}
if (!normalized.appendTo) {
normalized.appendTo = document.body
} else if (isString(normalized.appendTo)) {
let appendTo = document.querySelector<HTMLElement>(normalized.appendTo as any)
if (!isElement(appendTo)) {
appendTo = document.body
}
normalized.appendTo = appendTo
}
return normalized as MessageParamsNormalized
}
const createMessage = (
{ appendTo, ...options }: MessageParamsNormalized,
context?: AppContext | null
) => {
const id = `message_${seed++}`
const container = document.createElement('div')
const props = {
...options,
id,
onClose: () => {
closeMessage(instance)
options.onClose?.()
},
onDestroy: () => {
render(null, container)
}
}
const vnode = createVNode(
MessageConstructor,
props,
isFunction(props.message) || isVNode(props.message)
? { default: isFunction(props.message) ? props.message : () => props.message }
: null
)
vnode.appContext = context || null
render(vnode, container)
const vm = vnode.component!
appendTo.append(container.firstElementChild!)
const handler: MessageHandler = {
close: () => {
vm.exposed!.visible.value = false
}
}
const instance = {
id,
handler,
vm,
vnode,
props: (vnode.component as any).props
}
return instance
}
const message: MessageFn & Partial<Message> = (options = {}, context) => {
const normalized = normalizeOptions(options)
if (normalized.grouping && instances.length) {
const instance = instances.find(({ vnode: vm }) => vm.props?.message === normalized.message)
if (instance) {
instance.props.repeatNum += 1
instance.props.type = normalized.type as string
return instance.handler
}
}
const instance = createMessage(normalized, context)
instances.push(instance)
return instance.handler
}
const closeMessage = (instance: MessageContext) => {
const idx = instances.indexOf(instance)
if (idx === -1) return
instances.splice(idx, 1)
const { handler } = instance
return handler.close
}
const closeAll = (type?: MessageType) => {
for (const instance of instances) {
if (!type || type == instance.props.type) {
instance.handler.close()
}
}
}
messageTypes.forEach((type) => {
message[type] = (options = {}, appContext) => {
const normalized = normalizeOptions(options)
return message({ ...normalized, type }, appContext)
}
})
message.closeAll = closeAll
export default message as Message
4.use-global-config.ts
import { computed, getCurrentInstance, inject, ref, provide, unref } from 'vue'
import type { InjectionKey, Ref, App } from 'vue'
import type { MaybeRef } from '@vueuse/core'
import { defaultNamespace, useNamespace, namespaceContextKey } from '@/hooks/useNameSpace'
import { useZIndex, zIndexContextKey } from '@/hooks/use-z-index'
import type { ConfigProviderContext } from './constants'
const globalConfig = ref()
export const keysOf = <T extends object>(arr: T) => Object.keys(arr) as Array<keyof T>
export const configProviderContextKey: InjectionKey<Ref> = Symbol()
export function useGlobalConfig<
K extends keyof ConfigProviderContext,
D extends ConfigProviderContext[K]
>(key: K, defaultValue?: D): Ref<Exclude<ConfigProviderContext[K], undefined> | D>
export function useGlobalConfig(): Ref<ConfigProviderContext>
export function useGlobalConfig(key?: keyof ConfigProviderContext, defaultValue = undefined) {
const config = getCurrentInstance()
? inject(configProviderContextKey, globalConfig)
: globalConfig
if (key) {
return computed(() => config.value?.[key] ?? defaultValue)
} else {
return config
}
}
export function useGlobalComponentSettings(block: string) {
const config = getCurrentInstance()
? inject(configProviderContextKey, globalConfig)
: globalConfig
const ns = useNamespace(
block,
computed(() => config.value?.namespace || defaultNamespace)
)
const zIndex = useZIndex(computed(() => config.value?.zIndex || 2000))
provideGlobalConfig(computed(() => unref(config) || {}))
return {
ns,
zIndex
}
}
export const provideGlobalConfig = (
config: MaybeRef<ConfigProviderContext>,
app?: App,
global = false
) => {
const inSetup = !!getCurrentInstance()
const oldConfig = inSetup ? useGlobalConfig() : undefined
const provideFn = app?.provide ?? (inSetup ? provide : undefined)
if (!provideFn) {
return
}
const context = computed(() => {
const cfg = unref(config)
if (!oldConfig?.value) return cfg
return mergeConfig(oldConfig.value, cfg)
})
provideFn(configProviderContextKey, context)
// provideFn(
// localeContextKey,
// computed(() => context.value.locale)
// )
provideFn(
namespaceContextKey,
computed(() => context.value.namespace)
)
provideFn(
zIndexContextKey,
computed(() => context.value.zIndex)
)
// provideFn(SIZE_INJECTION_KEY, {
// size: computed(() => context.value.size || '')
// })
if (global || !globalConfig.value) {
globalConfig.value = context.value
}
return context
}
const mergeConfig = (a: ConfigProviderContext, b: ConfigProviderContext): ConfigProviderContext => {
const keys = [...new Set([...keysOf(a), ...keysOf(b)])]
const obj: Record<string, any> = {}
for (const key of keys) {
obj[key] = b[key] !== undefined ? b[key] : a[key]
}
return obj
}
5.config-provide-props.ts(配置全局组件的props)
可以去查看这个packages\components\config-provider这个组件
// import { buildProps, definePropType } from '@element-plus/utils'
// import { useEmptyValuesProps, useSizeProp } from '@element-plus/hooks'
import type { ExtractPropTypes } from 'vue'
import { definePropType } from '@/utils/props/runtime'
import type { MessageConfigContext } from '@/views/message/src/message'
// import type { Language } from '@element-plus/locale'
// import type { ButtonConfigContext } from '@element-plus/components/button'
// import type { MessageConfigContext } from '@element-plus/components/message'
export type ExperimentalFeatures = {
// TO BE Defined
}
export const configProviderProps = {
/**
* @description Controlling if the users want a11y features
*/
a11y: {
type: Boolean,
default: true
},
/**
* @description Locale Object
*/
locale: {
type: Object
},
/**
* @description global component size
*/
size: String,
/**
* @description button related configuration, [see the following table](#button-attributes)
*/
button: {
type: Object
},
/**
* @description features at experimental stage to be added, all features are default to be set to false | ^[object]
*/
experimentalFeatures: {
type: definePropType<ExperimentalFeatures>(Object)
},
/**
* @description Controls if we should handle keyboard navigation
*/
keyboardNavigation: {
type: Boolean,
default: true
},
/**
* @description message related configuration, [see the following table](#message-attributes)
*/
message: {
type: definePropType<MessageConfigContext>(Object)
},
/**
* @description global Initial zIndex
*/
zIndex: Number,
/**
* @description global component className prefix (cooperated with [$namespace](https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/mixins/config.scss#L1)) | ^[string]
*/
namespace: {
type: String,
default: 'el'
}
// ...useEmptyValuesProps
} as const
export type ConfigProviderProps = ExtractPropTypes<typeof configProviderProps>
6.constants.ts
import type { ConfigProviderProps } from './config-provider-props'
import type { InjectionKey, Ref } from 'vue'
export type ConfigProviderContext = Partial<ConfigProviderProps>
export const configProviderContextKey: InjectionKey<Ref<ConfigProviderContext>> = Symbol()
7.use-z-index.ts
import { computed, getCurrentInstance, inject, ref, unref } from 'vue'
import { isNumber } from '@/utils/types'
import type { InjectionKey, Ref } from 'vue'
export interface ElZIndexInjectionContext {
current: number
}
const initial: ElZIndexInjectionContext = {
current: 0
}
const zIndex = ref(0)
export const defaultInitialZIndex = 2000
// For SSR
export const ZINDEX_INJECTION_KEY: InjectionKey<ElZIndexInjectionContext> =
Symbol('elZIndexContextKey')
export const zIndexContextKey: InjectionKey<Ref<number | undefined>> = Symbol('zIndexContextKey')
export const useZIndex = (zIndexOverrides?: Ref<number>) => {
const increasingInjection = getCurrentInstance() ? inject(ZINDEX_INJECTION_KEY, initial) : initial
const zIndexInjection =
zIndexOverrides || (getCurrentInstance() ? inject(zIndexContextKey, undefined) : undefined)
const initialZIndex = computed(() => {
const zIndexFromInjection = unref(zIndexInjection)
return isNumber(zIndexFromInjection) ? zIndexFromInjection : defaultInitialZIndex
})
const currentZIndex = computed(() => initialZIndex.value + zIndex.value)
const nextZIndex = () => {
increasingInjection.current++
zIndex.value = increasingInjection.current
return currentZIndex.value
}
return {
initialZIndex,
currentZIndex,
nextZIndex
}
}
export type UseZIndexReturn = ReturnType<typeof useZIndex>
8.nameSpacename
import { computed, getCurrentInstance, inject, ref, unref } from 'vue'
import type { InjectionKey, Ref } from 'vue'
export const defaultNamespace = 'fz'
const statePrefix = 'is-'
export const namespaceContextKey: InjectionKey<Ref<string | undefined>> =
Symbol('namespaceContextKey')
//获取组件上下文注册的命名空间,如何没有则使用默认命名空间
export const useGetDerivedNamespace = (namespaceOverrides?: Ref<string | undefined>) => {
const derivedNamespace =
namespaceOverrides ||
(getCurrentInstance()
? inject(namespaceContextKey, ref(defaultNamespace))
: ref(defaultNamespace))
const namespace = computed(() => {
return unref(derivedNamespace) || defaultNamespace
})
return namespace
}
const _bem = (
namespace: string,
block: string,
blockSuffix: string,
element: string,
modifier: string
) => {
let cls = `${namespace}-${block}`
if (blockSuffix) {
cls += `-${blockSuffix}`
}
if (element) {
cls += `__${element}`
}
if (modifier) {
cls += `--${modifier}`
}
return cls
}
export const useNamespace = (block: string, namespaceOverrides?: Ref<string | undefined>) => {
const namespace = useGetDerivedNamespace(namespaceOverrides)
const b = (blockSuffix = '') => _bem(namespace.value, block, blockSuffix, '', '')
const e = (element?: string) => (element ? _bem(namespace.value, block, '', element, '') : '')
const m = (modifier?: string) => (modifier ? _bem(namespace.value, block, '', '', modifier) : '')
const be = (blockSuffix?: string, element?: string) =>
blockSuffix && element ? _bem(namespace.value, block, blockSuffix, element, '') : ''
const em = (element?: string, modifier?: string) =>
element && modifier ? _bem(namespace.value, block, '', element, modifier) : ''
const bm = (blockSuffix?: string, modifier?: string) =>
blockSuffix && modifier ? _bem(namespace.value, block, blockSuffix, '', modifier) : ''
const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
blockSuffix && element && modifier
? _bem(namespace.value, block, blockSuffix, element, modifier)
: ''
const is: {
(name: string, state: boolean | undefined): string
(name: string): string
} = (name: string, ...args: [boolean | undefined] | []) => {
const state = args.length >= 1 ? args[0]! : true
return name && state ? `${statePrefix}${name}` : ''
}
// for css var
// --el-xxx: value;
const cssVar = (object: Record<string, string>) => {
const styles: Record<string, string> = {}
for (const key in object) {
if (object[key]) {
styles[`--${namespace.value}-${key}`] = object[key]
}
}
return styles
}
// with block
const cssVarBlock = (object: Record<string, string>) => {
const styles: Record<string, string> = {}
for (const key in object) {
if (object[key]) {
styles[`--${namespace.value}-${block}-${key}`] = object[key]
}
}
return styles
}
const cssVarName = (name: string) => `--${namespace.value}-${name}`
const cssVarBlockName = (name: string) => `--${namespace.value}-${block}-${name}`
return {
namespace,
b,
e,
m,
be,
em,
bm,
bem,
is,
// css
cssVar,
cssVarName,
cssVarBlock,
cssVarBlockName
}
}
9.runtime.ts
import type { PropType } from 'vue'
export const definePropType = <T>(val: any): PropType<T> => val
vue实现
message.vue
这里我使用的icon图标是可以直接用element-icon的,fz-icon组件和fz-badge组件也是自己参照源码封装的,可以自己去先查看,后面我会继续分享el-icon和el-badge的源码
<template>
<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 },
ns.is('center', center),
ns.is('closable', showClose),
ns.is('plain', plain),
customClass
]"
:style="customStyle"
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
<fz-badge :value="repeatNum" v-if="repeatNum > 1" :type="badgeType" :class="ns.e('badge')" />
<fz-icon v-if="iconComponent" :class="[ns.e('icon'), typeClass]">
<component :is="iconComponent"></component>
</fz-icon>
<p v-if="!dangerouslyUseHTMLString" :class="ns.e('content')">{{ message }}</p>
<p v-else v-html="message" :class="ns.e('content')"></p>
<fz-icon v-if="showClose" :class="ns.e('closeBtn')" @click.stop="close">
<Close />
</fz-icon>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import type { CSSProperties } from 'vue'
import { messageEmits, messageProps } from './message'
import FzBadge from '@/views/badge/src/badge.vue'
import FzIcon from '@/views/icon/src/icon.vue'
import { useTimeoutFn, useResizeObserver } from '@vueuse/core'
import {
SuccessFilled,
WarningFilled,
CircleCloseFilled,
InfoFilled,
Close
} from '@element-plus/icons-vue'
import { getOffsetOrSpace, getLastOffset } from './instance'
import { useGlobalComponentSettings } from '@/views/config-provider/src/hooks/use-global-config'
defineOptions({
name: 'fzMessage'
})
defineEmits(messageEmits)
const visible = ref(false)
const messageRef = ref<HTMLDivElement>()
const height = ref(0)
const props = defineProps(messageProps)
const { ns, zIndex } = useGlobalComponentSettings('message')
const { currentZIndex, nextZIndex } = zIndex
const TypeComponentsMap: any = {
success: SuccessFilled,
warning: WarningFilled,
error: CircleCloseFilled,
info: InfoFilled
}
let stopTimer: (() => void) | undefined = undefined
function startTimer() {
if (props.duration === 0) return
;({ stop: stopTimer } = useTimeoutFn(() => {
close()
}, props.duration))
}
function clearTimer() {
stopTimer?.()
}
function close() {
visible.value = false
}
const iconComponent = computed(() => {
return TypeComponentsMap[props.type] || props.icon || ''
})
const badgeType = computed(() => {
return props.type ? (props.type === 'error' ? 'danger' : props.type) : 'info'
})
const typeClass = computed(() => {
const type = props.type
return { [ns.bm('icon', type)]: type && TypeComponentsMap[type] }
})
const lastOffset = computed(() => getLastOffset(props.id))
const offset = computed(() => getOffsetOrSpace(props.id, props.offset) + lastOffset.value)
const bottom = computed((): number => height.value + offset.value)
const customStyle = computed<CSSProperties>(() => ({
top: `${offset.value}px`,
zIndex: currentZIndex.value
}))
useResizeObserver(messageRef, () => {
height.value = messageRef.value!.getBoundingClientRect().height
})
onMounted(() => {
startTimer()
nextZIndex
visible.value = true
})
//当repeatNum发生变化,更新message显示
watch(
() => props.repeatNum,
() => {
clearTimer()
startTimer()
}
)
defineExpose({
bottom,
visible,
close
})
</script>
结语
技术水平有限,还请多多包含,我会持续分享elementplus组件源码,希望我们一起进步。