通常情况下,我们写小程序的时候,Taro帮我们省了大量时间,一套代码能跑到微信、支付宝、H5,已经很香了。但当老板说“顺便出个App,就按小程序来”的时候,咱们就得考虑Taro编译到React Native这条路。真走起来,你会发现事情没那么简单,网上各种零碎的资料,踩完坑还得自己拼图。这篇文章不聊大道理,就是把我们在实际项目里碰到的适配要点和原生模块集成问题掰开了说,给准备上车的人一点底气。

一、Taro编译到RN到底是怎么回事

先搞清楚一个大背景:Taro不是运行时像React Native那样直接渲染原生组件,它是编译时框架。也就是说,你写的JSX代码会被Taro的编译器解析,然后根据目标平台生成不同的代码。RN这个目标端比较特殊,因为它不是一个简单的web环境,而是有自己的组件系统、样式系统和事件系统。

从应用场景看,这种方案最吸引人的地方就是“一鱼两吃”。你已经有了一套Taro写的业务代码,只要处理掉一些平台差异,就能在App Store和安卓各大市场上架。省掉单独招RN开发或者原生开发的人力,对小团队尤其友好。

但优点背后也有代价。RN和浏览器的差异比想象中大,很多CSS写法的“人有我无”,很多原生能力需要写原生模块。你可能会想,Taro不是已经封装好了吗?其实它封装了大部分常用的,但碰到定制化的扫码、蓝牙、推送,你还是得摸到原生层去。

所以我们得先放下“编译完就万事大吉”的幻想,踏踏实实做适配。

二、从咱们的实际项目说起

前阵子我们接手一个电商小程序,商品列表、购物车、订单详情,全是Taro写的。当需求变成了要出App,我们就乐呵呵地执行了taro build --rn,结果打开模拟器发现页面是能跳转了,但首页的商品卡片排列全乱了,滚动列表卡顿得让人怀疑人生,甚至直接白屏报错“Element type is invalid”。这一瞬间才意识到,编译通过不等于能跑,能跑不等于能用,能用不等于好用。

接下来的两天,我们就在“查文档—改代码—报错—再查文档”的循环里挣扎。最后总结出了一套相对顺手的适配流程。如果你也正在这条路上,下面的内容可以直接帮你少掉不少头发。

三、适配要点:样式、路由、组件这些老伙计

3.1 样式:别再用CSS的习惯去写RN

最扎心的就是样式。RN的样式不是字符串CSS,它是一个JS对象。Taro在编译时会把单独的.css文件转换掉,但很多CSS能力在RN上根本没有,比如display: none、伪元素、媒体查询、position: fixed在不同机型上也会抽风。

咱们最保险的做法,是在RN环境下直接使用StyleSheet对象,而不是在.css文件里写一堆“看起来能用”的样式。刚开始可能不习惯,但写着写着也就顺了。

// 技术栈:Taro + React Native(JavaScript)
import { View, Text } from '@tarojs/components'
import { StyleSheet } from 'react-native'

export default function ProductCard() {
  return (
    <View style={styles.card}>
      <Text style={styles.price}>¥ 99.00</Text>
    </View>
  )
}

const styles = StyleSheet.create({
  card: {
    // 注意:RN 默认 flexDirection 是 column,和 web 的 row 不一样。
    flexDirection: 'row',
    alignItems: 'center',
    padding: 12,
    backgroundColor: '#fff',
    borderRadius: 8,
    marginBottom: 10,
  },
  price: {
    fontSize: 18,
    fontWeight: 'bold',
    color: '#e4393c',
  },
})

这里有个容易忽略的细节:在RN里,所有元素默认都是display: flex,所以你再写display: 'flex'其实可有可无,但写了也不算错。另外,百分比宽度在某些版本里支持不完整,能换成flex就尽量用flex。

3.2 路由:页面跳转别再想当然

Taro的navigateTo小程序里可以直接传url带参数,RN端也是一样用。但参数的格式有讲究,只能传字符串或者能被JSON序列化的东西,如果传一个对象,很可能到了下一个页面就变成了[object Object]。我们一开始就在这个坑里爬了很久。

建议把参数尽量拆成简单字段,接收方用useRouter来读。

// 技术栈:Taro + React Native(JavaScript)
import Taro from '@tarojs/taro'

// 跳转页面,只传基础类型参数
function goDetail(item) {
  Taro.navigateTo({
    url: `/pages/detail/detail?id=${item.id}&title=${encodeURIComponent(item.title)}`,
  })
}
// 技术栈:Taro + React Native(JavaScript)
import { useRouter } from '@tarojs/taro'

export default function Detail() {
  const router = useRouter()

  // 注意:拿到的参数全是字符串,需要自己做类型转换
  const id = router.params.id
  const title = decodeURIComponent(router.params.title || '')

  return <Text>详情页:{id},标题:{title}</Text>
}

