19
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Lancers(ランサーズAdvent Calendar 2021

Day 6

CakePHP+jQuery製UIをReactでシンプルにした件

Last updated at Posted at 2021-12-05

これは、Lancers(ランサーズ)Advent Calendar 2021 の6日目の記事です。

はじめまして、ランサーズでフロントエンドエンジニアとして従事しているです。

この記事では、CakePHP + jQueryで作成されていたUIを元に、Reactで作成した際に、学びや気付きがあったので、それを共有できればと思います。

また、記事の内容自体は、世間一般では、「それ、何回目やねん!」とツッコミが来そうな程、割とありふれた内容なのですが、ランサーズがリリースされた2008年当時、jsがまだまだおもちゃ扱いされていた時代を経て、Ajaxが流行となり、YUI、Prototype.js、jQueryが出現し、その後、Angular.js、 Backbone.jsをはじめ、いくつものjsフレームワークが出現し、jsフレームワーク戦争状態となり、React, Vueが出現した結果、その戦争も落ち着き、その後何年か経って、Webフロントエンドが成熟した現在において、ランサーズとして改めて振り返るべきタイミングなのかなと個人的に思ったので、記述した次第です。

実装内容について

今回の実装対象のUIは、チャットの様なUIになります。
具体的な実装内容は、以下になります。

  • 送信ボタンを押下時に、フェードインでコメントを追加表示
  • コメントにマウスオーバーをした際にツールバーを表示

↓コメント追加フェードイン コメント追加.gif

↓ツールバーを表示 ツールバー.gif

CakePHP + jQuery のコードについて

※便宜上、色々な箇所を脈絡も無く大胆に端折ります。

CakePHPのViewのコード

<?php
    $workspace = ...  ...;
    $workspaceAttachments = ...  ...;
?>
<div class="p-workspace-comment__content <?php if (...  ...): ?>p-workspace-comment__content--gray<?php endif; ?> p-workspace-file markdown-body js-workspace-comment-item-body">

    <?= $this->WorkspaceHtml->md2html(...  ...) ?>

    <?php if ($workspaceComment->isModified()): ?>
        <sub class="p-workspace-comment__content-sub">(編集済み)</sub>
    <?php endif; ?>

    <?php if ($workspaceComment->hasWorkspaceMeeting()): ?>
        <div class="p-workspace-comment__meeting">
            <div class="p-workspace-comment__meeting-member p-workspace-comment__row">
                <div class="p-workspace-comment__meeting-member-lists p-workspace-comment__col">
                    <?php foreach (...  ...): ?>
                        <?php $attendeeUser = ...  ... ?>
                        <div class="p-workspace-comment__meeting-member-list p-workspace-comment__meeting-member-avatar c-avatar c-avatar--small">
                            <a class="p-workspace-comment__meeting-member-avatar-wrapper c-avatar__image-wrapper js-workspace-profile-modal-open"
                                href="<?= $this->WorkspaceHtml->urlForWorkspaceProfile(...  ...) ?>"
                            >
                                ... 略 ...
                            </a>
                        </div>
                    <?php endforeach; ?>
                </div>
                <div class="p-workspace-comment__meeting-member-buttons p-workspace-comment__col">
                    <a class="p-workspace-comment__meeting-member-button p-workspace__button c-button c-button--small"
                        href="<?= h(...  ...) ?>"
                        onclick="window.open('<?= h(...  ...) ?>','_blank');return false;"
                    >
                        参加
                    </a>
                </div>
            </div>
        </div>
    <?php endif; ?>

    <?php if ($workspaceAttachments): ?>
        <ul class="p-workspace-file__lists">
            <?php foreach (...  ...): ?>
                <?php
                    ...  ...
                ?>
                <li class="p-workspace-file__list">
                    <a class="p-workspace-file__link p-workspace__control-link"
                        href="<?= ...  ... ?>"
                        target="_blank"
                    >
                        <?php if ($workspaceBlob->isTypeImageAndSizeSmall()): ?>
                            <figure class="p-workspace-file__figure"
                                style="background-image:url(<?= h(...  ...) ?>)"
                            >
                                <?=
                                    $this->Html->image(...  ...), [
                                        'alt' => $workspaceBlob->name,
                                        'class' => 'p-workspace-file__figure-image',
                                    ]);
                                ?>
                            </figure>
                        <?php else: ?>
                            <i class="p-workspace-file__icon fal <?= h(...  ...) ?>"></i>
                        <?php endif; ?>
                        <div class="p-workspace-file__name">
                            <?= h(...  ...) ?>
                            <sub class="p-workspace-file__name-sub">
                                <?= h(...  ...) ?>
                                <?= h(...  ...) ?>
                            </sub>
                        </div>
                    </a>
                </li>
            <?php endforeach; ?>
        </ul>
    <?php endif; ?>

    <?= $this->element('workspace/workspace_comment_item_reaction', [
        ...  ...
    ]) ?>

