React Native 最后的银弹——WebView 的使用模式和代码结构(一)

移动端项目中对 WebView 的使用心得。

React Native 最后的银弹——WebView 的使用模式和代码结构(一)

为何选择了入坑 React Native

2021年9月中旬,这是我开始写这篇文章的时间点。到目前为止,关于移动端 App 技术选型的讨论仿佛已经经历了从平缓到高昂,再到平缓的过程。从 google 的数据来看,React Native(后面简称RN 吧)只能说「had its days」,但根据公司的技术背景,和我与同事几个月以来所经历的开发体验来看,RN 就是现阶段最适合我们当前项目的技术。我们的项目极大的获益于 RN 所提供的 native rendering, JS runtime, 以及 community support 等等。

谷歌搜索热度,蓝色为React Native,红色为Flutter

从 RN 开始,慢慢向 Android/iOS native UI components 切换,这样的技术路线可以让我们从现有的 web 项目中复用大量代码,既能保证前期快速添加业务逻辑,又能保证后期的运行性能优化空间。

当然,每个项目有各自的技术和业务背景,不能一概而论,移动端的技术选型也不是本文讨论的重点。总之,在公司的 web (React) 项目已经接近成熟,并且人日投入比较有限的情况下,希望能做出一款补全移动端场景的补充产品,我们选择了 React Native。

为何需要 WebView

RN 提供的 UI 组件,以及社区的支持,足以让我们完成大部分的功能。但终于有一天,我们遇到了 Echarts。看了几个跟 Echarts 有关的库,都是依靠封装 WebView 来实现的,那么我们为何不自己通过 WebView 实现我们的图表呢?于是在一个仲夏的傍晚,我们在 mobile 项目中 yarn add 了本文的主角:react-native-webview

好了其实都是废话,怎么可能有 web 前端写 RN 不用 WebView 的?自然而然好吧!除非你们项目组有西北第一安卓。就算有,他也一定是在攻坚核心功能的性能,跑来搞 Echarts Native,可能 Apache 更适合他。

关于 WebView 的代码架构演进

WebView 在 RN 中完全是一个独立的小世界,里面有自己的生命周期,资源加载逻辑,Runtime context 等。后端同学可以把它理解为一个浏览器容器,前端同学也可以把它理解为一个浏览器容器(我本来想说微前端的,我都快被 webview 搞死了,你们竟然还在研究怎么往web里套web)Anyway,可能需要提前知道的知识点有两个:

  1. WebView 和 RN 通信只能靠极其有限的手段:RN 可以通过 injectJavaScript 向 WebView 内部注入 JS 代码 (string),注入时会立即执行;反之,WebView 内部可以通过调用 window.ReactNativeWebView.postMessage 向 RN 发送消息 (string), RN 通过监听 onMessage 获取到相应的信息。
  2. WebView 加载资源有 3 种方式:HTML string/URL/local HTML files

以下是最基本的双方通信示例:

基于以上限制,我们开始了第一版尝试:

第一版:inline HTML

我们最先赶制出来的一版,是采用 HTML string,实现了 Echarts 的需求。具体实现方法:

  1. 首先新建一个 HTML 模版,把 Echarts 的 js 源码拷贝到模版中的 script 标签中;
  2. 把 Echarts 渲染相关的业务代码也放在 script 里,跟在前一个 script 后面;
  3. 再把整个 HTML 的内容,作为一个模版字符串,放在 RN 项目中的 js 文件里;
  4. 把需要传进去的参数拼进模版字符串里,再用一个 function 把它 return 出来;
  5. RN 中调用这个方法,把获取到的 inline HTML string 丢进 WebView 中渲染出来;

有过类似经历的同学可能已经想象到 IDE 打开这个巨大的 js 文件时直接卡死的场面了。但那又如何,一个 story 已经被悄然拖到了「完成」那一列。

第二版:Parcel local files

TL;DR: 通过 Parcel 把 WebView 所需资源打包进 App,以 local HTML files 的方式加载。

为什么要做第二版去优化这个东西,当然是因为,作为一个优秀的软件工程师,我无法忍受有不好的架构出现在我维护的仓库中,而不是因为我们遇到了一个问题:

