这是一个纯前端项目,目的很简单,能跑起来就行。而且由于笔者基础非常差,因此学习路线和写下的内容可能显得非常诡异。

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 inpm 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,即靠左对齐。相应的还有 centerflex-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; // 如果没初始值,ts 必须这么做
#age; // 私有字段
constructor(name) {
this.name = name; // 也可不提前声明,直接赋值即可
}

speak() {
console.log(`${this.name} makes a sound.`);
}
}

class Dog extends Animal {
constructor(name, breed) {
super(name); // 调用父类 constructor
this.breed = breed;
}

bark() {
console.log('Woof!');
}
}

const d = new Dog('Rex', 'Golden');
d.speak(); // Rex makes a sound.
d.bark(); // Woof!

现在介绍一下 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"
> <!--这里是 vue 模板语法-->
{{ category.label }}
</button>
</div>

由于当前 active 的元素底下需要高亮条,这里用伪元素 after 实现即可。为了定位,需要:

1
2
3
.tab {
position: relative;
}

这是让 absolute 的子元素相对自己去定位。另外,在微调自身位置时,也经常使用相对定位。

absolute 的作用就是相对父元素去定位。它回去找最近的一个有定位的父元素,比如 relativeabsolutefixed

高亮条的定位需要先将其起始位置定位到父元素中间的位置(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 样式 ==== */
.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; /* 贴在 padding 开始的位置 */
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); // 找到 分类 id 等于当前 activeCategoryId
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 样式 ==== */
.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 样式 ==== */
.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 正常有 newVal oldVal 两个参数,但是我们这里只用 newVal,第二个参数就可以省略
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 = () => {
// do something
};

这里是一个事件回调函数,类似的写法有 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; // natural 是图片原始尺寸,width 是当前显示尺寸
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d')!; // 断言非 null

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); // await:等到 ... 完成
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 # 如果你的默认分支是 master,请将其改为 master

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" # 你可以根据项目需求修改 Node 版本
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 # Vite 默认打包输出的文件夹是 dist

仓库:https://github.com/james1BadCreeper/manosaba-emoji-generator
项目地址:https://manosaba.iznomia.com/

4. 总结

其实直接交给 AI 可以全自动完成全部过程,不过本着在航天时代体验走地鸡生活的理念,还是手搓了这么一个东西。

还是挺好玩的,而且做出来的这个东西对我来说也挺有用的。

还是有一些东西需要修补的,比如当浏览器过矮的时候的样式错误和移动端适配以及证词的生成。但是这篇文章再不发有点那个了,因此先这样,之后再改。


Nothing built can last forever.
本站由 iznomia 使用 Stellar 1.30.4 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。