在这里插入图片描述

本文是一篇从"踩坑"到"想通"的实战记录,围绕 HarmonyOS 中 Web 组件与 ArkTS 应用侧的双向调用,记录原理、对比、代码与心得。

为什么前端页面需要"反过来"调应用侧

做混合开发久了,会形成一种惯性思维:应用侧是"宿主",前端页面是"被加载的内容",方向永远是从宿主去驱动内容。但在真实业务里,这个方向经常需要反过来。

最典型的几个场景:

  • 前端 H5 页面里点一个"保存到本地"按钮,需要触发 ArkTS 把数据写入应用沙箱或偏好数据库;
  • H5 想拿到设备唯一标识、系统版本、网络状态这些只有原生才有的信息;
  • 前端发起一个需要鉴权的请求,要把 token 的获取和刷新交给应用侧统一管理;
  • 页面内嵌的富文本编辑器要调起原生的图片选择器、相机或文件系统。

这些需求的共同点是:数据或能力的"源头"在应用侧,而"触发时机"在前端。如果每次都绕一圈走 HTTP 接口或者 postMessage 那套老办法,既慢又脆。鸿蒙的 ArkWeb 给了一条更直接的路——把 ArkTS 对象"注入"到前端,让前端像调用一个普通 JS 对象那样调用应用侧函数。

这个机制叫 JavaScriptProxy T R A E R E F ] ( h t t p s : / / b l o g . s e g m e n t f a u l t . c o m / a / 1190000046913687 ) [ TRAE_REF](https://blog.segmentfault.com/a/1190000046913687)[ TRAEREF](https://blog.segmentfault.com/a/1190000046913687)[TRAE_REF

两种注册方式:不是二选一,而是时机不同

ArkWeb 提供了两个接口把 ArkTS 对象注册到前端页面,初学时很容易把它们当成"两种等价写法",其实它们的核心差异在于调用时机

javaScriptProxy():Web 组件初始化时注入

javaScriptProxy() 是 Web 组件的链式方法,跟着 Web(...) 一起声明,在组件初始化阶段就把对象注入到前端页面。这意味着页面一加载,注册的对象就已经存在,前端可以无感调用,不会出现"页面跑得太快、对象还没注册"的时序问题。

适合那些从第一行 JS 就要用到的对象,比如全局配置、设备信息、基础工具方法。

// xxx.ets
import { webview } from '@kit.ArkWeb';

class DeviceInfoClass {
  constructor() {}

  getOSVersion(): string {
    return 'HarmonyOS 5.0';
  }

  getDeviceId(): string {
    return 'device-uuid-xxxx';
  }

  saveToLocal(key: string, value: string): void {
    console.log(`保存到本地:${key} = ${value}`);
  }
}

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State deviceInfo: DeviceInfoClass = new DeviceInfoClass();

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
        .javaScriptProxy({
          object: this.deviceInfo,
          name: 'deviceInfo',
          methodList: ['getOSVersion', 'getDeviceId', 'saveToLocal'],
          controller: this.webviewController,
          asyncMethodList: [],
          permission: ''
        })
    }
  }
}

前端这边就像调一个全局对象:

<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
  <button onclick="showInfo()">获取设备信息</button>
  <p id="info"></p>
  <script>
    function showInfo() {
      const os = deviceInfo.getOSVersion();
      const id = deviceInfo.getDeviceId();
      document.getElementById('info').innerText = `系统:${os},设备ID:${id}`;
    }
  </script>
</body>
</html>

javaScriptProxy() 的参数里有几个容易忽略的点:methodList 声明哪些方法会被暴露,没在列表里的方法前端调不到;asyncMethodList 单独声明异步方法,和 methodList 是分开的;permission 控制哪些 URL 能访问,留空表示不做限制(开发期方便,上线前一定要补上)。

registerJavaScriptProxy():初始化完成后动态注册

registerJavaScriptProxy()WebviewController 的方法,不跟在 Web 组件声明里,而是在组件初始化完成之后、由业务代码主动调用。它的价值在于"动态"——可以根据运行时条件决定注册什么对象,或者在页面加载到某个阶段后再注入。

