写给女儿的英语App

4 阅读10分钟

背景

女儿上幼儿园之前,我跟老婆说得很坚定:幼儿园阶段不卷,快乐教育。 结果话说得很轻松,真正到上学看到其他同学也报了,没忍住给她报了英语外教班。

上课本身倒没什么问题,真正让我头疼的是课后复习。 每次上完课,老师都会在群里发当天的学习内容,有单词、有句型、有字母操,还有课堂视频和家庭作业。比如:


📆【今日学习内容】 LG1
————————————
👩‍🏫教学目标
一、复习Student book—《Classroom》
1. 重点词汇+句型
window 🪟
door 🚪
desk 桌子
chair 🪑
book 📖

二、学习《Super ABC》P12 Letter Cc
❤️学习Letter Cc 字母操
❤️学习关于字母c的单词
1️⃣cat 猫🐱
2️⃣cap 帽子🧢
3️⃣cake 蛋糕🍰
4️⃣car 汽车🚗
❤️相关句型学习

三、课前口语对话

四、书写练习
———————————— 
🌞【家庭作业】:
1️⃣口语作业💬
朗读《Super ABC》P12关于字母c的单词
————————————
[Sun]【今日课堂小结】
今天课堂宝贝们都很活跃👏 请回家之后多听音频磨耳朵进行复习哟!❤️请各位宝贝一定要完成作业噢💕💕   

或者:


📆【今日学习内容】 
————————————
👩‍🏫教学目标
1.Free Talk:课前问答

2.复习字母A所学内容

3.❤️学习字母B相关内容
—字母操B的学习
—初步识别字母B的样子
—关于字母b的重点单词学习
1️⃣bee 蜜蜂🐝
2️⃣ball 球
3️⃣balloon 气球🎈
4️⃣bear 熊🐻

拓展词汇:box 📦  bike 🚴
—句型学习:B is for __
—句型理解性输入:
1.I see a bee. (我看见一只蜜蜂。)
2. I have a ball. (我有一个球。)
3. The balloon is big. (气球很大。)
4. The bear is brown. (熊是棕色的。)

4.书写练习
———————————— 
🌞【家庭作业】:
口语作业💬
听音频 朗读《Super ABC book》P8
————————————
[Sun]【今日课堂小结】
今天课堂宝贝们都很活跃👏 请回家之后多听音频磨耳朵进行复习哟!❤️请各位宝贝一定要完成作业噢💕💕    

老师整理得其实已经很详细了,但它更像一份“课堂记录”,并不是一个适合 4 岁孩子反复练习的产品。

我最开始的复习方式很朴素:找到老师发的视频,点开,再拉回到要复习的位置,播一遍,停下来,跟读,再继续。

一两次还好,次数多了就发现效率很低。

孩子的注意力本来就短,家长还在群聊里翻视频、找进度条,她已经跑去玩别的了。有时候只是想复习 catcapcakecar 四个单词,却要把整段课堂视频重新播一遍。

后来我又试着用欧路词典、百词斩之类的 App 录入生词,希望让它们自动轮播。

百词斩 自定义的单词库,学习时,内容太多不聚焦

欧陆词典 要么只有文字, 要么虽然还有视频,但是内容过于复杂

这些 App 本身都很好,但放到我的场景里,总差一点:

  • 有些课堂单词没有合适的图例;

  • 有些解释是写给成年人看的,对幼儿来说太复杂;

  • 词典更关心音标、词性和释义,我只想让孩子看到一个清楚的画面;

  • 学习路径是产品提前设计好的,很难完全贴合老师当天教的内容;

  • 页面上的操作太多,孩子容易点到不需要的地方。

说到底,我要的并不是一套庞大的英语课程。

我只想把老师今天教的几个单词和句子,整理成一组能看、能听、能自动播放的复习卡片。

这个需求很小,小到以前我大概不会专门为它写一个 App。

但 AI 时代有一个很有意思的变化:以前这种“只服务一个家庭”的需求,投入产出比太低;现在我可以用 AI 快速把想法做成原型,再用自己的前端经验把它慢慢修成真正顺手的工具。

所以,之前给自己做完英语学习 App 之后,我又做了第二个版本。

这一次不是写给自己,而是写给女儿。

需求

我没有一上来就想做打卡、积分、排行榜,也没有打算造一个儿童版百词斩。

我先把需求压到最小。

