这是一个纯前端项目,目的很简单,能跑起来就行。而且由于笔者基础非常差,因此学习路线和写下的内容可能显得非常诡异。
0. 一个搞笑的前置小插曲
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| (base) ➜ vue-project npm i npm warn ERESOLVE overriding peer dependency npm warn ERESOLVE overriding peer dependency npm error code ERR_SSL_CIPHER_OPERATION_FAILED npm error 00CDC92A64780000:error:1C800066:Provider routines:ossl_gcm_stream_update:cipher operation failed:../deps/openssl/openssl/providers/implementations/ciphers/ciphercommon_gcm.c:346: npm error npm error A complete log of this run can be found in: /home/james1/.npm/_logs/2026-05-13T03_19_10_181Z-debug-0.log (base) ➜ vue-project npm install npm warn ERESOLVE overriding peer dependency npm warn ERESOLVE overriding peer dependency
added 146 packages, and audited 147 packages in 9s
32 packages are looking for funding run `npm fund` for details
found 0 vulnerabilities
|
难道 npm i 和 npm install 不等价?读完之后发现是 SSL 加密失败了,其实就是网络问题。
1. 开始
由于这就是个单页面应用,因此不需要使用路由。之后会做一些更好玩的东西来学一下路由。
1.1 主体框架的设计
这部分我们会用到 flex 布局,因此先学习一下。
css 默认布局采用流式布局,即从上往下排列。
可以发现 normal flow 可以被 flexbox 替代,但是这样会让代码变得臃肿且增加额外的性能开销。
弹性布局(flexbox),有父元素(容器)和子元素(项目)。
元素排列的方向是主轴方向,垂直于主轴的是交叉轴。
首先是容器的属性:
1 2 3 4
| .container { display: flex; flex-direction: row / column; }
|
首先是 justify-content,操控元素主轴上的分布,默认值是 flex-start,即靠左对齐。相应的还有 center 和 flex-end。
而 align-items 控制交叉轴上的元素分布。其默认是 stretch,即拉伸填满交叉轴。除了上述三种 justify-content 的值,还支持 baseline,即按照文字基线对齐。
然后是 gap,子元素之间的间距。flex-wrap,子元素放不下时是否换行。flex: 1 比较常见,用于占满全部宽度,flex: 0 会取消伸缩。
然后是子元素的属性。align-self 可以覆盖父元素的 align-items。
比较有趣的是 order,可以设置一个整数,数小的元素会排在前面,这样就可以用 CSS 排版而不用修改 HTML 结构。
1.2 设计页面
结构很简单:标题,双栏结构(选择与生成,在移动端合并为单栏),页脚。
我们先来设计 header。
1 2 3 4 5 6 7
| <header class="app-header"> <img src="/logo.webp" alt="魔法少女的魔女审判" class="logo" height="70px" /> <h1 class="title"> <span class="title-part title-main">表情包</span> <span class="title-part title-sub">生成器</span> </h1> </header>
|
标题应该居中,包括容器在内,样式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| .app-container display: flex flex-direction: column height: 100vh background: #0c1113 padding: 60px 64px
.app-header display: flex justify-content: center align-items: center gap: 10px padding: 12px 24px
.title display: flex align-items: baseline gap: 4px margin: 0 font-size: 2rem line-height: 1.2
.title-main color: #ff6b35 font-weight: 800 font-size: 1em
.title-sub color: #dfdfdf font-weight: 400 font-size: 0.7em
|
1.3 设计左栏
左栏包含的功能:选择表情,预览,输入要添加的文字。
1.3.1 表情存储
首先是表情数据的存储。这里用到 js 的 export,把当前文件的某些内容“公开”出去。
js 的变量和 python 一样是可以改变类型的,但是 ts 可以在变量名后加上冒号来标注原始类型(此时就不可以更改,如 let name: string = 'james1BadCreeper')。变量的默认值是 undefined。
然后注意 js 的内存管理是垃圾回收。
表情的设计我们通过 OOP 来解决。js 的 OOP 不像 java 那样以类为基础,而是以对象为基础。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| class Animal { name = ''; name: string; #age; constructor(name) { this.name = name; }
speak() { console.log(`${this.name} makes a sound.`); } }
class Dog extends Animal { constructor(name, breed) { super(name); this.breed = breed; }
bark() { console.log('Woof!'); } }
const d = new Dog('Rex', 'Golden'); d.speak(); d.bark();
|
现在介绍一下 ts 中的 interface,它会在编译后消失,目的是描述一个对象长什么样。我们有:
1 2 3 4 5
| export interface Emoji { id: string name: string src: string }
|
1.3.2 表情选择
首先我们来介绍 Vue 中最重要的东西之一:ref,用来创建一个响应式数据引用。当这个值改变时,所有用到它的地方都会自动更新。也包括需要传这个数据的地方,都会自动传。
ref 返回的是一个对象,因此在 <script> 中,读取其值需要 .value。但是在 <template> 里,会自动解包,就不需要了。但是注意到只有最外层的 ref 会被解包,不过暂时用不到不予讨论。
因为 ref 指向的是一个引用,因此声明时使用 const。
1 2 3
| import { ref } from 'vue';
const activeCategoryId = ref<string>(emojiCategories[0]?.id ?? '');
|
1 2 3 4 5 6 7 8 9 10
| <div class="category-tabs"> <button v-for="category in emojiCategories" :key="category.id" :class="['tab', { active: category.id === activeCategoryId }]" @click="activeCategoryId = category.id" > {{ category.label }} </button> </div>
|
由于当前 active 的元素底下需要高亮条,这里用伪元素 after 实现即可。为了定位,需要:
1 2 3
| .tab { position: relative; }
|
这是让 absolute 的子元素相对自己去定位。另外,在微调自身位置时,也经常使用相对定位。
absolute 的作用就是相对父元素去定位。它回去找最近的一个有定位的父元素,比如 relative、absolute 和 fixed。
高亮条的定位需要先将其起始位置定位到父元素中间的位置(left: 50%),然后将自身长度的一半移到左边(transform: translateX(-50%))。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| .category-tabs { display: flex; justify-content: center; gap: 8px; padding-bottom: 8px; margin-bottom: 8px; border-bottom: 1px solid rgba(255, 255, 255, .3); }
.tab { background: none; border: none;
color: rgba(255, 255, 255, 0.6); font-size: 1rem; font-family: inherit;
cursor: pointer; user-select: none;
position: relative; }
.tab:hover { color: rgba(255, 255, 255, 0.9); }
.tab.active { color: #ff6b35; }
.tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 70%; height: 1.3px; background: #4d78db; border-radius: 2px; }
|
接下来是 emoji-grid 的设计。
1 2 3 4 5 6 7 8 9 10
| const currentEmojis = computed(() => { const category = emojiCategories.find(cat => cat.id === activeCategoryId.value); return category?.list ?? []; })
const selectedEmoji = ref<Emoji | null>(emojiCategories[0]?.list[0] ?? null);
function selectEmoji(emoji: Emoji) { selectedEmoji.value = emoji; }
|
如果使用普通方法的话,每次在 category-tabs 发生 click 事件后都需要执行调用 currentEmojis 函数,造成额外的代码复杂度。
computed 则提供了缓存 + 自动更新的特性,非常适合“从已有状态派生出新数据”的场景。里面的参数是一个 getter 函数,返回值是一个 ref。
样式采用 Material Design,设计成一个药丸。代码和 category-tabs 类似,这里不重复。
1.3.3 表情预览
其实就很简单了。
1 2 3 4 5 6 7
| <div class="preview-image"> <img :key="selectedEmoji?.id" :src="selectedEmoji?.src" :alt="selectedEmoji?.name" /> </div>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| .preview-image { height: 240px; border-radius: 12px; padding: 12px; margin-top: 16px; display: flex; justify-content: center; margin-bottom: 16px; }
.preview-image img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); animation: fadeIn 0.25s ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
1.3.4 输入
最后是输入框,比较简单:
1 2 3
| <div class="input-area-container"> <input class="input-area" type="text" placeholder="在这里输入文字,不支持换行" /> </div>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| .input-area-container { display: flex; }
.input-area { flex: 1;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 10px 14px;
color: rgba(255, 255, 255, 0.87); font-size: .9rem; font-family: inherit; line-height: 1.5;
outline: none; transition: border-color 0.2s ease, box-shadow 0.2s ease;
&::placeholder { color: rgba(255, 255, 255, 0.3); opacity: 1; }
&:focus { border-color: #f1482b; box-shadow: 0 0 0 2px rgba(255, 107, 53, 0.2); } }
|
然后是我们要将输入的东西记录到一个 ref 中,这里可以采用 v-model 进行双向绑定:
1 2 3 4 5 6 7 8 9 10
| <script> const inputText = ref(''); </script> <input v-model="inputText" />
<input :value="inputText" @input="inputText = ($event.target as HTMLInputElement).value" />
|
2. 表情包输出
最为重要的部分。右栏暂时没有设计,因为没什么需要设计的。
2.1 信息的通信
我们先从文字入手,图片是同理的。
一开始我自己的想法是直接定义一个 ref 然后把它 export 出去。但是被 AI 告诉这过于野蛮,Vue 的设计逻辑是子组件通过 emit 向父组件发射事件,然后父组件通过 prop 传递给子组件。
首先是老板给员工传东西,使用 :子组件属性名="父组件变量"(比如 <PreviewPanel :image="selectedImage" :text="inputText" />)。然后子组件定义:
1 2 3 4 5
| const props = defineProps<{ image: string | null; }>();
let x = ref<string | null>(props.image);
|
然后是员工上报老板,用 emit('事件名', 参数),父组件通过 @事件名="处理函数" 来接收:
1 2 3 4 5 6 7
| const emit = defineEmits<{ 'update:text': [string] generate: [] }>();
emit('update:text', '新的文字'); emit('generate');
|
在父组件中,需要这样:
1
| <SelectPanel @update:text="handleTextUpdate" @generate="handleGenerate" />
|
1 2 3 4 5 6
| function handleTextUpdate(newText: string) { } function handleGenerate() { }
|
v-model 同样可以用于简便地实现父组件和子组件的双向绑定,这里用不到所以不做介绍。
因此我们在 SelectPanel 中要进行 emit。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const emit = defineEmits<{ 'update:text': [string] 'update:image': [string] }>();
const inputText = ref('');
watch(inputText, (newVal) => { emit('update:text', newVal); });
watch(selectedEmoji, (newEmoji) => { emit('update:image', newEmoji?.src ?? ''); }, { immediate: true });
|
在 App.vue 中:
1 2 3 4
| <SelectPanel v-model:text="inputText" v-model:image="selectedImage" />
|
相当于将这两个类型的事件的结果赋值给相应的变量。
但其实按照我的设计逻辑这里不应该 watch 的,因为不需要信息一改变就传,但是我懒得改了(
给 PreviewPanel 发东西也很简单:
1 2 3 4
| const props = defineProps<{ text: string image: string | null }>();
|
接下来要给 PreviewPanel 发送 Trigger 事件来生成表情,需要 watch 一个 prop:
1 2 3
| watch(() => props.generateID, () => {
});
|
watch 看着的必须是一个响应式对象(ref、reactive(虽然还没用过)),或者是返回响应值的 getter 函数,这里的 () => ... 就是一个 getter 函数,去追踪这个数值的变化。
2.2 Canvas 的使用
1 2 3 4 5
| const img = new Image(); img.src = props.image; img.onload = () => { };
|
这里是一个事件回调函数,类似的写法有 element.onclick = () => { console.log('clicked') }。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0); ctx.font = 'bold 64px "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.fillStyle = 'white'; ctx.strokeStyle = 'black'; ctx.lineWidth = 6; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
const x = canvas.width / 2; const y = canvas.height * 0.9; ctx.strokeText(props.text, x, y); ctx.fillText(props.text, x, y);
previewUrl.value = canvas.toDataURL('image/png'); isGenerating.value = false;
|
2.3 输出
这里记录一个比较有意思的,图片的复制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| async function copyImage() { if (!previewUrl.value) return;
try { const response = await fetch(previewUrl.value); const blob = await response.blob(); await navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }) ]); alert('已复制到剪贴板!'); } catch (err) { console.error('复制失败:', err); alert('复制失败,请检查浏览器权限'); } }
|
3. 项目发布
先丢到 Github 仓库上,然后使用 Github Actions 自动生成静态网页。
我服了怎么只有 Gemini 干的是对的,不管了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| name: Deploy to GitHub Pages
on: push: branches: - main
permissions: contents: write
jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4
- name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "24.13.x" cache: 'npm'
- name: Install dependencies run: npm install
- name: Build run: npm run build
- name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./dist
|
仓库:https://github.com/james1BadCreeper/manosaba-emoji-generator。
项目地址:https://manosaba.iznomia.com/。
4. 总结
其实直接交给 AI 可以全自动完成全部过程,不过本着在航天时代体验走地鸡生活的理念,还是手搓了这么一个东西。
还是挺好玩的,而且做出来的这个东西对我来说也挺有用的。
还是有一些东西需要修补的,比如当浏览器过矮的时候的样式错误和移动端适配以及证词的生成。但是这篇文章再不发有点那个了,因此先这样,之后再改。