这里有一个官方文档反复强调、但我第一次用就踩到的坑:注册之后必须调用 refresh() 才能生效$TRAE_REF

// xxx.ets
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

class UserServiceClass {
  constructor() {}

  getToken(): string {
    return 'token-from-arkts';
  }

  refreshToken(): string {
    return 'token-refreshed';
  }
}

@Entry
@Component
struct WebComponent {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State userService: UserServiceClass = new UserServiceClass();

  build() {
    Column() {
      Button('动态注册 UserService')
        .onClick(() => {
          try {
            this.webviewController.registerJavaScriptProxy(
              this.userService,
              'userService',
              ['getToken', 'refreshToken']
            );
            // 关键:注册后必须 refresh 才生效
            this.webviewController.refresh();
          } catch (error) {
            const e = error as BusinessError;
            console.error(`ErrorCode: ${e.code}, Message: ${e.message}`);
          }
        })

      Web({ src: $rawfile('index.html'), controller: this.webviewController })
    }
  }
}

我第一次写的时候漏掉了 refresh(),前端调用一直报 userService is not defined,排查了将近半小时才意识到注册和生效是两步。这个设计其实是合理的——动态注册往往伴随着页面状态变更,refresh() 让开发者显式控制生效时机,避免注册到一半就被前端调到。但文档里如果不特别强调,确实容易漏。

两种方式的对比

维度 javaScriptProxy() registerJavaScriptProxy()
调用主体 Web 组件(链式声明) WebviewController
调用时机 组件初始化阶段 初始化完成后任意时机
是否需要 refresh 不需要 必须调用 refresh()
适合场景 全局基础对象、首次加载就要用的能力 动态注入、条件注册、分阶段加载
时序风险 低,对象在页面加载前就绪 高,需自行保证注册早于调用

选型上我的体会是:能用 javaScriptProxy() 就用它,时序最稳;只有当你确实需要"运行时才知道该注册什么"的灵活性时,才上 registerJavaScriptProxy(),并且把 refresh() 当成肌肉记忆。

反向也不难:应用侧调用前端函数

理解了前端调应用侧,反向其实更简单。应用侧通过 WebviewControllerrunJavaScript() 方法,可以直接执行前端页面里的 JS 代码。$TRAE_REF

// 前端定义一个函数
// function updateContent(data) { document.getElementById('content').innerText = data; }

// 应用侧触发它
this.webviewController.runJavaScript('updateContent("来自ArkTS的问候")');

runJavaScript() 的参数是一段 JS 代码字符串,所以可以传函数调用、甚至一段小脚本。鸿蒙还提供了 runJavaScriptExt(),在参数类型和返回处理上更强,适合需要拿到执行结果或传结构化数据的场景。

这种反向调用在"应用侧拿到数据后通知前端刷新"的场景特别好用,比如:原生网络请求完成后,调前端函数更新 UI;或者收到推送后,调前端函数刷新消息列表。

复杂类型:不是只能传字符串

初看文档会以为 JavaScriptProxy 只能传基础类型,实际它对数组对象的支持很完整。

传数组

class NoteServiceClass {
  getRecentNotes(): Array<string> {
    return ['产品周会纪要', '鸿蒙适配笔记', '读书笔记'];
  }
}

前端拿到的就是一个正常的 JS 数组,可以直接 forEachmap

传对象

class NoteItem {
  title: string = '';
  createTime: string = '';
  tags: Array<string> = [];
}

class NoteServiceClass {
  getCurrentNote(): NoteItem {
    const note: NoteItem = {
      title: '前端页面调用应用侧函数',
      createTime: '2026-08-10',
      tags: ['鸿蒙', 'ArkWeb', 'JavaScriptProxy']
    };
    return note;
  }
}

前端拿到的就是一个普通 JS 对象,字段名和 ArkTS 里的保持一致。这里有个心得:ArkTS 侧的对象字段命名要和前端约定好,因为注入后字段名是直接透传的,ArkTS 用驼峰前端就用驼峰,别一边驼峰一边下划线,调试时会绕。

