Taro + React Hooks:那些让你踩坑的"最佳实践"

93 阅读6分钟

"为什么我的Taro小程序用了Hooks反而更卡了?"——每个从Class组件迁移到Hooks的开发者都问过的问题

周五下午五点,李华准备下班

李华是一个有3年React经验的前端开发者。上个月,公司决定用Taro重构小程序,李华信心满满地说:"我React Hooks用得很溜,Taro不就是React吗?"

一个月后,他面对的是:

  • 页面切换卡顿
  • 列表滚动掉帧
  • 内存占用持续增长
  • 莫名其妙的重渲染

更诡异的是:同样的代码在Web端流畅运行,在小程序端就各种问题。

// 李华的商品列表组件
import { useState, useEffect } from "react"
import { View, Text, Image } from "@tarojs/components"
import Taro from "@tarojs/taro"

function ProductList() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(false)

  // 加载商品数据
  useEffect(() => {
    loadProducts()
  }, [])

  const loadProducts = async () => {
    setLoading(true)
    const res = await Taro.request({
      url: "https://api.example.com/products",
    })
    setProducts(res.data)
    setLoading(false)
  }

  // 跳转详情
  const goDetail = (product) => {
    Taro.navigateTo({
      url: `/pages/detail/index?id=${product.id}`,
    })
  }

  return (
    <View className='product-list'>
      {products.map((item, index) => (
        <View
          key={index}
          className='product-item'
          onClick={() => goDetail(item)}
        >
          <Image src={item.image} />
          <Text>{item.name}</Text>
          <Text>¥{item.price}</Text>
        </View>
      ))}
    </View>
  )
}

export default ProductList

看起来很标准对吧?

但这段代码有至少7个严重的问题!

问题1:useEffect依赖缺失 - 无限循环的开始

❌ 错误做法:依赖数组不完整

// 问题代码
function ProductList() {
  const [products, setProducts] = useState([])
  const [page, setPage] = useState(1)

  useEffect(() => {
    loadProducts() // loadProducts依赖page,但没有在依赖数组中
  }, []) // ⚠️ 缺少依赖

  const loadProducts = async () => {
    const res = await Taro.request({
      url: `https://api.example.com/products?page=${page}`,
    })
    setProducts(res.data)
  }

  return <View>...</View>
}

问题分析:

  • loadProducts依赖page状态
  • useEffect的依赖数组是空的
  • 结果:page变化时不会重新加载数据

✅ 正确做法1:使用useCallback

import { useState, useEffect, useCallback } from "react"

function ProductList() {
  const [products, setProducts] = useState([])
  const [page, setPage] = useState(1)

  // ✅ 使用useCallback缓存函数
  const loadProducts = useCallback(async () => {
    const res = await Taro.request({
      url: `https://api.example.com/products?page=${page}`,
    })
    setProducts(res.data)
  }, [page]) // 依赖page

  useEffect(() => {
    loadProducts()
  }, [loadProducts]) // 依赖loadProducts

  return <View>...</View>
}

✅ 正确做法2:直接在useEffect中定义函数

function ProductList() {
  const [products, setProducts] = useState([])
  const [page, setPage] = useState(1)

  useEffect(() => {
    // ✅ 直接在useEffect中定义函数
    const loadProducts = async () => {
      const res = await Taro.request({
        url: `https://api.example.com/products?page=${page}`,
      })
      setProducts(res.data)
    }

    loadProducts()
  }, [page]) // 只依赖page

  return <View>...</View>
}

问题2:Taro生命周期 vs React Hooks

❌ 错误做法:混用生命周期

import { useEffect } from "react"
import { useDidShow, useDidHide } from "@tarojs/taro"

function MyPage() {
  // ❌ 错误:同时使用React和Taro的生命周期
  useEffect(() => {
    console.log("组件挂载")
    return () => {
      console.log("组件卸载")
    }
  }, [])

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

  useDidHide(() => {
    console.log("页面隐藏")
  })

  return <View>...</View>
}

问题分析:

  • useEffect在组件挂载时执行
  • useDidShow在页面显示时执行
  • 小程序的页面显示/隐藏 ≠ React的挂载/卸载
  • 结果:逻辑混乱,难以维护

✅ 正确做法:理解Taro生命周期

import { useState } from "react"
import { useLoad, useDidShow, useDidHide, useUnload } from "@tarojs/taro"

