如何实现 React Native 里的页面导航系统

React Native 中的页面导航和跳转一直是一个让人头疼的问题,其实社区里也已经有各种实现,比如 react-navigationwix/react-native-navigationLeoLeBras/react-router-navigationairbnb/native-navigation,如你所见,react native navigation 三个单词各种排列组合基本上都有了,所以从这些 library 里挑一个合适的同样更让人头疼。

我们在新起的项目中决定用纯 React Native 实现,以尽量减少对 native 的依赖,并且避免因 hybrid app 中 native 页面的层次结构(iOS 中 view controllers,Android 中 activities)在 React 侧不可知、不可控带来的状态管理问题。因此区别于在 React Native 在 Glow 的实践 一文中提到的通过 native module 来实现 hybrid app 里的页面跳转,在新项目中我们把页面跳转放在 React 里实现。这样一来,整个 App 的页面结构及状态都可以在 React 侧(比如用 Redux)进行管理。

在选型阶段,我们使用并比较了上述四个 library,但最终决定重新实现。本文会简单介绍这四个 library 的异同优缺,为何决定重新实现 navigation 系统,以及实现思路。通过本文你会了解到关于 React Native 的渲染、重渲染机制,动画实现,性能优化等知识点。

1. React Navigation vs. Native Navigation

React Native 里做页面导航有两种方式:在 React(JS)侧或是在 native 侧实现。

如果在 native 侧做,JS 侧通过调用 native module 来实现页面跳转。作为页面的 root component 的生命周期与 view controller 或 activity 一致,多个页面对应到多个 root view。适合于 hybrid app,尤其是 native 页面和 RN 页面会被交替 push 到一个页面栈的情况。**缺点是 React 对于 native 的页面栈的状态缺少感知和控制,从而缺少对整个 app 的状态的控制能力,对页面的控制粒度较粗,实现更多的自定义过渡动画需要更新 native 代码。**相对适用于现有 native app 引入 RN 来实现相对独立的页面或模块如活动页面,相对独立于其他模块的模块(如 Glow Apps 里的论坛模块)。

在 React(JS)侧做,则所有的跳转都在 JS 侧管理。优点是 JS 侧的状态变量中有完整的页面栈,可以实现粒度更细的页面管理,比如替换/删除栈中某个页面,重置整个 app 的页面栈,连续 push 多个页面等。也更便于在 React 侧实现页面跳转过渡动画(transition animation)。缺点是跟 native 页面做深度结合会变得困难一些,因为这种方案整个 app 在 native 侧只对应到一个 root view(也就是一个 view controller 或 activity),所以很难实现 native 和 RN 页面交替出现,与 native 页面的交互会变得比较原子,比如弹出一个图片选择器选择一个图片,弹出一个分享组件完成分享等。

上述四个 library 中,react-native-navigationnative-navigation 是 native navigation,react-navigationreact-router-navigation 则是 react navigation。

因为我们的新项目是纯 RN 实现,因此决定在 React 里实现 navigation。此外 React 里实现 navigation 并不会限制你通过 native module 来做 native navigation,所以在 hybrid app 里也可以考虑同时使用两种方式。

2. URL Routing vs. Name Based Routing

关于使用 URL 做路由的好处在之前的文章里也有提到,不再展开,我们选型的时候优先考虑基于 URL 做 routing。四个 lib 中,只有 react-router-navigation 是使用 URL 做 routing 的(基于 react-router 库),其他几个都是通过页面名字(screen name/id)来做 routing,尽管 react-navigation 也有 path 属性来做 deep link,但是对 URL 做页面跳转的支持并不是很好。

无论是基于 URL 还是页面名字做路由,有两种方式来配置 app 中的页面:中心化的配置或去中心化的注册机制。中心化配置的好处是一目了然,缺点是耦合度高,灵活性欠佳;去中心化注册的方式则更灵活,但是会给调试和代码理解、查找带来一定麻烦。react-navigationreact-router-navigation 是相对中心化的配置方式,react-native-navigationnative-navigation 则是基于注册方式的。推荐使用去中心化注册的方式,也便于分模块管理页面。

3. 横向比较

3.1 react-native-navigation

react-native-navigation 来自 Wix,Wix 是使用 RN 历史悠久的一个 app,该团队还有很多有意思的 RN 的开源项目,比如 react-native-calendarsreact-native-interactablereact-native-navigation 也是相对比较早的一个实现 native navigation 的 lib,提供了丰富的 API,除了常见的页面跳转,还有像 light box 这种弹窗效果。**但它的侵入性较强,对于纯 RN 的项目来说多了很多不必要的 native 逻辑。**它的下一个大版本(v2)正在开发当中,重写了很多代码,但是还没有稳定。另外因为是 native navigation 所以我们没有选用。

