⚖️

Nuxt.js でレイアウトコンポーネントを使ってレイアウトを統一した話

2021/12/08に公開

Legalscape でフロントエンドを担当している 大谷 です

この記事は Legalscape アドベントカレンダー 2021 の12月8日の記事です

Legalscape のフロントエンドについて

CTOである城戸の記事 でもありましたが、 フロントエンドは Nuxt.js をベースにした SPA で、 UIフレームワークとして Vuetify を利用して構成されています。ぼくがジョインした 2020年10月頃はトップページと検索ページ、閲覧ページの3パターンしかなく、 Vuetify のコンポーネントを直接使ってレイアウトからパーツまで作られていました

少ないページ数であればこの実装でも問題なかったのですが、 2021年6月1日の正式版リリース に向けて、デザインの統一とブラッシュアップと新機能のためのページ追加などが並行で動いており、このままの状態ではページごとに微妙な差異が発生し、正式版リリース時のクオリティに影響があるということでレイアウト部分の共通化を実施しました

レイアウトの共通化の方針

Nuxt.js にはページごとに利用するレイアウト用のコンポーネントを切り替える機能があります

ですが、 Legalscape ではサービス横断で使われる検索用のヘッダーが存在しています。これは、文献を検索するだけではなく、閲覧中の文献内の検索にも使われており、検索条件を常に引き継がなければなりません

<template>
  <v-app id="wklr">
    <search-bar />
    <nuxt />
  </v-app>
</template>

このようなレイアウトを 2つ用意してページごとに切り替えて利用すると、 <search-bar /> のインスタンスが新しく生成されてしまい、中のステートがリセットされてしまいます

対策として レイアウトはひとつだけにして各ページの最上位でレイアウト用のコンポーネントを呼ぶ ことで、 SPA の良さを活かしたままレイアウトの共通化ができるようになります

<template>
  <layout-normal>
    <template #main>
      <!-- your page contents here -->
      <page-component />
    </template>
  </layout-normal>
</template>

vue devtools のキャプチャ

レイアウトコンポーネントの導入

導入にあたって用意したのは以下の 3つのレイアウトになります(コードは簡略化したもの)

layout-normal

layout-normal のワイヤーフレーム

基本的なレイアウトでヘッダー・フッターがあり自然にスクロールします

<template>
  <div>
    <v-container>
      <slot name="main">
        <!-- メインコンテンツが入る -->
      </slot>
    </v-container>
    <my-footer />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator';
import MyFooter from '@/components/my-footer.vue';

/**
 * 通常のレイアウト
 */
@Component({ components: { MyFooter } })
export default class LayoutNormal extends Vue {}
</script>

layout-resizable-drawer

layout-resizable-drawer のワイヤーフレーム

閲覧画面などで使われるメインのコンテンツと、左カラムに目次などを表示するためのレイアウトです

高さは vh: 100 固定で、左カラムは自由に幅を変えることができます

<template>
  <div class="resizable-drawer-layout">
    <div v-show="isDrawerOpen" class="drawer-container">
      <section class="pane">
        <slot name="drawer">
          <!-- ドロワーコンテンツが入る -->
        </slot>
      </section>
    </div>
    <div class="main-container">
      <section class="pane">
        <slot name="main">
          <!-- メインコンテンツが入る -->
        </slot>
      </section>
    </div>
    <div
      class="resize-bar"
      :class="{ resizing }"
      :style="{ left: `${resizing ? 0 : drawerWidth}px` }"
      @mousedown="resizeStart"
      @mousemove="resizeMove"
      @mouseup="resizeEnd"
    />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator';

/**
 * ドロワーがあり、ドラッグでドロワーの幅を変更できるレイアウト
 * ドロワーとメインエリアは個別にスクロールすることでき、フッターは固定表示になっている
 */
@Component
export default class LayoutResizableDrawer extends Vue {
  /** ドロワー部分の幅 */
  get drawerWidth(): number {
    return this.resizeBarPosition;
  }

  /** リサイズバーの位置(ドロワー非表示でも保持しておく) */
  resizeBarPosition = 256;

  /** ドロワーがリサイズしているかのフラグ。リサイズ中は位置情報を持つ */
  resizing: number | false = false;

  resizeStart() {
    this.resizing = this.resizeBarPosition;
    document.documentElement.style.setProperty('--drawer-width-resizing', `${this.resizing}px`);
  }

  resizeMove(event: MouseEvent) {
    if (this.resizing) {
      this.resizing = event.clientX;
      document.documentElement.style.setProperty('--drawer-width-resizing', `${this.resizing}px`);
    }
  }

  resizeEnd() {
    if (this.resizing) {
      this.resizeBarPosition = this.resizing;
      this.resizing = false;
    }
  }
}
</script>

layout-foldable

layout-foldable のワイヤーフレーム

通常のレイアウトですが、ユーザーのインタラクションによって右カラムに折り畳まれたセクションが表示されます。こちらはページコンポーネント側から制御したかったので props でレイアウトの制御を行っています

主に、検索結果ページで必要に応じて右カラムにプレビューが表示されます

<template>
  <v-container class="foldable-layout">
    <v-row>
      <v-col :cols="isFold ? mainCols + foldCols : mainCols" class="pa-0">
        <v-container class="main-container">
          <v-row>
            <v-container class="main-section">
              <slot name="main">
                <!-- メインコンテンツが入る -->
              </slot>
            </v-container>
          </v-row>
          <v-row>
            <v-col class="pa-0">
              <my-footer />
            </v-col>
          </v-row>
        </v-container>
      </v-col>
      <v-col v-if="!isFold" ref="fold-wrapper" :cols="foldCols" class="pa-0">
        <section ref="scroll-container" class="fold-container">
          <slot name="fold">
            <!-- フォールドのコンテンツが入る -->
          </slot>
        </section>
      </v-col>
    </v-row>
  </v-container>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'nuxt-property-decorator';
import MyFooter from '@/components/base/my-footer.vue';

/**
 * 基本は 100% 幅だが、必要に応じてフォールド部分を表示することができるレイアウト
 */
@Component({ components: { MyFooter } })
export default class LayoutFoldable extends Vue {
  /** フォールド部分が非表示になっているか */
  @Prop() isFold!: boolean;

  /** フォールドを表示した時のメイン部分の幅 */
  @Prop({ default: 6 }) mainCols!: number;

  /** フォールドを表示した時のフォールド部分の幅 */
  @Prop({ default: 6 }) foldCols!: number;
}
</script>

レイアウトコンポーネントのメリット

のちに閲覧画面でのヘッダー非表示(画面上部にマウスホバーした場合の表示される)機能を追加したのですが、レイアウトとページが分離されていたことで実装や検証がスムーズにできました。また、別機能のために layout-resizable-drawer レイアウトに右ドロワーを追加した際にもレイアウトとロジックが分離できたため検証含めスピーディーに実装できたと思います

Legalscape のようなスモールなスタートアップでは、レイアウトを含めた機能のピボットが頻繁に発生します。そのためにもレイアウトをページと分離しておくことで素早い機能改修に繋げることができます


この記事ではスタートアップにおけるフロントエンドでの課題解決の取り組みを紹介させていただきました。Legalscape では 法情報を高機能に使いやすいシステムで利用者に届けたいエンジニアを絶賛募集中 です。興味があればぜひコンタクトください

Discussion