feat: enhance Typewriter component animation and performance
This commit is contained in:
		@@ -1,27 +1,33 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted, watch } from 'vue'
 | 
			
		||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  texts: {
 | 
			
		||||
    type: Array,
 | 
			
		||||
    required: true,
 | 
			
		||||
    validator: value => value.length > 0
 | 
			
		||||
    validator: value => Array.isArray(value) && 
 | 
			
		||||
              value.length > 0 && 
 | 
			
		||||
              value.every(item => typeof item === 'string' && item.length > 0)
 | 
			
		||||
  },
 | 
			
		||||
  typingSpeed: {
 | 
			
		||||
    type: Number,
 | 
			
		||||
    default: 100 // 毫秒/字符
 | 
			
		||||
    default: 100,
 | 
			
		||||
    validator: value => value > 0
 | 
			
		||||
  },
 | 
			
		||||
  deletingSpeed: {
 | 
			
		||||
    type: Number,
 | 
			
		||||
    default: 50 // 毫秒/字符
 | 
			
		||||
    default: 50,
 | 
			
		||||
    validator: value => value > 0
 | 
			
		||||
  },
 | 
			
		||||
  delayBetweenTexts: {
 | 
			
		||||
    type: Number,
 | 
			
		||||
    default: 1500 // 毫秒
 | 
			
		||||
    default: 1500,
 | 
			
		||||
    validator: value => value >= 0
 | 
			
		||||
  },
 | 
			
		||||
  cursorBlinkSpeed: {
 | 
			
		||||
    type: Number,
 | 
			
		||||
    default: 500 // 毫秒
 | 
			
		||||
    default: 500,
 | 
			
		||||
    validator: value => value > 0
 | 
			
		||||
  },
 | 
			
		||||
  loop: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
