一个活动页,用 Web 组件加载运营配置的 H5 页面。原生侧需要在页面加载完成后把当前用户的信息(昵称、头像、会员等级)推给 H5,H5 拿到之后渲染个性化内容。你写了 controller.runJavaScript("window.__setUserInfo(" + JSON.stringify(user) + ")"),在 onPageEnd 里调,返回值是一个 Promise,没有报错。你在 H5 侧加了 console.log,DevTools 里一行都没有。你以为是 H5 的接收函数没定义,打开源码一看,window.__setUserInfo 明明写在 <head> 里。你又把调用挪到 onPageBegin、挪到 aboutToAppear、挪到 onControllerAttached,有时候报一个“WebviewController not associated with a Web component”的错误,有时候安静地什么都不发生。

问题不在 H5 有没有写接收函数,也不在你拼的 JS 字符串有没有语法错误。runJavaScript 的执行依赖 Web 组件的控制器已经完成关联、页面已经进入可执行 JS 的阶段,而这两个条件分别有各自的就绪信号,不是“我调了 controller 的方法,它就该执行”。你在页面还没开始加载时调,控制器可能已经关联但渲染引擎还没启动;你在 onPageBegin 里调,页面正在加载,JS 上下文可能还没创建完成;你拿到一个成功返回的 Promise,只代表指令被投递到了 Web 内核的队列,不代表 H5 的 JS 引擎已经执行了它。

onControllerAttached 之后才能安全地碰 controller

先把控制器的关联时机钉死。Web 组件的 controller 在构造时传入,但控制器与组件真正完成关联是在 onControllerAttached 回调触发的时候。在这个回调之前,controller 对象存在,但调用它的方法会得到 “WebviewController not associated with a Web component” 的错误。这个错误在 aboutToAppear 里很常见,因为 aboutToAppear 触发时组件还没完成挂载。

typescript

Web({ src: this.url, controller: this.controller })
  .onControllerAttached(() => {
    // 此时 controller 已与组件关联,可以安全调用 controller 的方法
    // 但页面还没开始加载,runJavaScript 的执行时机仍然要看页面状态
    this.controllerAttached = true;
  })
  .onPageEnd(() => {
    // 页面加载完成,DOM 和 JS 上下文都已就绪
    // 适合注入依赖页面结构的脚本
  })

onControllerAttached 解决的是“能不能调 controller”,它不解决“调用的 JS 能不能执行”。在 onControllerAttached 里调 runJavaScript,如果页面还没开始加载,指令会被投递到一个空白的初始上下文里,页面加载完成之后,新的 JS 上下文会替换掉旧的,之前投递的脚本执行结果随着旧上下文一起消失。

onPageEnd 是页面加载完成的信号,但不是所有子资源都就绪

onPageEnd 的语义是“页面加载完成”,它在主文档加载完毕、DOM 解析完成时触发。但一个典型的 H5 页面会引用外部的 JS 文件、CSS 文件、图片资源,onPageEnd 触发时,这些子资源未必全部加载完毕。如果你的注入脚本依赖某个外部 JS 库(比如 window.$ 或者 window.__setUserInfo 定义在外部文件里),在 onPageEnd 里调用 runJavaScript,有可能那个库还没执行完,注入脚本里引用的对象还是 undefined。

更稳的做法是把注入逻辑放在 H5 侧的一个确定时机,由 H5 主动通知原生“我准备好了”,原生收到通知之后再执行注入。H5 侧在 DOMContentLoaded 或者所有依赖脚本加载完成之后,调一个原生注册的轻量桥接方法,比如 nativeBridge.notifyReady(),原生在回调里再执行真正的 runJavaScript。这样注入的时机由 H5 自己决定,不依赖原生对“页面加载完成”的猜测。

typescript

// 原生侧:注册一个轻量的就绪通知
this.controller.registerJavaScriptProxy(
  {
    notifyReady: (): void => {
      // H5 侧依赖已全部就绪,此时注入用户信息
      this.injectUserInfo();
    }
  },
  'nativeBridge',
  ['notifyReady']
);

// H5 侧:依赖加载完成后主动通知
window.addEventListener('load', () => {
  window.nativeBridge.notifyReady();
});

registerJavaScriptProxy 的注册也有一个时序问题,和 runJavaScript 是同一类。注册动作发生在页面加载完成之前时,注册的对象会出现在下一个加载的页面里,当前已经加载完成的页面看不到这个对象。如果 H5 在 load 事件里调 window.nativeBridge.notifyReady(),而 registerJavaScriptProxy 是在 onPageEnd 里才调的,H5 的 load 可能已经执行过了,nativeBridge 在当时还不存在,调用会抛 TypeError。

registerJavaScriptProxy 之后要 refresh,否则当前页面看不到新对象

registerJavaScriptProxy 的官方行为是:注册的对象的函数会被注入到 Web 组件的 JS 上下文中,但当前已经加载完成的页面不会自动刷新。你需要调用 controller.refresh() 来重新加载页面,新的 JS 上下文才会包含刚注册的对象。如果页面已经加载完成,你注册了对象但没有 refresh,H5 侧访问 window.nativeBridge 会得到 undefined,然后在调用 nativeBridge.xxx() 时抛异常。

typescript

