跳转到内容

Loading

@ep_src_vue_directives_loading_code_Example(./loading_code/Example.vue)
vue
<script setup lang="ts">
import { getCurrentInstance, ref } from 'vue'
import { vLoading, service } from './loading'

const loading = ref(false)

service.appContext = getCurrentInstance()?.appContext

const handleFullScreenLoading = () => {
  const instance = service.create({ fullscreen: true, lock: true })
  setTimeout(() => {
    instance.close()
  }, 3000)
}
</script>

<template>
  <button class="native" @click="loading = true">loading</button>
  <button class="native" @click="loading = false">close loading</button>
  <button class="native" @click="handleFullScreenLoading">
    fullscreen loading
  </button>
  <div
    style="
      width: 100px;
      height: 100px;
      margin-top: 20px;
      background-color: pink;
    "
    v-loading="loading"
  ></div>
</template>
ts
import './loading.css'
import LoadingContainer from './LoadingContainer.vue'
import LoadingContent from './LoadingContent.vue'
import { createVNode, render } from 'vue'
import type {
  AppContext,
  Directive,
  UnwrapRef,
  Plugin,
  DirectiveBinding,
} from 'vue'

interface LoadingOptions {
  target?: HTMLElement | string
  fullscreen?: boolean
  lock?: boolean
  background?: string
  className?: string
  zIndex?: number
  appContext?: AppContext
  beforeClose?: () => void | false
  onClosed?: () => void
}

type ResolvedLoadingOptions = LoadingOptions & { target: HTMLElement }

export interface LoadingMeta {
  close: () => void
}

export type LoadingBinding = boolean | UnwrapRef<LoadingOptions>

class LoadingService {
  private readonly map = new WeakMap<HTMLElement, LoadingMeta>()
  appContext?: AppContext

  create(options?: LoadingOptions) {
    const resolved = resolveOptions(options)

    const _meta = this.map.get(resolved.target)
    if (_meta) {
      _meta.close()
    }

    let container: HTMLDivElement | null = document.createElement('div')
    container.dataset.loading = ''

    const props = {
      background: resolved.background,
      className: resolved.className,
      zIndex: resolved.zIndex,
      onClose: () => {
        this.map.delete(resolved.target)
      },
      onClosed: () => {
        resolved.onClosed?.()
        if (!this.map.has(resolved.target)) {
          resolved.target.classList.remove('loading-target', 'scroll-lock')
        }
        render(null, container!)
        container = null
      },
    }

    const vnode = createVNode(LoadingContainer, props, {
      default: () => createVNode(LoadingContent),
    })
    vnode.appContext = resolved.appContext || this.appContext || null

    render(vnode, container)
    resolved.target.appendChild(container.firstElementChild!)
    addClassName(resolved)

    const meta = {
      close: () => {
        if (resolved.beforeClose && !resolved.beforeClose()) return
        vnode!.component!.exposed!.visible.value = false
      },
    }

    this.map.set(resolved.target, meta)

    return meta
  }

  createFromBinding(
    el: HTMLElement,
    binding: DirectiveBinding<LoadingBinding, string, string>,
  ) {
    const options = isObject(binding.value) ? binding.value : {}
    binding.modifiers.fullscreen && (options.fullscreen = true)
    binding.modifiers.lock && (options.lock = true)
    options.target = el
    service.create(options)
  }

  update(el: HTMLElement, options: LoadingOptions) {
    // TODO: implement update
  }

  close(target?: HTMLElement | string) {
    if (typeof target === 'string') {
      const el = document.querySelector<HTMLElement>(target)
      if (!el) return
      target = el
    } else if (!target) {
      target = document.body
    }

    this.map.get(target)?.close()
  }
}

const isObject = (val: any): val is Record<any, any> =>
  val !== null && typeof val === 'object'

const resolveOptions = (options: LoadingOptions = {}) => {
  if (options.fullscreen) {
    options.target = document.body
  } else if (typeof options.target === 'string') {
    const el = document.querySelector<HTMLElement>(options.target)
    if (!el) {
      throw new Error(`[Loading] target ${options.target} not found`)
    }
    options.target = el
  } else if (!options.target) {
    options.target = document.body
  }

  const { loadingBackground, loadingClassName } = options.target.dataset

  if (loadingBackground && !options.background) {
    options.background = loadingBackground
  }

  if (loadingClassName && !options.className) {
    options.className = loadingClassName
  }

  return options as ResolvedLoadingOptions
}

const addClassName = (resolved: ResolvedLoadingOptions) => {
  resolved.target.classList.remove('loading-target', 'scroll-lock')
  resolved.target.classList.add('loading-target')
  resolved.lock && resolved.target.classList.add('scroll-lock')
}

export const service = new LoadingService()

export const vLoading: Directive<HTMLElement, LoadingBinding> = {
  mounted(el, binding) {
    if (!binding.value) return
    service.createFromBinding(el, binding)
  },
  updated(el, binding) {
    if (binding.value !== binding.oldValue) {
      if (binding.value && !binding.oldValue) {
        service.createFromBinding(el, binding)
      } else if (binding.value && binding.oldValue) {
        if (isObject(binding.value)) {
          service.update(el, binding.value)
        }
      } else {
        service.close(el)
      }
    }
  },
  unmounted(el) {
    service.close(el)
  },
}

export const loadingPlugin: Plugin = {
  install(app) {
    app.directive('loading', vLoading)
    service.appContext = app._context
  },
}
vue
<script setup lang="ts">
import { ref } from 'vue'

interface Props {
  background?: string
  className?: string
  zIndex?: number
}
defineProps<Props>()

const emit = defineEmits<{
  (e: 'close'): void
  (e: 'closed'): void
}>()

const visible = ref(true)

defineExpose({
  visible,
})
</script>

<template>
  <Transition
    appear
    name="loading"
    @before-leave="emit('close')"
    @after-leave="emit('closed')"
  >
    <div
      v-show="visible"
      :class="['loading-container', className]"
      :style="{ zIndex, backgroundColor: background }"
    >
      <slot></slot>
    </div>
  </Transition>
</template>

<style scoped>
.loading-container {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.8);
  z-index: 9999;

  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
}
.loading-enter-active,
.loading-leave-active {
  transition: opacity 0.3s;
}
.loading-enter-from,
.loading-leave-to {
  opacity: 0;
}
</style>
vue
<template>
  <span class="loading-content" style="color: #fff;">Loading...</span>
</template>
css
.loading-target {
  position: relative;
}
.scroll-lock {
  overflow: hidden;
}