从 has.echo 到异步 API 注册表:一次 ASCF API 回调不触发的排查复盘
这篇文章记录一次新增
has.echoAPI 的真实排查过程。为了避免涉及内部实现细节,文中的目录和配置名称做了适当泛化,重点放在调用链路、排查方法和框架设计思路上。
最近我在学习 ASCF Runtime 的 API 调用链路。为了不一上来就陷入复杂业务,我选了一个最小 API:has.echo。
这里放一张流程图片

目标很简单:业务页面传一个 message,底层模块收到后返回结果,页面在 success 里更新文本。
期望用法是这样:
js
has.echo({
message: '我要去郑州',
success(res) {
console.log(res)
},
fail(err) {
console.log(err)
}
})
但真正做下来,我发现新增一个框架 API 并不是"写一个函数"这么简单。它至少涉及:
text
业务页面
↓
全局 has 对象
↓
逻辑层 API 暴露
↓
requireAPI
↓
底层 moduleMap
↓
ArkTS 能力模块
↓
异步 API 注册表
↓
success / fail / complete 回调
这次最有价值的地方,不是最后跑通了 has.echo,而是完整经历了一个错误思路被推翻、再找到正确注册闭环的过程。
一、最开始的问题:has.echo 不存在
一开始页面调用:
js
has.echo({
message: '你好',
success(res) {
console.log(res)
}
})
直接报:
text
TypeError: has.echo is not a function
这个错误说明两件事:
text
has 全局对象存在
但 echo 没有挂载到 has 上
因为 has.showToast 是可以正常调用的,所以问题不在页面事件,也不在全局对象,而在 API 暴露层。
后来确认,新增 ui/echo.js 之后,还必须把它加入最终导出的接口列表:
js
import { echo } from './ui/echo'
hasInterfaceList.echo = echo
或者对象写法:
js
const hasInterfaceList = {
showToast,
request,
echo
}
这里第一个经验是:
只新增文件、只 import 不够,必须进入最终导出的 has API 列表。
解决后,has.echo 可以被业务页面识别。
二、先证明页面到 ui/echo.js 是通的
在继续打到底层之前,我先把 ui/echo.js 写成纯 JS 版本:
js
export function echo(params = {}) {
const result = {
message: '先证明 ui/echo.js 已经执行',
from: 'ui/echo.js',
input: params.message
}
if (typeof params.success === 'function') {
params.success(result)
}
if (typeof params.complete === 'function') {
params.complete(result)
}
return result
}
页面成功更新文案。
这一步证明:
text
Page
↓
has.echo
↓
hasInterfaceList.echo
↓
ui/echo.js
↓
params.success
↓
页面 setData
这一段链路是通的。
也就是说:页面事件、has.echo 挂载、params.success、页面 setData 都没有问题。
三、再证明 ui/echo.js 能打到底层模块
接下来把 ui/echo.js 改成真实底层调用:
js
const demo = requireAPI('system.demo')
export function echo(params = {}) {
return demo.echo(params)
}
底层模块大概是这样:
ts
export interface EchoOption {
message: string
}
export interface EchoResult {
message: string
from: string
time: number
}
export class DemoModule {
@JsMethod({
alias: 'echo',
callback: true,
params: [
{
name: 'message',
type: 'string',
required: true
}
]
})
public echo(option: EchoOption): Promise<EchoResult> {
return new Promise<EchoResult>((resolve) => {
resolve({
message: option.message,
from: 'DemoModule',
time: new Date().getTime()
})
})
}
}
同时注册底层模块:
ts
moduleMap.set('system.demo', () => new DemoModule())
为了确认底层方法真的执行,我在 DemoModule.echo 里临时加了一个 Toast。
结果 Toast 成功弹出。
这说明:
text
Page
↓
has.echo
↓
ui/echo.js
↓
requireAPI('system.demo')
↓
DemoModule.echo
主调用链路已经打到底层了。
但是新的问题来了:
text
底层 Toast 能弹
Promise 也 resolve 了
但页面 success 没触发
文本没有变化
四、第一个错误思路:是不是 resolve 返回格式不对?
最开始我怀疑是返回格式不对。
比如我返回的是:
ts
resolve({
message: option.message,
from: 'DemoModule',
time: new Date().getTime()
})
但框架可能要求:
ts
resolve({
errMsg: 'echo:ok',
data: {
message: option.message
}
})
于是我去看已有 API,发现很多成功场景只是:
ts
resolve()
不传数据。
所以我也改成:
ts
return new Promise<void>((resolve) => {
resolve()
})
结果仍然是:
text
Toast 能弹
success 不触发
这说明问题不是返回数据格式。
五、第二个错误思路:是不是 return 影响异步 API?
我又怀疑 ui/echo.js 里的 return。
原来是:
js
export function echo(params = {}) {
return demo.echo(params)
}
我尝试改成:
js
export function echo(params = {}) {
demo.echo(params)
}
结果还是一样:
text
Toast 能弹
success 不触发
所以 return 写不写不是关键。
六、关键日志:success 是函数,但 requireAPI 返回 undefined
这时我在 ui/echo.js 加了日志:
js
export function echo(params = {}) {
console.log('[has.echo] start')
console.log('[has.echo] message =', params.message)
console.log('[has.echo] success type =', typeof params.success)
const demo = requireAPI('system.demo')
const ret = demo.echo(params)
console.log('[has.echo] ret =', ret)
console.log('[has.echo] ret.then type =', ret && typeof ret.then)
return ret
}
日志结果:
text
[has.echo] start
[has.echo] message = 我要去郑州
[has.echo] success type = function
[has.echo] ret = undefined
[has.echo] ret.then type = undefined
这个日志直接改变了排查方向。
它说明:
text
params.success 没有丢
底层 DemoModule.echo 能执行
但 requireAPI 调用没有返回 JS Promise
所以这类写法不成立:
js
demo.echo(params).then(res => {
params.success(res)
})
因为 demo.echo(params) 的返回值就是 undefined。
换句话说,底层 ArkTS 方法虽然写了 Promise,但它并不会以普通 JS Promise 的形式返回到 ui/echo.js。
问题已经不是"怎么 then",而是"框架到底在哪里把 native resolve 转成 success"。
七、临时手动 success 可以成功,但这不是最终答案
为了确认业务侧回调本身没问题,我临时写了一个手动回调版本:
js
export function echo(params = {}) {
try {
const demo = requireAPI('system.demo')
demo.echo({
message: params.message
})
const res = {
errMsg: 'echo:ok',
message: params.message,
from: 'ui/echo.js'
}
if (typeof params.success === 'function') {
params.success(res)
}
if (typeof params.complete === 'function') {
params.complete(res)
}
} catch (err) {
const error = {
errMsg: 'echo:fail',
error: String(err)
}
if (typeof params.fail === 'function') {
params.fail(error)
}
if (typeof params.complete === 'function') {
params.complete(error)
}
}
}
页面可以变化。
但这只能证明:
text
ui/echo.js 可以主动调用 params.success
不能证明:
text
DemoModule.echo 的 resolve 回到了页面 success
所以这只是临时兜底,不是框架标准 API 的最终链路。
八、正确方向:对比已有异步 API
继续改 resolve 和 return 已经没意义了。
正确方向是:找一个已有的、能触发 success 的异步 API,看它除了接口暴露、moduleMap 注册之外,还做了什么。
我优先看了类似 getSystemInfo 这种 API,因为它通常需要返回数据,更适合作为对照。
最后发现框架里还有一个异步 API 注册表,类似:
js
const asyncApis = {
'system.prompt': ['showToast'],
'system.device': ['getSystemInfo']
}
这个表的作用可以理解为:
text
声明哪些 module.method 是异步 API
这些 API 的 resolve/reject 需要被框架映射到 success/fail/complete
而我的 system.demo.echo 没有注册进去。
于是补上:
js
const asyncApis = {
'system.prompt': ['showToast'],
'system.device': ['getSystemInfo'],
// 新增
'system.demo': ['echo']
}
重新构建后,页面 success 终于触发了。
九、为什么 success 触发后 res 是 undefined?
刚跑通时,页面确实触发了 success,但拿到的 res 是 undefined。
这个结果是合理的。
因为底层当时写的是:
ts
return new Promise<void>((resolve) => {
resolve()
})
既然 resolve() 没有传数据,那么页面 success(res) 中的 res 就是 undefined。
这一步真正证明的是:
text
DemoModule.echo resolve()
↓
框架异步 API 包装层
↓
业务侧 success
已经通了。
如果要返回数据,再改成:
ts
return new Promise<EchoResult>((resolve) => {
resolve({
errMsg: 'echo:ok',
message: option.message,
from: 'DemoModule',
time: new Date().getTime()
})
})
十、最终完整链路
跑通之后,这个 API 的完整链路应该是:
text
业务页面 Page
↓
has.echo({ message, success, fail, complete })
↓
hasInterfaceList.echo
↓
ui/echo.js
↓
requireAPI('system.demo').echo(params)
↓
异步 API 注册表命中 system.demo.echo
↓
框架识别为异步 callback API
↓
moduleMap.get('system.demo')
↓
new DemoModule()
↓
@JsMethod(alias: 'echo', callback: true)
↓
DemoModule.echo(option)
↓
Promise resolve / reject
↓
框架异步回调派发
↓
params.success / params.fail / params.complete
↓
页面 setData
这才是一个真正完整的 API 闭环。
十一、这件事背后的框架设计思想
这次排查让我意识到,框架里的 API 不是一个函数,而是一套协议。
1. hasInterfaceList:解决入口暴露
它决定业务侧能不能写:
js
has.echo(...)
如果这里没配,就会出现:
text
has.echo is not a function
2. ui/echo.js:解决逻辑层包装
它决定业务侧 API 如何映射到底层模块:
js
requireAPI('system.demo').echo(params)
3. moduleMap:解决模块查找
它决定:
text
system.demo
↓
DemoModule
如果这里只通,底层可以执行,但不代表回调能回来。
4. @JsMethod:解决底层方法暴露
它声明底层方法名、别名、参数规则、callback 类型等。
5. asyncApis:解决异步语义
这是这次真正漏掉的地方。
它告诉框架:
text
这个 API 是异步 API
它的 resolve/reject 需要映射到 success/fail/complete
所以新增标准异步 API,不是只写方法,而是要完成:
text
入口暴露
逻辑层包装
模块注册
方法暴露
异步语义注册
这几层缺一层,都会出现不同的问题。
十二、一开始的思路 vs 最终正确思路
一开始我的理解是:
text
写 ui/echo.js
写 DemoModule.echo
moduleMap 注册
Promise resolve
页面 success 应该自动触发
这个思路的问题是:默认把底层 Promise resolve 和业务侧 success 画上了等号。
但实际排查后发现:
text
DemoModule 能执行,只说明调用链通了
resolve() 能执行,不代表 success 会触发
requireAPI 返回 undefined,说明 JS 层拿不到普通 Promise
success 是函数,只说明回调传进来了,不代表框架会调用它
最终正确思路是:
text
新增标准异步 API
= has 暴露
+ ui 包装
+ moduleMap 注册
+ @JsMethod 暴露
+ async API 注册表
补齐最后的异步 API 注册后,success 才真正触发。
十三、以后排查类似问题的顺序
这次之后,我会按这个顺序排查新增 API:
text
1. has.xxx 是否存在?
不存在:查接口导出 / hasInterfaceList
2. ui/xxx.js 是否执行?
不执行:查 import、export、构建产物
3. requireAPI('module.key') 是否能找到模块?
找不到:查 module key 和 moduleMap
4. 底层 @JsMethod 是否执行?
不执行:查 alias、params、模块注册
5. success / fail / complete 是否触发?
不触发:查 asyncApis / 异步 API 注册表 / callback 注册
6. success(res) 数据是否正确?
不正确:查 resolve 返回结构和序列化规则
这个顺序的好处是:每一步都有明确现象,不会在源码里盲猜。
总结
这次 has.echo 跑通后,最核心的结论是:
text
success 不触发的根因:
不是 return 问题,
不是 resolve() 问题,
不是 params.success 丢失,
而是没有把 system.demo.echo 注册进异步 API 表。
补齐之后,链路变成:
text
DemoModule.echo resolve()
↓
框架异步回调派发
↓
业务侧 success
↓
页面 setData
这次排查其实很像一次小型框架设计训练:
API 不是一个函数,而是一组约定。入口、分发、模块、方法、异步语义、回调派发,全部对齐,才算一个真正可用的框架能力。
这也是我这次调试最大的收获。
建议标签
text
HarmonyOS、ArkTS、JSBridge、框架设计、问题排查、API 设计