前阵子接了个鸿蒙上的混合应用需求,页面主体是Web组件,要内嵌一个运营H5页面。需求单上写得很简单:顶部放返回和前进按钮,页面加载时拦截一个特定接口,往请求头里塞token,顺便在H5跳转时保持按钮状态正确。当时心想这不就是WebView的基本操作吗,真正动手才发现,ArkWeb这套组件在请求拦截和浏览记录上跟安卓、iOS的WebView都有差异,几个功能凑一起时坑还不少。这篇就把我在鸿蒙Web组件上做请求拦截、浏览记录、前进后退的完整过程整理出来,代码能直接抄,顺便说清楚每个设计背后的原因。
1. 为什么请求拦截与导航管理,是Web组件绕不开的两座山
1.1 接手了一个"看起来很容易"的混合应用需求
先说背景。鸿蒙生态里,纯血应用已经不能再依赖安卓的WebView兜底,凡是涉及内嵌网页、H5活动页、第三方登录页面的场景,统一走ArkWeb组件(也就是日常说的Web组件)。它的能力边界直接决定了你的App能做多少混合开发的事情。
我那个需求拆开看其实就四件事:
- 加载一个https的运营页面;
- 页面发起某些接口请求时,原生侧要拦截下来,往请求里带鉴权参数;
- Web页面内部发生跳转后,顶部的返回、前进按钮要实时更新可用状态;
- 按系统物理返回键时,优先让网页后退,退无可退再退出当前页面。
讲真,单独看每一项都不难。但合在一起就涉及ArkWeb的导航生命周期、资源请求链路、以及ArkUI侧的组件状态同步。如果你只是照着官方文档堆回调,大概率会遇到"拦截回调不触发""后退之后前进按钮状态错乱""H5内跳转后物理返回键失灵"之类的问题。后面我会逐个拆。
1.2 先说结论:ArkWeb的拦截模型与传统WebView的差异点
在鸿蒙的ArkWeb体系里,和导航、请求相关的回调主要分两类:
- 页面级导航回调:
onLoadIntercept、onPageBegin、onPageEnd、onProgressChange、onTitleReceive; - 资源请求级回调:
onInterceptRequest。
很多从安卓转过来了人容易把onLoadIntercept当成万能的拦截入口,实际上它只能拦"导航跳转"这一层,拦不住页面内部XHR、fetch、图片、CSS这类子资源请求。而onInterceptRequest能拿到几乎每一个网络请求,包括子资源,但它的返回值是一个WebResourceResponse,意味着你不仅要决定"拦不拦",还要在拦截时自己构造响应数据。
这个模型差异,是所有业务逻辑设计的分水岭。你到底是"不让用户访问某个页面",还是"把某个接口的返回内容替换掉",走的完全是两条代码路径。
2. 环境准备与最小可运行示例:先把页面跑起来
2.1 DevEco Studio、API版本与权限配置
我用的开发环境是DevEco Studio 5.0.3.800,API版本是12(HarmonyOS NEXT)。如果你的工程创建时模板比较老,建议先把compileSdkVersion、targetSdkVersion这些对齐到不低于API 12的版本,不然后面有些子资源拦截的API行为会有差异。
新建工程后,最重要的一件事是检查网络权限。很多人第一次跑Web组件,页面加载直接报"Not allowed to load local resource"或者干脆白屏,多半就是漏了权限。在module.json5的requestPermissions里加上:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }这里有个容易被忽略的细节:如果用模拟器调试,确保模拟器系统镜像是有网络能力的版本;如果加载的页面是http明文地址,还需要在网络安全配置里放开明文流量,不过正式环境尽量上https,省得后面一堆麻烦。
2.2 最小Web组件页面与三个必挂回调
我习惯先把一个最小的页面跑通,再逐渐加逻辑。用ArkUI的Web组件最少只需要这样:
import { webview } from '@kit.ArkWeb'; @Entry @Component struct WebPage { controller: webview.WebviewController = new webview.WebviewController(); build() { Column() { Web({ src: 'https://developer.huawei.com', controller: this.controller }) .width('100%') .height('100%') .onPageBegin((event) => { console.info('开始加载:', event.url); }) .onPageEnd(() => { console.info('加载完成'); }) .onProgressChange((event) => { console.info('加载进度:', event.newProgress); }); } .width('100%') .height('100%'); } }Web组件首字母大写,构造参数里src是页面地址,controller是控制器。这个WebviewController非常重要,后面所有前进、后退、刷新、获取历史记录的操作都要通过它来执行。
三个回调先挂上,是为了方便观察生命周期:onPageBegin标志着一次页面导航开始,onPageEnd标志着这次导航结束,onProgressChange能拿到0到100的加载进度。先把这三个事件打出来,后面做按钮状态同步时你就知道应该在哪个时机刷新了。
3. 请求拦截:从监听、阻断到响应改写
3.1 onLoadIntercept与onInterceptRequest的分工
这是整个需求里最需要理解清楚的地方,我画个逻辑对比放在这里。
| 对比项 | onLoadIntercept | onInterceptRequest |
|---|---|---|
| 拦截时机 | 页面导航发生前,类似浏览器地址栏输入后的早期阶段 | 具体资源请求发出前,包括页面本身和所有子资源 |
| 回调返回类型 | boolean,返回true表示阻断本次导航 | WebResourceResponse或null,返回response表示用自定义数据响应 |
| 能拦哪些请求 | 页面级的跳转、iframe加载、表单提交等 | 页面文档、XHR、fetch、图片、CSS、JS等 |
| 典型场景 | 拦截外链跳转、强制替换导航地址 | 接口数据mock、注入鉴权参数、广告请求过滤、本地离线资源替换 |
实际开发里,我见过不少人把onLoadIntercept当万能拦截用,结果页面里的XHR请求一个都没拦住。记住一句话:想看"这个页面要跳去哪",用onLoadIntercept;想看"这个页面要请求什么资源",用onInterceptRequest。
另外还要强调一点,onInterceptRequest是支持同步返回WebResourceResponse的。这意味着你可以在回调里直接把本地的JSON字符串、图片字节塞回去,Web组件不会真的发起网络请求。这给本地mock、离线缓存、接口代理都留了很大的操作空间。
3.2 实操:拦截指定接口并注入自定义响应
回到我的需求:H5页面加载时,会向https://api.example.com/token发起一个获取token的GET请求。我要做的是用原生侧构造好的token模拟一个响应,不让它真的打到线上环境。
核心代码如下:
import { webview } from '@kit.ArkWeb'; @Entry @Component struct WebInterceptPage { controller: webview.WebviewController = new webview.WebviewController(); build() { Column() { Web({ src: 'https://activity.example.com/index.html', controller: this.controller }) .width('100%') .height('100%') .onInterceptRequest((event) => { const url = event.request.getRequestUrl(); console.info('拦截到请求: ', url); if (url.indexOf('https://api.example.com/token') === 0) { let response = new webview.WebResourceResponse(); response.setResponseData(JSON.stringify({ code: 0, data: { token: 'mock-token-from-native', expireAt: 1710000000000 } })); response.setResponseMimeType('application/json'); response.setResponseEncoding('utf-8'); response.setResponseCode(200); response.setReasonMessage('OK'); return response; } return null; }); } .width('100%') .height('100%'); } }几个容易踩的点:
event.request.getRequestUrl()拿到的URL是完整的,建议用indexOf或者new URL(url)解析,别直接写死全等匹配;- 返回
null表示不拦截当前请求,走正常网络加载; setResponseData可以传字符串,也可以传ArrayBuffer,如果需要返回图片或者二进制文件,用后者;- 设置了
responseCode之后,最好同步设置reasonMessage,否则部分版本的Web组件可能显示不出响应状态。
这里有个特别重要的实践:拦截到请求之后,函数内做耗时操作会阻塞页面加载。如果你要在这里做异步的本地数据库读取,建议先返回一个临时的"加载中"响应,等数据准备好再通过JS注入等方式通知页面,而不要在onInterceptRequest里sleep等待。
3.3 拦截逻辑的选型原则与性能注意
做了几次实际项目之后,我对拦截逻辑的选型有了自己的判断标准,直接写出来供参考:
- 只是想阻止某个页面被打开,用
onLoadIntercept,返回true,简单直接; - 想把某个接口的返回内容换掉,用
onInterceptRequest,但要注意影响范围,能精确到域名和路径匹配就尽量不要全量拦截; - 想做广告过滤,按域名黑名单过滤最有效,不要对URL做太复杂的正则,否则性能损耗大;
- 想做本地缓存,优先拦GET请求,POST请求的缓存麻烦且容易出语义问题;
- 千万别在拦截回调里打印每个请求的完整URL日志,生产环境里图片、埋点、上报请求会刷爆你的日志面板,调试时再开。
性能上还有一个经验:对图片、字体这类体积大的资源,如果要走自定义响应,尽量把数据准备做成内存缓存。否则页面滚动时频繁触发拦截回调,每一帧都创建响应体,会出现肉眼可见的滑动卡顿。
4. 浏览记录与导航状态:按钮是"灰"还是"亮",谁说了算
4.1 backForwardList与controller的四个方法
Web组件内部维护着一个浏览历史列表,这个概念和桌面浏览器基本一样:你访问过的每一个页面会按照先后顺序形成一个链表,有一个当前索引指向你现在所在的页面。当你后退时,当前索引往前移动,前进则往后移动。
在ArkWeb里,这个历史列表通过WebviewController暴露出来,最常用的是四个方法:
accessBackward():返回boolean,表示当前能否后退;backward():后退一步;accessForward():返回boolean,表示当前能否前进;forward():前进一步。
除了这四个,getBackForwardEntries()可以拿到完整的历史列表对象,返回的结果里包含当前索引和页面列表,适合做"长按弹出历史记录列表"这类高级功能。
一个核心认知是:历史记录的增删由Web组件内部自动管理,原生侧只需要关心"当前能不能后退/前进"以及"触发后退/前进动作"。你不需要也不应该自己去维护一个URL栈。有段时间我嫌它不够智能,想自己在ArkUI里存一个页面栈,结果H5内部用history.pushState改地址时,我这边完全感知不到,状态就乱了。后来发现直接用Controller提供的方法,省心得多。
4.2 什么时候刷新导航状态最靠谱
按钮的可用状态要跟随网页导航实时变化,核心问题是什么时候去刷新。网上很多例子只在前进步、后退步后手动刷新一次,这在纯原生的操作链路下没问题,但H5页面内部的点击、跳转、重定向,原生侧根本不知道。
我实测下来最可靠的时机有两个,配合起来用:
onPageBegin:页面开始加载新的URL时,先按"当前能后退"刷新按钮,因为一次导航开始后,前进记录就被清空了;onPageEnd:页面加载完成后,再刷新一次"能前进、能后退"的状态。
为什么是这两个时机?因为H5内部跳转时,onPageBegin一定触发,onPageEnd也一定触发,在它们里面更新状态,能覆盖几乎所有页面变动场景。代码长这样:
.onPageBegin(() => { this.canGoBack = this.controller.accessBackward(); this.canGoForward = this.controller.accessForward(); }) .onPageEnd(() => { this.canGoBack = this.controller.accessBackward(); this.canGoForward = this.controller.accessForward(); this.loading = false; })然后把canGoBack和canGoForward绑到按钮的enabled属性上,按钮的置灰和点亮就会自动跟上网页节奏。
这里顺便说一个细节:accessBackward()在部分SDK版本里是一个同步方法,直接在回调里调用没问题。如果你在异步任务里调用,只要Controller没有被销毁也OK。但是要注意,不要在页面销毁之后再去访问Controller的方法,否则会抛异常。
5. 前进后退按钮、物理返回键与加载界面的完整联动
5.1 页面结构与导航栏状态同步
我最终把页面搭成了这样:顶部是一个自定义导航栏,左边返回按钮,中间显示网页标题,右边前进按钮;导航栏下面是Web组件主体区。
@Entry @Component struct WebBrowserPage { controller: webview.WebviewController = new webview.WebviewController(); @State canGoBack: boolean = false; @State canGoForward: boolean = false; @State title: string = ''; @State progress: number = 0; @State loading: boolean = false; build() { Column() { Row() { Button('← 返回') .enabled(this.canGoBack) .onClick(() => { if (this.controller.accessBackward()) { this.controller.backward(); } }); Blank(); Text(this.title.length > 0 ? this.title : '加载中...') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }); Blank(); Button('前进 →') .enabled(this.canGoForward) .onClick(() => { if (this.controller.accessForward()) { this.controller.forward(); } }); } .width('100%') .padding({ left: 12, right: 12 }) .height(56); if (this.loading) { Progress({ value: this.progress, total: 100 }) .width('100%') .height(4); } Web({ src: 'https://activity.example.com/index.html', controller: this.controller }) .width('100%') .layoutWeight(1) .onPageBegin(() => { this.loading = true; this.progress = 0; this.refreshNavState(); }) .onPageEnd(() => { this.loading = false; this.refreshNavState(); }) .onProgressChange((event) => { this.progress = event.newProgress; }) .onTitleReceive((event) => { this.title = event.title; }); } .width('100%') .height('100%'); } refreshNavState() { this.canGoBack = this.controller.accessBackward(); this.canGoForward = this.controller.accessForward(); } }几个设计要点:
- 返回、前进按钮的点击回调里,没有直接调
backward()和forward(),而是先用accessBackward()、accessForward()做一次判断。这是双保险,防止在状态刷新不及时的极短时间窗口内,按钮状态和实际能力不一致; - 点击按钮后不需要手动刷新按钮状态,因为点击会产生新的导航,
onPageBegin/onPageEnd会自动触发; - 标题用
onTitleReceive从页面拿,H5里自行改document.title也会触发; - 进度条只在
loading为true时显示,加载结束直接隐藏,避免出现一个停驻在100%的进度条。
5.2 物理返回键与页面生命周期管理
鸿蒙里页面级返回键拦截用的是onBackPress(),这个函数返回true表示事件已被消费,返回false则交给系统处理页面退出。
onBackPress(): boolean { if (this.controller.accessBackward()) { this.controller.backward(); return true; } return false; }这段逻辑本身很直白,但有几个坑要提醒一下:
第一,onBackPress()只在当前页面处于前台并且是页面栈顶时才会被调用。如果你的Web组件页面是通过Navigation组件管理的子页面,返回键的拦截逻辑可能要走NavDestination的onBackPressed,不要混用。
第二,当网页退回到第一页时,物理返回键应该直接退出当前页面。这个逻辑就是上面代码里accessBackward()为false时返回false,让系统走页面退出。
第三,从页面A跳转到Web页面B,用户按返回键想让Web页后退而不是退出,这是移动端最常见的手势习惯,所以上面的逻辑优先级是"先让网页后退,再考虑关闭页面"。
第四,页面离开时,最好在aboutToDisappear里把Controller相关的引用置空或者重置,防止页面栈里还残留着对已销毁Web组件的引用。如果页面里有setInterval之类的定时器,也一并清掉。
6. 真机调试中高发问题与排查链路
6.1 "拦截方法写了却完全没触发"的排查路径
这是社区里问得最多的一个问题:明明在Web组件上挂了onInterceptRequest,结果一个请求都没拦住。我梳理一下自己排查这类问题的顺序,照着走大概率能解决。
第一,确认请求确实是Web组件发起的。如果你在ArkTS侧用http模块请求接口,那不叫Web组件资源请求,当然不会走onInterceptRequest。只有页面文档本身、页面内JS发起的XHR/fetch、图片、CSS、JS等子资源才会走这个回调。
第二,确认回调挂在了正确的Web组件上,而不是其他兄弟组件上。如果页面里有多个Web组件实例,每个都要单独挂回调,Controller也不能串。
第三,确认请求URL没有走Service Worker或者预加载缓存。这类请求在部分场景下不会触发拦截回调,比如Web组件内部对某些资源的预连接。这种比较少见,但真遇到时可以先尝试在拦截回调里打印日志,配合DevTools看是哪一步发起的。
第四,检查SDK版本。API 11和API 12之间的拦截行为有微调,旧版本里对data:协议的处理逻辑和新版本不一样。如果项目是老工程升级上来的,可以新建一个最小Demo对比测试。
第五,也是最隐蔽的:onInterceptRequest需要你在回调里return null表示放行。有些人写完分支逻辑后,忘记在默认路径上写return null,导致回调返回了一个未定义值,Web组件直接把这个请求当作被拦截了,但页面又没收到任何数据,表现就是"整个页面加载不出来"。这种问题比"没拦截到"更隐蔽,排查时优先看日志里有没有异常。
6.2 "后退后前进记录消失"与"白屏"的常见原因
后退之后前进按钮立刻变灰,很多人以为是Bug,其实大多数时候是Web组件的正常行为:后退之后,如果你再点击页面里的链接或者重新加载,新的导航会清空前进历史。这个和桌面浏览器逻辑一致,不算问题。如果你设计的业务是"后退后返回某个入口页面,再恢复之前的前进记录",那Web组件原生不会帮你保留,需要在业务层自己处理URL匹配,这个成本较高,不建议轻易做。
白屏则通常不是历史记录问题,而是拦截逻辑把页面主体请求拦掉之后没有正确返回数据。比如你按域名拦截了所有https://api.example.com的请求,但这个域下还有静态资源,拦截后返回的MIME类型和内容格式又不对,浏览器解析失败,就白屏了。排查思路是把拦截回调里的日志先全部打开,确认拦截列表里都有谁,再决定是否收窄匹配条件。
6.3 与H5通信、JS代理等周边问题的边界处理
做Web组件混合开发,绕不开与H5通信。请求拦截能解决数据层面的注入,但很多时候页面需要主动调用原生的能力,比如获取设备信息、调用登录SDK。ArkWeb里最常用的是registerJavaScriptProxy注册一个JS代理对象,让H5里通过window.xxx调用原生方法。
这里我提醒几个边界:
- 注册JS代理的时机要在
onPageBegin之前,并且通常建议在@State初始化后、页面加载前调用; - H5调用原生方法时,如果原生侧要返回数据给H5,一般通过回调函数传回;
- 这些JS代理方法是在子线程执行还是UI线程执行,取决于SDK版本和调用方式,做UI更新时注意线程切换;
- 请求拦截和JS代理是两条独立通道,不要试图用
onInterceptRequest去模拟JS调用,那是绕远路。
另外,如果你在H5侧用了第三方SDK(比如某些统计、地图SDK),它们自身可能也会创建WebView环境或者发起特殊协议请求。这时候你的拦截逻辑要设计成白名单模式,只处理明确要处理的域名,其余全部放行,否则很容易误伤第三方功能。
7. 回到需求本身:这套组合还能往哪些方向延伸
文章写到这里,核心内容其实已经讲完。不过既然是基于ArkWeb做混合应用,我想再补充几个从这套基础能力延伸出去的思路。
如果你已经能稳定实现请求拦截和前进后退,那么下面这些方向都可以试着做:
- 离线包机制:把H5的静态资源(HTML、JS、CSS、图片)打包到本地,在
onInterceptRequest里优先返回本地文件内容,实现弱网环境下的秒开。这个方向我在实践里认为最实用,但要注意版本更新时本地资源的替换策略,以及和服务器版本的协商逻辑; - 广告与统计请求过滤:在拦截回调里识别广告域名,直接返回一个空的204响应,可以显著减少页面加载耗时;
- 前端调试辅助:拦截特定接口返回,在真机上快速复现不同服务端返回场景,省去让后端改数据的沟通成本;
- 安全风控:对可疑的POST请求做记录或者告警,配合签名校验判断请求来源。
我个人在实际操作中的一个体会是:Web组件这套东西,文档读十遍不如亲手把一个完整的浏览器导航栏做一遍。尤其是"后退后前进记录被清空"这类行为,光看API说明你永远不会有直观的体感,只有亲手点了几个页面、看着按钮状态变化之后,才能理解历史记录链是怎么维护的。
如果后续你要做更复杂的Web组件功能,比如多窗口、Web组件内多个Tab页、或者在Web组件上叠一层原生绘制层,我建议把今天讲的这几个回调(onPageBegin、onPageEnd、onInterceptRequest、导航状态刷新)作为基础设施先沉淀成工具类,业务页面直接复用,不要每个页面各写一套。等遇到具体问题时,再针对那一个点深挖文档和排查链路,效率会高很多。