React Native for OpenHarmony 实战:三方库 react-native-google-places-autocomplete 的鸿蒙化适配指南
本文记录把react-native-google-places-autocomplete(Google 地点自动补全输入框)适配到 HarmonyOS 的完整过程。
它做的事:一个输入框,边打字边向 Google Places API 请求候选地点,下拉展示列表。
结论:组件本体在鸿蒙上可以渲染;但"真实请求的返回结果"本轮无法验证 —— 因为maps.googleapis.com在本机请求超时、不可达。
不过这个库有一个非常好的特点:它把qs与lodash.debounce都自己实现了一遍(src/queryString.js/src/debounce.js)
⇒这两个纯函数模块给了我 13 条零网络、零平台依赖的判据,全部通过✓
另外本文要专门记两件事:一个"看起来致命、其实是误报"的预检结论,以及我连续第二轮把契约断言写错的复盘。
★★★★★ 一、先纠正一个误报:ListView命中 14 处,但全是命名巧合
预检时我用ListView作关键词扫,命中14 处—— 按经验这几乎等于"用了 RN 0.61 已移除的组件 ⇒ import 即崩"。
但读进去后发现全是误报:
// GooglePlacesAutocomplete.jslistViewDisplayed:listViewDisplayedProp='auto',// ← 一个 prop 名listViewDisplayedProp==='auto'||listViewDisplayedProp===undefined,consthideListView=useCallback(…)// ← 一个函数名hideListView();hideListView(true);// ← 调用那个函数style={mergedStyles.listView}// ← 一个样式键名非注释行里 12 处"命中",没有一处是真实的ListView使用:
- ❌ 没有
import {ListView} from 'react-native' - ❌ 没有
<ListView …> - ❌ 没有
new ListView.DataSource(…)
⇒⇒ 所以这个库不会因为ListView崩✓✓
⇒ 教训(值得写进预检规则):
用
ListView作关键词粗扫,会被listViewDisplayed/hideListView/styles.listView这类"名字里含 ListView"的标识符全部骗到。
正确的扫描目标应该是"真实使用形态":ListView\.DataSource、import\s*\{[^}]*\bListView\b、<ListView、new ListView
(反例形态:如果真有import {ListView} from 'react-native'+new ListView.DataSource(...)写在模块顶层 ⇒ 那才是真的 import 即崩,只能否决。)
★★★★★ 二、13 条纯函数判据,全部通过(零网络)
这个库没有dependencies—— 因为它把两个常用的第三方能力自己实现了:
| 文件 | 替代了什么 |
|---|---|
src/queryString.js(1297 B) | qs.stringify的一个子集 |
src/debounce.js(683 B) | lodash.debounce |
⇒这两个是纯函数 ⇒ 可以脱离网络与平台直接断言✓✓
queryString.stringify—— 9 条全 PASS
PASS|★stringify({a:1})="a=1"PASS|★ 空格编码为%20→ q=x%20yPASS|★★RFC3986:"!*"也要转义 → k=%21%2APASS|★undefined被省略 → a=1PASS|★null序列化为"key="→ k=PASS|★★ 数组用 indices 格式 → a%5B0%5D=x&a%5B1%5D=yPASS|★★ 嵌套对象用 bracket 格式 → a%5Bb%5D=xPASS|★ 空入参返回空串 →''PASS|★★ 组合场景:key 里的&被编码 → key=K%261&input=a%20b&components%5Bcountry%5D=cn其中两条特别值得看:
- RFC3986 编码:
encodeURIComponent不转义!'()*,而qs会转义 ⇒
这个实现里专门补了:
实测constencode=(value)=>encodeURIComponent(String(value)).replace(/[!'()*]/g,c=>'%'+c.charCodeAt(0).toString(16).toUpperCase());{k: '!*'}⇒k=%21%2A✓ ⇒说明它连这个细节都对齐了qs✓ - 组合场景:
{key: 'K&1', input: 'a b', components: {country: 'cn'}}⇒key=K%261&input=a%20b&components%5Bcountry%5D=cn✓
⇒&被正确编码成%26,否则会把一个参数拆成两个 ——这是真实请求里最容易出错的地方✓
debounce—— 3 条全 PASS
PASS | ★ debounce 同步阶段尚未执行 → 0 次 PASS | ★★ debounce:5 次连续调用只执行 1 次 → 1 次 PASS | ★★ debounce.cancel() 有效(取消后不执行) → 0 次⇒ 该实现是trailing-edge(最后一次调用后wait毫秒才执行)+ 暴露.cancel()✓
⇒wait = 0也会延到下一个 tick(源码注释里说明这是为了对齐 lodash 的行为)✓
为什么这两个判据特别有价值:
它们完全不需要网络、不需要设备输入、不受平台差异影响⇒结论在任何基线上都成立✓✓
对一个"强依赖网络"的库来说,把可验证的部分切出来单独验证,是这个库唯一诚实的验证路径。
★★★★ 三、组件能渲染:placeholder文本可读
<GooglePlacesAutocomplete placeholder="GPA 搜索占位" minLength={2} requestUrl={{url: 'http://127.0.0.1:9/', useOnPlatform: 'all'}} query={{key: 'TEST-KEY', language: 'zh', components: 'country:cn'}} />实测(布局 dump 里找到):
✓ 找到 placeholder「GPA 搜索占位」 w=1188 h=42 x=66 y=962⇒组件的输入框正常渲染、placeholder正确显示✓
⇒ 而且requestUrl这个 prop可以把请求指向自定义 URL(源码getRequestUrl():useOnPlatform === 'all'时直接用requestUrl.url)⇒这是给"网络不可用"环境留的出口✓
★★★ 四、网络不可达 —— 我如何处理这个验证边界
预检时就确认了:
maps.googleapis.com 不可达:操作超时而库里的 URL 是硬编码的:
constGOOGLE_API_BASE='https://maps.googleapis.com/maps/api';⇒⇒ 所以在当前环境下,"输入 → 请求 → 拿到候选列表"这条主链路无法验证✗
我的处理:
- 把能验证的都验证透(纯函数 13 条 + 组件渲染)✓
- 把
requestUrl指到http://127.0.0.1:9/(一个必然ECONNREFUSED的地址)⇒
设计意图是:只要请求真的发出去,onFail就该被回调⇒用它证明"请求链路是通的、错误处理也正常" - 实测结果:
onFail回调次数 = 0⚠️
⇒ 原因很清楚:这个库只在"用户输入且长度 ≥minLength"之后才发请求,
而本轮没有在输入框里打字(我全程用代码驱动,没做输入注入)⇒请求根本没发✗
⇒⇒ 所以"XHR 失败路径"这一条本轮未验证,如实记录 ✓
⚠️ 这也说明了一个取舍:用
requestUrl造"必然失败"是个好思路,但它仍然需要"触发输入"这一步。
而RNOH 上的输入注入(uitest uiInput inputText)本身不可靠(实测过),
所以我这次没有把它算进必做项 —— 宁可诚实地少一条判据,也不写"看起来验证了"的结论 ✓
★★★★★ 五、我连续第二轮把契约断言写错(必须记下来)
契约部分3 条 FAIL,全是我的问题:
NOTE | 具名导出 GooglePlacesAutocomplete 的 typeof = object FAIL | ★ 具名导出是函数(forwardRef 组件) | 期望=function | 实际=object NOTE | $$typeof = Symbol(react.forward_ref) FAIL | ★★ 是 forwardRef($$typeof = react.forward_ref) | 期望=Symbol(react.forward_ref) | 实际=undefined FAIL | ★★ 对象里有 GooglePlacesAutocomplete 键 | 期望=function | 实际=object两个根因:
根因一:forwardRef返回的是对象,不是函数
源码:
exportconstGooglePlacesAutocomplete=forwardRef((props,ref)=>{…});exportdefault{GooglePlacesAutocomplete};⇒forwardRef(render)的返回值是一个带$$typeof: Symbol(react.forward_ref)的对象,typeof就是"object"✓
⇒所以"具名导出是 function"这条断言本身就是错的✗
(同一个坑之前也踩过 —— 那时也误判成function)
正确的断言写法:
constisForwardRef=(v)=>v!=null&&typeofv==='object'&&String(v.$$typeof)==='Symbol(react.forward_ref)';根因二:我的S()工具函数序列化不了 Symbol
constS=(v)=>{try{returnJSON.stringify(v);}catch{returnString(v);}};// JSON.stringify(Symbol('x')) ⇒ undefined(不抛错,直接返回 undefined)⇒ 于是$$typeof断言拿到的是'undefined',看起来像"没有 $$typeof",其实是我工具的问题 ✗
修法:序列化前先判 Symbol:
if(typeofv==='symbol')returnString(v);复盘:这是连续第二轮在契约断言上写错
此前某一轮错在凭"常见默认值"猜(duration/hideStatusBar/ColorTheme类型),
这一轮错在凭"组件就该是函数"猜(forwardRef的实际形态)+判据工具缺陷。
⇒共同点是"预期值不是从源码/实测来的"✓
⇒系统性的改法应该是:写断言前先把源码里的"形态事实"抄下来(导出语句、typeof、$$typeof),
而不是先写断言、再等它 FAIL 来纠正 ✓
★★ 六、其余预检结论
| 预检项 | 结果 |
|---|---|
dependencies | ✅空(qs/lodash.debounce都自己实现了) |
peerDependencies | ✅ 只有react >= 16.8/react-native >= 0.59⇒无原生 peer |
ListView | ⚠️14 处命中,但全是命名巧合(见第一节)⇒真实使用为 0✓ |
老写法九项(createClass/React.PropTypes/ViewPropTypes/Text.propTypes/ 字符串 ref /this.refs/mixins/componentWillReceiveProps/componentWillMount) | ✅✅全部为空 |
isRequired | ✅空 |
Platform.OS | 4 处,都是'web'/'android'判断 ⇒鸿蒙走非 web、非 android 分支✓ |
measure()/onLayout/useNativeDriver/NativeModules | ✅全空 |
| 网络请求 | 3 处new XMLHttpRequest()(搜索、第二路搜索、详情)⇒ 真实请求 |
scripts.prepare | ⚠️husky install⇒安装必须--ignore-scripts |
| 交付包形态 | ✅GooglePlacesAutocomplete.js(32344 B)+src/6 个模块 +index.d.ts(10898 B) |
| 静态资源 | ✅ 自带images/powered_by_google_on_white*.png(3 种倍图) |
七、接入与预检(要点)
# ★ 必须 --ignore-scripts:它的 scripts.prepare 是 "husky install"npminstall"git+https://atomgit.com/oh-react-native/react-native-google-places-autocomplete.git#2.6.5-ohos-1.0.0"--ignore-scripts# 它没有 dependencies ⇒ 不需要补装任何东西八、交付包质量与基线说明
| 类别 | 结果 |
|---|---|
| 交付包内容 | GooglePlacesAutocomplete.js(32344 B)+src/(debounce.js/queryString.js/results.js/Row.js/styles.js/uuid.js)+GooglePlacesAutocomplete.d.ts(10898 B)+ 3 张品牌图⇒ 完整 ✓(20 文件) |
spec.json | ⚠️缺upstreamCommit |
validation | ⚠️"build-pass-device-pending" |
| ⚠️ 声明的基线 | reactNative: "0.88.0"+rnoh: "0.84.4"⇒两字段自相矛盾 |
| 备注 | 库代码零改动(本轮未修改库任何文件) |
关于基线:本次验证在RN0.84.1+ RNOH0.84.3上完成。
纯函数判据(13 条)与平台无关 ⇒ 结论跨基线成立;组件渲染结论也跨基线成立。
唯一与基线相关的是"网络"(与环境有关,不是与基线有关)。
九、已知限制与本次验证边界
9.1 用这个库要知道的
| 事项 | 说明 |
|---|---|
★★★★★maps.googleapis.com必须可达 | 这是它的硬依赖;本机不可达 ⇒主链路无法验证 |
★★★★★ 它自己实现了qs与debounce | src/queryString.js/src/debounce.js⇒dependencies为空,且这两个纯函数可单独验证 |
★★★★requestUrlprop 可以把请求指向自定义 URL | useOnPlatform: 'all'时直接用requestUrl.url⇒ 便于本地 mock |
★★★GooglePlacesAutocomplete是forwardRef | typeof是object、$$typeof是Symbol(react.forward_ref)⇒断言时别当函数 |
| ★★★ 默认导出是【对象】 | export default { GooglePlacesAutocomplete }⇒import X from …拿到的是对象 |
★★ListView关键词会误报 | listViewDisplayed/hideListView/styles.listView都不是那个已移除的组件 |
| ★★ 请求只在输入后触发 | 且要求长度 ≥minLength(默认 2)⇒ 不输入就永远不会发请求 |
★scripts.prepare会跑husky install | 安装必须--ignore-scripts |
9.2 本次验证的边界
- ★★★ 未验证"真实请求 → 候选列表"主链路(
maps.googleapis.com不可达); - ★★★ 未验证 XHR 失败路径的
onFail回调(onFail实测 0 次 —— 因为没触发输入,请求没发出); - ★★ 未验证
fetchDetails的详情二次请求; - ★★ 未验证下拉列表的渲染与点击选择(依赖真实请求结果);
- ★★ 未验证
debounceprop 与真实输入的组合行为(只单独验证了debounce函数本身); - 未验证
predefinedPlaces/currentLocation/enableHighAccuracyLocation; - 未验证
styles/renderRow/renderDescription/renderLeftButton等自定义渲染; - 未验证
GooglePlacesDetailsQuery与fetchDetails组合; - 未在
RN 0.88.0/RNOH 0.84.4上复验; - 未测其它屏幕宽度;未做跨平台对照。
库代码未改动。
十、常见问题
Q1:它在 RN 0.84 上能跑吗?
组件能渲染(placeholder正确显示在y=962),纯函数全部正确(queryString.stringify9 条 +debounce3 条)。
但"真实请求 → 候选列表"这条主链路本轮无法验证,因为maps.googleapis.com在本机请求超时、不可达。
Q2:ListView命中是不是意味着它崩?
不一定—— 这个库的 14 处"命中"全是listViewDisplayed(prop)、hideListView(函数)、styles.listView(样式键)。
判据要看真实使用形态:ListView.DataSource、import {…ListView…}、<ListView、new ListView。
(如果真有new ListView.DataSource(...)写在模块顶层,那才是 import 即崩。)
Q3:没有 Google 网络怎么测?
两条路:
- 切出纯函数单独测(这个库做得很好:
src/queryString.js/src/debounce.js都能直接 import 断言); - 用
requestUrl指向自己的 mock 服务:
⚠️ 但要注意:请求只在"输入长度 ≥<GooglePlacesAutocomplete requestUrl={{url: 'http://你的mock地址', useOnPlatform: 'all'}} query={{key: 'x'}} />minLength"之后才发⇒ 还必须先真的输入文字。
Q4:onFail一直不触发?
因为它只在请求失败时回调;而没有输入就不会发请求。
本轮的实测就是 0 次 —— 我把requestUrl指到了必然拒绝的127.0.0.1:9,但因为没触发输入,请求根本没发出。
Q5:怎么判断这个导出到底是组件还是对象?
importGpaDefault,{GooglePlacesAutocomplete}from'react-native-google-places-autocomplete';typeofGooglePlacesAutocomplete;// ★ 'object'(它是 forwardRef 的结果)String(GooglePlacesAutocomplete.$$typeof);// ★ 'Symbol(react.forward_ref)'typeofGpaDefault;// ★ 'object'(默认导出是个对象)typeofGpaDefault.GooglePlacesAutocomplete;// ★ 'object'⇒别用typeof === 'function'判断forwardRef组件✓
Q6:queryString.stringify的行为和qs一致吗?
大部分一致:undefined省略、null⇒key=、数组 ⇒key[0]=、嵌套 ⇒key[sub]=、
RFC3986 编码(!'()*也转义)。
差异(本轮未逐一验证):qs还支持arrayFormat选项、深度限制、allowDots等,这个实现只覆盖了"本库用到的子集"(文件头注释里也这么写)。
小结
react-native-google-places-autocomplete是Google 地点自动补全输入框,
代码本身很现代(forwardRef+ hooks)、老写法九项全空、dependencies为空。
最值得记下来的是这几条:
★★★★★
ListView的 14 处命中是纯误报,教训在"扫描目标"。
全是listViewDisplayed/hideListView/styles.listView。
⇒扫ListView这个词会骗人,要扫真实使用形态:ListView.DataSource、import {…ListView…}、<ListView✓
⇒ 这条规则能避免"看到命中就下否决结论"的错误(也避免了误丢掉一个可用的库)。★★★★★ 13 条纯函数判据全部通过,是这个库最诚实的验证路径。
因为它自己实现了qs.stringify子集与lodash.debounce
⇒两个纯函数可以脱离网络与平台直接断言(含 RFC3986 的!'()*转义、&编码、数组/嵌套格式、debounce 合并与cancel)✓✓
⇒对强依赖网络的库,"把可验证的部分切出来"比"想办法造网络"更划算。★★★★★ 我连续第二轮把契约断言写错 —— 根因是"预期值不是从源码来的"。
这轮错在:forwardRef返回对象(我当函数)、以及我的S()序列化不了 Symbol(把$$typeof变成'undefined')。
上轮错在:凭"常见默认值"猜duration/hideStatusBar/ColorTheme类型。
⇒系统性的改法:写断言前,先把源码里的"形态事实"抄下来(导出语句、typeof、$$typeof、常量块),
别先写断言、再等它 FAIL 来纠正✓★★★ 网络不可达时的诚实处理。
预检就把maps.googleapis.com探了一遍(超时)⇒不写"验证了主链路";
也不把"用必然失败的 URL 触发onFail"硬算成已验证(因为还需要触发输入,而输入注入不可靠)⇒
宁可诚实地少一条判据✓★★★ 组件渲染这一条仍然值得做:
placeholder文本可读(y=962)⇒ 至少证明"它能在这个平台上挂载并渲染" ✓★★
requestUrl这类"可替换 URL"的 prop 是网络库的宝贵出口。
它让"依赖外网"变成"依赖一个可替换的端点" ⇒ 有条件时可以指向本地 mock ✓
⚠️ 但别忘了它还需要"真实输入"才会发请求。★★ 默认导出是对象而不是组件(
export default { GooglePlacesAutocomplete })⇒import X from …拿到的是对象、X.GooglePlacesAutocomplete才是那个forwardRef对象 ⇒
导入方式与断言方式都要照着源码写✓
本篇用到的库
| 项 | 内容 |
|---|---|
| 三方库 | react-native-google-places-autocomplete(上游2.6.5的鸿蒙适配版) |
| 适配仓库 | https://atomgit.com/oh-react-native/react-native-google-places-autocomplete |
| 适配 TAG | 2.6.5-ohos-1.0.0 |
| 需要 HAR / 权限 / ohpm | 都不需要(纯 JS) |
| 库类型 | 地点自动补全输入框(依赖 Google Places API) |
| 运行时依赖 | ✅dependencies为空(qs/lodash.debounce都是自己实现的) |
| 导出形态 | export const GooglePlacesAutocomplete = forwardRef(...)⇒typeof为object;默认导出是对象{GooglePlacesAutocomplete} |
| ⚠️ 外部硬依赖 | https://maps.googleapis.com/maps/api必须可达 |
⚠️scripts.prepare | husky install⇒安装需--ignore-scripts |
| 许可证 | MIT |
| 上游仓库 | https://github.com/FaridSafi/react-native-google-places-autocomplete |
| 宿主工程 | RNOH084Demo(测试页rnAppKey = GooglePlacesTestApp) |
npminstall"git+https://atomgit.com/oh-react-native/react-native-google-places-autocomplete.git#2.6.5-ohos-1.0.0"--ignore-scriptsimport GpaDefault, {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; // ① 正常用法(需要可访问 Google Places API) <GooglePlacesAutocomplete placeholder="搜索地点" minLength={2} fetchDetails={false} query={{key: 'YOUR_KEY', language: 'zh', components: 'country:cn'}} onPress={(data, details = null) => console.log(data.description)} /> // ② 指向自己的 mock 端点(无外网时) <GooglePlacesAutocomplete requestUrl={{url: 'http://127.0.0.1:8080/places', useOnPlatform: 'all'}} query={{key: 'x'}} /> // ③ 单独用它的纯函数(无需网络,最稳的验证路径) import {stringify} from 'react-native-google-places-autocomplete/src/queryString'; import {debounce} from 'react-native-google-places-autocomplete/src/debounce'; stringify({a: 1, b: 'x y'}); // 'a=1&b=x%20y' stringify({k: '!*'}); // 'k=%21%2A'(RFC3986) debounce(fn, 120)(); debounce(fn, 120).cancel?.(); // ⚠️ GooglePlacesAutocomplete 是 forwardRef ⇒ typeof 是 'object',不是 'function' // ⚠️ 默认导出是对象 {GooglePlacesAutocomplete},不是组件 // ⚠️ 请求只在输入长度 ≥ minLength 后才发 ⇒ 不输入永远不会发请求、onFail 也不会触发 // ⚠️ 安装必须 --ignore-scripts(prepare = husky install)# 换页启动测试页 —— force-stop 不能省hdc shell aa force-stop com.rnoh084.demo hdc shell aa start-bcom.rnoh084.demo-aEntryAbility--psrnAppKey GooglePlacesTestApp判据的复现方式
# ① 纯函数 + 契约:逐条 PASS/FAILhdc shell"hilog -x | grep gpa-test"|Select-String'PASS|FAIL|SUMMARY|NOTE'# ⇒ queryString 9 条 + debounce 3 条全 PASS;3 条 FAIL 是我自己的断言写错# ② 组件渲染:placeholder 文本devecocli ui layout|Select-String'GPA 搜索占位'# ③ 网络可达性(预检就该做)# Invoke-WebRequest https://maps.googleapis.com/... ⇒ 本机超时环境搭建
本手册的验证环境是React Native for OpenHarmony(RNOH)。从零准备开发环境(DevEco Studio、HarmonyOS SDK API 26、Node.js、ohpm、hvigorw、模拟器与hdc调试)请按 CPF-RN 开发者指南的「环境初始化」操作:
- CPF-RN 开发者指南 · 02-搭建准备 · 环境初始化:https://atomgit.com/CPF-RN/docs/blob/main/开发者指南/02-搭建准备/环境初始化.md
环境就绪后,本次验证使用的具体版本组合见文末「验证环境」一节。
验证环境
| 项 | 版本 |
|---|---|
| React Native | 0.84.1 |
| React | 19.2.3 |
| RNOH(npm / ohpm) | @react-native-oh/react-native-harmony/@rnoh/react-native-openharmony0.84.3 |
| Node.js / npm | v24.14.0/11.9.0 |
| DevEco Studio | 26.0.0.621 |
| HarmonyOS SDK | API 26(26.0.0.32) |
| 设备 | HarmonyOS 7.0.0(26.0.0) Beta2 模拟器Pura X View(ohos-x64),1320×2232,density 3 |
| 本机状态栏高度 | 117px = 39vp |
| 宿主依赖 | ✅无需额外依赖(dependencies为空) |
| 宿主 HAP 产物 | entry-default-signed.hap |
| 本次构建 | assembleHap3 分 43 秒⇒ 一次成功(打包与构建退出码都检查了) |
| 验证规模 | 纯函数判据 13/13 全 PASS(queryString9 +debounce3 + 同步阶段 1);组件渲染成功(placeholder可见);契约 3 条 FAIL 全是我自己写错;onFail0 次(未触发输入,故失败路径未验证);LogBox 无通知;4 张截图互异 |
⚠️基线说明:交付包
spec.json声明RN 0.88.0 + RNOH 0.84.4(两字段自相矛盾),本次验证在RN 0.84.1 + RNOH 0.84.3上完成。
纯函数判据与平台无关 ⇒ 结论跨基线成立;网络相关结论取决于运行环境,与基线无关。
相关社区与项目地址
| 项目 | 地址 |
|---|---|
| React Native 鸿蒙官方社区(CPF-RN) | https://atomgit.com/CPF-RN |
| RNOH 框架仓库 | https://atomgit.com/openharmony-RN/ohos_react_native |
| RNOH 三方库使用文档 | https://atomgit.com/OpenHarmony-RN/usage-docs |
| React Native for OpenHarmony 组织 | https://atomgit.com/oh-react-native |
| RN 三方库鸿蒙适配清单 | https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview |
欢迎加入 CPF-RN 鸿蒙社区:https://atomgit.com/CPF-RN
React Native for OpenHarmony 组织:https://atomgit.com/oh-react-native
RN 三方库鸿蒙适配清单:https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview