ながめ
Published on

Amon2 + JQuery のプロジェクトを React にする/ 〜 そして Next.js へ

Authors

レガシーフロントエンドに立ち向かう

  1. Amon2 + JQuery のプロジェクトを Amon2 + React にする
  2. 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) を行います。

ビルドジョブの前にESLinttscjestの実行をします。

html 側で読み込む

Text::Xslate というテンプレートエンジンを採用していて、WRAPPERディレクティブの中でWITHキーワードで

js を読み込んでいるケース

(WRAPPERは Rails でいうActionViewcontent_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 にする」は絶賛作業中なので落ち着いたらまた書く予定です。