前言

作为React开发者,如何快速切入小程序开发领域?Taro为你提供了无缝的开发体验。本文通过TodoList这个经典案例,带你从环境搭建开始,全面体验Taro的核心API,包括页面生命周期管理、状态切换、本地存储、下拉刷新等功能,同时讲解多端开发技巧和性能优化策略,让你快速掌握React思想在小程序开发中的应用,轻松实现从React到小程序的技术迁移。

环境搭建

搭建react+taro+typescript+weapp-tailwind的项目

taro文档:https://docs.taro.zone/docs/GETTING-STARTED
weapp-tailwind文档:https://weapp-tw.icebreaker.top/docs/quick-start/v4/taro-webpack

Taro安装及使用

安装

Taro 项目基于 node,请确保已具备较新的 node 环境(>=16.20.0),推荐使用 node 版本管理工具 nvm 来管理 node,这样不仅可以很方便地切换 node 版本,而且全局安装时候也不用加 sudo 了。

CLI 工具安装

首先,你需要使用 npm 或者 yarn 全局安装 @tarojs/cli,或者直接使用 npx:

  • npm
  • yarn
  • pnpm
# 使用 npm 安装 CLI
$ npm install -g @tarojs/cli

请注意

由于 Taro 部分能力使用 Rust 开发,在 Windows 上,请确保安装了 Microsoft Visual C++ Redistributable。请查看:https://docs.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist

查看 Taro 全部版本信息

可以使用 npm info 查看 Taro 版本信息,在这里你可以看到当前最新版本

npm info @tarojs/cli

npm info @tarojs/cli screenshot

由图第 1 行可知最新版本,如果你用的是 beta 或者 canary 你可以通过 dist-tags: 下面那行看到最新的版本。

项目初始化

使用命令创建模板项目:

$ taro init myApp

npm 5.2+ 也可在不全局安装的情况下使用 npx 创建模板项目:

$ npx @tarojs/cli init myApp

在这里插入图片描述

在创建完项目之后,Taro 会默认开始安装项目所需要的依赖,安装使用的工具按照 yarn > cnpm > npm 顺序进行检测。一般来说,依赖安装会比较顺利,但某些情况下可能会安装失败,这时候你可以在项目目录下自己使用安装命令进行安装:

  • npm
  • yarn
  • pnpm
# 进入项目根目录
$ cd myApp

# 使用 npm 安装依赖
$ npm install

Taro webpack

注意:选webpack的原因是因为vite适配有问题

官方解释:Taro Vite 目前不稳定,已知 bug 较多,而且依赖链版本较老,不推荐在新项目里使用。

如果没有强依赖,优先选择 Taro Webpackuni-appweapp-vite 等更稳定的方案。

安装

  • npm
  • Yarn
  • pnpm
  • Bun
pnpm add -D tailwindcss @tailwindcss/postcss postcss weapp-tailwindcss

然后把下列脚本,添加进你的 package.jsonscripts 字段里:

package.json

 "scripts": {

   "postinstall": "weapp-tw patch"

 }

使用 pnpm@10+

pnpm@10 默认只允许 onlyBuiltDependencies 中的包运行生命周期脚本。安装完本插件后,请执行 pnpm approve-builds weapp-tailwindcss 将其加入白名单,避免 postinstallweapp-tw patch 被跳过。

这是为了给 tailwindcss@4 打上支持 rpx 单位的补丁,否则它会把 rpx 认为是一种颜色

如需在补丁前强制刷新 tailwindcss-patch 的缓存,可改为:

package.json

 "scripts": {

   "postinstall": "weapp-tw patch --clear-cache"

 }

默认不清理缓存;只有当你怀疑缓存导致补丁未生效或目标不一致时,才需要添加 --clear-cache。***

配置

在你的根目录创建 postcss.config.mjs

postcss.config.mjs

export default {

  plugins: {

    "@tailwindcss/postcss": {},

  }

}

在你的 app.css 里面添加

@import "weapp-tailwindcss/index.css";

关于 @import ‘tailwindcss’

默认情况下,weapp-tailwindcssrewriteCssImports 选项会自动将@import 'tailwindcss' 改写为 @import 'weapp-tailwindcss/index.css'

这意味着你可以继续在 IntelliSense 辅助入口中使用官方文档的写法 @import 'tailwindcss',以获得更好的 IDE 智能提示 支持。

