Skip to content

🍨React Native 相关,涉及 MobX、MST使用,原生简易导航模块、列表组件封装,一些动画尝试,以及 HOC 应用。

Notifications You must be signed in to change notification settings

ljunb/RNProjectPlayground

Repository files navigation

目录

概览

这是一个自己随意玩耍的仓库,主要涉及的东西有以下几部分:

  • 基于 MobXMST 重新实现了 React Native 版食物派的个别页面
  • 通过 UINavigationController 和 Activity ,实现导航功能:push、pop、popTo、popToRoot。每个页面,React Native 都只作为 View 的角色存在
  • 收集一些自己的练习 Demo、组件,或是项目实践中的想法

导航功能

口袋蜜蜂(AppStore | 小米应用商店)是混编 App,在项目启动的前期,跟同事一起尝试了原生与 React Native 页面之间的各种导航场景,在此过程中也尝试了不同的几个 React Native 导航组件,略去其中细节,一番尝试后,回过头来想:既然是原生为主导,为何不就地取材,直接用原生的导航功能?React Native 本来就应该只承担 View 层的角色,数据的流转,实际仍是在原生层面。

因此,每次跳转的起始或最终界面,不管是原生,还是 React Native 页面,实际上都是原生到原生的导航。React Native 可通过注册多个 Component 的形式来加载多个页面,而口袋在几个版本的迭代下来之后,我们总结了较为推荐的方式是:

  • 共同:只注册一个 Component,不同页面在初始参数中添加标识位区分
  • iOS:采用单例 RCTBridge,并通过 - initWithBridge: moduleName: initialProperties: 的方式来创建 RCTRootView,然后在 initialProperties 这个初始化参数字典中,传入页面标识位和其他必要数据。
  • Android:通过一个 ReactActivityManager 来模拟 Activity 栈的管理,可以实现与 iOS 一样的 popTo 功能。在传递 Bundle 数据的时候,需注意的是 MapBundle 的转换处理。因为在 React Native 端调用 push(pageName, params) 时,带参情况传入的 params 为字典,映射到原生端的 MapBundle 对象存入数据时需按对应类型来进行获取。

在这个模式中,不同 React Native 页面之间的通知事件可正常使用,也可以按需在项目中集成 Redux 或是 MobX。口袋中集成了 MobX,类似代码在 App.js 文件中:

// App.js
import { Provider } from 'mobx-react';
import Router from './src/routers';
import stores from './src/stores';

export default (props) => {
  const { pageName: routerKey } = props;
  const Page = Router[routerKey].default;
  return (
    <Provider {...stores}>
      <Page {...props} />
    </Provider>
  );
};

store 的注入与普通的纯 React Native 项目一致,在相关页面通过 inject 按需检出子树即可。Router 是路由配置,页面标识位和页面文件路径是字典中 keyvalue 的关系:

// routers.js

export default {
  'main_tab': require('./pages'),
  'home': require('./pages/home'),
  'search': require('./pages/home/Search'),
  ...
}

所以只要在 routers 中配置好关系,通过 propspageName,即可匹配到不同的 React Native 页面。

↑ 返回顶部

与JavaScript的事件交互

既然是混编的 App ,那就免不了原生与 JavaScript 之间的事件交互。为了更方便地进行两端的发布&订阅,封装一个 CJNotification 的工具类。从 JavaScript 到原生端这一块的交互,当前工具类是不提供相关方法的,只是处理原生到 JavaScript 和 不同 React Native 页面之间的事件发布。工具类概览:

import {
  NativeEventEmitter,
  NativeModules,
  Platform,
  DeviceEventEmitter,
} from 'react-native';

const { CJNotificationCenter } = NativeModules;
const emitter = Platform.OS === 'android' ? new NativeEventEmitter() : new NativeEventEmitter(CJNotificationCenter);
const NativeEventName = 'NATIVE_TO_RN';

class Emitter {
  /**
   * 监听从 Native 发来的事件
   * @param event 事件名称
   * @param callback 监听回调
   * @function dispose 销毁监听对象
   */
  static addNativeListener = (event, callback) => {
    const subscription = emitter.addListener(
      NativeEventName,
      reminder => {
        const { eventName, body } = reminder;
        if (eventName !== event) return;
        callback && callback(body);
      },
    );
    subscription.dispose = () => subscription && subscription.remove();
    return subscription;
  };

