
最早看到Madeira这个名字我第一反应是葡萄牙马德拉群岛脑子里全是悬崖徒步和葡萄酒的画面。结果点进GitHub才发现这其实是个基于React Native的跨平台开发框架主打的是“一套TypeScript代码同时输出Android、iOS、Web三端”。我最近正好在给团队做跨端技术选型内部有个会议室预约的小工具原来Web端一套、App端一套每次改个字段要同步两处漏一次线上就对不齐。看到这个方案索性直接拿它做了个真实项目前后折腾了一周多。这篇文章会把整个过程的思路、代码、踩坑记录都摊开讲适合正在做技术选型的同学尤其是Web技术栈为主、又想低成本出App的团队。1. 先弄清楚Madeira到底解决什么问题1.1 跨端方案的现状没有银弹移动跨平台方案这几年实在太多了。Flutter走的是自绘引擎路线UI一致性好但Web端的支持一直不算它的主推方向uni-app和Taro在Web、小程序之间切换很舒服但到了原生App这一层性能和体验总感觉差一口气React Native本身也有官方Web支持如果你愿意花时间手动整合react-native-web确实能跑但总有一种“Web是后妈生的”的感觉。我团队的真实痛点是这样的核心业务逻辑其实只有一套但UI和交互在Web、Android、iOS三端分别维护。有一个内部工具Web端用React写App端用原生写改一个接口字段三处代码都要动。更要命的是交互细节经常对不齐Web端按钮叫“确认预约”App端叫“立即预约”产品经理每次都要截图对比。这种割裂感经历过的人应该都懂。所以当我看到Madeira的时候第一反应不是“又多了一个框架”而是它在解决一个非常具体的问题把Web端从“备胎”变成和Android、iOS地位完全相同的“一等公民”。1.2 Madeira的思路基于React Native但不把Web当退路Madeira这个名字虽然容易让人想到海岛但它的设计逻辑其实很务实不重新发明语言而是建立在React Native的成熟生态上把react-native-web做了底层整合在框架层面统一了组件、路由、样式和事件处理。你写的组件在三个平台上是同一套代码而不是各自翻译一遍。这对于一个以React技术栈为主的团队来说学习成本低到什么程度呢我们团队一个只写过Web React的实习生上来就能改页面几乎不用看文档。下面这个表是我做选型时候整理的可以更直观地看到它的定位方案语言Web支持小程序支持原生体验学习成本会ReactFlutterDart可编译但非主力无官方支持高高需要学Dartuni-appVue好好中低会Vue即可TaroReact/Vue好好中低React Native WebTypeScript需要自行整合无高中配置繁琐MadeiraTypeScript框架内置无高低会React即可我当时看到这个对比心里大概就有数了如果你团队本来就是React系Madeira的学习曲线几乎是平的如果你团队是Vue系那还是老实考虑uni-app或者Taro更现实。2. 搭环境比想象中省心但有几个坑2.1 基础环境准备搭环境这事儿跨端框架通常是一道坎但Madeira因为是站在React Native肩膀上的所以基本上照搬RN的那一套配置就行。先说结论你至少需要这些Node.js 18及以上建议用nvm管理版本避免项目间切换的麻烦JDK 17Android开发需要Android Studio用来跑Android模拟器和SDK管理如果你是MacXcode准备好如果你只有Windows那iOS的包就别想了老老实实走Android和Web我用的版本组合是Node 18.18、JDK 17、Android SDK 34、Xcode 15整体跑下来没有特别离谱的问题。这里有第一个坑Android的SDK路径。Windows上经常出现SDK找不到的情况如果命令行报错说找不到SDK优先检查两样东西一样是环境变量ANDROID_HOME另一样是项目根目录下的local.properties文件。# 先确认基础环境 node -v java -version # Android SDK路径示例Windows # 在local.properties里写入你的SDK路径 sdk.dirC\:\\Users\\你的用户名\\AppData\\Local\\Android\\Sdk2.2 创建项目与目录结构环境装好后创建项目就简单了。我用的是官方CLInpx madeira/cli create meeting-room-app cd meeting-room-app npm install装完依赖之后你会得到一个三端统一的工程结构。我第一次看到这个目录的时候确实有点意外因为它比我预想的要简洁meeting-room-app/ ├── src/ │ ├── components/ # 通用组件 │ ├── screens/ # 页面 │ ├── navigation/ # 路由配置 │ ├── services/ # 接口请求 │ └── theme/ # 主题与样式变量 ├── platforms/ │ ├── android/ # Android原生工程 │ ├── ios/ # iOS原生工程 │ └── web/ # Web构建配置 └── package.jsonsrc目录才是你每天写代码的地方platforms目录是初始化时生成的按道理来说你不需要手动去改当然真到了要加原生模块的时候还是得进去。这种“业务代码全在src原生工程收敛在platforms”的划分方式跟Flutter、uni-app的做法是一个思路好处是防止业务代码和原生配置混在一起。启动三端也直接npm run web npm run android npm run ios2.3 第一次跑通三端的体验我按顺序跑了一遍说下真实体验。Web端是起得最快的几秒钟就能在浏览器里看到页面热更新也很跟手改完代码保存就刷新。Android端第一次启动会比较煎熬因为Gradle要下载一大堆依赖还要编译原生代码头两次跑个几分钟都正常你得有点耐心等它把缓存建起来后面就快了。iOS端第一次跑还需要pod install这个环节经常卡在CocoaPods的仓库源上网络不好能卡到你怀疑人生。这里分享一个经验第一次跑Android的时候最好先用cd android ./gradlew --version预热一下Gradle让它先把依赖下载好这样后面通过CLI启动会顺很多。iOS如果pod install卡住试试把Podfile里的source源换成国内可访问的CDN镜像别死磕默认源。3. 业务代码写起来跟React几乎没有区别3.1 组件与Hooks熟悉的味道环境跑通之后真正写代码的时候我最大的感受就是这就是React。你看一个最简单的卡片组件import { View, Text, Pressable, StyleSheet } from madeira; export default function RoomCard({ room, onPress }) { return ( Pressable style{styles.card} onPress{onPress} Text style{styles.name}{room.name}/Text Text style{styles.capacity}可容纳 {room.capacity} 人/Text Text style{styles.status} {room.available ? 空闲 : 占用中} /Text /Pressable ); } const styles StyleSheet.create({ card: { backgroundColor: #ffffff, borderRadius: 12, padding: 16, marginBottom: 12, }, name: { fontSize: 18, fontWeight: 600, }, capacity: { fontSize: 14, color: #666666, marginTop: 4, }, status: { fontSize: 14, color: room room.available ? #2e7d32 : #c62828, }, });注意我用的是import { View, Text, Pressable, StyleSheet } from madeira而不是从react-native导入这是这个框架比较关键的一个差异点它在框架层对RN组件做了一层封装和转发保证了Web端的渲染一致性。但你写的组件语法和React Native几乎完全一样。3.2 导航与页面结构不用再纠结react-navigation的配置做过RN项目的人应该都体会过react-navigation的配置复杂度Stack、Tab、Drawer各种嵌套还要管header、deep link。Madeira内置了一套Router把Stack和Tab都封装好了用法很直白import { Router, Route, Tabs } from madeira; export default function App() { return ( Router Tabs Route path/ component{HomeScreen} / Route path/rooms component{RoomsScreen} / Route path/bookings component{BookingsScreen} / Route path/mine component{MineScreen} / /Tabs /Router ); }这个内置Router帮了大忙因为它不是简单地把react-navigation包一层而是把Web端的URL、浏览器的前进后退、原生的页面栈都给统一处理了。在Web端你能直接访问/rooms这个路径刷新页面页面不会白屏在App端原生返回手势和页面栈是正常的。这一点用户体验很重要你不需要额外处理路由的平台差异。3.3 样式系统flexbox打天下响应式要自己把握样式方面Madeira沿用了React Native的StyleSheet和flexbox布局。这跟CSS的flex布局基本一致但有几个差别值得注意默认没有display: block这种概念一切视图都是flex容器块级元素默认不是纵向排列的你得显式设置flexDirection: column来让子元素纵向堆叠没有position: fixed在RN里用的是position: absolute模拟固定定位zIndex可用但不总是生效尤其是在Android端需要注意层级响应式布局这块我建议直接用useWindowDimensions它跟浏览器的resize事件是对应的import { useWindowDimensions } from madeira; function useResponsiveWidth() { const { width } useWindowDimensions(); // 小于480px视作移动端否则是桌面端 return width 480 ? 100% : 720; }Web端还有一个比较实用的小技巧Web页面宽度其实不应该无限制拉伸尤其是你做了一个App风格的页面在桌面浏览器上全屏会显得很空。我习惯在根容器上加上maxWidth和alignSelf: center这样移动端是满宽桌面端有一个居中的舒适阅读宽度。3.4 数据请求与状态管理直接上React的常规打法数据请求这块没有任何黑魔法你在Web React里怎么写的这里就怎么写。我用的是fetch封装在src/services/api.ts里const BASE_URL https://api.example.com; export async function fetchRooms() { const res await fetch(${BASE_URL}/rooms); if (!res.ok) { throw new Error(HTTP ${res.status}); } return res.json(); } export async function createBooking(payload: { roomId: string; startTime: number; endTime: number; }) { const res await fetch(${BASE_URL}/bookings, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify(payload), }); return res.json(); }状态管理也一样项目小的时候用React自带的useState和useContext就够了复杂了再上zustand或者Redux Toolkit。我自己偏好zustand因为样板代码少而且在三端的行为都很稳定。3.5 一个小案例会议室预约列表与提交为了让整个流程更具体我拿一个真实的预约页面来说。需求很简单进入页面拉取会议室列表点“预约”弹窗选择时间段提交后刷新列表。完整页面代码大概是这样的import { useState, useEffect } from react; import { View, Text, FlatList, Pressable, Modal, ActivityIndicator, } from madeira; import { fetchRooms, createBooking } from ../services/api; import RoomCard from ../components/RoomCard; export default function RoomsScreen() { const [rooms, setRooms] useState([]); const [loading, setLoading] useState(true); const [selectedRoom, setSelectedRoom] useState(null); const [startTime, setStartTime] useState(); async function loadRooms() { const data await fetchRooms(); setRooms(data); setLoading(false); } useEffect(() { loadRooms(); }, []); async function handleBooking() { await createBooking({ roomId: selectedRoom.id, startTime: new Date(startTime).getTime(), endTime: new Date(startTime).getTime() 60 * 60 * 1000, }); setSelectedRoom(null); loadRooms(); } return ( View style{styles.container} {loading ? ( ActivityIndicator / ) : ( FlatList data{rooms} keyExtractor{(item) item.id} renderItem{({ item }) ( RoomCard room{item} onPress{() setSelectedRoom(item)} / )} / )} Modal visible{selectedRoom ! null} animationTypeslide View style{styles.modal} Text{selectedRoom?.name} 预约/Text Pressable onPress{handleBooking} style{styles.button} Text确认/Text /Pressable /View /Modal /View ); }这段代码在三个平台上的表现基本一致没有为Web单独适配也没有为原生单独改逻辑。单单这一点就已经解决了我之前“两套代码对齐”的核心痛苦。4. 从Demo到能用的App其实都在填坑4.1 Web端兼容性路由刷新、滚动与点击穿透写到这把最真实的部分说出来。从Demo到真正能上线的产品中间全是坑这跟任何框架都一样。先说Web端最典型的几个问题。第一个是路由刷新404。如果你把Web端部署到Nginx直接访问https://your-domain.com/rooms刷新一下大概率得到一个404。原因很好理解Nginx默认找不到物理路径/rooms对应的文件就返回了404。解决方法是配置Nginx的try_files让所有路由都回退到index.htmllocation / { try_files $uri $uri/ /index.html; }第二个是滚动容器的差异。在移动端页面滚动是自带惯性、自带滚动条的但在Web端如果你用普通的View包一个长列表默认根本不可滚动。你必须使用ScrollView或者FlatList让滚动行为在三端保持一致。因为内置了RN的滚动组件这在Web端表现为一个外部的滚动容器体验还不错。第三个是点击事件。Web端习惯用onClickRN里是onPressMadeira统一用了onPress。但这带来一个隐患如果你Web端习惯在div上绑事件很容易踩到“点击无反应”的坑。解决办法就是别用div通通用框架提供的Pressable或者TouchableOpacity。4.2 原生模块与第三方库绕不开的时候怎么办框架再强大也不可能覆盖所有原生能力。我们这个会议室工具需要一个日历控件在三端表现一致的那种找了一圈还是决定用框架提供的原生日历模块因为它内部已经封装了Android和iOS的原生实现。但如果你需要的库没有三端实现那就要分情况讨论了。纯JS库比如dayjs、lodash、axios直接用没有任何问题依赖原生视图的库比如地图、相机、视频播放你在RN生态里找到的库通常只支持Android和iOSWeb端是没有对应实现的Web专属库比如某些DOM操作库、Canvas库在原生端也没法用我的建议是在业务层做一层接口隔离。不要直接在页面里调用一个只在Web端存在的API而是封装一个service内部判断平台分别调用不同实现。这样虽然写起来多了一道工序但避免了平台代码散落在各个页面里。4.3 性能优化真机上最值得做的两件事Demo跑着流畅不代表真机同样流畅。这里面最容易翻车的是列表。第一列表必须用FlatList。如果你图省事用map渲染一个长列表一开始数据少看着还行一旦数据量上了百条滚动就开始掉帧Android上尤其明显。用FlatList之后还要记得加getItemLayout它能让列表直接计算滚动位置不用动态测量每一项的高度FlatList data{rooms} keyExtractor{(item) item.id} renderItem{renderRoom} getItemLayout{(_, index) ({ length: 100, offset: 100 * index, index, })} /第二图片资源一定要处理。在原生端图片加载不是一个img标签就完事的它会吃内存。如果你的列表项里有图片建议固定尺寸并且用适当的压缩格式。Web端无所谓但App端一个大图就能把内存顶上去尤其Android的低端机直接闪退都是有可能的。4.4 打包发布三端产物的差异构建命令很简单但产物和发布流程各有各的门道。npm run android -- --moderelease npm run ios -- --moderelease npm run web -- --modeproductionAndroid端构建出来是AAB或者APKAAB是上传Google Play商店的格式APK可以直接装到手机上。如果你只做内部工具不上架商店可以直接产出一个release的APK分发给同事。iOS端必须先有开发者账号和证书然后用Xcode打开platforms/ios里的工程Archive之后再导出IPA。这两步是我个人觉得最繁琐的地方尤其是证书配置几乎每年都要折腾一遍最好提前在团队里指定一个人专门负责。Web端就简单得多构建完就是一个静态资源目录扔到任意Nginx或者对象存储上就行几乎可以秒级部署。但Web端有一个坑跨域。如果你的API和前端不在同一个域名浏览器会直接拦截请求这时候需要在后端配置CORS允许前端的域名访问。在开发和测试阶段这是在Web端最容易踩到的接口问题App端反而没有这个限制因为原生请求不受CORS约束。5. 问题速查按现象查方案一周多踩下来我整理了一份问题速查表按“现象”去查“方案”希望能帮后面的人省点时间现象可能原因解决思路Android运行报SDK not foundSDK路径未配置检查ANDROID_HOME环境变量和local.propertiesiOSpod install卡住CocoaPods源下载慢切换CDN镜像源或提前手动下载依赖Web端刷新页面404路由未回退Nginx配置try_files $uri $uri/ /index.htmlWeb端图片显示不出来图片路径用了绝对URL改用require()或import方式引用本地资源真机访问接口失败代码里写了localhost改为局域网IP或配置开发域名白名单热更新不生效Metro缓存脏了执行npx react-native start --reset-cache字体三端不一致系统默认字体不同统一使用自定义字体文件避免依赖系统字体长列表滑动卡顿用map直接渲染了列表改成FlatList并配置getItemLayoutAndroid打包后白屏混淆规则漏配检查Proguard规则保留RN关键类有几个配置项特别容易被忽略我单独拿出来说。一个是local.properties这个文件在platforms/android目录下如果多人协作或者新同事拉代码这个文件可能会被覆盖导致SDK路径错乱。建议把它加入.gitignore每个人自己维护本机路径。另一个是Web端的publicPath配置。如果你的Web应用不是部署在域名根目录而是部署在https://your-domain.com/app/这样的子路径下那么依赖资源的路径会全部错乱页面打开全是白的。这需要在Web构建配置里设置publicPath为相对路径或者子路径具体看你用的部署方式。还有一个是关于API的跨域。App端没有CORS问题但Web端有而且这个差异往往是在联调阶段才暴露出来。我的建议是开发阶段就统一用一个域名后端把CORS配置好别等上线了再临时补。6. 项目复盘这套方案适合什么样的团队6.1 写一次、跑三端的真实收益做完整套工具之后我对“成本”的感知比一开始理性了很多。以往同时维护Web和App两套代码一次功能迭代大概要3天其中至少有半天浪费在“两边改一样的逻辑”和“两边对交互细节”上。用Madeira之后开发时间从3天压到了1天半差不多节省了一半。维护成本的变化更明显。之前的痛点不是“写代码”而是“同步”。Web端改了一个样式App端忘了同步App端修了个交互BugWeb端又在旧逻辑上继续开发。现在代码只有一套改哪里都是全局生效产品经理再也不用对着截图找差异了。6.2 边界与风险不是所有项目都适合如果你的项目核心是做高性能游戏、AR/VR、视频剪辑这类重度依赖原生能力的应用那这套方案并不合适。跨端框架的逻辑是帮你把80%的通用业务做好剩下20%的原生能力仍然需要原生工程师介入。还有一个要正视的风险框架本身的生命周期。Madeira相对年轻社区的第三方库生态肯定不如成熟的React Native遇到冷门需求时可能找不到现成的组件要么自己封装原生模块要么就得绕路。如果团队里没有原生开发背景的人帮助处理这确实是一个实际的问题。6.3 给正在选型的人三条建议如果你想试我给三条实在的建议。第一先做一个小而完整的工具型App验证不要一上来就迁移核心业务。内部工具、管理后台、预约系统这类逻辑清晰的场景最适合起步能快速验证框架的边界又不至于伤筋动骨。第二从Web项目渐进迁移。如果你现在有React Web项目优先把业务逻辑抽成独立的service层UI层用Madeira重建。这样即使以后换方案逻辑层也不会浪费。第三团队里至少要有一个能在原生层兜底的人。虽然框架帮你屏蔽了大部分原生差异但推送、分享、NFC这类能力大概率还是需要接触原生代码。没有原生兜底遇到问题就只能干瞪眼。做完这个项目我个人最大的体会是跨端方案解决的不是“代码量”问题而是“多端一致性”问题。你省下的不只是那几千行重复代码更是那种“Web改完了、App忘了同步”的焦虑感。如果你也是以Web技术栈为主的团队想用最小的成本把业务覆盖到Android和iOS这个方向值得一试。最后再分享一个调试小技巧Web端直接用浏览器DevTools就能调试大部分逻辑但原生端的网络请求和存储建议还是用react-native-debugger这一类工具比在Metro终端里看log清晰得多。