分类 Javascript 下的文章

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 核心代码有进一步的理解,才能很好地进行改造。笔者之后如有精力,将会持续完善。

React-Native 里怎么写表单业务代码

笔者最近处理了一些大表单的业务需求,本次结合自己的一些实践经验,来谈谈对表单业务代码的一些感悟

引子

我们先看看 React-Native 里面怎么写一个文本输入框:

export default class UselessTextInput extends Component {
  constructor(props) {
    super(props);
    this.state = { text: 'Useless Placeholder' };
  }

  render() {
    return (
      <TextInput
        onChangeText={(text) => this.setState({text})}
        value={this.state.text}
      />
    );
  }
}

为了收集输入框的值,我们需要给表单组件 TextInputvalue 绑定到一个state,并添加 onChangeText 事件来更改对应的 state,这样TextInput输入的值,就能被收集到state里面。

在输入框比较多的情况,我们需要一个一个给输入框加上这样的逻辑。在某些情况下,我们可以用一个共用的事件处理方法来处理。如:

handleChange = (type, value) => {
  this.setState({
    [type]: value
  })
}

render() {
  const { name, age } = this.state
  return (
    <View>
      <TextInput
        onChangeText={(text) => this.setState({name: text})}
        value={name}
      />
      <TextInput
        onChangeText={(text) => this.setState({age: text})}
        value={age}
      />
    </View>
  )
}

这样处理没什么很大的问题。

但是,如果我们的业务需求有很多大表单,需要文本输入框,单选框,复选框,弹出选择框等各种类型的表单组件,我们还需要表单数据收集,常规检验,与错误提示等等。在这些复杂的场景下,我们只会在上面再堆一大堆逻辑,这个时候可以想一下是不是可以做点优化什么的?

笔者在工作中正好遇到了这样的复杂场景,本着组件复用,逻辑复用的原则,笔者觉得应该需要封装一下这些表单组件与逻辑。

目标

在对表单组件做封装之前,我们先来确认一下希望达成的目标:

  • 定制化风格表单组件

    基本表单组件,自定义表单组件的封装,能实现页面布局,组件复用

  • 抽象表单数据收集

    onChangeText 等处理组件输入数据的同步逻辑可以抽象出来,使其通用

  • 抽象表单检验与错误处理

    能够提供通用常规检验处理与自定义检验方法

目标确认之后,笔者在使用第三方库与自己动手实现,这两个方面进行了思考,最后决定自己实现。理由如下:

  • 即使用了第三方的表单组件,但是UI还是需要定制
  • 第三方库没有非常规的表单组件,但是这种组件也需要将数据收集到表单
  • 核心的表单数据收集逻辑,并不复杂
  • 自己动手,可轻可重,细节可控,便于维护

实现

在编码之前,笔者参考了一些抽象化的表单的使用场景,最终希望实现的代码应该是长成下面的样子:

submit(){
  const values = this.form.getValues();
  if (values) {
    // 提交表单数据
  }
}

render() {
 return (
    <Form ref={(ref) => {
      this.form = ref;
    }}>
    <FormTextInput
      required
      help={"请输入姓名!"}
      name={"name"}
      placeholder={"请输入姓名"}
      defaultValue={""}
    />
    <FormTextInput
      required
      help={"请输入年龄!"}
      name={"age"}
      placeholder={"请输入年龄"}
      defaultValue={""}
    />
   </Form>
 )
}

组件 Form 是表单内数据统一收集检验的载体。

FormTextInput 是对TextInput做了封装的表单组件,需要放到Form里面使用,在UI上符合我们的定制化需求。

FormTextInput接受到的props,传入的name作为字段名,defaultValue 是初始值,help是为空值的错误提示语,requiredhelp搭配,在submit方法调用时,会对该字段做简单的非空检验,如果字段为空值则会提示。

围绕这些特性,笔者细化了一下目标,作为一套表单组件,应该至少有这些功能:

  • 表单组件: 涵盖常用的表单如文本输入框等

  • 数据管理: 赋初始值,保存变化值

  • 表单检验: 简单非空检验,自定义规则检验

首先,实现基本的表单组件,这个是页面布局与功能的基础。依照需求,需要实现文本框,单选框,复选框,选择框等组件,并且定制UI,这一点无需赘述。在这一个过程,我们只是实现UI与单个组件操作逻辑,还没有统一管理数据逻辑。

