2018年10月

React Web 图表 Native 化

本文探讨了 Web 图表 Native 化的可行性与 React SVG 图表 Native 化的具体操作过程,文中涉及的改造项目目前并不成熟,仅供参考。

引子

图表,作为数据可视化表达具有很重要的作用。在 React Native 中,官方提供的绘图方案只有 ART 组件,而 ART 组件类似于 Web 中的阉割版的 SVG ,只是一个基础绘图组件,并不是一个成熟的图表解决方案。而对于复杂图表的技术选型,主要有两个常见方案可以选择:WebView原生端组件

  • WebView

这种方案是使用 Web 图表库,将图表功能用网页实现 ,然后在 React Native 中利用 WebView 将网页展示出来。这种做法非常简单,稍微封装一下 WebView 组件,就能直接使用 Web 现有的成熟的图表如:Echarts,highCharts 来实现功能。

  • 原生端组件

这种方案分两种情况,其中:

一种是绘制逻辑由 React Native 端负责:Android 与 IOS 成熟的图表组件也很多,也可以将这些成熟的图表组件封装给 React Native 使用,由 React Native 端实现绘制逻辑。这种做法性能比 WebView 方式性能好,但是 React Native 生态中有两端都封装好的成熟的方案较少。

另一种绘制逻辑完全由原生端负责:Android 与 IOS 端分别实现图表功能,React Native 端直接展示。这种方式性能最好,但是要写两套代码成本也很高。

在实际项目当中,笔者也是用了 react-native-echarts 这种采用 WebView 的图表解决方案,对笔者而言,这应该是目前最为简便的方案。可是,除了使用 WebView ,还有没有别的方式能利用这些成熟的 Web 图表库呢?或者,如果要将 Web 图表库复刻到 React Native 有没有什么别的可行的方案?

为了解答这些疑问,笔者将在下文详细介绍 Web 图表 Native 化的可行性React SVG 图表 Native 化 的具体操作过程。

Web 图表 Native 化的可行性

在网页上进行绘图有两种方式:CanvasSVG。使用 Canvas 来绘图是利用命令式方式调用 CanvasRenderingContext2D API。使用 SVG 来绘图是在 DOM 节点里使用相关标签如:svg,path,line 等声明式方式来绘制图形。

如果能在 React Native 中使用 Canvas 或者 SVG,那么复刻 Web 图表技术就是可行的。

从 Canvas 角度看可行性

React Native 需要 Canvas 组件并且需要能提供 CanvasRenderingContext2D API。然而,社区并没有原生端支持的 Canvas 组件,虽然有一个 react-native-canvas 但是也是基于 WebView 的。此外,阿里开源的 GCanvas 项目想做跨平台的渲染引擎,但是目前提供的 Canvas API 也还不全,不够成熟。所以,目前,对于利用 Canvas 实现的 Web 图表库,还没有能用的方案

从 SVG 角度看可行性

SVG 绘图是声明式的,这点与 React 组件的特点是一样的。恰好官方也提供了 ART 组件,社区也有 react-native-svg 这种组件,所以,将用 React + SVG 来实现的 Web 图表组件转换到 React Native 端应该是非常可行

其实,React 生态里有一个 Web 图表库 victory,并且也有基于 react-native-svg 的 React Native 端的支持 victory-native 。但是 victory 在使用上比较难,文档示例较少且没有复杂示例,而 victory-native 也有不少因为 react-native-svg 的 bug 导致的问题,这也可能是 stars 数不多,不太流行的原因。

有了 victory-naitve 作为成功示例,笔者觉得 React SVG 图表 Native 化成功率更高了。那么,具体要怎么做?

React SVG 图表 Native 化

整个 Native 化的目标非常简单,只要让在 Web 端功能正常的图表能在 React Native 端展示出来就行了

虽然 react-native-svg 提供的组件 比 ART 组件更接近 SVG, 但是鉴于 react-native-svg 还有很多 issue 还没解决,而且也不是官方组件,所以笔者选用了 ART 。接着需要选择一个合适的 React Web 基于 SVG 的图表库来进行 Native 化。

笔者调研了两个项目:rechartsreact-charts ,其中:
recharts:star 数较多,比较流行,实现和架构大体同 victory,但没有 native 支持,项目结构稍复杂。
react-charts:star 数很少,但是基本类型图表都有,文档实例也完善,项目稍简单。

