AngularJS のチュートリアルを React にリプレイスしてみた③
それではいよいよ、AnguarJS のルーティングモジュールである `ngRoute` を置き換えましょう。この置き換えが完了したら AngularJS を完全に取り除くことができます。
ルーティングを置き換える
それではいよいよ、AnguarJS のルーティングモジュールである ngRoute
を置き換えましょう。この置き換えが完了したら AngularJS を完全に取り除くことができます。
React のルーティングライブラリである React Router をインストールします。現在の React Router の最新バージョンは v6 なのですが、hashbang(#!
)形式のルーティングを使用したいため、v5 をインストールします。
npm install react-router-dom@5
npm install --save-dev @types/react-router-dom@5
ルーティングの設定
app/App.tsx
ファイルを作成し、ルーティングの設定を行います。
import React from 'react';
import { Redirect, Route, Switch, useLocation } from 'react-router-dom';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import PhoneList from './phone-list/PhoneList';
import PhoneDetail from './phone-detail/PhoneDetail';
const App: React.FC = () => {
const location = useLocation();
return (
<TransitionGroup>
<CSSTransition
key={location.pathname}
timeout={1000}
classNames={{
appear: 'ng-appear',
appearActive: 'ng-appear-active',
appearDone: 'ng-appear-done',
enter: 'ng-enter',
enterActive: 'ng-enter-active',
enterDone: 'ng-enter-done',
exit: 'ng-leave',
exitActive: 'ng-leave-active',
exitDone: 'ng-leave-done'
}}>
<div className="view-frame">
<Switch>
<Route path="/phones" component={PhoneList} exact />
<Route path="/phones/:phoneId" component={PhoneDetail} exact />
<Redirect to="/phones" />
</Switch>
</div>
</CSSTransition>
</TransitionGroup>
);
};
export default App;
<TransitionGroup>
と <CSSTransition>
は PhoneItem.tsx
コンポーネントにおいても t ランジションアニメーションを実現するために使用しました。<TransitionGroup>
は <Transition>
や <CSSTransition>
のようなトランジショングループのリストを管理し、複数の要素の追加や削除によりトランジションが発生するようにします。<CSSTransiton>
コンポーネントは ng-animate と同様にクラスを付与することで CSS トランジションでアニメーションを制御します。これにより AnguarJS のルーティングが変化したときのアニメーションを再現しています。
<TransitionGroup>
の子要素を識別するための key
には location.pathname
で現在ページ URL のパス名を使用しています。location
は React Router の useLocation
フックから取得しています。
実際のルーティングの定義は <Switch>
コンポーネント配下で行っています。<Switch>
コンポーネントは子要素のルートの中の 1 つのみにマッチするように制御しています。
<Route>
コンポーネントは path
で指定したパスにマッチしたときに、component
で指定したコンポーネントを表示します。通常 <Route>
コンポーネントはパスに部分一致した場合にも表示されますが、exact
を指定した場合パスに完全一致した場合のみ表示されるようになります。
最後に <Redirect>
コンポーネントを配置することで、どのパスにも一致しなかった場合 /phones
にリダイレクトするようにしています。
作成したコンポーネントは app/index.tsx
ファイルで使用します。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import 'bootstrap/dist/css/bootstrap.css';
import './app.css';
import './app.animations.css';
import { HashRouter } from 'react-router-dom';
ReactDOM.render(
<React.StrictMode>
<HashRouter hashType="hashbang">
<App />
</HashRouter>
</React.StrictMode>,
document.getElementById('root')
);
app/index.tsx
は新たにエントリーポイントとなるファイルです。ReactDOM.render()
メソッドで React を指定された DOM 要素にマウントしています。さきほどルーティングを設定した <App>
コンポーネントを <HashRouter>
でラップしています。<HashRouter>
は URL Hash を利用したルーティングを提供します。hashType
に "hashbang"
を指定することで以前と変わらない形式のルーティング http://localhost:80000/#!
を使用できます。
続いて、app/index.html
を修正します。
<!doctype html>
- <html lang="en" ng-app="phonecatApp">
+ <html lang="en">
<head>
<meta charset="utf-8">
<title>Google Phone Gallery</title>
</head>
<body>
<div id="root" class="view-container">
- <div ng-view class="view-frame"></div>
</div>
</body>
</html>
app/index.tsx
でマウント対象の DOM を document.getElementById('root')
で取得しているので id="root"
属性を付与した要素を用意します。また ng-app
と ng-view
は AngularJS のための記述なのでこちらも削除します。
エントリーポイントを変更したので、webpack.config.js
の entry
を修正します。
module.exports = {
mode: 'development',
devtool: 'source-map',
- entry: './app/main.ts',
+ entry: './app/index.tsx',
PhoneList コンポーネントの修正
ルーティングの変更と、ルート要素に AngularJS ではなく React を使うように変更したのでコンポーネントの修正も必要です。まず PhoneList
コンポーネントでは react2angular
で AngularJS コンポーネントに変換していた箇所を削除します。
- import angular from 'angular';
import React, { useState } from 'react';
- import { react2angular } from 'react2angular';
- angular.module('phoneList').component('phoneList', react2angular(PhoneList, []));
テストコードの import も削除しましょう。
- import 'angular';
- import '../phone-list/phone-list.module';
PhoneItems
コンポーネントでは <a>
タグを使用していた箇所を React Router の <Link>
コンポーネントに置き換えます。<Link>
コンポーネントは href
の代わりに to
を使用し、パスにハッシュ(!#
)を含めないようにします。
+ import { Link } from 'react-router-dom';
<li className="thumbnail phone-list-item">
- <a href={`#!/phones/${phone.id}`} className="thumb">
+ <Link href={`/phones/${phone.id}`} className="thumb">
<img src={phone.imageUrl} alt={phone.name} />
- </a>
+ </Link>
- <a href={`#!/phones/${phone.id}`}>{phone.name}</a>
+ <Link to={`/phones/${phone.id}`}>{phone.name}</Link>
<p>{phone.snippet}</p>
</li>
<Link>
コンポーネントは <Router>
コンポーネントの配下でしか使用できないので、PhoneItems
コンポーネントのテストが失敗するようになってしまいます。
● PhoneItems › should render phone items
Invariant failed: You should not use <Link> outside a <Router>
6 | describe('PhoneItems', () => {
7 | it('should render phone items', () => {
> 8 | render(<PhoneItems phones={phones} orderProp="age" />);
| ^
9 |
10 | const phoneList = screen.getAllByRole('listitem');
11 |
これを修正するために、test-utils.tsx
ファイルを修正します。customRender
に使用する Wrapper
に <MemoryRouter>
を追加します。またテストごとに異なるルーティングの設定を行えるように customRender
で routeOptions
を受け取るように修正します。
import { MemoryRouter, MemoryRouterProps, Route } from 'react-router-dom';
type RouterOptions = {
initialEntries?: MemoryRouterProps['initialEntries'];
path?: string;
};
const Wrapper = ({ initialEntries, path = '/' }: RouterOptions = {}) =>
function Wrapper({ children }: { children: ReactNode }) {
return (
<MemoryRouter initialEntries={initialEntries}>
<SWRConfig value={{ dedupingInterval: 0 }}>
<Route path={path}>{children}</Route>
</SWRConfig>
</MemoryRouter>
);
};
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'> & { routeOptions?: RouterOptions },
routeOptions?: RouterOptions
) => render(ui, { wrapper: Wrapper(routeOptions), ...options });
app/phone-list/PhoneItmes.spec.tsx
においても @testing-library/react
の代わりに test-utils
から import するように修正します。
- import { render, screen } from '@testing-library>raect':
+ import { render, screen } from '../test-utils';
これで PhoneItems コンポーネントについてはテストが PASS するようになったはずです。
PhoneDetail コンポーネントの修正
まずは PhoneList
コンポーネントと同様に react2angular
で AngularJS コンポーネントに変換していた箇所を削除します。
- import angular from 'angular';
import React, { useEffect, useState } from 'react';
- import { react2angular } from 'react2angular';
- angular
- .module('phoneDetail')
- .component('phoneDetail', react2angular(PhoneDetail, [], ['$routeParams']));
PhoneDetail
コンポーネントではパスパラメータを受け取るために ngRoute
の $routeParams
を注入していました。ngRoute
は使用しないように変更したので Props で $routeParams
を受け取らないように修正します。React Router において同様にパスパラメータを受け取るためには useParams フックを使用します。useParams
はオブジェクトの形式で現在の URL のパスパラメータを返します。
- type Props = {
- $routeParams: ng.route.IRouteParamsService;
- };
- const PhoneDetail: React.FC<Props> = ({ $routeParams }) => {
+ const PhoneDetail: React.FC<Props> = () => {
+ const { phoneId } = useParams<{ phoneId: string }>();
const [mainImageUrl, setMainImageUrl] = useState('');
const { phone } = usePhone({
- phoneId: $routeParams.phoneId
+ phoneId
});
PhoneDetail
コンポーネントの修正箇所は以上です。続いてテストコードも修正しましょう。$routeParams
をモックする必要はなくなったので、関連するコードをすべて削除します。
- import angular from 'angular';
- import 'angular-route';
- import 'angular-mocks';
- import './phone-detail.module';
import PhoneDetail from './PhoneDetail';
describe('PhoneList', () => {
- let $routeParams: ng.route.IRouteParamsService;
- beforeEach(() => {
- angular.mock.module('phoneDetail');
- angular.mock.inject((_$routeParams_) => {
- $routeParams = _$routeParams_;
- $routeParams.phoneId = 'nexus-s';
- });
- });
各テストの render
関数では Props で渡していた $routeParams
を削除します。さらに、PhoneItems
コンポーネントのテストの修正時に現在のルーティングの設定を渡せるようにしておいたのでこれを利用します。path
はコンポーネントが描画されるべきルートのパスを指定します。initialEntries
には history のスタックが渡せるので、現在のパスとして 'phones/nexus-s
を渡しています。
- render(<PhoneDetail $routeParams={$routeParams} />);
+ render(<PhoneDetail />, undefined, {
+ path: '/phones/:phoneId',
+ initialEntries: ['/phones/nexus-s']
+ });
これにより、現在 /phones/:phoneId
のルートが描画されており、/phones/neuxs-s
のパスに存在することを表現できるので、useParams
フックが { phoneId: 'nexus-s
}` を返すように固定できます。
すべてのテストケースの修正が完了したら、テストを実行して確認してみましょう。
npm run test PhoneDetail
動作の確認
ここまでの修正が完了したら準備 OK です。実際に動作を確認してみましょう。Wepack の設定ファイルを更新したので、すでに npm run dev
コマンドを実行済の場合には一度停止してから再度コマンドを実行して開発サーバーを立ち上げましょう。
npm run dev
もう AngularJS に依存するコードは存在しないですが、以前と変わりなく動作するはずです。ここで真っ白な画面が表示される場合、react2angular
のコードどこかに残っている可能性があるので、すべて取り除いてください。
E2E テストも実行してテストが PASS することを確認しましょう。
npm run e2e
テストも PASS することを確認できるかと思います!最後に不要になったパッケージ・ファイルもすべて削除してしまいましょう。
npm uninstall angular angular-animate jquery react2angular @types/angular-mocks @types/angular-route @types/jquery angular-mocks
以下のファイルもすべて削除します。
app/main.ts
app/app.animations.js
app/app.config.js
app/phone-list/phone-list.module.js
app/phone-detail/phone-detail.module.js
パッケージ・ファイルの削除跡には念のためテストを実行しておきましょう。
npm run test
npm run e2e
このテストが PASS すればリプレイス作業はすべて完了です!お疲れさまでした!
ここまでのコミットログは以下のとおりです。