3.2 native-navigation

native-navigation 出自 Airbnb,刚开源的时候备受关注,也是我们之前在做 hybrid app 时考虑引入的。它的 API 和实现逻辑相较 react-native-navigation 要清晰很多,但是因为没有很积极的维护,且一直处于 beta 阶段,并没有真正发布过 1.0 版本,所以不适合用于生产环境

3.3 react-navigation

react-navigation 是目前官方比较推荐的在 React 侧做 navigation 的库,旨在取代原有的内置的 navigator。支持 Redux,支持 slide、modal 甚至自定义 transition,可以对 tab 和 navigation bar 做很多定制。这也是我们在选型阶段使用最深入的一个库,但是通过一段时间的试用,发现它对我们来说有以下缺点:

3.4 react-router-navigation

在尝试解决 react-navigation 上述问题的时候看到了 react-router-navigation,这个库更像是 react-routerreact-navigation 的粘合剂,通过结合使用这两个库,实现了用 URL 做 routing,然后利用 react-navigation 来实现动画,但是它在解决问题的同时引入了新的问题:

此外这个项目当时没有很积极的维护,所以也没有再使用这个库。

4. 需求

综上,我们决定自己造一个适合我们的轮子,简单整理我们的需求:

4.1 URL routing

定义页面的时候,希望能沿用我们以前在 hybrid 项目中定义 URL 到页面的 mapping 的方式,类似:

import { registerRouters } from 'Navigator';
const MyRouters = [
  {
    path: '/home',
    render: (url, params, initialProps) => {
      return <Home {...initialProps} />;
    },
  },
  {
    path: '/users/profile/:user_id',
    render: (url, params, initialProps) => {
      return <Profile userId={params.user_id} {...initialProps} />;
    },
  },
];
registerRouters(MyRouters);

这样便于模块化,页面跳转的时候传参也变得方便,比如 Navigator.push('/users/profile/1')

4.2 页面栈

以 iOS 为例,有三类常见的页面结构:navigation stack(push/pop)、modal stack(present/dismiss)和 tab bar controller。其中 tabs 比较特别,严格来说 tabs 不是一个 stack,而且我们在项目中大部分时候会在 push 新的页面到 navigation stack 的时候会隐藏 tab bar。所以我们决定 tab bar 作为根 component 内部的结构,而非另一种 stack(区别于 iOS 中 navigation controller 是 tab bar controller 的 child controller),所以 tab 的 active 状态也就不属于 routing 的一部分(区别于 react-navigation 的实现)。

为了支持 navigation/modal 两种 stack,我们把 app 的页面 stack 抽象成一个二维数组,第一级是 modal stack,第二级则是 navigation stack。比如从 home push 了一个 topic,然后点开(present)一个用户 profile 页面后,app 的 stack 简化后形如:

[
  ['/home', '/topics/1'],
  ['/users/profile/1'],
]

这样的数据结构的另一个好处就是通过比较 stack 的前后状态可以决定 transition 的类型:两个维度的变化分别对应着 present/dismiss 和 push/pop。

实际实现时,栈中的页面由一个 Object 而不只是 string 表示,以包含更多上下文信息如 unique key 和参数等,数据结构类似:

{
  url: '/home',
  initialProps: {},
  key: '5E8222FE-C71C-47EA-9E03-944B8A3D3137',
}

4.3 操作

针对上述页面栈,我们需要以下操作:

  • push:往最后一个 navigation stack 中 push 一个新的页面
  • pop:从最后一个 navigation stack 中 pop 最后一个页面(如果页面数量大于 1)
  • present:以新页面为 root,往 modal stack 中 push 一个新的 navigation stack
  • dismiss:从 modal stack 中 pop 最后一个 navigation stack(如果 stack 数量大于 1)
  • back:行为类似 pop,但是当该页面是 navigation stack 中最后一个页面时,dismiss 该 stack
  • reset:用来 reset 整个 app 的 stack,比如登录或登出之后
  • 其他:还有一些比如 removereplacepopToRoot 之类的操作,因为没有用到所以没有实现,但是实现方式和其他操作无异

上一小节的数据结构中看到的 key 是在 push 或 present 的时候生成的,具体用途见实现章节。