如果是实际运行时入口,推荐直接写 @import 'weapp-tailwindcss/index.css'

如果遇到报错或样式不生效,也请优先手动改为 @import 'weapp-tailwindcss/index.css', 或将rewriteCssImports 设置为false

注册插件

在项目的配置文件 config/index 中注册:

config/index.[jt]s

import { UnifiedWebpackPluginV5 } from 'weapp-tailwindcss/webpack'

import path from 'node:path'

// 假如你使用 js 配置,则使用下方 require 的写法

// const { UnifiedWebpackPluginV5 } = require('weapp-tailwindcss/webpack')

// const path = require('node:path')



{

  // 找到 mini 这个配置

  mini: {

    // postcss: { /*...*/ },

    // 中的 webpackChain, 通常紧挨着 postcss 

    webpackChain(chain, webpack) {

      // 复制这块区域到你的配置代码中 region start

      chain.merge({

        plugin: {

          install: {

            plugin: UnifiedWebpackPluginV5,

            args: [{

              // 这里可以传参数

              rem2rpx: true,

              cssEntries: [

                // 你 @import "weapp-tailwindcss/index.css"; 那个文件绝对路径

                path.resolve(__dirname, '../src/app.css'),

              ],

            }],

          },

        },

      })

      // region end

    }

  }

}

tailwindcss@4 必须配置 cssEntries 并且使用绝对路径,否则 tailwindcss 生成的类名不会参与转译。

运行

然后执行命令发布到微信小程序

  • npm
  • Yarn
  • pnpm
  • Bun
pnpm run dev:weapp

微信开发者工具导入这个项目,即可看到效果

做多端开发tips

config/index.ts
outputRoot: dist/${process.env.TARO_ENV}, //在dis目录下,创建获取不同运行环境的编译目录,保证打包不冲突

Step 1 执行方案

1. 开启下拉刷新

在页面配置文件中启用下拉刷新功能。

文件: src/pages/index/index.config.ts

export default definePageConfig({
  navigationBarTitleText: "待办清单",
  enablePullDownRefresh: true, // 允许下拉刷新
  backgroundTextStyle: "dark",
});

2. 构建基础结构与状态

在首页实现输入框、待办列表和已完成列表的布局,并初始化示例数据。

文件: src/pages/index/index.tsx

import { useState } from "react";
import { View, Text, Input, Button } from "@tarojs/components";
import Taro, { useLoad, useDidShow, usePullDownRefresh } from "@tarojs/taro";
import "./index.css";

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

export default function Index() {
  const [inputValue, setInputValue] = useState("");
  // 待办列表初始数据
  const [todos, setTodos] = useState<TodoItem[]>([
    { id: 1, text: "学习 Taro 基础", completed: false },
    { id: 2, text: "配置项目骨架", completed: false },
  ]);
  // 已完成列表初始数据
  const [completedTodos] = useState<TodoItem[]>([
    { id: 3, text: "环境搭建", completed: true },
  ]);

  // Taro 页面生命周期 Hook
  useLoad(() => {
    console.log("页面加载完成");
  });

  useDidShow(() => {
    console.log("页面显示");
  });

  // 监听下拉刷新
  usePullDownRefresh(() => {
    console.log("正在刷新数据...");
    // 模拟刷新逻辑
    setTimeout(() => {
      Taro.stopPullDownRefresh(); // 停止刷新动画
    }, 1000);
  });

  const handleAddTodo = () => {
    if (!inputValue.trim()) return;
    const newTodo: TodoItem = {
      id: Date.now(),
      text: inputValue,
      completed: false,
    };
    setTodos([...todos, newTodo]);
    setInputValue("");
  };

  return (
    <View className="p-4">
      {/* 输入区域 */}
      <View className="flex mb-6">
        <Input
          className="flex-1 border-b border-gray-300 p-2 mr-2"
          placeholder="添加新任务..."
          value={inputValue}
          onInput={(e) => setInputValue(e.detail.value)}
        />
        <Button size="mini" type="primary" onClick={handleAddTodo}>
          添加
        </Button>
      </View>

      {/* 待办列表 */}
      <View className="mb-6">
        <Text className="text-lg font-bold mb-2 block">待办事项</Text>
        {todos.map((item) => (
          <View key={item.id} className="p-3 border-b border-gray-100">
            <Text>{item.text}</Text>
          </View>
        ))}
      </View>

      {/* 已完成列表 */}
      <View>
        <Text className="text-lg font-bold mb-2 block text-gray-400">
          已完成
        </Text>
        {completedTodos.map((item) => (
          <View
            key={item.id}
            className="p-3 border-b border-gray-100 text-gray-400 line-through"
          >
            <Text>{item.text}</Text>
          </View>
        ))}
      </View>
    </View>
  );
}

