- Published on
Amon2 + JQuery のプロジェクトを React にする/ 〜 そして Next.js へ
- Authors
- Name
- nagamejun
- @nagamejun
レガシーフロントエンドに立ち向かう
- Amon2 + JQuery のプロジェクトを Amon2 + React にする
- Amon2 + React のプロジェクトを Next.js にする
Amon2 + JQuery のプロジェクトを Amon2 + React にする
10年前に作られた業務用管理画面のUIを刷新して1年以上経ったのでまとめたいと思います。
試行錯誤しながらほぼ1人で設計したので、もし誤りやアドバイスあればコメントいただけると嬉しいです。
レガシーフロントエンドの課題
- よく言われるDomが状態を持っている
- Ajaxで取得したJSONを加工して直接ページを書き換えている
- グローバル関数が色んな所で実行されている
- テストがない
- 上記の理由で副作用、依存関係がはっきりしてないので不要だと思われるコードを気軽に消せない
- ECMAScript5で書かれているので共通処理はグローバル関数orコピペのコードが複数存在する
それぞれの説明は割愛するが、長年の仕様変更や追加機能を実装した結果、
メンテナンス性の低いコードが積り重なっている。
なぜやるのか
- 開発速度を上げたい
- メンテナンスコストを下げたい
- モダンな環境を整えてエンジニアのモチベーションを上げる
- フロントエンドエンジニア採用において perl 経験者は少ない
- テストコードを書いてバグを減らしたい
前提
現状の技術要素は下記の通りです
- jQuery
- JavaScript(ECMAScript5)
- テストコードなし
- モジュール管理なし
何からはじめる?
React, TypeScript の導入にしてもテストを書くにしてもまずはモジュール管理が必要になります。
手動で管理していた OSS のライブラリを npm 管理するのが定石です。
しかし、今回は既存の管理画面と共存する(リプレイスは1画面づつ行う)方法を取るので、
手動で管理していた OSS のライブラリは一旦そのままにします。
パッケージマネージャー
npm 管理と前項で言いましたが、 yarn を使うことにしました。
TypeScript をはじめる
次に着手したのは TypeScript の導入です。既存の管理画面の機能を変更することなく
Webpack + Babel で TypeScript をトランスパイルできることを目標にしました。
また、動作保証の為に Cypress で E2E テストをしました。
ただ、React に置き換える際に削除することになるので、ここでの E2E テストは書かなくても良いかもしれません。
リプレイスは1画面づつ行う為、トランスパイルしたファイルは1つのバンドルファイルではなく
複数のエントリーポイントを設定する必要があります。下記のようにすれば複数ファイルが生成されるはずです。
後述しますが生成されたファイルを html 側で読み込みます。
webpack.config.js
const glob = require('glob');const entries = {};const path = require('path');glob.sync('./foo/{bar,baz}/ts/**/*.ts', {}).forEach(function(file) { entries[file.replace(/\.\/foo\/(.*)\/ts\/(.*)\.ts/, '$1/$2')] = file;});
module.exports = (env, argv) => ({ mode: argv.mode, entry: entries, output: { path: __dirname + '/dist', filename: '[name].js', }, // ...});
ESLint / Prettier
コードレビューで [nits] 余計なスペースです
のような指摘は不毛なので導入
eslint-config-prettier
のみを使う eslint-plugin-prettier
は不要になったので後に削除した
(↑ググれば有益な情報がたくさん出てくるので割愛)
ついでに、husky と lint-staged を使って Git にコミットする際に、 ESLint と Prettier を実行するように設定した
既存のコードにも ESlint + Prettier を適用
ESlint に"$" is not defined
と怒られるので env には "jquery": true
を設定します。
.eslintrc.json
{ "extends": "eslint:recommended", "env": { "browser": true, "jquery": true } // 略 }
TypeScript に別のルールを適用したい場合はoverrides
に書きます。
"overrides": [ { "files": ["**/*.ts"], "extends": [ ... "rules": { ... } ]
ビルド
ここではオンプレ環境について書きます。
任意の docker イメージ上で yarn install
, yarn build --mode production
(webpack) を行います。
ビルドジョブの前にESLint
とtsc
とjest
の実行をします。
html 側で読み込む
Text::Xslate
というテンプレートエンジンを採用していて、WRAPPER
ディレクティブの中でWITH
キーワードで
js を読み込んでいるケース
(WRAPPER
は Rails でいうActionView
のcontent_for
のようなものです)
[%- WRAPPER 'foo/include/header.tt' WITH- javascripts = [ 'bar.js' ],+ typescripts = [ 'bar.js' ], css = [ 'baz.css' ],-%]
foo/include/header.tt
<!DOCTYPE html><html lang="ja">...<head> [%- FOREACH typescript IN typescripts %] <script src="[% static_file('/dist/foo/' _ typescript ) %]"></script> [%- END %]...
React 導入
ようやく本題です。
.eslintrc.json に.tsx
ファイルの設定をoverrides
に追加
"overrides": [ ... { "files": ["**/*.tsx"], "extends": [ "plugin:prettier/recommended", "prettier", "prettier/@typescript-eslint", "prettier/react", "prettier/standard" ], "parser": "@typescript-eslint/parser", "parserOptions": { "sourceType": "module", "ecmaFeatures": { "jsx": true }, "ecmaVersion": 2020 }, "rules": { ...
Babel で React のコードを変換するには、専用の Preset を追加します
module.exports = { presets: [ ... ['@babel/preset-typescript'],+ ['@babel/preset-react'] ],
また、webpack.config.js に.tsx
関連の設定を追加します。
glob.sync('./foo/{bar,baz}/ts/**/*.ts', {}).forEach(function(file) { entries[file.replace(/\.\/foo\/(.*)\/ts\/(.*)\.ts/, '$1/$2')] = file;});+ glob.sync('./src/{components,containers,domains}/**/{*.ts,*.tsx}', {+ }).forEach(function(file) {+ entries[file.replace(/\.\/src\/(.*)\/(.*)\.tsx?/, '$1/$2')] = file;+ });module.exports = (env, argv) => ({ ...- extensions: [ '.ts', '.js' ],+ extensions: [ '.ts', '.js', '.tsx', '.jsx' ], module: { rules: [ {- test: /\.ts$/,+ test: [/\.ts$/, /\.tsx$/], use: ['babel-loader'] }
tsconfig.json に "jsx": "react"
を追加します
リプレイスする画面の~.tt
ファイルを作成
[%- WRAPPER 'foo/include/header_react.tt' WITH tsxs = [ 'containers/pages/bar.js' ]-%]
<div id="root"></div>
[% END %]
id="root"
に React コンポーネントが展開されるようにする
src/containers/pages/bar.tsx
import React, { FC } from 'react';import ReactDOM from 'react-dom';...ReactDOM.render( <QueryClientProvider client={queryClient}> <I18nextProvider i18n={i18nInstance}> <RecoilRoot> <Bar /> </RecoilRoot> </I18nextProvider> </QueryClientProvider>,document.getElementById('root') as HTMLElement);
それをfoo/include/header_react.tt
で読み込む
foo/include/header_react.tt
<!DOCTYPE html><html lang="ja">...<head> [%- FOREACH tsx IN tsxs %] <script defer src="[% static_file('/dist/' _ tsx ) %]"></script> [%- END %]...
その他
追加したライブラリの一部を羅列します。
- jest
- testing-library/react
- 言わずもがなテストに必要
- storybook
- コンポーネントカタログ
- addon-storyshots なども追加
- styled-components
- Chakra UI に変えたいです
- react-query
- トランとマスタで config を変える (キャッシュの時間を変更)
- i18next
- react-i18next
- po-loader
- 多言語化の既存のファイルが
.po
なので追加
- 多言語化の既存のファイルが
- recoil
- 権限で出し分けするボタンなど出し分けに利用
- 一部、テーブルのチェックボックスの id を保存したり
SPA にする
react-router-dom
を導入します。suspense に対応した 6.0.1
をインストールします。
src/index.tsx, src/app.tsx, src/index.html を作成
src/index.tsx
import React from 'react';import ReactDOM from 'react-dom';import { I18nextProvider } from 'react-i18next';import { QueryClientProvider } from 'react-query';import { BrowserRouter } from 'react-router-dom';import { RecoilRoot } from 'recoil';
import { App } from 'src/app';import { i18nInstance } from 'src/I18n';import { Layout } from 'src/foo/Layout';import { queryClient } from 'src/config/base';
ReactDOM.render( <BrowserRouter> <QueryClientProvider client={queryClient}> <I18nextProvider i18n={i18nInstance}> <RecoilRoot> <Layout> <App /> </Layout> </RecoilRoot> </I18nextProvider> </QueryClientProvider> </BrowserRouter>, document.getElementById('root') as HTMLElement);
src/app.tsx
export const App: FC = () => { return ( <div className="container"> <Routes> <Route path="/admin/accounts" element={<Index />} /> ... </Routes> </div> );};
src/index.html
<!DOCTYPE html><html lang="ja"> <head> ... </head> <body> <div id="root"></div> </body></html>
次に、webpack.config.js のentry
を修正します。
webpack.config.js
module.exports = (env, argv) => ({ mode: argv.mode,- entry: entries,+ entry: 'src/index.tsx',
最後に Amon2 の 〜Dispatcher.pm のレンダリング先を src/index.html にすれば SPA になります!
get '/foo/bar' => sub { my ($c) = @_; react_render($c, 'index.html');};...sub react_render { my $c = shift; my $template = shift; my $params = shift || {};
my $html = Text::Xslate->new({path => [File::Spec->catdir($c->base_dir(), 'dist')]})->render($template, $params);
for my $code ( $c->get_trigger_code('HTML_FILTER') ) { $html = $code->( $c, $html ); } $html = encode('utf8', $html);
return $c->create_response( 200, [ 'Content-Type' => "text/html; charset=UTF-8", 'Content-Length' => length($html) ], $html, );}
静的 Contens を Nginx で Serve する
トランスパイルしたdist
配下のファイルは app にアクセスして perl が Serve する必要はない
location /dist/ { root /hoge/project_dir; }
Nginx の設定に追記
まとめ
以上で Amon2 + JQuery のプロジェクトを Amon2 + React にできました。
他にも色々細かい Tips がありますが、要望、反応があれば Zenn の Books か、技術書典に出したいと思います・・
「Amon2 + React のプロジェクトを Next.js にする」は絶賛作業中なので落ち着いたらまた書く予定です。