4.4 过渡动画

我们实现的过渡动画是最常见的效果,push/pop 时从右侧滑入/滑出,present/dismiss 的时候新页面从底部滑入,旧页面 z 轴方向后退。

5. 实现

5.1 React Native 的渲染/重渲染机制

5.1.1 什么是 Component 和 JSX

开发过程中有很多工程师对 Component 和 UI 的关系有误解,所以我觉得首先要理解 React Native 中的 Component 是什么。虽然很多 Component 是 UI Component,比如 View、Text、Image 都对应到一个 native view,但这些 Component 本身不是一个 UI 元素,而且非 UI Component 甚至没有对应的 native view。Component 只是用于描述一个 React app 或其局部的属性和状态的基本数据结构。UI Component 描述了一个 native view 的属性和状态,但它本身并不是 UI 的一部分。

什么是 JSX,JSX 只是一个语法糖,它用类似 XML 的语法来快速构建 Component:

<Text style={{color: 'blue'}} numberOfLines={2}>
  Hello World!
</Text>

会被 packager 翻译成:

React.createElement(  
  Text, // type
  {style: {color: 'blue'}, numberOfLines: 2}, // props
  'Hello World!' // ...children
)

所以理论上你可以直接写上述 JS 代码来构建 Component 树,但是当嵌套层数变多的时候代码就会变得难以维护。

详见官方文档 JSX In Depth

递归后,JSX 就被翻译成一个完整的 Component 树,它就对应着整个 app 的属性和状态。但到这里为止,都是属于 React 而非 React Native 的部分,React Native 负责的是把这个 Component 树传回到 native,遍历并生成或更新每个 Component 对应的 native component(大部分情况下是一个 native view)。**所以 React Native 可以被看作是 React 的一个渲染引擎,外加一系列的 native API。**React Native 中,JS 代码(包括 Component 的渲染)运行在非主线程,每个 native module 有一个自己的线程(必要时候可以在主线程运行),只有 UI 的组装和绘制发生在主线程。

因此,Component 的 render 对应的是 React 中渲染这颗树(数据结构),而非 UI 的渲染,**所以 Component 的重建并不等价于 UI 的重绘。**此外,React Native 也会有一些优化效率的逻辑,比如 removeClippedSubviews 属性可以移除屏幕外的子 view,所以 UI Component 的数量也不等价于需要绘制的 view 的数量。

5.1.2 Component 的 Props 和 State,以及 React 中的信息流

这些内容在 之前的文章 已经有所提及,概括来说:state 是节点内部的数据状态,是可变的;props 则是数据从父节点流向子节点的媒介,父节点通过设置子节点的 props 把自己的状态变成子节点的属性;props 对于子节点来说是只读的,如果子节点需要改变父节点的数据则应该通过 callback(或 dispatch action,如果是 Redux 或者 Flux 的话)来实现。

5.1.3 Component 的生命周期,重渲染和重用

关于 Component 的生命周期,详见 React 官方文档

这里想展开说一下 Component 的重渲染和重用。

首先,首次渲染或调用 forceUpdate() 始终会触发 render() 方法的调用。

其次,当 Component 的 propsstate 有改变的时候,React 会调用 shouldComponentUpdate(nextProps, nextState) 方法询问是否需要重新渲染该 Component,如果该方法返回 false,则不会触发 render() 方法,也不再遍历其子节点;如果该方法返回 true,则会调用 render() 方法重新渲染,如果得到的 Component 与之前的不同,则遍历其所有子节点重复相同的过程。

默认情况下,Component 的 shouldComponentUpdate 始终返回 true,即 propsstate 的更新始终会触发重渲染,不管它们的值是否真的发生变化。一个简单的优化是使用 PureComponent 来避免多余的重绘,PureComponentshouldComponentUpdate 会对 propsstate 做一次浅比较,在 propsstate 被更新但各键值不变的情况下不触发重渲染。

注 1:PureComponentimmutable-js 很配,这里不再展开,以后再写。

注 2:PureComponent 会导致 context 的变化无法触发重渲染,如果你没有用到 context 这种奇技淫巧的话就不用太担心,否则需要配合 forceUpdate 之类的来触发重渲染(参见 Redux 用 context 传递 store)。

了解了 Component 的重渲染机制以后,你需要再了解一下重用的机制,Component 的重用发生在重渲染之后。所谓重用,就是在重渲染前后两棵树之间,React 如何做 diff,如何决定同一类型的 Component 在渲染前后的对应关系。

