我們在新起的專案中決定用純 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 來實現動畫,但是它在解決問題的同時引入了新的問題:

  • 個人覺得 react-routerrouter 定義方式(用 Component 來定義路由規則)好用但會帶來很多限制;
  • react-routerreact-navigationreact-router-navigation 三個庫的耦合會導致依賴過多,容易引入 bug,而且不便於修復;

此外這個專案當時沒有很積極的維護,所以也沒有再使用這個庫。

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 就被翻譯成一個完整的 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 上,有需要的自取,我們也考慮在不遠的將來整理並開源這套方案。