Appearance
实现 Emoji 选择器 工具
分类:developer | 标签:emoji、表情、复制 分类浏览并复制 Emoji
2.1 功能说明
分类浏览并复制 Emoji
2.1.1 使用指南
功能说明
按分类浏览 Emoji,支持中文关键词搜索,点击即复制到剪贴板,并记录最近使用。
使用场景
评论、社交文案、README、提交信息中快速插入表情符号。
小提示
若个别表情显示为方块,说明当前系统字体缺少该字符(多为较新版本 Unicode),复制出去在支持的设备上仍可正常显示。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
nameOf()loadRecent()pushRecent()clearRecent()pick()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed, onMounted } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
// 注意:Emoji 多为 UTF-16 代理对(4 字节),禁止用 str.split('') 拆分,
// 否则会被拆成孤立代理字符而渲染成「?」。此处直接以数组逐个声明。
const CATEGORIES = [
{
name: '表情',
list: [
['😀', '大笑'], ['😁', '露齿笑'], ['😂', '笑哭'], ['🤣', '笑翻'],
['😊', '微笑'], ['🙂', '轻微笑'], ['😉', '眨眼'], ['😍', '花痴'],
['😘', '飞吻'], ['😎', '墨镜酷'], ['🤔', '思考'], ['🤗', '拥抱'],
['😴', '睡觉'], ['😭', '大哭'], ['😢', '流泪'], ['😡', '生气'],
['🥳', '庆祝'], ['😇', '天使'], ['🤩', '星星眼'], ['😅', '尴尬'],
['😏', '得意'], ['😱', '尖叫'], ['🤯', '爆炸头'], ['😐', '面无表情'],
['🙄', '白眼'], ['😜', '吐舌'], ['🤮', '呕吐'], ['🥺', '可怜'],
['😷', '口罩'], ['🤡', '小丑']
]
},
{
name: '手势',
list: [
['👍', '点赞'], ['👎', '点踩'], ['👏', '鼓掌'], ['🙏', '拜托 祈祷'],
['💪', '肌肉 加油'], ['🤝', '握手'], ['✌️', '胜利'], ['🤞', '交叉手指'],
['👌', 'OK'], ['🤙', '打电话'], ['👋', '挥手 再见'], ['🤟', '爱你'],
['👊', '拳头'], ['✊', '举拳'], ['👈', '左指'], ['👉', '右指'],
['👆', '上指'], ['👇', '下指'], ['🖐️', '张开手'], ['🤲', '捧手']
]
},
{
name: '动物',
list: [
['🐶', '狗'], ['🐱', '猫'], ['🐭', '老鼠'], ['🐹', '仓鼠'],
['🐰', '兔子'], ['🦊', '狐狸'], ['🐻', '熊'], ['🐼', '熊猫'],
['🐨', '考拉'], ['🐯', '老虎'], ['🦁', '狮子'], ['🐮', '牛'],
['🐷', '猪'], ['🐸', '青蛙'], ['🐵', '猴子'], ['🐔', '鸡'],
['🐧', '企鹅'], ['🐦', '鸟'], ['🦄', '独角兽'], ['🐝', '蜜蜂'],
['🐢', '乌龟'], ['🐟', '鱼'], ['🐬', '海豚'], ['🐳', '鲸鱼']
]
},
{
name: '食物',
list: [
['🍎', '苹果'], ['🍊', '橘子'], ['🍋', '柠檬'], ['🍉', '西瓜'],
['🍇', '葡萄'], ['🍓', '草莓'], ['🍒', '樱桃'], ['🍑', '桃子'],
['🥭', '芒果'], ['🍍', '菠萝'], ['🍌', '香蕉'], ['🥑', '牛油果'],
['🍔', '汉堡'], ['🍟', '薯条'], ['🍕', '披萨'], ['🌭', '热狗'],
['🍿', '爆米花'], ['🍩', '甜甜圈'], ['🍪', '饼干'], ['🎂', '蛋糕'],
['🍚', '米饭'], ['🍜', '面条'], ['🍣', '寿司'], ['☕', '咖啡'],
['🍺', '啤酒'], ['🍵', '茶']
]
},
{
name: '自然',
list: [
['☀️', '太阳 晴'], ['🌙', '月亮'], ['⭐', '星星'], ['🌟', '闪耀'],
['💫', '眩晕星'], ['🌈', '彩虹'], ['🔥', '火'], ['🌊', '海浪 水'],
['🌸', '樱花'], ['🌺', '花'], ['🌲', '松树'], ['🌳', '树'],
['🌴', '椰树'], ['☁️', '云'], ['⚡', '闪电'], ['❄️', '雪花'],
['🌍', '地球'], ['🌧️', '下雨'], ['🍀', '四叶草'], ['🌵', '仙人掌']
]
},
{
name: '符号',
list: [
['✅', '对勾 通过'], ['❌', '叉 错误'], ['⚠️', '警告'], ['❓', '问号'],
['❗', '感叹号'], ['💡', '灯泡 想法'], ['🔔', '铃铛 通知'], ['📌', '图钉'],
['📎', '回形针'], ['✏️', '铅笔'], ['📚', '书'], ['💻', '电脑'],
['📱', '手机'], ['🔑', '钥匙'], ['💰', '钱'], ['❤️', '红心 爱'],
['💔', '心碎'], ['🔒', '锁'], ['🔍', '搜索 放大镜'], ['🗑️', '垃圾桶 删除'],
['⭕', '圈'], ['🚫', '禁止'], ['♻️', '回收'], ['🔗', '链接']
]
},
{
name: '活动',
list: [
['⚽', '足球'], ['🏀', '篮球'], ['🏈', '橄榄球'], ['⚾', '棒球'],
['🎾', '网球'], ['🏐', '排球'], ['🎱', '台球'], ['🎮', '游戏'],
['🎯', '靶心 目标'], ['🎲', '骰子'], ['🎸', '吉他'], ['🎺', '小号'],
['🎬', '电影'], ['🎨', '画画'], ['🚀', '火箭'], ['✈️', '飞机'],
['🚗', '汽车'], ['🏠', '房子'], ['🎉', 'party 庆祝'], ['🎁', '礼物'],
['🏆', '奖杯'], ['🥇', '金牌'], ['🎤', '麦克风'], ['📷', '相机']
]
}
]
const RECENT_KEY = 'emoji-picker-recent'
const RECENT_MAX = 24
const cat = ref(CATEGORIES[0].name)
const keyword = ref('')
const copied = ref('')
const recent = ref([])
const nameMap = new Map()
CATEGORIES.forEach(c => c.list.forEach(([e, n]) => nameMap.set(e, n)))
function nameOf(e) {
return nameMap.has(e) ? nameMap.get(e) : ''
}
const currentList = computed(() => {
const found = CATEGORIES.find(c => c.name === cat.value)
return found ? found.list : []
})
const searchResult = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return []
const out = []
CATEGORIES.forEach(c => {
c.list.forEach(item => {
if (item[1].toLowerCase().includes(kw) || c.name.includes(kw) || item[0] === kw) {
out.push(item)
}
})
})
return out
})
function loadRecent() {
const raw = localStorage.getItem(RECENT_KEY)
if (raw === null) return
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) {
recent.value = arr.filter(x => typeof x === 'string' && x.length > 0).slice(0, RECENT_MAX)
}
} catch {
// 本地数据损坏时直接忽略,不影响功能
localStorage.removeItem(RECENT_KEY)
}
}
function pushRecent(e) {
const next = [e, ...recent.value.filter(x => x !== e)].slice(0, RECENT_MAX)
recent.value = next
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
}
function clearRecent() {
recent.value = []
localStorage.removeItem(RECENT_KEY)
}
async function pick(e) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(e)
} else {
// 非安全上下文(http 访问)没有 Clipboard API,退回 textarea + execCommand
const ta = document.createElement('textarea')
ta.value = e
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
pushRecent(e)
copied.value = e
setTimeout(() => { if (copied.value === e) copied.value = '' }, 1500)
}
onMounted(loadRecent)2.3 效果截图
