Numable 创作文档同一份内容也在终端里:`numable docs <主题>`English

bridge —— H5 页 JS 桥契约

读者:做工具的用户,和替他干活的 AI。两者读同一份。

它是什么

window.xbridge 是 html 页面唯一能碰到宿主的口子。页面跑在容器注入了 CSP 的沙盒里:fetch / XMLHttpRequest 直连被焊死(connect-src 'none',图片是例外),所以取数、导航、弹提示、加组件、写参数,一律经这个对象。

它是 ActionFlow 能力面的第二个外观 —— 大部分方法背后就是同一条 action 的同一份实现,所以「桥能做什么」基本等于「.af 能做什么」(全表见 numable docs af)。只有 xpage / xform 页面用不到它:那两型页面直接绑 .df / .af。

只在 html 页里用。写之前先判环境,普通浏览器里 window.xbridge 是 undefined。

最小可用示例

<script>
  // 1. 判环境:浏览器里直接预览时不炸
  const inApp = !!(window.xbridge && xbridge.isXBundleEnv());

  // call 型方法回的是信封 { code, msg, data }:code === 0 才算成功,结果在 data。统一拆一层
  async function call(p) {
    const r = await p;
    if (!r || r.code !== 0) throw new Error((r && r.msg) || "bridge error");
    return r.data;
  }

  async function load() {
    if (!inApp) return;

    // 2. 取数:调 page/flow/home.df(runDataFlow 只搜 .df)
    const data = await call(xbridge.runDataFlow("home", { id: new URLSearchParams(location.search).get("id") || "" }));
    document.getElementById("n").textContent = data.count;

    // 3. 读 App 环境:一定要 await
    const app = await call(xbridge.appInfo());     // { theme, language, locale, … }
    document.documentElement.dataset.lang = app.language;
  }

  async function reset() {
    // 4. 二次确认用桥的,不用 window.confirm
    if (!(await call(xbridge.confirm("清空记录?", "此操作不可撤销")))) return;
    xbridge.haptic("warning");
    await call(xbridge.runActionFlow("reset"));    // 副作用流,.af
    xbridge.toast("已清空", "success");
  }

  // 5. 添加组件入口:一个按钮,打开面板由用户自己挑
  function addCard() { xbridge.pickWidgets([{ id: "today", params: { habit: "water" } }]); }

  load();
</script>

分组用例

上面那段覆盖了最常走的路。剩下的方法各给一行,复制就能用(call() 就是上面那个拆信封的 helper)。

取数与本地数据

const data = await call(xbridge.runDataFlow("home", { id: "1" }));   // page/flow/home.df
const r    = await call(xbridge.runActionFlow("save", { v: "1" }));  // page/flow/save.af,副作用流
await call(xbridge.setData("watchlist", JSON.stringify(["AAPL", "TSLA"])));   // 值只能是字符串
const list = JSON.parse((await call(xbridge.getData("watchlist"))) || "[]");  // 没存过时是空,自己兜底

getData / setData 的命名空间恒是本包,页面指定不了;它们对应 .af 里的 data.get / data.set,同一份存储,页面和流看得见彼此写的东西。

反馈

xbridge.showLoading("正在同步…");     // notify,不 await
try { await call(xbridge.runActionFlow("sync")); } finally { xbridge.hideLoading(); }
await xbridge.alert("今天已经打过组件了", "明天再来");
if (await call(xbridge.confirm("清空记录?", "此操作不可撤销"))) { /* … */ }
const r = await call(xbridge.singleValue({
  container: "sheet",
  title: "选择城市",
  component: { type: "select", value: "bj",
    props: { items: [{ label: "北京", value: "bj" }, { label: "上海", value: "sh" }] } }
}));
if (r && !r.cancelled) { /* 值在 r.value.value */ }

showLoading / hideLoading 是 notify:必须自己配对收起来,尤其是取数抛错那条路(所以写 finally)。忘了收,页面就永远盖着一层遮罩,而且不报错。

组件

