React Native for OpenHarmony 实战:三方库 react-native-google-places-autocomplete 的鸿蒙化适配指南

发布时间:2026/10/24 2:18:32
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

其中两条特别值得看:

  1. RFC3986 编码:encodeURIComponent不转义!'()*,而qs会转义 ⇒
    这个实现里专门补了:
    constencode=(value)=>encodeURIComponent(String(value)).replace(/[!'()*]/g,c=>'%'+c.charCodeAt(0).toString(16).toUpperCase());
    实测{k: '!*'}⇒k=%21%2A✓ ⇒说明它连这个细节都对齐了qs✓
  2. 组合场景:{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';

⇒⇒ 所以在当前环境下,"输入 → 请求 → 拿到候选列表"这条主链路无法验证✗

我的处理:

  1. 把能验证的都验证透(纯函数 13 条 + 组件渲染)✓
  2. 把requestUrl指到http://127.0.0.1:9/(一个必然ECONNREFUSED的地址)⇒
    设计意图是:只要请求真的发出去,onFail就该被回调⇒用它证明"请求链路是通的、错误处理也正常"
  3. 实测结果: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.OS4 处,都是'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与debouncesrc/queryString.js/src/debounce.js⇒dependencies为空,且这两个纯函数可单独验证
★★★★requestUrlprop 可以把请求指向自定义 URLuseOnPlatform: 'all'时直接用requestUrl.url⇒ 便于本地 mock
★★★GooglePlacesAutocomplete是forwardReftypeof是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 本次验证的边界

  1. ★★★ 未验证"真实请求 → 候选列表"主链路(maps.googleapis.com不可达);
  2. ★★★ 未验证 XHR 失败路径的onFail回调(onFail实测 0 次 —— 因为没触发输入,请求没发出);
  3. ★★ 未验证fetchDetails的详情二次请求;
  4. ★★ 未验证下拉列表的渲染与点击选择(依赖真实请求结果);
  5. ★★ 未验证debounceprop 与真实输入的组合行为(只单独验证了debounce函数本身);
  6. 未验证predefinedPlaces/currentLocation/enableHighAccuracyLocation;
  7. 未验证styles/renderRow/renderDescription/renderLeftButton等自定义渲染;
  8. 未验证GooglePlacesDetailsQuery与fetchDetails组合;
  9. 未在RN 0.88.0/RNOH 0.84.4上复验;
  10. 未测其它屏幕宽度;未做跨平台对照。

库代码未改动。


十、常见问题

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 网络怎么测?

两条路:

  1. 切出纯函数单独测(这个库做得很好:src/queryString.js/src/debounce.js都能直接 import 断言);
  2. 用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为空。

最值得记下来的是这几条:

  1. ★★★★★ListView的 14 处命中是纯误报,教训在"扫描目标"。
    全是listViewDisplayed/hideListView/styles.listView。
    ⇒扫ListView这个词会骗人,要扫真实使用形态:ListView.DataSource、import {…ListView…}、<ListView✓
    ⇒ 这条规则能避免"看到命中就下否决结论"的错误(也避免了误丢掉一个可用的库)。

  2. ★★★★★ 13 条纯函数判据全部通过,是这个库最诚实的验证路径。
    因为它自己实现了qs.stringify子集与lodash.debounce
    ⇒两个纯函数可以脱离网络与平台直接断言(含 RFC3986 的!'()*转义、&编码、数组/嵌套格式、debounce 合并与cancel)✓✓
    ⇒对强依赖网络的库,"把可验证的部分切出来"比"想办法造网络"更划算。

  3. ★★★★★ 我连续第二轮把契约断言写错 —— 根因是"预期值不是从源码来的"。
    这轮错在:forwardRef返回对象(我当函数)、以及我的S()序列化不了 Symbol(把$$typeof变成'undefined')。
    上轮错在:凭"常见默认值"猜duration/hideStatusBar/ColorTheme类型。
    ⇒系统性的改法:写断言前,先把源码里的"形态事实"抄下来(导出语句、typeof、$$typeof、常量块),
    别先写断言、再等它 FAIL 来纠正✓

  4. ★★★ 网络不可达时的诚实处理。
    预检就把maps.googleapis.com探了一遍(超时)⇒不写"验证了主链路";
    也不把"用必然失败的 URL 触发onFail"硬算成已验证(因为还需要触发输入,而输入注入不可靠)⇒
    宁可诚实地少一条判据✓

  5. ★★★ 组件渲染这一条仍然值得做:placeholder文本可读(y=962)⇒ 至少证明"它能在这个平台上挂载并渲染" ✓

  6. ★★requestUrl这类"可替换 URL"的 prop 是网络库的宝贵出口。
    它让"依赖外网"变成"依赖一个可替换的端点" ⇒ 有条件时可以指向本地 mock ✓
    ⚠️ 但别忘了它还需要"真实输入"才会发请求。

  7. ★★ 默认导出是对象而不是组件(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
适配 TAG2.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.preparehusky 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-scripts
import 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 Native0.84.1
React19.2.3
RNOH(npm / ohpm)@react-native-oh/react-native-harmony/@rnoh/react-native-openharmony0.84.3
Node.js / npmv24.14.0/11.9.0
DevEco Studio26.0.0.621
HarmonyOS SDKAPI 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