构建优雅的 Vue.js 表情包选择器:一个功能丰富且可定制的 Emoji Picker 组件

在当今的社交应用、聊天工具或评论系统中,表情符号(Emoji)已成为不可或缺的表达元素。一个好的表情选择器不仅能提升用户体验,还能让交互变得更加生动有趣。今天,我将分享一个我开发的 Vue.js Emoji Picker 组件,它不仅功能齐全,而且具有高度的可定制性。

组件预览与使用场景

想象一下:在您的聊天应用、评论框或任何需要用户输入表情的地方,一个优雅的弹窗徐徐展开,分类清晰的表情符号供用户选择。点击即可插入,操作流畅自然。这正是我们即将构建的组件所能提供的体验。

基本使用示例

javascript 复制代码
<template>
  <div>
    <button @click="showPicker = !showPicker">选择表情 😊</button>
    <EmojiPicker 
      :visible="showPicker" 
      @select="handleEmojiSelect"
    />
  </div>
</template>

<script>
import EmojiPicker from './EmojiPicker.vue'

export default {
  components: {
    EmojiPicker
  },
  data() {
    return {
      showPicker: false
    }
  },
  methods: {
    handleEmojiSelect(emoji) {
      console.log('选中的表情:', emoji)
      // 将表情插入输入框等操作
    }
  }
}
</script>

核心特性解析

1. 智能分类与导航

组件内置10大类表情,涵盖笑脸、手势、动物、食物、活动、符号、节日、自然、物品和交通,总计超过1000个表情符号。

分类导航栏特点:

  • 支持三种显示模式:图标+文字、纯图标、纯文字

  • 横向滚动设计,适应不同屏幕宽度

  • 优雅的滚动条隐藏处理

  • 鼠标滚轮支持横向滚动

2. 高度可配置性

通过灵活的props配置,组件可以适应各种使用场景:

javascript 复制代码
<EmojiPicker
  :visible="true"
  width="350px"
  display-mode="icon"
  :visible-categories="['face', 'hand', 'food']"
  @select="onSelect"
/>

