React Native WebView 定制与原理

本文主要基于 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个区域

  1. 导航栏,包含3个元素 返回按钮、标题、菜单按钮
  2. 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、事件回传 - onMessagepostMessage

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 件事

  1. 封装postMessage支持传参数与回调,为每一条发送的消息绑定唯一的消息 id
  2. 监听来自 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 插件存在,进行自定义将会更加方便。

本地缓存

页面请求的一些静态资源基本不会有变化或者有些资源过大,网络不稳定的情况下会出现加载中断,可以考虑将这部分资源打包到项目中,在页面请求时进行拦截,转为加载本地缓存,减少不必要的网络请求。

参考资料

  1. rn-webview-bridge-sample-new - Github
  2. WKWebView(一) - healthbird 简书
  3. iOS UIWebView小整理(三)(利用NSURLProtocol加载本地js、css资源) - MyLee 简书