diff --git a/.agents/skills/mpx2rn/references/rn-api-reference.md b/.agents/skills/mpx2rn/references/rn-api-reference.md
index a94a2b62cd..558a9b7553 100644
--- a/.agents/skills/mpx2rn/references/rn-api-reference.md
+++ b/.agents/skills/mpx2rn/references/rn-api-reference.md
@@ -1106,9 +1106,10 @@ mpx.use(apiProxy, {
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `url` | `string` | 是 | `wss://` 或 `ws://` 地址。 |
+| `header` | `Object` | 否 | 请求头,会传递给 RN 原生 **WebSocket**。 |
| `protocols` | `string[]` | 否 | 子协议列表。 |
-> 微信同名 API 的 `header`、`timeout`、`tcpNoDelay`、`perMessageDeflate` 等扩展参数**当前 RN 中暂不支持**。
+> 微信同名 API 的 `timeout`、`tcpNoDelay`、`perMessageDeflate` 等扩展参数**当前 RN 中暂不支持**。
#### 返回值
@@ -1576,13 +1577,13 @@ mpx.use(apiProxy, {
#### 说明
-取消键盘高度监听:仅移除与注册时**同一引用**的 **`callback`**;**不传引用不会移除其它监听**。当监听列表为空时会移除键盘显示/隐藏相关订阅。
+取消键盘高度监听:传入与注册时**同一引用**的 **`callback`** 时移除对应监听;不传回调时移除全部监听。当监听列表为空时会移除键盘显示/隐藏相关订阅。
#### 入参
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
-| `callback` | `function` | 是 | 与 **`onKeyboardHeightChange`** 注册时为同一函数引用。 |
+| `callback` | `function` | 否 | 与 **`onKeyboardHeightChange`** 注册时为同一函数引用;不传时移除全部监听。 |
#### 返回值
@@ -1730,7 +1731,7 @@ mpx.config.rnConfig.bluetoothPermission = () => {
| `stopWifi` | 结束 Wi‑Fi 模块并清空列表监听;RN iOS 目标直接走 `fail`。 | RN Android 成功时为 `{ errMsg: 'stopWifi:ok' }`。 |
| `getWifiList` | `startWifi` 就绪后扫描热点,通过 `onGetWifiList` 交付列表;RN iOS 目标直接走 `fail`。 | `success`:`{ errMsg: 'getWifiList:ok', errno: 0, errCode: 0 }`;热点列表不在此载荷中。 |
| `onGetWifiList` | 注册接收热点列表的回调。 | `{ wifiList }`;每项含 `SSID`、`BSSID`、`frequency`、`signalStrength`。 |
-| `offGetWifiList` | 移除热点列表回调。 | 传入与注册时同一 `callback`;当前实现不支持省略参数清空全部。 |
+| `offGetWifiList` | 移除热点列表回调。 | 传入与注册时同一 `callback`;省略参数清空全部。 |
| `getConnectedWifi` | 读取当前已连接 Wi‑Fi;支持 `partialInfo`,且需先 `startWifi` 成功就绪。 | `{ errMsg: 'getConnectedWifi:ok', wifi }`;`wifi` 含 `SSID`、`BSSID`、`signalStrength`、`frequency`。 |
Wi-Fi API 的成功 `errMsg` 与微信文档保持一致,均以 `:ok` 结尾。
diff --git a/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md b/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md
index 194e26fc9c..3cba19b2a1 100644
--- a/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md
+++ b/docs-vitepress/api-proxy/media/audio/createInnerAudioContext.md
@@ -18,3 +18,5 @@
### 注意事项 {#notes}
InnerAudioContext 音频资源不会自动释放,因此如果不再需要使用音频,请及时调用 InnerAudioContext.destroy() 释放资源,避免内存泄漏。
+
+Web 端的 `on*` 事件允许注册多个监听函数。调用对应的 `off*` 方法并传入注册时的同一函数引用,只移除该监听;不传参数时移除该事件的全部监听。
diff --git a/docs-vitepress/api-proxy/network/websocket/connectSocket.md b/docs-vitepress/api-proxy/network/websocket/connectSocket.md
index 6ad83be228..ae41435ff8 100644
--- a/docs-vitepress/api-proxy/network/websocket/connectSocket.md
+++ b/docs-vitepress/api-proxy/network/websocket/connectSocket.md
@@ -2,7 +2,7 @@
创建一个 WebSocket 连接。
-支持情况: 微信、支付宝
+支持情况: 微信、支付宝、RN、web
[参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/network/upload/wx.uploadFile.html)
@@ -13,7 +13,7 @@
| 属性 | 类型 | 默认值 | 必填 | 说明 | 最低版本 | 支付宝 | RN | web |
| ------------------- | -------------- | ------ | ---- | -------------------------------------------- | -------- | ------ | ---------- | --- |
| url | string | | 是 | 开发者服务器 wss 接口地址 | | ✓ | ✓ | ✓ |
-| header | Object | | 否 | HTTP Header,Header 中不能设置 Referer | | ✓ | ✗ | ✗ |
+| header | Object | | 否 | HTTP Header,Header 中不能设置 Referer | | ✓ | ✓ | ✗ |
| protocols | Array.<string> | | 否 | 子协议数组 | 1.4.0 | ✓ | ✓ | ✓ |
| tcpNoDelay | boolean | false | 否 | 建立 TCP 连接的时候的 TCP_NODELAY 设置 | 2.4.0 | ✗ | ✗ | ✗ |
| perMessageDeflate | boolean | false | 否 | 是否开启压缩扩展 | 2.8.0 | ✗ | ✗ | ✗ |
diff --git a/docs-vitepress/api-proxy/storage/clearStorageSync.md b/docs-vitepress/api-proxy/storage/clearStorageSync.md
index 89a850db13..e7942e61ff 100644
--- a/docs-vitepress/api-proxy/storage/clearStorageSync.md
+++ b/docs-vitepress/api-proxy/storage/clearStorageSync.md
@@ -4,6 +4,8 @@
支持情况: 微信、支付宝、web
+> RN 的存储实现基于异步的 AsyncStorage,不支持此同步 API,请使用 `mpx.clearStorage`。
+
[参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/storage/wx.clearStorageSync.html)
diff --git a/docs-vitepress/api-proxy/storage/removeStorageSync.md b/docs-vitepress/api-proxy/storage/removeStorageSync.md
index 3649d552c0..737605a11d 100644
--- a/docs-vitepress/api-proxy/storage/removeStorageSync.md
+++ b/docs-vitepress/api-proxy/storage/removeStorageSync.md
@@ -4,6 +4,8 @@
支持情况: 微信、支付宝、web
+> RN 的存储实现基于异步的 AsyncStorage,不支持此同步 API,请使用 `mpx.removeStorage`。
+
[参考文档](https://developers.weixin.qq.com/miniprogram/dev/api/storage/wx.removeStorageSync.html)
diff --git a/packages/api-proxy/__tests__/ali/set-navigation-bar.spec.js b/packages/api-proxy/__tests__/ali/set-navigation-bar.spec.js
new file mode 100644
index 0000000000..b488ba108a
--- /dev/null
+++ b/packages/api-proxy/__tests__/ali/set-navigation-bar.spec.js
@@ -0,0 +1,30 @@
+import { ENV_OBJ } from '../../src/common/js'
+import { setNavigationBarTitle } from '../../src/platform/api/set-navigation-bar/index.ali'
+
+jest.mock('../../src/common/js/index', () => {
+ const actual = jest.requireActual('../../src/common/js/index')
+ return Object.assign({}, actual, {
+ ENV_OBJ: {
+ canIUse: jest.fn(() => true),
+ setNavigationBarTitle: jest.fn()
+ }
+ })
+})
+
+describe('Ali setNavigationBarTitle', () => {
+ test('should normalize the success errMsg', () => {
+ const success = jest.fn()
+ ENV_OBJ.setNavigationBarTitle.mockImplementation(({ success }) => {
+ success({})
+ })
+
+ setNavigationBarTitle({
+ title: 'title',
+ success
+ })
+
+ expect(success).toHaveBeenCalledWith({
+ errMsg: 'setNavigationBarTitle:ok'
+ })
+ })
+})
diff --git a/packages/api-proxy/__tests__/rn/ble-connection.spec.js b/packages/api-proxy/__tests__/rn/ble-connection.spec.js
index ee8eaa998f..b3fa9e319e 100644
--- a/packages/api-proxy/__tests__/rn/ble-connection.spec.js
+++ b/packages/api-proxy/__tests__/rn/ble-connection.spec.js
@@ -109,4 +109,18 @@ describe('RN BLE connection APIs', () => {
}))
expect(BleManager.requestMTU).not.toHaveBeenCalled()
})
+
+ test.each([false, 0, ''])('generic parameter check should reject invalid value %p', (value) => {
+ const fail = jest.fn()
+ const complete = jest.fn()
+
+ getBLEDeviceRSSI({ deviceId: value, fail, complete })
+
+ expect(fail).toHaveBeenCalledWith({
+ errMsg: 'getBLEDeviceRSSI:fail parameter error',
+ errno: 1509000
+ })
+ expect(complete).toHaveBeenCalled()
+ expect(BleManager.readRSSI).not.toHaveBeenCalled()
+ })
})
diff --git a/packages/api-proxy/__tests__/rn/camera.spec.js b/packages/api-proxy/__tests__/rn/camera.spec.js
new file mode 100644
index 0000000000..0874ade9d4
--- /dev/null
+++ b/packages/api-proxy/__tests__/rn/camera.spec.js
@@ -0,0 +1,32 @@
+import CameraContext from '../../src/platform/api/camera/rnCamera'
+
+describe('RN CameraContext', () => {
+ test('setZoom should invoke fail only when the native method throws', () => {
+ const cameraContext = new CameraContext()
+ const success = jest.fn()
+ const fail = jest.fn()
+ const complete = jest.fn()
+ cameraContext.camera = {
+ setZoom: jest.fn(() => {
+ throw new Error('set zoom failed')
+ })
+ }
+
+ cameraContext.setZoom({
+ zoom: 2,
+ success,
+ fail,
+ complete
+ })
+
+ expect(cameraContext.camera.setZoom).toHaveBeenCalledWith(2)
+ expect(success).not.toHaveBeenCalled()
+ expect(fail).toHaveBeenCalledWith({
+ errMsg: 'setZoom:fail set zoom failed'
+ })
+ expect(complete).toHaveBeenCalledTimes(1)
+ expect(complete).toHaveBeenCalledWith({
+ errMsg: 'setZoom:fail set zoom failed'
+ })
+ })
+})
diff --git a/packages/api-proxy/__tests__/rn/keyboard.spec.js b/packages/api-proxy/__tests__/rn/keyboard.spec.js
new file mode 100644
index 0000000000..f0a951e209
--- /dev/null
+++ b/packages/api-proxy/__tests__/rn/keyboard.spec.js
@@ -0,0 +1,92 @@
+const mockSubscriptions = []
+const mockAddListener = jest.fn(eventName => {
+ // RN 新版 addListener 返回订阅对象,业务代码只能移除自己持有的订阅。
+ const subscription = {
+ eventName,
+ remove: jest.fn()
+ }
+ mockSubscriptions.push(subscription)
+ return subscription
+})
+const mockRemoveAllListeners = jest.fn()
+const mockDismiss = jest.fn()
+
+jest.mock('react-native', () => ({
+ Keyboard: {
+ addListener: mockAddListener,
+ removeAllListeners: mockRemoveAllListeners,
+ dismiss: mockDismiss
+ }
+}), { virtual: true })
+
+const {
+ onKeyboardHeightChange,
+ offKeyboardHeightChange,
+ hideKeyboard
+} = require('../../src/platform/api/keyboard/index.ios')
+
+describe('RN keyboard', () => {
+ beforeEach(() => {
+ offKeyboardHeightChange()
+ mockSubscriptions.length = 0
+ mockAddListener.mockClear()
+ mockRemoveAllListeners.mockClear()
+ mockDismiss.mockReset()
+ })
+
+ it('should only remove owned subscriptions when called without callback', () => {
+ onKeyboardHeightChange(jest.fn())
+
+ offKeyboardHeightChange()
+
+ // 不传回调会清空业务回调,但不能调用 removeAllListeners 误删外部监听。
+ expect(mockSubscriptions).toHaveLength(2)
+ mockSubscriptions.forEach(subscription => {
+ expect(subscription.remove).toHaveBeenCalledTimes(1)
+ })
+ expect(mockRemoveAllListeners).not.toHaveBeenCalled()
+ })
+
+ it('should only remove owned subscriptions when the last callback is removed', () => {
+ const callback = jest.fn()
+ onKeyboardHeightChange(callback)
+
+ offKeyboardHeightChange(callback)
+
+ // 最后一个业务回调移除后,键盘显示和隐藏两个底层订阅都应释放。
+ mockSubscriptions.forEach(subscription => {
+ expect(subscription.remove).toHaveBeenCalledTimes(1)
+ })
+ expect(mockRemoveAllListeners).not.toHaveBeenCalled()
+ })
+
+ it('should subscribe again after all callbacks are removed', () => {
+ const callback = jest.fn()
+ onKeyboardHeightChange(callback)
+ offKeyboardHeightChange(callback)
+
+ onKeyboardHeightChange(jest.fn())
+
+ // 首轮订阅释放后再次注册,需要重新创建两个底层订阅。
+ expect(mockAddListener).toHaveBeenCalledTimes(4)
+ })
+
+ it('should include the API name in the hideKeyboard fail errMsg', () => {
+ const fail = jest.fn()
+ const complete = jest.fn()
+ mockDismiss.mockImplementation(() => {
+ throw new Error('dismiss failed')
+ })
+
+ hideKeyboard({
+ fail,
+ complete
+ })
+
+ const result = {
+ errMsg: 'hideKeyboard:fail dismiss failed'
+ }
+ expect(fail).toHaveBeenCalledWith(result)
+ expect(complete).toHaveBeenCalledWith(result)
+ })
+})
diff --git a/packages/api-proxy/__tests__/rn/network.spec.js b/packages/api-proxy/__tests__/rn/network.spec.js
new file mode 100644
index 0000000000..bb24a5577c
--- /dev/null
+++ b/packages/api-proxy/__tests__/rn/network.spec.js
@@ -0,0 +1,85 @@
+const mockUnsubscribe = jest.fn()
+const mockAddEventListener = jest.fn(() => mockUnsubscribe)
+
+jest.mock('@react-native-community/netinfo', () => ({
+ __esModule: true,
+ default: {
+ addEventListener: mockAddEventListener,
+ fetch: jest.fn()
+ },
+ NetInfoStateType: {
+ cellular: 'cellular',
+ none: 'none',
+ wifi: 'wifi'
+ }
+}), { virtual: true })
+
+jest.mock('../../src/common/js', () => ({
+ successHandle: jest.fn(),
+ failHandle: jest.fn(),
+ defineUnsupportedProps: jest.fn()
+}))
+
+const {
+ getNetworkType,
+ offNetworkStatusChange,
+ onNetworkStatusChange
+} = require('../../src/platform/api/device/network/rnNetwork')
+const NetInfo = require('@react-native-community/netinfo').default
+const { failHandle } = require('../../src/common/js')
+
+describe('RN network events', () => {
+ beforeEach(() => {
+ offNetworkStatusChange()
+ mockAddEventListener.mockClear()
+ mockUnsubscribe.mockClear()
+ NetInfo.fetch.mockReset()
+ failHandle.mockClear()
+ })
+
+ test('should clear callbacks and native subscription when callback is null', () => {
+ onNetworkStatusChange(jest.fn())
+
+ // null 与 undefined 含义一致,都应清空回调并释放 NetInfo 底层订阅。
+ offNetworkStatusChange(null)
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
+ })
+
+ test('should release native subscription after the last callback is removed', () => {
+ const callbackA = jest.fn()
+ const callbackB = jest.fn()
+ onNetworkStatusChange(callbackA)
+ onNetworkStatusChange(callbackB)
+
+ offNetworkStatusChange(callbackA)
+ expect(mockUnsubscribe).not.toHaveBeenCalled()
+
+ offNetworkStatusChange(callbackB)
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
+ })
+
+ test('should subscribe again after all callbacks are removed', () => {
+ onNetworkStatusChange(jest.fn())
+ offNetworkStatusChange()
+
+ // 底层订阅释放后再次调用 on,需要重新向 NetInfo 注册。
+ onNetworkStatusChange(jest.fn())
+
+ expect(mockAddEventListener).toHaveBeenCalledTimes(2)
+ })
+
+ test('getNetworkType should include API name when fetching fails', async () => {
+ const fail = jest.fn()
+ const complete = jest.fn()
+ NetInfo.fetch.mockRejectedValue(new Error('fetch failed'))
+
+ getNetworkType({ fail, complete })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(failHandle).toHaveBeenCalledWith({
+ errMsg: 'getNetworkType:fail fetch failed'
+ }, fail, complete)
+ })
+})
diff --git a/packages/api-proxy/__tests__/rn/socket.spec.js b/packages/api-proxy/__tests__/rn/socket.spec.js
new file mode 100644
index 0000000000..3a15119e20
--- /dev/null
+++ b/packages/api-proxy/__tests__/rn/socket.spec.js
@@ -0,0 +1,53 @@
+import { connectSocket } from '../../src/platform/api/socket/index.ios'
+import { connectSocket as connectSocketWeb } from '../../src/platform/api/socket/index.web'
+
+class MockWebSocket {
+ constructor (url, protocols, options) {
+ this.url = url
+ this.protocols = protocols
+ this.options = options
+ this.readyState = 0
+ this.CONNECTING = 0
+ this.OPEN = 1
+ this.CLOSING = 2
+ this.CLOSED = 3
+ this.close = jest.fn()
+ }
+}
+
+describe('RN connectSocket', () => {
+ beforeEach(() => {
+ global.WebSocket = jest.fn((...args) => new MockWebSocket(...args))
+ })
+
+ it('should forward header and protocols to WebSocket', () => {
+ const header = {
+ Authorization: 'token'
+ }
+
+ connectSocket({
+ url: 'wss://example.com',
+ protocols: ['chat'],
+ header
+ })
+
+ expect(global.WebSocket).toHaveBeenCalledWith(
+ 'wss://example.com',
+ ['chat'],
+ {
+ headers: header
+ }
+ )
+ })
+
+ it('should not forward header options on web', () => {
+ connectSocketWeb({
+ url: 'wss://example.com',
+ header: {
+ Authorization: 'token'
+ }
+ })
+
+ expect(global.WebSocket).toHaveBeenCalledWith('wss://example.com')
+ })
+})
diff --git a/packages/api-proxy/__tests__/rn/storage.spec.js b/packages/api-proxy/__tests__/rn/storage.spec.js
index e4eb958fdb..5580bdfb2d 100644
--- a/packages/api-proxy/__tests__/rn/storage.spec.js
+++ b/packages/api-proxy/__tests__/rn/storage.spec.js
@@ -1,7 +1,8 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
-import { removeStorageSync, clearStorageSync } from '../../src/platform/api/storage/rnStorage'
+import { getStorageInfo, removeStorageSync, clearStorageSync } from '../../src/platform/api/storage/rnStorage'
jest.mock('@react-native-async-storage/async-storage', () => ({
+ getAllKeys: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn()
}), { virtual: true })
@@ -28,4 +29,22 @@ describe('RN storage APIs', () => {
expect(AsyncStorage.removeItem).not.toHaveBeenCalled()
expect(AsyncStorage.clear).not.toHaveBeenCalled()
})
+
+ test('getStorageInfo should use the correct API name when it fails', () => {
+ const error = new Error('get keys failed')
+ const fail = jest.fn()
+ const complete = jest.fn()
+ AsyncStorage.getAllKeys.mockImplementation(callback => callback(error))
+
+ getStorageInfo({
+ fail,
+ complete
+ })
+
+ const result = {
+ errMsg: 'getStorageInfo:fail Error: get keys failed'
+ }
+ expect(fail).toHaveBeenCalledWith(result)
+ expect(complete).toHaveBeenCalledWith(result)
+ })
})
diff --git a/packages/api-proxy/__tests__/rn/toast.spec.js b/packages/api-proxy/__tests__/rn/toast.spec.js
index 981a098366..b199805552 100644
--- a/packages/api-proxy/__tests__/rn/toast.spec.js
+++ b/packages/api-proxy/__tests__/rn/toast.spec.js
@@ -1,6 +1,12 @@
import Portal from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-portal/index'
import { showToast, showLoading } from '../../src/platform/api/toast/rnToast'
+jest.mock('react/jsx-runtime', () => ({
+ jsx: (type, props) => ({ type, props }),
+ jsxs: (type, props) => ({ type, props }),
+ Fragment: 'Fragment'
+}), { virtual: true })
+
jest.mock('react-native', () => ({
View: 'View',
Text: 'Text',
@@ -17,7 +23,7 @@ jest.mock('react-native', () => ({
jest.mock('@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-portal/index', () => ({
add: jest.fn(),
remove: jest.fn()
-}))
+}), { virtual: true })
describe('RN toast APIs', () => {
test.each([
diff --git a/packages/api-proxy/__tests__/rn/wifi.spec.js b/packages/api-proxy/__tests__/rn/wifi.spec.js
index 30f777fb7d..f393b1d004 100644
--- a/packages/api-proxy/__tests__/rn/wifi.spec.js
+++ b/packages/api-proxy/__tests__/rn/wifi.spec.js
@@ -3,7 +3,9 @@ import { PermissionsAndroid } from 'react-native'
import {
startWifi,
stopWifi,
- getWifiList
+ getWifiList,
+ onGetWifiList,
+ offGetWifiList
} from '../../src/platform/api/device/wifi/index.ios'
jest.mock('react-native', () => ({
@@ -25,6 +27,11 @@ jest.mock('@mpxjs/core', () => ({
}
}))
+jest.mock('@mpxjs/utils', () => ({
+ noop: jest.fn(),
+ type: value => Object.prototype.toString.call(value).slice(8, -1)
+}))
+
jest.mock('react-native-wifi-reborn', () => ({
__esModule: true,
default: {
@@ -37,6 +44,7 @@ describe('RN Wi-Fi APIs', () => {
beforeEach(() => {
global.__mpx_mode__ = 'android'
stopWifi()
+ offGetWifiList()
jest.clearAllMocks()
PermissionsAndroid.request.mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED)
WifiManager.isEnabled.mockResolvedValue(true)
@@ -63,6 +71,85 @@ describe('RN Wi-Fi APIs', () => {
expect(complete).toHaveBeenCalledWith(result)
})
+ test('stopWifi should preserve Wi-Fi list listeners', async () => {
+ const listener = jest.fn()
+
+ await new Promise((resolve, reject) => {
+ startWifi({ success: resolve, fail: reject })
+ })
+ onGetWifiList(listener)
+ stopWifi()
+ await new Promise((resolve, reject) => {
+ startWifi({ success: resolve, fail: reject })
+ })
+ WifiManager.reScanAndLoadWifiList.mockResolvedValue([])
+ await new Promise((resolve, reject) => {
+ getWifiList({ success: resolve, fail: reject })
+ })
+
+ expect(listener).toHaveBeenCalledWith({ wifiList: [] })
+ offGetWifiList(listener)
+ })
+
+ test('offGetWifiList should remove the specified listener after stopWifi', async () => {
+ const removedListener = jest.fn()
+ const retainedListener = jest.fn()
+
+ await new Promise((resolve, reject) => {
+ startWifi({ success: resolve, fail: reject })
+ })
+ onGetWifiList(removedListener)
+ onGetWifiList(retainedListener)
+ stopWifi()
+ offGetWifiList(removedListener)
+ await new Promise((resolve, reject) => {
+ startWifi({ success: resolve, fail: reject })
+ })
+ WifiManager.reScanAndLoadWifiList.mockResolvedValue([])
+ await new Promise((resolve, reject) => {
+ getWifiList({ success: resolve, fail: reject })
+ })
+
+ expect(removedListener).not.toHaveBeenCalled()
+ expect(retainedListener).toHaveBeenCalledWith({ wifiList: [] })
+ })
+
+ test('offGetWifiList should remove all listeners without callback', async () => {
+ const firstListener = jest.fn()
+ const secondListener = jest.fn()
+
+ await new Promise((resolve, reject) => {
+ startWifi({ success: resolve, fail: reject })
+ })
+ onGetWifiList(firstListener)
+ onGetWifiList(secondListener)
+ offGetWifiList()
+ WifiManager.reScanAndLoadWifiList.mockResolvedValue([])
+ await new Promise((resolve, reject) => {
+ getWifiList({ success: resolve, fail: reject })
+ })
+
+ expect(firstListener).not.toHaveBeenCalled()
+ expect(secondListener).not.toHaveBeenCalled()
+ })
+
+ test('onGetWifiList should not register the same callback repeatedly', async () => {
+ const listener = jest.fn()
+
+ await new Promise((resolve, reject) => {
+ startWifi({ success: resolve, fail: reject })
+ })
+ onGetWifiList(listener)
+ onGetWifiList(listener)
+ WifiManager.reScanAndLoadWifiList.mockResolvedValue([])
+ await new Promise((resolve, reject) => {
+ getWifiList({ success: resolve, fail: reject })
+ })
+
+ expect(listener).toHaveBeenCalledTimes(1)
+ expect(listener).toHaveBeenCalledWith({ wifiList: [] })
+ })
+
test('getWifiList should return the WeChat-compatible success errMsg', async () => {
await new Promise((resolve, reject) => {
startWifi({ success: resolve, fail: reject })
@@ -80,4 +167,29 @@ describe('RN Wi-Fi APIs', () => {
})
expect(complete).toHaveBeenCalledWith(result)
})
+
+ test('getWifiList should normalize native scan failures', async () => {
+ await new Promise((resolve, reject) => {
+ startWifi({ success: resolve, fail: reject })
+ })
+ WifiManager.reScanAndLoadWifiList.mockRejectedValue(new Error('native scan error'))
+ const fail = jest.fn()
+ const complete = jest.fn()
+
+ await new Promise(resolve => {
+ getWifiList({
+ fail (result) {
+ fail(result)
+ resolve()
+ },
+ complete
+ })
+ })
+
+ const result = {
+ errMsg: 'getWifiList:fail'
+ }
+ expect(fail).toHaveBeenCalledWith(result)
+ expect(complete).toHaveBeenCalledWith(result)
+ })
})
diff --git a/packages/api-proxy/__tests__/rn/window.spec.js b/packages/api-proxy/__tests__/rn/window.spec.js
new file mode 100644
index 0000000000..0b9dcd28c6
--- /dev/null
+++ b/packages/api-proxy/__tests__/rn/window.spec.js
@@ -0,0 +1,60 @@
+const mockSubscriptions = []
+const mockAddEventListener = jest.fn(() => {
+ const subscription = { remove: jest.fn() }
+ mockSubscriptions.push(subscription)
+ return subscription
+})
+
+jest.mock('react-native', () => ({
+ Dimensions: {
+ addEventListener: mockAddEventListener
+ }
+}), { virtual: true })
+
+const {
+ offWindowResize,
+ onWindowResize
+} = require('../../src/platform/api/window/rnWindow')
+
+describe('RN window resize', () => {
+ beforeEach(() => {
+ offWindowResize()
+ mockSubscriptions.length = 0
+ mockAddEventListener.mockClear()
+ })
+
+ test('should remove native subscription without callback', () => {
+ onWindowResize(jest.fn())
+ const subscription = mockSubscriptions[0]
+
+ // 不传回调时,清空业务回调并释放 Dimensions 底层订阅。
+ offWindowResize()
+
+ expect(subscription.remove).toHaveBeenCalledTimes(1)
+ })
+
+ test('should not remove the same native subscription repeatedly', () => {
+ onWindowResize(jest.fn())
+ const subscription = mockSubscriptions[0]
+
+ offWindowResize()
+ offWindowResize()
+
+ expect(subscription.remove).toHaveBeenCalledTimes(1)
+ })
+
+ test('should keep subscription until the last callback is removed', () => {
+ const firstCallback = jest.fn()
+ const secondCallback = jest.fn()
+ onWindowResize(firstCallback)
+ onWindowResize(secondCallback)
+ const subscription = mockSubscriptions[0]
+
+ offWindowResize(firstCallback)
+ expect(subscription.remove).not.toHaveBeenCalled()
+
+ // 最后一个业务回调移除后才释放共用的底层订阅。
+ offWindowResize(secondCallback)
+ expect(subscription.remove).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/api-proxy/__tests__/web/app-event.spec.js b/packages/api-proxy/__tests__/web/app-event.spec.js
new file mode 100644
index 0000000000..d813f154ae
--- /dev/null
+++ b/packages/api-proxy/__tests__/web/app-event.spec.js
@@ -0,0 +1,46 @@
+import {
+ offAppHide,
+ offAppShow,
+ offError,
+ offUnhandledRejection,
+ onAppHide,
+ onAppShow,
+ onError,
+ onUnhandledRejection
+} from '../../src/platform/api/app/index.web'
+
+describe('Web app events', () => {
+ const eventApis = [
+ ['show', onAppShow, offAppShow],
+ ['hide', onAppHide, offAppHide],
+ ['error', onError, offError],
+ ['rejection', onUnhandledRejection, offUnhandledRejection]
+ ]
+
+ afterEach(() => {
+ // 每个用例后清空模块级回调,避免测试之间互相影响。
+ eventApis.forEach(([, , offEvent]) => offEvent())
+ })
+
+ test.each(eventApis)('%s event should only remove the specified callback', (eventName, onEvent, offEvent) => {
+ const firstCallback = jest.fn()
+ const secondCallback = jest.fn()
+ onEvent(firstCallback)
+ onEvent(secondCallback)
+
+ // 传入具体回调时,只注销目标回调,保留其他监听。
+ offEvent(firstCallback)
+
+ expect(global.__mpxAppCbs[eventName]).toEqual([secondCallback])
+ })
+
+ test.each(eventApis)('%s event should remove all callbacks without callback', (eventName, onEvent, offEvent) => {
+ onEvent(jest.fn())
+ onEvent(jest.fn())
+
+ // 不传回调时,清空当前事件的全部监听。
+ offEvent()
+
+ expect(global.__mpxAppCbs[eventName]).toHaveLength(0)
+ })
+})
diff --git a/packages/api-proxy/__tests__/web/audio.spec.js b/packages/api-proxy/__tests__/web/audio.spec.js
new file mode 100644
index 0000000000..f8f353d06d
--- /dev/null
+++ b/packages/api-proxy/__tests__/web/audio.spec.js
@@ -0,0 +1,60 @@
+import { createInnerAudioContext } from '../../src/platform/api/audio/index.web'
+
+describe('Web inner audio events', () => {
+ let nativeAudio
+
+ beforeEach(() => {
+ global.Audio = jest.fn(function () {
+ nativeAudio = this
+ this.addEventListener = jest.fn()
+ this.removeEventListener = jest.fn()
+ this.pause = jest.fn()
+ })
+ })
+
+ test('should remove the wrapper corresponding to the specified pause callback', () => {
+ const audio = createInnerAudioContext()
+ const callback = jest.fn()
+
+ audio.onPause(callback)
+ const wrapper = nativeAudio.addEventListener.mock.calls[0][1]
+ audio.offPause(callback)
+
+ // pause 使用包装函数屏蔽 stop 触发的事件,注销时必须移除包装函数而非原回调。
+ expect(wrapper).not.toBe(callback)
+ expect(nativeAudio.removeEventListener).toHaveBeenCalledWith('pause', wrapper)
+ })
+
+ test('should remove all callbacks for an event without callback', () => {
+ const audio = createInnerAudioContext()
+ const firstCallback = jest.fn()
+ const secondCallback = jest.fn()
+ audio.onPlay(firstCallback)
+ audio.onPlay(secondCallback)
+
+ // 不传回调时,应移除当前事件下注册的所有原生监听。
+ audio.offPlay()
+
+ expect(nativeAudio.removeEventListener).toHaveBeenCalledTimes(2)
+ expect(nativeAudio.removeEventListener).toHaveBeenCalledWith('play', firstCallback)
+ expect(nativeAudio.removeEventListener).toHaveBeenCalledWith('play', secondCallback)
+ })
+
+ test('should remove stop callbacks separately', () => {
+ jest.useFakeTimers()
+ const audio = createInnerAudioContext()
+ const removedCallback = jest.fn()
+ const retainedCallback = jest.fn()
+ audio.onStop(removedCallback)
+ audio.onStop(retainedCallback)
+
+ // offStop 只管理模拟的 stop 回调,不应影响其他仍保留的 stop 监听。
+ audio.offStop(removedCallback)
+ audio.stop()
+ jest.runAllTimers()
+
+ expect(removedCallback).not.toHaveBeenCalled()
+ expect(retainedCallback).toHaveBeenCalledTimes(1)
+ jest.useRealTimers()
+ })
+})
diff --git a/packages/api-proxy/__tests__/web/device-network.spec.js b/packages/api-proxy/__tests__/web/device-network.spec.js
index 2ab36ec2df..95b54317c0 100644
--- a/packages/api-proxy/__tests__/web/device-network.spec.js
+++ b/packages/api-proxy/__tests__/web/device-network.spec.js
@@ -1,4 +1,8 @@
-import { getNetworkType } from '../../src/platform/api/device/network/index.web'
+import {
+ getNetworkType,
+ offNetworkStatusChange,
+ onNetworkStatusChange
+} from '../../src/platform/api/device/network/index.web'
describe('test getNetworkType', () => {
test('should be enums value', () => {
@@ -7,4 +11,71 @@ describe('test getNetworkType', () => {
})
})
})
-// todo complete unit tests
+
+describe('test onNetworkStatusChange', () => {
+ test('should return unknown when the browser comes online without Network Information API', () => {
+ Object.defineProperty(navigator, 'connection', {
+ configurable: true,
+ value: undefined
+ })
+ const callback = jest.fn()
+
+ onNetworkStatusChange(callback)
+ window.dispatchEvent(new Event('online'))
+
+ expect(callback).toHaveBeenCalledWith({
+ isConnected: true,
+ networkType: 'unknown'
+ })
+ offNetworkStatusChange(callback)
+ })
+
+ test('should remove all proxy callbacks when the same callback is registered repeatedly', () => {
+ // 模拟浏览器 Network Information API,并记录监听的注册和移除情况。
+ const addEventListener = jest.fn()
+ const removeEventListener = jest.fn()
+ Object.defineProperty(navigator, 'connection', {
+ configurable: true,
+ value: {
+ addEventListener,
+ removeEventListener
+ }
+ })
+ const callback = jest.fn()
+
+ onNetworkStatusChange(callback)
+ onNetworkStatusChange(callback)
+ offNetworkStatusChange()
+
+ // 同一个业务回调重复注册时会生成不同的代理回调,清空监听时二者都应被移除。
+ const firstProxyCallback = addEventListener.mock.calls[0][1]
+ const secondProxyCallback = addEventListener.mock.calls[1][1]
+ expect(firstProxyCallback).not.toBe(secondProxyCallback)
+ expect(removeEventListener).toHaveBeenCalledTimes(2)
+ expect(removeEventListener).toHaveBeenCalledWith('change', firstProxyCallback)
+ expect(removeEventListener).toHaveBeenCalledWith('change', secondProxyCallback)
+ })
+
+ test('should remove all proxy callbacks for the specified callback', () => {
+ const addEventListener = jest.fn()
+ const removeEventListener = jest.fn()
+ Object.defineProperty(navigator, 'connection', {
+ configurable: true,
+ value: {
+ addEventListener,
+ removeEventListener
+ }
+ })
+ const callback = jest.fn()
+
+ // 指定业务回调注销时,也要移除该回调重复注册产生的全部代理回调。
+ onNetworkStatusChange(callback)
+ onNetworkStatusChange(callback)
+ offNetworkStatusChange(callback)
+
+ expect(removeEventListener).toHaveBeenCalledTimes(2)
+ addEventListener.mock.calls.forEach(([, proxyCallback]) => {
+ expect(removeEventListener).toHaveBeenCalledWith('change', proxyCallback)
+ })
+ })
+})
diff --git a/packages/api-proxy/__tests__/web/route.spec.js b/packages/api-proxy/__tests__/web/route.spec.js
new file mode 100644
index 0000000000..1bfe550a32
--- /dev/null
+++ b/packages/api-proxy/__tests__/web/route.spec.js
@@ -0,0 +1,102 @@
+import {
+ navigateTo,
+ redirectTo,
+ reLaunch,
+ switchTab
+} from '../../src/platform/api/route/index.web'
+
+function createRouter () {
+ return {
+ currentRoute: {
+ path: '/pages/current/index',
+ query: {}
+ },
+ history: {
+ current: {}
+ },
+ stack: [{}],
+ match: jest.fn(url => ({ path: url })),
+ push: jest.fn((location, onComplete) => onComplete()),
+ replace: jest.fn((location, onComplete) => onComplete()),
+ go: jest.fn()
+ }
+}
+
+function createCallbacks () {
+ return {
+ success: jest.fn(),
+ fail: jest.fn(),
+ complete: jest.fn()
+ }
+}
+
+describe('Web route APIs', () => {
+ beforeEach(() => {
+ global.__tabBarPagesMap = {
+ 'pages/tab/index': true
+ }
+ global.__mpxRouter = createRouter()
+ })
+
+ afterEach(() => {
+ delete global.__tabBarPagesMap
+ delete global.__mpxRouter
+ })
+
+ describe('invalid route target', () => {
+ test.each([
+ ['navigateTo', navigateTo, 'push'],
+ ['redirectTo', redirectTo, 'replace']
+ ])('%s should stop after rejecting a tabBar page', (name, api, routerMethod) => {
+ const callbacks = createCallbacks()
+
+ api(Object.assign({
+ url: '/pages/tab/index'
+ }, callbacks))
+
+ expect(callbacks.fail).toHaveBeenCalledTimes(1)
+ expect(callbacks.success).not.toHaveBeenCalled()
+ expect(callbacks.complete).toHaveBeenCalledTimes(1)
+ expect(global.__mpxRouter[routerMethod]).not.toHaveBeenCalled()
+ })
+
+ test('switchTab should stop after rejecting a non-tabBar page', () => {
+ const callbacks = createCallbacks()
+
+ switchTab(Object.assign({
+ url: '/pages/detail/index'
+ }, callbacks))
+
+ expect(callbacks.fail).toHaveBeenCalledTimes(1)
+ expect(callbacks.success).not.toHaveBeenCalled()
+ expect(callbacks.complete).toHaveBeenCalledTimes(1)
+ expect(global.__mpxRouter.replace).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('single callback settlement', () => {
+ test('reLaunch should invoke success and complete only once', () => {
+ const callbacks = createCallbacks()
+
+ reLaunch(Object.assign({
+ url: '/pages/detail/index'
+ }, callbacks))
+
+ expect(callbacks.success).toHaveBeenCalledTimes(1)
+ expect(callbacks.fail).not.toHaveBeenCalled()
+ expect(callbacks.complete).toHaveBeenCalledTimes(1)
+ })
+
+ test('switchTab should invoke success and complete only once', () => {
+ const callbacks = createCallbacks()
+
+ switchTab(Object.assign({
+ url: '/pages/tab/index'
+ }, callbacks))
+
+ expect(callbacks.success).toHaveBeenCalledTimes(1)
+ expect(callbacks.fail).not.toHaveBeenCalled()
+ expect(callbacks.complete).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/packages/api-proxy/__tests__/web/socket-task.spec.js b/packages/api-proxy/__tests__/web/socket-task.spec.js
new file mode 100644
index 0000000000..87404b5632
--- /dev/null
+++ b/packages/api-proxy/__tests__/web/socket-task.spec.js
@@ -0,0 +1,27 @@
+import SocketTask from '../../src/platform/api/socket/SocketTask'
+
+describe('SocketTask', () => {
+ const NativeWebSocket = global.WebSocket
+
+ afterEach(() => {
+ global.WebSocket = NativeWebSocket
+ })
+
+ test('reports the actual CloseEvent after close is requested', () => {
+ const close = jest.fn()
+ global.WebSocket = jest.fn(function () {
+ this.close = close
+ this.readyState = 1
+ })
+ const socketTask = new SocketTask('ws://localhost')
+ const onClose = jest.fn()
+
+ socketTask.onClose(onClose)
+ socketTask.close({ code: 1000, reason: 'normal closure' })
+ // close 入参只是关闭请求,回调应以服务端最终返回的 CloseEvent 为准。
+ socketTask._socket.onclose({ code: 1006, reason: '' })
+
+ expect(close).toHaveBeenCalledWith(1000, 'normal closure')
+ expect(onClose).toHaveBeenCalledWith({ code: 1006, reason: '' })
+ })
+})
diff --git a/packages/api-proxy/__tests__/web/tab-bar.spec.js b/packages/api-proxy/__tests__/web/tab-bar.spec.js
new file mode 100644
index 0000000000..d2591b91eb
--- /dev/null
+++ b/packages/api-proxy/__tests__/web/tab-bar.spec.js
@@ -0,0 +1,40 @@
+import {
+ setTabBarItem,
+ setTabBarStyle,
+ showTabBar,
+ hideTabBar
+} from '../../src/platform/api/tab-bar/index.web'
+
+describe('Web tabBar APIs', () => {
+ afterEach(() => {
+ delete global.__tabBar
+ })
+
+ test.each([
+ ['setTabBarStyle', setTabBarStyle, { color: '#000000' }],
+ ['setTabBarItem', setTabBarItem, { index: 0, text: 'home' }],
+ ['showTabBar', showTabBar, {}],
+ ['hideTabBar', hideTabBar, {}]
+ ])('%s should not invoke fail after success', (name, api, options) => {
+ global.__tabBar = {
+ custom: false,
+ isShow: true,
+ list: [{}]
+ }
+ const success = jest.fn()
+ const fail = jest.fn()
+ const complete = jest.fn()
+
+ api(Object.assign({}, options, {
+ success,
+ fail,
+ complete
+ }))
+
+ expect(success).toHaveBeenCalledWith({
+ errMsg: `${name}:ok`
+ })
+ expect(fail).not.toHaveBeenCalled()
+ expect(complete).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/api-proxy/__tests__/web/window.spec.js b/packages/api-proxy/__tests__/web/window.spec.js
new file mode 100644
index 0000000000..1bf60d4975
--- /dev/null
+++ b/packages/api-proxy/__tests__/web/window.spec.js
@@ -0,0 +1,38 @@
+import {
+ offWindowResize,
+ onWindowResize
+} from '../../src/platform/api/window/index.web'
+
+describe('Web window resize', () => {
+ afterEach(() => {
+ offWindowResize()
+ })
+
+ test('should remove the specified callback', () => {
+ const removedCallback = jest.fn()
+ const retainedCallback = jest.fn()
+ onWindowResize(removedCallback)
+ onWindowResize(retainedCallback)
+
+ // 传入具体回调时,只移除对应监听。
+ offWindowResize(removedCallback)
+ window.dispatchEvent(new Event('resize'))
+
+ expect(removedCallback).not.toHaveBeenCalled()
+ expect(retainedCallback).toHaveBeenCalledTimes(1)
+ })
+
+ test('should remove all callbacks without callback', () => {
+ const firstCallback = jest.fn()
+ const secondCallback = jest.fn()
+ onWindowResize(firstCallback)
+ onWindowResize(secondCallback)
+
+ // 不传回调时,后续 resize 不应再通知任何已注册回调。
+ offWindowResize()
+ window.dispatchEvent(new Event('resize'))
+
+ expect(firstCallback).not.toHaveBeenCalled()
+ expect(secondCallback).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/api-proxy/src/platform/api/app/index.web.js b/packages/api-proxy/src/platform/api/app/index.web.js
index 7feb4c345c..3e3ee675f5 100644
--- a/packages/api-proxy/src/platform/api/app/index.web.js
+++ b/packages/api-proxy/src/platform/api/app/index.web.js
@@ -24,6 +24,10 @@ function onUnhandledRejection (callback) {
}
function offUnhandledRejection (callback) {
+ if (callback == null) {
+ global.__mpxAppCbs.rejection.length = 0
+ return
+ }
off(global.__mpxAppCbs.rejection, callback)
}
@@ -34,6 +38,10 @@ function onError (callback) {
}
function offError (callback) {
+ if (callback == null) {
+ global.__mpxAppCbs.error.length = 0
+ return
+ }
off(global.__mpxAppCbs.error, callback)
}
@@ -44,6 +52,10 @@ function onAppShow (callback) {
}
function offAppShow (callback) {
+ if (callback == null) {
+ global.__mpxAppCbs.show.length = 0
+ return
+ }
off(global.__mpxAppCbs.show, callback)
}
@@ -54,6 +66,10 @@ function onAppHide (callback) {
}
function offAppHide (callback) {
+ if (callback == null) {
+ global.__mpxAppCbs.hide.length = 0
+ return
+ }
off(global.__mpxAppCbs.hide, callback)
}
@@ -65,6 +81,10 @@ function onLazyLoadError (callback) {
function offLazyLoadError (callback) {
if (isReact) {
+ if (callback == null) {
+ global.__mpxAppCbs.lazyLoad.length = 0
+ return
+ }
off(global.__mpxAppCbs.lazyLoad, callback)
}
}
diff --git a/packages/api-proxy/src/platform/api/audio/index.web.js b/packages/api-proxy/src/platform/api/audio/index.web.js
index d8c1a853b0..c8ca3b1f8b 100644
--- a/packages/api-proxy/src/platform/api/audio/index.web.js
+++ b/packages/api-proxy/src/platform/api/audio/index.web.js
@@ -12,9 +12,16 @@ export const createInnerAudioContext = () => {
__audio.pause = () => audio.pause()
+ const _stopCbs = []
+
__audio.stop = () => {
- __audio.pause()
- __audio.seek(0)
+ _stopping = true // 打开屏蔽开关,后续 pause 事件的 wrapper 会看到这个标志
+ audio.pause()
+ audio.currentTime = 0
+ setTimeout(() => {
+ _stopping = false // pause 事件已经派发完,关掉开关
+ _stopCbs.forEach(cb => cb())
+ }, 0)
}
__audio.seek = value => {
@@ -49,27 +56,63 @@ export const createInnerAudioContext = () => {
'Seeking',
'TimeUpdate',
'Waiting',
- 'Stop',
'Error'
]
- const eventListeners = [
- ['on', audio.addEventListener],
- ['off', audio.removeEventListener]
- ]
+
+ let _stopping = false
+
+ const eventCallbacks = {}
eventNames.forEach(eventName => {
- eventListeners.forEach(([eventNameItem, listenerFn]) => {
- Object.defineProperty(__audio, `${eventNameItem}${eventName}`, {
- get () {
- return (callback = audio.cb) => {
- if (eventNameItem !== 'off') {
- audio.cb = callback
+ const nativeName = eventName.toLowerCase()
+ eventCallbacks[nativeName] = []
+
+ Object.defineProperty(__audio, `on${eventName}`, {
+ get () {
+ return (cb) => {
+ const wrapper = nativeName === 'pause'
+ ? (e) => { if (!_stopping) cb(e) }
+ : cb
+ eventCallbacks[nativeName].push({ cb, wrapper })
+ audio.addEventListener(nativeName, wrapper)
+ }
+ }
+ })
+
+ Object.defineProperty(__audio, `off${eventName}`, {
+ get () {
+ return (cb) => {
+ if (cb == null) {
+ eventCallbacks[nativeName].forEach(({ wrapper }) => audio.removeEventListener(nativeName, wrapper))
+ eventCallbacks[nativeName] = []
+ } else {
+ const idx = eventCallbacks[nativeName].findIndex(item => item.cb === cb)
+ if (idx > -1) {
+ audio.removeEventListener(nativeName, eventCallbacks[nativeName][idx].wrapper)
+ eventCallbacks[nativeName].splice(idx, 1)
}
- // debugger
- return listenerFn.call(audio, eventName.toLowerCase(), audio.cb)
}
}
- })
+ }
})
})
+
+ Object.defineProperty(__audio, 'onStop', {
+ get () {
+ return (cb) => { _stopCbs.push(cb) }
+ }
+ })
+ Object.defineProperty(__audio, 'offStop', {
+ get () {
+ return (cb) => {
+ if (cb == null) {
+ _stopCbs.length = 0
+ } else {
+ const idx = _stopCbs.indexOf(cb)
+ if (idx > -1) _stopCbs.splice(idx, 1)
+ }
+ }
+ }
+ })
+
return __audio
}
diff --git a/packages/api-proxy/src/platform/api/ble-connection/index.ios.js b/packages/api-proxy/src/platform/api/ble-connection/index.ios.js
index a463949451..39dc54cc44 100644
--- a/packages/api-proxy/src/platform/api/ble-connection/index.ios.js
+++ b/packages/api-proxy/src/platform/api/ble-connection/index.ios.js
@@ -179,7 +179,7 @@ function closeBluetoothAdapter (options = {}) {
complete(result)
} catch (error) {
const result = {
- errMsg: 'closeBluetoothAdapter:fail ' + error.message
+ errMsg: 'closeBluetoothAdapter:fail ' + (error?.message != null ? error.message : '')
}
fail(result)
complete(result)
@@ -197,7 +197,7 @@ function startBluetoothDevicesDiscovery (options = {}) {
} = options
if (!bleManagerInitialized) {
- commonFailHandler('startBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hans\'t been opened or ble is unavailable.')
+ commonFailHandler('startBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hasn\'t been opened or ble is unavailable.')
return
}
DiscoverPeripheralSubscription = BleManager.onDiscoverPeripheral((device) => {
@@ -257,7 +257,7 @@ function stopBluetoothDevicesDiscovery (options = {}) {
const { success = noop, fail = noop, complete = noop } = options
if (!bleManagerInitialized) {
- commonFailHandler('stopBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hans\'t been opened or ble is unavailable.')
+ commonFailHandler('stopBluetoothDevicesDiscovery:fail', fail, complete, 'ble adapter hasn\'t been opened or ble is unavailable.')
return
}
removeBluetoothDevicesDiscovery()
@@ -397,7 +397,7 @@ function getBluetoothDevices (options = {}) { // 该能力只是获取应用级
const { success = noop, fail = noop, complete = noop } = options
if (!bleManagerInitialized) {
const result = {
- errMsg: 'getBluetoothDevices:fail ble adapter hans\'t been opened or ble is unavailable.'
+ errMsg: 'getBluetoothDevices:fail ble adapter hasn\'t been opened or ble is unavailable.'
}
fail(result)
complete(result)
@@ -576,15 +576,15 @@ function offBLECharacteristicValueChange () {
function setBLEMTU (options = {}) {
const BleManager = require('react-native-ble-manager').default
const { deviceId, mtu, success = noop, fail = noop, complete = noop } = options
- if (!deviceId && !mtu) {
+ if (deviceId == null && mtu == null) {
commonFailHandler('setBLEMTU:fail', fail, complete, 'parameter error: parameter.deviceId should be String instead of Undefined;parameter.mtu should be Number instead of Undefined;')
return
}
- if (!mtu) {
+ if (mtu == null) {
commonFailHandler('setBLEMTU:fail', fail, complete, 'parameter error: parameter.mtu should be Number instead of Undefined;')
return
}
- if (!deviceId) {
+ if (deviceId == null) {
commonFailHandler('setBLEMTU:fail', fail, complete, 'parameter error: parameter.deviceId should be String instead of Undefined;')
return
}
diff --git a/packages/api-proxy/src/platform/api/camera/rnCamera.js b/packages/api-proxy/src/platform/api/camera/rnCamera.js
index 1f524f7049..5f44413ee4 100644
--- a/packages/api-proxy/src/platform/api/camera/rnCamera.js
+++ b/packages/api-proxy/src/platform/api/camera/rnCamera.js
@@ -10,10 +10,10 @@ export default class CameraContext {
const { zoom, success = noop, fail = noop, complete = noop } = options
try {
if (this.camera.setZoom) {
+ this.camera.setZoom(zoom)
const result = { errMsg: 'setZoom:ok' }
success(result)
complete(result)
- this.camera.setZoom(zoom)
} else {
const result = {
errMsg: 'setZoom:fail camera instance not found'
diff --git a/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js b/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js
index b7c880ab07..56f8ffaaa9 100644
--- a/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js
+++ b/packages/api-proxy/src/platform/api/create-intersection-observer/IntersectionObserver.js
@@ -1,8 +1,6 @@
import { nextTick } from '../next-tick'
import { parseDataset, warn } from '@mpxjs/utils'
-let isInit = true
-
class WebIntersectionObserver {
constructor (_component, options) {
this._component = _component
@@ -14,6 +12,7 @@ class WebIntersectionObserver {
this._rootMargin = ''
this._disconnected = false
this._minThreshold = this.getMinThreshold()
+ this._isFirst = true
}
initObserver () {
@@ -25,7 +24,7 @@ class WebIntersectionObserver {
return new IntersectionObserver((entries, observer) => {
const initialRatio = this._options.initialRatio || 0
entries.forEach(entry => {
- if (!isInit || (isInit && (entry.intersectionRatio !== initialRatio && (this._minThreshold <= entry.intersectionRatio)))) {
+ if (!this._isFirst || (this._isFirst && (entry.intersectionRatio !== initialRatio && (this._minThreshold <= entry.intersectionRatio)))) {
Object.defineProperties(entry, {
id: {
get () {
@@ -57,7 +56,7 @@ class WebIntersectionObserver {
this._callback && this._callback(entry)
}
})
- isInit = false
+ this._isFirst = false
}, {
root: this._root || null,
rootMargin: this._rootMargin,
diff --git a/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js b/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js
index 36623d9eb9..a31197943b 100644
--- a/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js
+++ b/packages/api-proxy/src/platform/api/device/network/onNetworkStatusChange.js
@@ -8,7 +8,7 @@ if (isBrowser) {
oldObserveList.forEach(fn => fn({ isConnected: false, networkType: 'none' }))
})
window.addEventListener('online', () => {
- oldObserveList.forEach(fn => fn({ isConnected: true, networkType: 'unknow' }))
+ oldObserveList.forEach(fn => fn({ isConnected: true, networkType: 'unknown' }))
})
}
@@ -25,7 +25,9 @@ export function onNetworkStatusChange (callbackFn) {
networkType: isConnected ? evt.currentTarget.effectiveType : 'none'
})
}
- fnMap.set(callbackFn, proxyCallback)
+ const proxyCallbacks = fnMap.get(callbackFn) || new Set()
+ proxyCallbacks.add(proxyCallback)
+ fnMap.set(callbackFn, proxyCallbacks)
navigator.connection.addEventListener('change', proxyCallback)
} else {
typeof callbackFn === 'function' && oldObserveList.add(callbackFn)
@@ -37,8 +39,25 @@ export function offNetworkStatusChange (callbackFn) {
throwSSRWarning('offNetworkStatusChange API is running in non browser environments')
return
}
+ if (callbackFn == null) {
+ // 不传 callback 时清除所有监听
+ fnMap.forEach((proxyCallbacks) => {
+ if (navigator.connection) {
+ proxyCallbacks.forEach(proxyCallback => {
+ navigator.connection.removeEventListener('change', proxyCallback)
+ })
+ }
+ })
+ fnMap.clear()
+ oldObserveList.clear()
+ return
+ }
if (navigator.connection) {
- navigator.connection.removeEventListener('change', fnMap.get(callbackFn))
+ const proxyCallbacks = fnMap.get(callbackFn)
+ proxyCallbacks && proxyCallbacks.forEach(proxyCallback => {
+ navigator.connection.removeEventListener('change', proxyCallback)
+ })
+ fnMap.delete(callbackFn)
} else {
oldObserveList.has(callbackFn) && oldObserveList.delete(callbackFn)
}
diff --git a/packages/api-proxy/src/platform/api/device/network/rnNetwork.js b/packages/api-proxy/src/platform/api/device/network/rnNetwork.js
index 96617bd69a..9daaa284ff 100644
--- a/packages/api-proxy/src/platform/api/device/network/rnNetwork.js
+++ b/packages/api-proxy/src/platform/api/device/network/rnNetwork.js
@@ -24,7 +24,7 @@ const getNetworkType = function (options = {}) {
successHandle(result, success, complete)
}).catch((err) => {
const result = {
- errMsg: err.message
+ errMsg: `getNetworkType:fail ${err.message}`
}
failHandle(result, fail, complete)
})
@@ -45,7 +45,11 @@ const onNetworkStatusChange = function (callback) {
const offNetworkStatusChange = function (callback) {
if (callback && typeof callback === 'function') {
_callbacks.delete(callback)
- } else if (callback === undefined) {
+ if (!_callbacks.size) {
+ _unsubscribe && _unsubscribe()
+ _unsubscribe = null
+ }
+ } else if (callback == null) {
_callbacks.clear()
_unsubscribe && _unsubscribe()
_unsubscribe = null
diff --git a/packages/api-proxy/src/platform/api/device/wifi/index.ios.js b/packages/api-proxy/src/platform/api/device/wifi/index.ios.js
index 6c2b1c13a4..9a342d9a52 100644
--- a/packages/api-proxy/src/platform/api/device/wifi/index.ios.js
+++ b/packages/api-proxy/src/platform/api/device/wifi/index.ios.js
@@ -78,7 +78,6 @@ function stopWifi (options = {}) {
return
}
startWifiReady = false
- wifiListListeners.length = 0
const result = {
errMsg: 'stopWifi:ok'
}
@@ -137,14 +136,15 @@ function getWifiList (options = {}) {
}
function onGetWifiList (callback) {
- if (!startWifiReady && wifiListListeners.indexOf(callback) > -1) {
+ if (wifiListListeners.indexOf(callback) > -1) {
return
}
wifiListListeners.push(callback)
}
function offGetWifiList (callback) {
- if (!startWifiReady) {
+ if (callback == null) {
+ wifiListListeners.length = 0
return
}
const index = wifiListListeners.indexOf(callback)
diff --git a/packages/api-proxy/src/platform/api/keyboard/index.ios.js b/packages/api-proxy/src/platform/api/keyboard/index.ios.js
index 611eb03292..f3b169d826 100644
--- a/packages/api-proxy/src/platform/api/keyboard/index.ios.js
+++ b/packages/api-proxy/src/platform/api/keyboard/index.ios.js
@@ -1,6 +1,8 @@
import { Keyboard } from 'react-native'
import { successHandle, failHandle } from '../../../common/js'
let hasListener = false
+let keyboardShowSubscription
+let keyboardHideSubscription
const callbacks = []
function keyboardShowListener (e) {
@@ -11,22 +13,15 @@ function keyboardShowListener (e) {
}))
}
function keyboardHideListener (e) {
- const endCoordinates = e.endCoordinates || {}
- let height
- if (__mpx_mode__ === 'ios') {
- height = 0
- } else {
- height = endCoordinates.height
- }
// eslint-disable-next-line node/no-callback-literal
callbacks.forEach(cb => cb({
- height
+ height: 0
}))
}
const onKeyboardHeightChange = function (callback) {
if (!hasListener) {
- Keyboard.addListener('keyboardDidShow', keyboardShowListener)
- Keyboard.addListener('keyboardDidHide', keyboardHideListener)
+ keyboardShowSubscription = Keyboard.addListener('keyboardDidShow', keyboardShowListener)
+ keyboardHideSubscription = Keyboard.addListener('keyboardDidHide', keyboardHideListener)
hasListener = true
}
callbacks.push(callback)
@@ -36,10 +31,13 @@ const offKeyboardHeightChange = function (callback) {
if (index > -1) {
callbacks.splice(index, 1)
}
- if (callbacks.length === 0) {
- Keyboard.removeAllListeners('keyboardDidShow')
- Keyboard.removeAllListeners('keyboardDidHide')
- hasListener = false
+ if (callbacks.length === 0 || callback == null) {
+ callbacks.length = 0
+ if (hasListener) {
+ keyboardShowSubscription.remove()
+ keyboardHideSubscription.remove()
+ hasListener = false
+ }
}
}
@@ -50,7 +48,7 @@ const hideKeyboard = function (options = {}) {
const result = { errMsg: 'hideKeyboard:ok' }
successHandle(result, success, complete)
} catch (err) {
- const result = { errMsg: err.message }
+ const result = { errMsg: `hideKeyboard:fail ${err.message}` }
failHandle(result, fail, complete)
}
}
diff --git a/packages/api-proxy/src/platform/api/location/index.web.js b/packages/api-proxy/src/platform/api/location/index.web.js
index 23fe942abc..306117a812 100644
--- a/packages/api-proxy/src/platform/api/location/index.web.js
+++ b/packages/api-proxy/src/platform/api/location/index.web.js
@@ -10,7 +10,7 @@ const getLocation = function (options = {}) {
errMsg: 'getLocation:ok',
latitude: coords.latitude,
longitude: coords.longitude,
- speed: coords.accuracy
+ speed: coords.speed
}
defineUnsupportedProps(result, ['horizontalAccuracy', 'verticalAccuracy'])
successHandle(result, success, complete)
diff --git a/packages/api-proxy/src/platform/api/route/index.web.js b/packages/api-proxy/src/platform/api/route/index.web.js
index e41a946048..e521f70b20 100644
--- a/packages/api-proxy/src/platform/api/route/index.web.js
+++ b/packages/api-proxy/src/platform/api/route/index.web.js
@@ -13,6 +13,7 @@ function redirectTo (options = {}) {
if (isTabBarPage(options.url, router)) {
const res = { errMsg: 'redirectTo:fail can not redirectTo a tabBar page' }
failHandle(res, options.fail, options.complete)
+ return
}
router.__mpxAction = { type: 'redirect' }
if (routeCount === 0 && router.currentRoute.query.routeCount) routeCount = router.currentRoute.query.routeCount
@@ -45,6 +46,7 @@ function navigateTo (options = {}) {
if (isTabBarPage(options.url, router)) {
const res = { errMsg: 'navigateTo:fail can not navigateTo a tabBar page' }
failHandle(res, options.fail, options.complete)
+ return
}
const finalPath = resolvePath(options.url, router.currentRoute.path).slice(1)
const eventChannel = new EventChannel()
@@ -138,8 +140,10 @@ function reLaunch (options = {}) {
}
)
}
- const res = { errMsg: 'reLaunch:ok' }
- successHandle(res, options.success, options.complete)
+ if (delta > 0) {
+ const res = { errMsg: 'reLaunch:ok' }
+ successHandle(res, options.success, options.complete)
+ }
}
}
@@ -156,6 +160,7 @@ function switchTab (options = {}) {
if (!isTabBarPage(options.url, router)) {
const res = { errMsg: 'switchTab:fail can not switch to no-tabBar page!' }
failHandle(res, options.fail, options.complete)
+ return
}
router.__mpxAction = {
type: 'switch',
@@ -180,6 +185,7 @@ function switchTab (options = {}) {
failHandle(res, options.fail, options.complete)
}
)
+ return
}
}
const res = { errMsg: 'switchTab:ok' }
diff --git a/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js b/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js
index a71a31e5bf..43cac50862 100644
--- a/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js
+++ b/packages/api-proxy/src/platform/api/set-navigation-bar/index.ali.js
@@ -2,7 +2,7 @@ import { ENV_OBJ, changeOpts, handleSuccess } from '../../../common/js'
function setNavigationBarTitle (options = {}) {
handleSuccess(options, res => {
- return changeOpts(res, {}, { errMsg: 'setScreenBrightness:ok' })
+ return changeOpts(res, {}, { errMsg: 'setNavigationBarTitle:ok' })
})
if (ENV_OBJ.canIUse('setNavigationBarTitle')) {
return ENV_OBJ.setNavigationBarTitle(options)
diff --git a/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js b/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js
index 198ac4b945..43d7a1b7c3 100644
--- a/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js
+++ b/packages/api-proxy/src/platform/api/set-navigation-bar/index.web.js
@@ -19,10 +19,13 @@ function setNavigationBarColor (options = {}) {
return
}
const { backgroundColor, success, complete } = options
- const meta = document.createElement('meta')
- meta.setAttribute('name', 'theme-color')
+ let meta = document.querySelector('meta[name="theme-color"]')
+ if (!meta) {
+ meta = document.createElement('meta')
+ meta.setAttribute('name', 'theme-color')
+ document.head.appendChild(meta)
+ }
meta.setAttribute('content', backgroundColor)
- document.head.appendChild(meta)
successHandle({ errMsg: 'setNavigationBarColor:ok' }, success, complete)
}
diff --git a/packages/api-proxy/src/platform/api/socket/SocketTask.js b/packages/api-proxy/src/platform/api/socket/SocketTask.js
index fa6cb517ae..f4fb28513f 100644
--- a/packages/api-proxy/src/platform/api/socket/SocketTask.js
+++ b/packages/api-proxy/src/platform/api/socket/SocketTask.js
@@ -4,17 +4,20 @@ import { type } from '@mpxjs/utils'
const socketTasks = new Set()
class SocketTask {
- constructor (url, protocols) {
+ constructor (url, protocols, header) {
this._openCb = null
this._closeCb = null
this._messageCb = null
this._errorCb = null
- this._closeData = null
- if (protocols && protocols.length > 0) {
- this._socket = new window.WebSocket(url, protocols)
+ if (header) {
+ this._socket = new WebSocket(url, protocols, {
+ headers: header
+ })
+ } else if (protocols && protocols.length > 0) {
+ this._socket = new WebSocket(url, protocols)
} else {
- this._socket = new window.WebSocket(url)
+ this._socket = new WebSocket(url)
}
this.addListener(this._socket)
socketTasks.add(this._socket)
@@ -57,12 +60,8 @@ class SocketTask {
close (options = {}) {
const { code = 1000, reason = '', success, fail, complete } = options
- this._closeData = {
- code,
- reason
- }
try {
- this._socket.close()
+ this._socket.close(code, reason)
const res = { errMsg: 'closeSocket:ok' }
successHandle(res, success, complete)
} catch (err) {
@@ -89,11 +88,7 @@ class SocketTask {
if (typeof this._closeCb !== 'function') {
return
}
- if (this._closeData) {
- this._closeCb(event)
- } else {
- this._closeCb({ code: event.code, reason: event.reason })
- }
+ this._closeCb({ code: event.code, reason: event.reason })
}
}
diff --git a/packages/api-proxy/src/platform/api/socket/index.ios.js b/packages/api-proxy/src/platform/api/socket/index.ios.js
index cddf8e7080..6e63401a36 100644
--- a/packages/api-proxy/src/platform/api/socket/index.ios.js
+++ b/packages/api-proxy/src/platform/api/socket/index.ios.js
@@ -1 +1,27 @@
-export * from './index.web'
+import { successHandle, failHandle } from '../../../common/js'
+import SocketTask from './SocketTask'
+
+function connectSocket (options = { url: '' }) {
+ const { url, protocols, header, success, fail, complete } = options
+
+ try {
+ const socketTask = new SocketTask(url, protocols, header)
+ successHandle({ errMsg: 'connectSocket:ok' }, success, complete)
+ return socketTask
+ } catch (e) {
+ failHandle({ errMsg: `connectSocket:fail ${e}` }, fail, complete)
+ }
+}
+
+export {
+ sendSocketMessage,
+ closeSocket,
+ onSocketOpen,
+ onSocketError,
+ onSocketMessage,
+ onSocketClose
+} from './index.web'
+
+export {
+ connectSocket
+}
diff --git a/packages/api-proxy/src/platform/api/socket/index.web.js b/packages/api-proxy/src/platform/api/socket/index.web.js
index 3b5137affe..e16f5a7f14 100644
--- a/packages/api-proxy/src/platform/api/socket/index.web.js
+++ b/packages/api-proxy/src/platform/api/socket/index.web.js
@@ -1,11 +1,7 @@
-import { warn, successHandle, failHandle, isBrowser, throwSSRWarning } from '../../../common/js'
+import { warn, successHandle, failHandle } from '../../../common/js'
import SocketTask from './SocketTask'
function connectSocket (options = { url: '' }) {
- if (!isBrowser) {
- throwSSRWarning('connectSocket API is running in non browser environments')
- return
- }
const { url, protocols, success, fail, complete } = options
try {
diff --git a/packages/api-proxy/src/platform/api/storage/index.web.js b/packages/api-proxy/src/platform/api/storage/index.web.js
index d0c15a579a..3fd121b549 100644
--- a/packages/api-proxy/src/platform/api/storage/index.web.js
+++ b/packages/api-proxy/src/platform/api/storage/index.web.js
@@ -126,7 +126,7 @@ function removeStorage (options = { key: '' }) {
function removeStorageSync (key) {
if (!isBrowser) {
- throwSSRWarning('getStorageInfoSync API is running in non browser environments')
+ throwSSRWarning('removeStorageSync API is running in non browser environments')
return
}
window.localStorage.removeItem(key)
diff --git a/packages/api-proxy/src/platform/api/storage/rnStorage.js b/packages/api-proxy/src/platform/api/storage/rnStorage.js
index 213b7afaab..9f1e5ea385 100644
--- a/packages/api-proxy/src/platform/api/storage/rnStorage.js
+++ b/packages/api-proxy/src/platform/api/storage/rnStorage.js
@@ -67,7 +67,7 @@ function getStorageInfo (options = {}) {
AsyncStorage.getAllKeys((err, keys) => {
if (err) {
const result = {
- errMsg: `getStorage:fail ${err}`
+ errMsg: `getStorageInfo:fail ${err}`
}
failHandle(result, fail, complete)
return
diff --git a/packages/api-proxy/src/platform/api/tab-bar/index.web.js b/packages/api-proxy/src/platform/api/tab-bar/index.web.js
index add213c535..0a2267bcc3 100644
--- a/packages/api-proxy/src/platform/api/tab-bar/index.web.js
+++ b/packages/api-proxy/src/platform/api/tab-bar/index.web.js
@@ -21,7 +21,7 @@ function setTabBarStyle (options = {}) {
}
if (resolved) {
- successHandle(resolved, options.success, options.complete)
+ return successHandle(resolved, options.success, options.complete)
}
failHandle(rejected, options.fail, options.complete)
}
@@ -50,7 +50,7 @@ function setTabBarItem (options = {}) {
}
if (resolved) {
- successHandle(resolved, options.success, options.complete)
+ return successHandle(resolved, options.success, options.complete)
}
failHandle(rejected, options.fail, options.complete)
}
@@ -66,7 +66,7 @@ function showTabBar (options = {}) {
}
if (resolved) {
- successHandle(resolved, options.success, options.complete)
+ return successHandle(resolved, options.success, options.complete)
}
failHandle(rejected, options.fail, options.complete)
}
@@ -82,7 +82,7 @@ function hideTabBar (options = {}) {
}
if (resolved) {
- successHandle(resolved, options.success, options.complete)
+ return successHandle(resolved, options.success, options.complete)
}
failHandle(rejected, options.fail, options.complete)
}
diff --git a/packages/api-proxy/src/platform/api/window/index.web.js b/packages/api-proxy/src/platform/api/window/index.web.js
index b85f0c13c8..5cae440309 100644
--- a/packages/api-proxy/src/platform/api/window/index.web.js
+++ b/packages/api-proxy/src/platform/api/window/index.web.js
@@ -19,6 +19,11 @@ function onWindowResize (callback) {
}
function offWindowResize (callback) {
+ if (callback == null) {
+ // 不传 callback 时清除所有监听
+ callbacks.length = 0
+ return
+ }
const index = callbacks.indexOf(callback)
if (index > -1) {
callbacks.splice(index, 1)
diff --git a/packages/api-proxy/src/platform/api/window/rnWindow.js b/packages/api-proxy/src/platform/api/window/rnWindow.js
index 026d127777..a86d715488 100644
--- a/packages/api-proxy/src/platform/api/window/rnWindow.js
+++ b/packages/api-proxy/src/platform/api/window/rnWindow.js
@@ -19,6 +19,7 @@ const addListener = function () {
const removeListener = function () {
subscription && subscription.remove()
+ subscription = null
}
function onWindowResize (callback) {
@@ -29,6 +30,12 @@ function onWindowResize (callback) {
}
function offWindowResize (callback) {
+ if (callback == null) {
+ // 不传 callback 时清除所有监听
+ callbacks.length = 0
+ removeListener()
+ return
+ }
const index = callbacks.indexOf(callback)
if (index > -1) {
callbacks.splice(index, 1)