这一过程在 React 里被称为 Reconciliation,官网这篇文章具体介绍了该 diff 算法的逻辑,这里不赘述。想特别提的是,在 React 中,数组子节点在重用时的对应关系默认取决于它的顺序,但是如果你提供了 key,就可以实现基于 key 的重用。

举例来说:

<View>
  <Text>A</Text>
  <Text>B</Text>
  <Text>C</Text>
</View>

<View>
  <Text>B</Text>
  <Text>C</Text>
  <Text>D</Text>
</View>

React 会认为这里有三个 Text 节点发生了更新,即 A => B,B => C,C => D,这样会带来两个问题,一个是多余的重渲染导致的效率问题,二是如果节点有其他内部状态(比如高亮,选中,禁用之类),重新渲染后,UI 的状态就可能乱套了。

但如果你提供了 key

<View>
  <Text key='a'>A</Text>
  <Text key='b'>B</Text>
  <Text key='c'>C</Text>
</View>

<View>
  <Text key='b'>B</Text>
  <Text key='c'>C</Text>
  <Text key='d'>D</Text>
</View>

React 就可以明确的知道 B 和 C 只是发生了位移,A 被移除了,D 被添加了,而且这里的 B 和 C 不会发生重渲染。明白这一点对于渲染我们的 navigation stack 会很有帮助。

5.2 渲染页面

先不关心动画部分,简化后,我们类似这样渲染整个 stack:

render() {
  const { stack } = this.state;
  let screens = [];
  for (let x = 0; x < stack.length; x++) {
    const nav = stack[x];
    for (let y = 0; y < nav.length; y++) {
      const item = nav[y];
      screens.push(
        <View style={styles.screen} key={item.key}>
          <SceneView url={item.url} initialProps={item.initialProps} />
        </View>
      );
    }
  }
  return (
    <View style={styles.container}>
      {screens}
    </View>
  );
}

这里的 key 是之前提到在 push/present 时生成的,对应到一个页面,这样一来,即便 stack 的结构发生变化,同一个页面始终对应到一个 Component。

这里引入了一个 SceneView 的概念,它的实现很简单但是却起着至关重要的作用。首先,它负责从 routing map 里匹配 URL 对应的 render 方法,并渲染;其次,它是一个 PureComponent,因为 stack 里 item 的 url 和 initialProps 一般情况下是不再发生变化的,所以 SceneView 就像一堵墙,隔绝了外界的状态变化无意触发页面的重渲染,只有页面内部才会触发自己的重渲染。

5.3 关于纯函数和状态管理

5.3.1 纯函数(Pure Function)

这其实是一个题外话但是还是值得一提。什么是纯函数?

在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
  • 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。

Wikipedia

例如 add = (a, b) => (a + b) 就是一个纯函数,因为它的输出只取决于输入;而 add2 = (b) => (a + b) 就不是,因为它的输出还取决于全局变量 a 的值。

为什么鼓励尽可能使用纯函数?因为它让程序的状态变化可预测,可管理,可回放。让代码的可读性、可维护性和单元测试的可实现性都大大提高。关于纯函数的更多好处请自行搜索,这里不展开。

5.3.2 状态管理

Redux 推荐使用纯函数来做 reducer 可能很多人都知道,但其实 React 本身也支持提供类似 reducer 的方式来更新 state,这种方式因为相对麻烦较少被用到。

比如一个计时器,常见的实现可能是:

tick1() {
  this.setState({
    count: this.state.count + 1,
  });
}

其实更好的实现是:

tick2() {
  this.setState((prevState, props) => ({
    ...prevState,
    count: prevState.count + 1,
  }));
}

一方面,这会降低你以后将 state 从 Component 迁往 Redux 的成本;另一方面,React 的 setState 是异步执行的,调用 setState 后,this.state 本身不会被实时的更新,因此在一个 JS frame 里多次调用 tick1 可能会导致结果不正确,而 tick2 即便被多次调用也不会有问题,因为它的输出不依赖 this.state

注:这里 tick2 自身不是纯函数,因为调用 setState 即副作用,但是传给 setState 的 reducer 是一个纯函数。

5.3.3 实现我们的 reducer

我们实际实现是基于 Redux 的,因此有 action 和 reducer 的概念,这里为了演示简洁,简化成几个单独的函数:

function push(prevState, item) {
  let nav;
  if (prevState.length == 0) {
    nav = [item];
  } else {
    nav = [...prevState[prevState.length - 1], item];
  }
  return [
    ...prevState.slice(0, prevState.length - 1),
    nav,
  ];
}