xbridge.pickWidgets([{ id: "today", params: { habit: "water" } }]);  // 请用户挑,面板里才落盘
xbridge.removeWidget("today");                                       // 直接落盘,删这个组件的全部实例
const p = await call(xbridge.updateParams(brickId, { city: "shanghai" })); // 只有参数页拿得到 brickId
await call(xbridge.refreshWidget());                                       // data = { refreshed: 张数 }

⚠️ removeWidget 不过面板、直接落盘,而且删的是该组件的全部实例(用户可能摆了三张各带各的参数)。调它之前自己加一次 xbridge.confirm。

环境

const app  = await call(xbridge.appInfo());           // 一定要 await:{ platform, appVersion, theme, language, locale }
const mine = await xbridge.installedBundles();        // 只对系统包开放,自制包拿到的是 code 非 0
const cred = await call(xbridge.credentialState("github"));  // { state: bound|unbound|expired, fp }

installedBundles() 在自制包里调不出东西,别围着它设计「已装/未装」两态 UI。判某个方法在不在,用 typeof xbridge.x === "function" —— 门面上挂着什么就是页面能调到什么,没有第二份清单要核对。

调用形态

方法全表(26)

每个平台上都是这一张表 —— 同名、同签名、同行为。表里没有的名字就是没有。

方法 形态 签名 返回 对应 AF action
isXBundleEnv — () true(浏览器里整个 xbridge 是 undefined) —
runDataFlow call (flow, params?, timeout?) 流的 resultFilter 输出 —
runActionFlow call (flow, params?, timeout?) 流结果 —
runFlow call (flow, params?, timeout?) 流结果;等价 runActionFlow,只搜 .af —
getData call (key) 值(字符串) data.get
setData call (key, value) — data.set
credentialState call (declId) { state, fp } —— state = bound / unbound / expired,fp 换绑即变(可作缓存键的一维) credential.state
route notify (url) — nav.open
closePage notify () — page.close
setResult notify (payload) — page.setResult
toast notify (msg, type?) — ui.toast;type = success / error,其余值按普通提示渲
haptic notify (type?) — ui.haptic
alert call (title, message?, okText?) void ui.alert
confirm call (title, message?, opts?) boolean ui.confirm
showLoading notify (text?) — ui.showLoading
hideLoading notify () — ui.hideLoading
singleValue call (config) { value: { value }, cancelled } singleValue
pickWidgets notify (items?) — widget.pick
removeWidget notify (widgetId) — 删该 widget 的全部实例,直接落盘
updateParams call (brickId, params) 合并后的 params widget.updateParams
refreshWidget call (pid?) { refreshed } widget.refresh
alertAdd call ({ id, params? }) ok / cancel / quota alert.add
alertRemove call ({ id, params? }) { removed } alert.remove;需要 manifest.minEngine 至少 3,旧版应用的门面上没有它
appInfo call () { platform, appVersion, theme, language, locale }(language 与 locale 同值) —
installBundle notify (id, version?, ref?) — —
installedBundles call () 已装包清单(只对系统包开放) —

语义纪律

这几条不是「用法」,是「用错了不会报错」的地方。

容器环境

页面能从容器拿到四样东西,全都不需要调方法。

CSS 变量(容器注入,自己在 :root 里写一份 0 兜底,浏览器里预览才不塌):

:root { --xb-content-top: 0px; --xb-content-bottom: 0px; }
body { padding: calc(var(--xb-content-top) + 16px) 16px calc(var(--xb-content-bottom) + 16px); }
变量 含义
--xb-content-top 顶部要让出的高度(安全区 + 浮动 chrome 占位)。99% 的页面该读的是这个
--xb-content-bottom 底部要让出的高度
--xb-safe-bottom 纯系统安全区底部
--xb-safe-top 纯系统安全区顶部。只有手机上有,桌面版没有 —— 别拿它当唯一让位依据
--xb-bar-bottom 滚动后那条小标题栏的底边(安全区 + 44)。页内吸顶工具条写 position: sticky; top: var(--xb-bar-bottom),贴在栏下面;写 top: 0 会钻到栏底下

