
关于Expo插件,以及原生能力
首先呢,这篇博文的所有知识点都能在Expo Modules文档中找到,我这里主要是把自己对expo模块的理解写出来,也省的以后在同样的知识点上浪费时间。
这篇文章同样回答了很多人的一个问题,那就是Expo到底有没有原生能力?能不能调用或者修改原声代码?本文介绍了Expo使用原生代码的其中一种方式,封装原生代码为Expo模块,发布到npm,方便自己,也方便了有同样需求的其他开发者,岂不美哉!
创建模块
命令行使用npx create-expo-module 你的expo模块名,比如我希望创建一个用于监听硬件键盘输入的插件,那么我可以:
1 | npx create-expo-module expo-keyboard-event |
执行这个命令后,接下来根据提示输入插件的基本信息,比如插件描述,你的GitHub地址等等,如下所示:
1 | ➜ Desktop npx create-expo-module expo-keyboard-event |
输入完了之后,Expo会开始安装依赖,接下来开始介绍目录结构。
1 | ├── CHANGELOG.md # 你的插件更新日志 |
走通流程
流程梗概:从模块入口
index.ts,到*Module.ts(代码模块)和*View.ts(页面模块),到具体的iOS和安卓的原生模块类。
web平台会走平台拓展名的方式,没有最后一步,直接渲染JSX了就。
首先,从src/index.ts入口启动,src/index.ts中导出了ExpoKeyboardEventModule.ts和ExpoKeyboardEventView.ts,这两个文件分别负责执行原生代码,以及渲染原生视图:
1 | // Reexport the native module. On web, it will be resolved to ExpoKeyboardEventModule.web.ts |
平台拓展名
得益于React Native的平台拓展名,不同的平台拓展名,会在对应的平台上被启用:.web.ts或.web.tsx会在web平台上被启用。.native.ts或.native.tsx会在原生平台,也就是iOS和安卓平台上被启用。.ios.ts或.ios.tsx对应iOS。.android.ts或.ios.tsx安卓。
简单来说,两个文件名相同,但是后缀名不同的文件,RN会去找跟自己平台名字后缀相符的文件去执行。这是处理平台兼容性的最好的办法。
好,现在流程来到ExpoKeyboardEventModule.ts以及ExpoKeyboardEventView.ts了。
这俩文件其实就是个代理,这两个文件分别利用requireNativeModule()和requireNativeView()方法,把对应平台的原生代码桥接进来。
另外考虑到TypeScript类型问题,它在桥接进来后,会添加上我们需要的类型,也就是我们的模块需要具体哪些方法和参数,它大概是这么做的:
1 | import { NativeModule, requireNativeModule } from 'expo'; |
ExpoKeyboardEventView的基本流程也是一样的,利用requireNativeView()方法,引入原生的UI页面,然后使用泛型等方法,赋予其TS类型。
好的,流程终于到了原生代码部分了。
上面的各种方法和常量比如setValueAsync()和PI,到目前只是方法的定义部分,真正的实现部分在原生代码里面,所以原生代码的方法和常量要与TS的方法定义一一对应。
Expo为此专门创造了一套Module API,可以让我们用Swift和Kotlin来编写原生模块,并且由于Swift和Kotlin本身的语言特性,安卓和iOS平台的原生模块代码相似度高达99%!可以说是开发成本极低!
1 | import ExpoModulesCore |
1 | package expo.modules.keyboardevent |
解释一些问题,JS的Promise到了原生端,会在方法最后加一个Promise类型的参数,调用该参数的reject()或者resolve()方法来返回内容或者报错。
原生View里面,Prop("url") { (view: ExpoKeyboardEventView, url: URL) in接受了一个Swift的URL参数,可是RN里面明明传入的是string啊?怎么变成URL的?
原来,Expo内部对一些基本的类型做了转换,凡是符合URL格式的字符串,到原生代码里面就变成URL了。具体的类型转换可以看这里。
那么Web呢?
上面说了,借助平台拓展名,web平台调用这个插件的时候,实际会进入到ExpoKeyboardEventModule.web.ts和ExpoKeyboardEventView.web.ts两个文件里面,不会再继续转发。那么此时你在开发的是普通的npm包,在这两个文件内分别实现模块方法和页面内容即可。
需要注意的是,模块方法需要跟ExpoKeyboardEventModule.ts保持一致。ExpoKeyboardEventView.web.ts内,原生层会返回一个原生的View对象,在这里,直接渲染JSX即可,此时你是在开发React程序,而不是RN程序。
总结
RN和Expo 模块精义,如果你需要编写的原生代码部分没有UI页面,就找XXXModule。反之如果需要写页面,就找XXXView。
原生代码返回内容到Expo
上面的流程基本上是走通了,目前来说已经实现了RN调用原生安卓、原生iOS、Web平台的代码。那么反过来,调用后怎么拿到返回值呢?原生端怎么主动向RN端发送事件和数据呢?
同步方法
上面的代码例子里面写过,Function("hello")用于创建一个同步方法。同步方法会阻塞原生线程,直到这个方法返回。
上面的例子中定义了一个同步方法叫做hello(),则RN中可以直接这么调用:
1 | <Text>{ExpoKeyboardEvent.hello()}</Text> |
同步方法最多能接受8个参数,这是Swift和Kotlin的语言限制。
同步方法会阻塞原生线程,还会拖慢程序的响应速度。官方建议,在执行IO任务,比如发送网络请求,或者进行文件操作,或者需要在子线程上执行等耗时操作时,必须使用异步方法。
异步方法
AsyncFunction("setValueAsync")用于定义一个异步方法。异步方法会在子线程中执行,这种方法的返回值是一个Promise。假设此时你直接return true,那么Expo会自动帮你隐式promise.resolve(true)。如果方法本身有异步操作,还可以把Promise参数显式写出来,手动调用resolve()或者reject(),更加灵活。
1 | AsyncFunction("myAsyncFunction") { (message: String) in |
1 | AsyncFunction("myAsyncFunction") { message: String -> |
讲道理,Expo的开发者的确全是人才(褒义),这么封装一下,Swift和Kotlin代码除了闭包的in换成->,其他几乎一毛一样,简直是艺术!
同步方法和异步方法除了Function换成AsyncFunction,前者没有Promise之外,其他写法都是一样的。
发送事件
事件用于持续性的、多次的传递信息。
其流程依次是是注册事件、发送事件、监听事件。
注册事件
1 | Events("onChange", "onEdit", "onUpdate", "aaa", "bbb") |
Swift和Kotlin的语法一样的,使用Events()方法,可以向RN暴露出一堆事件名,用于RN代码中根据事件名监听。
发送事件
原生模块内,调用sendEvent()方法向RN发送事件。注意事件名必须要是上面注册过的。
需要注意的是,模块内发送事件和View发送事件有一点点区别,模块类内发送事件直接调用sendEvent(eventName: string, payload: Map)
1 | AsyncFunction("setValueAsync") { (value: String) in |
1 | AsyncFunction("setValueAsync") { value: String -> |
kotlin的
mapOf()、bundleOf()、arrayOf()语法多少有点反人类了哈…
原生视图内的发送事件比模块内的稍微麻烦一点。
首先,你在模块内使用View() {}为这个模块定义了一个原生视图。在原生视图定义里面,使用Events(...)定义了一堆视图内的事件名。流程看起来跟模块的发送事件差不多啊,可是原生视图类在另一个文件里面啊?我在它里面怎么调用这里的sendEvent()呢?
这里其实Expo做了一点黑魔法。在原生视图类内,创造一个神奇的EventDispatcher类的实例,保持变量名跟其中一个事件名一致,此时变量名就是事件名,调用该变量即可。没错,就是调用这个变量。这当然借助于Swift的神奇的callAsFunction()方法,将变量当作方法来调用。
假设你在View内定义了这样一堆事件:Events('onChange', 'onFinish'),那么你在原生代码内,像这样写:
1 | /// 这里相当于创建了一个专用的事件发送器,只能用于给`onChange`事件发送消息 |
1 | /// 利用Kotlin的by关键字特性,onChange被委托给EventDispatcher(),可以理解为后者是一个监听器 |
监听事件
监听模块事件
上面说了,原生端暴露事件之后,RN端就可以用addListener来对模块监听事件,比如下面这样:
1 | const ExpoKeyboardEventModule = requireNativeModule('ExpoKeyboardEvent'); |
本质上就是对模块内的某个事件名字进行监听。
Expo又对监听的过程进行了一次封装,它提供了一个useEvent() hook,传入模块对象,以及事件名即可,hook本身会自己做清理工作。
1 | /// 此时onChangePayload得到的就是上面sendEvent()的第二个参数发送的payload字段本身 |
监听原生视图事件
原生视图事件就更简单了,它用起来跟普通的View Props是一样的。比如你在View内注册了onLoad事件,那么这个原生视图在RN端就会产生一个onLoad()属性,就像这样:
1 | const onLoad = useCallback((event) => {}, []) |
监听器的生命周期随着ExpoKeyboardEventView一起,不需要关心清理监听器的事情。
到这一步,原生端向RN端回传数据的知识也算是记录完了。那么问题又来了,web呢?web的同步方法异步方法以及Promise不需要处理,那么发送事件呢?
首先,模块事件。ExpoKeyboardEventModule.web.ts的定义如下:
1 | import { registerWebModule, NativeModule } from 'expo'; |
至于视图事件,上面说了,视图事件跟普通的组件参数没有区别,所以你在你的React组件里面加一个同名的参数,并且手动调用就行了:
1 | import * as React from 'react'; |
模块部分终于算是写完了!到目前为止,RN调用原生和Web,以及向RN回传数据,都已经明了了!但是Expo模块带给我们的惊喜远不止如此!包括但不限于:
给原生视图添加自定义属性
比如,给ExpoKeyboardEventView添加一个属性叫做background,类型当然是Hex Color:
1 | Prop("background") { (view: UIView, color: UIColor) in |
Expo内部给Hex Color自动做了转换,具体可以看文档中的Argument Types。
1 | Prop("background") { view: View, color: Int -> |
插件生命周期监听
1 | /// 插件初始化 |
此外,如果你希望Expo能在RN和原生代码之间无缝传递你自定义的对象,那你可以参照文档中的Records。Expo把原生的类型做了封装,Expo可以帮助你自动读取JS对象的属性。
如果你希望在RN和原生代码中使用通用的枚举类型,那你可以参照文档中的Enums。
如果你希望你的原生代码可以像TypeScript一样使用联合类型1 | 2,那你可以使用Eithers。
如果你实在是难以描述JS对象的内容,那你可以使用JavaScript values,在原生端使用字典的形式处理JS对象。
如果你需要监听程序生命周期,以及iOS的AppDelegate回调方法等,那你可以参照官方的生命周期监听方案。
Config Plugin
Config Plugin让你可以在app.json内配置原生项目的信息。
假设你开发的插件需要权限信息,我们知道,申请权限的时候需要在Info.plist以及AndroidManifest.xml中添加字段,在传统RN以及其他跨平台语言中,我们往往需要手动修改这些字段。借助Config Plugin,你可以自动化的修改工程信息,代码修改Info.plist或者AndroidManifest.xml文件,甚至是其他基础项目文件。
假设我的插件需要一个apiKey字段作为常量参数,那么我可以这样写:
1 | // plugin/src/index.ts |
此时执行npx expo prebuild,主项目的Info.plist以及AndroidManifest.xml分别多了一个字段。
此时你在主项目的app.json提供apiKey的内容:
1 | { |
写入字段后,在原生代码里面需要读取我们插入的字段,如下所示:
1 | import ExpoModulesCore |
1 | package expo.modules.nativeconfiguration |
这么做有什么优势呢?
你的插件现在可以变成纯自动挡了,不需要一半JS配,一半原生项目配了。可谓是非常优雅。
你现在不需要关心原生代码实现了,一切都能在JS代码里面完成。
到目前为止,这个插件本身就算是完成了,剩下的就是把这个插件发布到npm上。命令行执行npm login,然后npm publish即可。一个崭新的轮子就这么诞生了。
- 标题: 关于Expo插件,以及原生能力
- 作者: Aron
- 创建于 : 2025-03-07 20:44:02
- 更新于 : 2025-10-29 15:18:24
- 链接: https://likeso.github.io/2025/03/07/about-expo-modules/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。