Web({ src: this.url, controller: this.controller })
  .onControllerAttached(() => {
    // 在页面加载之前注册,注册对象会随页面首次加载一起注入
    this.controller.registerJavaScriptProxy(
      this.bridge,
      'nativeBridge',
      ['notifyReady', 'getUserInfo'],
      ['getUserInfo']          // asyncMethodList,异步方法
    );
  })
  .onPageEnd(() => {
    // 如果需要注入额外的对象,注册后必须 refresh 才能让当前页面看到
    // 更好的做法是在 onControllerAttached 里一次性注册完
  })

registerJavaScriptProxy 的最佳时机是 onControllerAttached 里、页面开始加载之前。这个时机注册的对象会随页面的首次加载一起进入 JS 上下文,不需要额外的 refresh。在页面加载完成之后才注册,要么 refresh 重新加载页面(用户会看到一次闪烁),要么等下一次页面加载(本次会话看不到)。

注入的脚本要包装成 IIFE,返回值不能直接依赖表达式

runJavaScript 接收的字符串会被当作脚本执行,它不是一个函数调用,没有自动的隔离作用域。如果你拼接的脚本里有多个语句,或者依赖了页面上的全局变量,这些语句在页面上下文里的执行顺序和变量可见性需要你自己保证。

一个常见的错误是拼接了一个裸表达式,比如 "window.__userInfo",期望 runJavaScript 的返回值就是那个对象。实际上 runJavaScript 的返回值和脚本的最后一个表达式的值有关,但不同版本的行为不完全一致,依赖它拿返回值不如在脚本里显式调用一个回调。更稳的写法是把注入逻辑包成一个 IIFE,把结果通过一个约定的全局函数交回来:

typescript

const script = `(function() {
  try {
    if (typeof window.__setUserInfo === 'function') {
      window.__setUserInfo(${JSON.stringify(user)});
      return 'ok';
    }
    return 'not_ready';
  } catch (e) {
    return 'error:' + e.message;
  }
})()`;
const result = await this.controller.runJavaScript(script);

typeof window.__setUserInfo === 'function' 这一行是防御。H5 的脚本可能还没加载完,或者被内容安全策略拦截了,直接调用会抛异常,包在 IIFE 里至少能把错误信息通过返回值带回来,而不是让 runJavaScript 的 Promise 静默兑现成一个 undefined。JSON.stringify 的结果直接嵌在脚本里,如果用户信息里包含引号、换行或者 </script> 这类字符,拼接出来的脚本会语法错误。对 JSON.stringify 的结果做一次转义,或者用 JSON.stringify(JSON.stringify(user)) 再拼接,确保字符串在 JS 源码里是合法的字面量。

javaScriptAccess 默认值和内容安全策略,不是所有页面都允许执行 JS

Web 组件的 javaScriptAccess 属性控制是否允许执行 JavaScript,默认值是 true。如果你或者某个安全配置把它设成了 false,runJavaScript 不会执行,H5 页面里的内联脚本也不会执行。这个属性的关闭是静默的,runJavaScript 的 Promise 仍然会兑现,但页面里没有任何 JS 被执行。

另一个更隐蔽的拦截来自 H5 页面自身的内容安全策略。如果页面的 CSP 头里 script-src 没有包含 'unsafe-inline',通过 runJavaScript 注入的内联脚本会被浏览器引擎拒绝执行,控制台里会有一条 CSP 违规日志,但原生侧的 runJavaScript 返回值不会反映这个拒绝。排查“注入了但没效果”的时候,打开 DevTools 看控制台有没有 CSP 报错,比反复检查拼接的字符串快。

排查顺序:先看关联,再看页面阶段,最后看注册时机和 CSP

下次 runJavaScript 返回成功但 H5 没反应的时候,按这个顺序走。

第一步,确认调用发生在 onControllerAttached 之后。之前调会得到“not associated”错误,但如果你把错误吞了,表现就是“没反应”。

第二步,确认页面处于可执行 JS 的阶段。onPageBegin 太早,JS 上下文可能还没创建;onPageEnd 相对安全,但如果注入依赖外部脚本,等 H5 主动通知更稳。在 aboutToAppear 里调 runJavaScript,控制器可能还没关联,页面更没开始加载。

第三步,如果是 registerJavaScriptProxy 注册的对象 H5 访问不到,检查注册时机是不是在页面加载完成之后。页面加载完成后注册的对象需要 refresh 才对当前页面生效,最佳实践是在 onControllerAttached 里一次性注册完。

第四步,检查 javaScriptAccess 是否为 true,DevTools 控制台有没有 CSP 违规日志。这两个拦截都不会让 runJavaScript 的 Promise 失败。

第五步,检查拼接的脚本有没有语法错误,用户信息里的特殊字符有没有转义,注入逻辑有没有包在 IIFE 里做防御。

runJavaScript 的 Promise 兑现只说明指令被投递到了 Web 内核的队列,它不承诺页面处于可执行状态、不承诺 H5 的接收函数已经定义、不承诺 CSP 允许这次执行。控制器关联看 onControllerAttached,页面就绪看 onPageEnd 或 H5 的主动通知,代理注册要在页面加载之前完成,注入脚本要防御、要转义、要能带回错误信息。下一次注入的脚本石沉大海的时候,先别怀疑 H5 的代码写错了——打开原生侧看一眼,那个 runJavaScript,到底是在页面生命周期的哪一步被调用的。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