
最近在二次元社区里经常刷到“用炫卡斗士W方式打开宝可梦XYZ”这种标题。很多人把它当成一个搞笑二创或视频特效但如果你是一名前端开发者应该能看到另一层有意思的问题怎么把一个动画 IP 的内容拆成一张张有收集、战斗和进化逻辑的卡牌再做成一个可交互的网页应用这类项目看起来容易实际做起来很容易翻车。比如加了三种 CSS 翻转动画之后发现数据和组件之间根本没法联动或者做了 50 张卡之后手写 JSON 里已经全是重复字段。核心问题不在动画而在于你想清楚没有卡牌到底应该用什么数据结构稀有度怎么设计进化链怎么建模这篇文章会从一个开发视角把“宝可梦XYZ”的内容素材抽象成一套可运行的卡牌前端项目。我们用 Vue 3 Vite 实现一个最小但完整的卡牌图鉴支持抽卡、翻转动画、属性展示和进化链联动。你可以把它理解成用“炫卡斗士W”这种卡牌交互方式重新打开宝可梦XYZ世界观的一次前端实战。读完你会得到的不是一段炫酷但脆弱的动画而是一套能持续扩展的卡牌应用骨架。1. 这篇文章真正要解决的问题先说结论卡牌化内容应用的第一优先级是数据结构而不是动画。动画只是用来表现数据的如果数据模型不对后面所有展示层都会被迫打补丁。“用 X 方式打开 Y”这种创作形式在短视频平台非常流行。它通常意味着把一套原有的内容放进另一套互不相关的表达框架里制造反差感。但对开发者来说这其实是一次非常典型的内容工程化任务你拿到了一个世界观丰富的 IP 素材需要把它们转成一套可维护、可扩展、可交互的数据产品。宝可梦XYZ里有训练家、宝可梦、属性、招式、进化链、超级进化甚至羁绊现象这些内容如果不做结构化设计就只能停留在视频剪辑层面没法成为应用。真正适合读这篇文章的人有两类。第一类是想做粉丝向网站或小程序的开发者正在纠结“卡牌页面应该怎么做”第二类是刚学完 Vue 基础、想找一个完整练手项目的同学。前者可以从这里拿到可落地的数据建模和组件拆分思路后者可以跟着步骤跑通项目并且理解每一步代码放在哪里、为什么这样放。这个项目的边界也很重要文章只讨论技术方案不涉及任何版权争议。示例数据仅为演示用途不建议直接用于商业产品。你在开发中如果要用真实宝可梦素材需要自行确认 IP 授权和素材使用规范。2. 核心概念“炫卡斗士W方式”到底是什么“炫卡斗士W”并不是一个严格的技术名词它更像一种卡牌游戏交互范式的代称。这种范式的核心特征可以概括为四个点卡牌是信息的最小容器一张卡承载角色、属性、数值、技能和背景。稀有度驱动收集欲望从普通卡到传说卡层级感很强。卡牌之间存在组合逻辑比如进化链、阵容羁绊、属性克制。开牌/抽卡是一种体验动作用动画、音效和节奏感制造“开包”的惊喜。如果把“炫卡斗士W方式”翻译成产品语言就是把一段内容拆成有边界的实体再给每个实体赋予可交互的表现层。放到宝可梦XYZ场景里我们并不需要真做一个完整对战游戏只需要把世界观中最能体现“卡牌感”的内容提炼出来。宝可梦XYZ这部动画的核心看点是卡洛斯地区的冒险故事里面有道馆挑战、劲敌对战、超级进化以及甲贺忍蛙和小智之间的羁绊进化。从内容到卡牌可以这样映射动画内容卡牌化表达对应数据字段宝可梦宝可梦卡name、type、rarity战斗能力HP、攻击、防御hp、attack、defense招式技能卡skills[].name、power进化关系进化链evolutionChain[]训练家羁绊训练家字段trainer超级进化/羁绊现象特殊稀有度卡rarity、form这种映射的价值在于它让前端开发者有了一个明确的数据模型意识。很多人做卡牌应用第一反应是先找好看的卡面素材然后写 CSS 动画。但我建议反过来先把所有需要展示的内容写成结构化数据再围绕数据设计组件。数据如果稳定换主题只是换 JSON代码可以复用。如果你只是想在动画效果上模仿“炫卡斗士W”那就忽略了真正有技术价值的部分。做一个好看的单张卡片不难难的是做一套能够支撑 100 张卡、多个属性、多段进化的前端系统。3. 环境准备与前置条件这个项目是纯前端应用不需要后端服务器也不需要数据库。运行环境相对简单Node.js建议使用当前 LTS 版本。具体版本请以实际项目为准本文演示的是通用思路不需要写死版本号。npmNode.js 自带如果版本过旧可以升级。浏览器推荐 Chrome 或 Edge方便用 DevTools 调试。编辑器VS Code 或 WebStorm 均可。包管理我们使用 Vite 创建 Vue 3 项目。由于我们只需要展示卡牌数据和动画不涉及用户登录、持久化存储所以项目结构可以非常轻。后续如果你要加入收藏、背包、抽卡记录等功能再考虑引入 Pinia 或后端 API现在不需要。创建项目时我建议使用 Vite 官方脚手架。Vite 的本地启动速度比传统 Webpack 方案快很多对新手也更友好。打开终端执行npm create vitelatest poke-xyz-card -- --template vue注意npm create vite在不同版本下可能会提示交互选项。如果命令行无法直接指定模板可以手动选择 Vue 模板。项目创建完成后进入目录并安装依赖cd poke-xyz-card npm install npm run dev如果一切正常终端会输出一个本地访问地址类似http://localhost:5173。打开这个地址就能看到 Vite 默认的 Vue 欢迎页。到这里环境准备就完成了。4. 数据建模卡牌项目的地基在写任何动画代码之前先把数据模型定下来。这是整个项目最重要的决策。对于一张宝可梦卡我们需要保存以下信息id唯一标识用于列表渲染时的 key。name名称。trainer训练家。type属性比如电、水、火、草。rarity稀有度可以设计为 R、SR、SSR 等。hp、attack、defense基础数值。skills技能数组每个技能有名称、威力、效果。evolutionStage当前进化阶段从 1 开始。evolutionChain进化链数组例如 [皮丘, 皮卡丘, 雷丘]。这个结构很接近传统卡牌游戏中的“卡牌实体”。为了演示我们创建src/data/cards.json文件写入几条示例数据。注意这些数据只是为了跑通流程不是官方数据也不包含真实图片资源。[ { id: xyz-001, name: 皮卡丘, trainer: 小智, type: 电, rarity: SR, hp: 60, attack: 55, defense: 40, skills: [ { name: 十万伏特, power: 90, effect: 概率麻痹 }, { name: 电光一闪, power: 40, effect: 先制攻击 } ], evolutionStage: 1, evolutionChain: [皮丘, 皮卡丘, 雷丘] }, { id: xyz-002, name: 甲贺忍蛙, trainer: 小智, type: 水, rarity: SSR, hp: 72, attack: 95, defense: 67, skills: [ { name: 飞水手里剑, power: 80, effect: 必定先制 }, { name: 水手里剑, power: 60, effect: 连续攻击 } ], evolutionStage: 3, evolutionChain: [呱呱泡蛙, 呱头蛙, 甲贺忍蛙] }, { id: xyz-003, name: 烈咬陆鲨, trainer: 希特隆, type: 龙, rarity: SSR, hp: 108, attack: 130, defense: 95, skills: [ { name: 龙爪, power: 80, effect: 无视守护 }, { name: 地震, power: 100, effect: 范围攻击 } ], evolutionStage: 3, evolutionChain: [圆陆鲨, 尖牙陆鲨, 烈咬陆鲨] } ]这段 JSON 有两个设计细节值得注意。第一技能是一个数组而不是对象。原因是宝可梦卡牌通常有多个招式如果用对象就只能写死一两个字段扩展性差。数组结构让组件可以用v-for渲染技能列表新技能只需要追加一条记录。第二进化链用字符串数组。这样做的好处是后面做“进化链展示”时可以非常简单地用join( → )输出一条完整链路也可以作为后续战斗逻辑中判断“能否进化”的基础数据。稀有度字段也需要提前规划。在“炫卡斗士W式”的卡牌体验中稀有度不仅影响卡面颜色还会影响抽卡概率和玩家收集目标。后续如果要实现抽卡功能稀有度的权重计算就要依赖它。初始阶段可以只做展示但字段必须保留。5. 核心代码实现抽卡与翻牌动画数据模型确定以后我们来写真正的前端代码。这里会用到两个 Vue 组件App.vue负责页面整体布局、抽卡逻辑和状态管理。src/components/CardView.vue负责单张卡片的展示和翻转动画。先看CardView.vue。它接收一个card对象和flipping布尔值flipping为true时卡片会执行翻牌动画。为了减少文件依赖我们把卡片样式也写在组件内部。!-- 文件路径src/components/CardView.vue -- script setup defineProps({ card: { type: Object, required: true }, flipping: { type: Boolean, default: false } }) /script template div classcard-scene div classcard :class{ flipping } div classcard-face card-back span?/span /div div classcard-face card-front h2{{ card.name }}/h2 p classtrainer训练家{{ card.trainer }}/p p classmeta属性{{ card.type }} | 稀有度{{ card.rarity }}/p div classstats spanHP {{ card.hp }}/span span攻击 {{ card.attack }}/span span防御 {{ card.defense }}/span /div ul classskills li v-forskill in card.skills :keyskill.name {{ skill.name }}威力 {{ skill.power }}{{ skill.effect }} /li /ul p classevolution 进化链{{ card.evolutionChain.join( → ) }} /p /div /div /div /template style scoped .card-scene { width: 280px; height: 420px; margin: 20px auto; perspective: 900px; } .card { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s ease; } .card.flipping { transform: rotateY(180deg); } .card-face { position: absolute; inset: 0; backface-visibility: hidden; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box; } .card-back { background: linear-gradient(135deg, #1e3c72, #2a5298); color: #fff; font-size: 64px; } .card-front { background: linear-gradient(160deg, #ffffff, #f5f7fa); border: 1px solid #d1d5db; transform: rotateY(180deg); text-align: center; } .card-front h2 { margin: 0 0 4px; font-size: 22px; } .card-front .trainer { color: #6b7280; margin: 0 0 8px; } .card-front .meta { font-size: 14px; color: #374151; } .stats { display: flex; gap: 12px; margin: 12px 0; } .stats span { background: #f3f4f6; border-radius: 8px; padding: 4px 10px; font-size: 14px; } .skills { list-style: none; padding: 0; margin: 0 0 12px; text-align: left; } .skills li { font-size: 14px; margin-bottom: 4px; background: #f9fafb; border-left: 3px solid #3b82f6; padding: 6px 8px; border-radius: 4px; } .evolution { margin: 0; font-size: 14px; color: #6b7280; } /style这里的关键不是复杂动画而是transform-style: preserve-3d和backface-visibility: hidden。第一个属性让卡片内部能维持 3D 透视环境第二个属性让背面的卡片不被透视。正面和背面的卡面分别位于两个.card-face容器中正面默认旋转 180 度所以当整个卡片翻转 180 度时正面才会显示出来。很多新手在这里会踩坑如果只在.card上写了transform: rotateY(180deg)但没有在.card-front上写transform: rotateY(180deg)就会出现正反两面同时显示或者正面是镜像的。所以这段代码的正面容器必须显式转 180 度。接下来是App.vue。它维护currentCard这个响应式数据。点击“抽一张卡”按钮时从cards.json中随机取一张并触发一个 600ms 的翻转动画。!-- 文件路径src/App.vue -- script setup import { ref } from vue import cardsData from ./data/cards.json import CardView from ./components/CardView.vue const cards ref(cardsData) const currentCard ref(null) const animating ref(false) function drawCard() { if (animating.value) return const index Math.floor(Math.random() * cards.value.length) const nextCard cards.value[index] currentCard.value null animating.value true setTimeout(() { currentCard.value nextCard animating.value false }, 300) } /script template main classapp h1宝可梦 XYZ 卡牌图鉴/h1 p classsubtitle用炫卡斗士 W 风格打开宝可梦 XYZ抽卡、收藏、进化链展示/p button classdraw-btn clickdrawCard :disabledanimating 抽一张卡 /button CardView v-ifcurrentCard :cardcurrentCard :flippinganimating / /main /template style scoped .app { max-width: 600px; margin: 0 auto; padding: 32px 16px; text-align: center; font-family: PingFang SC, Microsoft YaHei, sans-serif; } .subtitle { color: #6b7280; margin-bottom: 24px; } .draw-btn { background: #3b82f6; border: none; color: #fff; font-size: 16px; padding: 10px 24px; border-radius: 999px; cursor: pointer; transition: background 0.2s; } .draw-btn:hover { background: #2563eb; } .draw-btn:disabled { opacity: 0.6; cursor: not-allowed; } /style抽卡逻辑实际上分两步。第一步按下按钮时先把当前卡片清空然后设置为“动画中”这样卡面会先消失。第二步300ms 之后把新卡赋值给currentCard同时关闭animating。这里没有直接使用一个“旧卡先翻过去、新卡再翻过来”的双段动画是因为最小示例中要避免过多状态分支。如果你想做得更细腻可以改成“先翻转背面再替换数据再翻转正面”。flipping属性在示例中被传给了CardView但因为我们在setTimeout里已经关闭了animating所以新卡显示时不会立刻翻转。如果你希望新卡出现时带有“翻牌”过程可以把 setTimeout 的时机改成先让旧卡翻到背面再替换数据然后再打开animating。这个留给你作为练习能更好理解 Vue 状态更新时序。到这里项目已经能运行了。但一个卡牌图鉴如果只能抽卡就显得太单薄。下一步我们给它加上属性筛选和进化链联动。6. 增加属性筛选与进化链联动实际的卡牌应用里用户不只会随机抽卡更多时候是想按属性浏览看所有的电系卡、水系卡、龙系卡。我们可以在App.vue里增加一个selectedType状态并提供一个计算属性filteredCards。这样抽卡时就可以从筛选结果中抽取而不是从全部卡池中抽取。改造后的App.vue代码如下!-- 文件路径src/App.vue -- script setup import { ref, computed } from vue import cardsData from ./data/cards.json import CardView from ./components/CardView.vue const cards ref(cardsData) const selectedType ref(全部) const currentCard ref(null) const animating ref(false) const types computed(() { const set new Set(cards.value.map((card) card.type)) return [全部, ...set] }) const filteredCards computed(() { if (selectedType.value 全部) { return cards.value } return cards.value.filter((card) card.type selectedType.value) }) function drawCard() { if (animating.value) return const pool filteredCards.value if (pool.length 0) return const index Math.floor(Math.random() * pool.length) const nextCard pool[index] currentCard.value null animating.value true setTimeout(() { currentCard.value nextCard animating.value false }, 300) } /script template main classapp h1宝可梦 XYZ 卡牌图鉴/h1 p classsubtitle用炫卡斗士 W 风格打开宝可梦 XYZ抽卡、收藏、进化链展示/p div classfilter-bar button v-fortype in types :keytype classtype-btn :class{ active: selectedType type } clickselectedType type {{ type }} /button /div button classdraw-btn clickdrawCard :disabledanimating 从 {{ selectedType }} 卡池抽一张 /button CardView v-ifcurrentCard :cardcurrentCard :flippinganimating / div classcard-list div v-forcard in filteredCards :keycard.id classlist-item clickcurrentCard card span{{ card.name }}/span span{{ card.type }} / {{ card.rarity }}/span /div /div /main /template style scoped .app { max-width: 720px; margin: 0 auto; padding: 32px 16px; text-align: center; font-family: PingFang SC, Microsoft YaHei, sans-serif; } .subtitle { color: #6b7280; margin-bottom: 24px; } .filter-bar { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 16px; } .type-btn { background: #f3f4f6; border: 1px solid #d1d5db; color: #374151; padding: 6px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; } .type-btn.active { background: #3b82f6; border-color: #3b82f6; color: #fff; } .draw-btn { background: #3b82f6; border: none; color: #fff; font-size: 16px; padding: 10px 24px; border-radius: 999px; cursor: pointer; transition: background 0.2s; } .draw-btn:hover { background: #2563eb; } .draw-btn:disabled { opacity: 0.6; cursor: not-allowed; } .card-list { margin-top: 32px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; } .list-item { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px; display: flex; justify-content: space-between; cursor: pointer; transition: box-shadow 0.2s; } .list-item:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } /style这里的核心逻辑是filteredCards计算属性。它不修改原始数据只是根据selectedType生成一个子集。抽卡时循环遍历的是pool也就是过滤后的卡池。这样用户选中“水”属性后抽到的任何卡都是水系宝可梦体验上更贴近卡牌游戏里的“属性卡池”。列表区域也有一个交互细节点击某一行的卡片名会把currentCard设置成这张卡同时卡片预览区会立刻切换。这个功能虽然不是炫酷动画但非常实用。它让用户能快速浏览所有卡牌而不需要一张一张抽。进化链联动则体现在CardView.vue中。我们已经在卡片正面用evolutionChain.join( → )展示了进化链。如果后续要做真正的联动可以在列表中把同一进化链的卡片高亮或者在点击进化链上的某个名字时自动筛选同一进化链的卡。不过当前这个最小示例已经足够展示数据结构的力量只要 JSON 里有进化链前端就能零成本展示。7. 运行结果与效果验证完成以上代码后再次运行npm run dev浏览器打开http://localhost:5173你应该能看到一个居中排列的卡牌图鉴页面。页面上方有属性筛选按钮包括“全部、电、水、龙”等。点击属性按钮后下方列表会立刻刷新按钮本身也会有高亮效果。点击“从全部卡池抽一张”按钮卡片应该先被清空然后在大约 300ms 后出现。如果代码里保留了flipping绑定并且CardView的样式正确你应该能看到卡片从背面翻到正面的过程。需要重点验证的内容包括控制台是否有红色报错。如果项目无法启动优先看终端输出。筛选“电”属性后再抽卡是否只会出现皮卡丘。如果抽到了其他属性说明filteredCards没有生效。点击卡片列表中的任意一张卡预览区是否切换成对应卡牌。卡背是否有“”文字正面是否正常显示数字和技能信息。浏览器窗口缩小到手机宽度时卡片是否还能居中显示。如果页面空白第一步应该检查main.js是否正确引入了App.vue。Vite 的 Vue 模板默认会生成src/main.js里面内容一般是import { createApp } from vue import ./style.css import App from ./App.vue createApp(App).mount(#app)如果你的cards.json放在src/data下并且CardView.vue放在src/components下那么 import 路径就不要写错。这是前端开发中最常见的“路径报错”问题。8. 常见问题与排查思路我在不同步骤中预埋了一些常见坑这里整理成表格方便你快速定位问题。问题现象可能原因排查方式解决方案项目启动时报错提示找不到 Vue没有执行 npm install或依赖安装中断查看终端报错信息执行 npm install 后重试补装依赖删除 node_modules 后重新安装页面空白main.js 中 import 路径错误或 App.vue 没有默认导出打开浏览器 F12 查看 Console 报错检查 src/main.js 和 App.vue 的导出方式卡片不显示currentCard 一直为 null在 drawCard 函数中打印 console.log(pool)确认 cards.json 有数据且 JSON 路径正确抽卡无动画CSS 3D 属性不完整检查 .card-face 是否设置 backface-visibility 和 rotateY补齐 transform-style、backface-visibility、rotateY(180deg)点击属性筛选无反应selectedType 绑定错误或者 computed 没有返回新数组检查按钮 click 事件是否设置 selectedType使用 v-for 渲染 types并绑定 click卡片列表点击不生效列表项的 click 没有指定 card 变量检查 v-for 是否写成 v-forcard in filteredCards确保点击事件绑定 cardJSON 加载报错文件后缀写成了 .js或者 JSON 格式不对用 JSON 校验工具检查确保 JSON 文件合法数组元素之间用逗号分隔卡片正面文字镜像.card-front 上没有 transform: rotateY(180deg)检查 CardView.vue 样式添加 transform: rotateY(180deg)这些问题的共同特点是多半不是 Vue 语法没学会而是文件路径、CSS 3D 属性或状态更新时机没写对。前端开发最耗时间的部分经常不是业务逻辑而是这种“明明看起来没问题但就是不生效”的排错过程。如果你遇到问题了记住一个原则先看 Console 报错再断点再改代码。不要在不知道报错信息的情况下盲目修改样式或逻辑。9. 最佳实践与工程建议项目跑通只是第一步。如果要在实际项目里继续推进下面这些工程建议非常值得参考。第一数据和视图分离。cards.json只存放数据CardView.vue只负责展示App.vue只负责状态编排。这样后续如果要接入后端接口只需要把import cardsData from ./data/cards.json换成请求函数组件不需要改动。第二卡牌图片不要直接写进 JSON。真实项目中imageUrl应该单独管理并且需要考虑图片懒加载。如果 100 张卡都同步加载页面首屏会很慢。Vue 本身没有内置懒加载但可以使用浏览器原生的loadinglazy属性或者引入vue-lazyload这类库。第三稀有度字段要设计成可扩展的枚举。不要只写“R”“SR”“SSR”建议维护一个rarityConfig配置集中管理颜色、出现概率、排序权重。这样抽卡概率、卡面颜色、列表排序都能复用同一个配置避免散落在组件里。第四动画效果要克制。抽卡动画确实能提升体验但不要让动画成为页面主体。如果用户每次切换卡片都需要等 600ms会很烦躁。最好把抽卡动画做成“一次性开包”体验而列表点击则立即切换不需要动画。第五版权合规必须提前考虑。宝可梦是一个商业 IP名称、角色、动画截图都有版权限制。个人学习项目无所谓但如果你要公开发布或商业使用需要先确认素材使用授权。技术上最稳妥的方式是把所有素材数据化图片使用占位图等你有合法素材后再替换。第六状态管理不要过度设计。当前项目只有currentCard、selectedType和cards三个状态完全不需要 Pinia。等项目出现“卡牌收藏列表”“抽卡历史”“用户背包”时再把共享状态抽到 Pinia 里。过早引入状态管理会让小项目变得复杂。第七加入单元测试。对于卡牌数据这类纯逻辑可以写几个测试用例验证稀有度过滤、进化链排序、抽卡随机性。Vue 生态里常用 Vitest 做测试它和 Vite 天然兼容。测试不需要覆盖全部 UI只需要覆盖数据逻辑和工具函数。第八考虑移动端适配。卡牌图鉴的主要用户很可能在手机上浏览。建议在开发早期就使用响应式布局避免项目做完后才发现卡片宽度在手机上溢出。grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))是一种比较方便的方式配合媒体查询调整卡片尺寸会更合理。第九用好computed而不是watch。如果某个结果是派生数据应该用computed生成只有当你要“执行副作用”时才用watch。很多 Vue 新手喜欢把所有逻辑都塞进watch结果代码变得越来越混乱。这个项目里filteredCards就是一个典型的computed场景。第十把项目当成组件库来思考。这一个卡牌图鉴应用拆出来的CardView.vue未来可以用在很多地方首页推荐位、图鉴列表、卡片详情弹窗。如果从一开始就把它设计成一个高内聚组件复用价值会大很多。10. 总结与后续学习方向从“用炫卡斗士W方式打开宝可梦XYZ”这个看起来挺娱乐化的标题出发我们完整拆解了一个卡牌化前端项目的核心路径先做内容映射再做数据建模然后写组件最后补充交互和筛选。整个过程看起来是在做一张张小卡片实际上是在训练一种能力把复杂 IP 内容转换成结构化的前端应用。现在你可以拿着这套骨架继续扩展。下一步比较推荐的方向有三个。第一个方向是把静态 JSON 换成后端 API。用 Node.js 的 Express 或 Python 的 FastAPI 写一个接口动态读取卡牌数据前端请求接口渲染。这个过程能让你理解前后端分离开发中接口设计、错误处理、加载状态是怎么工作的。第二个方向是实现属性克制和简单战斗系统。在数据模型里增加克制关系表例如“水克制火”“火克制草”然后让两张卡进行简易攻防计算。这样就把卡牌从展示工具变成了真正可玩的轻量战斗系统。第三个方向是加入本地收藏功能。用 localStorage 保存用户已经拥有的卡牌增加收藏按钮、卡牌计数、图鉴完成度等模块。这个功能不需要后端也能带来很强的完成感。但不管怎么扩展都先稳住数据模型。数据模型一旦改所有组件都要跟着改。这也是我在这篇文章里最想传达的判断卡牌化应用的炫酷表现固然重要但真正决定它能走多远的是底层的数据结构、组件边界和状态管理。想清楚了这些动画和交互都是水到渠成的事。