主题与语言:容器在 documentElement 上打 data-theme(light / dark)与 data-lang(语言码),切换时改属性 + 派发事件,不重载页面:

addEventListener("themechange", (e) => {/* e.detail.theme */});
addEventListener("languagechange", (e) => {/* e.detail.language */});

写 [data-theme="dark"] … 的 CSS,不要靠 prefers-color-scheme:页面在 iframe / WebView 里,那个媒体查询跟的是宿主系统,App 内切主题拨不动它。

网络:CSP connect-src 'none',fetch / XHR 一律走不通,而且失败得很安静(被 try/catch 一吞就全程无日志)。所有数据经 runDataFlow / runActionFlow。

路由参数:页面读 location.search,和普通网页一样。

规则(违反 = 返工)

规则 检查方式 违反时的现象 修法
调的 flow 必须存在,且后缀对得上方法(runDataFlow→page/flow/<名>.df;runFlow / runActionFlow→.af) check G12b 页面显示「加载失败(-6)」,flow not found 改后缀或改方法名,二者取其一
输入控件显式 font-size ≥ 16px check G15 iOS 上聚焦输入框整页被放大,失焦不完全缩回 CSS 里写 input, select, textarea { font-size: 16px; }
单个文件里的加组件调用不超过 2 处;加组件页不出现「已添加」字样 check G24(W) 页面里手抄了一份组件目录,新增组件忘改就永远加不到 收成一个按钮 + pickWidgets(items)
只调方法全表里的桥方法 check G42 裸调 = TypeError(E);带 if (xbridge.x) 守卫 = 不崩,但那条回落分支从此永远生效(W) 对着方法全表核名字;没有对应能力就别留那条假分支
添加组件按钮用平台基准版式(.pickb CSS 块逐字相等) check G27(W) 同一个平台动作在每个包里长得不一样 别手改版式,按报错提示重新生成那一份
appInfo() 必须 await 人审 同步读 .theme / .language 拿到的是 Promise 上不存在的属性 → 主题恒浅色、语言恒基准语言,且不报错 const app = await xbridge.appInfo();
call 型返回的是信封,结果在 .data check G49(W) 字段全是 undefined;confirm 点了「取消」照样执行 —— 都不报错 写一个 call() 拆信封(code !== 0 抛错,否则返回 data),所有 call 型都经它
页面里不出现 fetch / XMLHttpRequest 直连 人审 数据永远为空,控制台常被自己的 try/catch 吞掉 一律改走 runDataFlow

出错怎么办

现象 最可能的原因 先做什么
页面显示「加载失败(-6)」 flow 名或后缀对不上方法 跑 numable check,看 G12b
数据永远是空的,也不报错 用了 fetch 直连被 CSP 拦 全文搜 fetch( / XMLHttpRequest
主题恒浅色、语言恒中文 appInfo() 忘了 await 补 await;或改读 documentElement.dataset
切主题/语言后页面没变 只在首屏读了一次属性 监听 themechange / languagechange 重渲
弹窗跑到窗口中央、大屏上溢出容器 用了 window.confirm / window.alert 换 xbridge.confirm / xbridge.alert
点「添加到仪表盘」没有任何反应 pickWidgets(items) 里一个 id 都不认识 对着 xWidget/*.xwidget 的文件名核 id
某个桥调用像是完全没发生(值没变、页面没动),控制台也干净 方法名不在全表里 —— 被自己的 try/catch 吞了,或者那句 if (xbridge.x) 守卫直接走了回落 跑 numable check,看 G42
页面永远盖着一层 loading 遮罩 showLoading 之后取数抛了错,hideLoading 没走到 把 hideLoading 放进 finally
getData 读出来是空,明明存过 存的时候没 JSON.stringify,对象被存成 [object Object] 存字符串,读出来自己 JSON.parse 并兜底
用户少了两个组件,谁也没删 页面调了 removeWidget,它删的是该组件全部实例 调用前补一次 xbridge.confirm

相关