从零到一:用 Taro + React 搭建数据采集小程序
本文用最白痴化的方式,带你一步步完成一个可用于面试的小程序项目
一、项目简介
这是一个数据采集工具小程序,功能包括:
- 图片采集(拍照/相册选择)
- 数据管理(查看/删除)
- 本地存储
二、什么是 Taro?为什么选它?
2.1 Taro 是什么?
一句话理解:Taro 是一个让你用 React/Vue 写小程序的框架
以前(原生小程序):
├── index.js Page({ ... })
├── index.wxml <view>...</view>
├── index.wxss 样式
└── index.json 配置
现在(Taro + React):
└── index.tsx 组件,在一个文件里写逻辑 + JSX
2.2 为什么面试喜欢 Taro?
- ✅ 大厂都在用(京东、腾讯等)
- ✅ React 开发者零学习成本
- ✅ 一套代码多端运行(微信/支付宝/H5等)
- ✅ 现代化工程化开发体验
三、从零开始:一键搭建项目
3.1 准备工作
你需要先安装:
- Node.js:去官网下载安装即可
- pnpm/npm:包管理器
检查安装:
bash
node -v
npm -v
3.2 初始化 Taro 项目
注意: 这里有个坑!别自己手动创建,一定要用官方脚手架!
bash
# 进入项目目录
cd /Users/lichenyang/项目/小程序面试开发项目
# 用 Taro 脚手架初始化
npx @tarojs/cli init taro-demo
然后会问你一堆问题,按如下选择:
? 请输入项目名称 taro-demo
? 请输入项目介绍 数据采集工具小程序
? 请选择框架 React
? 请选择语言 TypeScript
? 请选择 CSS 预处理器 Sass
? 请选择模板 默认模板
3.3 进入目录安装依赖
bash
cd taro-demo
npm install
3.4 启动项目!
bash
npm run dev:weapp
注意点!
- ❌ 不要直接在微信开发者工具打开
taro-demo目录 - ✅ 打开的是编译后的
taro-demo/dist目录
四、项目目录结构分析
先看一眼生成的目录:
taro-demo/
├── src/
│ ├── pages/ # 页面文件夹(重要!)
│ │ └── index/ # 首页
│ ├── app.tsx # 入口组件
│ ├── app.config.ts # 全局配置
│ └── app.scss # 全局样式
├── config/ # Taro 配置
├── project.config.json # 微信开发者工具配置
└── package.json # 依赖包
重点讲解:
| 文件/目录 | 作用 |
|---|---|
app.config.ts |
小程序全局配置(页面路由、TabBar等) |
pages/ |
放各个页面 |
src/utils/ |
放工具函数(我们自己建的) |
五、第一步:配置页面路由和 TabBar
5.1 理解 app.config.ts
这个文件相当于原生小程序的 app.json,我们在里面配置三个页面:
typescript
// src/app.config.ts
export default defineAppConfig({
pages: [
'pages/index/index', // 首页
'pages/media/index', // 多媒体采集
'pages/data/index' // 数据管理
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#1890ff',
navigationBarTitleText: '数据采集工具',
navigationBarTextStyle: 'white',
backgroundColor: '#f5f5f5'
},
tabBar: {
color: '#999',
selectedColor: '#1890ff',
backgroundColor: '#fff',
borderStyle: 'black',
list: [
{ pagePath: 'pages/index/index', text: '首页' },
{ pagePath: 'pages/media/index', text: '多媒体' },
{ pagePath: 'pages/data/index', text: '数据' }
]
}
})
5.2 创建页面文件夹
在 src/pages/ 下创建两个新文件夹:
src/pages/
├── index/ # 已存在
├── media/ # 新建
└── data/ # 新建
每个页面需要三个文件:
index.tsx- 页面逻辑和结构index.scss- 页面样式index.config.ts- 页面配置
六、第二步:本地存储工具类
6.1 为什么需要单独封装存储?
- ✅ 统一管理,方便维护
- ✅ 加上类型安全(TypeScript)
- ✅ 后续加上传逻辑时方便修改
6.2 创建存储工具类
新建 src/utils/storage.ts:
typescript
import Taro from '@tarojs/taro'
// 定义数据类型(TypeScript 的好处)
interface ImageItem {
id: string
path: string
createTime: string
}
const STORAGE_KEY = 'collectedImages'
export const StorageManager = {
// 获取所有图片
getImages(): ImageItem[] {
try {
const data = Taro.getStorageSync(STORAGE_KEY)
return data || []
} catch (e) {
console.error('获取图片失败', e)
return []
}
},
// 添加一张图片
addImage(imagePath: string): ImageItem | null {
const images = this.getImages()
const newImage: ImageItem = {
id: Date.now() + Math.random().toString(36).substr(2, 9),
path: imagePath,
createTime: new Date().toISOString()
}
images.unshift(newImage)
try {
Taro.setStorageSync(STORAGE_KEY, images)
return newImage
} catch (e) {
console.error('保存图片失败', e)
return null
}
},
// 删除图片
deleteImage(id: string): boolean {
let images = this.getImages()
images = images.filter(img => img.id !== id)
try {
Taro.setStorageSync(STORAGE_KEY, images)
return true
} catch (e) {
console.error('删除图片失败', e)
return false
}
},
// 清空所有
clearAll(): boolean {
try {
Taro.setStorageSync(STORAGE_KEY, [])
return true
} catch (e) {
console.error('清空失败', e)
return false
}
}
}
注意点:
- 这里用了
unshift而不是push,让新添加的图片在最前面 - 每个方法都加了
try-catch,防止报错崩溃
七、第三步:图片采集页(多媒体页)
7.1 页面代码拆解
新建 src/pages/media/index.tsx:
tsx
import { useState, useEffect } from 'react'
import { View, Button, Image, Text } from '@tarojs/components'
import { useDidShow } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { StorageManager } from '../../utils/storage'
import './index.scss'
interface ImageItem {
id: string
path: string
createTime: string
}
export default function Media() {
const [images, setImages] = useState<ImageItem[]>([])
// 加载图片的函数
const loadImages = () => {
const savedImages = StorageManager.getImages()
setImages([...savedImages]) // ... 是为了创建新数组,确保 React 检测到变化
}
// 页面加载时执行一次
useEffect(() => {
loadImages()
}, [])
// ⭐ 重点!每次页面显示时都刷新!
// 切换 Tab 回来时,能看到新添加的数据
useDidShow(() => {
loadImages()
})
// 选择图片
const chooseImage = () => {
Taro.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const tempPaths = res.tempFilePaths
tempPaths.forEach(path => {
StorageManager.addImage(path)
})
loadImages()
Taro.showToast({
title: `已添加 ${tempPaths.length} 张图片`,
icon: 'success'
})
},
// ⭐ 必须加!用户取消选择时不会报错
fail: (err) => {
console.log('取消选择', err)
}
})
}
// 删除图片
const deleteImage = (id: string) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除这张图片吗?',
success: (res) => {
if (res.confirm) {
StorageManager.deleteImage(id)
loadImages()
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
})
}
// 预览大图
const previewImage = (path: string) => {
const allPaths = images.map(img => img.path)
Taro.previewImage({
current: path,
urls: allPaths
})
}
return (
<View className='container'>
<View className='header'>
<Text className='title'>图片采集</Text>
<Button className='add-btn' onClick={chooseImage}>+ 添加图片</Button>
</View>
{images.length > 0 ? (
<View className='image-grid'>
{images.map((item) => (
<View key={item.id} className='image-item'>
<Image
className='image'
src={item.path}
mode='aspectFill'
onClick={() => previewImage(item.path)}
/>
<View className='delete-btn' onClick={() => deleteImage(item.id)}>×</View>
</View>
))}
</View>
) : (
<View className='empty'>
<Text className='empty-text'>还没有采集图片</Text>
<Text className='empty-desc'>点击上方按钮添加图片</Text>
</View>
)}
</View>
)
}
7.2 重点注意点!
| 问题 | 解决方案 |
|---|---|
| 切换 Tab 后数据不更新 | 加 useDidShow,每次页面显示时刷新 |
| React 列表不重新渲染 | 用 [...savedImages] 创建新数组,而不是直接赋值 |
| 取消选择时报红 | 给 chooseImage 加 fail 回调 |
| 删除时需要二次确认 | 用 Taro.showModal |
7.3 页面配置
新建 src/pages/media/index.config.ts:
typescript
export default definePageConfig({
navigationBarTitleText: '多媒体采集'
})
7.4 样式代码
新建 src/pages/media/index.scss:
scss
.container {
padding: 32px;
min-height: 100vh;
background: #f7f8fa;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
padding: 0 8px;
}
.title {
font-size: 40px;
font-weight: 700;
color: #1f2937;
}
.add-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 20px;
padding: 20px 40px;
font-size: 28px;
font-weight: 600;
margin: 0;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4);
&::after {
border: none;
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.image-item {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 20px;
overflow: hidden;
background: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
width: 44px;
height: 44px;
background: rgba(255, 59, 48, 0.95);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
line-height: 1;
font-weight: bold;
box-shadow: 0 4px 12px rgba(255, 59, 48, 0.4);
backdrop-filter: blur(10px);
}
.empty {
text-align: center;
padding: 160px 40px;
}
.empty-text {
display: block;
font-size: 32px;
color: #9ca3af;
margin-bottom: 16px;
font-weight: 500;
}
.empty-desc {
display: block;
font-size: 26px;
color: #d1d5db;
}
八、第四步:数据管理页
8.1 页面代码
新建 src/pages/data/index.tsx:
tsx
import { useState, useEffect } from 'react'
import { View, Button, Image, Text } from '@tarojs/components'
import { useDidShow } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { StorageManager } from '../../utils/storage'
import './index.scss'
interface ImageItem {
id: string
path: string
createTime: string
}
export default function Data() {
const [images, setImages] = useState<ImageItem[]>([])
const loadImages = () => {
const savedImages = StorageManager.getImages()
console.log('Data 加载图片:', savedImages)
setImages([...savedImages])
}
useEffect(() => {
loadImages()
}, [])
useDidShow(() => {
console.log('Data 页面显示')
loadImages()
})
// 格式化时间显示
const formatTime = (isoString: string) => {
const date = new Date(isoString)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
}
const previewImage = (path: string) => {
const allPaths = images.map(img => img.path)
Taro.previewImage({
current: path,
urls: allPaths
})
}
const deleteImage = (id: string) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除这张图片吗?',
success: (res) => {
if (res.confirm) {
StorageManager.deleteImage(id)
loadImages()
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
})
}
const clearAll = () => {
if (images.length === 0) {
Taro.showToast({ title: '没有数据', icon: 'none' })
return
}
Taro.showModal({
title: '确认清空',
content: '确定要清空所有图片吗?此操作不可恢复!',
confirmColor: '#ff4d4f',
success: (res) => {
if (res.confirm) {
StorageManager.clearAll()
loadImages()
Taro.showToast({ title: '已清空', icon: 'success' })
}
}
})
}
return (
<View className='container'>
<View className='header'>
<Text className='title'>数据管理 ({images.length})</Text>
{images.length > 0 && (
<Button className='clear-btn' onClick={clearAll}>清空</Button>
)}
</View>
{images.length > 0 ? (
<View className='image-list'>
{images.map((item) => (
<View key={item.id} className='image-card'>
<Image
className='preview-img'
src={item.path}
mode='aspectFill'
onClick={() => previewImage(item.path)}
/>
<View className='card-info'>
<Text className='time'>{formatTime(item.createTime)}</Text>
<Button
className='delete-btn'
size='mini'
onClick={() => deleteImage(item.id)}
>
删除
</Button>
</View>
</View>
))}
</View>
) : (
<View className='empty'>
<Text className='empty-text'>暂无采集数据</Text>
<Text className='empty-desc'>去「多媒体」页面添加图片吧</Text>
</View>
)}
</View>
)
}
8.2 同样的,页面配置和样式
配置 src/pages/data/index.config.ts:
typescript
export default definePageConfig({
navigationBarTitleText: '数据管理'
})
样式 src/pages/data/index.scss 参考前面的样式代码。
九、第五步:首页美化
修改 src/pages/index/index.tsx:
tsx
import { View, Text } from '@tarojs/components'
import './index.scss'
export default function Index() {
return (
<View className='container'>
<View className='card'>
<Text className='title'>数据采集工具</Text>
<Text className='desc'>Taro + React + TypeScript</Text>
</View>
</View>
)
}
样式 src/pages/index/index.scss 参考前面的美化版代码。
十、常见坑点总结(面试会问!)
10.1 关于 Taro 单位
| 写法 | 说明 |
|---|---|
px |
Taro 推荐,编译时自动转成 rpx |
px * 2 |
❌ 不要这样! |
10.2 关于页面刷新
| 场景 | 用什么钩子 |
|---|---|
| 第一次加载 | useEffect |
| 每次页面显示 | useDidShow |
面试话术:
小程序切换 Tab 时页面不会重新加载,所以需要用
useDidShow在每次页面显示时刷新数据。
10.3 关于 React 列表渲染
typescript
// ❌ 这样可能不会重新渲染
setImages(savedImages)
// ✅ 这样创建新数组,React 会检测到变化
setImages([...savedImages])
10.4 关于微信开发者工具导入目录
| 目录 | 是否正确 |
|---|---|
taro-demo |
❌ 源目录,不能直接打开 |
taro-demo/dist |
✅ 编译后的目录 |
十一、面试时如何介绍这个项目?
11.1 项目介绍话术
这是一个数据采集工具小程序,用于采集图片数据并本地管理。
技术栈:Taro + React + TypeScript
核心功能:
- 图片采集(拍照/相册选择)
- 数据管理(查看/删除)
- 本地持久化存储
11.2 面试官可能会问的点
| 问题 | 回答方向 |
|---|---|
| 为什么选 Taro? | 多端复用、React 生态、大厂在用 |
| 数据为什么要存在本地? | 模拟上传队列、网络异常时不丢数据 |
| 切换 Tab 数据为什么不更新? | 用 useDidShow 钩子解决 |
| 还有什么可以优化的? | 图片压缩、断点续传、云存储上传等 |
11.3 进阶优化方向(可选)
如果面试官问你还可以怎么加功能,你可以说:
- ✅ 图片压缩:上传前压缩图片大小
- ✅ 断点续传:大文件分片上传
- ✅ 图片元数据提取:获取时间、地点等信息
- ✅ 批量上传队列:管理上传状态
- ✅ 数据质量校验:检测图片是否模糊
十二、完整运行流程回顾
- ✅
cd taro-demo - ✅
npm install - ✅
npm run dev:weapp - ✅ 微信开发者工具打开
taro-demo/dist - ✅ 测试功能!
十三、总结
恭喜你!现在你已经有了一个可以用于面试的小程序项目了。
这个项目展示了什么能力?
- ✅ Taro/React 基础开发
- ✅ 小程序常用 API 使用
- ✅ 状态管理(React Hooks)
- ✅ 本地数据存储
- ✅ 工程化项目搭建