传入 Echarts 中的业务代码主要有两部分,一部分是用来渲染的实际数据,这部分数据是接口取到,在 RN 中完成整理的(整理逻辑复用 web 中的一些 data utils, thank God it's all about js)当然,这些数据是完全可序列化的;另一部分就比较麻烦了,它们是用来 config Echarts 的 options.

如果是一个标准的 JSON,也还好,但偏偏这份 options 里面,有 callback,比如文档中的这个属性: xAxis.axisLabel.formatter

// Use string template; template variable is the default label of axis {value}
formatter: '{value} kg'
// Use callback.
formatter: function (value, index) {
    return value + 'kg';
}

x 轴上的 label 可能需要根据实际这个轴上的数据渲染不同的内容,但它当时还不知道这个数据是什么值,这很合理。但函数被序列化后再反序列化,闭包中的内容会因为 context 丢失而丢失。所以我们必须把 options 相关的所有逻辑和信息都在 Echarts 那一端实现.

怎么办,打开那个巨大的模版字符串,继续往里塞 js?但如果这个 optionMaker 又引用了其他一些 utils,而这些 utils 还是 ts写的,怎么办?

第二天凌晨,优秀的软件工程师小米向这个 RN 项目提交了一个 commit,在 devDependencies 里面多了一个 Parcel,并且增加了几条 WebView 相关的 yarn 命令,包括 webview:build, webview:dev。

至此,我们得到了第二版的 WebView 方案:

  1. 新建一个专门存放 WebView 资源的路径,写一个 echarts.entry.js 作为 Parcel 的入口文件,里面引入一切与 Echarts 渲染相关的 js,最后封装成一个 React Component,用 ReactDOM 渲染在 HTML 提前写好的 div 容器上(也可以不用 React,根据实际业务来)
  2. 封装一个 updateEcharts 方法,用来接收 RN 传入的参数,并挂在 window 上
  3. 通过强大便捷的 Parcel 将所有 Echarts 相关的 js 打包到 android/app/src/main/assets/web 目录下,并生成相应的 HTML 入口文件;
  4. 在 RN 的 WebView 组件上,直接引入本地资源(就是上一条打包出来的资源),iOS 和 Android 引用本地资源方式不同,需要注意一下,之所以将 Parcel 打包产物放在那个特殊的路径也是因为 Android 通过 gradle 打包时有指定的本地资源路径;
  5. 通过 injectJavaScript 调用 updateEcharts 方法并把序列化过的业务数据传进去。

示例代码如下:

// WebView 内部
const updateEcharts = (reportData) => {
  ReactDOM.render(
    <EChartsRenderer reportData={reportData} />,
    document.getElementById('main'),
  );
};
window.updateEcharts = updateEcharts;

RN 侧只需把以下字符串 injectJavaScript 即可:

injectJS(`updateEcharts(${JSON.stringify(reportData)});`);

从代码组织结构来说,WebView 的代码和 RN 依然混淆在一起。但从构建流程来说,由于引入了除 metro 之外的 JS bundler,构建变成了两条独立的线:

通过这一版改动,我们获益之处在于:

  1. 不再需要手工维护 HTML 资源;parcel 可以自动根据依赖关系把  js/ts/css 全部打包好,并生成 HTML 入口文件;
  2. 提供了 dev server,可以将 WebView 的 source 指向 localhost,实现了调试时 hot reload;

第二版所使用的 Parcel + local files 的方式组织 WebView 资源,依然有很多地方存在硬伤,并且后面业务也马上就遇到了:

  1. 首先 Parcel 是一个非常轻量化的 bundler,但轻量化的同时,想做一些定制就比较难。我们迫切的需要更强大的 bundler(对,就是 webpack);
  2. 本地化的资源,每次都需要发新包才能更新,而发包则意味着审核,还要面临用户不愿意升级的问题。我们的 RN jsbundle 已经有了 codepush 来帮我们做 hotfix,WebView 也需要这样的能力;
  3. 像例子中的 Echarts,以及我们后来用到的很多库,体积其实都是很大的,我们把这些库装在 RN 的项目中,其实 RN 也并没有实际引用到这些库,从代码组织形式上来看应该还有优化的空间;

下一篇,我们会继续讨论 WebView 更优的代码组织形式(也就是我们目前在用的方式),并且讲讲除此之外,实际开发中还遇到了哪些坑,以及我们的解决方式。