Step 2 - Step 5 执行方案

1. 核心功能点

  • Step 2 (新增): 增加输入校验与 Taro.showToast 反馈。
  • Step 3 (切换): 实现待办与已完成状态的互转。
  • Step 4 (编辑): 增加行内编辑功能,支持保存与取消。
  • Step 5 (删除): 增加删除确认弹窗 Taro.showModal

2. 代码实现

文件: src/pages/index/index.tsx

import { useState } from "react";
import { View, Text, Input, Button } from "@tarojs/components";
import Taro, { useLoad, useDidShow, usePullDownRefresh } from "@tarojs/taro";
import "./index.css";

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

export default function Index() {
  const [inputValue, setInputValue] = useState("");
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editingText, setEditingText] = useState("");

  const [todos, setTodos] = useState<TodoItem[]>([
    { id: 1, text: "学习 Taro 基础", completed: false },
    { id: 2, text: "配置项目骨架", completed: false },
  ]);
  const [completedTodos, setCompletedTodos] = useState<TodoItem[]>([
    { id: 3, text: "环境搭建", completed: true },
  ]);

  useLoad(() => console.log("页面加载完成"));
  useDidShow(() => console.log("页面显示"));
  usePullDownRefresh(() => {
    setTimeout(() => Taro.stopPullDownRefresh(), 1000);
  });

  // --- Step 2: 新增待办 ---
  const handleAddTodo = () => {
    if (!inputValue.trim()) {
      Taro.showToast({ title: "内容不能为空", icon: "none" });
      return;
    }
    const newTodo: TodoItem = {
      id: Date.now(),
      text: inputValue,
      completed: false,
    };
    setTodos([newTodo, ...todos]);
    setInputValue("");
    Taro.showToast({ title: "添加成功", icon: "success" });
  };

  // --- Step 3: 切换状态 ---
  const toggleTodo = (item: TodoItem) => {
    if (!item.completed) {
      // 待办 -> 已完成
      setTodos(todos.filter((t) => t.id !== item.id));
      setCompletedTodos([{ ...item, completed: true }, ...completedTodos]);
    } else {
      // 已完成 -> 待办
      setCompletedTodos(completedTodos.filter((t) => t.id !== item.id));
      setTodos([{ ...item, completed: false }, ...todos]);
    }
  };

  // --- Step 4: 编辑待办 ---
  const startEdit = (item: TodoItem) => {
    setEditingId(item.id);
    setEditingText(item.text);
  };

  const saveEdit = () => {
    if (!editingText.trim()) {
      Taro.showToast({ title: "内容不能为空", icon: "none" });
      return;
    }
    const updateList = (list: TodoItem[]) =>
      list.map((t) => (t.id === editingId ? { ...t, text: editingText } : t));

    setTodos(updateList(todos));
    setCompletedTodos(updateList(completedTodos));
    setEditingId(null);
    Taro.showToast({ title: "修改成功", icon: "success" });
  };

  // --- Step 5: 删除待办 ---
  const handleDelete = (id: number, isCompleted: boolean) => {
    Taro.showModal({
      title: "确认删除",
      content: "确定要删除这条待办吗?",
      success: (res) => {
        if (res.confirm) {
          if (isCompleted) {
            setCompletedTodos(completedTodos.filter((t) => t.id !== id));
          } else {
            setTodos(todos.filter((t) => t.id !== id));
          }
          Taro.showToast({ title: "已删除", icon: "success" });
        }
      },
    });
  };

  return (
    <View className="p-4">
      {/* 输入区域 */}
      <View className="flex mb-6">
        <Input
          className="flex-1 border-b border-gray-300 p-2 mr-2"
          placeholder="添加新任务..."
          value={inputValue}
          onInput={(e) => setInputValue(e.detail.value)}
        />
        <Button size="mini" type="primary" onClick={handleAddTodo}>添加</Button>
      </View>

      {/* 待办列表 */}
      <View className="mb-6">
        <Text className="text-lg font-bold mb-2 block">待办事项 ({todos.length})</Text>
        {todos.map((item) => (
          <View key={item.id} className="flex items-center justify-between p-3 border-b border-gray-100">
            {editingId === item.id ? (
              <Input
                className="flex-1 border-b border-blue-400"
                value={editingText}
                onInput={(e) => setEditingText(e.detail.value)}
                onBlur={saveEdit}
                focus
              />
            ) : (
              <Text className="flex-1" onClick={() => toggleTodo(item)}>{item.text}</Text>
            )}
            <View className="flex">
              <Text className="text-blue-500 mr-3 text-sm" onClick={() => startEdit(item)}>编辑</Text>
              <Text className="text-red-500 text-sm" onClick={() => handleDelete(item.id, false)}>删除</Text>
            </View>
          </View>
        ))}
      </View>

      {/* 已完成列表 */}
      <View>
        <Text className="text-lg font-bold mb-2 block text-gray-400">已完成 ({completedTodos.length})</Text>
        {completedTodos.map((item) => (
          <View key={item.id} className="flex items-center justify-between p-3 border-b border-gray-100 opacity-60">
            <Text className="flex-1 line-through" onClick={() => toggleTodo(item)}>{item.text}</Text>
            <Text className="text-red-500 text-sm" onClick={() => handleDelete(item.id, true)}>删除</Text>
          </View>
        ))}
      </View>
    </View>
  );
}