@@ -29,79 +35,137 @@ const props = defineProps({
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['cycle-complete'])
 | 
			
		||||
 | 
			
		||||
// 响应式状态
 | 
			
		||||
const currentText = ref('')
 | 
			
		||||
const isTyping = ref(true)
 | 
			
		||||
const isDeleting = ref(false)
 | 
			
		||||
const phase = ref('typing') // 'typing' | 'pausing' | 'deleting'
 | 
			
		||||
const currentIndex = ref(0)
 | 
			
		||||
const cursorVisible = ref(true)
 | 
			
		||||
const isRunning = ref(false)
 | 
			
		||||
 | 
			
		||||
// 光标闪烁效果
 | 
			
		||||
// 计算属性
 | 
			
		||||
const currentFullText = computed(() => {
 | 
			
		||||
  return props.texts[currentIndex.value] || ''
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// 清理所有定时器
 | 
			
		||||
let timers = []
 | 
			
		||||
const clearAllTimers = () => {
 | 
			
		||||
  timers.forEach(timer => clearTimeout(timer))
 | 
			
		||||
  timers = []
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 安全的定时器封装
 | 
			
		||||
const setSafeTimeout = (fn, delay) => {
 | 
			
		||||
  const timer = setTimeout(() => {
 | 
			
		||||
    try {
 | 
			
		||||
      fn()
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Typewriter error:', error)
 | 
			
		||||
      resetAnimation()
 | 
			
		||||
    }
 | 
			
		||||
  }, delay)
 | 
			
		||||
  timers.push(timer)
 | 
			
		||||
  return timer
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 光标动画
 | 
			
		||||
let cursorInterval
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
const startCursorAnimation = () => {
 | 
			
		||||
  cursorInterval = setInterval(() => {
 | 
			
		||||
    cursorVisible.value = !cursorVisible.value
 | 
			
		||||
  }, props.cursorBlinkSpeed)
 | 
			
		||||
  
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 重置动画
 | 
			
		||||
const resetAnimation = () => {
 | 
			
		||||
  clearAllTimers()
 | 
			
		||||
  currentText.value = ''
 | 
			
		||||
  currentIndex.value = 0
 | 
			
		||||
  phase.value = 'typing'
 | 
			
		||||
  isRunning.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 打字机核心逻辑
 | 
			
		||||
const type = () => {
 | 
			
		||||
  if (!isRunning.value) return
 | 
			
		||||
 | 
			
		||||
  switch (phase.value) {
 | 
			
		||||
    case 'typing':
 | 
			
		||||
      if (currentText.value.length < currentFullText.value.length) {
 | 
			
		||||
        currentText.value = currentFullText.value.substring(0, currentText.value.length + 1)
 | 
			
		||||
        setSafeTimeout(type, props.typingSpeed)
 | 
			
		||||
      } else {
 | 
			
		||||
        phase.value = 'pausing'
 | 
			
		||||
        setSafeTimeout(() => {
 | 
			
		||||
          phase.value = 'deleting'
 | 
			
		||||
          type()
 | 
			
		||||
        }, props.delayBetweenTexts)
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
 | 
			
		||||
    case 'deleting':
 | 
			
		||||
      if (currentText.value.length > 0) {
 | 
			
		||||
        currentText.value = currentFullText.value.substring(0, currentText.value.length - 1)
 | 
			
		||||
        setSafeTimeout(type, props.deletingSpeed)
 | 
			
		||||
      } else {
 | 
			
		||||
        if (props.loop || currentIndex.value < props.texts.length - 1) {
 | 
			
		||||
          currentIndex.value = (currentIndex.value + 1) % props.texts.length
 | 
			
		||||
          emit('cycle-complete', currentIndex.value)
 | 
			
		||||
          phase.value = 'typing'
 | 
			
		||||
          setSafeTimeout(type, props.delayBetweenTexts / 2)
 | 
			
		||||
        } else {
 | 
			
		||||
          isRunning.value = false
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 启动打字机
 | 
			
		||||
const startTyping = () => {
 | 
			
		||||
  if (isRunning.value) return
 | 
			
		||||
  if (props.texts.length === 0) return
 | 
			
		||||
 | 
			
		||||
  isRunning.value = true
 | 
			
		||||
  currentIndex.value = 0
 | 
			
		||||
  phase.value = 'typing'
 | 
			
		||||
  currentText.value = ''
 | 
			
		||||
  type()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 生命周期
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  startCursorAnimation()
 | 
			
		||||
  startTyping()
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
  clearInterval(cursorInterval)
 | 
			
		||||
  clearAllTimers()
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
// 打字机核心逻辑
 | 
			
		||||
let typingTimeout
 | 
			
		||||
const startTyping = () => {
 | 
			
		||||
  const fullText = props.texts[currentIndex.value]
 | 
			
		||||
  
 | 
			
		||||
  if (isTyping.value) {
 | 
			
		||||
    // 打字阶段
 | 
			
		||||
    currentText.value = fullText.substring(0, currentText.value.length + 1)
 | 
			
		||||
    
 | 
			
		||||
    if (currentText.value === fullText) {
 | 
			
		||||
      isTyping.value = false
 | 
			
		||||
      typingTimeout = setTimeout(() => {
 | 
			
		||||
        isDeleting.value = true
 | 
			
		||||
        startTyping()
 | 
			
		||||
      }, props.delayBetweenTexts)
 | 
			
		||||
    } else {
 | 
			
		||||
      typingTimeout = setTimeout(startTyping, props.typingSpeed)
 | 
			
		||||
    }
 | 
			
		||||
  } else if (isDeleting.value) {
 | 
			
		||||
    // 删除阶段
 | 
			
		||||
    currentText.value = fullText.substring(0, currentText.value.length - 1)
 | 
			
		||||
    
 | 
			
		||||
    if (currentText.value === '') {
 | 
			
		||||
      isDeleting.value = false
 | 
			
		||||
      
 | 
			
		||||
      if (props.loop || currentIndex.value < props.texts.length - 1) {
 | 
			
		||||
        currentIndex.value = (currentIndex.value + 1) % props.texts.length
 | 
			
		||||
        isTyping.value = true
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      typingTimeout = setTimeout(startTyping, props.delayBetweenTexts / 2)
 | 
			
		||||
    } else {
 | 
			
		||||
      typingTimeout = setTimeout(startTyping, props.deletingSpeed)
 | 
			
		||||
    }
 | 
			
		||||
// 监听props变化
 | 
			
		||||
watch(() => props.texts, (newVal, oldVal) => {
 | 
			
		||||
  if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
 | 
			
		||||
    resetAnimation()
 | 
			
		||||
    startTyping()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 监听文本变化
 | 
			
		||||
watch(() => props.texts, () => {
 | 
			
		||||
  clearTimeout(typingTimeout)
 | 
			
		||||
  currentText.value = ''
 | 
			
		||||
  currentIndex.value = 0
 | 
			
		||||
  isTyping.value = true
 | 
			
		||||
  isDeleting.value = false
 | 
			
		||||
  startTyping()
 | 
			
		||||
}, { deep: true })
 | 
			
		||||
 | 
			
		||||
// 开发环境错误提示
 | 
			
		||||
if (import.meta.env.DEV && props.texts.some(text => !text || typeof text !== 'string')) {
 | 
			
		||||
  console.warn('Typewriter: 检测到无效文本内容,请确保所有文本都是非空字符串')
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="typewriter-container">
 | 
			
		||||
  <div class="typewriter-container" aria-live="polite">
 | 
			
		||||
    <span class="typewriter-text">{{ currentText }}</span>
 | 
			
		||||
    <span 
 | 
			
		||||
      class="typewriter-cursor" 
 | 
			
		||||
      v-show="isRunning"
 | 
			
		||||
      class="typewriter-cursor"
 | 
			
		||||
      :class="{ 'cursor-visible': cursorVisible }"
 | 
			
		||||
      aria-hidden="true"
 | 
			
		||||
    >|</span>
 | 
			
		||||
@@ -115,34 +179,36 @@ watch(() => props.texts, () => {
 | 
			
		||||
  font-family: 'Fira Code', 'Courier New', monospace;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
  min-height: 1.5em; /* 防止内容变化时布局抖动 */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.typewriter-text {
 | 
			
		||||
  color: var(--text-primary);
 | 
			
		||||
  white-space: pre-wrap;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  vertical-align: top;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.typewriter-cursor {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  width: 0.1em;
 | 
			
		||||
  color: var(--primary-color);
 | 
			
		||||
  height: 1em;
 | 
			
		||||
  margin-left: 2px;
 | 
			
		||||
  background-color: var(--primary-color);
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  animation: cursorPulse 1s infinite;
 | 
			
		||||
  transition: opacity 0.2s ease;
 | 
			
		||||
  vertical-align: top;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.cursor-visible {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 光标动画 */
 | 
			
		||||
@keyframes cursorPulse {
 | 
			
		||||
  0%, 100% {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
  50% {
 | 
			
		||||
    opacity: 0.3;
 | 
			
		||||
  }
 | 
			
		||||
/* 性能优化 */
 | 
			
		||||
.typewriter-container {
 | 
			
		||||
  will-change: contents;
 | 
			
		||||
  contain: content;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 响应式调整 */
 | 
			
		||||
@@ -153,11 +219,18 @@ watch(() => props.texts, () => {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 暗黑模式适配 */
 | 
			
		||||
[data-theme="dark"] .typewriter-text {
 | 
			
		||||
  color: var(--text-primary-dark);
 | 
			
		||||
@media (prefers-color-scheme: dark) {
 | 
			
		||||
  .typewriter-text {
 | 
			
		||||
    color: var(--text-primary-dark);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .typewriter-cursor {
 | 
			
		||||
    background-color: var(--primary-color-dark);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] .typewriter-cursor {
 | 
			
		||||
  color: var(--primary-color-dark);
 | 
			
		||||
/* 减少重排优化 */
 | 
			
		||||
.typewriter-text {
 | 
			
		||||
  backface-visibility: hidden;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
</style>
 | 
			
		||||
		Reference in New Issue
	
	Block a user