本文记录把 react-native-popup-menu 适配到 HarmonyOS 的完整过程。

这个库是目前遇到的最省事的一个:纯 JS、零运行时依赖、没有 prepare、main 指向的 rollup 产物与上游逐字节一致。接入几乎不需要动脑。

但它有一个很容易把断言写错的地方,值得单独拿出来讲:

同一个库里,导出组件的形态是混的 —— MenuProvider / MenuContext 是普通 class,
而 Menu / MenuOption / MenuOptions / MenuTrigger 全部被 HOC 包过(withCtx → React.forwardRef)
⇒ typeof 是 object、没有可用的 prototype。

我第一版按"都是 class"写断言,一口气错了 12 条。修正后 29 / 29 全部通过。

在这里插入图片描述


一、先说结论

项结果
上游最新版0.19.0;license: ISC;gitHead = 2ec80a8e38e2eb641eb2dd16950873f3fe0126cd
库类型纯 JS 组件库:MenuProvider + Menu + MenuTrigger + MenuOptions + MenuOption,外带 4 个渲染器
是否需要原生适配不需要(无 harmony/、无 NativeModules)
运行时依赖零(dependencies 是空对象 {})
prepare / prepack都没有 ✓
prepublish⚠️ 有一个 prepublish: "yarn build",但实测在 npm 11.9.0 下不触发(install 与 pack 都不跑)⇒ 死脚本,而且 npm 元数据里也有同一行 ⇒ 上游自带
mainbuild/rnpm.js(rollup 的 UMD 产物,104 KB,已提交)
与 npm 上游的差异33 / 34 个文件逐字节一致;唯一差异是 package.json(含 src/** 20 个文件与 build/rnpm.js 产物)
需要 HAR / 权限 / ohpm❌ 都不需要
编译assembleHap 3 分 36 秒 / 3 分 46 秒
设备侧断言✅ 29 / 29 全部通过
★ 最有价值的结论组件形态混用(class vs HOC 包装的 forwardRef);且 HOC 没有提升内层 class 的 propTypes ⇒ 那些校验根本不会执行
★ 实测行为选中即关闭;onSelect 返回 false 则不关闭;背板点击关闭;返回键关闭(且不会退出应用)
我自己的失误按"都是 class"写断言(错 12 条);用 React state 读 isMenuOpen() 导致读到旧值

二、判定过程:最省事的一个库

步做法结果
①package.json 里有没有 harmony.autolinking没有
②仓库里有没有 harmony/没有
③代码里有没有 NativeModules / requireNativeComponent全无

dependencies 是空对象 ⇒ 不需要补装任何传递依赖,安装路径最干净。


三、交付包:实现与产物双重零改动

3.1 逐文件比对(先去 CR 再逐字节比)

类别结果
一致33 个文件
不同1 个:package.json
交付包缺少0
交付包多出6 个:.gitignore、两个 README.OpenHarmony*、代码检查报告、spec.json、契约测试

★ 这里要分两层说"零改动":

  1. src/** 全部 20 个文件一致 ⇒ 没有偷偷改实现;
  2. build/rnpm.js(rollup 产物)也与上游逐字节一致 ⇒ 排除了"源码改了但产物没重打 / 产物陈旧"这类问题。

为什么要单独核对产物:这个库的 main 指向 build/rnpm.js,运行时真正执行的是产物。所以"逐文件 diff"里必须把它算进去;只看 src/ 是不够的。

3.2 prepublish 实测:在 npm 11.9.0 下是"死脚本"

package.json 里有:

"scripts": { "build": "rollup -c …", "prepublish": "yarn build", "test": "node --test …", "lint": "eslint ." }

prepare / prepublish 这一族脚本会在安装时执行,而且能在包的目录里动手改产物 —— 本系列已经栽过两次(一次"打不出 tgz + 退出码 1",一次 rm -rf built 直接删掉自己的 main 目标)。而且它调的是 yarn,如果真在安装时跑、而消费者没有 yarn,安装就会失败。

所以在副本上实测(这类脚本可能改产物):

实验结果
npm install(不加 --ignore-scripts)exit 0、added 1 package;输出里没有 prepublish/yarn/rollup 任何字样;build/rnpm.js 完好
npm install --ignore-scripts(对照)exit 0
npm pack --ignore-scriptsexit 0,产出 tgz
npm pack(不加)exit 0,产出 tgz;同样没有 prepublish 输出

⇒ prepublish 在 npm 11.9.0 下既不在 install 触发、也不在 pack 触发 ⇒ 它是死脚本,不构成交付缺陷。

教训:“有这个脚本” ≠ “这个脚本会跑”。npm 文档对 prepublish 的描述改过多次,以实测为准。反过来说,在别的 npm 版本 / 别的安装路径上它可能真的会跑,所以 --ignore-scripts 仍值得作为默认。

3.3 问题

问题说明
spec.json 缺 upstreamCommit而 npm 明确有 gitHead(2ec80a8e…)⇒ 属应修项
validation 是裸字符串 "pass"没有可复核的测试项清单
多发了 .claude/settings.local.json78 字节,是 AI 编程工具的本地配置,不该随包发布(工程卫生问题)
契约测试是包级 smoke test只检查包名 / 版本 / 主入口 / 中文 README 存在,一次组件都没渲染过

四、接入方式

npm install "git+https://atomgit.com/oh-react-native/react-native-popup-menu.git#0.19.0-ohos-1.0.0" --ignore-scripts
// metro.config.js —— file: / 软链装法需要把真实目录加进 watchFolders
watchFolders: [ path.resolve(__dirname, '../react-native-popup-menu') ],
import PopupMenuTestApp from './PopupMenuTestApp';
AppRegistry.registerComponent('PopupMenuTestApp', () => PopupMenuTestApp);
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey PopupMenuTestApp

不需要 HAR、权限、ohpm、link-harmony,也不需要补装任何依赖。


五、导出形态:这个库里 class 和 HOC 是混着的

5.1 源码里的导出

// src/index.js
export {
  Menu as default, Menu, MenuProvider, MenuContext,
  MenuOption, MenuOptions, MenuTrigger,
  renderers, withCtx as withMenuContext,
};
const renderers = { ContextMenu, SlideInMenu, NotAnimatedContextMenu, Popover };

5.2 但运行时形态不一样(实测)

导出源码写法实测 typeof有无可用 prototype
MenuProviderexport default class MenuProvider extends Componentfunction✅ 有(openMenu / closeMenu / toggleMenu / isMenuOpen)
MenuContextdeprecatedComponent(…)(MenuProvider) ⇒ 返回一个 classfunction✅ 有
Menu(default)export default MenuExternal,而 MenuExternal 由 withCtx(...) 包出object❌ undefined
MenuOptionexport default withCtx(MenuOption)object❌
MenuOptionsexport default withCtx(MenuOptions)object❌
MenuTriggerexport default withCtx(MenuTrigger)object❌

withCtx = withContext(PopupMenuContext, 'ctx') —— 内部走 React.forwardRef(源码里有 if (!React.forwardRef) 的兼容分支)⇒ 返回的是对象,所以 typeof 不是 function、也没有 prototype。

⇒ 断言"这是 class"时必须区分对待,否则会像我一样一口气错 12 条。

在这里插入图片描述

5.3 顺带发现一个真实后果:这些 propTypes 不会被执行

MenuTrigger / MenuOptions / MenuOption 的 propTypes 是定义在内层 class 上的(源码里能读到),但导出给消费者的是外层 HOC 对象,而 withCtx 没有把 propTypes 提升上去:

typeof MenuTrigger.propTypes   // 实测 'undefined'
typeof MenuOptions.propTypes   // 实测 'undefined'

⇒ React 校验 propTypes 时读的是最终渲染的那个组件(这里是 forwardRef 对象)。它上面没有 propTypes ⇒ 这些校验根本不会运行。

影响:开发期少了属性类型提示(比如 MenuTrigger 的 text 写成 number 不会报警告)。库能跑,但"写了 propTypes"这件事没有生效。

5.4 Menu 上还有两个静态配置方法

Menu.setDefaultRenderer(renderer)         // 改全局默认渲染器
Menu.setDefaultRendererProps(props)

它们的实现是直接改 menuConfig.defRenderer:

// src/config.js
export const menuConfig = { defRenderer: ContextMenu, defRendererProps: {} }

⇒ 改的是全局默认值,影响此后所有未显式指定 renderer 的 Menu(和某些库的 setMode 一样属于全局副作用)。


六、可测行为(都来自源码,不是猜的)

6.1 默认渲染器是 ContextMenu

// src/config.js
export const menuConfig = { defRenderer: ContextMenu, defRendererProps: {} }

⇒ 不传 renderer 时用的是 ContextMenu,不是 Popover。(renderers 一共 4 个:ContextMenu / SlideInMenu / NotAnimatedContextMenu / Popover。)

6.2 选中后是否关闭,由 onSelect 的返回值决定

// src/MenuOption.js
_onSelect() {
  const { value } = this.props;
  const onSelect = this.props.onSelect || this._getMenusOnSelect();
  const shouldClose = onSelect(value) !== false;   // ← 关键
  …
}

⇒ onSelect 返回 false ⇒ 菜单不关闭;返回其它值(含 undefined)⇒ 关闭。

6.3 两条关闭路径

// src/MenuProvider.js
_handleBackButton = () => { if (this.isMenuOpen()) { this.closeMenu(); } };   // 返回键
_onBackdropPress = () => { … this.closeMenu(); };                             // 点背板

⇒ 返回键能关菜单(MenuProvider 自己装了 BackHandler)、点背板也能关。

6.4 动画是原生驱动的

// src/constants.js
export const USE_NATIVE_DRIVER = (Platform.OS !== "web");

⇒ 鸿蒙(非 web)上 useNativeDriver 为真 ⇒ JS 侧读不到动画值、布局 dump 也读不到 transform。所以本轮把验证重点放在结构性证据上(菜单是否打开、选项文本是否出现、几何矩形、回调与 isMenuOpen()),而不追动画中间态。


七、实测:断言 29/29,行为四条全部成立

7.1 断言

组内容结果
A 契约导出形态(class vs HOC 对象)、$$typeof、Menu 的两个静态方法、renderers 4 个、MenuProvider.propTypes 4 键、propTypes 未被提升、MenuContext 是 class25/25
B 开关与几何触发器几何、isMenuOpen() 返回布尔3/3
C 回调与边界事件记录格式1/1
合计29/29

7.2 命令式开关(通过 MenuProvider 的 ref)

OPEN 命令式 openMenu(m1)
OPEN m1 onOpen                 ← onOpen 触发
QUERY isMenuOpen() = false     ← 打开前
QUERY isMenuOpen() = true      ← 打开后

而布局 dump 里三个选项都渲染出来了:

OPT-ALPHA  [81,1875,651,1924]
OPT-BETA   [81,1954,651,2003]
OPT-GAMMA  [81,2033,651,2082]

⇒ 选项间距恒为 79px(1954−1875 = 79,2033−1954 = 79),且三者左边界、高度完全一致 ⇒ 列表是规整排布的。

7.3 选中即关闭

点 OPT-BETA:

SELECT m1 onSelect(beta) ⇒ 返回 undefined ⇒ 应关闭
CLOSE m1 onClose

⇒ onSelect 收到的是选项的 value('beta'),随后 onClose 触发、选项从屏上消失 ✓

7.4 ★ 边界:onSelect 返回 false 时菜单不关闭

点 m2 的 OPT-ONE(它的 onSelect 故意 return false):

SELECT m2 onSelect(one) ⇒ 返回 false ⇒ 不应关闭

实测:选项仍然留在屏上 ✓✓ 源码语义完全成立。

这条对实际开发很有用:想做"多选""选完不关"的菜单,就让 onSelect 返回 false。

7.5 背板点击关闭

m2 仍开着时点屏幕空白处:

(选项从屏上消失)

⇒ 背板接收了这次点击并调用了 closeMenu() ✓

7.6 返回键关闭(且不退出应用)

先打开 m1,再按返回键:

CLOSE m1 onClose
(选项从屏上消失)
应用仍在后台栈中(未被退出)

⇒ MenuProvider 的 BackHandler 生效,且返回键被菜单消费掉、没有传下去把 Activity 关掉 ✓

在这里插入图片描述


八、我自己的两个失误

#失误后果纠正
1按"都是 class"写断言(对 Menu / MenuOption / MenuOptions / MenuTrigger 检查 prototype.render 等)一次错 12 条(实测 typeof 是 object、prototype 是 undefined)先读源码看导出写法、再用 typeof 实测确认;改成断言"被 HOC 包过的对象(typeof 'object' + $$typeof 存在 + 无可用 prototype)",并顺带发现 propTypes 没被提升
2用 React state 读 isMenuOpen()(在同一个 tick 里先 setState 再断言)该条断言读到旧值而失败改成实时调用 providerRef.current?.isMenuOpen?.(),不经过 state

⇒ 两条都是测试写法问题,不是库的问题;修正后 29/29。

在这里插入图片描述


九、已知限制与本次验证边界

9.1 用这个库要知道的

事项说明
Menu 必须在 MenuProvider 之下否则构造函数直接 throw("Menu component must be ancestor of MenuProvider")
组件形态混用MenuProvider / MenuContext 是 class;Menu / MenuOption / MenuOptions / MenuTrigger 是 HOC 包过的对象(typeof 'object')
propTypes 不生效它们挂在内层 class 上,HOC 没有提升 ⇒ React 不会校验
onSelect 返回 false 可阻止关闭想做多选/连续操作时很有用
setDefaultRenderer 是全局副作用改的是 menuConfig.defRenderer,影响此后所有未显式指定 renderer 的 Menu
默认渲染器是 ContextMenu不是 Popover
MenuContext 已废弃用它会打印废弃警告,应改用 MenuProvider
返回键会被菜单消费菜单开着时按返回键只关菜单,不会退出页面/应用

9.2 本次验证的边界

  1. 只在一台模拟器上验证(Pura X View,density 3,1320×2232),没有真机。
  2. 只验证了默认渲染器 ContextMenu;Popover / SlideInMenu / NotAnimatedContextMenu 完全没测(它们的位置计算逻辑复杂,是最值得后续单独测的部分)。
  3. renderOptionsContainer / optionsContainerStyle / customStyles 自定义未测。
  4. MenuTrigger 的 disabled、onPress、openOnLongPress 等 prop 未逐条测(只测了 text 能渲染出来)。
  5. 多菜单同时打开 / 同名菜单(重名会 console.warn)未测。
  6. 动画中间态未测(USE_NATIVE_DRIVER 在鸿蒙为真,动画在原生侧,本轮不追)。
  7. withMenuContext HOC 的用法未测;MenuContext 只断言了形态,没实际用它开菜单。
  8. 弹出的菜单位置是实测记录的,不是对照源码公式算出来的:本轮记录了"选项在触发器附近、三者间距恒 79px",没有把 ContextMenu 的定位公式推导一遍再验证。
  9. 没有跨平台对照(未在 iOS/Android 上跑同一套用例)。
  10. 实测中观察到一次 isMenuOpen() 在返回键关闭后返回 undefined(同一时刻选项已消失、onClose 已触发);未定性,如实记录。

未改动库代码。


十、常见问题

Q1:为什么 Menu.prototype.render 是 undefined?

因为它不是 class。源码里 Menu 的默认导出是 withCtx(Menu),而 withCtx 内部用 React.forwardRef ⇒ 返回对象(typeof 'object'),没有 prototype。

只有 MenuProvider 和 MenuContext 是真正的 class,能访问到原型方法。

Q2:那我怎么打开/关闭菜单?

两条路都可以:

// ① ref 命令式(推荐做自动化与外部控制)
<MenuProvider ref={ref} />
ref.current.openMenu('m1');
ref.current.closeMenu();
ref.current.isMenuOpen();

// ② 组件式
<Menu name="m1">
  <MenuTrigger text="打开" />
  <MenuOptions>
    <MenuOption value="a" text="选项 A" />
  </MenuOptions>
</Menu>

Q3:onSelect 的返回值有什么作用?

const shouldClose = onSelect(value) !== false;

返回 false ⇒ 菜单保持打开;返回其它任何值(包括不写 return)⇒ 选中后关闭。实测已确认(SELECT m2 onSelect(one) ⇒ 返回 false 之后选项仍在屏上)。

Q4:为什么我写的 propTypes 没生效?

因为它们是定义在内层 class 上的,而导出给消费者的是外层 HOC 对象,withCtx 没有把 propTypes 提升上去。实测:

typeof MenuTrigger.propTypes   // 'undefined'
typeof MenuOptions.propTypes   // 'undefined'

⇒ React 校验 propTypes 时读的是最终渲染的组件,它上面没有 ⇒ 不会执行校验。

Q5:默认用哪个渲染器?

ContextMenu(源码 config.js 里 defRenderer: ContextMenu),不是 Popover。要换:

<Menu renderer={renderers.Popover}>…</Menu>
// 或者改全局默认
Menu.setDefaultRenderer(renderers.SlideInMenu);

⚠️ setDefaultRenderer 改的是全局 menuConfig.defRenderer,影响此后所有未显式指定 renderer 的 Menu。

Q6:点空白处能关掉菜单吗?按返回键呢?

都能。 源码里:

  • _onBackdropPress ⇒ closeMenu()(点背板关闭);
  • _handleBackButton:if (this.isMenuOpen()) this.closeMenu()(返回键关闭)。

实测确认:点屏幕空白处选项消失;按返回键选项消失且 onClose 触发,应用没有被退出。

Q7:Menu 报 Menu component must be ancestor of MenuProvider 怎么办?

字面意思:Menu 必须在 MenuProvider 的子树里。源码在 Menu 的构造函数里就直接 throw 了。把 <MenuProvider> 提到最外层即可。

Q8:MenuContext 还能用吗?

能用但已废弃——它是 deprecatedComponent(...) 包出来的,用旧 API(openMenu / toggleMenu / closeMenu / isMenuOpen)时会打印废弃警告。新代码请用 MenuProvider。

Q9:main 指向的是 build/rnpm.js,会不会拿到过期的产物?

这个交付包里不会 —— 实测 build/rnpm.js 与 npm 上游逐字节一致,src/** 也全部一致。但核对时必须把产物一起比,否则"源码一致"并不能保证"跑起来的产物一致"。

Q10:装的时候要注意什么?

几乎什么都不用注意:零运行时依赖、没有 prepare/prepack,那个 prepublish 实测是死脚本。本系列仍统一加 --ignore-scripts(零成本、最稳)。


小结

react-native-popup-menu 是目前遇到最省事的一个:纯 JS、零运行时依赖、没有 prepare、src/** 与 rollup 产物都与上游逐字节一致(33/34 个文件)。设备侧 29 / 29 断言全过,四条行为——选中即关闭、onSelect 返回 false 则不关闭、背板点击关闭、返回键关闭——全部实测成立。

值得记下来的是这几条:

  1. 同一个库里,导出组件的形态可能是混的。 MenuProvider / MenuContext 是 class,而 Menu / MenuOption / MenuOptions / MenuTrigger 都是 withCtx(→ React.forwardRef)包出来的对象。⇒ 别按"组件就是 class"写断言,先读源码的导出写法、再用 typeof 实测确认。(我第一版因此一次错了 12 条。)

  2. HOC 不提升 propTypes,校验就静默失效。 这个库把 propTypes 定义在内层 class 上,而导出的是外层 HOC 对象 ⇒ React 根本不会执行这些校验。⇒ 看到"库里有 propTypes"不等于"真的会校验"。

  3. main 指向打包产物时,diff 必须包含产物。 这个库运行时执行的是 build/rnpm.js;只比 src/ 会漏掉"源码改了但产物没重打"。本轮产物也与上游一致,才算真的零改动。

  4. “有这个脚本” ≠ “这个脚本会跑”。 prepublish: "yarn build" 看着很危险(会改产物、还依赖 yarn),但实测在 npm 11.9.0 下 install 与 pack 都不触发。⇒ npm 生命周期脚本一律实测,别按文档推断;同时 --ignore-scripts 仍值得作为默认。

  5. 用 React state 做同一 tick 的断言会读到旧值。 我有一版把 isMenuOpen() 的结果写进 state 再在同一个回调里断言 ⇒ 读到打开前的值而失败。⇒ 验证类代码要实时取值,不要绕 state。


本篇用到的库

项内容
三方库react-native-popup-menu(上游 0.19.0 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-popup-menu
适配 TAG0.19.0-ohos-1.0.0
需要 HAR / 权限 / ohpm都不需要(纯 JS,零运行时依赖)
库类型纯 JS 组件库:菜单/弹出菜单(MenuProvider + Menu + 触发器等)
运行时依赖零(dependencies: {})
prepare / prepublish无 prepare;有一个 prepublish: "yarn build",实测不触发
许可证ISC
上游仓库https://github.com/instea/react-native-popup-menu
上游基线npm gitHead = 2ec80a8e38e2eb641eb2dd16950873f3fe0126cd
宿主工程RNOH084Demo(测试页 rnAppKey = PopupMenuTestApp)
# 从适配仓库安装
npm install "git+https://atomgit.com/oh-react-native/react-native-popup-menu.git#0.19.0-ohos-1.0.0" --ignore-scripts

# 或本地 file: 安装(装成软链,需要配 watchFolders)
npm install ../react-native-popup-menu --ignore-scripts
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-popup-menu') ],
// 用法:MenuProvider 必须在最外层;Menu 必须在它之下
import Menu, {MenuProvider, MenuTrigger, MenuOptions, MenuOption, renderers} from 'react-native-popup-menu';

<MenuProvider ref={menuRef}>
  <Menu
    name="m1"
    renderer={renderers.ContextMenu}          // 默认就是 ContextMenu
    onOpen={() => console.log('opened')}
    onClose={() => console.log('closed')}
    onSelect={(value) => {
      console.log('selected', value);
      // 返回 false ⇒ 选中后【不关闭】(适合多选/连续操作)
      // 返回其它值(含 undefined)⇒ 选中后关闭
    }}>
    <MenuTrigger text="打开菜单" />
    <MenuOptions>
      <MenuOption value="alpha" text="选项 A" />
      <MenuOption value="beta" text="选项 B" />
    </MenuOptions>
  </Menu>
</MenuProvider>;

// 命令式控制(ref 指向 MenuProvider)
menuRef.current?.openMenu('m1');
menuRef.current?.closeMenu();
menuRef.current?.isMenuOpen();
# 换页启动测试页(force-stop 不能省,换页参数只在冷启动生效)
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey PopupMenuTestApp

验证环境

项版本
React Native0.84.1
React19.2.3
RNOH(npm / ohpm)@react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.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
宿主 HAP 产物entry-default-signed.hap(82,263,324 字节)
本次构建assembleHap 3 分 36 秒 / 改断言后重编 3 分 46 秒
验证规模设备侧 29 / 29 断言;外部协议含命令式开/关、选中关闭、false 边界、背板关闭、返回键关闭各 1 次;上游 34 个文件逐字节比对;prepublish 4 组安装/打包实测

欢迎加入 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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