在这里插入图片描述

Step 6 执行方案:本地持久化 (Storage)

1. 核心任务

  • 使用 Taro.getStorageSync 在页面加载时读取历史数据。
  • 使用 useEffect 监听数据变化,并调用 Taro.setStorageSync 自动保存。
  • 统一存储 Key 为 TODO_LIST_DATA

2. 代码实现

文件: src/pages/index/index.tsx

import { useState, useEffect } from "react"; // 新增 useEffect
import { View, Text, Input, Button } from "@tarojs/components";
import Taro, { useDidShow, useLoad, usePullDownRefresh } from "@tarojs/taro";
import "./index.css";

const STORAGE_KEY = "TODO_LIST_DATA"; // 定义统一的 Key

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

export default function Index() {
  const [inputValue, setInputValue] = useState("");
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editingText, setEditingText] = useState("");
  
  const [todos, setTodos] = useState<TodoItem[]>([]);
  const [completedTodos, setCompletedTodos] = useState<TodoItem[]>([]);

  // --- Step 6: 页面初始化读取数据 ---
  useLoad(() => {
    try {
      const savedData = Taro.getStorageSync(STORAGE_KEY);
      if (savedData) {
        setTodos(savedData.todos || []);
        setCompletedTodos(savedData.completedTodos || []);
      }
    } catch (e) {
      console.error("读取本地数据失败", e);
    }
  });

  // --- Step 6: 数据变化自动持久化 ---
  useEffect(() => {
    // 只有当数据不为空(或已初始化)时才保存,避免初始化时的空数据覆盖本地存储
    // 或者简单处理:每次变化都保存
    Taro.setStorageSync(STORAGE_KEY, {
      todos,
      completedTodos
    });
  }, [todos, completedTodos]);

  // ... 其余业务逻辑保持不变 (handleAddTodo, toggleTodo, saveEdit, handleDelete)
  // 注意:在 useEffect 自动保存后,无需在每个函数里手动调用 setStorageSync
  
  // (以下省略重复的 UI 渲染部分,实际开发时请合并到 index.tsx)
}

3. 验收标准

  1. 新增一个待办事项。
  2. 刷新微信开发者工具(或点击编译)。
  3. 检查页面是否仍然显示刚才新增的内容。
  4. 在“调试器 -> Storage”面板中应能看到 TODO_LIST_DATA 及其内容。

在这里插入图片描述

刷新页面后=》

在这里插入图片描述

Step 7 执行方案:下拉刷新与页面生命周期

1. 核心任务

  • usePullDownRefresh 中实现从 Storage 重新读取数据的功能,模拟数据刷新。
  • useLoaduseDidShow 中添加日志,观察小程序页面生命周期的触发顺序。
  • 确保在逻辑结束时调用 Taro.stopPullDownRefresh() 停止动画。

2. 代码实现

文件: src/pages/index/index.tsx

import { useState, useEffect } from "react";
import { View, Text, Input, Button } from "@tarojs/components";
import Taro, { useDidShow, useLoad, usePullDownRefresh } from "@tarojs/taro";
import "./index.css";