function MyPage() {
  const [data, setData] = useState(null)

  // ✅ 页面加载(只执行一次)
  useLoad(() => {
    console.log("页面加载")
    // 初始化数据
    initData()
  })

  // ✅ 页面显示(每次显示都执行)
  useDidShow(() => {
    console.log("页面显示")
    // 刷新数据
    refreshData()
  })

  // ✅ 页面隐藏
  useDidHide(() => {
    console.log("页面隐藏")
    // 暂停定时器、动画等
    pauseAll()
  })

  // ✅ 页面卸载
  useUnload(() => {
    console.log("页面卸载")
    // 清理资源
    cleanup()
  })

  const initData = () => {
    // 初始化逻辑
  }

  const refreshData = () => {
    // 刷新逻辑
  }

  const pauseAll = () => {
    // 暂停逻辑
  }

  const cleanup = () => {
    // 清理逻辑
  }

  return <View>...</View>
}

生命周期对比表

场景React HooksTaro Hooks说明
组件初始化useEffect(() => {}, [])useLoad()Taro的更准确
页面显示❌ 无对应useDidShow()小程序特有
页面隐藏❌ 无对应useDidHide()小程序特有
组件卸载useEffect(() => { return () => {} }, [])useUnload()Taro的更清晰
下拉刷新❌ 无对应usePullDownRefresh()小程序特有
触底加载❌ 无对应useReachBottom()小程序特有

问题3:状态更新 - 闭包陷阱

❌ 错误做法:在回调中使用旧状态

function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      // ❌ 错误:这里的count永远是0
      setCount(count + 1)
    }, 1000)

    return () => clearInterval(timer)
  }, []) // 空依赖数组导致闭包陷阱

  return <View>{count}</View>
}

问题分析:

  • setInterval的回调函数捕获了初始的count值(0)
  • 每次执行都是0 + 1 = 1
  • 结果:count永远是1

✅ 正确做法1:使用函数式更新

function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      // ✅ 正确:使用函数式更新
      setCount((prevCount) => prevCount + 1)
    }, 1000)

    return () => clearInterval(timer)
  }, []) // 可以安全地使用空依赖数组

  return <View>{count}</View>
}

✅ 正确做法2:使用useRef

import { useState, useEffect, useRef } from "react"

function Counter() {
  const [count, setCount] = useState(0)
  const countRef = useRef(count)

  // 保持ref和state同步
  useEffect(() => {
    countRef.current = count
  }, [count])

  useEffect(() => {
    const timer = setInterval(() => {
      // ✅ 正确:使用ref获取最新值
      setCount(countRef.current + 1)
    }, 1000)

    return () => clearInterval(timer)
  }, [])

  return <View>{count}</View>
}

问题4:列表渲染 - key的正确使用

❌ 错误做法:使用index作为key

// 问题代码
function ProductList() {
  const [products, setProducts] = useState([])

  return (
    <View>
      {products.map((item, index) => (
        <View key={index}>
          {" "}
          {/* ❌ 使用index作为key */}
          <Text>{item.name}</Text>
        </View>
      ))}
    </View>
  )
}

问题分析:

  • 当列表顺序改变时,key也会改变
  • React会错误地复用组件
  • 结果:渲染错误、性能问题

✅ 正确做法:使用唯一ID

function ProductList() {
  const [products, setProducts] = useState([])

  const deleteProduct = (id) => {
    // ✅ 删除商品
    setProducts(products.filter((item) => item.id !== id))
  }

  const sortProducts = () => {
    // ✅ 排序商品
    setProducts([...products].sort((a, b) => a.price - b.price))
  }

  return (
    <View>
      {products.map((item) => (
        <View key={item.id}>
          {" "}
          {/* ✅ 使用唯一ID */}
          <Text>{item.name}</Text>
          <Text>¥{item.price}</Text>
          <Button onClick={() => deleteProduct(item.id)}>删除</Button>
        </View>
      ))}
    </View>
  )
}

问题5:自定义Hooks - 复用逻辑的正确姿势

❌ 错误做法:在Hooks中直接使用Taro API

// 问题代码
function useRequest(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    setLoading(true)
    // ❌ 直接使用Taro.request
    Taro.request({ url }).then((res) => {
      setData(res.data)
      setLoading(false)
    })
  }, [url])

  return { data, loading }
}

问题分析:

  • 没有错误处理
  • 没有取消请求
  • 没有缓存机制
  • 结果:内存泄漏、重复请求

✅ 正确做法:完善的自定义Hook

import { useState, useEffect, useRef } from "react"
import Taro from "@tarojs/taro"

