HTML+jQueryでできたLPをwebpack+TypeScript+Cypressで楽しく保守する

10 min read読了の目安(約9300字

はじめに

副業にてとある会社のLPを構築して以来、約2年間に渡ってほそぼそと機能追加やデザイン・文言の変更等をおこなってきたのですが、
元々保守性を意識した構造でないものに対して、場当たり的に修正を重ねてきてしまったため手を入れるのがしんどくなってきており、
そんな中で、 LPにページを増やしてほしい という依頼を受けたため、色々とリファクタリングに取り組んだのでその備忘録です✍

私自身はデザイン・フロントエンド領域に明るくなく(別にバックエンドも明るくないが…インフラもか…)、抜本的な解決方法が思いつかなかったため、
今回は 既存実装の修正をできるだけ抑えつつ、保守性を向上させる仕組みを取り入れる という方針で頑張ってみることとしました🍦

現状

内容自体は1Pのシンプルなもので、以下のようなディレクトリ構造で管理しています
クラウドソーシングでデザイン含めて初期実装をしてもらったものに、私が手を加えてきたものになります

$ tree -L 2 .
.
├── README.md
├── html
│   ├── favicon.ico
│   ├── img/ 画像ファイル
│   ├── index.html
│   ├── robots.txt
│   ├── script.js
│   └── style.css
└── scripts/
    ├── deploy.bash デプロイ用スクリプト
    └── run_server.bash 開発サーバ立ち上げスクリプト

現時点で特にツラミをかんじているのは以下の部分でした

  • 生のHTML/CSS/JS(jQuery)を記述する必要があり、改修がしんどい
    • ページを増やすのにヘッダやフッタなどをコピペしないといけない
    • SCSSで使える諸々の記法(入れ子で書けるやつとか)が使えずガァァとなる
    • ブラウザ互換性を気にして varfunction() { ... } の世界。型はない
  • テストがない
    • デザインは目視してるので別にして、特にJS実装をカジュアルに壊しがち&デプロイしてから気づきがち

総じて、普段やってるバックエンドやフロントエンド(SPA)の開発と比較して、違う筋肉を使ってる感じだったので、
このLPについても同じような書き味で開発ができると、ストレスが低減できるように感じます

結果

一夜に渡る激闘の末、以下のような構成になりました🌛

$ tree -L 2 .
.
├── README.md
├── bin/
│   └── deploy デプロイスクリプト
├── cypress.json
├── package-lock.json
├── package.json
├── public/ ルートに直接コピーするファイル
│   ├── favicon.ico
│   └── robots.txt
├── src/
│   ├── img/ 画像ファイル
│   ├── index.ts
│   ├── styles/ SCSSファイル
│   └── templates/ EJSファイル
├── tests/
│   └── e2e/
├── tsconfig.json
└── webpack.config.js

順を追ってご紹介していきます

webpackを導入

最終的なデプロイ結果を人間が手で編集していることにが問題の根源だと思ったため、
webpackを導入して、ソースコードと生成物を分離して扱うようにしました
(結果的にwebpackにいろいろやらせすぎ?な気がしてきているのですが、今回のようなユースケースを解決する何かってあったりするんでしょうか…?)

webpack init で作成したテンプレを元に、最終的に以下の形となりました

webpack.config.js
const path = require('path');

const { ProgressPlugin } = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

const includePath = path.resolve(__dirname, 'src');
const excludePath = /node_modules/;

module.exports = (_env, argv) => {
  const { mode } = argv;
  return {
    mode,
    entry: {
      index: './src/index.ts',
      company: './src/company.ts',
      // その他ページを増やす...
    },

    externals: {
      jquery: '$',
    },

    plugins: [
      new ProgressPlugin(),
      new MiniCssExtractPlugin(),
      ...[
        {
          template: './src/templates/index.ejs',
          chunks: ['index'],
          filename: 'index.html',
        },
        {
          template: './src/templates/company.ejs',
          chunks: ['company'],
          filename: 'company/index.html',
        },
        // その他ページを増やす...
      ].map((props) => {
        return new HtmlWebpackPlugin({
          ...props,
          hash: true,
          meta: [
            { 'http-equiv': 'content-language', content: 'ja' },
            // metaタグいろいろ...
          ],
        });
      }),
      new CopyWebpackPlugin({
        patterns: [
          { from: './public' },
          { from: './src/img', to: './img' },
        ],
      }),
    ],

    module: {
      rules: [
        {
          test: /\.ejs$/,
          include: [includePath],
          exclude: [excludePath],
          use: {
            loader: 'ejs-compiled-loader',
            options: {
              htmlmin: true,
              htmlminOptions: {
                removeComments: true,
              },
            },
          },
        },
        {
          test: /\.(ts|tsx)$/,
          loader: 'ts-loader',
          include: [includePath],
          exclude: [excludePath],
        },
        {
          test: /.(sa|sc|c)ss$/,
          include: [includePath],
          exclude: [excludePath],
          use: [
            { loader: MiniCssExtractPlugin.loader, options: {} },
            {
              loader: 'css-loader',
              options: {
                url: false,
                sourceMap: true,
              },
            },
            {
              loader: 'sass-loader',
              options: {
                sourceMap: true,
              },
            },
          ],
        },
      ],
    },

    resolve: {
      extensions: ['.tsx', '.ts', '.js'],
    },

    // 開発サーバの設定
    devServer: {
      open: true,
      host: 'localhost',
      port: 9000,
      compress: true,
      proxy: {
        '/contact/': {
          bypass: (_, res) => res.send('This is dummy contact page.'),
        },
      },
    },
  };
};

EJS/SCSS/TypeScriptの導入

EJSいれる

html-webpack-pluginejs-compiled-loader の力を使って、HTMLをテンプレートエンジン(EJS)を用いて生成することとしました
導入にあたってはこちらのページを参考にしました

EJSを選択した理由としては、使い慣れていたというのもありますが、移行にあたりHTMLをそのまま貼り付けることができるのと、
かつ別テンプレートのincludeといった必要としている機能を満たしていたためになります

これによって、GoogleAnalyticsの設定やCDNから取得している外部ライブラリ、iconファイルの設定などを複数ページで共有できるようになりました

index.ejs
<!DOCTYPE html>
<html lang="ja">
  <head>
    <% include common/_ga %>
    <% include common/_icon %>
    <% include common/_vendor %>

    <title>hogehoge</title>
  </head>
<!-- 後略 -->
common/_icon.ejs
<link
  rel="apple-touch-icon"
  type="image/png"
  href="/apple-touch-icon-180x180.png"
/>
<link rel="icon" type="image/png" href="/icon-192x192.png" />

SCSSいれる

webpack init の対話モードの中でCSSフレームワークの選択ができたため、使い慣れているSCSSを選択した上で、既存の style.css の内容をそのまま貼り付けました
SCSSもCSSと互換性があるので、移行コストを最小限に導入できるのが大きなメリットだと思います

本当は記述のリファクタリングやファイル分割(共通設定とページごとの設定に分離)までやりたかったですが、
現状だと検証がそこそこ大変そうに思えたため、今回のスコープからは外すこととしました

TypeScriptいれる

こちらもwebpack導入時にチェックを入れたところ、 ts-loader が追加され簡単に使い始めることができました
既存のアレな script.js の拡張子を ts に変更し、エラーが出ているところを潰していきました

今回の対応にあたりnpmでのパッケージ管理ができるようになったため、
LPでCDNから読み込んでいるライブラリをimportで扱うようにするか悩みましたが、
バージョンの古いjQueryに対していろいろとプラグインを使って拡張していたこともあり、今回は見送ることとしました

実際のファイルはEJSに記載したscriptタグで読み込むこととし、
tsファイルからは @types/jquery を参照しての型付けに留めることとしました

これだと、プラグインにより$に生えてるメソッドには型が付けられないのですが、古いライブラリを使い続ける方がよくなさそうなため、
今回は修正を保留した上で、別の類似ライブラリで実現できないか別途検討したいです🍩

webpack.config.js
  externals: {
    jquery: '$',
  },
index.ts
import './styles/main.scss';
import $ from "jquery";

$(() => {
  // TODO: なんとかする ←本当に?
  // @ts-ignore
  $('.selector').somePlugin();
});

また、 $.getJSON() など、外部依存によりanyが渡ってくる部分については適宜型付けを行いました
最初は苦戦するかと思ったのですが、上記の対応で strict: true のTypeScriptに書き換えることができたので嬉しかったです

ESLint/Prettierの導入

JSをTypeScriptに書き換えたところで、ESLintとPrettierでフォーマットもするようにしました
特筆すべきことはないですが、 @ts-ignore に屈しないためwarnにしています

.eslintrc.js
module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  rules: {
    '@typescript-eslint/ban-ts-comment': 'warn',
  },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'prettier',
  ],
};

