记得年初接了个电商类的单子,需求听起来简单——把一套React Native商城跑起来,上架双端。结果做到一半,对方补了一句:“后面可能要考虑鸿蒙。”当时我还没意识到这句话的分量,直到真正动手做鸿蒙适配那天,才发现这已经不是“换个平台编译”那么简单了。
如果你也在做RN商城项目,或者正准备把现有RN应用往鸿蒙上迁移,这篇内容应该能帮你省掉不少查文档和翻源码的时间。我尽量把所有能写的实操步骤、踩坑记录、选型理由都摊开来讲,从脚手架搭建到商品模块实现,再到鸿蒙侧的兼容改造,一条线走完整。
1. 商城类RN项目的架构选型:为什么我从裸RN转向了模块化组合
先说结论:商城这种业务形态,和普通工具类App在技术架构上的要求完全不是一个量级。
商品列表、购物车、订单状态、商家入驻、优惠券核销、直播入口……任何一个模块单拎出来都是完整的中型功能。如果一开始就写裸RN,把所有页面堆在一个大工程里,后期团队协作和打包调试都会非常痛苦。我在几个项目里吃过这个亏,所以这次从一开始就采用了模块化组合方案。
1.1 依赖拆分:核心包与业务包的边界
我的做法是先把依赖分成三层。底层是react和react-native本体,版本锁定不轻易动。中间层是导航(@react-navigation/native)、状态管理(mobx+mobx-react-lite)、网络请求(axios+ 自定义拦截器)这类基础设施。顶层才是业务组件,像商品卡片、分类侧边栏、商家信息流这些全部在src/components里按域划分。
这样拆分最大的好处是:鸿蒙适配时,大多数问题集中在底层和中间层,业务代码的改动量能控制到最小。我见过有团队不做分层,结果鸿蒙侧某个库不兼容,到处找引用点,改了一周才编译通过,那种滋味真的不好受。
1.2 双端UI一致性的取舍
做商城App,UI一致性直接影响转化率。我的原则是:能用RN自带组件解决的绝不用原生View,能用JS实现的绝不用原生Module。这么做是有原因的——鸿蒙侧的RN兼容层对原生自定义组件的支持力度,和iOS/Android不完全一致,写得越“JS化”,迁移成本越低。
比如商品图片的占位逻辑、价格小数点的格式化、库存角标的角标定位,这些统统用纯JS实现。纯JS代码在鸿蒙运行时上基本是无感的,真正需要动脑子的,是那些必须依赖原生能力的部分,这个我在后面专门讲。
2. 商品展示与分类导航的数据流设计:从静态Mock到动态接口的平滑过渡
电商App最核心的骨架就是商品数据流。用户打开App先看到首页推荐——瀑布流。点进分类页是双栏列表——左侧大类右侧子类。搜商品进入结果页——筛选+排序。三条链路看着简单,但把它们抽象成一套可复用的状态模型,需要动点脑筋。
2.1 商品数据模型:不要被后端接口牵着走
很多新手容易犯一个错误:后端给什么字段,前端就建什么模型。结果业务一迭代,字段改名、嵌套层级变深,页面全部要跟着动。
我习惯在RN层建一套稳定的商品模型,核心字段包括:id、title、price、originalPrice、cover、gallery、stock、soldCount、shopId、shopName、tags。后端接口字段通过normalizer统一映射到这个模型上。这样做的好处是:页面层永远只依赖自己的模型,无论后端接口来自Java还是Node,无论字段叫goods_name还是name,到RN层都是一回事。
class Product { constructor(raw) { this.id = raw.goodsId ?? raw.id; this.title = raw.goodsName ?? raw.title ?? '未命名商品'; this.price = Number(raw.price ?? raw.salePrice ?? 0); this.cover = raw.mainImage ?? raw.cover ?? ''; this.gallery = Array.isArray(raw.gallery) ? raw.gallery : []; this.stock = Number(raw.stock ?? raw.inventory ?? 0); this.soldCount = Number(raw.soldCount ?? raw.sales ?? 0); this.shopId = raw.shopId ?? ''; this.shopName = raw.shopName ?? ''; this.tags = Array.isArray(raw.tags) ? raw.tags : []; } get isSoldOut() { return this.stock <= 0; } get discountRate() { return this.originalPrice > 0 ? Math.round((this.price / this.originalPrice) * 100) / 10 : null; } }2.2 分类导航的联动实现:左边大类、右边子类的同步滚动
分类页是商城App里交互最繁重的一个页面,左侧是竖向滚动的大类列表,右侧是随动加载的子类商品流。
我的实现思路是:左侧用ScrollView+ 绝对定位的选中态背景,右侧用SectionList承载每个大类下的商品。SectionList的onViewableItemsChanged回调是一个关键点,用它来判断当前屏幕顶部的类目索引,反向高亮左侧菜单。
const onViewableItemsChanged = useRef(({ viewableItems }) => { if (viewableItems.length > 0) { const sectionIndex = viewableItems[0].section.index ?? 0; if (sectionIndex !== activeCategoryIndex) { setActiveCategoryIndex(sectionIndex); } } }).current;这里有个坑:viewableItems在快速滑动时,拿到的第一个可视项不一定是离屏幕顶最近的项,所以高亮会“跳”。我的兜底方案是检测viewableItems数组,取viewableItems.sort((a, b) => a.viewPosition - b.viewPosition)[0]。如果viewPosition都拿不准,就用最长可见时间的那个item来定,总之不能让用户一滑就出现左右不同步的错觉。
2.3 推荐瀑布流与列表性能
首页推荐瀑布流,我用了FlatList的numColumns={2}模式,结合一个简单的等高差算法来模拟瀑布流效果。真正的原生WaterfallLayout在RN里没有原生实现,所以多数项目都在用等高双列,然后在节点布局上做一点错位,看着像瀑布流,实际上还是规整的网格。
性能上,图片缓存我推荐react-native-fast-image社区的方案。但这里要留个心眼:这个库在鸿蒙侧的适配情况不明确。我在做鸿蒙适配时,直接写了一个自己的ImageBox组件,内部判断平台,iOS/Android用FastImage,鸿蒙用内置的Image组件。
3. 鸿蒙跨端适配:踩过一遍坑后的改造实录
终于到重头戏了。鸿蒙适配不是简单地把RN代码拿过去编译,而是整个构建链路、原生依赖、组件映射都要重新过一遍。
3.1 构建链路的变化:从Gradle到hb build
Android端构建依赖Gradle,iOS端是Xcode,鸿蒙端则用的是自家的构建工具链。现在的RN鸿蒙生态,主要是通过社区维护的鸿蒙化RN运行时,把JS引擎、组件映射和API桥都跑在鸿蒙系统上。
实际执行过程是:先把RN工程里用到的JS Bundle构建出来,然后通过鸿蒙的开发工具生成鸿蒙工程,再把JS Bundle放入鸿蒙工程entry/src/main/resources/rawfile目录下,最后通过hb build命令打出hap包。
# 生成JS Bundle npx react-native bundle --platform harmony --dev false --entry-file index.js --bundle-output harmony/entry/src/main/resources/rawfile/index.jsbundle --assets-dest harmony/entry/src/main/resources/rawfile # 进入鸿蒙工程目录构建 cd harmony hb build # 或者用开发工具直接点击构建3.2 组件与API兼容性清单
不是RN的所有组件在鸿蒙上都能无障碍运行。这块建议提前做摸底,不要等到最后联调才发现。
| RN原生组件 | iOS/Android | 鸿蒙适配情况 | 替代方案 |
|---|---|---|---|
| ScrollView | 支持 | 一般,惯性/回弹略有差异 | 减少复杂嵌套,用FlatList替代 |
| FlatList/SectionList | 支持 | 支持,但高性能模式需改配置 | 关闭调试模式,减少renderItem复杂度 |
| Image | 支持 | 支持,但缓存策略不同 | 自封装图片缓存组件 |
| Modal | 支持 | 兼容性问题较多 | 自实现半屏弹层组件 |
| WebView | 支持 | 支持但不至于完全一致 | 协议层统一封装 |
| AsyncStorage | 支持 | 支持 | 必要时做降级方案 |
3.3 原生Module的鸿蒙兼容:一个真实的权限弹窗案例
商城App往往会申请相机权限,用来扫码、拍商品图。Android/iOS都有成熟的权限请求库,鸿蒙侧则需要走自己的权限API。
我写了一个统一的PermissionBridge,原生侧在Android/iOS各自实现,鸿蒙侧用ArkTS实现。JS侧不用关心平台差异,只知道调用它的方法会返回是否授权。
import { NativeModules } from 'react-native'; const { PermissionBridge } = NativeModules; export const requestCameraPermission = async () => { if (Platform.OS === 'harmony') { return PermissionBridge.requestCamera(); } return PermissionBridge.requestCamera(); };鸿蒙侧用ArkTS写对应实现,在组件生命周期里调用权限接口。这里有个坑:鸿蒙权限申请的返回时机比Android晚很多,如果立即弹Toast,用户会感觉延迟。稳妥做法是做一个权限状态监听,授权完成后回调。
3.4 鸿蒙适配时最容易翻车的三个地方
第一个翻车点是字号和字体渲染。鸿蒙默认字体不是系统字体,中文字体渲染的笔画和iOS/Android有细微差异。做商城界面,价格数字字体要单独指定数字字体文件,否则数字宽度参差不齐,看着很难受。
第二个翻车点是状态栏和沉浸式适配。Android的StatusBar高度可以通过StatusBar.currentHeight获取,鸿蒙上报的是0。我后来是用鸿蒙系统的像素密度和状态栏窗口参数计算出来的。这块不处理好,弹层、顶部导航会整体错位。
第三个翻车点是图片的缓存路径。RN的Image在iOS/Android会把缓存写到各自沙箱目录,鸿蒙的沙箱规则不同。如果你用getSize获取图片尺寸,或者做图片预加载,一定得单独处理。我把图片缓存组件内部改成了:先判断是否有内存缓存,再用磁盘缓存,最后才走网络,与平台无关。
4. 性能优化与包体瘦身:电商App的硬指标
商城App页面多、图片多、活动弹窗多,启动速度和流畅度稍微差一点,用户就会骂街。这几个性能指标我是重点盯着的:冷启动到首屏可交互时间、列表滑动帧率、包体大小。
4.1 启动阶段的分包加载策略
React Native是JS Bundle一次性加载,但商城这种大业务App把全部业务代码打进一个Bundle,启动时就要解析大量代码。这里我用了分包方案:把首屏必须的代码打入index.jsbundle,其他模块(订单中心、积分商城、直播)做动态加载。
动态加载的逻辑是基于react-native的require扩展实现的,按模块名去映射对应的Bundle文件。鸿蒙端同样支持分段加载JS Bundle,但需要在原生侧配好Bundle的目录规则。
4.2 FlatList的极致化渲染
商品列表页性能优化,常见的几板斧都要用上:removeClippedSubviews、maxToRenderPerBatch、windowSize。但真正决定流畅度的,是renderItem里的组件复杂度。
商城卡片不要写太复杂。我见过有同事把优惠券、满减、直播标、品牌标全部塞在一个卡片里,导致iOS上渲染都要卡一下。我这边统一压到:图片、标题、价格、销量、门店标签,最多加一个活动角标。复杂的营销组件放到商品详情页去,列表页只负责“让用户愿意点进去”。
const renderItem = useCallback(({ item }) => { return ( <ProductCard product={item} onPress={() => goDetail(item.id)} /> ); }, [goDetail]);renderItem必须用useCallback包起来,不包的话,每次父组件重渲染,列表都要重新生成render函数,滚动性能直接腰斩。
4.3 图片体积与CDN策略
电商App的图片带宽成本非常高。我在项目里做了一套图片尺寸裁剪规则:列表页用宽度为750px的压缩图,详情页用1080px的图,超过2MB的图一律走WebP转换,能省掉差不多60%的流量。
在RN层,封装一个ImageBox组件,接收uri和scene参数,自动拼接CDN裁剪参数:
export const ImageBox = ({ uri, scene = 'list' }) => { const width = scene === 'list' ? 750 : 1080; const q = scene === 'list' ? 70 : 85; const finalUri = `${uri}?imageView2/2/w/${width}/q/${q}/format/webp`; return <Image source={{ uri: finalUri }} ... />; };5. 商城功能的完整闭环:购物车、订单与商家的模块设计
光有商品列表肯定不能叫商城,还得有购物车、订单和商家体系。这部分逻辑比较重,核心在于状态的一致性和操作的时效性。
5.1 购物车的本地状态与服务端同步
购物车在用户端要做到“不畏网络延迟”:用户点了加购,UI立刻更新,但是数据要同时同步到服务端。如果同步失败要能重试,而不是告诉用户“网络错误”。
我的方案是:购物车本地状态放在Store里,所有变更操作先写本地,再通过一个幂等接口同步。addToCart、removeFromCart、updateSku都走这个通道,服务端返回的购物车数据作为对账使用。
class CartStore { items = []; async addToCart(product, sku, count) { const cartItem = { productId: product.id, sku, count, checked: true }; this.items.push(cartItem); await syncCartToServer(cartItem); } }这里要说一下为什么要用幂等接口。商城购物车场景下,用户很容易连续快速点击加购,如果同步接口不幂等,就会出现重复加购的诡异Bug。幂等key可以用userId + productId + skuId组合生成,这样重复提交也只是更新数量而不是新增一行。
5.2 订单状态机的设计
订单的状态是电商项目里最容易写乱的地方。待付款、待发货、待收货、已完成、售后处理中,各种状态还伴随着超时自动取消、退款中、退款完成等分支。
我在项目里把订单状态做成一张状态流转表,用不可变模型来管理。每次状态变更,都需要校验是否符合流转规则:
| 当前状态 | 允许的下一状态 | 触发条件 |
|---|---|---|
| 待付款 | 已取消 / 待发货 | 用户取消 / 支付回调成功 |
| 待发货 | 待收货 / 退款中 | 商家发货 / 用户申请退款 |
| 待收货 | 已完成 / 售后中 | 确认收货 / 发起售后 |
| 已完成 | 售后中 | 在售后期内申请售后 |
这个设计帮你省了很多事。你不用担心用户在待收货状态下突然点“付款”,也不用担心在待付款状态点“确认收货”,因为前端的按钮渲染完全是依赖状态机来的。
5.3 商家模块的独立分包
商家页面(店铺首页、店铺全部商品、店铺优惠券)是一个独立的信息架构。我在做RN路由时,把这几个页面单独组织在一个shop路由栈里,和主Tab的商品列表隔离。
好处是:商家页面的商品数据结构和主流程不同,可以单独做一套网络层和状态管理配置,不用互相污染。比如商家页面的商品列表有单独的翻页游标、单独的加载状态、单独的缓存策略。同时,商家页面的代码也能作为独立分包打进Bundle,减少主包的体积。
6. 联调与发布的完整流程:RN工程与鸿蒙工程的协同
项目做到最后,你会发现RN工程和鸿蒙工程已经不是一个单纯的“打包关系”了。它们需要协作、需要明确各自的边界和构建节奏。这块一定要提前定好规范,否则团队合作时会频繁出现“你打的包缺了我刚改的样式”这类冲突。
6.1 RN代码迭代流程
正常RN迭代:改JS代码,重编Bundle,再走原生打包。在鸿蒙侧,我的做法是把JS Bundle放到鸿蒙工程的rawfile目录,之后只重编Bundle,然后在开发工具里刷新即可。
# 一键完成Bundle构建并同步到鸿蒙工程 npm run build:harmony:debug为了减少重复劳动,我写了一个scripts/buildHarmony.js的脚本,它封装了Bundle生成、文件复制、资源同步三步操作。每次主线迭代,跑一次脚本,再打开开发工具构建HAP包。
6.2 原生能力拆分的边界
RN项目需要一个“原生能力清单”,你每加一个原生Module,都要明确它的边界。比如:相机扫码归CameraModule管,分享功能归ShareModule管,推送归PushModule管。
在鸿蒙侧,这些Module要用ArkTS写对应的实现,并注册到原生模块表里。这里的一个常见坑是:iOS/Android的原生Module返回的数据是Promise,但鸿蒙侧的桥接方式在某些版本上对Promise支持不完整,需要你在调用层额外包一层防空的逻辑。
export const safelyInvokeNativeModule = (module, method, params) => { return new Promise((resolve) => { try { const result = module[method](params); if (result && result.then) { result.then(resolve).catch(() => resolve(null)); } else { resolve(result ?? null); } } catch { resolve(null); } }); };6.3 灰度发布与热更新策略
商城App不能容忍严重的线上Bug,所以发布策略上我做了一套“双通道”:主站走原生市场包发布,紧急修复走热更新通道。RN的热更新可以撑起小版本的快速迭代,但是大的架构改动还是要走市场包,避免热更新堆积产生不可逆的耦合。
热更新通道的选择上,我对比过自建和第三方方案。自建的话,要负责CDN、版本兼容、回滚逻辑,开发成本高。次选社区方案,配置简单,但要对鸿蒙构建做一份独立映射。我在这个项目里先用了社区方案,线上跑了几个月,稳定性还行。不过建议你在选型前先确认一下社区方案是否支持鸿蒙运行时,这个很重要因为它直接决定了后续的更新链路能不能走通。
7. 常见踩坑与问题排查:这份列表建议收藏
最后一部分,我整理了一份商城RN项目里高频问题的排查清单。很多问题不是一锤子能解决的,但有了清单之后,你在现场排查的速度至少能快一半。
7.1 JS层报错排查优先级
RN项目里,JS层报错和原生层报错要分开看。JS层报错优先看“红色报错框”的堆栈信息,定位到具体页面和组件;如果堆栈指到原生模块,再去翻原生日志。
高频的JS层问题包括:渲染层数据类型不对(商品价格为字符串)、组件key重复导致列表渲染异常、导航路由未注销导致内存泄漏。这些问题的共性是——在开发环境常常复现不了,只有真机在弱网或低端机场景才出现。我的经验是给所有的列表项加稳定的唯一key,诸如商品id、店铺id、sku id,绝不能用数组下标当key,这个坑在商城列表里尤为致命。
7.2 鸿蒙端无法加载Bundle的排查链路
鸿蒙端打开App白屏,先别急着怀疑代码。按照这三步走:
- 检查JS Bundle是否正常生成,用文本编辑器打开Bundle文件,看头部是不是“varBUNDLE_START_TIME”这类RN标准头部。
- 检查Bundle是否被正确拷贝到鸿蒙工程的rawfile目录,或者远程Bundle的URL是否配置正确。
- 检查容器组件是否成功加载,确认容器有没有吃到Bundle。
如果你确认这三步都没问题,但还是白屏,大概率是容器版本和Bundle的RN版本不匹配。鸿蒙适配时RN版本不能随意升级,一个版本差,整个桥接层都可能会失效。我遇到过容器版本和Bundle版本不一致导致白屏的问题,最后只能重新构建整个鸿蒙工程才解决,这个坑真的很花时间。
7.3 内存泄漏与页面销毁治理
商城的页面栈非常深,用户从首页点进商品详情,再从详情进店铺,再进优惠券中心,再回来,流程很长。如果不做页面销毁治理,内存占用会指数级上升。
我在导航监听里统一做页面级埋点:页面失焦时释放大图资源、取消未完成的网络请求,页面完全卸载时清空Store里的临时状态。列表页滚动的锚点位置不保留,避免额外开销。用InteractionManager把非关键任务延后到空闲时执行,避免页面转场动画卡顿。
useFocusEffect( useCallback(() => { return () => { // 页面失焦时取消请求 cancelPendingRequests(); }; }, []) );这套治理做完之后,用低端安卓机连续反复进出一百次商品详情页,内存占用都能保持稳定。这个数字是实测的,对于商城App来说,算是一个还不错的及格线。
8. 一些体会
写到这里,React Native商城从架构到实现,再到鸿蒙适配的关键路径,基本都覆盖了。回头去看这个项目,我最深的感受是:跨端开发真正的难度不在写页面,而在“边界管理”——你和原生能力的边界、业务和基础库的边界、各端构建链路的边界,每一处边界都要提前想清楚,而不是等踩坑了再补。
如果你正准备启动一个RN商城项目,我可以给你几条比较实在的建议:先把依赖层面的兼容性矩阵做出来,再写业务代码;所有原生能力统一走桥接层,不要散落在业务组件里;把一个端(比如Android)跑通跑稳了,再动鸿蒙适配。这样虽然前期看起来慢,但后期实际上反而是最快的。
鸿蒙适配这一块,整个社区还在快速演进,版本更新也频繁。我的经验是:不要追求所有API一套代码通吃,而是做好降级方案,保证核心购物流程可用,其他次要功能渐进适配。这样即使在鸿蒙某个版本上发现兼容问题,也不会影响用户完成一次完整的购物操作。