Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする
Search
igayamaguchi
October 19, 2024
Technology
6.4k
3
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする
Vue Fes Japan 2024 登壇資料
igayamaguchi
October 19, 2024
More Decks by igayamaguchi
See All by igayamaguchi
一休/Yahooトラベル、マルチブランドにまたがるデザインシステム
igayamaguchi
1
660
VeeValidate 3
igayamaguchi
3
640
==と===を調べてみた
igayamaguchi
0
530
Other Decks in Technology
See All in Technology
現場のトークンマネジメント
dak2
1
190
AIペネトレーションテスト・ セキュリティ検証「AgenticSec」紹介資料
laysakura
2
7.5k
レガシーな広告配信システムでのAI駆動開発/運用の挑戦
i16fujimoto
0
120
螺旋型キャリアの生存戦略 / kinoko-conf2026
rakus_dev
1
1k
LayerX コーポレートエンジニアリング室におけるサプライチェーンセキュリティへの取り組み / Supply Chain Security at LayerX Corporate Engineering
yuyatakeyama
3
840
起点・思考・出力で分解する 〜PM業務の自動化設計〜
kazu_kichi_67
1
1.1k
From Prompt Engineering to Loop Engineering
shibuiwilliam
1
230
データレイクの「見えない問題」を可視化する
sansantech
PRO
1
200
IaC コードを資産へ:AWS CDK 社内ライブラリと横断展開 / aws-summit-japan-2026
gotok365
10
1.6k
5分でわかるDuckDB Quack
chanyou0311
3
250
AWS Security Agent といっしょに脅威モデリングをやってみよう
amarelo_n24
1
210
MUSUBI 田中裕一『AIと共に行う「しごとのリデザイン」- スモールバックオフィス編』AI Ops Lab #4
musubi
0
320
Featured
See All Featured
Un-Boring Meetings
codingconduct
0
320
Heart Work Chapter 1 - Part 1
lfama
PRO
7
36k
Why Your Marketing Sucks and What You Can Do About It - Sophie Logan
marketingsoph
0
170
GraphQLとの向き合い方2022年版
quramy
50
15k
Fantastic passwords and where to find them - at NoRuKo
philnash
52
3.7k
How to train your dragon (web standard)
notwaldorf
97
6.7k
How to Create Impact in a Changing Tech Landscape [PerfNow 2023]
tammyeverts
55
3.4k
JavaScript: Past, Present, and Future - NDC Porto 2020
reverentgeek
52
6k
WENDY [Excerpt]
tessaabrams
11
38k
B2B Lead Gen: Tactics, Traps & Triumph
marketingsoph
0
160
The Hidden Cost of Media on the Web [PixelPalooza 2025]
tammyeverts
2
330
The Psychology of Web Performance [Beyond Tellerrand 2023]
tammyeverts
49
3.5k
Transcript
Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする 2024/10/19 Vue Fes Japan 2024 株式会社一休 CTO室 いがにん
(山口将希)
いがにんこと山口将希 Twitter: @igayamaguchi 一休のCTO室で一休.com/Yahoo!トラベルの フロントエンド設計の改善をしています 業務でVue.js/Nuxtをずっと触ってます 自己紹介
概要 • 株式会社一休では一休 .com/Yahoo!トラベルを運営 • Nuxtを使ってフロントエンドを実装 • 開発をしていく中で、大量のコピペコードが存在するように • 問題発生の背景とその課題にどう対処してきたかを話す
アジェンダ • サービスの概要 • 問題の概要 • 原因別の対応と解決策 ◦ 汎用的なUI要素 ◦
一休.com/Yahoo!トラベル特有のコンポーネント ◦ Vueの状態が絡んだロジック
運営サービスの概要 • 一休では2つの宿泊予約サイトを運営 ◦ 一休.com ◦ Yahoo!トラベル (2021年に統合) • フロントエンドをNuxtで実装
• 両サイトは1ソースで実現されており、ビルド時の環境 変数に応じて振る舞いが切り替わる • PC/SPはほとんど別デザイン ビルド時に環境変数で 切り替え
一休.com/Yahoo!トラベルのフロントエンド 一休.com/Yahoo!トラベルの ソースコードを統合 Nuxt v2の導入 Vue.jsを導入 当時はVue2でWebpackでの 自前のビルド 一休.comはもともとjQueryと素 のJavaScriptで書かれていた
2017年12月 2020年4月 2021年10月
何かを変更するときにやけに手数が必要 2022年ごろのこと 当時はVue2/Nuxt2で開発 フロントエンドフレームワークを使用することでリッチにフロントエンドを開発 全体としては開発効率が大きく上がっていた しかし、何かを変更するときにやけに手数が必要で時間がかかると思うことが増えた
コードを紐解くと大量のコピペコードが存在していた 大量のコピペコードが存在していた 300行のうち差分は4行しかな いコンポーネント ほぼ同じ料金表示だが別実装 同じUIの再実装 HTML、CSSが重複している
重複コードの割合 一休の他プロジェクト SonarCloudを使用してプロダクトコードの重複コードの割合を算出 ※TypeScriptの部分のみの計測 一休.com/Yahoo!トラベル 3%
重複コードの割合 20 % 一休の他プロジェクト SonarCloudを使用してプロダクトコードの重複コードの割合を算出 ※TypeScriptの部分のみの計測 一休.com/Yahoo!トラベル 3%
なぜコピペコードが生まれているのか 大きく3つに分類でき、それらを改善していった • 汎用的なUI要素 • 一休.com/Yahoo!トラベル特有のコンポーネント • Vueの状態が絡んだロジック
対応と解決策 • 汎用的な UI要素 • 一休.com/Yahoo!トラベル特有のコンポーネント • Vueの状態が絡んだロジック
汎用的なUI要素 サイト内で統一的な見た目を実現する、 HTMLの拡張のようなもの 例えばボタン、チップ、タブなど 一休.com/Yahoo!トラベル、PC/SP、各ページ間で同じデザインのものがコピペ、再発明されていた <button class=" bg-brand-gradient-p text-white rounded-md
… " type="button" > 検索する </button> ほぼ同じHTML、CSS テキストや色を少し変えているだけ
Vueではコンポーネント化により使いまわしが可能 VueではHTML、CSS、JavaScriptを1つのコンポーネントとして実装できる 実装したコンポーネントを各所で使いまわすことで重複を排除できる <template> <div :class="$style.wrapper" @click="onClick"> <div :class="$style.content">~~~</div> </div>
</template> <style module> .wrapper { /* */ } .content { /* */ } </style> <script> export default { methods: { onClick() { goToHotel() } } } </script>
当時から共通化の仕組みがあるのに、なぜコピペが発生したのか • 実装フローの問題 • UIコンポーネントを適切に切るノウハウ、思考がなかった • ノウハウ、思考もないので UIコンポーネントがそろっていなかった 上記理由により積極的に UIコンポーネントを切ることをしない開発が常態化した
当時の実装フローの問題 • 画面、特定機能を実装することに集中する形 • サイト全体でどういったものが汎用的に使われる UIなのか、切り出して共通化すべきかを考えることがで きていなかった デザイナーがXDで1枚のページを作成 XDのモックをエンジニアに共有 エンジニアがモックを見て画面を実装
解決方法 この問題にデザインシステムを構築して対応 • デザイン、開発のフローをコンポーネントを考慮したものに • UIコンポーネントライブラリの実装 • UIコンポーネント構築のノウハウを貯める
デザインシステム 標準の定義があるわけではないが • デザインガイドライン • デザインパターンライブラリ • コンポーネントライブラリ(実装) からなるものが多い Design
Systems―デジタルプロダクトのためのデザインシステム実践ガイド https://www.borndigital.co.jp/book/11908.html
デザインシステムを作る やったこと • Figmaの導入 • トンマナの洗い出し • コンポーネントの洗い出し • Figma上でコンポーネントの構築
• コンポーネントのコード化 • Storybookで一覧化 • 開発フローの整備
デザインシステムを作る やったこと • Figmaの導入 • トンマナの洗い出し • コンポーネントの洗い出し • Figma上でコンポーネントの構築
• コンポーネントのコード化 • Storybookで一覧化 • 開発フローの整備 デザインシステムはそれ1つで大きなトピック 今日は一部だけピックアップ
コンポーネントの洗い出し / Figma上でコンポーネント化 一休.com/Yahoo!トラベルのサイト全体を見て、サイト内でまとまりとなるものを洗い出し デザイン上どんなUIコンポーネントが存在し、どういった形で使われるかが明確になる
コンポーネントのコード化 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で表現
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', }, }
コンポーネントのディレクトリを整理
開発フローの整備 Figma、モックを共有 実装したUIコンポーネントを組 み立てて 画面を実装 デザイナーが Figmaで 1枚のページを作成 デザイナーとエンジニアで Figmaを
見てコンポーネントについて議論 コンポーネントをStorybookに カタログ化 土台が整ったので、最後に開発フローを整備 コンポーネントについて議論、開発するフェーズを追加 意識が、画面だけでなくコンポーネントにも焦点が当たるように
<BrandButton theme="search" size="large" > 検索する </button> 汎用的なUI要素 UIコンポーネントが実装され、開発フローも整備された これにより汎用的なUI要素の重複は解消 コンポーネントを使いまわしつつ、各
propsを変えるだけ
対応と解決策 • 汎用的なUI要素 • 一休.com/Yahoo!トラベル特有のコンポーネント • Vueの状態が絡んだロジック
一休.com/Yahoo!トラベル特有のコンポーネント デザインシステムでは汎用的なものがカバーされた 次はサイト特有のコンポーネント 例えば料金や部屋プランなど
デザインの統一 デザイナーとエンジニアでコミュニケーションをとり統一を図る 一休.com/Yahoo!トラベルで明確に違うものにしたいわけではないのであればデザインを統一 既存のデザインから引き継がれたものや、なんとなくでデザインを分けていたものが多く、統一して問題ないも のが多かった
コンポーネントの分割 大枠だけ共通化して、細部だけ別のコンポーネントに 青枠がそれぞれ共通コンポーネント、赤枠だけ実行時の環境変数を見て差し替え <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>
一休.com/Yahoo!トラベル特有のコンポーネント デザインを統一することでコードを分ける必要がなくなった 分けざるを得ないところはコンポーネント分割により、共通化できる箇所を増やすことで、影響が小さくなった
対応と解決策 • 汎用的なUI要素 • 一休.com/Yahoo!トラベル特有のコンポーネント • Vueの状態が絡んだロジック
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, } }, } }, })
なぜコピペが発生したのか 当時はVue2を使用、Vueの状態が絡んだロジックの良い共通化手法が提供されていなかった Vueの状態を使わない純粋な処理として切り出せるところも限界があった
当時はOptions API 当時のVueの標準的な記述方法 宣言的に状態、状態からなる処理を記述可能 変数の変化に応じて UIが更新されるように適切に各 グループ内で記述する必要がある export default Vue.extend({
data() { return { lastName: '', firstName: '', } }, computed: { fullName() { return `${this.lastName} ${this.firstName}` }, }, watch: { fullName() { showNotice('本名が変更されました') }, }, }) 持っている状態 状態を使って計算した値 値を監視し、変更に 応じて行う処理
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で どんな状態が追加されたかは分からない
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() 関数として複数コンポーネントで 呼び出し可能 明示的にどんな値を使うのかが分かる
Vue3/Nuxt3へのアップデート Composition APIを使うために、2023年2月にアップデート コピペ問題以外にも開発環境の改善、型の改善など良いことが多数 これでロジックのコピペ問題への対応準備ができた
一休.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
一休/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() 関数を参照 コンポーネントに書かれたロジックを関数として抽出、共通化
小さい責務で分ける 例えば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 }
小さい責務で分ける 例えばデータ取得の関数 一休では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, } }
まだロジックが綺麗ではない。もう一歩 … 共通化したはずの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行を超え、あり とあらゆる責務を持つように
コンポーネントが大きすぎる 例えばプランの詳細モーダルが巨大なコンポーネントになっ ていた HTML部分だけで1200行… コンポーネントが責務(機能)を持ちすぎている これを責務(機能)ごとに分ける 目安はざっくりHTML部分が300行以下に収める 共通化のためのコンポーネント切り出しではなく、責務わ けのためのコンポーネント切り出し
分割例 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) そのコンポーネントに必要な関 数だけを呼び出して必要な返り 値だけしようすればよい 各コンポーネントの ロジックがシンプルに
ディレクトリ、ファイルを整理 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を切り、配置する 入れ子で同様の構造が 続く
Vueの状態が絡んだロジック Composition APIによりロジックが共通化された コンポーネント分割によって、共通化されたロジックも保守しやすいものに
まとめ 一休.com/Yahoo!トラベルのフロントエンド開発において発生したコピペ問題に対して 以下のように対応した • 汎用的なUI要素 ◦ UIコンポーネントの作成 ◦ ワークフローの整備 •
一休.com/Yahoo!トラベル特有のコンポーネント ◦ デザインの統一 ◦ コンポーネント分割 • Vueの状態が絡んだロジック ◦ Composition APIの活用 ◦ コンポーネント分割
ご清聴ありがとうございました