Slide 1

Slide 1 text

Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする 2024/10/19 Vue Fes Japan 2024 株式会社一休 CTO室 いがにん (山口将希)

Slide 2

Slide 2 text

いがにんこと山口将希 Twitter: @igayamaguchi 一休のCTO室で一休.com/Yahoo!トラベルの フロントエンド設計の改善をしています 業務でVue.js/Nuxtをずっと触ってます 自己紹介

Slide 3

Slide 3 text

概要 ● 株式会社一休では一休 .com/Yahoo!トラベルを運営 ● Nuxtを使ってフロントエンドを実装 ● 開発をしていく中で、大量のコピペコードが存在するように ● 問題発生の背景とその課題にどう対処してきたかを話す

Slide 4

Slide 4 text

アジェンダ ● サービスの概要 ● 問題の概要 ● 原因別の対応と解決策 ○ 汎用的なUI要素 ○ 一休.com/Yahoo!トラベル特有のコンポーネント ○ Vueの状態が絡んだロジック

Slide 5

Slide 5 text

運営サービスの概要 ● 一休では2つの宿泊予約サイトを運営 ○ 一休.com ○ Yahoo!トラベル (2021年に統合) ● フロントエンドをNuxtで実装 ● 両サイトは1ソースで実現されており、ビルド時の環境 変数に応じて振る舞いが切り替わる ● PC/SPはほとんど別デザイン ビルド時に環境変数で 切り替え

Slide 6

Slide 6 text

一休.com/Yahoo!トラベルのフロントエンド 一休.com/Yahoo!トラベルの ソースコードを統合 Nuxt v2の導入 Vue.jsを導入 当時はVue2でWebpackでの 自前のビルド 一休.comはもともとjQueryと素 のJavaScriptで書かれていた 2017年12月 2020年4月 2021年10月

Slide 7

Slide 7 text

何かを変更するときにやけに手数が必要 2022年ごろのこと 当時はVue2/Nuxt2で開発 フロントエンドフレームワークを使用することでリッチにフロントエンドを開発 全体としては開発効率が大きく上がっていた しかし、何かを変更するときにやけに手数が必要で時間がかかると思うことが増えた

Slide 8

Slide 8 text

コードを紐解くと大量のコピペコードが存在していた 大量のコピペコードが存在していた 300行のうち差分は4行しかな いコンポーネント ほぼ同じ料金表示だが別実装 同じUIの再実装 HTML、CSSが重複している

Slide 9

Slide 9 text

重複コードの割合 一休の他プロジェクト SonarCloudを使用してプロダクトコードの重複コードの割合を算出 ※TypeScriptの部分のみの計測 一休.com/Yahoo!トラベル 3%

Slide 10

Slide 10 text

重複コードの割合 20 % 一休の他プロジェクト SonarCloudを使用してプロダクトコードの重複コードの割合を算出 ※TypeScriptの部分のみの計測 一休.com/Yahoo!トラベル 3%

Slide 11

Slide 11 text

なぜコピペコードが生まれているのか 大きく3つに分類でき、それらを改善していった ● 汎用的なUI要素 ● 一休.com/Yahoo!トラベル特有のコンポーネント ● Vueの状態が絡んだロジック

Slide 12

Slide 12 text

対応と解決策 ● 汎用的な UI要素 ● 一休.com/Yahoo!トラベル特有のコンポーネント ● Vueの状態が絡んだロジック

Slide 13

Slide 13 text

汎用的なUI要素 サイト内で統一的な見た目を実現する、 HTMLの拡張のようなもの 例えばボタン、チップ、タブなど 一休.com/Yahoo!トラベル、PC/SP、各ページ間で同じデザインのものがコピペ、再発明されていた 検索する ほぼ同じHTML、CSS テキストや色を少し変えているだけ

Slide 14

Slide 14 text

Vueではコンポーネント化により使いまわしが可能 VueではHTML、CSS、JavaScriptを1つのコンポーネントとして実装できる 実装したコンポーネントを各所で使いまわすことで重複を排除できる
~~~
.wrapper { /* */ } .content { /* */ } export default { methods: { onClick() { goToHotel() } } }

Slide 15

Slide 15 text

当時から共通化の仕組みがあるのに、なぜコピペが発生したのか ● 実装フローの問題 ● UIコンポーネントを適切に切るノウハウ、思考がなかった ● ノウハウ、思考もないので UIコンポーネントがそろっていなかった 上記理由により積極的に UIコンポーネントを切ることをしない開発が常態化した