const STORAGE_KEY = "TODO_LIST_DATA";

export default function Index() {
  // ... 状态定义保持不变

  // --- Step 7: 页面生命周期观察 ---
  useLoad(() => {
    console.log("Lifecycle: useLoad (页面加载,仅触发一次)");
    loadData();
  });

  useDidShow(() => {
    console.log("Lifecycle: useDidShow (页面显示,每次切回页面都会触发)");
  });

  // 封装读取逻辑,以便复用
  const loadData = () => {
    try {
      const savedData = Taro.getStorageSync(STORAGE_KEY);
      if (savedData) {
        setTodos(savedData.todos || []);
        setCompletedTodos(savedData.completedTodos || []);
      }
    } catch (e) {
      console.error("读取本地数据失败", e);
    }
  };

  // --- Step 7: 下拉刷新 ---
  usePullDownRefresh(() => {
    console.log("Lifecycle: usePullDownRefresh (用户触发下拉刷新)");
    
    // 重新读取数据
    loadData();

    // 提示刷新成功并停止动画
    setTimeout(() => {
      Taro.stopPullDownRefresh();
      Taro.showToast({ title: "刷新成功", icon: "none" });
    }, 500);
  });

  // ... 其余业务逻辑保持不变
}

在这里插入图片描述

3. 验收标准

  1. 生命周期观察:打开控制台,观察进入页面时 useLoaduseDidShow 的输出顺序。尝试切换到其他页面再回来,观察 useDidShow 是否再次触发。
  2. 下拉刷新验证:在模拟器中手动下拉页面。
    • 应看到顶部刷新动画。
    • 0.5 秒后动画消失,并弹出“刷新成功”提示。
    • 控制台应有相应的刷新日志。

Step A 执行方案:组件拆分与解耦

1. 核心任务

  • 将输入区域、待办列表、已完成列表拆分为独立组件,提升代码可维护性。

2. 代码实现

2.1 输入组件 src/components/TodoInput.tsx

import { View, Input, Button } from "@tarojs/components";

interface TodoInputProps {
  value: string;
  onInput: (value: string) => void;
  onAdd: () => void;
}

export default function TodoInput({ value, onInput, onAdd }: TodoInputProps) {
  return (
    <View className="flex mb-6">
      <Input
        className="flex-1 border-b border-gray-300 p-2 mr-2"
        placeholder="add new todo..."
        value={value}
        onInput={(e) => onInput(e.detail.value)}
      />
      <Button size="mini" type="primary" onClick={onAdd}>
        添加
      </Button>
    </View>
  );
}

2.2 待办列表组件 src/components/TodoList.tsx

import { View, Text, Input } from "@tarojs/components";

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoListProps {
  todos: TodoItem[];
  editingId: number | null;
  editingText: string;
  onToggle: (item: TodoItem) => void;
  onStartEdit: (item: TodoItem) => void;
  onEditInput: (text: string) => void;
  onSaveEdit: () => void;
  onDelete: (id: number) => void;
  onLongPress: (item: TodoItem) => void;
}

export default function TodoList({
  todos,
  editingId,
  editingText,
  onToggle,
  onStartEdit,
  onEditInput,
  onSaveEdit,
  onDelete,
  onLongPress,
}: TodoListProps) {
  return (
    <View className="mb-6">
      <Text className="text-lg font-bold mb-2 block">
        TodoList({todos.length})
      </Text>
      {todos.map((item) => (
        <View
          key={item.id}
          className="flex items-center justify-between p-3 border-b border-gray-100"
        >
          {editingId === item.id ? (
            <Input
              className="flex-1 border-b border-blue-400"
              value={editingText}
              onInput={(e) => onEditInput(e.detail.value)}
              onBlur={onSaveEdit}
              focus
            />
          ) : (
            <Text
              className="flex-1"
              onClick={() => onToggle(item)}
              onLongPress={() => onLongPress(item)}
            >
              {item.text}
            </Text>
          )}
          <View className="flex">
            <Text
              className="text-blue-500 mr-3 text-sm"
              onClick={() => onStartEdit(item)}
            >
              编辑
            </Text>
            <Text
              className="text-red-500 text-sm"
              onClick={() => onDelete(item.id)}
            >
              删除
            </Text>
          </View>
        </View>
      ))}
    </View>
  );
}