  /**
   * 监听不同 RN 页面的通知事件
   * @param event 事件名称
   * @param callback 监听回调
   * @function dispose 销毁监听对象
   */
  static addRNListener = (event, callback) => {
    const subscription = DeviceEventEmitter.addListener(
      event,
      reminder => callback && callback(reminder),
    );
    subscription.dispose = () => subscription && subscription.remove();
    return subscription;
  };

  /**
   * 发送 RN 页面之间的通知
   * @param event 事件名称
   * @param body 发送内容
   */
  static sendRNEvent = (event, body) => DeviceEventEmitter.emit(event, body);
}

export default Emitter;

这里是原生端 iOSAndroid 的对应实现。

使用方式示例:

import CJNotification from '../utils/CJNotification';

export default class TestPage extends Component {
  componentDidMount() {
    this.addNativeListener();
    this.addRNListener();
  }

  addNativeListener = () => {
    this.nativeListener = CJNotification.addNativeListener('updateUserInfo', userInfo => {
      const { name } = userInfo;
      // todo sth
    });
  };

  addRNListener = () => {
    this.rnListener = CJNotification.addRNListener('updateFeedList', () => {
      // todo sth
    });
  };

  componentWillUnmount() {
    this.nativeListener.dispose();
    this.rnListener.dispose();
  }

  render() {
    ...
  }
}

iOS 端原生发送事件示例:

#import "CJNotificationCenter.h"

// 发送事件到 JavaScript
[[CJNotificationCenter center] sendRNEventWithName:@"updateUserInfo" body:@{@"name": @"cookiej"}];

Android 端:

// 发送事件到 JavaScript
WritableMap body = Arguments.createMap();
body.putString("name", "cookiej");
CJNotification.sendRNEvent("updateUserInfo", body);

不同 React Naitve 页面之间:

import CJNotification from '../utils/CJNotification';

export default class OtherPage extends Component {

  handleUpdateTestPageFeedList = () => CJNotification.sendRNEvent("updateFeedList");

  render() {
    ...
  }
}

↑ 返回顶部

Demo目录

这里主要是一些平时在有意无意中看到一些效果时,而做的 Demo 实践。没有一一罗列,更多的 Demo 可 clone 项目到本地查看。

类朋友圈查看图片

该效果 是类朋友圈查看图片效果的尝试,不过页码切换有所不一样,支持设置形变动画。运行示例: demo

新手引导装饰器

DemoDecorator 的简易应用,主要是实现一个快速为 React Native App 添加新手引导遮盖的需求,方便快捷易使用,相应组件地址

浮动文本动画输入框

该 效果 其实是属于 Google 的 Material 系列中的交互效果,上周有简单玩了下 Flutter ,发现里面的输入框组件,就是默认这种交互效果。而 React Native 相关的,其实网上也有类似组件,这里是自己看到效果后,做个简易版实现。运行示例:

demo

类Path菜单动画

Demo 是仿 Path 的菜单动画效果:

demo

常见支付密码输入框

Demo 是与支付宝类似的密码输入框:

demo

类WhatsApp转场动画

Demo 是自己在偶然之中,发现一位国外开发者的 仓库,里面是参考 UI Movement 上的动画而做的 React Native 实现,自己看完也是跃跃欲试,所以写了这个动画 Demo。运行示例:

demo

↑ 返回顶部

带索引SectionList

口袋项目中有一个选择汽车的分组列表,在指压并滑动索引时会有动画,项目启动时评估过 React Native 实现的性能问题,最终还是选择了原生实现。恰巧早上写完了家居的业务功能,想着用纯 React Native 来实现这个列表:

demo

iOS 在模拟器上的效果如上所示,JavaScript 线程掉帧还是挺严重的,UI FPS 看起来倒是正常,实际滑动起来表现并不卡。Android 端在模拟器上表现一般般,没有在真机中测试,并且还需要处理 overflow 的问题,所以到时布局还需根据平台做适配处理。

其实之前用官方自带的 SectionList 实现过这个模块,但是效果挺差的,分组跨度较大时,点击索引滚动时会出现白屏(只在 iOS 模拟器下调试,Android 没做进一步尝试)。当前 Demo 基于 react-native-largelist 实现(自己只在该示例中使用了该组件,并未集成到商业项目中)。

↑ 返回顶部

粘性TabBar