Cypressの導入

画面機能をざっくり検証するにはE2Eテストが一番ということで、Cypressを使って機能中心にテストを書いてみました
テスト実施にあたり、 webpackで起動しているwebサーバと連携を取る必要があるため start-server-and-test にて開発サーバの起動待ちをおこなっています

package.json
  "scripts": {
    "dev": "webpack-cli serve",
    "test:e2e": "start-server-and-test dev http://localhost:9000 \"cypress open\"",
    "test:e2e:ci": "start-server-and-test dev http://localhost:9000 \"cypress run\"",
  },

テストケースはざっくりとこのような粒度で書きました
お問い合わせは別のSPAにて実現しているため、この画面自体には入力を受け付ける機能がなかったこともあり、まずはページごとにシンプルな正常系の確認のみ行うこととしました

index.spec.js
describe('Indexページ', () => {
  it('アクセス時に正常に表示される', () => {
    cy.visit('/');
    cy.title().should('contain', 'サイト名');
  });

  it('画像が正常に読み込まれる', () => {
    // 略
  });

  it('XXX機能が動作する', () => {
    // 略
  });

  it('YYY機能が動作する', () => {
    // 略
  });

  it('お問い合わせ画面に遷移できる', () => {
    // 略
  });
});

上記テストの実行について、先程作成したlintと併せて、GitHub Actionsに取り込みました
PR作成時に検証を行うことで、デザイン変更に気を取られて機能を壊していた…ということを抑止します

