javascripterです。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。今回の記事では、AutoReserveでおこなっているコード共通化の取り組みについて紹介します。
背景
AutoReserveのネイティブアプリはReact Nativeで書かれており、またウェブ版は、Reactで書かれています。
ウェブ版では、React Native for Webという、React上でReact NativeのコンポネントのAPIを使えるようにするライブラリを使用しています。
React Native for Webを採用したことで、ハローでは現在、エンジニア1人でiOS、Android、ウェブの全てのプラットフォームに同時展開できるようになりました。 また、不具合修正やデザインの修正も、一箇所を修正するだけで同時にできるようになりました。それぞれのプラットフォームでの実装・デザインの乖離が起こりづらいこともメンテナンス性の向上に繋がっています。
今回の記事では、AutoReserveでどのようにアプリとウェブのコードの共通化をおこなっているか説明します。
何を共通化しているのか
以前の記事(なぜNext.jsをやめたのか?)でも軽く紹介していますが、アプリの機能実装を含む、下記のコードを共通化しています。
- ボタン、チェックボックスなどのUIコンポネント
- アプリケーション画面のコード
- レストランの画面
- 予約の表示画面
- レストランの予約フォーム画面
- eslint、prettier、TypeScriptの設定ファイル
- TypeScriptの型
- Protocol Buffersで自動生成したAPIの型
- 定数
- 色などのテーマの定義 - レスポンシブデザインのbreakpointの幅定義
- 税率など定数
- モジュール
- usePrevious / useStable / useMediaQueryなどよく使うhooks
- アプリ・ウェブで共通して使えるalert関数の実装
これらのコードを、@hello-ai/ar_shared
という名前でパッケージ化して社内で利用しています。
現在、フロントエンドはmonorepo化を進めており、一部アプリを除き、共通ライブラリの変更とアプリケーションコードの変更をまとめて行えるようになっています。
まだmonorepo化できていないリポジトリに関しては、
- 共通コードに変更があった場合、GitHub Packagesにnpmパッケージを自動でpublishする
- 各リポジトリの
@hello-ai/ar_shared
の依存関係のバージョンを自動であげる
というGitHub Actionsのアクションを書き、対処しています。パッケージをまたぐ変更が行いやすくなるよう、いずれ全てmonorepoに集約される予定です。
再利用可能なUIライブラリの共通化
まず、再利用可能なUIコンポネント集・hooksなどのモジュールを用意しています。
- フォーム関連のコンポネント
- グリッドレイアウトのコンポネント
- チェックボックス
- テキスト入力のコンポネント
などです。UIライブラリはドキュメントとしてstorybookを用意しています。
UIコンポネント集を作成する取り組みはよく見かけますが、AutoReserveではさらに一歩進んで、アプリケーションコード自体も共通化しています。
アプリケーションコードの共通化
アプリケーションコードを共通化するには、アプリとウェブのスクロールの仕組みの違いや、ナビゲーションの構造の違いなどを吸収する必要があり、工夫が必要です。この章では、 アプリケーション画面のコードを共通化する方法を紹介します。
先行の取り組みとして、solitoというNext.jsとReact Nativeでの実装の共通化を行うライブラリがあり、一部参考にしていますが、AutoReserveではReact Routerを使っているため、内部で新規ライブラリを実装しています。
共通化しているもの
現在、アプリ、ウェブの両方で共通化しているアプリケーションコードは下記です。
- アプリケーションの機能画面全体
- リンク、画面遷移のコード
ナビゲーションの共通化の仕組み
アプリ、ウェブで使用しているライブラリはそれぞれ異なり
- アプリ側はReact Nativeのreact-navigation
- ウェブ側はReactのReact Router
を使用しています。
アプリとウェブの主な構造の違いとして、通常、アプリはhooksを使い現在のスタックに対して画面をpushするという操作を行うのに対して、ウェブはURLによるリンクで遷移するということが挙げられます。
アプリ:
import { useNavigation } from '@react-navigation/native' function Component() { const navigation = useNavigation() const onPress = () => { navigation.navigate('Restaurant', { restaurantId: restaurantId }) } return <Button onPress={onPress}>レストラン</Button> }
ウェブ:
import { Link } from 'react-router-dom' function Component() { return <Link href={'/restaurants/xxxx'}>レストラン</Link> }
ウェブでは、SEOやアクセシビリティの観点から、onClickによる遷移ではなく、必ず<a>
要素としてレンダリングされるようにしたい、という要件があります。
これらの遷移の仕組みの違いを吸収するため、AutoReserveでは下記のような仕組みを採用しています。
- ウェブでは、通常のリンクとなるようurlを指定して遷移
- アプリではhooksを使用するが、遷移先の指定はdeep linkで行う
共通UIライブラリ側:
import { Platform, Linking, Text } from 'react-native' const prefix = 'autoreserve://autoreserve' export function useLinkTo() { // アプリでdeep linkによる遷移を行うhooks if (Platform.OS === 'web') { return () => { throw new Error('useLinkTo is not implemented for web. You should only use it in native.') } } else { return (to) => Linking.openURL(prefix + to) } } // ウェブではtoでリンク先を指定、アプリではonPressが作動するコンポネント export function LinkText({ to, onPress, ...rest }) { return <Text href={to} // webではhrefを指定しリンク化 onPress={ Platform.select({ web: () => React_Router_navigate(to), // React Routerによる遷移 default: onPress // アプリでは通常のonPressを指定 }) } /> }
アプリケーションコードでの使用例
function Component() { // 使用例: const linkTo = useLinkTo() return <LinkText to="/restaurants/slug" // toを指定するとウェブでのみリンクになる(アプリでは無視) onPress={() => { // onPressはアプリでのみ実行される linkTo('/restaurants/1234') }}> レストランへのリンク </LinkText> }
まとめると、ウェブではリンク、アプリではonPressのイベントハンドラーを渡し、deep linkによる遷移をおこなっているという形になります。
アプリ側ではdeep linkのpathを渡すのではなくonPressを渡すよう実装にした理由は、アプリとウェブでURLのpathnameの構造が一致しておらず、またアプリでは現状deep linkで表現できない遷移がまだあり、JSコードを間に挟めるようescape hatchを用意したかったためです。
スクロール周りの挙動の違いの吸収
また、アプリとウェブで挙動が大きく異なる部分として、スクロール周りがあります。
- React Nativeのアプリでは
<ScrollView>
というコンポネントを使用すると任意の箇所を スクロール可能にできる - ウェブの場合、ページのスクロールは通常
<body>
要素によって行われる
例えば、ウェブでbodyの内側のdiv
にoverflow-y: auto
を当てメインのスクロール要素にすると、モバイルでURLバーが自動で隠れなくなるなどUX上の問題があるため、基本的には必ずbodyでスクロールを発生させる必要があります。
これらの問題に対処するため、アプリケーションコードの共通化を行う際は、スクロール要素そのものはコンポネントに含めず、内部のコンテンツのみをコンポネント化しています。
共通化部分:
function RestaurantContent(props: RestaurantContentProps) { return <View> { /* レストランページの中身 */ } </View> }
アプリ:
import { RestaurantContent } from '@hello-ai/ar_shared/...' function RestaurantScreen() { return <View style={{ flex:1 }}> <Header /> // ヘッダは上部固定 <ScrollView style={{ flex: 1 }}> <RestaurantContent .../> // レストラン画面の中身だけをスクロール可能に </ScrollView> </View> }
ウェブ:
import { RestaurantContent } from '@hello-ai/ar_shared/...' function RestaurantPage() { return <body> <Header style={{ // body全体をスクロール可能にしているためposition: fixedでレイアウト position: 'fixed', top: 0, left:0, right: 0 }}/> <View style={{ marginTop: headerHeight }}> // レストラン画面の中身はbody内にそのまま広げ // bodyでスクロール可能に <RestaurantContent .../> </View> </body> }
ページ/画面のコンポネント自体はアプリ/ウェブで別に実装し、そこからの共通コンポネント実装を呼び出すようにしています。この方法には
- ウェブ/アプリで最適なUI構造となるよう構造を変えられる
- アプリ/ウェブ側から共通ライブラリ側にpropsを渡せるので、プラットフォームの差異がある場合も共通ライブラリ内で分岐せずに済む
といったメリットがあります。
今後の課題
コード共通化の仕組みは途中で導入したため、まだ共通化ができていない箇所がいくつかあります。現時点での課題として
- ウェブとアプリで、URLパスとdeep linkの構造が一致していないため、リンク先を複数記述する必要がある
- データ取得のコードをアプリ・ウェブで共通化できていない
があります。
まとめ
React Native for Webを使用し、少人数でiOS, Android, ウェブの全プラットフォームに同時展開できるコード共通化基盤を構築しているという事例を紹介しました。
ハローでは、生産性を追求し、積極的に技術的な挑戦に取り組める本物のエンジニアを募集しています。
少しでも気になる方は気軽に javascripter にDMでお声がけください!