异步调用:Promise 没那么神秘

JavaScriptProxy 对 Promise 的支持,是我在实际项目里用得最多的能力。很多应用侧操作是异步的——读文件、发请求、查数据库——前端不可能同步等。$TRAE_REF

应用侧返回一个 Promise:

class FileServiceClass {
  readFile(path: string): Promise<string> {
    return new Promise((resolve, reject) => {
      // 模拟异步读文件
      setTimeout(() => {
        if (path) {
          resolve(`文件内容:${path} 的数据`);
        } else {
          reject('路径不能为空');
        }
      }, 1000);
    });
  }
}

前端用 .then() / .catch() 正常处理:

function loadFile() {
  fileService.readFile('notes/test.md')
    .then((content) => {
      document.getElementById('content').innerText = content;
    })
    .catch((err) => {
      console.error('读取失败:', err);
    });
}

异步方法的声明位置要注意:在 javaScriptProxy() 里,异步方法放在 asyncMethodList,不是 methodList。放错了前端调用会拿不到 Promise,这是个隐蔽的坑。

权限配置:上线前必须补上的一环

开发阶段为了方便,permission 经常留空。但真正要发布时,这一块是安全防线。权限配置是一个 JSON 字符串,分两层:对象级权限控制哪些 URL 能访问该对象的所有方法,方法级权限更细,控制哪些 URL 能访问特定方法。$TRAE_REF

{
  "javascriptProxyPermission": {
    "urlPermissionList": [
      {
        "scheme": "resource",
        "host": "rawfile",
        "port": "",
        "path": ""
      }
    ],
    "methodList": [
      {
        "methodName": "getDeviceId",
        "urlPermissionList": [
          {
            "scheme": "https",
            "host": "your-trusted-domain.com",
            "port": "",
            "path": ""
          }
        ]
      }
    ]
  }
}

几个匹配规则值得记住:

  • schemehost 是精确匹配,不能为空;
  • port 精确匹配,留空表示不检查端口;
  • path 是前缀匹配,留空表示不检查路径。

我的实践建议:敏感方法单独配方法级权限,只放行可信域名。比如 getDeviceIdgetToken 这类方法,对象级权限可能放得比较宽,但方法级权限收紧到只允许特定来源调用,这样即使页面被 XSS 注入了恶意脚本,也无法越权调到敏感方法。

几个踩坑心得

写到这里,把实战中反复出现的问题梳理一下,希望能帮你少走弯路。

第一,registerJavaScriptProxy() 之后忘记 refresh() 前面已经强调过,这是最高频的坑。症状是前端调用报对象未定义,排查方向很容易跑偏到"是不是方法名拼错了"。记住:动态注册 = 注册 + refresh,缺一不可。

第二,异步方法放错列表。 methodList 放同步方法,asyncMethodList 放异步方法。如果异步方法放进了 methodList,前端调用时不会报错,但拿不到 Promise 对象,.then() 直接异常。判断依据:方法返回值是不是 Promise<T>,是就走 asyncMethodList

第三,对象引用与状态更新。 注册到前端的对象,是 ArkTS 对象的引用。如果用 @State 声明并且对象内部状态变化,前端调用时拿到的是最新值。但如果整个对象被重新赋值(this.deviceInfo = new DeviceInfoClass()),前端持有的还是旧引用。需要重新注册或者避免整体替换。

第四,方法名大小写敏感。 methodList 里的方法名和 ArkTS 类里定义的方法名必须完全一致,包括大小写。getToken 写成 gettoken 不会报错,但前端调不到。

第五,前端调用时机。 即使用 javaScriptProxy(),也要保证前端在 DOM 加载完成后调用。最稳的做法是在 window.onload 或脚本放在 body 末尾,避免在对象还没注入时就触发调用。

第六,runJavaScript() 的字符串转义。 应用侧调前端时,如果参数里有引号、换行,直接拼字符串会出问题。建议对传入参数做 JSON 序列化后再拼到调用代码里,比如 runJavaScript('updateContent(' + JSON.stringify(data) + ')'),避免特殊字符破坏 JS 语法。