可能存在于某些商城类 App 中,在页面滚动至顶部时,分段菜单停留在导航栏底部,表现为粘性效果,并可点击菜单项滚动到对应的分组。在 iPhone 效果如下:

demo

不过比较意外的是,iPhone 上运行时,滑动过程中设置了粘性的子组件老是会跳动,Android 反而表现良好……当前 Demo 没有集成下拉刷新,可能仍需基于某些第三方来做定制。

↑ 返回顶部

轮播图动画指示器

暂时做了流动样式,后面考虑再做个 scale 渐变样式: demo

↑ 返回顶部

组件

PullRefreshListView

PullRefreshListView 是对 react-native-smart-pull-to-refresh-listview 的二次封装,可自定义下拉刷新、上拖加载更多的样式,也添加了空列表、数据加载出错时(分有数据和无数据)的样式定制,更适用于商业项目使用。简单使用示例:

import PullRefreshListView from './PullRefreshListView';

export default class MsgList extends Component {
  pageNo = 1;
  msgList = [];

  componentDidMount() {
    this.listView && this.listView.beginRefresh();
  }
  
  fetchMsgList = async() => {
    try {
      const responseData = await fetch(url).then(res => res.json());
      
      const result = this.pageNo === 1 ? [...responseData.list] : [...this.msgList, ...responseData.list];
      this.msgList = result;
      const isLoadAll = this.msgList.length >= responseData.total;
      this.listView && this.listView.setData(result, this.pageNo, isLoadAll);
    } catch (e) { 
      this.listView && this.listView.setError();
      // and log error message
    }
  };
  
  handleRefresh = () => {
    this.pageNo = 1;
    this.fetchMsgList();
  };
  
  handleLoadMore = () => {
    this.pageNo++;
    this.fetchMsgList();
  };
  
  /**
   *  setError 调用时触发,情况为:
   *  1 第一次进入列表出错时,pageNo 从 1 减至为 0,重置为 1
   *  2 加载更多时出错,此时页码已经加了 1 ,需要减 1,确保再次加载更多时的页码正确
   */
  handleLoadError = () => {
    this.pageNo--;
    if (this.pageNo < 1) {
      this.pageNo = 1;
    }
  };

  render() {
    return (
      <PullRefreshListView
        ref={r => this.listView = r}
        onRefresh={this.handleRefresh}
        onLoadMore={this.handleLoadMore}
        onSetError={this.handleLoadError}
      />
    );
  }
}

↑ 返回顶部

关于HOC应用

基本上,每个页面都会存在首屏渲染和网络出错的占位图,大部分情况下,我们会发现其中的实现逻辑大同小异,所以看到这些页面,自己经常觉得代码很冗余,一直想着有没一些优化的方法。

较早之前写过一个关于新手引导的 组件,是对 HOC 的简单应用,大抵是抽取公用的代码逻辑做为上一层的封装,新手引导内容则由具体组件去负责。基于这种思路,尝试对网络请求的通用业务需求做一次解耦简化,期望是通过一次编写 HOC ,然后不再涉及首屏渲染,或是网络出错这些状态处理的编写逻辑,并支持动态配置不同的占位组件。

于是,有了这个 尝试

代码概览

罗列的代码中,将省略部分不必要内容:

// HOCUtils.js

const enhanceFetch = (WrappedComponent, options) => class extends Component {
  static propTypes = {
    requestQueues: PropTypes.array.isRequired, // A.1
  }
  
  constructor(props) {
    super(props)
    this.state = {
      isLoading: true,
      isLoadError: false,
      data: null,
    }
  }

  componentDidMount() {
    this.fetchData()
  }

  fetchData = async () => {
    try {
      const { requestQueues } = this.props
      const requestHandlers = []

      requestQueues.map(request => requestHandlers.push(this.convertHandler(request)))
      const requestResults = await Promise.all(requestHandlers) // A.2
      this.setState({
        isLoading: false,
        data: requestResults.length === 1 ? requestResults[0] : requestResults,
      })
    } catch (e) {
      this.setState({
        isLoading: false,
        isLoadError: true,
      })
    }
  }

  convertHandler = ({url, options = {}}) => {
    return new Promise((resolve, reject) => {
      fetch(url, options)
        .then(res => res.json())
        // TODO:实际上这里还应有接口响应 code 的判断,eg:code === 1 → success
        // 具体跟接口同事协商即可
        .then(responseData => resolve(responseData))
        .catch(err => reject(err))
    })
  }

  handleReload = () => this.setState({ isLoading: true, isLoadError: false }, this.fetchData)

  handleUpdateData = data => this.setState({ data })

  render() {
    const { style, ...rest } = this.props
    const { isLoadError, isLoading, data } = this.state
    const isShowContent = !isLoading && !isLoadError
    const ShowedLoading = options && options.loading || DefaultLoading
    const ShowedNetError = options && options.error || DefaultNetError

    return (
      <View style={[styles.root, style]}>
        {isLoading && <ShowedLoading />}
        {isLoadError && <ShowedNetError onReload={this.handleReload} />}
        {isShowContent &&
          <WrappedComponent
            {...rest}
            data={data}
            fetchData={this.fetchData}
            updateData={this.handleUpdateData}
          />
        }
      </View>
    )
  }
}