如果参数特别多,也别怕麻烦,序列化成JSON字符串再传,接收方再JSON.parse回来。但要记得捕获解析异常,别让一条脏数据搞挂了整个页面。

3.3 组件:别痴心妄想所有小程序组件都有对应物

Taro RN端支持很多常用组件,像View、Text、Image、ScrollView、Button都没问题。但有些组件,比如Swiper、Canvas、Map,RN端的表现和小程序完全不是一回事,甚至根本没有实现。

这时候不能硬撸,要用条件编译来处理。Taro提供了process.env.TARO_ENV,可以在代码里判断当前环境。

// 技术栈:Taro + React Native(JavaScript)
import { View } from '@tarojs/components'

// 先定义变量,根据环境加载不同的轮播组件
let BannerSwiper

if (process.env.TARO_ENV === 'rn') {
  // RN 环境下使用第三方库 react-native-swiper
  BannerSwiper = require('react-native-swiper').default
} else {
  // 非 RN 环境(微信、H5 等)使用 Taro 自带的 Swiper
  BannerSwiper = require('@tarojs/components').Swiper
}

export default function Banner() {
  return (
    <View>
      <BannerSwiper>
        {/* 这里放轮播内容 */}
      </BannerSwiper>
    </View>
  )
}

这种写法比在配置文件里做alias要直白得多,团队里谁都能看懂。不过要记住,require进去的组件必须是同一个接口的,否则还得再包一层适配组件。

3.4 图片和资源:路径别搞花花肠子

RN里的本地图片不是直接给个uri路径就行,它需要经过打包处理。Taro编译时对import图片的语法有一定支持,但最好的做法是统一用require,这样打包工具才能把资源编进App。

// 技术栈:Taro + React Native(JavaScript)
import { Image } from '@tarojs/components'

// 网络图片,直接传 src 字符串
const remoteImg = 'https://example.com/logo.png'

// 本地图片,用 require 拿到的数值才能在 RN 里正确显示
const localImg = require('./assets/logo.png')

export default function Logo() {
  return (
    <View>
      <Image src={remoteImg} style={{ width: 100, height: 40 }} />
      <Image src={localImg} style={{ width: 100, height: 40 }} />
    </View>
  )
}

如果你在项目里用了@/assets/logo.png这种别名,RN端可能会蒙圈。建议在config里配好resolve.alias,或者干脆在RN环境里都写相对路径。

四、原生模块集成:最让人夜不能寐的部分

样式和组件的问题磨一磨还能过去,原生模块才真是掉坑深渊。RN是JS层和原生层之间的桥,你想在App里调用摄像头扫码、读取手机通讯录、调起支付,都得通过原生模块。

4.1 先弄懂RN原生模块是怎么回事

简单说,原生模块就是一句由原生语言写的代码包,比如iOS里的RCTBridgeModule和安卓里的ReactContextJavaModule。它们对外暴露一些方法,JS通过NativeModules去调用。

在Taro项目编译到RN后,我们可以在JS代码里直接引入React Native的NativeModules。

// 技术栈:Taro + React Native(JavaScript)
import { NativeModules } from 'react-native'

// 假设原生模块在原生侧注册的名字叫 QRScanner
const { QRScanner } = NativeModules

// 调用原生方法,注意传失败回调
QRScanner.scan((error, result) => {
  if (error) {
    console.error('扫码出错:', error)
  } else {
    console.log('扫码结果:', result)
  }
})

这里只是一个演示。实际项目中,我们不会把裸调用写得满屏都是,而是包装成一个Promise,方便业务调用。

4.2 常见问题一:模块找不到,NativeModules里是undefined

这个现象太常见了,你明明在原生代码里写了QRScanner,但JS里打印出来却是undefined。多半是名字对不上,或者原生模块没有正确注册。

解决思路很简单:

  • 先确认原生模块暴露的名字是否和JS里完全一致,包括大小写。
  • 如果是iOS工程,检查一下Podfile里是否引入了对应的pod库。
  • 重新安装原生依赖,让Xcode和安卓Studio重新编译一次。
# 清理并重新安装 iOS 原生依赖
cd ios
pod deintegrate
pod install
# 清理安卓构建缓存
cd android
./gradlew clean

这种“找不到”的问题,多半是缓存惹的祸。清理完再跑,基本能解决。

4.3 常见问题二:回调不触发,原生方法死活没反应

有时候模块找到了,方法也能调,但回调就是不执行。大概率是原生侧的方法没有在主线程运行,或者回调宏用错了。比如iOS原生代码里要用RCTResponseSenderBlock,安卓要用Callback。

但咱们是JS侧,能做的事情是给自己留一条退路,别把回调用死:

// 技术栈:Taro + React Native(JavaScript)
import { NativeModules } from 'react-native'
import Taro from '@tarojs/taro'