function pop(prevState) {
  if (prevState.length == 0) {
    return prevState;
  }
  let nav = prevState[prevState.length - 1];
  if (nav.length <= 1) {
    return prevState;
  }
  return [
    ...prevState.slice(0, prevState.length - 1),
    nav.slice(0, nav.length - 1),
  ];
}

function present(prevState, item) {
  return [
    ...prevState,
    [item],
  ];
}

function dismiss(prevState) {
  if (prevState.length == 0) {
    return prevState;
  }
  return prevState.slice(0, prevState.length - 1);
}

至此,结合这些 reducer 和上一章节的 render 方法,你已经得到了一个基于 URL routing 的页面跳转系统,唯独缺少的是动画效果了,这让页面看起来像在网页上做跳转,下一章节我们会给它加上一些简单的动画效果。

5.4 React Native 里的动画

React Native 里主要通过 Animated API 实现动画。不同于 propsstate,Animated API 是脱离于 Component 生命周期的,它通过 setNativeProps 直接更新 native view 的属性。

在 React Native 里做动画有一个原则跟 native 动画一样:尽量使用 transform 实现动画,而不是直接更新布局(如 width、height、padding、margin 等),以提高动画效率。

其实一个页面的 layout,只取决于它在 stack 里的位置,以及 stack 当前位置,所以我们用两个 Animated.Value 来表示当前位置信息:stackIndexnavIndex,而页面则根据自己的 indexinterpolate

对于 stack,我们有如下动画:

let stackScale = this.state.stackAnimation.interpolate({
  inputRange: [x - 1, x, x + 1],
  outputRange: [1, 1, 0.9],
  extrapolateLeft: 'clamp',
  extrapolateRight: 'extend',
});
let stackTranslateY = this.state.stackAnimation.interpolate({
  inputRange: [x - 1, x, x + 1],
  outputRange: [SCREEN_HEIGHT, 0, 0],
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});
let stackTransform = [{scaleX: stackScale}, {scaleY: stackScale}, {translateY: stackTranslateY}];

这里的 x 是当前 navigation stack 在整个 stack 里的 index,所以这里动画的意思是,新的 navigation stack 从屏幕下方滑入,旧的 navigation stack 没有位移,但是整体缩小到 90% 大小,这样就会有后退的效果。

此外,每个 navigation stack 会有如下一个半透明黑的背景淡入,遮挡住其他 stack。

let stackBgOpacity = this.state.stackAnimation.interpolate({
  inputRange: [x - 1, x, x + 1],
  outputRange: [0, 1, 0],
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});

对于顶部 navigation stack 里的每个页面,我们又有如下动画:

let translateX = this.state.navAnimation.interpolate({
  inputRange: [y - 1, y, y + 1],
  outputRange: [SCREEN_WIDTH, 0, SCREEN_WIDTH * -0.3],
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});
let opacity = this.state.navAnimation.interpolate({
  inputRange: [y - 2, y - 1, y, y + 1],
  outputRange: [0, 1, 1, 0],
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});
style = {
  opacity,
  transform: [{translateX}, ...stackTransform],
};

意思是新的页面从屏幕右侧滑入,旧的页面从屏幕左侧部分滑出(30% 屏幕宽),再加上一个透明度的变化,基本上与 iOS 系统效果类似。

这些动画均由 stackAnimationnavAnimation 驱动,那什么时候触发这两个 animation 呢?NavigatorcomponentWillReceiveProps 里会根据前后 stack 的区别决定如何驱动这两个动画。这里有一个需要注意的是,当 pop 或 dismiss 的时候,因为旧的页面不在新的 stack 里,直接用新的 stack 渲染页面会导致旧页面直接消失,没有过渡动画。这里的解决办法是对于这样的过渡,在 state 里临时存了一个 stackForAnimation,用于渲染过渡动画,动画完成后再置空。具体逻辑参见 Expo 上的 Demo。

6 总结

至此,我们完成了整个 Navigation 库,但这只是一个简化后的 Demo,实际我们还解决了以下问题:

  • 手势后退
  • 防止 JS frame 被 block 时连续点击导致多次 push
  • NavigationBar & TabBar
  • 判断当前页面是否顶部页面
  • 等等

因为不影响整体思路的呈现,在此不一一展开。

我把 Demo 放在了 Expo Snack 上,有需要的自取,我们也考虑在不远的将来整理并开源这套方案。