Slide 16

Slide 16 text

当時の実装フローの問題 ● 画面、特定機能を実装することに集中する形 ● サイト全体でどういったものが汎用的に使われる UIなのか、切り出して共通化すべきかを考えることがで きていなかった デザイナーがXDで1枚のページを作成 XDのモックをエンジニアに共有 エンジニアがモックを見て画面を実装

Slide 17

Slide 17 text

解決方法 この問題にデザインシステムを構築して対応 ● デザイン、開発のフローをコンポーネントを考慮したものに ● UIコンポーネントライブラリの実装 ● UIコンポーネント構築のノウハウを貯める

Slide 18

Slide 18 text

デザインシステム 標準の定義があるわけではないが ● デザインガイドライン ● デザインパターンライブラリ ● コンポーネントライブラリ(実装) からなるものが多い Design Systems―デジタルプロダクトのためのデザインシステム実践ガイド https://www.borndigital.co.jp/book/11908.html

Slide 19

Slide 19 text

デザインシステムを作る やったこと ● Figmaの導入 ● トンマナの洗い出し ● コンポーネントの洗い出し ● Figma上でコンポーネントの構築 ● コンポーネントのコード化 ● Storybookで一覧化 ● 開発フローの整備

Slide 20

Slide 20 text

デザインシステムを作る やったこと ● Figmaの導入 ● トンマナの洗い出し ● コンポーネントの洗い出し ● Figma上でコンポーネントの構築 ● コンポーネントのコード化 ● Storybookで一覧化 ● 開発フローの整備 デザインシステムはそれ1つで大きなトピック 今日は一部だけピックアップ

Slide 21

Slide 21 text

コンポーネントの洗い出し / Figma上でコンポーネント化 一休.com/Yahoo!トラベルのサイト全体を見て、サイト内でまとまりとなるものを洗い出し デザイン上どんなUIコンポーネントが存在し、どういった形で使われるかが明確になる

Slide 22

Slide 22 text

コンポーネントのコード化 Figmaで洗い出したコンポーネントを実装 デザインのまとまりを 1コンポーネントで表現 propsに応じていくつかのバリエーションで表示できるように 検索する propsに応じて見た目が変化 export default Vue.extends({ props: { theme: { type: String as PropType< 'primary' | 'secondary' | 'tertiary' | 'search' >, default: 'primary', }, 基本的に内部で状態を持たないように props、slot、emitで表現

Slide 23

Slide 23 text

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 type Story = StoryObj export default meta export const Primary: Story = { args: { theme: 'primary', default: 'Button', }, }

Slide 24

Slide 24 text

コンポーネントのディレクトリを整理

Slide 25

Slide 25 text

開発フローの整備 Figma、モックを共有 実装したUIコンポーネントを組 み立てて 画面を実装 デザイナーが Figmaで 1枚のページを作成 デザイナーとエンジニアで Figmaを 見てコンポーネントについて議論 コンポーネントをStorybookに カタログ化 土台が整ったので、最後に開発フローを整備 コンポーネントについて議論、開発するフェーズを追加 意識が、画面だけでなくコンポーネントにも焦点が当たるように

Slide 26

Slide 26 text

検索する 汎用的なUI要素 UIコンポーネントが実装され、開発フローも整備された これにより汎用的なUI要素の重複は解消 コンポーネントを使いまわしつつ、各 propsを変えるだけ

Slide 27

Slide 27 text

対応と解決策 ● 汎用的なUI要素 ● 一休.com/Yahoo!トラベル特有のコンポーネント ● Vueの状態が絡んだロジック

Slide 28

Slide 28 text

一休.com/Yahoo!トラベル特有のコンポーネント デザインシステムでは汎用的なものがカバーされた 次はサイト特有のコンポーネント 例えば料金や部屋プランなど

Slide 29

Slide 29 text

デザインの統一 デザイナーとエンジニアでコミュニケーションをとり統一を図る 一休.com/Yahoo!トラベルで明確に違うものにしたいわけではないのであればデザインを統一 既存のデザインから引き継がれたものや、なんとなくでデザインを分けていたものが多く、統一して問題ないも のが多かった

Slide 30

Slide 30 text

コンポーネントの分割 大枠だけ共通化して、細部だけ別のコンポーネントに 青枠がそれぞれ共通コンポーネント、赤枠だけ実行時の環境変数を見て差し替え
import AmountIkyu from '~/Amount.ikyu.vue' import AmountYahoo from '~/Amount.yahoo.vue' // 他処理… const activeTemplateAmount = computed(() => { // modeは環境変数から設定される if (mode === 'yahoo') { return AmountYahoo } return AmountIkyu })

Slide 31

Slide 31 text

一休.com/Yahoo!トラベル特有のコンポーネント デザインを統一することでコードを分ける必要がなくなった 分けざるを得ないところはコンポーネント分割により、共通化できる箇所を増やすことで、影響が小さくなった

Slide 32

Slide 32 text

対応と解決策 ● 汎用的なUI要素 ● 一休.com/Yahoo!トラベル特有のコンポーネント ● Vueの状態が絡んだロジック

Slide 33

Slide 33 text

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, } }, } }, })