老师每次发来的内容虽然很长,但真正需要反复复习的核心只有两类:

单词 + 句子

像“教学目标”“课堂小结”“书写练习”“请各位宝贝完成作业”这些内容,对家长有用,却不应该出现在孩子的复习卡片里。

因此,这个 App 最核心的流程被我整理成了:

老师发来课堂内容
        ↓
提取英文单词和句子
        ↓
补充中文、Emoji 或图片
        ↓
逐张播放并朗读
        ↓
记录当天复习进度

在这个核心流程之上,我又补了几个实际使用时绕不开的要求。

1. 内容必须按上课日期组织

孩子每节课学的内容不同。今天是 Letter B,下一节可能是 Letter C,再下一节又换成水果。

所以内容不能混成一个越来越长的单词本,而要按日期创建“复习日”。切换日期后,继续上次的复习进度。

2. 一张卡片只做一件事

每张卡片只显示一个字母、单词或句子,保留英文、中文、图片和发音。

孩子不用理解复杂操作,只要看当前内容、听发音,再点“下一个”。

3. 打开就能听,最好还能自动轮播

复习时不应该每个单词都让家长点一次播放。

页面切换到新卡片后自动朗读,也可以开启学习模式:朗读当前内容,停顿几秒,再自动跳到下一张。

4. 图片要简单、直观,而且允许替换

对幼儿来说,bear 对应一只熊,比一长串中文解释更有效。

系统自带 Emoji 是最省事的方案:不需要下载、不需要存储、加载也快。cat 用 🐱,bee 用 🐝,chair 用 🪑,大部分常见单词都能直接找到。

但 Emoji 不可能覆盖所有内容。有些单词没有对应图标,有些图标也不够准确,所以还要支持联网查图和本地上传。

5. 家里不同设备要看到同一份内容

如果数据只放在浏览器 localStorage,换一台手机就没有了。爸爸录好的内容,妈妈的手机也看不到。

因此它最终不能只是一个纯静态页面,还需要一个很轻量的云端存储。

实现

技术栈没有刻意求新,还是我最熟悉的一套:

Vue 3
Vite
TypeScript
Element Plus
Supabase
GitHub Pages

这类个人工具,技术选型最重要的不是“高级”,而是我能不能很快改、改完敢不敢继续用。

1. 把老师原文和生成内容分开

添加复习日时,我保留了两份内容:

  • teacherText:老师发来的完整课堂原文,只作为以后核对的依据;
  • generatedContent:真正用于生成卡片的单词和句子。

这一步很重要。

如果直接拿整段群消息去渲染,就会把 LG1Super ABC、教学目标和家庭作业一起塞进题库。与其追求一个“百分百理解所有老师文案”的万能解析器,不如让 AI 先整理出核心内容,再给家长一个可以人工确认的输入框。

例如 Letter B 这节课,最后进入应用的内容可以简化成:

Words
bee 蜜蜂🐝
ball 球
balloon 气球🎈
bear 熊🐻
box 盒子📦
bike 自行车🚲

Sentences
B is for __
I see a bee.
I have a ball.
The balloon is big.
The bear is brown.

应用会按行解析,自动判断是单词还是句子,并去掉重复项:

export function parseReviewText(rawText: string) {
  let currentCategory: ReviewItemCategory | undefined;

  const parsed = rawText.split(/\r?\n/).flatMap((line) => {
    const heading = cleanLine(line).replace(/[::]$/g, "").trim();

    if (/^(words?|单词)$/i.test(heading)) {
      currentCategory = "word";
      return [];
    }

    if (/^(sentences?|句子)$/i.test(heading)) {
      currentCategory = "sentence";
      return [];
    }

    return parseReviewLine(line).map((item) => ({
      ...item,
      category: currentCategory || item.category
    }));
  });

  return assignContextualEmojis(dedupeReviewItems(parsed));
}

如果只想从老师原文里拿单词,也可以直接提取所有英文单词,去重后再编辑:

function extractEnglishWords(sourceText: string) {
  const matches = sourceText.match(/[A-Za-z]+(?:['’-][A-Za-z]+)*/g) || [];
  const seenWords = new Set<string>();

  return matches.reduce<string[]>((words, match) => {
    const normalizedWord = match.toLocaleLowerCase();
    if (!normalizedWord || seenWords.has(normalizedWord)) return words;

    seenWords.add(normalizedWord);
    words.push(normalizedWord);
    return words;
  }, []);
}

这里 AI 并没有被塞进每一次运行流程里。它更像开发和整理阶段的助手:帮我把老师的长文本收敛成结构化内容;真正播放时,应用使用的仍然是简单、稳定、可人工修改的数据。

2. 中文翻译、Emoji 和句子自动补齐

家长只输入英文也可以。

应用会优先尝试浏览器内置翻译能力,不支持时再调用在线翻译服务,并把结果缓存在本地,避免同一个单词反复请求。

Emoji 的策略也尽量简单:

  1. 老师原文里本来有 Emoji,优先保留;
  2. 本地映射表里有对应单词,直接匹配;
  3. 句子中包含已经出现过的单词,就复用这个单词的 Emoji;
  4. 实在找不到,再显示通用占位图标。

比如:

function buildReviewItem(english: string, chinese = "") {
  const explicitEmoji = extractReviewEmoji(chinese);

  return {
    id: createReviewId("item"),
    english,
    chinese,
    category: inferCategory(english),
    emoji: explicitEmoji || pickEmoji(english, chinese)
  };
}

为什么先用系统 Emoji,而不是一开始就为每个单词联网找图?

因为“能稳定显示”比“图片特别漂亮”更重要。

Emoji 不受图片防盗链、接口限流和网络波动影响。对于 applecatcar 这种幼儿词汇,它已经足够清楚。

3. Emoji 不够用时,再联网查图

遇到 Emoji 表达不准确的内容,卡片上可以直接打开图片选择器。

应用内接入了 Openverse 和 Wikimedia Commons,输入英文关键词后返回几张候选图。如果 Openverse 请求失败,会自动尝试 Wikimedia。

async function fetchReferenceImages(item, cursor, source) {
  if (source === "openverse") {
    try {
      return await fetchOpenverseReferenceImages(item.english, cursor);
    } catch {
      return await fetchCommonsReferenceImages(item.english, cursor);
    }
  }

  return fetchCommonsReferenceImages(item.english, cursor);
}

如果开放图库还是找不到合适的图片,也可以跳到百度图片、搜狗图片等外部来源搜索,复制图片地址回来。

再不行,就本地上传。

本地图片不会原封不动地传上去。页面会先用 Canvas 把最长边压到 720px,再转成 WebP,控制体积后上传到 Supabase Storage:

const maxDimension = 720;
const scale = Math.min(
  1,
  maxDimension / Math.max(image.naturalWidth, image.naturalHeight)
);

context.drawImage(image, 0, 0, width, height);
const result = canvas.toDataURL("image/webp", 0.72);

这套图片策略其实就是三层兜底:

系统 Emoji
    ↓ 不合适
Openverse / Wikimedia 联网查图
    ↓ 还不合适
本地上传到 Supabase Storage

不追求一次做到完美,但保证家长总有办法给孩子换成一张看得懂的图。

4. 发音不单独购买服务,先用浏览器能力

英语朗读使用的是浏览器自带的 speechSynthesis

const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "en-US";
utterance.pitch = 1.08;
utterance.rate = 0.78;

window.speechSynthesis.speak(utterance);

我把语速调得比默认值慢一点,更适合孩子跟读。

为了防止某些浏览器没有正常触发结束事件,还加了一个兜底计时器。这样自动学习模式不会永远卡在某一张卡片上。

这个方案的声音未必像专业外教音频那么自然,但它免费、调用简单,而且对“重复听当天的几个单词”已经够用。

5. 本地优先,再同步云端

复习内容和学习进度会先写进 localStorage

这么做是因为家庭学习工具最怕网络一抖,整个页面就不能用了。本地缓存至少保证已经录入的内容还能继续复习。

云端同步则负责解决多设备共享。

我没有把 Supabase 的 service role key 放到前端,而是让所有请求经过 Supabase Edge Function:

Vue 页面
   ↓
Supabase Edge Function
   ├── PostgreSQL:保存复习内容与版本号
   └── Storage:保存本地上传的参考图片

读取可以公开进行,写入则要输入家庭编辑码。

同时,每份云端数据都有一个 revision。保存时前端会带上自己读取到的版本号;如果云端已经被另一台设备改过,接口返回 409,提示先同步最新内容,避免后一次保存直接覆盖前一次修改。

对于只有家里几个人使用的小工具,这套权限并不复杂,却刚好够用。

部署上的坑

一开始我想把前端和数据接口都放在 Cloudflare。

前端用 Cloudflare Pages,数据放 D1,再通过 Pages Functions 提供读取和保存接口。方案看起来很完整,而且都有免费额度。

我甚至已经把表结构、D1 binding 和 REVIEW_EDIT_CODE 都接好了。

但实际调试时,问题不是代码跑不通,而是服务经常无法正常访问。部署后的页面、接口和控制台在我的网络环境里都不够稳定,调一个很小的问题,也要反复确认到底是代码错了,还是网络没有连上。

个人项目最消耗人的往往不是难题,而是这种不确定性。

最后我没有继续硬扛,直接把数据层迁到了 Supabase:

  • D1 换成 PostgreSQL;

  • Pages Functions 换成 Supabase Edge Function;

  • 参考图片放进 Supabase Storage;

  • 数据表开启 RLS,前端不能直接访问;

  • 写操作继续使用家庭编辑码;

  • 保留 revision 做乐观锁,避免多设备覆盖。

前端本身仍然是 Vite 静态构建,所以没有必要和数据服务绑在一起。我把页面交给 GitHub Pages,通过 GitHub Actions 在每次推送 main 后自动构建:


- name: Build

run: pnpm build:github

env:

VITE_REVIEW_SYNC_URL: ${{ vars.VITE_REVIEW_SYNC_URL }}

- name: Deploy to GitHub Pages

uses: actions/deploy-pages@v4

这里还有一个容易漏掉的点:GitHub Pages 部署在仓库子路径下,Vite 的 base 需要对应仓库名。


const base =

process.env.VITE_DEPLOY_TARGET === "github-pages"

? "/vancy-learn-english/"

: "/";

最终架构变成了:


GitHub Pages:部署 Vue 静态页面

Supabase PostgreSQL:保存复习内容

Supabase Storage:保存上传图片

Supabase Edge Function:统一读写入口

页面和数据分开以后,反而更清楚。

需要说明的是,GitHub、Supabase、Openverse、Wikimedia 这些服务在国内网络环境下都可能有访问波动。我自己在初始化项目、部署函数和排查接口时,需要准备稳定的网络环境。

这也是为什么应用始终保留 localStorage:云端同步失败时,本机缓存还能继续用,不能因为一个免费服务暂时连不上,就耽误当天复习。

效果

最终做出来的东西,比我最开始想的更简单。

整体效果

设置

转化内容

设置自定义图片

学习模式

打开页面后,孩子看到的是当前复习日的一张大卡片。中间是 Emoji 或参考图片,下面是英文和中文。卡片切换时自动朗读,也可以进入学习模式,让它按照设置的间隔自动轮播。

家长侧的操作则集中在“复习设置”里:

  • 按日期切换老师当天的学习内容;

  • 粘贴老师原文,提取单词并整理句子;

  • 新增、编辑和删除复习日;

  • 调整自动播放的间隔;

  • 给不合适的单词更换 Emoji 或参考图;

  • 同步云端内容,让家里其他设备直接使用。

以前复习 Letter B,要先翻群聊、找视频、拖进度条,再一个个暂停。

现在只要打开当天的复习日:


bee → ball → balloon → bear

单词播完,再接着听:


I see a bee.

I have a ball.

The balloon is big.

The bear is brown.

它没有复杂的学习体系,也没有让人上瘾的积分设计。

但它把一次原本有点费家长的复习,变成了孩子愿意自己点几下的过程。

这对我来说就够了。

总结

做完这个 App,我越来越觉得,好的软件不一定是功能最多的软件。

对商业产品来说,我们会关心用户规模、增长和通用性;但对个人产品来说,价值可以非常具体:它只要替家里省掉每天十分钟重复、低效又容易烦躁的操作,就已经有存在的意义。

这次真正解决问题的,也不是 Vue、Supabase 或 AI 里的某一个技术点。

而是先把痛点说清楚:


我不需要另一套英语课程。

我只需要把老师今天教的单词和句子,变成孩子愿意反复听的卡片。

需求一旦收敛,后面的实现自然就简单了。

AI 把“做一个只服务自己家人的软件”的成本降了下来,而前端经验让我可以继续把这个原型修到顺手、稳定、能长期使用。

项目地址:github.com/mjsong07/va…

在线地址:mjsong07.github.io/vancy-learn…

“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.” — Antoine de Saint-Exupéry

完美并非无可增加,而是无可删减。