2.3 已完成列表组件 src/components/CompletedList.tsx

import { View, Text } from "@tarojs/components";

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

interface CompletedListProps {
  todos: TodoItem[];
  onToggle: (item: TodoItem) => void;
  onDelete: (id: number) => void;
  onLongPress: (item: TodoItem) => void;
}

export default function CompletedList({
  todos,
  onToggle,
  onDelete,
  onLongPress,
}: CompletedListProps) {
  return (
    <View>
      <Text className="text-lg font-bold mb-2 block text-gray-400">
        FinishList({todos.length})
      </Text>
      {todos.map((item) => (
        <View
          key={item.id}
          className="flex items-center justify-between p-3 border-b border-gray-100 opacity-60"
        >
          <Text
            className="flex-1 line-through"
            onClick={() => onToggle(item)}
            onLongPress={() => onLongPress(item)}
          >
            {item.text}
          </Text>
          <Text
            className="text-red-500 text-sm"
            onClick={() => onDelete(item.id)}
          >
            删除
          </Text>
        </View>
      ))}
    </View>
  );
}

2.4 首页容器 src/pages/index/index.tsx

import { View } from "@tarojs/components";
import Taro, { useDidShow, useLoad, usePullDownRefresh } from "@tarojs/taro";
import "./index.css";
import { useState, useEffect } from "react";
import TodoInput from "../../components/TodoInput";
import TodoList from "../../components/TodoList";
import CompletedList from "../../components/CompletedList";

const STORAGE_KEY = "TODO_LIST_DATA";

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

export default function Index() {
  const [inputValue, setInputValue] = useState("");
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editingText, setEditingText] = useState("");

  const [todos, setTodos] = useState<TodoItem[]>([]);
  const [completedTodos, setCompletedTodos] = useState<TodoItem[]>([]);

  useLoad(() => {
    loadData();
  });

  const loadData = () => {
    try {
      const savedData = Taro.getStorageSync(STORAGE_KEY);
      if (savedData) {
        setTodos(savedData.todos || []);
        setCompletedTodos(savedData.completedTodos || []);
      }
    } catch (e) {
      console.error("读取本地数据失败", e);
    }
  };

  useEffect(() => {
    Taro.setStorageSync(STORAGE_KEY, {
      todos,
      completedTodos,
    });
  }, [todos, completedTodos]);

  usePullDownRefresh(() => {
    loadData();
    setTimeout(() => {
      Taro.stopPullDownRefresh();
      Taro.showToast({ title: "刷新成功", icon: "none" });
    }, 500);
  });

  const handleAddTodo = () => {
    if (!inputValue.trim()) {
      Taro.showToast({ title: "内容不能为空", icon: "none" });
      return;
    }
    const newTodo: TodoItem = {
      id: Date.now(),
      text: inputValue,
      completed: false,
    };
    setTodos([newTodo, ...todos]);
    setInputValue("");
    Taro.showToast({ title: "添加成功", icon: "success" });
  };

  const toggleTodo = (item: TodoItem) => {
    if (!item.completed) {
      setTodos((prev) => prev.filter((t) => t.id !== item.id));
      setCompletedTodos((prev) => [{ ...item, completed: true }, ...prev]);
    } else {
      setCompletedTodos((prev) => prev.filter((t) => t.id !== item.id));
      setTodos((prev) => [{ ...item, completed: false }, ...prev]);
    }
  };

  const startEdit = (item: TodoItem) => {
    setEditingId(item.id);
    setEditingText(item.text);
  };

  const saveEdit = () => {
    if (!editingText.trim()) {
      Taro.showToast({ title: "内容不能为空", icon: "none" });
      return;
    }
    const updateList = (list: TodoItem[]) =>
      list.map((t) => (t.id === editingId ? { ...t, text: editingText } : t));

    setTodos(updateList(todos));
    setCompletedTodos(updateList(completedTodos));
    setEditingId(null);
    Taro.showToast({ title: "修改成功", icon: "success" });
  };

  const handleDelete = (id: number, isCompleted: boolean) => {
    Taro.showModal({
      title: "确认删除",
      content: "确定要删除这条待办吗?",
      success: (res) => {
        if (res.confirm) {
          if (isCompleted) {
            setCompletedTodos(completedTodos.filter((t) => t.id !== id));
          } else {
            setTodos(todos.filter((t) => t.id !== id));
          }
          Taro.showToast({ title: "已删除", icon: "success" });
        }
      },
    });
  };

  const showActionMenu = (item: TodoItem) => {
    const options = [
      item.completed ? "撤回到待办" : "标记为完成",
      "编辑内容",
      "删除任务",
    ];

    Taro.showActionSheet({
      itemList: options,
      success: (res) => {
        switch (res.tapIndex) {
          case 0:
            toggleTodo(item);
            Taro.showToast({
              title: item.completed ? "已撤回" : "已完成",
              icon: "success",
            });
            break;
          case 1:
            startEdit(item);
            break;
          case 2:
            handleDelete(item.id, item.completed);
            break;
        }
      },
    });
  };

  return (
    <View className="p-4">
      <TodoInput
        value={inputValue}
        onInput={setInputValue}
        onAdd={handleAddTodo}
      />

      <TodoList
        todos={todos}
        editingId={editingId}
        editingText={editingText}
        onToggle={toggleTodo}
        onStartEdit={startEdit}
        onEditInput={setEditingText}
        onSaveEdit={saveEdit}
        onDelete={(id) => handleDelete(id, false)}
        onLongPress={showActionMenu}
      />

      <CompletedList
        todos={completedTodos}
        onToggle={toggleTodo}
        onDelete={(id) => handleDelete(id, true)}
        onLongPress={showActionMenu}
      />
    </View>
  );
}