Slide 34

Slide 34 text

なぜコピペが発生したのか 当時はVue2を使用、Vueの状態が絡んだロジックの良い共通化手法が提供されていなかった Vueの状態を使わない純粋な処理として切り出せるところも限界があった

Slide 35

Slide 35 text

当時はOptions API 当時のVueの標準的な記述方法 宣言的に状態、状態からなる処理を記述可能 変数の変化に応じて UIが更新されるように適切に各 グループ内で記述する必要がある export default Vue.extend({ data() { return { lastName: '', firstName: '', } }, computed: { fullName() { return `${this.lastName} ${this.firstName}` }, }, watch: { fullName() { showNotice('本名が変更されました') }, }, }) 持っている状態 状態を使って計算した値 値を監視し、変更に 応じて行う処理

Slide 36

Slide 36 text

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で どんな状態が追加されたかは分からない

Slide 37

Slide 37 text

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() 関数として複数コンポーネントで 呼び出し可能 明示的にどんな値を使うのかが分かる

Slide 38

Slide 38 text

Vue3/Nuxt3へのアップデート Composition APIを使うために、2023年2月にアップデート コピペ問題以外にも開発環境の改善、型の改善など良いことが多数 これでロジックのコピペ問題への対応準備ができた

Slide 39

Slide 39 text

一休.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

Slide 40

Slide 40 text

一休/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() 関数を参照 コンポーネントに書かれたロジックを関数として抽出、共通化

Slide 41

Slide 41 text

小さい責務で分ける 例えば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 }

Slide 42

Slide 42 text

小さい責務で分ける 例えばデータ取得の関数 一休では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, } }

Slide 43

Slide 43 text

まだロジックが綺麗ではない。もう一歩 … 共通化したはずの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行を超え、あり とあらゆる責務を持つように

Slide 44

Slide 44 text

コンポーネントが大きすぎる 例えばプランの詳細モーダルが巨大なコンポーネントになっ ていた HTML部分だけで1200行… コンポーネントが責務(機能)を持ちすぎている これを責務(機能)ごとに分ける 目安はざっくりHTML部分が300行以下に収める 共通化のためのコンポーネント切り出しではなく、責務わ けのためのコンポーネント切り出し

Slide 45

Slide 45 text

分割例 Header SearchCondition Calendar SearchCondition PeopleAndRoom IntroductionPlan IntroductionRoom AmountIkyu BookingButton AddComparison List RoomPlanSharing const props = defineProps<{ data: FragmentType | undefined | null bookableDateTime: | FragmentType | undefined | null }>() const { inventory } = useInventory() const { bookableDateTimeTo } = useBookableDateTime( toRefs(props).bookableDateTime, ) const { firstImages, plan, bookingAmount, room, } = useHeader(toRefs(props).data) そのコンポーネントに必要な関 数だけを呼び出して必要な返り 値だけしようすればよい 各コンポーネントの ロジックがシンプルに

Slide 46

Slide 46 text

ディレクトリ、ファイルを整理 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を切り、配置する 入れ子で同様の構造が 続く

Slide 47

Slide 47 text

Vueの状態が絡んだロジック Composition APIによりロジックが共通化された コンポーネント分割によって、共通化されたロジックも保守しやすいものに

Slide 48

Slide 48 text

まとめ 一休.com/Yahoo!トラベルのフロントエンド開発において発生したコピペ問題に対して 以下のように対応した ● 汎用的なUI要素 ○ UIコンポーネントの作成 ○ ワークフローの整備 ● 一休.com/Yahoo!トラベル特有のコンポーネント ○ デザインの統一 ○ コンポーネント分割 ● Vueの状態が絡んだロジック ○ Composition APIの活用 ○ コンポーネント分割

Slide 49

Slide 49 text

ご清聴ありがとうございました