export { enhanceFetch }

代码梳理

  • HOC 负责 isLoadingisLoadError 的管理,完成不同占位图的渲染
  • 暴露 enhanceFetch(component: ReactComponent, options: object) 的接口,根据需要在 options 中配置 loadingerror。如无设置,则使用默认的占位图

关于 props

  • A.xrequestQueues:这里主要是接收多个请求的配置及其接口响应处理。每个请求将保持 {url: ‘’, options: {}} 的格式,触发请求之前会进行 Promise 化,然后基于 Promise.all() 进行并发。单请求将返回一个结果,并发请求将返回一个结果数组,与传入的请求参数顺序一一对应

关于 WrappedComponentprops

  • data:接口响应的数据
  • fetchData:如果页面需要重新请求数据,通过 this.props.fetchData() 的方式触发
  • updateData:单纯的进行本地数据更新,可采用 this.props.updateData(newData) 的方式,newData 为最新数据,格式应与旧数据保持一致

使用方式:

import { enhanceFetch } from './HOCUtils'

class TargetList extends PureComponent {

  handleUpdateData = () => this.props.updateData && this.props.updateData([0, 1, 2, 3])

  handleFetchData = () => this.props.fetchData && this.props.fetchData()

  renderContent = (item, index) => {
    return (
      <View key={`Content_${index}`} style={styles.item}>
        {item.keywords && <Text>热搜词:{item.keywords}</Text>}
        {item.group_count && <Text>分组数量:{item.group_count}</Text>}
        {Number.isInteger(item) && <Text>Reload data: {item}</Text>}
      </View>
    )
  }

  render() {
    const { data = null } = this.props

    return (
      <View style={styles.root}>
        <View>
          {data && data.map(this.renderContent)}
        </View>
        <View style={styles.btnWrapper}>
          <TouchableOpacity onPress={this.handleUpdateData}>
            <Text>Update Data</Text>
          </TouchableOpacity>
          <TouchableOpacity onPress={this.handleFetchData}>
            <Text>Fetch Data</Text>
          </TouchableOpacity>
        </View>
      </View>
    )
  }
}

const CustomerLoading = () => {
  return (
    <Text>Customer Loading...</Text>
  )
}

// 进行修饰
const FinalList = enhanceFetch(TargetList, { loading: CustomerLoading })

export default () => {
  const requestQueues = [
    {url: 'http://food.boohee.com/fb/v1/keywords', options: {}},
    {url: 'http://food.boohee.com/fb/v1/categories/list', options: {}}
  ]
  return <FinalList requestQueues={requestQueues} />
}

很明显,其实 FinalList 就是智能组件,用于进行占位图、网络请求的配置,或者还有其他配置;而 TargetList 则是木偶组件,无须感知与 UI 无关的其他东西。到这一步,假如要新建业务页面,那么需要做的工作,就是做好接口和占位图的按需配置,然后直接进行 UI 的编码工作即可,无须再处理首屏渲染和网络出错逻辑。

其他思考

  • 列表下拉刷新、加载更多支持?
  1. WrappedComponent 增加 enableRefreshenableLoadMoreprops,来开启或忽略这些功能。但是页码的参数名?page?亦或pageNo?
  2. 目前项目中的列表基于 react-native-smart-pull-to-refresh-listview 做了二次封装,满足通用的首屏渲染和网络出错的处理,不过该组件目前仍然未采用 FlatList 实现
  • 其他暂未想到

↑ 返回顶部

About

🍨React Native 相关,涉及 MobX、MST使用,原生简易导航模块、列表组件封装,一些动画尝试,以及 HOC 应用。

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published