😈

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

2021/11/09に公開

この記事は、2本立てです。ブログの内容をこちらに転機しました。細かいところはブログに書いているので気になる方は確認してみてください! https://www.nagamejun.dev/

  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
  • 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