Amon2 + JQuery のプロジェクトを React にする/ 〜 そして Next.js へ
この記事は、2本立てです。ブログの内容をこちらに転機しました。細かいところはブログに書いているので気になる方は確認してみてください! https://www.nagamejun.dev/
- 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
- styled-components
- react-query
- i18next
- react-i18next
- po-loader
- recoil
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,
);
}
まとめ
以上で Amon2 + JQuery のプロジェクトを Amon2 + React にできました。
他にも色々細かい Tips がありますが、要望、反応があれば Zenn の Books か、技術書典に出したいと思います・・
不明点、ご指摘ありましたらコメントお願いします。ご相談ありましたら twitter に DM 下さい。
「Amon2 + React のプロジェクトを Next.js にする」は絶賛作業中なので落ち着いたらまた書く予定です。
Discussion