在这里插入图片描述

Step B 执行方案:交互反馈 API (操作可感知)

1. 核心任务

  • ActionSheet 菜单: 长按待办项时调用 Taro.showActionSheet
  • 操作闭环: 在菜单中集成“完成/撤回”、“编辑”和“删除”功能。
  • 反馈增强: 确保每个动作(新增、删除、修改、状态切换)都有对应的 Toast 提示。

2. 代码实现

文件: src/pages/index/index.tsx

// --- Step B: 长按菜单处理 ---
const showActionMenu = (item: TodoItem) => {
  const options = [
    item.completed ? "撤回到待办" : "标记为完成",
    "编辑内容",
    "删除任务"
  ];

  Taro.showActionSheet({
    itemList: options,
    success: (res) => {
      switch (res.tapIndex) {
        case 0: // 切换状态
          toggleTodo(item);
          Taro.showToast({ 
            title: item.completed ? "已撤回" : "已完成", 
            icon: "success" 
          });
          break;
        case 1: // 编辑
          startEdit(item);
          break;
        case 2: // 删除
          handleDelete(item.id, item.completed);
          break;
      }
    }
  });
};

// --- UI 渲染部分更新 ---
// 在 Text 组件上增加 onLongPress 事件
<Text 
  className="flex-1" 
  onClick={() => toggleTodo(item)}
  onLongPress={() => showActionMenu(item)} // 关键:长按触发
>
  {item.text}
</Text>

3. 验收标准

  1. Toast 覆盖:
    • 新增成功有提示。
    • 输入为空有警告提示。
    • 删除成功有提示。
    • 切换状态(点击或菜单)有提示。
  2. Modal 确认:
    • 点击“删除”按钮或通过菜单选择删除,必须弹出确认框。
  3. ActionSheet 唤起:
    • 长按任何一条待办(无论待办中还是已完成),都能弹出底部菜单。
    • 菜单项功能(撤回/完成、编辑、删除)均能正常工作。

更多API参考

官网:组件库说明 | Taro 文档

tips:配置context7的mcp、对应skill,开发体更佳:Context7 - Up-to-date documentation for LLMs and AI code editors

mcp:

"context7": {
      "args": [
        "-y",
        "@upstash/context7-mcp@latest"
      ],
      "command": "npx"
    }

skill:

 pnpx playbooks add skill whinc/my-claude-plugins --skill taro-docs

Step C:本地持久化 API(重启不丢)

目标

  • 掌握小程序本地存储读写。

极简实现

  • 初始化读取 TODO_LIST_V2
  • 每次列表变更立即写回。

API 体验点(Context7 官方)

  • Taro.getStorageSync
  • Taro.setStorageSync

验收

  • 关闭开发者工具再打开,数据仍在。

Step D:页面生命周期与刷新 API

目标

  • 体验“页面级”而非“组件级”的小程序能力。

极简实现

  • 开启 enablePullDownRefresh: true
  • 下拉刷新后重读本地数据,并停止动画。
  • 打印页面显示/隐藏日志。

