从 0 新增一个 `has.echo`:我如何理解小程序容器里的 API 调用链路

从 0 新增一个 has.echo:我如何理解小程序容器里的 API 调用链路

说明:本文是一次脱敏后的学习复盘。文中的 has.echoEchoModulesystem.demo 都是为了讲清楚调用链路而设计的最小示例,不包含公司内部源码、真实业务接口、真实模块路径和敏感实现细节。

最近在学习一个类似"小程序容器"的运行时框架。它的形态和很多小程序框架比较像:业务页面不会直接调用系统能力,而是通过一个全局对象调用框架暴露的 API。

比如我们希望业务侧可以这样写:

js 复制代码
has.echo({
  message: 'hello runtime',
  success(res) {
    console.log('success:', res)
  },
  fail(err) {
    console.log('fail:', err)
  },
  complete() {
    console.log('complete')
  }
})

这个 echo API 的功能很简单:

  1. 业务侧传入一个 message
  2. 底层收到参数后,调用 ArkUI 弹窗展示这段内容;
  3. 底层再把 { message } 返回给业务侧;
  4. 业务侧在 success 回调里拿到结果。

功能虽然简单,但它刚好能帮我走通一条完整链路:

text 复制代码
业务页面 has.echo
  ↓
全局 has 对象
  ↓
逻辑层 echo.js
  ↓
requireAPI('system.demo')
  ↓
核心模块 EchoModule
  ↓
ArkUI 弹窗能力
  ↓
Promise.resolve 返回数据
  ↓
success / complete 回到业务页面

这篇文章就记录一下:如果从 0 新增一个 has.echo,需要改哪些地方,以及为什么只写一个函数是不够的。


一、先从问题开始:为什么只写 echo.js 不够?

刚开始我以为新增一个 API 很简单:

text 复制代码
写一个 echo.js
把它挂到 has 上
页面调用 has.echo

但真正调试后发现,这个理解太简单了。

在这种运行时框架里,一个 API 能不能被业务页面正常调用,至少要经过几层:

text 复制代码
1. 底层能力是否存在
2. 底层模块是否被注册
3. 逻辑层是否能 require 到底层模块
4. 全局 has 是否暴露了这个 API
5. 当前 API 版本是否允许对外发布
6. 异步回调机制是否认识这个 API

任何一层漏掉,最后表现出来的问题都可能很迷惑。

比如:

text 复制代码
has.echo is not a function
requireAPI 找不到模块
底层方法执行了但 success 不触发
页面拿到 undefined
complete 没有回调

所以,这次我用 has.echo 做了一个最小闭环,把这条链路完整走了一遍。


二、第一步:在核心模块实现 EchoModule

新增 API 的第一步,不是先写页面,也不是先写 has.echo,而是先实现真正干活的底层模块。

这里我用一个示例模块:EchoModule

它的职责很简单:

text 复制代码
接收 JS 层传入的 params
  ↓
读取 params.message
  ↓
调用 ArkUI 弹窗展示 message
  ↓
Promise.resolve 返回结果

示意代码如下:

ts 复制代码
export class EchoModule {
  @JsMethod({ callback: true })
  echo(params: Record<string, Object>): Promise<Record<string, Object>> {
    const options = params as Record<string, Object>
    const message = options['message'] as string

    // 这里调用 ArkUI 弹窗能力。
    // 注意:这里是脱敏示例,不贴真实工程实现。
    // showDialog(message)

    return Promise.resolve({
      message
    })
  }
}

这里有两个关键点。

第一个是 @JsMethod

它表示这个方法可以被 JS 层调用。如果底层方法没有通过框架约定的方式暴露出来,逻辑层就算知道模块名,也没有办法正常调用。

第二个是 callback: true

它表示这个方法支持异步回调模式。底层返回的 Promise 结果,最终会被框架包装成业务侧的 success / fail / complete 回调。

也就是说,底层这里返回:

ts 复制代码
return Promise.resolve({
  message
})

业务侧最后可以在 success 里拿到:

js 复制代码
success(res) {
  console.log(res.message)
}

三、第二步:在核心模块初始化时注册底层能力

只写 EchoModule 还不够。

因为框架运行时并不会自动知道这个模块存在。我们还需要在核心模块初始化阶段,把这个模块注册进去。

示意代码如下:

ts 复制代码
addApiModule({
  'system.demo': () => new EchoModule()
})

这一步的意义是告诉运行时:

text 复制代码
现在底层有一个模块叫 system.demo。
当 JS 层调用 requireAPI('system.demo') 时,
可以返回一个 EchoModule 实例。

