Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする

Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする

Vue Fes Japan 2024 登壇資料

igayamaguchi

October 19, 2024
Tweet

More Decks by igayamaguchi

Other Decks in Technology

Transcript

  1. アジェンダ • サービスの概要 • 問題の概要 • 原因別の対応と解決策 ◦ 汎用的なUI要素 ◦

    一休.com/Yahoo!トラベル特有のコンポーネント ◦ Vueの状態が絡んだロジック
  2. 運営サービスの概要 • 一休では2つの宿泊予約サイトを運営 ◦ 一休.com ◦ Yahoo!トラベル (2021年に統合) • フロントエンドをNuxtで実装

    • 両サイトは1ソースで実現されており、ビルド時の環境 変数に応じて振る舞いが切り替わる • PC/SPはほとんど別デザイン ビルド時に環境変数で 切り替え
  3. デザインシステムを作る やったこと • Figmaの導入 • トンマナの洗い出し • コンポーネントの洗い出し • Figma上でコンポーネントの構築

    • コンポーネントのコード化 • Storybookで一覧化 • 開発フローの整備 デザインシステムはそれ1つで大きなトピック 今日は一部だけピックアップ
  4. コンポーネントのコード化 Figmaで洗い出したコンポーネントを実装 デザインのまとまりを 1コンポーネントで表現 propsに応じていくつかのバリエーションで表示できるように <BrandButton theme="primary" size="large" > 検索する

    </button> propsに応じて見た目が変化 export default Vue.extends({ props: { theme: { type: String as PropType< 'primary' | 'secondary' | 'tertiary' | 'search' >, default: 'primary', }, 基本的に内部で状態を持たないように props、slot、emitで表現
  5. Storybookで一覧化 コンポーネントの様々なバリエーションを簡単に試すことができるように UIコンポーネントとして完成度を高めるには独立した UIコンポーネント開発環境が必要 Storybookを使うことは必須 Vueのpropsを自由に変更 して試すことができる import type {

    Meta, StoryObj } from '@storybook/vue3' import BrandButton from './BrandButton.vue' const meta = { title: 'elements/BrandButton', component: BrandButton, } satisfies Meta<typeof BrandButton> type Story = StoryObj<typeof meta> export default meta export const Primary: Story = { args: { theme: 'primary', default: 'Button', }, }
  6. 開発フローの整備 Figma、モックを共有 実装したUIコンポーネントを組 み立てて 画面を実装 デザイナーが Figmaで 1枚のページを作成 デザイナーとエンジニアで Figmaを

    見てコンポーネントについて議論 コンポーネントをStorybookに カタログ化 土台が整ったので、最後に開発フローを整備 コンポーネントについて議論、開発するフェーズを追加 意識が、画面だけでなくコンポーネントにも焦点が当たるように
  7. コンポーネントの分割 大枠だけ共通化して、細部だけ別のコンポーネントに 青枠がそれぞれ共通コンポーネント、赤枠だけ実行時の環境変数を見て差し替え <template> <div> <!-- 一部だけ動的にコンポーネント描画 --> <Component :is="activeTemplateAmount"

    :amount2="amount" /> </div> </template> <script setup lang="ts"> import AmountIkyu from '~/Amount.ikyu.vue' import AmountYahoo from '~/Amount.yahoo.vue' // 他処理… const activeTemplateAmount = computed(() => { // modeは環境変数から設定される if (mode === 'yahoo') { return AmountYahoo } return AmountIkyu }) </script>
  8. Vueの状態が絡んだロジック Vueの状態が絡んだロジックがコピペされていた 例えばGraphQLから取得するコード 同じようなコードが様々なコンポーネントに存在 export default Vue.extend({ data() { return

    { query: undefined, plan: undefined, } }, created() { this.query = getByUrl() }, apollo: { plan: { query: PlanDocument, variables() { return { pln: this.query.pln, } }, } }, })
  9. 当時はOptions API 当時のVueの標準的な記述方法 宣言的に状態、状態からなる処理を記述可能 変数の変化に応じて UIが更新されるように適切に各 グループ内で記述する必要がある export default Vue.extend({

    data() { return { lastName: '', firstName: '', } }, computed: { fullName() { return `${this.lastName} ${this.firstName}` }, }, watch: { fullName() { showNotice('本名が変更されました') }, }, }) 持っている状態 状態を使って計算した値 値を監視し、変更に 応じて行う処理
  10. Options APIでの共通化手法の問題 Vue2のOptions APIにおいてもMixinという共通化手法はあ る。しかし • 実装が隠ぺいされる • 暗黙的なオーバーライドが発生する など、よい共通化手法とは言えず積極的に使う判断はできな

    かった Vueの状態が絡んだロジックのよい共通化手法が必要だった // Mixin export default { data() { return { lastName: '', firstName: '', } }, computed: { fullName() { return `${this.lastName} ${this.firstName}` }, }, } // Component import NameMixin from './TestMixin' export default Vue.extend({ mixins: [NameMixin], computed: { displayName() { return `${this.fullName}様` }, }, }) Mixinを呼び出すコードからは Mixinで どんな状態が追加されたかは分からない
  11. Composition API • Vue3で導入されたAPI • 用意された関数を呼び出すことで状態、状態からなる 処理を定義 • Vueの状態も含めたロジックを関数に切り出すことが可 能

    これを使うためにはVue3/Nuxt3へのアップデートが必要にな る // 別ファイル function useName() { const lastName = ref('') const firstName = ref('') const fullName = computed(() => { return `${lastName.value} ${firstName.value}` }) return { lastName, firstName, fullName, } } // コンポーネント側 const { lastName, firstName, fullName, } = useName() 関数として複数コンポーネントで 呼び出し可能 明示的にどんな値を使うのかが分かる
  12. 一休.com/Yahoo!トラベル、 PC/SP間のコピペを共通化 一休.com/Yahoo!トラベルは1ソースで実装している。 PC/SPも別のデザイン コンポーネントはコピペで作られていたため、一休 .com/Yahoo!トラベル × PC/SPそれぞれに同じロジックが別 のコードとして実装されていた export

    default Vue.extend({ data() { return { query: undefined, plan: undefined, } }, created() { this.query = getByUrl() }, apollo: { plan: { query: PlanDocument, variables() { return { pln: this.query.pln, } }, } }, }) PlanDetail.ikyu.pc.vue PlanDetail.ikyu.sd.vue PlanDetail.yahoo.pc.vue PlanDetail.yahoo.sd.vue
  13. 一休/Y!、PC/SPでのコピペを共通化 export default usePlanDetail() { const query = useRouteQuery() const

    { data } = useAsyncQuery( PlanDocument, computed(() => ({ pln: query.value.pln })), ) // 取得した値からの処理... return { plan: data, } } PlanDetail.ikyu.pc.vue PlanDetail.ikyu.sd.vue PlanDetail.yahoo.pc.vue PlanDetail.yahoo.sd.vue 共通のふるまいを抽出して関 数化 Composition API導入前は同じ コードをN倍書かなければいけな かったが1つにまとまる // 各コンポーネント setup const { plan } = usePlanDetail() 関数を参照 コンポーネントに書かれたロジックを関数として抽出、共通化
  14. 小さい責務で分ける 例えばURLからの値のマッピング どのページ、コンポーネントであろうとやることは変わらないので共通の関数に Top.vue AccommodationList.vue PlanList.vue Plan.vue // 各コンポーネント setup

    const { query } = useRouteQuery() 関数を参照 export default function useRouteQuery() { const nuxtApp = useNuxtApp() const route = useRoute() const config = useRuntimeConfig().public const { interpretedKeywords } = useInterpretedKeywords() const query = computed(() => { return getQuery( route, config.mode, nuxtApp, interpretedKeywords.value, ) }) return query }
  15. 小さい責務で分ける 例えばデータ取得の関数 一休ではGraphQLを使用しており、GraphQLのqueryと variablesの設定のコードは大きくなりがち // コンポーネントのsetup内 const { query }

    = useRouteQuery() // GraphQLとの通信処理 const { facet } = useFetchFacet() const { areaMaster } = useFetchAreaMaster() const { searchStatusNames } = useFetchSearchStatusNames() // 取得した値を使って別のコンポーザブルを呼び出し const { tags, searchLabel } = useTags( query, facet, searchStatusNames, areaMaster, ) export function useFetchFacet() { const query = useRouteQuery() const { skipAccommodationSearch } = useSearchSkip(query) const { data } = useAsyncQuery( graphql(` query UseFetchFacet($input: SearchAccommodationsInput!) { # 省略... } `), computed(() => { // variablesの組み立て処理が入る return { /* ... */} }), { server: false, skip: skipAccommodationSearch, }, ) const facet = computed(() => { if (!data.value) { return undefined } return new Facet(data.value.searchAccommodations.facet) }) return { facet, } }
  16. まだロジックが綺麗ではない。もう一歩 … 共通化したはずのComposition APIが分かりにくいところが … const { accommodation, amenities, attribute,

    booking, bookingLoading, calendar, checkInOutLabel, discount, inventory, limitations, plan, room, roomPlan, settlementLabels, isZenkokuCouponTarget, landingPageUrl, meals, } = useRoomPlanDetail({ isPage: props.isPage, }) 共通関数から大量の返り値が返っ てきて把握コスト増 共通関数の中身も600行を超え、あり とあらゆる責務を持つように
  17. 分割例 Header SearchCondition Calendar SearchCondition PeopleAndRoom IntroductionPlan IntroductionRoom AmountIkyu BookingButton

    AddComparison List RoomPlanSharing const props = defineProps<{ data: FragmentType<typeof fragment> | undefined | null bookableDateTime: | FragmentType<typeof bookableDateTimeFragment> | undefined | null }>() const { inventory } = useInventory() const { bookableDateTimeTo } = useBookableDateTime( toRefs(props).bookableDateTime, ) const { firstImages, plan, bookingAmount, room, } = useHeader(toRefs(props).data) そのコンポーネントに必要な関 数だけを呼び出して必要な返り 値だけしようすればよい 各コンポーネントの ロジックがシンプルに
  18. ディレクトリ、ファイルを整理 PlanDetail/ lib/ plan.yahoo.ts plan.ikyu.ts amount.ts calendar.ts composables/ useInventory.ts usePlanDetail.ts

    PlanDetail.ikyu.vue PlanDetail.yahoo.vue components/ PlanDetail/ plan.yahoo.ts plan.ikyu.ts useInventory.ts usePlanDetail.ts PlanDetail.ikyu.vue PlanDetail.yahoo.vue Header/ Coupon/ BookingButton/ Header/ Coupon/ BookingButton/ コンポーネント、コンポーザブルが大量に増えるので分かりやすいように 特定コンポーネントでしか使用されないものはそのコンポーネントの配下に配置する 配下はそれぞれlib、components、composablesを切り、配置する 入れ子で同様の構造が 続く
  19. まとめ 一休.com/Yahoo!トラベルのフロントエンド開発において発生したコピペ問題に対して 以下のように対応した • 汎用的なUI要素 ◦ UIコンポーネントの作成 ◦ ワークフローの整備 •

    一休.com/Yahoo!トラベル特有のコンポーネント ◦ デザインの統一 ◦ コンポーネント分割 • Vueの状態が絡んだロジック ◦ Composition APIの活用 ◦ コンポーネント分割