本文主要基于 React Native 0.51 版本(iOS)进行分析。
一 概述
React Native 提供 WebView 组件调用原生视图组件渲染 web 内容,支持 iOS/Android 双平台。
在开发 RN App 的过程中,使用 H5 的场景越来越多。与 RN 代码或原生代码相比,使用 WebView(H5) 具有诸多优点:
-
快速更新
一般来说, App 一个功能的上线需要经过漫长流程,版本的发布存在铺量的问题;而 WebView 加载远端页面的方式,远端页面一经发布,立即全量。所以,页面需要频繁更新时可以考虑 WebView 实现。
-
页面复用
一次开发,多处运行。新开发的 H5 页面可以在 RN App WebView、微信/QQ的内置浏览器、微信小程序 WebView 等 WebView 组件上运行。页面在 iOS/Android 上都能获得不错表现。
-
缩小 App 安装包大小
H5 页面是远端资源,能有效减少 App 安装包的大小。
简单使用
调用 WebView 组件加载一个网页的代码如下
import React, { Component } from 'react';
import { WebView } from 'react-native';
class MyWeb extends Component {
render() {
return (
<WebView
source=
style=
/>
);
}
}
将上面组件填充满整个屏幕的宽度,就是一个基本的内置浏览器。但是这种简单的 web 容器无法满足我们日益发展的业务需求,如登录态与 App 共享、原生能力调用等,所以有必要将 WebView 组件封装成一个强大的 web 容器。
二 初步设计
应用市场上大多数 App 内置浏览器的设计,从功能上分为2个区域
- 导航栏,包含3个元素 返回按钮、标题、菜单按钮
- WebView,H5 的加载容器
下面按这种结构来实现自定义 WebView 容器。
Section 1 导航栏
1️⃣ 标题
WebView 的标题一般等网页内容载入完毕后从 html 的 <title>
标签读取。WebView 组件的 props onLoadEnd
定义
设置了 onLoadEnd 函数,WebView 加载结束时会执行调用。查看组件源码,调用时会传入一个 event 对象,从控制台打印该对象可以看到以下信息
其中的 title 是不是我们想要的?我们从 React/Views/RCTWebView.m 得到答案
- (NSMutableDictionary<NSString *, id> *)baseEvent
{
NSMutableDictionary<NSString *, id> *event = [[NSMutableDictionary alloc] initWithDictionary:@{
@"url": _webView.request.URL.absoluteString ?: @"",
@"loading" : @(_webView.loading),
@"title": [_webView stringByEvaluatingJavaScriptFromString:@"document.title"],
@"canGoBack": @(_webView.canGoBack),
@"canGoForward" : @(_webView.canGoForward),
}];
return event;
}
event.title 正是 WebView 实例读取 document.title
得到的,因此 WebView 容器的标题可以从 onLoadEnd
中读取。
document.title 不是一劳永逸的方案,在实际的开发中,有一个加载资讯页面的场景,这些资讯页面通过模板生成,document.title 被写死为一个没有实际意义的值,该情况下去读取 document.title 作为 WebView 容器的标题不合适。此问题可以通过传入 flag 和 资讯页面的 title,标记当前页面的标题不从 onLoadEnd 中读取,而直接读取页面跳转时传入的 title。
还有一种情况,SPA(单页面应用)只包含一个 document.title。针对此问题的解决方法是暴露设置 WebView 容器标题的 api(下面会详细描述),让 SPA 自行调用设置。
2️⃣ 返回按钮
WebView 容器的返回按钮有 2 个作用:1、页面发生跳转,点击返回按钮应该回退至上一个页面;2、页面未发生跳转,点击返回按钮应该关闭当前页面。
查看 WebView 的组件代码,可以看到 WebView 挂了一个 goBack
方法 Libraries/Components/WebView/WebView.ios.js。(注:自 RN 0.55 版本起在官网放出该方法的说明)
/**
* Go back one page in the web view's history.
*/
goBack = () => {
UIManager.dispatchViewManagerCommand(
this.getWebViewHandle(),
UIManager.RCTWebView.Commands.goBack,
null
);
};
每次点击返回,执行一次goBack
,当返回到最后一页时,再点击返回,应该关闭 WebView,这时需要知道 WebView 是否处于不可返回状态。我们当然希望 WebView 实例能告诉我们是否能执行返回,很遗憾,WebView 没有这样的 API。
能不能用 window.history 来实现?不行,我们尝试了获取 window.history 的长度,发现调用goBack
之后,history 的长度没有发生变化。因为 WebView 是有前进能力的,即使执行了后退,也能再次调用 goForward
回到原来的页面,所以它的长度没办法给我们利用。
为了获取 WebView 当前是否处于可返回的状态,先后探索了 2 个方案。
plan A
popstate: 当活动历史记录条目更改时,将触发popstate事件。 —— MDN
当 WebView 可以后退时,调用 goBack
方法,会触发 popstate
事件;当 WebView 无法后退时,调用 goBack
方法不会触发任何事件。因此,可以采取这样的思路:点击后退时,总是调用 goBack
方法,如果没有收到 popstate
事件,则把 WebView 关闭;如果收到了 popstate
事件,那就说明此次 goBack
调用是有效的,也就是说页面的确可以回退。
基于这个思路,需要解决2个问题:1、监听 popstate 事件,2、RN 与 webview 的通信。发生 popstate 事件后,要把事件回传给 RN。
1、监听 popState
事件
RN 的 WebView 组件支持 javascript 的注入,因此通过 WebView props injectedJavaScript
,注入事件监听器
<WebView
...
injectedJavaScript={`
window.addEventListener('popstate', function() {
// send message to RN
})
`}
/>
这时webview实现了监听Popstate事件,而且可以在回调函数里执行发送RN消息的操作。接下来看Webview和RN是如何通信的。
2、事件回传 - onMessage
与 postMessage
RN 与 webview 的通信是通过 onMessage、postMessage 搭配使用的,后面会详细描述通信机制。从 RN v0.37
版本开始,WebView 增加了 onMessage
属性和 postMessage
方法用于双端通信。发生页面切换,webview 接收到 popstate 事件,调用 window.postMessage 发送消息,RN 端就能通过 onMessage 接收到来自 webview 抛出的事件,进行相应的处理。
<WebView
...
injectedJavaScript={`
window.addEventListener('popstate', function() {
window.postMessage('popstate!')
})
`}
onMessage={e => {
const message = e.nativeEvent.data // message => popstate!
}}
/>
基于解决思路,有了最终的方案。导航栏点击后退的方法如下
// 后退响应函数
onPressNavBarBack = () => {
this.webview.back()
this.closeTimer = setTimeout(function () {
// close webview here
}, 1000)
}
点击导航栏后退,设置定时器1秒后将 WebView 关闭。如果 WebView 可以后退,还没回退到最后的页面,这时就会触发 popstate
事件,RN 端可以在 onMessage 中捕获这个事件,再将关闭 WebView 的 Timer 取消。如果已经到最后一个页面了呢?此时点击导航栏后退,1s的关闭webview计时器就开始启动,由于不会收到popstate事件,webview在1s后就会关闭,符合逻辑。
// 注意:以下代码省略了对 message 判断
<WebView
...
injectedJavaScript={`
window.addEventListener('popstate', function() {
window.postMessage('popstate!')
})
`}
onMessage={e => {
this.closeTimer && clearTimeout(this.closeTimer)
}}
/>
上述解决方案有一个严重的体验问题:如果 WebView 的访问历史只有一个页面,点击后退,WebView 需要等待1秒才能关闭,严重影响用户体验。又或者,RN会不会超过1s才能接收到来自 webview 抛出的事件呢?
需要动态计算 webview 与 RN 之间通信所需时间。RN 不必等待 WebView 抛出 popstate 事件,我们可以伪造一个。注入 js 时,顺便 post 一个 Message,带上时间戳,RN 接收到 Message 时,用当前时间戳减去接收到的时间戳等到时间差 T,T 就是 WebView 与 RN 之间通信所需时间。约等于,注意了。为保险起见,可把关闭 WebView 的 timeout 值设置为 3 * T(在iPhone 7p上测试,T ≈ 100ms;华为荣耀8,T ≈ 120ms)。3倍约300ms 在体验上影响不大。
plan B
细心的同学已经看到,上面 onLoadEnd
抛出的 event 对象包含 canGoBack
属性
@"canGoBack": @(_webView.canGoBack)
原生 WebView 组件的 canGoBack 属性指示当前状态是否能回退。
在本地保存 canGoBack
属性,每次发生页面切换时去更新这个值,点击返回时判断 canGoBack
即可。
onPressNavBarBack = () => {
if (this.canGoBack) {
this.webview.back()
} else {
// close webview here
}
}
plan B 已经能完美解决返回按钮的需求,我有点好奇,为什么 RN 不把 canGoBack 以实例方法的方式暴露出来?事实上,iOS 与 Android 的原生 WebView 组件都支持 canGoBack 属性,直到今天最新的 RN 0.59
版本依然没有加上这个方法。
3️⃣ 菜单按钮
按需设置,暴露设置的 api(详见 深度定制 章节),大多数情况下以分享功能为主。
–
Section 2 主界面
对于主界面的设计,包含 3 点:1、userAgent;2、请求预处理;3、异常处理。
1️⃣ userAgent
运行在 WebView 内的页面,有时候需要知道自身所处于的平台,因为页面还有可能运行在微信的 WebView 或其他 App 的 WebView。可以自定义 userAgent
,记录特殊信息供页面判断平台使用,还可以记录 App 版本号等关键信息。
Android 平台设置 UA 简单,因为 RN 已经提供了相应的 props
iOS 平台则需要自己到原生代码里实现。可以在 AppDelegate 的 didFinishLaunchingWithOptions
中实现
UIWebView* tempWebView = [[UIWebView alloc] initWithFrame:CGRectZero];
NSString* userAgent = [tempWebView stringByEvaluatingJavaScriptFromString:@"navigator.userAgent"];
NSString *ua = [NSString stringWithFormat:@"%@\\%@",
userAgent,
@"YourAgent"];
[[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent" : ua, @"User-Agent" : ua}];
2️⃣ 请求预处理
对被载入到 WebView 的 url 进行一个拦截校验。
首先,https only,避免被劫持,对于被加载的 url 需要进行强制字符串替换 http -> https
。
其次,设置域名白名单,限定可加载的域名。推荐的做法是用一个 json 配置可访问的域名,每次进入 App 时查询这份配置并存放在全局 Redux 中,加载 url 时再读取验证域名合法性。这里决定是否加载,在 iOS 上是通过 WebView props onShouldStartLoadWithRequest
实现的
每一次请求都会先经过这个函数处理,如果不拦截这个 url,返回 true 让其继续加载,否则,返回 false 来终结这一次加载。RN 的文档太过简陋,没有说明传入的参数,事实上这个函数传入的参数仍是上文提到的 event 对象。
⚠️ 注意这个函数有坑,在开发中发现有时候 url 没有被拦截并抛出一句警告
Did not receive response to shouldStartLoad in time, defaulting to YES
按照这个提示,定位到 RN 源码有个锁定主线程操作,
// Block the main thread for a maximum of 250ms until the JS thread returns
if ([_shouldStartLoadLock lockWhenCondition:0 beforeDate:[NSDate dateWithTimeIntervalSinceNow:.25]]) {
BOOL returnValue = _shouldStartLoad;
[_shouldStartLoadLock unlock];
_shouldStartLoadLock = nil;
return returnValue;
} else {
RCTLogWarn(@"Did not receive response to shouldStartLoad in time, defaulting to YES");
return YES;
}
一旦我们定义的拦截函数 onShouldStartLoadWithRequest
处理超过250ms,更精确的说,底层如果在250ms内没有收到我们的应答,WebView 会自动放过这一次请求。网上也有人反馈这个问题,给它续 0.1s 的经验值,也就是350ms,能大大提供拦截处理的成功率。
在 Android 平台,拦截请求可以通过 onNavigationStateChange
来实现,如果目标加载 url 需要被拦截,则通过 webview 实例调用goBack
,这一次请求发出去后马上执行了返回,在体验上是有损的。
3️⃣ 登录态共享
想要 WebView 变得强大,它能像其他 RN 页面一样能访问 App 后台的服务,那就需要 WebView 共享 App 的登录态。一般前端对登录态参数的携带有2种:1、请求参数加上 token;2、token 写到 cookie。
第 1 种处理的方式较为简单,可以把 token 以参数形式拼接到加载的 url 里。
第 2 种 cookie 注入,iOS 可以参考 NSHTTPCookieStorage 类,Android 可以查看 CookieManager 类。
4️⃣ 异常处理
WebView 加载 url 异常时,可能是由404或者ssl证书错误引起等,会显示一个报错页面
WebView 在加载失败时会显示默认的报错页面,传入3个参数: 错误域、错误码、描述。(WebView.ios.js)
otherView = (this.props.renderError || defaultRenderError)(
errorEvent.domain,
errorEvent.code,
errorEvent.description
);
WebView 对外暴露 props renderError
用于显示我们自定义的报错视图。可以设计一个比较美观的页面替换默认的报错页,考虑增加[重试]的按钮。
三 深度定制 - JSSDK
参照微信 JS-SDK,实现一个基于 RN WebView 的开发工具包。通过 JS-SDK,WebView 可以调用 App 的原生能力,或封装一些常用方法,达到深度定制的目的。
实现自定义 JS-SDK,关键是处理好 RN 与 WebView(加载的网页) 之前的通信。
消息互通
1. RN => WebView
先看 RN 是怎样给 WebView 发消息的。RN 会在 WebView 实例上挂着 postMessage
函数,调用postMessage
可给 WebView 发送消息,查看 WebView 组件源码 WebView.ios.js
postMessage = (data) => {
UIManager.dispatchViewManagerCommand(
this.getWebViewHandle(),
UIManager.RCTWebView.Commands.postMessage,
[String(data)]
);
};
可见组件最终调用了原生组件的 postMaessage
方法,查看其实现
- (void)postMessage:(NSString *)message
{
NSDictionary *eventInitDict = @{
@"data": message,
};
NSString *source = [NSString
stringWithFormat:@"document.dispatchEvent(new MessageEvent('message', %@));",
RCTJSONStringify(eventInitDict, NULL)
];
[_webView stringByEvaluatingJavaScriptFromString:source];
}
在原生里,执行了一段事件分发代码,抛出 MessageEvent
,完成了消息发送。在 WebView 端,可以通过监听message
事件接收消息
document.addEventListener('message', e => { document.title = e.data; });
2. WebView => RN
RN 是通过 WebView props onMessage
接收来自 WebView 的消息,官方文档介绍onMessage
如果给<WebView>
设置了onMessage
属性,RN 会在 window 对象注入
一个全局方法postMessage
,会替换原本就存在的postMessage,最终 WebView 通过window.postMessage
给 RN 发消息。看看原生的注入做了什么 RCTWebView.m
NSString *source = [NSString stringWithFormat:
@"(function() {"
"window.originalPostMessage = window.postMessage;"
"var messageQueue = [];"
"var messagePending = false;"
"function processQueue() {"
"if (!messageQueue.length || messagePending) return;"
"messagePending = true;"
"window.location = '%@://%@?' + encodeURIComponent(messageQueue.shift());"
"}"
"window.postMessage = function(data) {"
"messageQueue.push(String(data));"
"processQueue();"
"};"
"document.addEventListener('message:received', function(e) {"
"messagePending = false;"
"processQueue();"
"});"
"})();", RCTJSNavigationScheme, kPostMessageHost
];
新的postMessage
会把需要发送的消息存入消息队列,串行发送消息,当确认消息被接收才能进行下一条消息的发送。发送消息实际上是 url 重定向,但这个重定向不会被执行 RCTWebView.m#L249
if (isJSNavigation && [request.URL.host isEqualToString:kPostMessageHost]) {
NSString *data = request.URL.query;
data = [data stringByReplacingOccurrencesOfString:@"+" withString:@" "];
data = [data stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
NSMutableDictionary<NSString *, id> *event = [self baseEvent];
[event addEntriesFromDictionary: @{
@"data": data,
}];
NSString *source = @"document.dispatchEvent(new MessageEvent('message:received'));";
[_webView stringByEvaluatingJavaScriptFromString:source];
_onMessage(event);
}
形如react-js-navigation://postMessage?xxx
的 url 在原生内部写死不被执行跳转,因为它是 WebView 调用 window.postMessage
触发用于消息传递。
当 WebView 进行了发送消息的操作,原生里会拦截这一次请求,然后抛出message:received
自定义事件,确认消息接收,执行onMessage
回调,在 RN 端就能接收到来自 WebView 发送的消息。
JS-SDK 基本实现
在消息互通的基础上,封装一个 JS 文件,供需要调用 App 能力的页面使用,demo sdk.js
如下
class Sdk {
constructor() {
this.callbacks = {}
this._init()
}
_init() {
document.addEventListener('message', e => {
console.log('message received from react native')
var message;
try {
message = JSON.parse(e.data)
} catch (err) {
console.error('failed to parse message from react-native ' + err)
return
}
//trigger callback
if (message.args && this.callbacks[message.msgId]) {
if (message.isSuccessful) {
this.callbacks[message.msgId].onsuccess(message.args)
} else {
this.callbacks[message.msgId].onerror(message.args)
}
delete this.callbacks[message.msgId]
}
})
}
send(targetFunc, data, success, error) {
var msgObj = {
targetFunc: targetFunc,
data: data || {}
}
if (success || error) {
msgObj.msgId = this._guid()
}
console.log('sending message ' + msgObj.targetFunc)
if (msgObj.msgId) {
this.callbacks[msgObj.msgId] = {
onsuccess: success,
onerror: error
}
}
window.postMessage(JSON.stringify(msgObj))
}
_guid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1)
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4()
}
}
const jssdk = new Sdk()
sdk.js
做了 2 件事
- 封装
postMessage
支持传参数与回调,为每一条发送的消息绑定唯一的消息 id - 监听来自 RN 的消息,执行回调
在目标页面引入sdk.js
,假设需要调用 App 提供的 api testApi
,调用jssdk.send
进行通信
jssdk.send('testApi', {data: 'test data!'}, res => {
console.log('调用成功 %o', res)
})
页面调用jssdk.send
方法后,RN 端会受到相应的消息,附上 RN 端的 demo
type Props = {};
export default class App extends Component<Props> {
onWebViewMessage(event) {
console.log("Message received from webview")
let msgData
try {
msgData = JSON.parse(event.nativeEvent.data)
} catch (err) {
console.warn(err)
return
}
switch (msgData.targetFunc) {
case "testApi":
// 执行自定义逻辑
const response = {
...msgData,
args: 'test', // 回传数据
isSuccessful: true
}
this.myWebView.postMessage(JSON.stringify(response))
break
}
}
render() {
return (
<View style=>
<WebView
ref={webview => {
this.myWebView = webview
}}
source=
onMessage={this.onWebViewMessage.bind(this)}
style=
/>
</View>
)
}
}
当然,可以对 sdk 进行进一步封装,对send
方法进行封装,对外暴露能被 App 处理的方法,如上面的 testApi 被封装为
jssdk.testApi({data: 'test data!'}, res => {
console.log('调用成功 %o', res)
})
开发者可以根据自身需求,为 RN WebView 提供更多的 API。至此,WebView 已经能满足绝大部分业务需求了。
⚠️ 坑
实际开发过程偶现 WebView 发送消息到 RN 失败。原因是调用window.postMessage
失败,提示错误
Failed to execute ‘postMessage’ on ‘Window’: 2 arguments required, but only 1 present.
因为 WebView 在页面加载完毕后会执行 postMessage 的替换,如果在完成替换之前页面调用了 postMessage,就会抛出参数不一致的错误。
在 github 上有人给出数据劫持的解决方案(点击查看)。我尝试过使用这种办法,但是在 iOS 9 上会报错,因为 iOS 9 Safari 不允许修改对象的configurable
属性。
实际上,如果实现了 JSSDK,我们完全可以把替换 postMessage 的操作移到 JSSDK 中,这样就能确保调用 postMessage 时都已经完成替换。
四 展望
实现一个高度定制的 WebView 不但扩展了 App 的基础能力,还能提升开发效率,岂不美哉。那么还有哪些可优化地方?
WKWebView 与 UIWebView
在 iOS 12 上,UIWebView 被打上Deprecated
的标签,而 WKWebView 是苹果在iOS 8中引入的组件,将会成为主流。WKWebView 是一个现代的支持最新 Webkit 功能的网页浏览控件,摆脱过去 UIWebView 的老、旧、笨,特别是内存占用量巨大的问题。它使用与 Safari 中一样的 Nitro JavaScript 引擎,大大提高了页面js执行速度。
RN 自0.57
版本开始,WebView 组件支持 WKWebView,通过 props useWebKit
进行切换。
RN 自0.58
版本开始,使用 WebView 组件会抛出警告
Warning: WebView has been extracted from react-native core and will be removed in a future release. It can now be installed and imported from ‘react-native-webview’ instead of ‘react-native’. See https://github.com/react-native-community/react-native-webview
后续 WebView 组件会从 react-native 框架中抽离出去,作为一个独立的 RN 插件存在,进行自定义将会更加方便。
本地缓存
页面请求的一些静态资源基本不会有变化或者有些资源过大,网络不稳定的情况下会出现加载中断,可以考虑将这部分资源打包到项目中,在页面请求时进行拦截,转为加载本地缓存,减少不必要的网络请求。