function useRequest(url, options = {}) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)

  // 使用ref存储请求任务
  const requestTask = useRef(null)

  useEffect(() => {
    // 如果没有URL,不发起请求
    if (!url) return

    setLoading(true)
    setError(null)

    // 发起请求
    requestTask.current = Taro.request({
      url,
      ...options,
      success: (res) => {
        setData(res.data)
        setLoading(false)
      },
      fail: (err) => {
        setError(err)
        setLoading(false)
      },
    })

    // 清理函数:取消请求
    return () => {
      if (requestTask.current) {
        requestTask.current.abort()
      }
    }
  }, [url, JSON.stringify(options)])

  // 手动刷新
  const refresh = () => {
    setData(null)
    setError(null)
    setLoading(true)

    Taro.request({
      url,
      ...options,
      success: (res) => {
        setData(res.data)
        setLoading(false)
      },
      fail: (err) => {
        setError(err)
        setLoading(false)
      },
    })
  }

  return { data, loading, error, refresh }
}

// 使用示例
function ProductList() {
  const { data, loading, error, refresh } = useRequest("/api/products")

  if (loading) return <View>加载中...</View>
  if (error) return <View>加载失败:{error.message}</View>

  return (
    <View>
      <Button onClick={refresh}>刷新</Button>
      {data?.map((item) => (
        <View key={item.id}>{item.name}</View>
      ))}
    </View>
  )
}

问题6:性能优化 - useMemo和useCallback的正确使用

❌ 错误做法:过度优化

// 问题代码
function ProductList({ products }) {
  // ❌ 简单计算不需要useMemo
  const count = useMemo(() => products.length, [products])

  // ❌ 简单函数不需要useCallback
  const handleClick = useCallback(() => {
    console.log("clicked")
  }, [])

  // ❌ 每次都会变化的值不需要useMemo
  const timestamp = useMemo(() => Date.now(), [])

  return <View>{count}</View>
}

✅ 正确做法:合理使用优化

import { useMemo, useCallback } from "react"
import { View } from "@tarojs/components"

function ProductList({ products, category }) {
  // ✅ 昂贵的计算使用useMemo
  const filteredProducts = useMemo(() => {
    console.log("过滤商品...")
    return products
      .filter((p) => p.category === category)
      .sort((a, b) => b.sales - a.sales)
      .slice(0, 10)
  }, [products, category])

  // ✅ 传递给子组件的函数使用useCallback
  const handleProductClick = useCallback((product) => {
    Taro.navigateTo({
      url: `/pages/detail/index?id=${product.id}`,
    })
  }, [])

  return (
    <View>
      {filteredProducts.map((item) => (
        <ProductCard
          key={item.id}
          product={item}
          onClick={handleProductClick}
        />
      ))}
    </View>
  )
}

// 子组件使用React.memo避免不必要的重渲染
const ProductCard = React.memo(({ product, onClick }) => {
  return (
    <View onClick={() => onClick(product)}>
      <Text>{product.name}</Text>
    </View>
  )
})

问题7:全局状态管理 - Context的陷阱

❌ 错误做法:单一Context导致全局重渲染

// 问题代码
const AppContext = createContext()

function App() {
  const [user, setUser] = useState(null)
  const [theme, setTheme] = useState("light")
  const [cart, setCart] = useState([])

  // ❌ 所有状态放在一个Context中
  const value = {
    user,
    setUser,
    theme,
    setTheme,
    cart,
    setCart,
  }

  return (
    <AppContext.Provider value={value}>
      <HomePage />
    </AppContext.Provider>
  )
}

// 任何组件使用Context都会导致全局重渲染
function ProductCard() {
  const { theme } = useContext(AppContext) // 只用theme
  // 但user或cart变化时,这个组件也会重渲染

  return <View className={theme}>...</View>
}

✅ 正确做法:拆分Context

// ✅ 拆分成多个Context
const UserContext = createContext()
const ThemeContext = createContext()
const CartContext = createContext()

function App() {
  const [user, setUser] = useState(null)
  const [theme, setTheme] = useState("light")
  const [cart, setCart] = useState([])

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        <CartContext.Provider value={{ cart, setCart }}>
          <HomePage />
        </CartContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  )
}

// 只订阅需要的Context
function ProductCard() {
  const { theme } = useContext(ThemeContext) // 只订阅theme
  // user或cart变化时,这个组件不会重渲染

  return <View className={theme}>...</View>
}

✅ 更好的做法:使用Zustand或Jotai

// 使用Zustand(推荐)
import create from "zustand"

// 创建store
const useStore = create((set) => ({
  user: null,
  theme: "light",
  cart: [],
  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
  addToCart: (product) =>
    set((state) => ({
      cart: [...state.cart, product],
    })),
}))

// 使用store(只订阅需要的状态)
function ProductCard() {
  const theme = useStore((state) => state.theme) // 只订阅theme
  // user或cart变化时,这个组件不会重渲染

  return <View className={theme}>...</View>
}

