@lark-base-open/base-site-channel
v0.1.1
Published
用于宿主页面与 iframe 页面之间的双向调用。
Maintainers
Keywords
Readme
@lark-base-open/base-site-channel
用于宿主页面与 iframe 页面之间的双向调用。
适用场景
- 宿主页面需要嵌入一个 iframe 页面
- 宿主页面需要主动调用 iframe 内提供的能力
- iframe 页面也需要反向调用宿主页面提供的能力
- 希望通过统一的调用方式管理双方通信
对外能力
- Host 侧创建 iframe 容器并加载目标页面
- Host 侧监听页面加载状态
- Host 侧注册可被 Client 调用的方法
- Host 侧主动调用 Client 提供的方法
- Host 侧订阅 Client 派发的事件
- Host 侧主动向 Client 派发事件
- Client 侧注册可被 Host 调用的方法
- Client 侧主动调用 Host 提供的方法
- Client 侧订阅 Host 派发的事件
- Client 侧主动向 Host 派发事件
- Client 侧直接使用内置批量二进制拉取能力,从 Host 内部 worker 拉取 URLList
导出内容
import { Host, LoadStatus, client } from "@lark-base-open/base-site-channel";快速开始
1. 在 Host 页面创建通信实例
Host 负责:
- 创建 iframe
- 加载 client 页面地址
- 监听加载状态
- 注册给 client 使用的方法
- 调用 client 方法
- 订阅 client 事件
- 派发事件给 client
import { Host, LoadStatus } from "@lark-base-open/base-site-channel";
const container = document.getElementById("app");
if (!container) {
throw new Error("container not found");
}
const host = new Host(
container,
(status, errorInfo) => {
if (status === LoadStatus.ERROR) {
console.error("页面加载失败", errorInfo);
}
},
"demo-block",
);
host.setClientUrl("https://example.com/view");2. 在 Host 页面注册可被 Client 调用的方法
host.registerApi<{ userId: string }, { name: string }>(
"getUserInfo",
async (params) => {
return {
name: `user-${params.userId}`,
};
},
);3. 在 Host 页面主动调用 Client 方法
const result = await host.invokeApi<{ keyword: string }, { total: number }>(
"searchData",
{
keyword: "hello",
},
);4. 在 Host 页面订阅 Client 事件
const unsubscribe = host.subscribeEvent<{ count: number }>(
"dataChange",
(payload) => {
// 监听 client 派发的事件
console.log("host receive dataChange", payload.count);
},
);
// 不再需要时取消订阅
unsubscribe();5. 在 Host 页面主动向 Client 派发事件
await host.emitEvent("themeChange", {
theme: "dark",
});Client 页面使用方式
Client 侧直接使用库导出的单例 client,适合在 iframe 页面中直接接入。
1. 在 Client 页面注册可被 Host 调用的方法
import { client } from "@lark-base-open/base-site-channel";
client.registerApi<{ keyword: string }, { total: number }>(
"searchData",
async (params) => {
return {
total: params.keyword.length,
};
},
);2. 在 Client 页面主动调用 Host 方法
const userInfo = await client.invokeApi<{ userId: string }, { name: string }>(
"getUserInfo",
{
userId: "1001",
},
);3. 在 Client 页面订阅 Host 事件
const unsubscribe = client.subscribeEvent<{ theme: string }>(
"themeChange",
(payload) => {
// 监听 host 派发的事件
console.log("client receive themeChange", payload.theme);
},
);
// 不再需要时取消订阅
unsubscribe();4. 在 Client 页面主动向 Host 派发事件
await client.emitEvent("dataChange", {
count: 1,
});推荐使用流程
Host 侧流程
- 准备一个用于挂载 iframe 的容器元素
- 创建
Host实例,建议传入唯一的instanceId - 先通过
registerApi暴露宿主能力 - 再调用
setClientUrl加载 iframe 页面 - 在需要监听 client 状态变化时通过
subscribeEvent订阅事件 - 在业务需要时通过
invokeApi调用 client 能力,或通过emitEvent派发事件 - 页面卸载时调用
destroy
Client 侧流程
- 在 iframe 页面中引入
client - 通过
registerApi暴露 iframe 页面能力 - 在需要监听 host 状态变化时通过
subscribeEvent订阅事件 - 在业务需要时通过
invokeApi调用 host 能力,或通过emitEvent派发事件
加载状态说明
LoadStatus 包含以下几种状态:
pending:初始状态loading:正在加载 iframe 页面loaded:页面已可正常通信error:加载失败或通信不可用
常见处理方式:
loading时展示加载中状态loaded后再执行依赖 iframe 的业务操作error时展示错误提示,并根据需要允许重试
构造参数说明
new Host(containerEl, onLoadStatusChange, instanceId, allowedOrigin, extraAttributes, options)
containerEl:iframe 挂载容器onLoadStatusChange:页面状态变化回调instanceId:当前通信实例标识,建议保持唯一allowedOrigin:允许通信的绝对 URL 或 origin 列表;非法配置会被拒绝,不支持正则或通配符extraAttributes:附加到 iframe 上的属性options:Host 可选配置对象options.enableBinaryFetch:是否允许 Client 调用内置二进制 fetch,默认false;Site Runtime 接入必须保持关闭
通信日志
Host 与 Client 的握手、invokeApi/registerApi、emitEvent/subscribeEvent、回调和销毁日志统一在 Channel API 内部记录。运行环境存在 window.BearWebSlardarWeb 时通过 sendLog 上报,统一使用 event_name=Runtime_SiteChannel_Message,并通过 channel_side、channel_message、key 等脱敏字段区分阶段;没有 Slardar 实例或实例调用失败时,降级到对应级别的 console.info/warn/error。
Client 调用 invokeApi 时生成 logId 并记录 api request sent;Host 收到后记录 api request received,返回时记录 api response sent 并复用同一个 logId;Client 收到回调后记录 api response received。这些日志只记录操作阶段、方法 key、状态和错误类型等必要元数据,不记录请求参数或响应内容;标识符只记录是否存在。业务方不需要在每个 Channel 调用点重复打印通信日志。
invoke 与 callback 已由上述 API 语义日志覆盖,不再额外记录 send message to host/client 或 receive host/client message;通用收发日志仅用于握手、事件等其他消息类型。
Site Runtime
本包同时提供 Site iframe 专用 Runtime 门面,业务方不需要直接操作底层 Channel:
import {
getCurrentUser,
invokeHttp,
logout,
requestEdit,
siteRuntime,
} from "@lark-base-open/base-site-channel/runtime";
const response = await invokeHttp({ url: "/base-site/api/example" });
const currentUser = await getCurrentUser();
await requestEdit();
await logout();
await siteRuntime.ready();./runtime:子页面使用的 HTTP、Function、生命周期和 Chatbot stream API./runtime-contract:父子页面共享的site.v1key、payload、类型和稳定错误码requestEdit():通知父页面申请进入编辑态getCurrentUser():读取父页面登录用户信息,稳定包含name、avatarUrllogout():请求父页面退出当前登录态- 上述三个方法统一通过
site.v1.function.invoke调用内置 Runtime Function,不新增平级 Channel API
常用方法说明
Host
setClientUrl(url):设置并加载 client 页面地址registerApi(key, handler):注册给 client 使用的方法invokeApi(key, params):调用 client 方法subscribeEvent(key, handler):订阅 client 派发的事件,返回取消订阅函数unsubscribeEvent(key, handler?):取消指定事件订阅,不传handler时移除该事件全部订阅emitEvent(key, params):主动向 client 派发事件destroy():销毁实例并释放资源
Client
registerApi(key, handler):注册给 host 使用的方法invokeApi(key, params):调用 host 方法subscribeEvent(key, handler):订阅 host 派发的事件,返回取消订阅函数unsubscribeEvent(key, handler?):取消指定事件订阅,不传handler时移除该事件全部订阅emitEvent(key, params):主动向 host 派发事件fetchBinaryList(params):发起内置批量二进制拉取任务,返回{ jobId }subscribeBinaryFetchProgress(handler):订阅内置批量二进制拉取进度cancelBinaryFetch(jobId):取消指定的二进制拉取任务
内置批量二进制拉取能力
这套能力用于 Host <-> iframe 场景下的大文件、图片密文或附件密文传输。
为什么 iframe 里直接拉 URL 会被拦
在很多接入场景里:
- iframe 页面运行在外网沙箱域名
- 文件下载地址位于内网域名或受限 CDN 域名
- iframe 内直接执行
fetch(url).arrayBuffer()时会受到浏览器 CORS 限制
即使业务已经拿到下载地址,只要浏览器判定当前 iframe 页面来源无权访问该资源,请求仍然会失败。
为什么把拉取动作放到 Host
Host 页面通常运行在具备访问权限的环境中,可以正常访问这些内网或受限域名。因此更合理的方案是:
- iframe 只负责描述“要拉哪些 URL”
- Host 在内部 worker 中真正执行批量下载
- 每个文件下载完成后立刻把完整
ArrayBuffer渐进式回传给 iframe - iframe 拿到密文 bytes 后再自行解密、生成
Blob或blobUrl
业务层不需要自己额外封装 host worker,也不需要自己设计底层 postMessage 协议。
为什么回传要使用 Transferable
二进制文件可能很大,如果直接走普通结构化拷贝,主线程和 iframe 之间会产生额外复制成本。内置能力会使用:
postMessage(message, "*", [arrayBuffer]);把 ArrayBuffer 作为 Transferable 传输,避免大块二进制在通信层重复拷贝,更适合图片密文、附件密文这类场景。
对外 API
Client / iframe 侧直接使用以下内置 API:
type BinaryFetchItem = {
itemId: string;
url: string;
};
type BinaryFetchParams = {
items: BinaryFetchItem[];
concurrency?: number;
credentials?: 'omit' | 'same-origin' | 'include';
};
type BinaryFetchProgress =
| {
type: 'job-start';
jobId: string;
total: number;
}
| {
type: 'item-complete';
jobId: string;
itemId: string;
totalBytes?: number;
buffer: ArrayBuffer;
}
| {
type: 'item-error';
jobId: string;
itemId: string;
error: string;
}
| {
type: 'job-complete';
jobId: string;
successCount: number;
failCount: number;
}
| {
type: 'job-cancelled';
jobId: string;
};
client.fetchBinaryList(params: BinaryFetchParams): Promise<{ jobId: string }>;
client.subscribeBinaryFetchProgress(handler: (progress: BinaryFetchProgress) => void): () => void;
client.cancelBinaryFetch(jobId: string): Promise<void>;说明:
- 不需要业务层手动
registerApi - Host 会在内部自动启用专用 worker
- 默认支持并发控制
- 单个 job 默认并发为
4 - 单 worker 下的全局总并发上限默认是
6 credentials可选,默认使用same-origin- 每个文件下载完成后会一次性回传完整
ArrayBuffer - 如果握手未完成,
fetchBinaryList和cancelBinaryFetch会像invokeApi一样直接 reject
如何发起 URLList 拉取
import { client } from "@lark-base-open/base-site-channel";
const { jobId } = await client.fetchBinaryList({
items: [
{
itemId: "file-token-1",
url: "https://internal-cdn.example.com/file-1",
},
{
itemId: "file-token-2",
url: "https://internal-cdn.example.com/file-2",
},
],
concurrency: 4,
credentials: "include",
});如何订阅 progress
const unsubscribe = client.subscribeBinaryFetchProgress((progress) => {
switch (progress.type) {
case "job-start":
console.log("binary fetch job start", progress.jobId, progress.total);
break;
case "item-complete":
console.log(
"binary fetch item complete",
progress.jobId,
progress.itemId,
progress.totalBytes,
);
break;
case "item-error":
console.error(
"binary fetch item error",
progress.jobId,
progress.itemId,
progress.error,
);
break;
case "job-complete":
console.log(
"binary fetch job complete",
progress.jobId,
progress.successCount,
progress.failCount,
);
break;
case "job-cancelled":
console.warn("binary fetch job cancelled", progress.jobId);
break;
}
});如何取消任务
await client.cancelBinaryFetch(jobId);
unsubscribe();完整示例
下面的示例演示:
- client 发起
fetchBinaryList - client 监听
item-complete - 业务层按
itemId接收完整ArrayBuffer - 最终在 iframe 中自行解密并生成
blobUrl
import { client } from "@lark-base-open/base-site-channel";
const decryptedUrlMap = new Map<string, string>();
let currentJobId = "";
const unsubscribe = client.subscribeBinaryFetchProgress(async (progress) => {
if (progress.type === "job-start") {
currentJobId = progress.jobId;
console.log("job started", progress.jobId, progress.total);
return;
}
if (progress.type === "item-error") {
console.error("item fetch failed", progress.itemId, progress.error);
return;
}
if (progress.type === "item-complete") {
const encryptedBuffer = progress.buffer;
const decryptedBuffer = await decryptFileBytes(
encryptedBuffer,
progress.itemId,
);
const blob = new Blob([decryptedBuffer]);
const blobUrl = URL.createObjectURL(blob);
decryptedUrlMap.set(progress.itemId, blobUrl);
console.log("blob url ready", progress.itemId, blobUrl);
return;
}
if (progress.type === "job-complete") {
console.log(
"all items finished",
progress.successCount,
progress.failCount,
);
return;
}
if (progress.type === "job-cancelled") {
console.warn("job cancelled", progress.jobId);
}
});
const { jobId } = await client.fetchBinaryList({
items: [
{ itemId: "image-a", url: "https://internal-cdn.example.com/image-a" },
{ itemId: "image-b", url: "https://internal-cdn.example.com/image-b" },
],
concurrency: 4,
});
function decryptFileBytes(
buffer: ArrayBuffer,
itemId: string,
): Promise<ArrayBuffer> {
console.log("decrypt file bytes", itemId);
return Promise.resolve(buffer);
}
// 业务需要时可以取消
if (shouldCancelJob(jobId)) {
await client.cancelBinaryFetch(jobId);
}
function shouldCancelJob(targetJobId: string) {
return currentJobId === targetJobId && false;
}进度消息结构
内置 progress 消息包含以下几种类型:
type BinaryFetchProgress =
| {
type: "job-start";
jobId: string;
total: number;
}
| {
type: "item-complete";
jobId: string;
itemId: string;
totalBytes?: number;
buffer: ArrayBuffer;
}
| {
type: "item-error";
jobId: string;
itemId: string;
error: string;
}
| {
type: "job-complete";
jobId: string;
successCount: number;
failCount: number;
}
| {
type: "job-cancelled";
jobId: string;
};Event 使用说明
事件通信适合处理“状态变化通知”这类不需要返回值的场景,比如:
- 宿主页面主题切换后通知 iframe 页面更新样式
- iframe 页面数据变更后通知宿主页面刷新外层状态
- 双方同步选中态、表单状态、可见性等轻量级状态
与 invokeApi 的区别:
invokeApi适合请求-响应场景,调用方需要拿到返回值emitEvent适合单向通知场景,发送后不等待业务返回值subscribeEvent用于订阅对端派发的事件
Host 订阅 Client 事件
const stopListenDataChange = host.subscribeEvent<{ count: number }>(
"dataChange",
(payload) => {
// 当 iframe 页面发生数据变化时,宿主页面会收到通知
console.log("host receive dataChange", payload.count);
},
);Client 向 Host 派发事件
client.emitEvent("dataChange", {
// 通知 host 当前数据已变化
count: 1,
});Client 订阅 Host 事件
const stopListenThemeChange = client.subscribeEvent<{
theme: "light" | "dark";
}>("themeChange", (payload) => {
// 监听 host 的主题变化事件
document.body.dataset.theme = payload.theme;
});Host 向 Client 派发事件
client.emitEvent("themeChange", {
// 通知 client 同步主题
theme: "dark",
});取消订阅
推荐优先使用 subscribeEvent 返回的取消函数:
const unsubscribe = client.subscribeEvent<{ visible: boolean }>(
"visibleChange",
(payload) => {
// 处理可见性变化
console.log(payload.visible);
},
);
unsubscribe();也可以通过 unsubscribeEvent 主动移除:
const onVisibleChange = (payload: { visible: boolean }) => {
// 处理可见性变化
console.log(payload.visible);
};
client.subscribeEvent("visibleChange", onVisibleChange);
client.unsubscribeEvent("visibleChange", onVisibleChange);使用建议:
- 事件
key建议使用明确的业务语义名称,例如themeChange、dataChange - 事件更适合通知类场景,不建议替代有返回值的接口调用
- 建议在页面销毁、组件卸载时及时取消订阅,避免重复监听
- 事件参数建议保持轻量、稳定,避免传递难以序列化的数据
invokeApi 报错说明
invokeApi 在调用失败时会返回一个被拒绝的 Promise,错误对象格式如下:
type InvokeApiError = {
code: number;
msg: string;
};建议统一使用 try/catch 处理:
try {
const result = await host.invokeApi<{ id: string }, { success: boolean }>(
"saveDetail",
{
id: "1",
},
);
console.log(result);
} catch (error) {
const invokeError = error;
console.error(
"调用失败",
invokeError?.code,
invokeError?.msg || invokeError?.message,
);
}常见报错场景:
- 业务接口报错
- iframe 页面加载失败
- 当前加载地址不在允许通信范围内
- 页面未完成初始化,导致调用超时
- 对端方法内部执行失败,并返回业务错误
常见错误码:
- 业务接口错误码
-1:未知错误10001:iframe 加载失败10002:当前页面地址不允许通信10003:页面初始化超时10004:对端未注册该 API10005:调用超时10006:通道已销毁10007:内置二进制下载能力未开启10008:同一请求 id 被新请求顶替10009:当前环境不具备建联条件(非 iframe 环境、拿不到 host origin、Host 尚未setClientUrl)
处理建议:
- 在
loaded状态后再调用invokeApi - Host 侧先完成
registerApi,再执行setClientUrl - 将业务异常转换为明确的
code和msg返回,便于调用方统一处理
完整示例
Host 页面
import { Host, LoadStatus } from "@lark-base-open/base-site-channel";
const container = document.getElementById("app");
if (!container) {
throw new Error("container not found");
}
const host = new Host(
container,
(status) => {
if (status === LoadStatus.LOADED) {
console.log("client 页面已就绪");
}
},
"demo-block",
);
host.registerApi<{ id: string }, { title: string }>(
"getDetail",
async (params) => {
return {
title: `detail-${params.id}`,
};
},
);
const stopListenDataChange = host.subscribeEvent<{ count: number }>(
"dataChange",
(payload) => {
console.log("host receive dataChange", payload.count);
},
);
host.setClientUrl("https://example.com/client");
const response = await host.invokeApi<{ id: string }, { success: boolean }>(
"saveDetail",
{
id: "1",
},
);
host.emitEvent("themeChange", {
theme: "dark",
});
stopListenDataChange();Client 页面
import { client } from "@lark-base-open/base-site-channel";
client.registerApi<{ id: string }, { success: boolean }>(
"saveDetail",
async (params) => {
return {
success: params.id.length > 0,
};
},
);
const stopListenThemeChange = client.subscribeEvent<{ theme: string }>(
"themeChange",
(payload) => {
document.body.dataset.theme = payload.theme;
},
);
const detail = await client.invokeApi<{ id: string }, { title: string }>(
"getDetail",
{
id: "1",
},
);
client.emitEvent("dataChange", {
count: 1,
});
stopListenThemeChange();使用建议
key建议按业务语义命名,避免重复instanceId建议为每个 iframe 实例保持唯一- 在 Host 页面销毁、切页或重新挂载时主动调用
destroy - 只有在 iframe 页面已经可用时再发起业务调用