一个完整的双向通信例子

把前面的能力串起来,做一个最小但完整的双向通信 Demo:前端展示笔记列表,点击笔记调应用侧读取详情,应用侧读取完成后再调前端函数渲染详情。

应用侧:

// xxx.ets
import { webview } from '@kit.ArkWeb';

class NoteBridge {
  getNoteList(): Array<{ id: number, title: string }> {
    return [
      { id: 1, title: '平行视界适配心得' },
      { id: 2, title: 'ArkWeb 双向通信笔记' },
      { id: 3, title: '折叠屏布局实践' }
    ];
  }

  getNoteDetail(id: number): Promise<string> {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(`这是笔记 ${id} 的详细内容,由 ArkTS 异步读取。`);
      }, 500);
    });
  }
}

@Entry
@Component
struct NoteWebPage {
  webviewController: webview.WebviewController = new webview.WebviewController();
  @State bridge: NoteBridge = new NoteBridge();

  build() {
    Column() {
      Web({ src: $rawfile('index.html'), controller: this.webviewController })
        .javaScriptProxy({
          object: this.bridge,
          name: 'noteBridge',
          methodList: ['getNoteList'],
          controller: this.webviewController,
          asyncMethodList: ['getNoteDetail'],
          permission: ''
        })
    }
  }
}

前端:

<!DOCTYPE html>
<html>
<body>
  <ul id="list"></ul>
  <div id="detail"></div>
  <script>
    function renderList() {
      const list = noteBridge.getNoteList();
      const ul = document.getElementById('list');
      ul.innerHTML = list.map(item =>
        `<li onclick="loadDetail(${item.id})">${item.title}</li>`
      ).join('');
    }

    function loadDetail(id) {
      noteBridge.getNoteDetail(id)
        .then(content => {
          document.getElementById('detail').innerText = content;
        });
    }

    window.onload = renderList;
  </script>
</body>
</html>

这个例子里,前端调应用侧的同步方法 getNoteList 拿列表,再调异步方法 getNoteDetail 拿详情,应用侧的 Promise 在前端用 .then() 接住。整条链路没有 HTTP 请求,没有 postMessage 序列化,调用就像本地函数一样直接。

延伸:除了 JavaScriptProxy 还有什么

JavaScriptProxy 解决的是"前端调应用侧",反过来 runJavaScript() 解决"应用侧调前端"。但如果需要持续的双向数据流,比如前端不断上报状态、应用侧不断推送更新,单次调用模式会比较啰嗦。

ArkWeb 还提供了 建立应用侧与前端页面数据通道 的能力(createWebMessagePort),可以建立一个持久化的消息端口,两侧互相 post 消息,适合实时性要求高的场景。这是 JavaScriptProxy 之外的另一条路,思路更接近浏览器的 MessageChannel。

我的选择思路是:

  • 一次性调用、请求-响应模式 → JavaScriptProxy + Promise;
  • 应用侧单向通知前端 → runJavaScript();
  • 持续双向通信、流式数据 → WebMessagePort。

三者不是互斥的,一个稍复杂的混合应用里,往往三种都会用到。

结语

从"前端调应用侧"这个点切入鸿蒙 ArkWeb,最大的感受是:鸿蒙在混合开发这块的设计相当克制和务实。没有发明一套全新的通信协议,而是沿用前端开发者熟悉的"对象注入 + Promise"模型,学习成本很低;同时通过 methodList / asyncMethodList 的显式声明、permission 的分层控制,把安全边界交还给开发者。

真正花时间的不是 API 本身,而是那些"文档写了但容易漏"的细节:refresh() 的必要性、异步方法的归属列表、对象引用的生命周期、权限 JSON 的匹配规则。把这些细节理清,混合开发的通信层就能搭得很稳。

下一篇打算聊聊 WebMessagePort 和 Web 组件的多实例管理,那是另一个有意思的方向。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