在基于 Taro 的微信、钉钉小程序中使用 MQTT
本文的主要内容介绍了如何在基于 Taro 实现的微信、钉钉小程序中引入 MQTT,包括如何调整 MQTT 源代码使其能够支持钉钉小程序、如何更新 MQTT 编译过程以便通过钉钉的小程序开发者工具的编译过程。
2024-12-03
使用的开发环境是:
- Taro 版本:4.0.5
- MQTT 版本:5.10.3
- 微信开发者工具:Stable 1.06.2409140
- (钉钉)小程序开发者工具:3.1.3
修改好的 MQTT 库文件及依赖文件可以参考这个 Gist。
仅需要在微信小程序中使用 MQTT
如果仅需要编译为微信小程序,可以参考这个 issue 中的回答,基本步骤是:
首先安装依赖:
yarn install mqtt abortcontroller-polyfill
由于
mqtt.min.js
iife
import
node_modules/mqtt/dist/mqtt.min.js
// 将 `"use strict";var mqtt=` 修改为:
"use strict";module.exports=
node_modules/mqtt/dist/mqtt.min.js
修改完成后更新
config/index.ts
node_modules/mqtt/dist/mqtt.min.js
export default defineConfig<"webpack5">(async (merge, _) => {
// ...
mini: {
compile: {
// ...
exclude: [
// ...
path.resolve(__dirname, "..", "node_modules/mqtt/dist/mqtt.min.js"),
// ...
],
// ...
},
}
// ...
}
config/index.ts
配置更新后,按照下面的方式引入和连接 MQTT 服务,然后重新启动 dev server:
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only' // import before mqtt.
import 'esbuild-plugin-polyfill-node/polyfills/navigator'
import mqtt from'mqtt/dist/mqtt.min'
const client = mqtt.connect("wxs://test.mosquitto.org", {
timerVariant: 'native' // more info ref issue: #1797
});
要注意的是,在使用中,不论是微信小程序还是钉钉小程序,都不支持
TextDecoder
构建支持在钉钉小程序中使用的 MQTT 库文件
如果不仅要在微信小程序中使用,同时也要在钉钉小程序中使用,则需要调整 mqtt.js 的源码,增加对钉钉小程序的适配,并且调整编译脚本,以便能够通过钉钉小程序开发工具的编译流程。
在 MQTT 中适配钉钉小程序 socket 接口
首先将 MQTT.js
src/lib/connect/dd.ts
import { Buffer } from "buffer";
import { Transform } from "readable-stream";
import type { StreamBuilder } from "../shared";
import MqttClient, { IClientOptions } from "../client";
import { BufferedDuplex } from "../BufferedDuplex";
let my: any;
let proxy: Transform;
let stream: BufferedDuplex;
let isInitialized = false;
function buildProxy() {
const _proxy = new Transform();
_proxy._write = (chunk: ArrayBuffer, _encoding: string, next: (_: any) => void) => {
my.sendSocketMessage({
data: Buffer.from(chunk.buffer).toString("base64"),
isBuffer: true,
success() {
next();
},
fail(e) {
next(new Error());
},
});
};
_proxy._flush = (_done) => {
my.closeSocket({});
};
return _proxy;
}
function setDefaultOpts(opts: IClientOptions) {
if (!opts.hostname) {
opts.hostname = "localhost";
}
if (!opts.path) {
opts.path = "/";
}
if (!opts.wsOptions) {
opts.wsOptions = {};
}
opts.protocol = "wss";
}
function buildUrl(opts: IClientOptions, client: MqttClient) {
const protocol = opts.protocol === "dds" ? "wss" : "ws";
let url = `${protocol}://${opts.hostname}${opts.path}`;
if (opts.port && opts.port !== 80 && opts.port !== 443) {
url = `${protocol}://${opts.hostname}:${opts.port}${opts.path}`;
}
if (typeof opts.transformWsUrl === "function") {
url = opts.transformWsUrl(url, opts, client);
}
return url;
}
function bindEventHandler() {
if (isInitialized) return;
isInitialized = true;
my.onSocketOpen(() => {
stream.socketReady();
});
my.onSocketMessage((res) => {
if (res.isBuffer) {
proxy.push(Buffer.from(res.data, "base64"));
} else {
proxy.push(Buffer.from(res.data, "utf8"));
}
});
my.onSocketClose(() => {
stream.emit("close");
stream.end();
stream.destroy();
});
my.onSocketError((e) => {
stream.destroy(e);
});
}
const buildStream: StreamBuilder = (client: MqttClient, opts: IClientOptions) => {
opts.hostname = opts.hostname || opts.host;
if (!opts.hostname) {
throw new Error("Could not determine host. Specify host manually.");
}
setDefaultOpts(opts);
const url = buildUrl(opts, client);
my = opts.my;
my.connectSocket({
url,
success: () => console.log("[INFO] connect to socket"),
fail: (err) => console.log("[ERROR] can not connect to socket", err)
});
proxy = buildProxy();
stream = new BufferedDuplex(opts, proxy, my);
bindEventHandler();
return stream;
};
export default buildStream;
src/lib/connect/dd.ts
上面这段代码是从
src/lib/connect/ali.ts
- 修改 接口,支付宝小程序接口的
my.sendSocketMessage
字段支持传递data
,而钉钉小程序只支持传递字符串(ArrayBuffer
字符串或utf8
编码后的base64
),要注意,由于 MQTT 无法解析钉钉接口发送的ArrayBuffer
,因此一定要使用utf8 string
的格式(即编码为 base64 格式的字符串)发送;isBuffer
- 在 方法中使用
buildUrl
替换了dd(s)
;ali(s)
- 修改 接口,针对钉钉小程序的返回值进行了调整;
my.onSocketMessage
- 调整 方法,移除了
my.connectSocket
字段。protocols
方法不支持dd.connectSocket
参数,如果添加该参数在模拟器中不会有问题,但是真机上会无法连接(不会报错 ...)。protocols
添加完
src/lib/connect/dd.ts
dd(s)
src/lib/connect/index.ts
// ...
function connect(
brokerUrl: string | IClientOptions,
opts?: IClientOptions,
): MqttClient {
// ...
if (opts.cert && opts.key) {
if (opts.protocol) {
// add `dds` for dingtalk mini-program
if (
["mqtts", "wss", "wxs", "alis", "dds"].indexOf(opts.protocol) === -1
) {
switch (opts.protocol) {
case "mqtt":
opts.protocol = "mqtts";
break;
case "ws":
opts.protocol = "wss";
break;
case "wx":
opts.protocol = "wxs";
break;
case "ali":
opts.protocol = "alis";
break;
// for dingtalk mini-program
case "dd":
opts.protocol = "dds";
break;
default:
throw new Error(
`Unknown protocol for secure connection: "${opts.protocol}"!`,
);
}
}
} else {
// A cert and key was provided, however no protocol was specified, so we will throw an error.
throw new Error("Missing secure protocol key");
}
}
// ...
// only loads the protocols once
if (!protocols) {
protocols = {};
if (!isBrowser && !opts.forceNativeWebSocket) {
protocols.ws = require("./ws").streamBuilder;
protocols.wss = require("./ws").streamBuilder;
protocols.mqtt = require("./tcp").default;
protocols.tcp = require("./tcp").default;
protocols.ssl = require("./tls").default;
protocols.tls = protocols.ssl;
protocols.mqtts = require("./tls").default;
} else {
protocols.ws = require("./ws").browserStreamBuilder;
protocols.wss = require("./ws").browserStreamBuilder;
protocols.wx = require("./wx").default;
protocols.wxs = require("./wx").default;
protocols.ali = require("./ali").default;
protocols.alis = require("./ali").default;
// 增加 dd(s)
protocols.dd = require("./dd").default;
protocols.dds = require("./dd").default;
}
}
// ...
if (!protocols[opts.protocol]) {
const isSecure = ["mqtts", "wss"].indexOf(opts.protocol) !== -1;
// returns the first available protocol based on available protocols (that depends on environment)
// if no protocol is specified this will return mqtt on node and ws on browser
// if secure it will return mqtts on node and wss on browser
opts.protocol = [
"mqtt",
"mqtts",
"ws",
"wss",
"wx",
"wxs",
"ali",
"alis",
"dd", // 增加 dd(s)
"dds",
].filter((key, index) => {
if (isSecure && index % 2 === 0) {
// Skip insecure protocols when requesting a secure one.
return false;
}
return typeof protocols[key] === "function";
})[0] as MqttProtocol;
}
// ...
}
// ...
src/lib/connect/index.ts
然后更新
src/lib/client.ts
dd(s)
// ...
// 增加 dd(s)
export type BaseMqttProtocol =
| 'wss'
| 'ws'
| 'mqtt'
| 'mqtts'
| 'tcp'
| 'ssl'
| 'wx'
| 'wxs'
| 'ali'
| 'alis'
| 'dd'
| 'dds'
// ...
src/lib/client.ts
更新 MQTT 编译脚本
完成上面的调整后可以使用下面的脚本测试是否能够正常编译:
yarn build
如果没有报错,应当能够在
dist
mqtt.js
mqtt.min.js
mqtt.esm.js
es5
更新
tsconfig.json
target
ES5
downlevelIteration
Generator
yarn build:ts
big integer literal
readable-stream
BigInt
es5
node_modules/readable-stream/lib/ours/errors.js
big integer literal
// ...
E(
'ERR_OUT_OF_RANGE',
(str, range, input) => {
assert(range, 'Missing "range" argument')
let received
if (Number.isInteger(input) && Math.abs(input) > 2 ** 32) {
received = addNumericalSeparator(String(input))
} else if (typeof input === 'bigint') {
received = String(input)
// 将 2n ** 32n 修改成下面的样子
if (input > 2 ** 32 || input < -(2 ** 32)) {
received = addNumericalSeparator(received)
}
received += 'n'
} else {
received = inspect(input)
}
return `The value of "${str}" is out of range. It must be ${range}. Received ${received}`
},
RangeError
)
// ...
node_modules/readable-stream/lib/ours/errors.js
修改完成后,使用
yarn build:ts
spread
babel
yarn add -D @babel/cli @babel/core @babel/plugin-transform-classes @babel/plugin-transform-spread @babel/preset-env
然后在项目根目录下添加
babel.config.js
const presets = [["@babel/preset-env"]];
module.exports = { presets, plugins: ["@babel/plugin-transform-spread", "@babel/plugin-transform-classes"] };
babel.config.js
最后将
esbuild.js
format
iife
cjs
yarn build && npx babel dist/mqtt.min.js --out-dir dist_for_dd/
将
dist_for_dd/mqtt.min.js
针对多端的文件名调整
虽然上面构建的代码在编译目标是微信小程序时也可以使用,但是为了适配钉钉小程序,重新编译后的 MQTT 库文件大小达到了 654KB,对于 2M 的限制影响还是很大的;并且如果项目中引入了第三方插件,也可能会发生不可预见的错误,建议为不同的端的 MQTT 库文件添加
env
. ├── mqtt.min.weapp.js ├── mqtt.min.dd.js └── ...
注意,使用这种写法要在
config/index.ts
config/index.ts
mqtt.min.dd.js
在代码中引入 MQTT 库文件
经过前面的步骤已经完成了对 MQTT 的调整,但是由于钉钉小程序的 JavaScript 引擎不支持访问
globalThis
global
首先在
/path/to/your/mqtt.min.dd.js
globalThis
"use strict"
var globalThis = {}
// 其他代码 ...
path/to/your/mqtt.min.js
由于 MQTT 依赖的 abortcontroller-polyfill 中用到了
global
node_modules/abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js
// ...
(function (self) {
if (!polyfillNeeded(self)) {
return;
}
self.AbortController = AbortController;
self.AbortSignal = AbortSignal;
})(typeof self !== 'undefined' ? self : window); // 将这里的 global 替换为 window
path/to/your/abortcontroller-polyfill-only.js
更新 Taro 的
config/index.ts
export default defineConfig<"webpack5">(async (merge, _) => {
// ...
mini: {
compile: {
// ...
exclude: [
// ...
path.resolve(__dirname, "..", "path/to/your/mqtt.min.dd.js"),
path.resolve(__dirname, "..", "path/to/your/abortcontroller-polyfill-only.js"),
// ...
],
// ...
},
}
// ...
}
config/index.ts
最后再额外安装两个依赖:
yarn add fast-unique-numbers worker-timers-broker
然后参考下面的代码进行连接:
import 'path/to/your/abortcontroller-polyfill-only.js' // import before mqtt.
import mqtt from'path/to/your/mqtt.min.js'
const client = mqtt.connect("wxs://test.mosquitto.org", {
timerVariant: 'native' // more info ref issue: #1797
// @ts-ignore
my: dd, // 将 dd 实例传给 mqtt 用于处理 WebSocket 相关操作
log: console.log, // 传递一个 log 方法可以便于调试
});
至此就完成了在基于 Taro 的钉钉小程序中使用 MQTT 的调整,此时启动 dev server 应该就能正常运行了。
如果在小程序开发者工具中导入 Production Build 的时候报了
TypeError: Constructor Map requires 'new'
钉钉小程序无法编译 Taro Production Build——TypeError: Constructor Map requires 'new'
TypeError: Constructor Map requires 'new'
钉钉的小程序开发者工具在启动 Taro Production Build 时报错
TypeError: Constructor Map requires 'new'
node_modules/@tarojs/runtime/dist/runtime.esm.js
EventSource
// ...
class EventSource {
data = {};
get(id) {
return this.data[id];
}
set(id, value) {
this.data[id] = value;
}
delete(id) {
delete this.data[id];
}
has(id) {
return id in this.data;
}
removeNode(child) {
const { sid, uid } = child;
this.delete(sid);
if (uid !== sid && uid) this.delete(uid);
}
removeNodeTree(child) {
this.removeNode(child);
const { childNodes } = child;
childNodes.forEach((node) => this.removeNodeTree(node));
}
}
// class EventSource extends Map {
// removeNode(child) {
// const { sid, uid } = child;
// this.delete(sid);
// if (uid !== sid && uid)
// this.delete(uid);
// }
// removeNodeTree(child) {
// this.removeNode(child);
// const { childNodes } = child;
// childNodes.forEach(node => this.removeNodeTree(node));
// }
// }
const eventSource = new EventSource();
// ...
node_modules/@tarojs/runtime/dist/runtime.esm.js
完成修改后重新 build 一个 production 版本即可。