// 包装成 Promise,且加超时兜底
function scanQRCode(timeout = 5000) {
  return new Promise((resolve, reject) => {
    const { QRScanner } = NativeModules
    if (!QRScanner) {
      reject(new Error('找不到 QRScanner 原生模块'))
      return
    }

    // 设置超时,防止回调永远不来
    const timer = setTimeout(() => {
      reject(new Error('调取扫码超时'))
    }, timeout)

    try {
      QRScanner.scan((error, result) => {
        clearTimeout(timer)
        if (error) {
          reject(new Error(error))
        } else {
          resolve(result)
        }
      })
    } catch (e) {
      clearTimeout(timer)
      reject(e)
    }
  })
}

// 页面中使用
scanQRCode().then(result => {
  Taro.showToast({ title: result })
}).catch(err => {
  console.error('扫码失败:', err)
})

这个Promise加超时的写法,起码能让你发现“回调没来”的问题,而不是让业务死等。

4.4 常见问题三:原生主动发消息,JS收不到

有些场景是原生那边主动通知JS,比如扫码成功之后,原生封装了一个事件广播。这时候要用DeviceEventEmitter来监听,但一定要记得在页面销毁时移除监听,否则会内存泄漏,甚至重复收到事件。

// 技术栈:Taro + React Native(JavaScript)
import { useEffect } from 'react'
import { DeviceEventEmitter } from 'react-native'

export default function ScanPage() {
  useEffect(() => {
    // 监听原生发过来的事件
    const subscription = DeviceEventEmitter.addListener('onScanResult', (data) => {
      console.log('原生告诉我扫到了:', data)
    })

    // 组件卸载时一定要移除监听
    return () => {
      subscription.remove()
    }
  }, [])

  return null
}

如果你收不到事件,先查看原生侧发送事件的名字是否和这里一致,并且确认事件是在JS监听之后才发出来的。有时候原生在页面初始化时就广播了,JS还没挂上监听,自然丢消息。

4.5 常见问题四:编译时报错,找不到头文件

如果你在原生模块里引用了某些系统框架,比如AVFoundation,但iOS工程没有链接,就会出现头文件找不到的错误。安卓那边则是缺依赖库。

这时候最好去node_modules里查一下项目自带的原生模块示例,跟着它们的配置文件走一遍。比如在Podfile里添加subspec:

# 这是 CocoaPods 的配置示例
pod 'RNQRScanner', :path => '../node_modules/react-native-qr-scanner', :subspecs => ['QRScanner']

虽然这是Ruby,但它只是配置文件,文章主题还是围绕JS。

五、适配过程中的其他注意事项

代码层面的坑说完了,还有几个细节,看起来不起眼,但特别影响上线体验。

5.1 异步存储:别乱放大数据

Taro的Taro.setStorage在RN端同样可用。但RN的存储是基于异步的,如果你往里面塞一个几兆的大对象,读写速度会明显变慢,甚至导致内存波动。建议只存轻量的设置项和用户ID,复杂的列表数据走后端接口。

5.2 环境判断:一键切换特性

我们用process.env.TARO_ENV来判断当前端别,已经是很成熟的做法。除了组件加载,还可以用它来控制日志输出、不同端的统计上报。

// 技术栈:Taro + React Native(JavaScript)
function reportEvent(eventName, data) {
  if (process.env.TARO_ENV === 'rn') {
    // RN 环境走 App 自带的埋点
    console.log('[RN埋点]', eventName, data)
  } else {
    // 小程序环境走微信埋点
    console.log('[小程序埋点]', eventName, data)
  }
}

这样写,逻辑清晰,以后加新事件也不会乱。

5.3 调试:别再用浏览器F12那套思路

RN调试推荐用React Native的DevTools,或者直接在终端看日志。你可能会发现,print到控制台的信息和浏览器里长得不一样,但多加几个关键日志,定位问题反而更快。

六、文章总结

回头看看,Taro编译到React Native这条路并不是铺好的高速公路,更像一条需要自己动手修的土路。好处是业务代码复用率高,一台服务器、一套接口、一套前端代码就能同时维护小程序和App,对开发周期短的团队来说是福音。坏处是你必须接受RN的“小脾气”,比如样式写法、路由传参、组件限制,还有躲不开的原生模块调试。

适配要点无非就是:样式尽量用StyleSheet对象,路由参数保持简单类型,组件按环境做条件加载,图片资源统一require。原生模块集成则要记住“名字一致、清理缓存、Promise包装、事件别挂错时辰”这几条护身符。

最后想提醒你,原生模块的集成不是一蹴而就的,要给调试留出充足时间。如果你能提前把原生模块封装成一套统一的JS接口,不管未来换不换框架,这笔投入都值得。

希望这些踩过的坑能让你走得更顺一点。毕竟,跨端开发这碗饭,吃得好不好,全看细节准不准。