前端页面调用应用侧函数:鸿蒙 ArkWeb 双向通信学习笔记
本文是一篇从"踩坑"到"想通"的实战记录,围绕 HarmonyOS 中 Web 组件与 ArkTS 应用侧的双向调用,记录原理、对比、代码与心得。
为什么前端页面需要"反过来"调应用侧
做混合开发久了,会形成一种惯性思维:应用侧是"宿主",前端页面是"被加载的内容",方向永远是从宿主去驱动内容。但在真实业务里,这个方向经常需要反过来。
最典型的几个场景:
- 前端 H5 页面里点一个"保存到本地"按钮,需要触发 ArkTS 把数据写入应用沙箱或偏好数据库;
- H5 想拿到设备唯一标识、系统版本、网络状态这些只有原生才有的信息;
- 前端发起一个需要鉴权的请求,要把 token 的获取和刷新交给应用侧统一管理;
- 页面内嵌的富文本编辑器要调起原生的图片选择器、相机或文件系统。
这些需求的共同点是:数据或能力的"源头"在应用侧,而"触发时机"在前端。如果每次都绕一圈走 HTTP 接口或者 postMessage 那套老办法,既慢又脆。鸿蒙的 ArkWeb 给了一条更直接的路——把 ArkTS 对象"注入"到前端,让前端像调用一个普通 JS 对象那样调用应用侧函数。
两种注册方式:不是二选一,而是时机不同
ArkWeb 提供了两个接口把 ArkTS 对象注册到前端页面,初学时很容易把它们当成"两种等价写法",其实它们的核心差异在于调用时机。
javaScriptProxy():Web 组件初始化时注入
javaScriptProxy()是 Web 组件的链式方法,跟着Web(...)一起声明,在组件初始化阶段就把对象注入到前端页面。这意味着页面一加载,注册的对象就已经存在,前端可以无感调用,不会出现"页面跑得太快、对象还没注册"的时序问题。
适合那些从第一行 JS 就要用到的对象,比如全局配置、设备信息、基础工具方法。
// xxx.etsimport{webview}from'@kit.ArkWeb';classDeviceInfoClass{constructor(){}getOSVersion():string{return'HarmonyOS 5.0';}getDeviceId():string{return'device-uuid-xxxx';}saveToLocal(key:string,value:string):void{console.log(`保存到本地:${key}=${value}`);}}@Entry@Componentstruct WebComponent{webviewController:webview.WebviewController=newwebview.WebviewController();@StatedeviceInfo:DeviceInfoClass=newDeviceInfoClass();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 --><!DOCTYPEhtml><html><body><buttononclick="showInfo()">获取设备信息</button><pid="info"></p><script>functionshowInfo(){constos=deviceInfo.getOSVersion();constid=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.etsimport{webview}from'@kit.ArkWeb';import{BusinessError}from'@kit.BasicServicesKit';classUserServiceClass{constructor(){}getToken():string{return'token-from-arkts';}refreshToken():string{return'token-refreshed';}}@Entry@Componentstruct WebComponent{webviewController:webview.WebviewController=newwebview.WebviewController();@StateuserService:UserServiceClass=newUserServiceClass();build(){Column(){Button('动态注册 UserService').onClick(()=>{try{this.webviewController.registerJavaScriptProxy(this.userService,'userService',['getToken','refreshToken']);// 关键:注册后必须 refresh 才生效this.webviewController.refresh();}catch(error){conste=errorasBusinessError;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()当成肌肉记忆。
反向也不难:应用侧调用前端函数
理解了前端调应用侧,反向其实更简单。应用侧通过WebviewController的runJavaScript()方法,可以直接执行前端页面里的 JS 代码。$TRAE_REF
// 前端定义一个函数// function updateContent(data) { document.getElementById('content').innerText = data; }// 应用侧触发它this.webviewController.runJavaScript('updateContent("来自ArkTS的问候")');runJavaScript()的参数是一段 JS 代码字符串,所以可以传函数调用、甚至一段小脚本。鸿蒙还提供了runJavaScriptExt(),在参数类型和返回处理上更强,适合需要拿到执行结果或传结构化数据的场景。
这种反向调用在"应用侧拿到数据后通知前端刷新"的场景特别好用,比如:原生网络请求完成后,调前端函数更新 UI;或者收到推送后,调前端函数刷新消息列表。
复杂类型:不是只能传字符串
初看文档会以为 JavaScriptProxy 只能传基础类型,实际它对数组和对象的支持很完整。
传数组
classNoteServiceClass{getRecentNotes():Array<string>{return['产品周会纪要','鸿蒙适配笔记','读书笔记'];}}前端拿到的就是一个正常的 JS 数组,可以直接forEach、map。
传对象
classNoteItem{title:string='';createTime:string='';tags:Array<string>=[];}classNoteServiceClass{getCurrentNote():NoteItem{constnote:NoteItem={title:'前端页面调用应用侧函数',createTime:'2026-08-10',tags:['鸿蒙','ArkWeb','JavaScriptProxy']};returnnote;}}前端拿到的就是一个普通 JS 对象,字段名和 ArkTS 里的保持一致。这里有个心得:ArkTS 侧的对象字段命名要和前端约定好,因为注入后字段名是直接透传的,ArkTS 用驼峰前端就用驼峰,别一边驼峰一边下划线,调试时会绕。
异步调用:Promise 没那么神秘
JavaScriptProxy 对 Promise 的支持,是我在实际项目里用得最多的能力。很多应用侧操作是异步的——读文件、发请求、查数据库——前端不可能同步等。$TRAE_REF
应用侧返回一个 Promise:
classFileServiceClass{readFile(path:string):Promise<string>{returnnewPromise((resolve,reject)=>{// 模拟异步读文件setTimeout(()=>{if(path){resolve(`文件内容:${path}的数据`);}else{reject('路径不能为空');}},1000);});}}前端用.then()/.catch()正常处理:
functionloadFile(){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":""}]}]}}几个匹配规则值得记住:
scheme和host是精确匹配,不能为空;port精确匹配,留空表示不检查端口;path是前缀匹配,留空表示不检查路径。
我的实践建议:敏感方法单独配方法级权限,只放行可信域名。比如getDeviceId、getToken这类方法,对象级权限可能放得比较宽,但方法级权限收紧到只允许特定来源调用,这样即使页面被 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.etsimport{webview}from'@kit.ArkWeb';classNoteBridge{getNoteList():Array<{id:number,title:string}>{return[{id:1,title:'平行视界适配心得'},{id:2,title:'ArkWeb 双向通信笔记'},{id:3,title:'折叠屏布局实践'}];}getNoteDetail(id:number):Promise<string>{returnnewPromise((resolve)=>{setTimeout(()=>{resolve(`这是笔记${id}的详细内容,由 ArkTS 异步读取。`);},500);});}}@Entry@Componentstruct NoteWebPage{webviewController:webview.WebviewController=newwebview.WebviewController();@Statebridge:NoteBridge=newNoteBridge();build(){Column(){Web({src:$rawfile('index.html'),controller:this.webviewController}).javaScriptProxy({object:this.bridge,name:'noteBridge',methodList:['getNoteList'],controller:this.webviewController,asyncMethodList:['getNoteDetail'],permission:''})}}}前端:
<!DOCTYPEhtml><html><body><ulid="list"></ul><divid="detail"></div><script>functionrenderList(){constlist=noteBridge.getNoteList();constul=document.getElementById('list');ul.innerHTML=list.map(item=>`<li onclick="loadDetail(${item.id})">${item.title}</li>`).join('');}functionloadDetail(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 组件的多实例管理,那是另一个有意思的方向。