其次,来看看数据管理,我们需要将数据统一交给Form组件管理,保持输入框值改变了之后能同步保存。这里就需要跨组件进行通信。利用context,可以无需声明props来进行父子组件的通讯,符合要求。

而对于表单检验,也是统一在Form组件里面处理,不过是否需要检验则需要看像FormTextInput这样的组件是否传了相关的条件如required

本文不谈UI层面上的基本表单组件的实现,主要谈谈表单组件数据相关的逻辑

架构

笔者本次实现的表单组件主要是三部分:Form组件,createInput高阶组件(HOC),createInput包装过的表单组件。

架构概览图如下:

form

基本能力

如下是Form组件的实现:

// @flow
// 表单组件抽象,利用 context 与表单内输入框做数据绑定
// 统一管理输入框值,基础检验,自定义检验等

import React, { PureComponent } from "react";
import { View } from "react-native";
import propTypes from "prop-types";
import Toast from "components/toast";
import type { formValueType, formValidationType } from "./flowTypes";

export default class Form extends PureComponent<{
  children: any
}> {
  static childContextTypes = {
    inForm: propTypes.bool,
    setValue: propTypes.func,
    getValue: propTypes.func,
    setValidation: propTypes.func,
    clear: propTypes.func
  };
  _values: formValueType = {};
  _validation: formValidationType = {};
  getChildContext() {
    return {
      inForm: true,
      setValue: this.setValue,
      getValue: this.getValue,
      setValidation: this.setValidation,
      clear: this.clear
    };
  }
  getValues(isValidate: boolean = true) {
    // 获取数据
    if (isValidate) {
      return this.validate() ? this._values : null;
    }
    return this._values;
  }
  validate() {
    // 表单检验
    return Object.keys(this._values).every(name => {
      const validation = this._validation[name];
      const value = this._values[name];
      if (typeof validation === "function") {
        return validation(value);
      }
      if (validation && !value) {
        Toast.show(validation);
        return false;
      }
      return true;
    });
  }
  removeValue(name: string) {
    delete this._values[name];
  }
  removeValidation(name: string) {
    delete this._validation[name];
  }
  clear = (name: string) => {
    this.removeValidation(name);
    this.removeValue(name);
  };
  setValidation = (name: string, help: any) => {
    this._validation[name] = help;
  };
  getValue = (name: string) => this._values[name];
  setValue = (name: string, value: any) => {
    this._values[name] = value;
  };
  render() {
    return <View>{this.props.children}</View>;
  }
}

getChildContext 为子组件提供了一些属性和方法,其中,setValue getValue方法提供了对字段值的读写操作,使得表单组件能够同步数据到Form

 getChildContext() {
    return {
      inForm: true,
      setValue: this.setValue,
      getValue: this.getValue,
      setValidation: this.setValidation,
      clear: this.clear
    };
  }

高级能力

对于Form里面的表单组件,都需要使用 setValue getValue,这是每个表单组件的必要逻辑,我们可以抽象这些逻辑。

React强调组合优于继承的理念,官方推荐的做法是用HOC 高阶组件的方式来复用小组件来构建大组件。使用高阶组件,可以将任何组件进行数据绑定到表单,既能满足常用组件,也能满足自定义组件。

// @flow
// 将输入框绑定到 Form 表单进行管理,支持自定义组件拓展

import React, { PureComponent } from "react";
import propTypes from "prop-types";

export default function createInput(WrappedComponent: any): any {
  return class BaseFormInput extends PureComponent<any> {
    static contextTypes = {
      inForm: propTypes.bool,
      inCollapse: propTypes.bool,
      setValue: propTypes.func,
      getValue: propTypes.func,
      setValidation: propTypes.func,
      clear: propTypes.func
    };
    state = {
      value: this.getInitValue()
    };
    constructor(props: any, context: any) {
      super(props, context);
      if (this.context.inForm !== undefined) {
        this.init();
      }
    }
    init() {
      const { name, validate, required, help } = this.props;
      const { value } = this.state;
      if (name) {
        if (validate) {
          this.context.setValidation(name, validate);
        } else if (required && help) {
          this.context.setValidation(name, help);
        }
        this.context.setValue(name, value);
      }
    }
    getInitValue() {
      const { name, defaultValue } = this.props;
      return defaultValue || (name ? this.context.getValue(name) || "" : "");
    }
    setValue = (value: any) => {
      this.setState({ value });
      const { name } = this.props;
      if (this.context.inForm && name) {
        this.context.setValue(name, value);
      }
    };
    render() {
      return (
        <WrappedComponent
          {...this.props}
          setValue={this.setValue}
          value={this.state.value}
        />
      );
    }
  };
}