API 体验点(Context7 官方)

  • usePullDownRefresh
  • Taro.stopPullDownRefresh
  • useDidShow / useDidHide / useLoad

验收

  • 下拉可触发刷新且动画正常结束。
  • 能说清 3 个生命周期触发场景。

Step E:滚动与触底 API(列表场景常用)

目标

  • 体验列表页面高频 API:滚动监听、触底加载。

极简实现

  • 用 mock 数据做分页(每页 10 条)。
  • 触底追加下一页。
  • 顶部显示实时滚动位置。

API 体验点(Context7 官方)

  • useReachBottom
  • usePageScroll

验收

  • 触底自动追加数据。
  • 页面显示 scrollTop 实时变化。

Step F:页面视觉与节点查询 API

目标

  • 体验运行时查询节点信息的能力。

极简实现

  • 页面 ready 后测量 Todo 容器高度,并展示在页面上。

API 体验点(Context7 官方)

  • useReady
  • Taro.createSelectorQuery().select(...).boundingClientRect().exec()

验收

  • 可拿到容器高度并渲染。

Step G:设备能力小实验(轻量增强)

目标

  • 在不破坏主流程的前提下,体验设备能力 API。

极简实现

  • 点击“复制内容”按钮:复制当前 Todo 文本。
  • 点击“网络状态”按钮:读取当前网络类型。
  • 新增成功后轻震动(可选,真机体验更明显)。

API 体验点(Context7 官方)

  • Taro.setClipboardData / Taro.getClipboardData
  • Taro.getNetworkType
  • Taro.vibrateShort

验收

  • 复制、读网络、轻震动均可触发。

Step H:导航栏与状态提示 API

目标

  • 体验页面壳层相关 API(标题、角标)。

极简实现

  • 动态设置标题:待办(n)
  • 当存在已完成项时显示角标,否则移除角标。

API 体验点(Context7 官方)

  • Taro.setNavigationBarTitle
  • Taro.setTabBarBadge / Taro.removeTabBarBadge(仅 tabBar 页面生效)

验收

  • 标题随数据变化。
  • 满足条件时角标正确显示/移除。

Step I:网络请求 API(可选扩展)

目标

  • 体验请求链路,但不把项目复杂化。

极简实现

  • Taro.request 拉一组远程 mock todos(或静态 JSON)。
  • 提供“同步远程示例数据”按钮。

API 体验点(Context7 官方)

  • Taro.request

验收

  • 可发起请求并将返回结果映射为 Todo。

Step J:媒体处理 API(图片与视频)

目标

  • 掌握小程序中最常用的媒体选择与保存能力。

实现任务

  • 图片:chooseImage 选择图片并预览,saveImageToPhotosAlbum 保存图片到相册。
  • 视频:chooseVideo (或 chooseMedia) 选择视频,saveVideoToPhotosAlbum 保存视频。
  • 练习权限处理:保存到相册需要 scope.writePhotosAlbum 权限。

重点 API(官方)

  • Taro.chooseImage / Taro.chooseVideo / Taro.chooseMedia
  • Taro.saveImageToPhotosAlbum / Taro.saveVideoToPhotosAlbum
  • Taro.getSetting / Taro.authorize (权限申请)

验收标准

  • 能成功唤起系统相册选择资源。
  • 能将临时文件(tempFilePath)成功保存至手机本地相册。

Step K:文件系统与上传(File System)

目标

  • 理解小程序临时文件、持久化文件与远程上传流程。

实现任务

  • 文件保存:使用 Taro.saveFile 将临时文件转为小程序本地持久化文件。
  • 文件上传:使用 Taro.uploadFile 将选择的媒体文件发送至服务端。
  • 文件信息:使用 Taro.getFileInfo 获取文件大小、摘要(MD5)等。

重点 API(官方)

  • Taro.saveFile
  • Taro.uploadFile
  • Taro.getFileInfo / Taro.getSavedFileInfo

验收标准

  • 选择文件后能看到上传进度或成功日志。
  • 能在小程序本地存储中管理已下载或保存的文件。

建议执行顺序(学习效率优先)

A -> B -> C -> D -> E -> F -> G -> H -> I(可选) -> J -> K

每步记录模板(建议)

  • 我用了哪个 API:
  • 它解决了什么问题:
  • 我踩到的坑:
  • 如果重写一次,我会怎么改:
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