</div>


jsのコード

$(function() {
    var primaryBody = $('.js-workspace-primary-body');
    var refresh = $('.js-workspace-refresh');
    var commentForm = $('.js-workspace-comment-form');
    var commentCount = $('.js-workspace-comment-count');
    var commentList = $('.js-workspace-comment-list');
    var commentListMore = $('.js-workspace-comment-list-more');

    if (commentForm.length) { // Workspace
        commentForm.find('textarea').focus();
    } else {
        var commentReplyForm = commentList.find('.js-workspace-comment-reply-form');
        commentReplyForm.find('textarea').focus();
    }

    function focusTextarea($textarea) {
        $textarea.attr('readonly', true);
        $textarea.focus();
        $textarea.attr('readonly', false);
    }

    commentForm.submit(function() {
        var self = $(this);
        if (self.hasClass('is-loading')) {
            return false;
        }
        if ($.trim(self.find('textarea').val()) === '') {
            return false;
        }
        var blobItemIsLoading = self.find('.js-workspace-blob-item.is-loading');
        if (blobItemIsLoading.length) {
            return false;
        }
        $.ajax({
            ...  ...
        });
        return false;
    });

    commentListMore.click(function() {
        var self = $(this);
        ...  ...
        $.ajax({
            ...  ...
        });
        return false;
    });

    commentList.on('click', '.js-workspace-comment-item-edit', function() {
        var self = $(this);
        var commentItemBody = commentList.find('.js-workspace-comment-item-body');
        var commentItemBodyForm = commentList.find('.js-workspace-comment-item-body-form');
        ...  ...
        return false;
    });

    commentList.on('click', '.js-workspace-comment-item-edit-cancel', function() {
        var self = $(this);
        var commentItemBody = commentList.find('.js-workspace-comment-item-body');
        var commentItemBodyForm = commentList.find('.js-workspace-comment-item-body-form');
        ...  ...
        return false;
    });

    commentList.on('submit', '.js-workspace-comment-item-body-form', function() {
        var self = $(this);
        if (self.hasClass('is-loading')) {
            return false;
        }
        var commentItemBodyFormTextarea = self.find('textarea'); 
        if ($.trim(commentItemBodyFormTextarea.val()) === '') {
            return false;
        }
        $.ajax({
            ...  ...
        });
        return false;
    });

    commentList.on('click', '.js-workspace-comment-item-copy', function() {
        var self = $(this);
        var commentItem  = self.parents('.js-workspace-comment-item');
        var commentItemBodyForm = commentItem.find('.js-workspace-comment-item-body-form');
        var textarea = $('<textarea style="position: absolute; clip:rect(0,0,0,0);"></textarea>')
        ...  ...
        return false;
    });

    commentList.on('click', '.js-workspace-comment-item-reply', function() {
        var self = $(this);
        var commentItemBody = commentList.find('.js-workspace-comment-item-body');
        var commentItemBodyForm = commentList.find('.js-workspace-comment-item-body-form');
        ...  ...
        return false;
    });

    ...  ...
});

...  ...

かなりの量のコードを省略していますが、肌感でただならぬカオスっぷりを感じ取っていただけると思います。

