Sin
In a
Nutshell
基于 React Native 的三端同构应用

基于 React Native 的三端同构应用

February 04, 2018 /大前端之路

前言

V8 引擎面世以后,JavaScript 迎来了大爆炸式的发展。 Atwood 定律(“凡是能被 JS 实现的,终将会被 JS 实现”)也成为了日常的论调。 HTML5 规范定义的一众新接口和 WebSocket, WebGL 这样的新技术带给浏览器令人耳目一新的能力; NodeJS 的出现让使用同一种语言编写前后端代码成为了可能; 而 PhoneGap 将 JS 的势力范围扩展到了移动 App,React Native 更是将 JS 移动 App 开发推入了一个新的时代。

手上的武器更新换代了,前端开发者也自然会比以前承担更多的责任。 公司里“明道大前端”这个群组建立的时候,完全没想到日后前端开发真的会在明道扮演如此重要的角色 —— 从传统的浏览器富应用,到一众后端微服务,再到跨平台的桌面客户端和移动 App, 以至于微信小程序,前端工程师的劳动成果贯穿了产品的方方面面。

因此,准备借这个机会写一个《大前端之路》的系列,记录一下明道前端开发过程中遇到的各种问题和收获。

基于 React Native 的三端同构应用

明道的主要前端技术栈基于 React 框架,同构应用(Universal App,之前被称为 Isomorphic App)是 React 生态圈里一个经常提到的概念。 React 的哲学是 UI = f(state),对于相同的数据状态,经由相同 React 代码渲染出来的 UI 都是相同的。 因此利用这个能力,我们可以编写同一套渲染代码(可能根据平台进行差异化处理),在各端给定相同的初始数据,就可以得到一致的最终结果。 这个模式就被称为同构应用。

这里的三端,指的是 Web 浏览器端、服务端和原生移动端(React Native)。 最近由于一些契机,我们开始着手进行明道移动网页版(即俗称的 H5 版本)的开发。 在技术选型的阶段,考虑到有部分应用模块已经开发好了 React Native 版本,所以希望可以尽可能复用这部分代码,减少开发和维护的成本。 最终的目标是使用同一套代码,既可以嵌入原生移动应用,发挥 RN 的性能优势,又可以作为一个网页跑在浏览器内。再加上服务端渲染直出,加快首屏呈现速度。

因为 RN 有自己特殊的一套组件和 API,将 Web 版端代码转为 RN 代码相当麻烦,所以自然是 RN 代码优先,对浏览器进行适配。 这就需要一个中间层,使得 RN 的组件和接口能力可以在浏览器运行。 这方面已经有不少先驱进行过探索了,不过淘宝的 react-web 活跃度越来越低,而携程逢会必吹的 CRN 至今连一点开源代码的影子都没有看到。 因此,我们选择了来自 Twitter 更老牌和稳定的 react-native-web 实现。 对于部分尚未实现的组件和接口,使用了包括 ant-design-mobile 在内的一些库进行兼容,并自己做了很多 polyfill. 这个库还提供了完整的 CSS-in-JS 实现,使得组件内聚性可以很强。

react-native-web 的使用我们已经有同学分享过了:《使用react-native-web将你的react-native应用H5化》。 这篇文章主要介绍一下实践中的一些整体思路。

整体结构

大致的项目结构是这样的:

MingdaoLite
├─js (JavaScript 目标文件)
├─public (浏览器可以直接访问的公开文件)
├─server
│  └─server.js (服务端)
├─src (TypeScript 源文件)
│  ├─api
│  ├─assets
│  ├─components
│  ├─utils
│  ├─index.browser.tsx (浏览器端组件主入口)
│  ├─index.server.tsx (服务端组件主入口)
│  └─routes.tsx (React Router 路由配置)
├─webpack
│  ├─dev.config.js
│  └─prod.config.js
├─.babelrc
├─index.js (移动 App RN 组件主入口)
├─package.json
├─tsconfig.json
└─webpack.config.js

语言

语言方面,我们选择了 TypeScript, 这是之前 RN 项目启动时确定的。 一段时间使用下来,静态类型带来的自动补全、错误提示等特性的确极大地提升了生产力。 并且相比 Facebook 自家的 Flow, TS 的社区更加成熟,对 React 的支持也可圈可点。 很多库都自带类型定义,其他的库也有很多热心群众编写了第三方类型定义,独立发布在 npm 上,省去了很多自己写定义的痛苦。