注册完成后,底层能力可以抽象理解为:

text 复制代码
system.demo.echo

到这里,只是说明"底层有能力了"。

但是业务页面还不能直接写 has.echo,因为中间还缺少逻辑层转发和 API 暴露。


四、第三步:在逻辑层新增 echo.js

底层模块注册完成后,需要在逻辑层新增一个对应的 API 文件。

它的作用是把业务侧的 has.echo 转发到底层的 system.demo.echo

示意代码如下:

js 复制代码
import { requireAPI } from './require-api'

export function echo(params = {}) {
  const demo = requireAPI('system.demo')
  return demo.echo(params)
}

这层看起来很薄,但它非常关键。

因为业务页面调用的是:

js 复制代码
has.echo(...)

它不是直接调用:

js 复制代码
system.demo.echo(...)

所以 echo.js 的职责就是做一次桥接:

text 复制代码
has.echo(params)
  ↓
ui/echo.js
  ↓
requireAPI('system.demo')
  ↓
demo.echo(params)

这里还有一个好处:逻辑层可以统一做参数校验、默认值处理、错误包装、兼容处理,而不是把这些逻辑散落到底层模块里。


五、第四步:在 has 初始化入口导入 echo

创建了 echo.js 之后,还需要在 has 初始化入口把它导入进去。

否则文件虽然存在,但全局 has 对象上并不会有这个方法。

示意代码如下:

js 复制代码
import { echo } from './ui/echo'

const has = {
  echo,
  // other api...
}

完成这一步后,应用初始化时才有机会生成:

js 复制代码
window.has.echo

如果忘了这一步,页面调用时大概率会遇到:

text 复制代码
has.echo is not a function

这类错误说明:底层能力可能已经写好了,但 API 没有真正暴露给业务侧。


六、第五步:把 echo 加入接口白名单

很多运行时框架不会让所有函数都随便暴露出去,而是会维护一个 API 白名单。

所以除了导入 echo,还需要把它加入接口列表。

可以抽象理解为:

js 复制代码
const hasInterfaceList = [
  'getSystemInfo',
  'showToast',
  'echo'
]

这一步的作用是告诉框架:

text 复制代码
echo 是一个合法 API。
初始化全局 has 时,可以把它挂出去。

如果这一步漏了,可能出现一种情况:

text 复制代码
代码文件存在
底层模块也存在
但是框架认为 echo 不是合法 API
所以业务侧无法正常调用

这里我理解到一个点:

text 复制代码
EchoModule 解决的是"底层有没有能力";
hasInterfaceList 解决的是"这个能力允不允许暴露给业务"。

两者不是一回事。


七、第六步:加入当前版本的公开 API 列表

除了接口白名单,框架里通常还会有"版本维度"的 API 发布表。

例如某个版本支持哪些 API,另一个版本支持哪些 API。

新增 echo 时,也需要把它加入当前版本对应的公开 API 列表。

示意结构如下:

js 复制代码
const publishAPI = {
  v1006: [
    'getSystemInfo',
    'showToast',
    'echo'
  ]
}

这一步的意义是:

text 复制代码
不是所有实现过的 API 都会对外发布。
只有加入当前版本的公开 API 列表,业务侧才应该认为它可用。

这个设计其实很常见。

比如框架要做版本兼容、灰度能力、平台差异能力,就不能只看"代码里有没有实现",还要看"当前版本允不允许用"。

所以新增 API 时,要同时关注两个问题:

text 复制代码
这个 API 有没有实现?
这个 API 有没有被当前版本发布?

八、第七步:在异步 API 注册表中注册 system.demo.echo

这是我这次排查里最容易漏、也最关键的一步。

has.echo 是一个异步 API,业务侧使用的是:

js 复制代码
has.echo({
  success(res) {},
  fail(err) {},
  complete() {}
})

这种回调形式通常需要框架的异步 API 管理逻辑参与。

所以还要把它加入异步 API 注册表。

示意代码如下:

js 复制代码
const asyncApis = {
  'system.demo': ['echo']
}

这一步的作用是告诉框架:

text 复制代码
system.demo.echo 是一个异步 API。
调用它时,需要走 success / fail / complete 的回调包装逻辑。

如果忘了这一步,可能会出现一个非常迷惑的问题:

text 复制代码
页面传入的 success 确实是 function;
底层方法也确实执行了;
但是 success 就是不触发。

这个问题从页面看很像"回调丢了"。

但本质不是业务侧没有传回调,而是框架的异步 API 注册表没有认识这个新 API,所以没有正确走回调包装流程。

