はじめまして Nuxt3。 Nuxt3 にアップデートすることになったあなたへ。
みなさん、こんにちは。
Contrea 株式会社で業務委託の副業エンジニアとして MediOS の開発に携わっております櫻井と申します。
この度、業務で Nuxt2 のプロジェクトを Nuxt3 にアップデートするというミッションに携わった話について書きたいと思います。
私たちについて
本題に入る前に、私たち Contrea 株式会社について簡単に紹介いたします。
弊社では、医療者と患者さんをつなぐコミュニケーションプラットフォーム「MediOS」を提供しています。
主に、大きく2つのサービスから構成されており、
- 検査や病状、手術等に関する説明を動画で再現する 「MediOS 動画説明」
- 同意書をスマホやタブレットで取得、確認できる 「MediOS 電子同意書」
というサービスを提供しています。
Contrea 株式会社では、「医療に関わるすべての人に安心を。」 をミッションに患者さんの医療への安心はもちろん、現場を支えている医療従事者がより安心し、気持ちよく働ける環境を作っていくことを目指しております。
背景・目的
それでははじめに、なぜ私たちが Nuxt3 へのアップデートを決定したのか、その背景と目的について説明します。
背景
まず、このアップデートに至った背景は以下の通りです。
- Vue2 のサポートが2023年12月31日に終了する予定で、それに伴い Vue2 のみをサポートしている Nuxt2 のサポートも同時に終了。これにより、我々は Nuxt3 へのアップデートを早期に行う必要があった。(下記リンクを参照)
- プロダクトが初リリースされてから約3年が経過し、その間にプロダクトが肥大化。これにより、コンポーネントの共通化や型付けのリファクタリングに課題が生じていた。
- Nuxt3 への移行プロジェクトが始まった当初、Nuxt3 を production 環境で運用している企業はまだ多くはなかった。
Vue 2 will reach End of Life (EOL) on December 31st, 2023. After that date, Vue 2 will continue to be available in all existing distribution channels (CDNs and package managers), but will no longer receive updates, including security and browser compatibility fixes.
目的
これらの背景を踏まえて、私たちが Nuxt3 へのアップデートを決定した目的について述べていきます。
- Composion API による共通化や、Setup 関数を用いた TypeScript ファーストな書き方を利用し、開発効率を上げたかった。
- 私たち Contrea が他企業に比べ、ファーストペンギンとして最新のフレームワークを活用することにより、モダンな開発組織あるとアピールすることを目指した。
これらのの背景や目的を踏まえ、私たちは「今だ!」と判断しました。
移行の進め方
では、早速、どのように移行を進めたかについてお話します。
計画を立てる
Nuxt2 を Nuxt3 にアップデートするにあたって、事前に把握していた情報によれば、かなり破壊的な変更が入っているということはなんとなく知っていました。おそらく無計画で進めると、作業を進めている途中でやらないといけないことが際限なく発生したり、作業の依存関係によってスムーズに進められない可能性が考えられたからです。
そこで、そのような非効率的な作業にならないために、事前にやることやらないことを洗い出したり、事前に懸念となるようなことがないかをドキュメント(弊社ではドキュメントに Notion を使っていますので、以降は Notion とします。)にまとめて、ゴール(リリース)までの順序を検討して、定例会などの時間を使ってチーム内で共有したりしました。
実際に手を動かす前にやったことをまとめると、
情報を集める
事前に該当の技術やアップデートに関する情報収集をしました。公式ドキュメントの読み込みはもちろん(ただし、当時のドキュメントはまだ不完全なものであったため、記載されていないページもあり、Nuxt のコードを参照しながら作業を進めた箇所もありました)、他の開発者が同じ作業を行った際の経験を共有しているブログを探し、それらを Notion にブックマークしました。これにより、チームメンバー全員が必要な情報に迅速にアクセスできるようになりました。
作業手順を決める
作業手順を決めて、その中でタスク間の依存関係を明らかにすることで、作業の効率化を目指しました。さらに、各タスクの優先度を設定し、どのタスクを先に行うべきか、そしてどのタスクが遅れても問題ないかを洗い出しました。これにより、チーム全体が作業の流れを理解しやすくなりました。
また、前述の通り、我々の組織には複数の関連プロダクトが存在します。したがって、今回のアップデート作業は、後続の同様の作業を行う際の有益な参考資料として活用する予定です。
懸念事項
事前に既知の問題や懸念点を明記し、必要な検証を優先的に行って以降の作業に影響が出ないように計画します。しかし、完璧を求めるあまりに時間をかけすぎるのも避けるべきだと思います。実際に、本ブログの後半部分では、作業終盤に予想していなかった問題が発生した事例を共有します。
前もってチーム内で懸念事項を共有しておくことで、早めに解決案が出たり、思いの外懸念事項が増えるのであれば、他のメンバーにヘルプに依頼することも可能です。
それでは、実際にやってことについてお話していきます。
Nuxt Bridge へアップデート
まず、Nuxt3 へのアップデートを行う前に、Nuxt Bridge にアップデートしました。Nuxt Bridge は、Nuxt2 の環境で Nuxt3 の機能を利用できる便利なツールです。Nuxt2 から一気にNuxt3 へのアップデートには多くの破壊的変更が含まれており、かなりの労力が必要になることが予想されました。また、公式では段階的な移行を推奨していたため、この方法を選択しました。
Nuxt Bridge の段階では、書き換えが可能な部分を順次対応していくことにしました。そのため、setup
や ref
を使用するように変更するといったことは、一旦保留しておくことに決定しました。
参考までに、Nuxt2、Nuxt Bridge、そしてNuxt3の各バージョンについて、機能の観点から比較した表を示します。
ライブラリをアップデートする
Nuxt Bridge へは、package.json で設定する Nuxt Bridge のライブラリのバージョンを変更します。dependencies
に "nuxt-edge": "^2.16.0-273xxxx"
と、devDependencies
に "@nuxt/bridge": "^0.10.1"
をそれぞれ追加します。
"dependencies": {
"nuxt-edge": "^2.16.0-273xxxx",
}
"devDependencies": {
"@nuxt/bridge": "^0.10.1"
}
ここで一旦、Nuxt Bridge で問題なく動作することを確認し、最初のリリースを行いました。
defineComponent
に切り替え
続いて、Nuxt Bridge の状態で対応したことについて話してきます。
(但し、本ブログは Nuxt3 へのアップデートがテーマなのでここでは軽く触れる程度にします。また、当時の情報とだいぶ差分がある可能性がありますので、最新の情報は最新のドキュメントをご覧ください。)
まず、全ての Vue.extend
を defineComponent
に置き換えていきます。なお、Composition API は Options API と一緒に使うことができます。
import Vue from 'vue'
も不要になるので同時に削除します。
- import Vue from 'vue'
- export default Vue.extend({
+ export default defineComponent({
useState
へ切り替え
これまでのデータ管理では、Vuexを使用していましたが、Nuxt3からはVuexがデフォルトでは含まれていないため、代わりにuseStateを用いた管理方法へと切り替えを行います。
Vuex で状態管理していたときのコードは以下のような感じです。
一部を抜粋した形ですが、このように state の定義と、setter と getter がそれぞれ定義してあります。
export const state = () => ({
hospital: {} as StateHospital,
:
})
export type RootState = ReturnType<typeof state>;
export const mutations: MutationTree<RootState> = {
setHospital (state, payload: StateHospital) {
state.hospital = payload
},
:
}
export const actions: ActionTree<RootState, RootState> = {
setHospital ({ commit }, payload: StateHospital) {
commit('setHospital', payload)
},
:
}
export const getters: GetterTree<RootState, RootState> = {
getHospital (state): StateHospital {
return state.hospital
},
:
}
参照する場合は以下のようにします。
const { $store } = useNuxtApp()
const userId = $store.getters.getUserId
そして、上記の Vuex を useState を使う形に書き換えます。
例えば、Hospital の状態を定義したいとした場合の例です。
const hospital = useState<Hospital>('useCommonState_hospital', () => (
{
id: '',
code: '',
name: '',
doctor: ''
}
))
さらに、useCommonState.ts
というファイルを作成し、composables
ディレクトリの中に配置します。その後、useCommonState.ts
内で以下のように実装することで、Vue コンポーネント内で const { hospital } = useCommonState()
のように記述して state の状態を取得することができます。これを Vuex で定義されている箇所に対しても、書き換えを行います。
export const useCommonState = () => {
const hospital = useState<Hospital>('useCommonState_hospital', () => (
{
id: '',
code: '',
name: '',
doctor: ''
}
))
return {
hospital,
:
}
}
const { hospital } = useCommonState()
:
const hospital = hospital.value
ここで注意が必要な点は、useState の第一引数で string を渡しているところですが、これは全体で一意である必要があります。そのため、私たちは ファイル名_state名
のような命名規則を設定し、重複がないようにしています。
@param key a unique key ensuring that data fetching can be properly de-duplicated across requests
import type { Ref } from 'vue';
/**
* Create a global reactive ref that will be hydrated but not shared across ssr requests
*
* @param key a unique key ensuring that data fetching can be properly de-duplicated across requests
* @param init a function that provides initial value for the state when it's not initiated
*/
export declare function useState<T>(key?: string, init?: (() => T | Ref<T>)): Ref<T>;
export declare function useState<T>(init?: (() => T | Ref<T>)): Ref<T>;
ひとまず一旦この時点でリリースしました。お疲れさまでした。
Nuxt3 へのアップデート
次に、Nuxt3へのアップデートに向けて検討を始めます。
middleware の書き換え
まず、middleware を修正します。
Nuxt3 環境で middleware を読み込むには defineNuxtRouteMiddleware
を使用します。使い方は以下のようにします。
例えば、画面ごとに middleware を読み込むようにしてログインユーザーを取得して、ユーザーが取得できなければログイン画面に遷移する、みたいなことができるかもしれません。
import { useAuthentication } from '/path/to/useAuthentication'
export default defineNuxtRouteMiddleware(async () => {
const { currentUser } = useAuthentication()
:
})
<script setup lang="ts">
definePageMeta({
middleware: 'authentication',
})
</script>
<template>
:
</template>
vee-validate 対応
(可能な範囲で具体的なコードを乗せる)
次に大きい対応としては、vee-validate
の対応です。Vue3 では vee-dalidate4 の対応が必要です。
まず、package.json を新しいバージョンにします。作業時点では 4.6.9
が最新でしたのでこちらに変更します。また、バリデーションルールは @vee-validate/rules
が便利なのでこちらも追加します。localize が必要な場合は @vee-validate/i18n
も入れると良いでしょう。
+ "@vee-validate/i18n": "^4.7.3",
+ "@vee-validate/rules": "^4.7.3",
- "vee-validate": "^3.4.5",
+ "vee-validate": "^4.6.9",
そして、plugins に以下を設定します。
import { defineRule, configure } from 'vee-validate'
import { localize, setLocale } from '@vee-validate/i18n'
import ja from '@vee-validate/i18n/dist/locale/ja.json'
import { required, numeric, email, max, min, alpha_num } from '@vee-validate/rules'
export default defineNuxtPlugin((_nuxtApp) => {
configure({
generateMessage: localize({
ja
})
})
defineRule('required', required)
defineRule('numeric', numeric)
defineRule('alpha_num', alpha_num)
defineRule('max', max)
defineRule('min', min)
defineRule('email', email)
setLocale('ja')
})
そして、 import ja from '@vee-validate/i18n/dist/locale/ja.json'
の json の中身を見てみると、このような感じで各言語でメッセージが用意されているので特にこだわりや特殊なメッセージを必要としない場合はとても便利です。
{
"code": "ja",
"messages": {
"_default": "{field}は有効な値ではありません",
"alpha": "{field}はアルファベットのみ使用できます",
"alpha_num": "{field}は英数字のみ使用できます",
"alpha_dash": "{field}は英数字とハイフン、アンダースコアのみ使用できます",
"alpha_spaces": "{field}はアルファベットと空白のみ使用できます",
"between": "{field}は 0:{min} から 1:{max} の間でなければなりません",
"confirmed": "{field}が一致しません",
"digits": "{field}は 0:{length}桁の数字でなければなりません",
"dimensions": "{field}は幅 0:{width}px、高さ 1:{height}px 以内でなければなりません",
"email": "{field}は有効なメールアドレスではありません",
:
"min_value": "{field}は 0:{min} 以上でなければなりません",
"min": "{field}は 0:{length} 文字以上でなければなりません",
"numeric": "{field}は数字のみ使用できます",
"one_of": "{field}は有効な値ではありません",
"regex": "{field}のフォーマットが正しくありません",
"required": "{field}は必須項目です",
"required_if": "{field}は必須項目です",
"size": "{field}は 0:{size}KB 以内でなければなりません",
"url": "{field}は有効なURLではありません"
}
}
これをコンポーネントで使うためには、vee-validate
から Field
コンポーネントを import して各 props を渡してあげます。それぞれ、
-
v-slot="{ field, errors }"
は、スロットスコープを使用して、フィールドの状態とバリデーションエラーオブジェクトerrors
を取得しています。 -
:name="説明"
は、入力フィールドにname
属性を設定します。 -
:rules="required|min:200"
は、この入力フィールドに適用するバリデーションルールを定義しています。required
はフィールドが必須であることを示し、min:200
はフィールドの最小値が 200 であることを示します。 -
<textarea>
コンポーネントでは、上記のfield
をバインドしています。 -
<p>
コンポーネントでは、同じくerros
のメッセージをテキストとして表示します。
これらのルールに従わない場合、バリデーションエラーが発生します。
import { Field } from 'vee-validate'
<template>
<Field
v-slot="{ field, errors }"
v-model="value"
name="説明"
:rules="required|min:200"
>
<textarea
v-bind="field"
v-model="value"
:disabled="disabled"
name="説明"
:placeholder="placeholder"
/>
<p :text="errors[0]" />
</Field>
</template>
以上が vee-validate 4 の使い方です。
ここで、さらっと defineNuxtPlugin
というものが出てきましたが、これも Nuxt3 で plugin を使う場合に書き換える必要があります。plugin は Nuxt3 のアプリケーションを初期化時に自動的に実行してくれる便利な機能です。グローバルで処理が必要なものはここで定義しておくとよいでしょう。
これまでですと、これらの設定を nuxt.config.ts
に記述されていたと思いますが、Nuxt3 では不要となりますので、当該箇所は削除してしまいます。ただし、suffix として、サーバーサイドの場合は、.server
、クライアントの場合は client
を付けましょう。
export default defineNuxtPlugin((NuxtApp) => {
const foo = useFoo()
})
- plugins: [
- '~/plugins/vee-validate.js'
Nuxt automatically reads the files in your plugins directory and loads them at the creation of the Vue application. You can use .server or .client suffix in the file name to load a plugin only on the server or client side.
動画が再生できない問題
Nuxt3 で video.js が再生できない
Nuxt3 へのアップデートの終盤、動画の再生のテストを行っていたところ、Nuxt3 環境で video.js によるストリーミング再生ができない問題が発生しました。console や network など色々調べてみたのですが、特にエラー等が出ているわけでもなく、根本的な原因を見つけることができませんでした。動画の再生は、弊社プロダクトの根幹に関わる機能なので、何とか代替方法を見つける必要がありました。
その過程で、いくつか代替案が出された中で、hls.js が良いのではという意見が挙がりました。試しにプロトタイプで試したところ、弊社がサポートしているブラウザで動画を再生することが確認できました。
詳しくは下記別途ブロクに詳細を記しましたので、ご興味のある方はご覧いただければ幸いです。
良かったとことと改善点
最後に、これまでのアップデート作業を通して良かったことと、もう少し改善できたことを挙げてみます。
まず良かった点としては、
プロジェクトの方針が明確になる
Notion にドキュメントをまとめたことで、何をどのように進めるべきかが明確になったことは良かったと思いました。また、自身のタスクだけではなく、チーム全体にタスクを共有もできることで、メンバーが作業の内容を理解しやすくようになったり、引き継ぎや協力を仰ぎやすくなる点も良い点だと思います。今後同じような作業を行う際に大きな役割を果たすのではないかと思います。
新しい技術に取り組むことができた
Nuxt3 や Composition API、その関連するライブラリに触れて、既存の技術の棚卸しをしつつ新しい技術を学んでドキュメントにまとめる過程は、改めて自身の知識を整理することの手助けとなり、その技術について理解することに役立ちました。また、上記でも述べたような想定していなかった問題に直面して解決したことも、私自身これまであまり触れてこなかった動画ストリーミングのような技術にも取り組むことができたのは良かったです。
次に改善点です。
時間的制約
本プロジェクトは業務委託という形で進められました。これは稼働時間が限られるという条件の下で行われ、その結果、やや長めのプロジェクトスケジュールになってしまいました。もしフルタイムのメンバーでこのプロジェクトを進めることができたら、もしかしたらより短期間での完成が可能だったかもしれません。これは、プロジェクトスケジュールの組み方やリソース配分に関して今後改善できる点となると思います。
並行する作業による課題
また、このプロジェクトでは、画面リニューアルが Nuxt3 への移行作業と同時並行で進行していました。これによって、Nuxt3 と同じ箇所に変更が入るたびに高頻度でコンフリクトが発生し、その解決に時間を要するという問題がありました。
そこで、Nuxt2 でも動作する機能を先行して開発して develop へマージし、その後、別プロジェクトのブランチに取り込んで別プロジェクトでも最新の状態で開発を進められるようにしました。
しかし、マージルールの設定やマージ情報の共有については、もう少し改善ができたかもしれません。Nuxt3 への移行に伴い、どの部分に改修が入るかを適宜共有することで、コンフリクトを回避できる可能性もありましたが、勤務体系の違いなどでお互いの稼働時間がずれる場合だと少し難しさも感じました。
この点については、チームやプロジェクトの規模によって最適解はそれぞれだと思います。今回採ったアプローチが必ずしも最善の手段だったとは断言できませんが、私たちは引き続き Nuxt3 を使って更に改善をしていきたいと思っています。
ある程度大きくなったプロダクトを Nuxt3 にアップデートすることは一筋縄ではいかないこともあるかと思いますが、本記事が少しでもお役にたてば幸いです!
おわりに
最後までご覧いただきありがとうございました。
弊社では最新の技術を積極的にプロダクトに取り入れ、より良いプロダクトを世の中に届けられるよう一緒に挑戦していくエンジニアを募集しています。
「医療に関わるすべての人に安心を。」
という我々のミッションに共感いただける方がいらっしゃいましたら、ぜひ一度カジュアル面談にお越しください。フレンドリーな雰囲気の中で、お互いのビジョンや価値観を共有できる場です。ご関心のある方は、どうぞお気軽にエントリーください。心よりお待ちしております!
Discussion