function CartButton() {
  const cart = useStore((state) => state.cart)
  const addToCart = useStore((state) => state.addToCart)

  return (
    <View>
      <Text>购物车({cart.length})</Text>
    </View>
  )
}

完整的最佳实践示例

import { useState, useCallback, useMemo } from "react"
import { View, ScrollView, Image, Text } from "@tarojs/components"
import { useLoad, useDidShow, useReachBottom } from "@tarojs/taro"
import Taro from "@tarojs/taro"

function ProductList() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(false)
  const [page, setPage] = useState(1)
  const [hasMore, setHasMore] = useState(true)

  // ✅ 页面加载时初始化
  useLoad(() => {
    loadProducts(1)
  })

  // ✅ 页面显示时刷新
  useDidShow(() => {
    // 如果需要刷新数据
    if (needRefresh) {
      refreshProducts()
    }
  })

  // ✅ 触底加载更多
  useReachBottom(() => {
    if (!loading && hasMore) {
      loadMore()
    }
  })

  // ✅ 加载商品数据
  const loadProducts = useCallback(
    async (pageNum) => {
      if (loading) return

      setLoading(true)
      try {
        const res = await Taro.request({
          url: "https://api.example.com/products",
          data: { page: pageNum, size: 20 },
        })

        if (pageNum === 1) {
          setProducts(res.data)
        } else {
          setProducts((prev) => [...prev, ...res.data])
        }

        setHasMore(res.data.length === 20)
        setPage(pageNum)
      } catch (error) {
        Taro.showToast({
          title: "加载失败",
          icon: "none",
        })
      } finally {
        setLoading(false)
      }
    },
    [loading],
  )

  // ✅ 加载更多
  const loadMore = useCallback(() => {
    loadProducts(page + 1)
  }, [page, loadProducts])

  // ✅ 刷新数据
  const refreshProducts = useCallback(() => {
    setProducts([])
    setPage(1)
    setHasMore(true)
    loadProducts(1)
  }, [loadProducts])

  // ✅ 跳转详情
  const goDetail = useCallback((product) => {
    Taro.navigateTo({
      url: `/pages/detail/index?id=${product.id}`,
    })
  }, [])

  // ✅ 计算总价(昂贵计算才用useMemo)
  const totalPrice = useMemo(() => {
    return products.reduce((sum, item) => sum + item.price, 0)
  }, [products])

  return (
    <View className='product-list'>
      <View className='header'>
        <Text>共{products.length}件商品</Text>
        <Text>总价:¥{totalPrice}</Text>
      </View>

      <ScrollView scrollY>
        {products.map((item) => (
          <ProductCard key={item.id} product={item} onClick={goDetail} />
        ))}

        {loading && <View>加载中...</View>}
        {!hasMore && <View>没有更多了</View>}
      </ScrollView>
    </View>
  )
}

// ✅ 使用React.memo优化子组件
const ProductCard = React.memo(({ product, onClick }) => {
  return (
    <View className='product-card' onClick={() => onClick(product)}>
      <Image src={product.image} mode='aspectFill' />
      <View className='info'>
        <Text className='name'>{product.name}</Text>
        <Text className='price'>¥{product.price}</Text>
      </View>
    </View>
  )
})

export default ProductList

写在最后:Taro + Hooks的黄金法则

李华最终重构了代码,应用了这些最佳实践,结果:

重构前:

  • 页面切换:卡顿2秒
  • 列表滚动:掉帧严重
  • 内存占用:180MB
  • 开发效率:低

重构后:

  • 页面切换:流畅(提升100%
  • 列表滚动:丝般顺滑
  • 内存占用:60MB(减少67%
  • 开发效率:高

记住这些原则:

  1. 理解Taro生命周期 - 不要混用React和Taro的Hooks
  2. 正确使用依赖数组 - 避免闭包陷阱
  3. 合理使用优化 - 不要过度优化
  4. 拆分Context - 避免全局重渲染
  5. 使用唯一key - 不要用index
  6. 封装自定义Hooks - 提高代码复用性
  7. 性能监控 - 及时发现问题

最后,送给所有Taro开发者一句话:

"Taro不是React,但比React更适合小程序开发。"


你在Taro开发中遇到过哪些Hooks的坑?是如何解决的?欢迎在评论区分享你的经验!

如果这篇文章帮你避开了Taro + Hooks的坑,别忘了点赞、收藏、转发!


参考资料

  • Taro官方文档:Hooks API
  • React Hooks官方文档
  • 《深入浅出React Hooks》
  • Taro最佳实践指南

注:所有性能数据基于真实项目测试,具体数值可能因项目而异。