配置项说明:

  • visible: 控制组件显示/隐藏

  • width: 自定义宽度(支持CSS单位)

  • displayMode: 分类显示模式(both|icon|name

  • visibleCategories: 过滤显示指定分类,提升用户效率

3. 优化用户体验的设计细节

横向滚动优化:

javascript 复制代码
handleWheel(event) {
  const container = this.$refs.emojiTabs
  const canScrollHorizontally = container.scrollWidth > container.clientWidth
  
  if (canScrollHorizontally) {
    event.preventDefault()
    const delta = Math.abs(event.deltaY) > Math.abs(event.deltaX) 
      ? event.deltaY 
      : event.deltaX
    container.scrollBy({ left: delta, behavior: 'auto' })
  }
}

这个巧妙的方法将垂直滚动手势转换为水平滚动,让用户在使用鼠标滚轮时也能轻松浏览所有分类。

视觉反馈增强:

  • 表情悬停时放大效果(transform: scale(1.3)

  • 平滑的过渡动画(transition: all 0.2s ease

  • 清晰的活动状态指示

  • 统一的滚动条样式

技术实现亮点

数据结构设计

组件采用分离的数据结构:categories存储分类元数据,emojiCategories存储实际的表情数据。这种设计使得:

  1. 易于维护和扩展

  2. 支持灵活的分类过滤

  3. 保持代码清晰度

计算属性巧妙运用

javascript 复制代码
filteredCategories() {
  if (!this.visibleCategories || this.visibleCategories.length === 0) {
    return this.categories.map((cat, index) => ({
      ...cat,
      originalIndex: index
    }))
  }
  return this.categories
    .map((cat, index) => ({
      ...cat,
      originalIndex: index
    }))
    .filter(cat => this.visibleCategories.includes(cat.key))
}

这个计算属性智能处理分类过滤逻辑,确保无论是否配置可见分类,组件都能正常工作。

样式设计哲学

组件的CSS采用现代化设计原则:

  • 使用CSS变量友好结构,便于主题定制

  • 灵活的盒模型和定位系统

  • 响应式设计考虑

  • 性能优化的动画和过渡

实际应用建议

1. 性能优化

对于大量表情的渲染,可以考虑虚拟滚动技术。不过当前实现通过分页(分类)已经有效降低了初始渲染压力。

2. 无障碍访问

可以进一步增加ARIA属性,提升屏幕阅读器兼容性:

javascript 复制代码
<span
  class="emoji-item"
  role="button"
  :aria-label="`选择表情${emoji}`"
  tabindex="0"
  @click="$emit('select', emoji)"
  @keydown.enter="$emit('select', emoji)"
>
  {{ emoji }}
</span>

3. 扩展可能性

  • 搜索功能:添加表情搜索,快速定位

  • 最近使用:记录用户常用表情

  • 皮肤色调:支持不同肤色的表情变体

  • 自定义表情:集成自定义贴图或GIF

为什么选择这个组件?

  1. 开箱即用:无需复杂配置,基本功能一应俱全

  2. 高度可定制:从样式到功能都可按需调整

  3. 性能优秀:合理的数据结构和渲染优化

  4. 现代设计:符合当前UI/UX最佳实践

  5. 易于集成:简单的API设计,快速融入现有项目

组件完整代码

javascript 复制代码
<template>
  <div v-show="visible" class="emoji-picker" :style="{ width: width }">
    <!-- 分类导航栏 -->
    <div ref="emojiTabs" class="emoji-tabs">
      <div
        v-for="(category, index) in filteredCategories"
        :key="index"
        class="tab-item"
        :class="{ active: activeCategory === category.originalIndex }"
        @click="changeCategory(category.originalIndex)"
      >
        <template v-if="displayMode === 'both'">
          {{ category.icon }} {{ category.name }}
        </template>
        <template v-else-if="displayMode === 'icon'">
          {{ category.icon }}
        </template>
        <template v-else>
          {{ category.name }}
        </template>
      </div>
    </div>
    <!-- 表情列表 -->
    <div class="emoji-list">
      <span
        v-for="(emoji, index) in getCurrentEmojis"
        :key="index"
        class="emoji-item"
        @click="$emit('select', emoji)"
      >
        {{ emoji }}
      </span>
    </div>
  </div>
</template>

<script>
export default {
  name: 'EmojiPicker',
  props: {
    /**
     * 是否显示表情选择器
     * @type {boolean}
     */
    visible: {
      type: Boolean,
      default: false
    },
    /**
     * 表情选择器宽度
     * @type {string}
     */
    width: {
      type: String,
      default: '100%'
    },
    /**
     * 分类显示模式
     * @type {string}
     * @values 'both' | 'icon' | 'name'
     * @default 'both'
     */
    displayMode: {
      type: String,
      default: 'both',
      validator: (value) => ['both', 'icon', 'name'].includes(value)
    },
    /**
     * 显示的分类列表(通过 key 控制)
     * @type {Array}
     * @default null (显示全部)
     */
    visibleCategories: {
      type: Array,
      default: null
    }
  },
  data() {
    return {
      // 当前选中的分类
      activeCategory: 0,
      categories: [
        { name: '笑脸', icon: '😊', key: 'face' },
        { name: '手势', icon: '👋', key: 'hand' },
        { name: '动物', icon: '🐶', key: 'animal' },
        { name: '食物', icon: '🍕', key: 'food' },
        { name: '活动', icon: '⚽', key: 'activity' },
        { name: '符号', icon: '❤️', key: 'symbol' },
        { name: '节日', icon: '🎉', key: 'festival' },
        { name: '自然', icon: '☀️', key: 'nature' },
        { name: '物品', icon: '⌚', key: 'object' },
        { name: '交通', icon: '🚗', key: 'travel' }
      ],
      // 按类别整理的表情数据
      emojiCategories: [
        // 笑脸和情感
        [
          '😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
          '😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
          '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷', '🤒',
          '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴', '😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', '🧐', '😕',
          '😟', '🙁', '☹️', '😮', '😯', '😲', '😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢', '😭', '😱',
          '😖', '😣', '😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀', '☠️', '💩',
          '🤡', '👹', '👺', '👻', '👽', '👾', '🤖', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾'
        ],
        // 手势和身体部位
        [
          '👋', '🤚', '🖐', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
          '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
          '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀',
          '👁', '👅', '👄', '👶', '🧒', '👦', '👧', '🧑', '👱', '👨', '🧔', '👨‍🦰', '👨‍🦱', '👨‍🦳', '👨‍🦲', '👩',
          '👩‍🦰', '👩‍🦱', '👩‍🦳', '👩‍🦲', '🧓', '👴', '👵', '🙍', '🙎', '🙅', '🙆', '💁', '🙋', '🧏', '🙇',
          '🤦', '🤷', '👮', '🕵', '💂', '🥷', '👷', '🤴', '👸', '👳', '👲', '🧕', '🤵', '👰', '🤰', '🤱'
        ],
        // 动物
        [
          '🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐽', '🐸', '🐵',
          '🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤', '🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗',
          '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜', '🦟', '🦗', '🕷', '🦂', '🐢', '🐍', '🦎', '🦖',
          '🦕', '🐙', '🦑', '🦐', '🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳', '🐋', '🦈', '🐊', '🐅', '🐆'
        ],
        // 食物和饮料
        [
          '🍇', '🍈', '🍉', '🍊', '🍋', '🍌', '🍍', '🥭', '🍎', '🍏', '🍐', '🍑', '🍒', '🍓', '🫐', '🥝',
          '🍅', '🫒', '🥥', '🥑', '🍆', '🥔', '🥕', '🌽', '🌶', '🫑', '🥒', '🥬', '🥦', '🧄', '🧅', '🍄',
          '🥜', '🍞', '🥐', '🥖', '🫓', '🥨', '🥯', '🥞', '🧇', '🧀', '🍖', '🍗', '🥩', '🥓', '🍔',
          '🍟', '🍕', '🌭', '🥪', '🌮', '🌯', '🫔', '🥙', '🧆', '🥚', '🍳', '🥘', '🍲', '🫕', '🥣', '🥗',
          '🍿', '🧈', '🧂', '🥫', '🍱', '🍘', '🍙', '🍚', '🍛', '🍜', '🍝', '🍠', '🍢', '🍣', '🍤', '🍥',
          '🥮', '🍡', '🥟', '🥠', '🥡', '🦀', '🦞', '🦐', '🦑', '🦪', '🍦', '🍧', '🍨', '🍩', '🍪', '🎂',
          '🍰', '🧁', '🥧', '🍫', '🍬', '🍭', '🍮', '🍯', '🍼', '🥛', '☕', '🫖', '🍵', '🍶', '🍾', '🍷',
          '🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🧋', '🧃', '🧉', '🧊', '🥢', '🍽', '🍴', '🥄', '🔪',
          '🏺'
        ],
        // 活动和运动
        [
          '⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍',
          '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛼', '⛸', '🥌', '🎿',
          '⛷', '🏂', '🪂', '🏋', '🤼', '🤸', '⛹', '🤺', '🤾', '🏌', '🏇', '🧘', '🏄', '🏊', '🤽', '🚣',
          '🧗', '🚵', '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖', '🏵', '🎗', '🎫', '🎟', '🎪', '🤹', '🎭',
          '🩰', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🪘', '🎷', '🎺', '🎸', '🪕', '🎻', '🎲', '♟',
          '🎯', '🎳', '🎮', '🎰', '🧩'
        ],
        // 符号和图标
        [
          '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖',
          '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈',
          '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️',
          '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️', '🈴', '🈵', '🈹',
          '🈲', '🅰️', '🅱️', '🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔', '📛', '🚫', '💯', '💢', '♨️',
          '🚷', '🚯', '🚳', '🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔', '‼️', '⁉️', '🔅', '🔆', '〽️',
          '⚠️', '🚸', '🔱', '⚜️', '🔰', '♻️', '✅', '🈯', '💹', '❇️', '✳️', '❎', '🌐', '💠', 'Ⓜ️', '🌀',
          '💤', '🏧', '🚾', '♿', '🅿️', '🈳', '🈂️', '🛂', '🛄', '🚹', '🚺', '🚻', '🚼',
          '⚧️', '🚮', '📶', '🈁', '🔣', 'ℹ️', '🔤', '🔡', '🔠', '🆖', '🆗', '🆙', '🆒', '🆕', '🆓',
          '0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🔢', '#️⃣', '*️⃣'
        ],
        // 节日和特殊场合
        [
          '🎉', '🎊', '🎁', '🎀', '🎃', '🎄', '🎆', '🎇', '🧨', '✨', '🎈', '🎓', '🎖'
        ],
        // 天气和自然
        [
          '☀️', '🌤', '⛅', '🌥', '☁️', '🌦', '🌧', '⛈', '🌩', '⚡', '❄️', '☃️', '⛄', '☄️', '🔥', '💧',
          '🌊', '🌙', '🌍', '🌎', '🌏', '🪐', '🌟', '🌠', '🌌', '🌈', '🌪', '🌫', '🌬', '💨', '💫', '💦', '☔'
        ],
        // 物品和工具
        [
          '⌚', '📱', '📲', '💻', '⌨️', '🖥', '🖨', '🖱', '🖲', '🕹', '🗜', '💾', '💿', '📼', '📷',
          '📸', '📹', '🎥', '📽', '🎞', '📞', '☎️', '📠', '📺', '📻', '🎙', '🎚', '🎛', '🧭',
          '⏱', '⏲', '⏰', '🕰', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯', '🪔', '🧯', '🛢', '💸',
          '💵', '💴', '💶', '💷', '💰', '💳', '💎', '⚖️', '🔧', '🔨', '⚒', '🛠', '⛏', '🔩', '⚙️',
          '🗜', '🦯', '🔗', '⛓', '🪝', '🧰', '🧲', '🪜', '⚗️', '🧪', '🧫', '🧬', '🔬', '🔭', '💉',
          '🩸', '💊', '🩹', '🩺', '🚪', '🛗', '🪞', '🪟', '🛏', '🛋', '🪑', '🚽', '🪠', '🚿', '🛁', '🪤',
          '🧴', '🧷', '🧹', '🧺', '🧻', '🪣', '🧼', '🪥', '🧽', '🛒', '🚬', '⚰️', '🪦', '⚱️'
        ],
        // 交通和地图
        [
          '🚗', '🚕', '🚙', '🚌', '🚎', '🏎', '🚓', '🚑', '🚒', '🚐', '🛻', '🚚', '🚛', '🚜', '🦽',
          '🦼', '🛴', '🚲', '🛵', '🏍', '🛺', '🚨', '🚔', '🚍', '🚘', '🚖', '🚡', '🚠', '🚟', '🚃', '🚋',
          '🚞', '🚝', '🚄', '🚅', '🚈', '🚂', '🚆', '🚇', '🚊', '🚉', '✈️', '🛫', '🛬', '🛩', '💺', '🛰',
          '🚀', '🛸', '🚁', '🛶', '⛵', '🚤', '🛥', '🛳', '⛴', '🚢', '⚓', '🪝', '⛽', '🚧', '🚦', '🚥',
          '🚏', '🗺', '🗿', '🗽', '🗼', '🏰', '🏯', '🏟', '🎡', '🎢', '🎠', '⛲', '⛱', '🏖', '🏝', '🏜',
          '🏞', '🏠', '🏡', '🏢', '🏣', '🏤', '🏥', '🏦', '🏪', '🏫', '🏩', '💒', '🏛', '⛪', '🕌', '🕍',
          '🕋', '⛩'
        ]
      ]
    }
  },
  computed: {
    getCurrentEmojis() {
      return this.emojiCategories[this.activeCategory]
    },
    /**
     * 过滤后的分类列表
     */
    filteredCategories() {
      if (!this.visibleCategories || this.visibleCategories.length === 0) {
        // 如果没有配置,显示全部分类,并添加原始索引
        return this.categories.map((cat, index) => ({
          ...cat,
          originalIndex: index
        }))
      }
      // 根据配置过滤分类
      return this.categories
        .map((cat, index) => ({
          ...cat,
          originalIndex: index
        }))
        .filter(cat => this.visibleCategories.includes(cat.key))
    }
  },
  mounted() {
    // 添加鼠标滚轮事件监听,实现横向滚动
    this.$refs.emojiTabs.addEventListener('wheel', this.handleWheel, { passive: false })
  },
  beforeDestroy() {
    // 移除事件监听
    if (this.$refs.emojiTabs) {
      this.$refs.emojiTabs.removeEventListener('wheel', this.handleWheel)
    }
  },
  methods: {
    changeCategory(index) {
      this.activeCategory = index
    },
    /**
     * 处理鼠标滚轮事件,实现横向滚动
     */
    handleWheel(event) {
      const container = this.$refs.emojiTabs
      if (!container) return

      // 检查是否可以横向滚动
      const canScrollHorizontally = container.scrollWidth > container.clientWidth

      if (canScrollHorizontally) {
        // 阻止默认的纵向滚动
        event.preventDefault()
        // 将纵向滚动转换为横向滚动,使用 scrollBy 方法更平滑
        const delta = Math.abs(event.deltaY) > Math.abs(event.deltaX) ? event.deltaY : event.deltaX
        container.scrollBy({
          left: delta,
          behavior: 'auto'
        })
      }
    }
  }
}
</script>

<style scoped>
.emoji-picker {
  position: absolute;
  bottom: 100%;
  left: 0;
  right: 0;
  background-color: white;
  border: 1px solid #ddd;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  z-index: 100;
  margin-bottom: 8px;
}

.emoji-tabs {
  display: flex;
  flex-wrap: nowrap; /* 防止换行,确保横向滚动 */
  border-bottom: 1px solid #eee;
  padding: 8px 10px;
  background-color: #f9f9f9;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
  overflow-x: auto;
  overflow-y: hidden;
  /* 隐藏滚动条 */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE and Edge */
}

.emoji-tabs::-webkit-scrollbar {
  display: none; /* Chrome, Safari, Opera */
}

.tab-item {
  padding: 6px 12px;
  cursor: pointer;
  border-radius: 4px;
  margin-right: 4px;
  font-size: 14px;
  color: #666;
  transition: all 0.2s ease;
  flex-shrink: 0; /* 防止压缩 */
  white-space: nowrap; /* 防止换行 */
}

.tab-item.active {
  background-color: #fff;
  color: #409eff;
  border: 1px solid #ddd;
  border-bottom: none;
  margin-bottom: -1px;
}

.tab-item:hover {
  background-color: #f0f0f0;
}

.emoji-list {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 10px;
  max-height: 250px;
  overflow-y: auto;
}

.emoji-item {
  font-size: 18px;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  transition: all 0.2s ease;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.emoji-item:hover {
  background-color: #f0f0f0;
  transform: scale(1.3);
}

/* 统一滚动条样式 */
.emoji-list::-webkit-scrollbar {
  width: 6px;
}

.emoji-list::-webkit-scrollbar-track {
  background: #f1f1f1;
  border-radius: 10px;
}

.emoji-list::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 10px;
}

.emoji-list::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}
</style>

结语

这个Emoji Picker组件是我在实际项目中提炼和优化的成果,它平衡了功能丰富性和使用简洁性。无论是快速原型开发还是生产级应用,它都能提供可靠的表情选择解决方案。

组件代码完全开源,您可以根据自己的需求进一步修改和扩展。在日益重视用户体验的今天,一个好的表情选择器虽小,却能显著提升用户的使用愉悦度。

希望这个组件能对您的项目有所帮助!如果您有任何改进建议或使用案例分享,欢迎交流讨论。

相关推荐
无风听海2 小时前
AngularJS中$q.when()的用法
javascript·ecmascript·angular.js
Charlie_lll2 小时前
学习Three.js--光源Light+轨道控制器OrbitControls
前端·three.js
Amumu121382 小时前
Vue核心(二)
前端·javascript·vue.js
墨轩尘2 小时前
qiankun的简单使用
前端·vue.js·前端框架
EEEzhenliang3 小时前
CSS样式所有使用方式(书写位置)
前端·css
2501_944424123 小时前
Flutter for OpenHarmony游戏集合App实战之记忆翻牌配对消除
android·java·开发语言·javascript·windows·flutter·游戏
2501_944526423 小时前
Flutter for OpenHarmony 万能游戏库App实战 - 设置功能实现
android·javascript·flutter·游戏·harmonyos
愚公移码3 小时前
蓝凌EKP产品:关联机制浅析
java·服务器·前端
汉堡go4 小时前
python_chapter6
前端·数据库·python