利用createInput函数,可以创造各种表单组件,如下是一个文本输入表单组件示例:

export default createInput(
  class ItemTextInput extends PureComponent<any> {
    onChangeText = (value: string) => {
      const { onChange, setValue } = this.props;
      setValue(value);
      onChange && onChange(value);
    };
    render() {
      return (
       <View style={styles.rawInput}>
         <TextInput
           placeholderTextColor={"#999999"}
           onChangeText={this.onChangeText}
           {...otherProps}
         />
       </View>
     );
   }

各个表单组件只需要 setValuevalue来保持数据同步。

总结

通过这些实践,笔者编写的业务代码十分清爽,达到了想要的效果。虽然实现的功能还有一些不全,但是当下还能满足业务需求。

在整个开发的过程中,笔者花的时间最多的不是在这些表单的数据逻辑抽象复用,而是在实现基本的定制化的表单组件上。这也说明了有些优化性的尝试不一定会花掉很多时间,为了提升代码体验和自身的代码能力,花的这些时间也是很有意义的。

题外

笔者在本次实践中使用了两个套路:

  • context组件通信

本次利用了context来进行组件通信,笔者也看到有开源方案用redux来进行数据存储与事件处理

  • HOC高阶组件

这是React推荐的组件复用方法,我们能在很多开源代码如react-redux源码里面看到

React-Redux 工作原理

原理

  • 发布/订阅

connect 过的组件订阅 state变化,dispatch通知所有订阅者更新

  • 渲染时机

每次 dispatchreducer 产生一个新 state 对象,所有 connect 组件判断是否更新,因为是数据的浅比较,所以所需对象引用变了,则该组件重新渲染

  • 性能问题

v5.0 之前存在着一个问题:父(祖先)子(孙)组件都订阅,子(孙)组件可能受到父(祖先)组件渲染影响,而导致多次渲染。v5.0 之后 发布/订阅 模块重写,解决了之前的问题,主要特点:增加层级(嵌套)观察者,保证事件通知与组件更新的顺序

v5 源码浅析

简单分析下 v5 是如何解决性能问题的:

观察者

// 源码:src/utils/Subscription.js

// 添加层级订阅
addNestedSub(listener) {
  this.trySubscribe()
  return this.listeners.subscribe(listener)
}
// 订阅
trySubscribe() {
  if (!this.unsubscribe) {
   this.unsubscribe = this.parentSub
     ? this.parentSub.addNestedSub(this.onStateChange) // 订阅到父(向上找到最开始的祖先)组件
     : this.store.subscribe(this.onStateChange) // 订阅到 redux

   this.listeners = createListenerCollection()
  }
}
// 通知层级订阅
notifyNestedSubs() {
  this.listeners.notify()
}
// 通知
notify() {
   const listeners = current = next
   for (let i = 0; i < listeners.length; i++) {
     listeners[i]()
   }
}

订阅

通过 context 传递 subscription,组件订阅前检查,如果父(祖先)组件已经订阅,则将子组件的回调函数 stateChange 订阅到父(向上找到最开始的祖先)组件的观察者,否则订阅到 redux store(上面的 trySubscribe 方法)

// 源码:src/components/connectAdvanced.js

getChildContext() {
  const subscription = this.propsMode ? null : this.subscription
    return { [subscriptionKey]: subscription || this.context[subscriptionKey] }
}

componentDidMount() {
  ...
  this.subscription.trySubscribe() // 订阅
}

initSubscription() {
  if (!shouldHandleStateChanges) return
    const parentSub = (this.propsMode ? this.props : this.context)[subscriptionKey]
    this.subscription = new Subscription(this.store, parentSub, this.onStateChange.bind(this)) // 新建订阅对象
    this.notifyNestedSubs = this.subscription.notifyNestedSubs.bind(this.subscription)
}

发布更新

  • 父(祖先)组件需要更新,则执行 setState 重新 render, 并在 ComponentDidUpdate 时通知子(孙)组件更新(触发回调函数)
  • 父(祖先)组件不需要更新,直接通知子(孙)组件更新
// 源码:src/components/connectAdvanced.js

onStateChange() {
  this.selector.run(this.props)

  if (!this.selector.shouldComponentUpdate) {
   this.notifyNestedSubs() // 通知
  } else {
   this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
   this.setState(dummyState) // 更新自身
  }
}

notifyNestedSubsOnComponentDidUpdate() {
  // 在 componentDidUpdate 方法通知,确保自身与子组件渲染之后再通知
  this.componentDidUpdate = undefined
  this.notifyNestedSubs()
}

疑问与总结

父(祖先)组件setState明明可能会让子(孙)组件更新,为什么还需要通知一次?

  • 必要性:因为存在这样的场景:子组件可能不更新(ShouldComponentUpdate 返回 false),则需要更新的孙子组件没有受 setState 影响,为了保证所有订阅的组件都能得到数据更新,所以需要通知

  • 无副作用:假设子组件因为父组件 render 而重新 render,因为通知是在父组件 componentDidUpdate 方法触发,而子组件因为已经渲染过,数据得到了更新,所以就算再收到了通知也不会再渲染了

React-Native Animated 动画源码浅析

引子

Aniamted组件是React-Native官方出品的动画组件。在本文开始之前,我们首先来看看来自官网的一个例子,看看如何使用Animated组件。

import React from 'react';
import { Animated, Text, View } from 'react-native';

class FadeInView extends React.Component {
  state = {
    fadeAnim: new Animated.Value(0),  // Initial value for opacity: 0
  }

  componentDidMount() {
    Animated.timing(                  // Animate over time
      this.state.fadeAnim,            // The animated value to drive
      {
        toValue: 1,                   // Animate to opacity: 1 (opaque)
        duration: 10000,              // Make it take a while
      }
    ).start();                        // Starts the animation
  }

  render() {
    let { fadeAnim } = this.state;

    return (
      <Animated.View                 // Special animatable View
        style={{
          ...this.props.style,
          opacity: fadeAnim,         // Bind opacity to animated value
        }}
      >
        {this.props.children}
      </Animated.View>
    );
  }
}

// You can then use your `FadeInView` in place of a `View` in your components:
export default class App extends React.Component {
  render() {
    return (
      <View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
        <FadeInView style={{width: 250, height: 50, backgroundColor: 'powderblue'}}>
          <Text style={{fontSize: 28, textAlign: 'center', margin: 10}}>Fading in</Text>
        </FadeInView>
      </View>
    )
  }
}

这个例子展示了如何写一个FadeInView动画组件,呈现了在10s内驱动一个组件的透明度从0到1渐变的过程。

要点

从上面这个例子中,我们可以归纳出Animated动画组件的几个要点:

  • new Animated.Value(0) => 即 Animated.Value 动画值
  • Animated.timing(value, { toValue }).start() => 即Animated.timing动画函数
  • <Animated.View style={ animatedValue }/> => 即Animated.View动画组件

问题

在下文的源码分析中,我们将带着以下几个问题来进行分析:

  • 动画组件 Animated.View style 如何更新?
  • 动画值 Animated.Value 与 动画组件 Animated.View之间有什么关系?
  • 动画函数 Animated.timing 与 动画值 Animated.Value 之间有什么关系?

源码分析

本文分析的源码在:react-native@0.49.3/Libraries/Animated/src

本文分析篇幅仅限不使用原生动画驱动的场景,原生动画驱动使用了NativeAnimatedModule,鉴于笔者没有相关原生开发经验,在此不展开。不过据笔者阅读源码所知:原生动画驱动并不会驱动组件重复渲染,而是直接修改相关属性

调用栈

首先来看看动画执行时的调用栈,之后我们再详细分析:

我们先来了解一下,作为动画过程的关键变化值Animated.Value

动画值Animated.Value

动画值的Animated.ValueAnimatedValue类的实例,源码在:nodes/AnimatedValue.js
AnimatedValue有几个特点:

维护自身值的变化

setValue,__getValue,_updateValue等方法

管理自身调用的其他实例对象

class AnimatedValue extends AnimatedWithChildren (72)

AnimatedValue 继承自 AnimatedWithChildren,使的AnimatedValue实例拥有_children数组属性,与相关操作_childrenaddremove方法。(AnimatedWithChildren之后也有相关介绍)

在自身值变化会触发其他实例对象的update方法

由上图调用栈我们知道:animate => _updateValue => _flush => animateStyle.update (63)

我们先来看下animate方法:

animate(animation: Animation, callback: ?EndCallback): void {
    ......
    animation.start(
      this._value,
      value => {
        // Natively driven animations will never call into that callback, therefore we can always
        // pass flush = true to allow the updated value to propagate to native with setNativeProps
        this._updateValue(value, true /* flush */);
      },
      result => {
        this._animation = null;
        if (handle !== null) {
          InteractionManager.clearInteractionHandle(handle);
        }
        callback && callback(result);
      },
      previousAnimation,
      this,
    );
  }

_updateValue会作为动画函数的回调函数,在数据更新时触发。而_updateValue会更新自身value,并触发flush函数。

我们再来看下flush方法:

function _flush(rootNode: AnimatedValue): void {
  const animatedStyles = new Set();
  function findAnimatedStyles(node) {
    if (typeof node.update === 'function') {
      animatedStyles.add(node);
    } else {
      node.__getChildren().forEach(findAnimatedStyles);
    }
  }
  findAnimatedStyles(rootNode);
  /* $FlowFixMe */
  animatedStyles.forEach(animatedStyle => animatedStyle.update());
}

flush 函数通过向上递归遍历找到最近的使用该 value,并且拥有update方法的实例对象,并触发该类的 update 方法。

接下来我们看看,动画怎么执行。

动画函数 Animated.timing

动画开始start其实是触发AnimatedValue类的animate方法

singleValue.animate(new TimingAnimation(singleConfig), callback);(AnimatedImplementation.js 200)

AnimatedValueanimate方法中,其实是调用了TimingAnimation类的start方法

TimingAnimation类的源码在:animations/TimingAnimation.js

我们来看下start方法:

  start(
    fromValue: number,
    onUpdate: (value: number) => void,
    onEnd: ?EndCallback,
    previousAnimation: ?Animation,
    animatedValue: AnimatedValue,
  ): void {
      ......
    const start = () => {
      // Animations that sometimes have 0 duration and sometimes do not
      // still need to use the native driver when duration is 0 so as to
      // not cause intermixed JS and native animations.
      if (this._duration === 0 && !this._useNativeDriver) {
        this._onUpdate(this._toValue);
        this.__debouncedOnEnd({finished: true});
      } else {
        this._startTime = Date.now();
        if (this._useNativeDriver) {
          this.__startNativeAnimation(animatedValue);
        } else {
          this._animationFrame = requestAnimationFrame(
            this.onUpdate.bind(this),
          );
        }
      }
    };
    if (this._delay) {
      this._timeout = setTimeout(start, this._delay);
    } else {
      start();
    }
  }

即:animation.start => animation.onUpdate (requestAnimationFrame)

onUpdate方法,则是根据当前选择的缓动方法easing计算更新时机,并在requestAnimationFrame触发回调函数(AnimatedValue_udpateValue)。

接下来,是最重要的组件环节了。

动画组件 Animated.View

Animated.View是封装过的React组件,名称是AnimatedComponent,源码在createAnimatedComponent.js

props属性获取

render() {
  const props = this._propsAnimated.__getValue();
  ......
}

this._propsAnimatedAniamtedProps类的实例,AnimatedComponent组件的propsAniamtedProps维护,关于AniamtedProps,之后会详细介绍。

组件更新

AnimatedComponent组件的生命周期方法componentWillMount/componentWillReceiveProps中都调用了_attachProps方法,我们先来看下这个方法:

_attachProps(nextProps) {
  ......
  const callback = () => {
    if (
      !AnimatedComponent.__skipSetNativeProps_FOR_TESTS_ONLY &&
      this._component.setNativeProps
    ) {
      if (!this._propsAnimated.__isNative) {
        this._component.setNativeProps(
          this._propsAnimated.__getAnimatedValue(),
        );
      } else {
        throw new Error(
          'Attempting to run JS driven animation on animated ' +
            'node that has been moved to "native" earlier by starting an ' +
            'animation with `useNativeDriver: true`',
        );
      }
    } else {
      this.forceUpdate();
    }
  };

  this._propsAnimated = new AnimatedProps(nextProps, callback);
  ......
}

即:_attachProps => new AnimatedProps(props, callback(forceUpdate)) (104)

我们注意到,组件将forceUpdate封装成了回调函数,并交给了AniamtedProps

接下来,我们来详细看看AnimatedProps

props维护者AnimatedProps

AnimatedProps的源码在nodes/AnimatedProps.js,该类的构造函数如下:

class AnimatedProps extends AnimatedNode {
  _props: Object;
  _animatedView: any;
  _callback: () => void;

  constructor(props: Object, callback: () => void) {
    super();
    if (props.style) {
      props = {
        ...props,
        style: new AnimatedStyle(props.style),
      };
    }
    this._props = props;
    this._callback = callback;
    this.__attach();
  }

注意这行:this._callback = callback;

Animated.View组件,将触发自身forceUpdate的回调函数传给AnimatedProps类的构造函数,以在适当的时机触发该回调函数。

与此相关的是AnimatedProps的实例方法update

update(): void {
    this._callback();
 }

所以:执行update方法,便会触发从动画组件Animated.View传过来的回调函数,在不使用原生动画驱动的情况下,触发forceUpdate,使组件重新渲染。

style是一个AnimatedStyle类实例对象。AnimatedStyle类下文会详细介绍。

同样的,__attach方法很关键,我们来看一下:

__attach(): void {
    for (const key in this._props) {
      const value = this._props[key];
      if (value instanceof AnimatedNode) {
        value.__addChild(this);
      }
    }
  }

对于使用了继承自AnimatedNode的类实例,该实例会将当前AnimatedProps实例对象,添加到_children数组中寄存,便于之后使用。

在本例中,AnimatedStyle实例对象会将AnimatedProps实例对象添加到_children数组属性中存储。

对于props值的获取,是AnimatedProps类的_getValue方法,该方法如下:

__getValue(): Object {
    const props = {};
    for (const key in this._props) {
      const value = this._props[key];
      if (value instanceof AnimatedNode) {
        if (!value.__isNative || value instanceof AnimatedStyle) {
          // We cannot use value of natively driven nodes this way as the value we have access from
          // JS may not be up to date.
          props[key] = value.__getValue();
        }
      } else if (value instanceof AnimatedEvent) {
        props[key] = value.__getHandler();
      } else {
        props[key] = value;
      }
    }
    return props;
  }

从这里我们知道,对于style属性,AnimatedProps直接取自AnimatedStyle_getValue

接下来,我们来看下AnimatedStyle

style的维护者AnimatedStyle

在例子中以及经过上面的代码分析,我们得知:AnimatedValue的实例对象交给了AnimatedStyle

我们来看看AnimatedStyle的构造函数。

class AnimatedStyle extends AnimatedWithChildren {
    ......
    constructor(style: any) {
    ......
    if (style.transform) {
      style = {
        ...style,
        transform: new AnimatedTransform(style.transform),
      };
    }
    this._style = style;
    }
}

在此说明一下,如果style 中有transform属性,则会创建一个AnimatedTransform类实例来管理transform

我们知道,AnimatedValue也是style的属性,我们来关注下与AnimatedValue相关的有两个主要地方:

  • __attach 方法

该方法保证将使用了AnimatedValue的当前实例被添加到_children数组中。

__attach(): void {
    for (const key in this._style) {
      const value = this._style[key];
      if (value instanceof AnimatedNode) {
        value.__addChild(this);
      }
    }
  }

不像AnimatedProps,在AnimatedStyle类的构造函数中,并没有调用__attach方法。通过观察函数调用栈,我们发现了端倪:

AnimatedWithChildren__addChild方法,会在_children数组为空时,调用__attach方法。因为AnimatedStyle继承自AnimateWithChildren也没有重写__addChild,所以会执行同样的逻辑。

 __addChild(child: AnimatedNode): void {
    if (this._children.length === 0) {
      this.__attach();
    }
    ......
  }

(nodes/AnimatedWithChildren.js)

所以:AnimatedProps__attach方法里,AnimatedStyle 执行__addChild时,便会自动执行__attach完成下层调用关系 (AnimatedValue) 的添加

  • AnimateValue的获取
__getValue(): Object {
    return this._walkStyleAndGetValues(this._style);
  }

_walkStyleAndGetValues(style) {
    const updatedStyle = {};
    for (const key in style) {
      const value = style[key];
      if (value instanceof AnimatedNode) {
        if (!value.__isNative) {
          // We cannot use value of natively driven nodes this way as the value we have access from
          // JS may not be up to date.
          updatedStyle[key] = value.__getValue();
        }
      } else if (value && !Array.isArray(value) && typeof value === 'object') {
        // Support animating nested values (for example: shadowOffset.height)
        updatedStyle[key] = this._walkStyleAndGetValues(value);
      } else {
        updatedStyle[key] = value;
      }
    }
    return updatedStyle;
  }

这里保证了AnimatedStyle能够拿到AnimatedValue的值。

归纳

由此可知,动画组件Animated.ViewAnimatedComponent,在初次渲染时,构建了如下所属关系:

AnimatedComponent <= AnimatedProps <= AnimatedStyle (<= AniamtedTransform)<= AnimatedValue

其中,AnimatedValue.__children保留了AnimatedStyle实例,AnimatedStyle.__children保留了AnimatedProps实例,AnimatedProps保留了AnimatedComponentforceUpdate回调函数,以此满足了由下至上的遍历方式,并驱动组件更新。

完整过程

flow

Animated.View组件在初始化渲染的同时,使得Animated.Value保存了调用自身的类实例对象。

timing.start 动画开始执行,Animated.Value会以一定时间规律触发自身值改变,并在改变的同时,触发flush函数,通过向上遍历调用实例的update方法。

在整个调用子集中,只有AnimatedProps类有 update 方法,而AnimatedProps类的update函数,只是执行来自Aimated.View组件的回调函数。

动画组件Animated.View的回调函数里调用了forceUpdate,该函数执行,组件强行更新,并重新render获得新的style

结论

不使用原生动画驱动的场景下,Animated.View会在动画函数根据缓动方式的更新时机触发在requestAnimationFrame 包裹下的forceUpdate ,使组件持续更新并拿到最新的style

题外

在本文中,我们可以学到几个套路:

React 动画解决方案:缓动函数 + requestAnimationFrame + forceUpdate

自底向上遍历:跨组件通信,底层组件驱动嵌套的上层组件更新。

模板预编译技术

介绍

预编译 的使用场景在于,一些文本转换或格式化,通常模板固定,数据动态变化的情况。通过预编译技术,将重复使用的模板预先编译,避免了实时编译转换,以达到提升性能的目的。

下文将介绍 预编译技术 的相关知识:

  • 内部原理
  • 简单实现
  • 相关实践

内部原理

预编译的结果,得到的是对模板进行词法分析而得到的一系列标记 (tokens)。对数据进行渲染 (执行 render 函数)时,其实是通过遍历 tokens 再组合数据,生成结果。
即:

  • Compile: template => tokens
  • Render: tokens + data => result string

简单实现

function parse(templ) {
  var tokens = [];
  var maxTime = Date.now() + 10;
  while (templ) {
    if (templ.indexOf("{") === 0) {
      var index = templ.indexOf("}");
      tokens.push({
        type: "variable",
        name: templ.slice(1, index)
      });
      templ = templ.substring(index + 1);
      continue;
    }
    var index = templ.indexOf("{");
    var text = index < 0 ? templ : templ.substring(0, index);
    templ = index < 0 ? "" : templ.substring(index);
    tokens.push({
      type: "text",
      value: text
    });
    if (Date.now() >= maxTime) break;
  }
  return tokens;
}
function compile(tokens, data) {
  var str = '';
  for (var i = 0; i < tokens.length; i++) {
    var token = tokens[i];
    switch (token.type) {
      case "text":
        str += token.value;
        break;
      case "variable":
        str += data[token.name];
        break;
      default:
        break;
    }
  }
  return str;
}
var preCompile = function (template) {
  var tokens = parse(template);
  return {
    render: function (data) {
      return compile(tokens, data);
    }
  };
};

var template = preCompile("I am {name} i'm {age} years old!");
console.log(template.render({ name: "Sam", age: 22 }));
console.log(template.render({ name: "Jack", age: 23 }));

相关实践

  • 模板引擎
  • 时间格式化 tiny-time (https://github.com/aweary/tinytime)
  • Css Selector css-what (https://github.com/fb55/css-select)