これらのコードの問題点を具体的に挙げると、

  • 初回のレンダリングの為に、ViewにPHPの記述が散りばめられている為、html全体の見通しが悪い
  • バックエンドとフロントエンドの境界が曖昧になっており、エンジニアの責務が曖昧で、開発体験が悪くなりがち
  • cssクラスが適用されすぎている箇所があり、可読性が悪く、打ち消し合っているプロパティが分かりにくい
  • BEMで記述されたcssクラスの命名が非常に長く、管理するのが辛い
  • jsの記述は、jQueryを利用した命令的記述で、DOM取得とイベント定義の記述が多い為、可読性が悪い
  • DOM取得とイベント定義の記述が多い為、パフォーマンス的にもあまり良くない
  • js側のコードで、単純にクラス設計が出来ていないというのはあるが、似たようなDOM取得のコードやajaxの記述が多く再利用性が殆どない
    etc.

といった感じで問題点を挙げだすときりがなく、かなり保守性の低いコードとなっています。
これをReactで書き直すと、どういったコードになるかを比較してみましょう。

Reactのコードについて

Reactについて改めておさらいすると、

  • jsフレームワークの一つ ※厳密にいうと、Viewを設計する為のライブラリ
  • jsx(html, css, jsをまとめて記述可能なファイル)を利用することが特徴的で、jsx内に記述したhtml, css, jsのスコープは、そのjsx内に留めることが可能
  • jsxのおかげで、ボタンやボックスといった要素をコンポーネントという単位で管理でき、コンポーネント指向開発ができる
  • 宣言的に処理を記述することが出来るので、DOM取得やイベント定義の記述がほとんど無くなり、コードがシンプルになる
  • ステート管理、ライフサイクルの概念を用いることで、データの管理や描画制御にルールを持たせることが容易になる

ざっくりと思いつく限りのメリットはこの様な感じです。

昨今のWebフロントエンド事情に関して、
Reactをはじめとしたjsフレームワークのおかげで、jQueryをメインに利用していた時代よりも データ管理がしやすくコンポーネント単位でUI管理もしやすい為、js以外のcss, htmlといった要素に関しても、jsフレームワークを正しく利用できていれば、フロントエンドの構造やコードがカオス化するリスクを少なく出来る時代になりました。

少し脱線しましたが、
以下は、チャットUIをReactで記述した際のコードです。

main.tsx(チャットUI部分の構造が記述されている大元のjsxファイル。tsxはTypeScriptで記述したjsxのことです。) ※一部コードを省略

import { css } from '@emotion/core';
import ActivityBox from 'components/organisms/ActivityBox';
import { CSSTransition, TransitionGroup } from 'react-transition-group';

const BoardRootPage: React.FC = () => {
  const { activities } = Activity.useContainer();

  return (
     <section>
        <TransitionGroup>
          {activities.map((activity, i) => (
            <CSSTransition key={i} timeout={0} unmountOnExit>
              <div css={animationCss}>
                <ActivityBox key={activity.id} {...activity} />
              </div>
            </CSSTransition>
          ))}
        </TransitionGroup>
      </section>
  );
};

const animationCss = css`
  &.enter-active {
    opacity: 0;
  }

  &.enter-done {
    transition: opacity 0.4s;
    opacity: 1;
  }
`;

上記で利用されているActivity.tsx(コメント部分のコンポーネント) ※一部コードを省略