笔者认为先从简单的项目入手比较容易,因此就选择 react-charts 这个项目来进行实验。整个实验性项目处理了相关的兼容性问题,主要涉及到 底层组件转换,css 转换 ,Dom API 替换等。下图是改动总览:

react-charts native化

以下详细介绍这几个填坑要点:

底层组件转换

哪些组件需要转换?对于一些抽象的高层组件如 LineChart 等折线图组件是无需做转换的,而底层组件如 HTML 标签如:svg ,这些标签组件就需要做转换,因为 React Native 里面没有,平台不兼容性。转换工作主要是需要将 Web 里面的 HTML 标签,尤其是 SVG 标签转换到 React Native 对应的组件。

  • SVG 标签的转换

笔者选用 React Native 里的 ART 组件对于 Web 的 SVG 标签,来做对应的组件替换。

react-charts 里面用到的 SVG 标签有: svg,g,circle,line,rect,path,text。而在 ART 里,只有 Surface,Group,Shape,Text 等组件。

svg 可以对应到 Surface,g 对应到 Group, text 对应到 Text ,其他的 circle,line,rect,path 都要用 Shape 通过创建 path 路径来绘制相应的组件:Circle,Line,Rect,Path 。比如,依据 SVG 的 line 标签的使用方法,可以通过 ART 的 Shape 组件来创建与之对应的 Line 组件。

line.js 的源码如下:

// @flow
import React from "react";
import { ART } from "react-native";

const { Path, Shape } = ART;
// ...

export default class Line extends React.Component {
  render() {
    const { x1 = 0, x2 = 0, y1 = 0, y2 = 0, ...rest } = this.props;
    const path = new Path()
      .moveTo(x1, y1)
      .lineTo(x2, y2)
      .close();
      
     // ...
     
    return (
      <Shape {...rest}  d={path} />
    );
  }
}

所以,这些 SVG 标签组件大致都能得到对应的 ART 替换组件。

  • 其他 HTML 标签的转换

除了 SVG 标签,react-charts 里面还有用到一些 HTML 标签,如 div,table,tbody,tr,td,strong,span 等标签,这些标签可以用 React Native 里简单的 View,Text 标签来作替换。

html.js 源码如下:

// @flow
import React from "react";
import { View as RawView, Text as RawText } from "react-native";
import transCssStyle from "../utils/transCssStyle";

class View extends React.Component {
  render() {
    const { style, ...rest } = this.props;

    return <RawView {...rest} {...transCssStyle(style)} />;
  }
}

class Text extends React.Component {
  render() {
    const { style, ...rest } = this.props;

    return <RawText {...rest} {...transCssStyle(style)} />;
  }
}

export default {
  Div: View,
  Table: View,
  Tbody: View,
  Tr: View,
  Td: View,
  Strong: Text,
  Span: Text
};

css 转换

Web css 样式也是不能直接在 React Native 里面用的,因此也需要转换。关于 css 转换到 React Native 社区已有比较成熟的方案了,就是:css-to-react-native。好在 react-charts 很多都是用内联样式,如果是用 className + 单独的 css 样式文件的话,处理起来会更麻烦。简单已有成熟方案,但是在实际操作中还是出现了一些问题,主要问题是在对 transform 的处理上。

css-to-react-native 对 trasform 的支持性不好,而且对于 ART 组件,不能直接在 style prop 中用 transform,而是有个单独的 transform prop 与单独的 Tranform 接口来做 tranform 。因此对于 ART 组件,需要应用 ART 的 Tranform 接口生成的 transform。

transTransform.js 的源码如下:

// @flow
import { ART } from "react-native";

const { Transform } = ART;

export default function transTransform(transforms?: Array<number>) {
  if (!transforms) {
    return undefined;
  }

  const transformStyle = transforms.reduce((acc, transform) => {
    if (transform.rotate !== undefined) {
      transform.rotate = transform.rotate.replace(
        /(\d+)deg/g,
        ($input, $1) => $1
      );
    }
    return Object.assign(acc, transform);
  }, {});
  const { translateX, translateY, scaleX, scaleY, rotate } = transformStyle;
  const transform = new Transform();

  if (translateX !== undefined || translateY !== undefined) {
    transform.move(translateX || 0, translateY || 0);
  }

  if (rotate !== undefined) {
    transform.rotate(rotate || 0);
  }

  if (scaleX !== undefined || scaleY !== undefined) {
    transform.scale(scaleX || 1, scaleY || 1);
  }

  return transform;
}

将上文的 ART Line 组件应用到 style 的转换:

// ...
const resolvedStyle = {
 ...defaultStyle,
 ...transCssStyle(style)
};
const transform = transTransform(resolvedStyle.transform);
const path = new Path()
 .moveTo(x1, y1)
 .lineTo(x2, y2)
 .close();

return (
 <Shape {...rest} {...resolvedStyle} d={path} transform={transform} />
);

Dom API 替换

对于 react-charts 的 Dom API 的替换主要涉及到有 getBoundingClientRect点击事件的处理。

  • getBoundingClientRect

在 React Native 中可以用 UIManager 来提供类似的接口,但是在使用上需要进行调整:

由 react-charts 的 el.getBoundingClientRect() 到 React Native 平台上的 getBoundingClientRect(el)

getBoundingClientRect.js 源码如下:

import { findNodeHandle, UIManager } from "react-native";

function transformBoundingClientRect(x, y, width, height, pageX, pageY) {
  return {
    left: pageX,
    top: pageY,
    right: pageX + width,
    bottom: pageY + height,
    width,
    height,
    offsetLeft: x,
    offsetTop: y
  };
}

export async function getBoundingClientRect(ref) {
  return new Promise(resolve => {
    UIManager.measure(findNodeHandle(ref), (...args) => {
      resolve(transformBoundingClientRect(...args));
    });
  });
}
  • 点击事件

React Native 处理点击事件需要用 PanResponder 来处理,因此涉及点击事件处理的部分也需要有所调整。

react-charts 里的写法:

<Svg onTouchStart={this.this.onTouchStart} {...otherProps}/>

需要改为:

panHandlers = createPanResponder({
  start: this.onTouchStart,
  move: this.onTouchMove, 
  end: this.onTouchEnd
});

<Svg {...panHandlers} {...otherProps}/>
  

createPanResponder.js 源码如下:

// @flow
import { PanResponder } from "react-native";

type ResponderFunction = (evt: Object, gestureState: Object) => void;

export default function createPanResponder({
  start,
  move,
  end,
  cancel
}: {
  start?: ResponderFunction,
  move?: ResponderFunction,
  end?: ResponderFunction,
  cancel?: ResponderFunction
}) {
  return PanResponder.create({
    onStartShouldSetPanResponder: (evt, gestureState) => true,
    onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
    onMoveShouldSetPanResponder: (evt, gestureState) => true,
    onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,

    onPanResponderGrant: (evt, gestureState) => {
      // start
      start && start(evt, gestureState);
    },
    onPanResponderMove: (evt, gestureState) => {
      // move
      move && move(evt, gestureState);
    },
    onPanResponderTerminationRequest: (evt, gestureState) => true,
    onPanResponderRelease: (evt, gestureState) => {
      // end
      end && end(evt, gestureState);
    },
    onPanResponderTerminate: (evt, gestureState) => {
      // cancel
      cancel && cancel(evt, gestureState);
    },
    onShouldBlockNativeResponder: (evt, gestureState) => {
      return true;
    }
  }).panHandlers;
}

成果

经过这一番折腾,笔者最终达成了基本目标。这个改造项目可以在: react-charts-native 进行查看 ,读者可以查看 commit 记录了解具体的改动过程。以下是成果截图:

react-charts-native-demo

总结

对笔者而言,本文的这番探索非常有意义。Web 图表库如果能复刻到 React Native 平台,React Native 图表技术选型将会有更多的选择。此外,在有了相对统一的图表框架的基础上,React Native 图表 Web 化也将非常方便

不过,目前笔者对 react-charts Native 化的进展只是停留在实验性的阶段,距离完全复刻 Web 上的成熟型功能还有一段距离。该项目也还有几个部分需要完善的地方:

  • 坐标的精细化

标记过长时展示会有重叠,这点不够完善。

  • 点击交互 (点击事件定位)处理

react-charts 的点击事件处理,不是将事件监听在 svg 元素上而是监听在 svg 里面的 path 等元素,而 ART 组件中除了 Surface 能监听点击事件,其他的 Group,Shape 组件都不行。所以,目前根据手势动态展示图例等功能还不能很好地支持。

  • 支持动画

react-charts 的动画依赖 react-show ,react-show 使用了 css3 transition 动画,这部分改造还没有做,所以没有支持。后期可以动态构造 path d 并结合 Animated 组件来实现动画。

以上部分涉及到具体的绘制逻辑,改动较大,需要在对 react-charts 核心代码有进一步的理解,才能很好地进行改造。笔者之后如有精力,将会持续完善。