.github/workflows/check.yml
on: pull_request

name: Check

jobs:
  deploy:
    name: Check
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-node@v1
        with:
          node-version: 12.x

      - run: npm ci

      - run: npm run lint:check

      - run: npm run test:e2e:ci

結果

コードベース

GitHubのLanguagesは以下のようになりました
HTMLがEJSに、CSSがSCSSに置き換わりいい感じです。JSが増えているのはE2EテストはJSで書いているからと思います
(ところで昔からEJSって認識してましたでしょうか?HTMLとして扱われていた気が…)

before

before

after

after

パフォーマンス

パフォーマンスについてもデプロイ前後でLighthouseで比較してみましたが、若干劣化していました…
リファクタリングに加えて、ページ上部でデザイン変更を伴う改修を若干おこなったため、これによって劣化が発生したものと思われます

ただ、コードベースを大きく改善できたことを考えれば、これから直していけばよいということで!

before

before

after

after

おわりに

色々取り組みましたが、今後はE2EテストやTSの力で品質を担保しつつ機能改修ができそうでいい感じです🍜

今回積み残してしまったタスクとしては以下がありましたので、引き続き頑張りたいと思います🍤

  • ビジュアルリグレッションテストを入れたい
    • 現状だとEJSやSCSSのリファクタリングによるデグレードを検知できないため、ぜひ導入したいと思っています
      • CIでmasterとトピックブランチの実行結果を比較して、差分があったらPRのコメントに投稿とかやりたい
  • EJS/SCSSのリファクタリング
    • リグレッションテストが充実するとやりやすくなるものと思いますので、順を追ってやっていきたいと思います
  • jQuery周りの依存ライブラリの管理ができてない
    • これはライブラリの置き換えを目標に、地道に対応していきたいと思います
  • クライアントで動くJSを監視してない(今気づいた)
    • すぐ入れられる気もしましたが記事を書いて疲れたので、次の週末にSentryを導入したいと思います…

ありがとうございました!
疲れた!!♨️