const ActivityBox: React.FC<ActivityModel> = ({
  body,
  created,
  deleted,
  modified,
  id,
  user,
  project_board_attachments,
}) => {
  const { id: project_board_id } = useParams<{ id: string }>();
  const { loginUser } = LoginUser.useContainer();
  const [editMode, setEditMode] = useState<boolean>(false);
  const [stateBody, setStateBody] = useState<string>(body);
  const [showToolbar, setShowToolbar] = useState<boolean>(false);

  const handleShowToolbar = useCallback(() => {
    setShowToolbar(true);
  }, [setShowToolbar]);

  const handleHideToolbar = useCallback(() => {
    setShowToolbar(false);
  }, [setShowToolbar]);

  return (
    <Root
      onClick={handleShowToolbar}
      onMouseEnter={handleShowToolbar}
      onMouseLeave={handleHideToolbar}
    >
      <Icon>
        <img src={user.image} alt={user.nickname} />
      </Icon>
      <Content>
        <Head>
          <Text size="xs" tag="div" css={ContentHeadCss}>
            <Text bold>{user.display_name}</Text> ({user.nickname})
          </Text>
          {showToolbar && (
            <div css={ToolbarCss}>
              <Toolbar
                isUser={loginUser?.user_id === user.id}
                copyText={stateBody}
              />
            </div>
          )}
          <Text size="xxs" color="gray1">
            {dayjs(created).format('YYYY年M月D日 H:mm')}
          </Text>
        </Head>
        <Body>
          <PreviewBox>
            <MarkdownView body={body} />
          </PreviewBox>
        </Body>
      </Content>
    </Root>
  );
};

const Root = styled.div`
  display: flex;
  flex-direction: row;
  justify-content: flex-start;
  align-items: flex-start;
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid ${({ theme }) => theme.color.gray3};
`;

const Icon = styled.figure`
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  border-radius: 100px;
  overflow: hidden;

  img {
    width: 100%;
    height: 100%;
    object-fit: contain;
  }
`;

const Content = styled.div`
  flex: 1;
  padding-left: 8px;
  position: relative;

  button {
    &:disabled {
      opacity: 0.7;
    }
  }
`;

... 省略 ...

といった感じで、一部省略をしてはいるものの

  • 初回レンダリングの為だけのphpが存在しない為、htmlの可読性が良い
  • phpの記述がViewに介在しない為、フロントエンドとバックエンドのエンジニアで責務を取り合わなくて良い
  • html内にjsが記述されている為、DOMを取得したり、イベントを定義したりといったコードが無い
  • cssは、コンポーネント内でスコープが保たれている為、BEMを利用した複雑な命名をする必要がない
  • 複雑になりそうな要素は、コンポーネントとして切り出すことが可能で、切り出し元のコンポーネントはシンプルになり、切り出し先のコンポーネントは再利用性が高まる
  • フロントエンド側のモデルに関しても別途クラスやコンポーネントとして切り出すことで、再利用性とコードの可読性を高められる

と言った形で、メリットを想像する方が容易な形となりました。

更に、Reactの便利な点としては、サードパーティ製のモジュールが充実しており、今回の様なチャットUIで、コメント投稿時の追加要素に対し、アニメーションを行う様な記述もreact-transition-groupというモジュールを利用すれば、瞬殺で実装が可能です。

また、UI全般に関しては、Material UIというライブラリを用いることで、ボタンを押した際の細かいエフェクトやマウスオーバーした際のツールチップ等も非常にシンプルに実装することが可能です。

これらもReactを採用する上での十分なメリットです。

今後の課題

ランサーズのシステムは、大部分がCakePHPで作成されており、前述したとおり、Viewに対し、CakePHPのControllerが密接に絡んでいて、保守性が低い状態となっています。

一部のUIや新規開発された機能のViewでは、Reactを採用し、整理されていってはいますが、
長年CakePHP中心に開発・運用が行われた結果、それに付随したフロントエンド周辺がカオスになっています。

これは、ランサーズだけの課題ではなく、おそらく、長年CakePHP等のバックエンド系フレームワーク中心で開発が行われているシステムにとっては、あるあるな課題だと思われます。

フロントエンドを担当するエンジニア陣では、この課題を解決する為に、また、システムのパフォーマンスや開発体験を良くする為に、日々のSlackや週一のフロントエンド定例MTGで情報共有や今後の展望を会話し、改善していく取り組みを行っています。

1日も早く、理想となるWeb開発を行える様に、これからも一歩ずつ前進していきます。

この内容を見て、レガシーな環境をモダンな環境にリプレースしたい!といった欲が強いフロントエンドエンジニアは是非、ランサーズにどうぞ!よろしくお願いいたします。

アドベントカレンダー、明日は @rrih さんです。お楽しみに!

19
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?