由于 React Native 的打包流程不够灵活,为了让 RN 可以直接引用 JavaScript 代码, 我们在打包之前预先使用 TypeScript 自带的 tsc 工具,将 src 目录下的 TS 源文件整体编译一遍,输出到 js 目录下。 之后,原生应用 RN 打包时直接引用 js 下的文件(通过 .babelrc 配置 babel 转码过程中的一些行为),而 web 版通过 webpack 预先进行打包构建。

构建

说到构建,市面上有很多傻瓜式的脚手架工具,例如 create-react-app, Next.js 等。 但易上手的同时也牺牲了自由配置的灵活性。 由于我们的需求比较小众,所以还是决定从头编写 webpack 配置文件。 webpack 目录下存放的就是相关配置文件,根据 NODE_ENV 环境变量的不同使用不同的配置。

除了开发和生产环境的区别,webpack 配置另一个重要功能是区分浏览器和服务端。给大家看下 webpack.config.js 的代码:

const config = process.env.NODE_ENV === 'production'
    ? require('./webpack/prod.config')
    : require('./webpack/dev.config');
module.exports = [config('browser'), config('server')];

最终实际是生成了两份目标代码,一份加载到浏览器,一份交由服务端进行渲染。 两个环境的 webpack 配置差异包括:

  • target 不同。服务端是 'node',浏览器端是 'web'
  • output.libraryTarget 不同。服务端是 'commonjs',浏览器端是 'umd'
  • plugins 列表有差异。浏览器端比服务端多使用了一些特定的 webpack 插件
  • resolve.extensions 不同。这个是比较重要的一个选项。 对于不同平台的异化,有些小差异是在代码中进行判断,而对于大的差异,不同的平台使用不同的文件。 extensions 这个参数就使得根据不同的扩展名进行加载成为可能。 它是一个数组,按顺序加载这个数组里扩展名对应的文件。 服务端是 ['.server.js', '.web.js', '.js', '.android.js', '.ios.js'],客户端是 ['.browser.js', '.web.js', '.js', '.android.js', '.ios.js']. 这样打包时,webpack 会优先查找浏览器或服务端对应的文件,如果没有就查找 web 版本特定的文件,再没有就查找通用的 js 文件。后面两个是为了兼容少数安卓或 iOS 特定的第三方库。
  • url-loaderemitFile 参数。对于服务端传入 false,不生成相关文件

还有一个需要注意的点是,通常 node_modules 目录里的文件都是生成过的最终文件,所以不需要使用额外的加载器。 但是有很多 React Native 三方库里是包含 ES6 和 JSX 代码的(因为 RN 直接支持), 对于这些库需要使用 babel-loader 转一次。 我们用到的包括 react-native-animatable, react-native-storage 等

服务端

对于服务端,我们使用的是 koa 框架。 这个项目的后端只是很薄的一个渲染层,直接调用主站接口,复杂逻辑较少,koa 的简洁很好地契合了需要。 再加上对 asyncawait 的支持,大大简化了异步编程。

路由

路由库使用的是 react-router v4. 这一版相较之前的版本改动较大,主要是由之前所谓的“静态路由”改成了“动态路由”。 按新的使用方式,路由不再在一个地方统一配置,而是像普通组件一样在需要的地方进行渲染。

但按这个方式,就无法使用服务端渲染了。 因此,我们使用了官方提供的 react-router-config 库,仍然使用统一配置的方式。

服务端渲染的详细流程,后面有时间专门介绍一下。

问题

由于项目架构剑走偏锋,不可避免地遇到了一系列坑。有些仍然没有解决,这里简单列一下:

  1. 转场动画。react-router 并没有像 react-navigation 那样的转场动画。这对 web 端不那么重要,但是对移动 App 体验影响很大。目前还没有解决,后面还是需要投入看一下。
  2. 打包后的资源太大。移动浏览器这样寸土寸金的地方,这一系列的框架和工具库加上应用代码,即使是压缩后也高达好几个 MB. 不过这个是富应用难以避免的事情,服务端渲染也正是为了缓解这个问题。另外,我们还做了一系列打包优化和缓存策略,后面再找时间详细介绍。
  3. 服务端全局状态污染问题。新添加的应用模块使用了 redux,所有状态数据都存在 redux 的 store 里, 问题不是很大。但是之前的 RN 代码是基于 mobx 的,当时 mobx 刚发布不久也没什么最佳实践,导致项目里使用了不少全局的 mobx store. 这些全局状态在服务端每轮 render 时其实都是同一个变量,造成了安全方面的隐患。临时的解决方案是每次渲染前重新加载一遍应用代码,但这对性能造成了很大负面影响,后面还是需要从代码层面重构来解决根本问题。
  4. JS 模块异步加载仍未解决。由于使用了 react-router-config 的统一配置,目前还没有想到很好的代码拆分、异步加载的实现方式。