补上注册后,success 才能正常拿到 Promise.resolve(...) 返回的数据。


九、业务页面最终怎么调用?

前面的步骤全部完成后,应用初始化时会自动生成全局 has 对象。

业务页面就可以直接调用:

js 复制代码
has.echo({
  message: 'hello runtime',
  success(res) {
    console.log('echo success:', res)
  },
  fail(err) {
    console.log('echo fail:', err)
  },
  complete() {
    console.log('echo complete')
  }
})

完整执行流程如下:

text 复制代码
业务页面调用 has.echo
  ↓
进入逻辑层 echo.js
  ↓
echo.js 通过 requireAPI('system.demo') 获取底层模块
  ↓
调用 EchoModule.echo
  ↓
EchoModule 读取 params.message
  ↓
调用 ArkUI 弹窗展示 message
  ↓
Promise.resolve({ message })
  ↓
框架把 Promise 结果包装成 success 回调
  ↓
业务侧 success(res) 拿到返回数据
  ↓
complete 执行

最终业务侧可以拿到类似结果:

js 复制代码
{
  message: 'hello runtime'
}

十、完整顺序总结

has.echo 为例,从 0 新增一个 API,完整顺序可以整理成这样:

text 复制代码
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 中拿到返回结果

把它画成链路图就是:

text 复制代码
核心模块 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) 里可能拿到空值

这也是这次调试最大的收获:

text 复制代码
新增 API 不是改一个函数,而是打通一条链路。

十二、这条链路背后的框架设计

has.echo 这个小 API 反过来看,框架其实做了几层隔离。

第一层是业务层。

业务开发者只关心:

js 复制代码
has.echo({ success, fail, complete })

他们不需要知道底层是 ArkUI、ArkTS,还是其他系统能力。

第二层是逻辑层。

逻辑层负责把 has.echo 转成底层模块调用,例如:

text 复制代码
has.echo → requireAPI('system.demo') → demo.echo

这一层适合做参数处理、兼容处理、错误包装。

第三层是核心能力层。

核心层真正调用系统能力,比如弹窗、设备信息、网络、定位等。

第四层是框架治理层。

包括:

text 复制代码
接口白名单
版本发布表
异步 API 注册表
回调包装机制
模块注册表

这些东西看起来繁琐,但它们解决的是一个框架必须面对的问题:

text 复制代码
哪些能力能用?
哪个版本能用?
调用方式是同步还是异步?
底层模块从哪里找?
结果如何返回给业务侧?

理解这几层之后,再看运行时源码,就不会觉得它是一堆零散文件,而是能看出它的分工。


十三、最终结论

has.echo 的功能很小,但它完整覆盖了新增 API 的核心流程。

它让我理解到:

text 复制代码
一个小程序容器里的 API,并不是页面函数到系统能力的直接调用。
它中间要经过 API 暴露、版本控制、模块注册、逻辑转发、异步回调包装等多层处理。

所以以后新增类似 API 时,可以按照同一套模板:

text 复制代码
底层能力实现
  ↓
底层模块注册
  ↓
逻辑层 API 文件
  ↓
has 初始化导入
  ↓
接口白名单
  ↓
版本发布表
  ↓
异步 API 注册表
  ↓
业务页面调用验证

这条链路跑通之后,再去看更复杂的 API,就不再只是看某一个文件,而是能顺着调用链路一步步定位问题。

这也是我这次新增 has.echo 最大的收获。

相关推荐
leptune2 小时前
Mac 使用 Microsoft Word 批量将 DOCX 转 PDF(保持原排版)
前端
大龄秃头程序员2 小时前
Flutter 长列表 1 万条滑动卡顿治理:Baseline vs Optimized 可跑 Demo
前端
yingyima2 小时前
Git 核心命令速查:掌握底层原理与实战技巧
前端
黄林晴2 小时前
Kuikly 是什么?和KMP有什么关系?
android·前端
HokKeung2 小时前
Git Hooks 和 Husky 怎么选
前端·前端工程化
李明卫杭州2 小时前
Vue 3 响应式三剑客:ref、shallowRef、reactive,你真的用对了吗?
前端
触底反弹2 小时前
🔥 JavaScript this 指向全解析:面试必考的 5 大绑定规则
前端·javascript·面试
Csvn3 小时前
TypeScript 类型守卫的 4 个实战级别:从 `filter(Boolean)` 到自定义类型谓词
前端
骑士雄师3 小时前
langchain:推荐切片策略
服务器·前端·langchain