从 0 新增一个has.echo:我如何理解小程序容器里的 API 调用链路
说明:本文是一次脱敏后的学习复盘。文中的
has.echo、EchoModule、system.demo都是为了讲清楚调用链路而设计的最小示例,不包含公司内部源码、真实业务接口、真实模块路径和敏感实现细节。
最近在学习一个类似“小程序容器”的运行时框架。它的形态和很多小程序框架比较像:业务页面不会直接调用系统能力,而是通过一个全局对象调用框架暴露的 API。
比如我们希望业务侧可以这样写:
has.echo({message:'hello runtime',success(res){console.log('success:',res)},fail(err){console.log('fail:',err)},complete(){console.log('complete')}})这个echoAPI 的功能很简单:
- 业务侧传入一个
message; - 底层收到参数后,调用 ArkUI 弹窗展示这段内容;
- 底层再把
{ message }返回给业务侧; - 业务侧在
success回调里拿到结果。
功能虽然简单,但它刚好能帮我走通一条完整链路:
业务页面 has.echo ↓ 全局 has 对象 ↓ 逻辑层 echo.js ↓ requireAPI('system.demo') ↓ 核心模块 EchoModule ↓ ArkUI 弹窗能力 ↓ Promise.resolve 返回数据 ↓ success / complete 回到业务页面这篇文章就记录一下:如果从 0 新增一个has.echo,需要改哪些地方,以及为什么只写一个函数是不够的。
一、先从问题开始:为什么只写echo.js不够?
刚开始我以为新增一个 API 很简单:
写一个 echo.js 把它挂到 has 上 页面调用 has.echo但真正调试后发现,这个理解太简单了。
在这种运行时框架里,一个 API 能不能被业务页面正常调用,至少要经过几层:
1. 底层能力是否存在 2. 底层模块是否被注册 3. 逻辑层是否能 require 到底层模块 4. 全局 has 是否暴露了这个 API 5. 当前 API 版本是否允许对外发布 6. 异步回调机制是否认识这个 API任何一层漏掉,最后表现出来的问题都可能很迷惑。
比如:
has.echo is not a function requireAPI 找不到模块 底层方法执行了但 success 不触发 页面拿到 undefined complete 没有回调所以,这次我用has.echo做了一个最小闭环,把这条链路完整走了一遍。
二、第一步:在核心模块实现EchoModule
新增 API 的第一步,不是先写页面,也不是先写has.echo,而是先实现真正干活的底层模块。
这里我用一个示例模块:EchoModule。
它的职责很简单:
接收 JS 层传入的 params ↓ 读取 params.message ↓ 调用 ArkUI 弹窗展示 message ↓ Promise.resolve 返回结果示意代码如下:
exportclassEchoModule{@JsMethod({callback:true})echo(params:Record<string,Object>):Promise<Record<string,Object>>{constoptions=paramsasRecord<string,Object>constmessage=options['message']asstring// 这里调用 ArkUI 弹窗能力。// 注意:这里是脱敏示例,不贴真实工程实现。// showDialog(message)returnPromise.resolve({message})}}这里有两个关键点。
第一个是@JsMethod。
它表示这个方法可以被 JS 层调用。如果底层方法没有通过框架约定的方式暴露出来,逻辑层就算知道模块名,也没有办法正常调用。
第二个是callback: true。
它表示这个方法支持异步回调模式。底层返回的 Promise 结果,最终会被框架包装成业务侧的success / fail / complete回调。
也就是说,底层这里返回:
returnPromise.resolve({message})业务侧最后可以在success里拿到:
success(res){console.log(res.message)}三、第二步:在核心模块初始化时注册底层能力
只写EchoModule还不够。
因为框架运行时并不会自动知道这个模块存在。我们还需要在核心模块初始化阶段,把这个模块注册进去。
示意代码如下:
addApiModule({'system.demo':()=>newEchoModule()})这一步的意义是告诉运行时:
现在底层有一个模块叫 system.demo。 当 JS 层调用 requireAPI('system.demo') 时, 可以返回一个 EchoModule 实例。注册完成后,底层能力可以抽象理解为:
system.demo.echo到这里,只是说明“底层有能力了”。
但是业务页面还不能直接写has.echo,因为中间还缺少逻辑层转发和 API 暴露。
四、第三步:在逻辑层新增echo.js
底层模块注册完成后,需要在逻辑层新增一个对应的 API 文件。
它的作用是把业务侧的has.echo转发到底层的system.demo.echo。
示意代码如下:
import{requireAPI}from'./require-api'exportfunctionecho(params={}){constdemo=requireAPI('system.demo')returndemo.echo(params)}这层看起来很薄,但它非常关键。
因为业务页面调用的是:
has.echo(...)它不是直接调用:
system.demo.echo(...)所以echo.js的职责就是做一次桥接:
has.echo(params) ↓ ui/echo.js ↓ requireAPI('system.demo') ↓ demo.echo(params)这里还有一个好处:逻辑层可以统一做参数校验、默认值处理、错误包装、兼容处理,而不是把这些逻辑散落到底层模块里。
五、第四步:在has初始化入口导入echo
创建了echo.js之后,还需要在has初始化入口把它导入进去。
否则文件虽然存在,但全局has对象上并不会有这个方法。
示意代码如下:
import{echo}from'./ui/echo'consthas={echo,// other api...}完成这一步后,应用初始化时才有机会生成:
window.has.echo如果忘了这一步,页面调用时大概率会遇到:
has.echo is not a function这类错误说明:底层能力可能已经写好了,但 API 没有真正暴露给业务侧。
六、第五步:把echo加入接口白名单
很多运行时框架不会让所有函数都随便暴露出去,而是会维护一个 API 白名单。
所以除了导入echo,还需要把它加入接口列表。
可以抽象理解为:
consthasInterfaceList=['getSystemInfo','showToast','echo']这一步的作用是告诉框架:
echo 是一个合法 API。 初始化全局 has 时,可以把它挂出去。如果这一步漏了,可能出现一种情况:
代码文件存在 底层模块也存在 但是框架认为 echo 不是合法 API 所以业务侧无法正常调用这里我理解到一个点:
EchoModule 解决的是“底层有没有能力”; hasInterfaceList 解决的是“这个能力允不允许暴露给业务”。两者不是一回事。
七、第六步:加入当前版本的公开 API 列表
除了接口白名单,框架里通常还会有“版本维度”的 API 发布表。
例如某个版本支持哪些 API,另一个版本支持哪些 API。
新增echo时,也需要把它加入当前版本对应的公开 API 列表。
示意结构如下:
constpublishAPI={v1006:['getSystemInfo','showToast','echo']}这一步的意义是:
不是所有实现过的 API 都会对外发布。 只有加入当前版本的公开 API 列表,业务侧才应该认为它可用。这个设计其实很常见。
比如框架要做版本兼容、灰度能力、平台差异能力,就不能只看“代码里有没有实现”,还要看“当前版本允不允许用”。
所以新增 API 时,要同时关注两个问题:
这个 API 有没有实现? 这个 API 有没有被当前版本发布?八、第七步:在异步 API 注册表中注册system.demo.echo
这是我这次排查里最容易漏、也最关键的一步。
has.echo是一个异步 API,业务侧使用的是:
has.echo({success(res){},fail(err){},complete(){}})这种回调形式通常需要框架的异步 API 管理逻辑参与。
所以还要把它加入异步 API 注册表。
示意代码如下:
constasyncApis={'system.demo':['echo']}这一步的作用是告诉框架:
system.demo.echo 是一个异步 API。 调用它时,需要走 success / fail / complete 的回调包装逻辑。如果忘了这一步,可能会出现一个非常迷惑的问题:
页面传入的 success 确实是 function; 底层方法也确实执行了; 但是 success 就是不触发。这个问题从页面看很像“回调丢了”。
但本质不是业务侧没有传回调,而是框架的异步 API 注册表没有认识这个新 API,所以没有正确走回调包装流程。
补上注册后,success才能正常拿到Promise.resolve(...)返回的数据。
九、业务页面最终怎么调用?
前面的步骤全部完成后,应用初始化时会自动生成全局has对象。
业务页面就可以直接调用:
has.echo({message:'hello runtime',success(res){console.log('echo success:',res)},fail(err){console.log('echo fail:',err)},complete(){console.log('echo complete')}})完整执行流程如下:
业务页面调用 has.echo ↓ 进入逻辑层 echo.js ↓ echo.js 通过 requireAPI('system.demo') 获取底层模块 ↓ 调用 EchoModule.echo ↓ EchoModule 读取 params.message ↓ 调用 ArkUI 弹窗展示 message ↓ Promise.resolve({ message }) ↓ 框架把 Promise 结果包装成 success 回调 ↓ 业务侧 success(res) 拿到返回数据 ↓ complete 执行最终业务侧可以拿到类似结果:
{message:'hello runtime'}十、完整顺序总结
以has.echo为例,从 0 新增一个 API,完整顺序可以整理成这样:
1. 在核心模块新增 EchoModule 2. 在 EchoModule 中使用 @JsMethod({ callback: true }) 暴露 echo 方法 3. echo 方法接收 params,读取 message 4. echo 方法调用 ArkUI 弹窗能力 5. echo 方法通过 Promise.resolve({ message }) 返回数据 6. 在核心模块初始化逻辑中注册 system.demo 7. 在逻辑层新增 echo.js 8. echo.js 中通过 requireAPI('system.demo') 调用底层能力 9. 在 has 初始化入口中导入 echo 10. 在接口白名单中加入 echo 11. 在当前版本的公开 API 列表中加入 echo 12. 在异步 API 注册表中加入 system.demo: ['echo'] 13. 应用初始化后生成全局 has.echo 14. 业务页面调用 has.echo,并在 success 中拿到返回结果把它画成链路图就是:
核心模块 EchoModule ↓ 注册到底层模块表 system.demo ↓ 被逻辑层 requireAPI 获取 ui/echo.js ↓ 被 has 初始化导入 has.echo ↓ 被接口白名单和版本发布表允许 业务页面可调用 ↓ 被异步 API 表识别 success / fail / complete 正常回调十一、每一层漏掉会发生什么?
新增 API 时,最好不要只看“我写了没有”,而要看“每一层有没有连上”。
可以用下面这张表自查:
| 漏掉的位置 | 可能现象 |
|---|---|
| 底层模块没写 | 逻辑层调用到底层时报错 |
| 模块没有注册 | requireAPI('system.demo')找不到模块 |
逻辑层echo.js没写 | has.echo没有转发入口 |
has初始化没导入 | has.echo is not a function |
| 接口白名单没加 | 框架不认为它是合法 API |
| 版本发布表没加 | 当前版本不对外暴露该 API |
| 异步 API 表没加 | success / complete可能不触发 |
| Promise 没返回数据 | success(res)里可能拿到空值 |
这也是这次调试最大的收获:
新增 API 不是改一个函数,而是打通一条链路。十二、这条链路背后的框架设计
从has.echo这个小 API 反过来看,框架其实做了几层隔离。
第一层是业务层。
业务开发者只关心:
has.echo({success,fail,complete})他们不需要知道底层是 ArkUI、ArkTS,还是其他系统能力。
第二层是逻辑层。
逻辑层负责把has.echo转成底层模块调用,例如:
has.echo → requireAPI('system.demo') → demo.echo这一层适合做参数处理、兼容处理、错误包装。
第三层是核心能力层。
核心层真正调用系统能力,比如弹窗、设备信息、网络、定位等。
第四层是框架治理层。
包括:
接口白名单 版本发布表 异步 API 注册表 回调包装机制 模块注册表这些东西看起来繁琐,但它们解决的是一个框架必须面对的问题:
哪些能力能用? 哪个版本能用? 调用方式是同步还是异步? 底层模块从哪里找? 结果如何返回给业务侧?理解这几层之后,再看运行时源码,就不会觉得它是一堆零散文件,而是能看出它的分工。
十三、最终结论
has.echo的功能很小,但它完整覆盖了新增 API 的核心流程。
它让我理解到:
一个小程序容器里的 API,并不是页面函数到系统能力的直接调用。 它中间要经过 API 暴露、版本控制、模块注册、逻辑转发、异步回调包装等多层处理。所以以后新增类似 API 时,可以按照同一套模板:
底层能力实现 ↓ 底层模块注册 ↓ 逻辑层 API 文件 ↓ has 初始化导入 ↓ 接口白名单 ↓ 版本发布表 ↓ 异步 API 注册表 ↓ 业务页面调用验证这条链路跑通之后,再去看更复杂的 API,就不再只是看某一个文件,而是能顺着调用链路一步步定位问题。
这也是我这次新增has.echo最大的收获。