Vue3での共有状態の管理
公開: 2022-03-10状態は難しい場合があります。 単純なVueプロジェクトを開始するとき、特定のコンポーネントで作業状態を維持するのは簡単です。
setup() { let books: Work[] = reactive([]); onMounted(async () => { // Call the API const response = await bookService.getScienceBooks(); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } }); return { books }; },
プロジェクトがデータを表示する単一のページである場合(おそらくデータを並べ替えたりフィルタリングしたりするため)、これは説得力のあるものになる可能性があります。 ただし、この場合、このコンポーネントはすべてのリクエストでデータを取得します。 あなたがそれを維持したい場合はどうなりますか? そこで、状態管理が機能します。 ネットワーク接続は高価であることが多く、信頼性が低い場合もあるため、アプリケーション内を移動するときは、この状態を維持することをお勧めします。
もう1つの問題は、コンポーネント間の通信です。 イベントや小道具を使用して直接の子親と通信することはできますが、各ビュー/ページが独立している場合、エラー処理やビジーフラグなどの単純な状況の処理は難しい場合があります。 たとえば、エラーと読み込み中のアニメーションを表示するためにトップレベルのコントロールが配線されていると想像してください。
// App.vue <template> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert" v-if="error">{{ error }}</div> <div class="alert bg-gray-200 text-gray-900" v-if="isBusy"> Loading... </div> <router-view :key="$route.fullPath"></router-view> </div> </template>
この状態を処理する効果的な方法がない場合は、パブリッシュ/サブスクライブシステムが提案される可能性がありますが、実際には、データの共有は多くの場合、より簡単です。 状態を共有したい場合、どのように対処しますか? これを行うためのいくつかの一般的な方法を見てみましょう。
注:このセクションのコードは、GitHubのサンプルプロジェクトの「メイン」ブランチにあります。
Vue3の共有状態
Vue 3に移行してから、CompositionAPIの使用に完全に移行しました。 この記事では、TypeScriptも使用していますが、これは、ここで紹介する例では必須ではありません。 状態は好きなように共有できますが、最も一般的に使用されるパターンを見つけるいくつかのテクニックを紹介します。 それぞれに長所と短所があるので、ここで私が話していることをドグマと見なさないでください。
テクニックは次のとおりです。
- 工場、
- 共有シングルトン、
- Vuex 4、
- Vuex5。
注: Vuex 5は、この記事の執筆時点ではRFC(Request for Comments)の段階にあるため、Vuexの準備を整えたいと思いますが、現時点では、このオプションの有効なバージョンはありません。
掘り下げましょう…
工場
注:このセクションのコードは、GitHubのサンプルプロジェクトの「Factories」ブランチにあります。
ファクトリパターンは、関心のある状態のインスタンスを作成することです。 このパターンでは、CompositionAPIのstart関数によく似た関数を返します。 スコープを作成し、探しているもののコンポーネントを構築します。 例えば:
export default function () { const books: Work[] = reactive([]); async function loadBooks(val: string) { const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books }; }
工場で作成されたオブジェクトの必要な部分だけを次のように要求できます。
// In Home.vue const { books, loadBooks } = BookFactory();
ネットワークリクエストが発生したときに表示するisBusy
フラグを追加した場合、上記のコードは変更されませんが、 isBusy
を表示する場所を決定できます。
export default function () { const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } return { loadBooks, books, isBusy }; }
別のビュー(vue?)では、ファクトリの残りの部分がどのように機能するかを知らなくても、isBusyフラグを要求できます。
// App.vue export default defineComponent({ setup() { const { isBusy } = BookFactory(); return { isBusy } }, })
しかし、あなたは問題に気づいたかもしれません。 ファクトリを呼び出すたびに、すべてのオブジェクトの新しいインスタンスを取得します。 ファクトリに新しいインスタンスを返してもらいたい場合がありますが、この場合は状態の共有について話しているので、クリエーションをファクトリの外に移動する必要があります。
const books: Work[] = reactive([]); const isBusy = ref(false); async function loadBooks(val: string) { isBusy.value = true; const response = await bookService.getBooks(val, currentPage.value); if (response.status === 200) { books.splice(0, books.length, ...response.data.works); } } export default function () { return { loadBooks, books, isBusy }; }
現在、ファクトリは共有インスタンス、または必要に応じてシングルトンを提供しています。 このパターンは機能しますが、毎回新しいインスタンスを作成しない関数を返すと混乱する可能性があります。
基になるオブジェクトはconst
としてマークされているため、それらを置き換えることはできません(そしてシングルトンの性質を壊すことはできません)。 したがって、このコードは文句を言う必要があります。
// In Home.vue const { books, loadBooks } = BookFactory(); books = []; // Error, books is defined as const
したがって、可変状態を更新できることを確認することが重要になる場合があります(たとえば、本を割り当てる代わりにbooks.splice()
を使用します)。
これを処理する別の方法は、共有インスタンスを使用することです。
共有インスタンス
このセクションのコードは、GitHubのサンプルプロジェクトの「SharedState」ブランチにあります。
状態を共有する場合は、状態がシングルトンであるという事実についても明確になっている可能性があります。 この場合、静的オブジェクトとしてインポートするだけです。 たとえば、リアクティブオブジェクトとしてインポートできるオブジェクトを作成したいと思います。
export default reactive({ books: new Array<Work>(), isBusy: false, async loadBooks() { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books.splice(0, this.books.length, ...response.data.works); } this.isBusy = false; } });
この場合、オブジェクト(この例ではストアと呼んでいます)をインポートするだけです。
// Home.vue import state from "@/state"; export default defineComponent({ setup() { // ... onMounted(async () => { if (state.books.length === 0) state.loadBooks(); }); return { state, bookTopics, }; }, });
次に、状態にバインドするのが簡単になります。
<!-- Home.vue --> <div class="grid grid-cols-4"> <div v-for="book in state.books" :key="book.key" class="border bg-white border-grey-500 m-1 p-1" > <router-link :to="{ name: 'book', params: { id: book.key } }"> <BookInfo :book="book" /> </router-link> </div>
他のパターンと同様に、このインスタンスをビュー間で共有できるという利点があります。
// App.vue import state from "@/state"; export default defineComponent({ setup() { return { state }; }, })
次に、これは同じオブジェクトにバインドできます( Home.vue
の親であるか、ルーターの別のページであるかは関係ありません)。
<!-- App.vue --> <div class="container mx-auto bg-gray-100 p-1"> <router-link to="/"><h1>Bookcase</h1></router-link> <div class="alert bg-gray-200 text-gray-900" v-if="state.isBusy">Loading...</div> <router-view :key="$route.fullPath"></router-view> </div>
ファクトリパターンと共有インスタンスのどちらを使用する場合でも、どちらにも共通の問題があります。それは、可変状態です。 バインディングやコードが望まないときに状態が変化するという偶発的な副作用が発生する可能性があります。 私がここで使用しているような些細な例では、心配するほど複雑ではありません。 しかし、ますます大きなアプリを構築しているので、状態の変化についてより慎重に考える必要があります。 そこで、Vuexが救いの手を差し伸べることができます。
Vuex 4
このセクションのコードは、GitHubのサンプルプロジェクトの「Vuex4」ブランチにあります。
VuexはVueの状態マネージャーです。 別のプロジェクトとして管理されていますが、コアチームによって構築されました。 Vuexの目的は、状態を、状態に対して実行するアクションから分離することです。 すべての状態変化はVuexを経由する必要があります。つまり、より複雑ですが、偶発的な状態変化から保護されます。
Vuexのアイデアは、状態管理の予測可能なフローを提供することです。 ビューはアクションに流れ、アクションはミューテーションを使用して状態を変更し、アクションはビューを更新します。 状態変化の流れを制限することで、アプリケーションの状態を変化させる副作用を減らすことができます。 したがって、より大きなアプリケーションを簡単に構築できます。 Vuexには学習曲線がありますが、その複雑さにより、予測可能性が得られます。
さらに、Vuexは、タイムトラベルと呼ばれる機能を含む状態管理と連携するための開発時ツール(Vueツールを介して)をサポートしています。 これにより、状態の履歴を表示し、前後に移動して、状態がアプリケーションにどのように影響するかを確認できます。
Vuexも重要な場合があります。
Vue 3プロジェクトに追加するには、次のいずれかのパッケージをプロジェクトに追加します。
> npm i vuex
または、VueCLIを使用して追加することもできます。
> vue add vuex
CLIを使用すると、Vuexストアの開始点が作成されます。それ以外の場合は、CLIをプロジェクトに手動で接続する必要があります。 これがどのように機能するかを見ていきましょう。
まず、VuexのcreateStore関数で作成された状態オブジェクトが必要です。
import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });
ご覧のとおり、ストアではいくつかのプロパティを定義する必要があります。 状態は、アプリケーションにアクセスを許可するデータの単なるリストです。
import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });
状態はrefまたはreactiveラッパーを使用してはならないことに注意してください。 このデータは、共有インスタンスまたはファクトリで使用したのと同じ種類の共有データです。 このストアはアプリケーションではシングルトンになるため、状態のデータも共有されます。
次に、アクションを見てみましょう。 アクションは、状態を含む、有効にする操作です。 例えば:
actions: { async loadBooks(store) { const response = await bookService.getBooks(store.state.currentTopic, if (response.status === 200) { // ... } } },
アクションはストアのインスタンスに渡されるため、状態やその他の操作を取得できます。 通常、必要な部分だけを分解します。
actions: { async loadBooks({ state }) { const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { // ... } } },
これの最後の部分は突然変異です。 ミューテーションは、状態をミューテーションできる関数です。 突然変異だけが状態に影響を与えることができます。 したがって、この例では、状態を変更するミューテーションが必要です。
mutations: { setBusy: (state) => state.isBusy = true, clearBusy: (state) => state.isBusy = false, setBooks(state, books) { state.books.splice(0, state.books.length, ...books); } },
ミューテーション関数は常に状態オブジェクトを渡すため、その状態をミューテーションできます。 最初の2つの例では、状態を明示的に設定していることがわかります。 しかし、3番目の例では、設定する状態を渡します。 ミューテーションは常に2つのパラメーターを取ります。ミューテーションを呼び出すときの状態と引数です。
ミューテーションを呼び出すには、ストアのcommit関数を使用します。 私たちの場合、それを破壊に追加します:
actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },
ここに表示されるのは、 commitがアクションの名前をどのように要求するかです。 これをマジックストリングだけでなくするための秘訣がありますが、今はそれをスキップします。 このマジックストリングの使用は、Vuexを使用する際の制限の1つです。
commitを使用することは不要なラッパーのように見えるかもしれませんが、Vuexは、ミューテーション内を除いて状態をミューテーションさせないことに注意してください。したがって、 commitを介した呼び出しのみが行われます。
setBooksの呼び出しが2番目の引数を取ることもわかります。 これは、突然変異を呼び出している2番目の引数です。 より多くの情報が必要な場合は、それを1つの引数にまとめる必要があります(現在のVuexのもう1つの制限)。 書籍を書籍リストに挿入する必要があるとすると、次のように呼び出すことができます。
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
次に、必要な部分に分解することができます。
mutations: { insertBook(state, { book, place }) => // ... }
これはエレガントですか? 実際にはそうではありませんが、機能します。
アクションがミューテーションで機能するようになったので、コードでVuexストアを使用できるようにする必要があります。 店に着くには本当に2つの方法があります。 まず、ストアをアプリケーション(main.ts/jsなど)に登録することで、アプリケーションのどこからでもアクセスできる一元化されたストアにアクセスできるようになります。
// main.ts import store from './store' createApp(App) .use(store) .use(router) .mount('#app')
これはVuexを追加するのではなく、作成している実際のストアを追加することに注意してください。 これを追加したら、 useStore
を呼び出してストアオブジェクトを取得できます。
import { useStore } from "vuex"; export default defineComponent({ components: { BookInfo, }, setup() { const store = useStore(); const books = computed(() => store.state.books); // ...
これは問題なく機能しますが、ストアを直接インポートすることを好みます。
import store from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const books = computed(() => store.state.books); // ...
ストアオブジェクトにアクセスできるようになったので、どのように使用しますか? 状態については、変更がバインディングに伝播されるように、計算された関数でそれらをラップする必要があります。
export default defineComponent({ setup() { const books = computed(() => store.state.books); return { books }; }, });
アクションを呼び出すには、ディスパッチメソッドを呼び出す必要があります。
export default defineComponent({ setup() { const books = computed(() => store.state.books); onMounted(async () => await store.dispatch("loadBooks")); return { books }; }, });
アクションには、メソッド名の後に追加するパラメーターを含めることができます。 最後に、状態を変更するには、アクション内で行ったのと同じようにcommitを呼び出す必要があります。 たとえば、ストアにページングプロパティがあり、 commitを使用して状態を変更できます。
const incrementPage = () => store.commit("setPage", store.state.currentPage + 1); const decrementPage = () => store.commit("setPage", store.state.currentPage - 1);
このように呼び出すと、エラーがスローされることに注意してください(手動で状態を変更できないため)。
const incrementPage = () => store.state.currentPage++; const decrementPage = () => store.state.currentPage--;
これがここでの真の力です。状態が変更される場所を制御し、開発のさらに後の段階でエラーを発生させる副作用がないようにする必要があります。
Vuexの可動部分の数に圧倒されるかもしれませんが、それは、より大規模でより複雑なプロジェクトの状態を管理するのに本当に役立ちます。 いずれの場合も必要だとは言えませんが、全体的に役立つ大規模なプロジェクトがあります。
Vuex 4の大きな問題は、TypeScriptプロジェクトでVuex 4を使用すると、多くのことが望まれることです。 あなたは確かに開発と構築を助けるためにTypeScriptタイプを作ることができます、しかしそれは多くの動く部分を必要とします。
ここで、Vuex 5は、TypeScript(および一般的なJavaScriptプロジェクト)でのVuexの動作を簡素化することを目的としています。 次にリリースされたら、それがどのように機能するか見てみましょう。
Vuex 5
注:このセクションのコードは、GitHubのサンプルプロジェクトの「Vuex5」ブランチにあります。
この記事の時点では、Vuex5は本物ではありません。 これはRFC(Request for Comments)です。 それは計画です。 それは議論の出発点です。 したがって、ここで説明する内容の多くは、多少変更される可能性があります。 しかし、Vuexの変更に備えるために、私はあなたにそれがどこに向かっているのかについての見解を示したかったのです。 このため、この例に関連付けられているコードはビルドされません。
Vuexがどのように機能するかについての基本的な概念は、それが始まって以来、いくらか変わっていません。 Vue 3の導入により、Vuex 4は、主にVuexが新しいプロジェクトで機能できるようにするために作成されました。 しかし、チームはVuexの本当の問題点を調べて、それらを解決しようとしています。 この目的のために、彼らはいくつかの重要な変更を計画しています。
- これ以上の突然変異はありません:アクションは状態(そしておそらく誰でも)を突然変異させることができます。
- TypeScriptのサポートが向上しました。
- より優れたマルチストア機能。
では、これはどのように機能しますか? ストアの作成から始めましょう:
export default createStore({ key: 'bookStore', state: () => ({ isBusy: false, books: new Array<Work>() }), actions: { async loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } }, getters: { findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } } });
最初に確認する変更は、すべてのストアに独自のキーが必要になることです。 これは、複数のストアを取得できるようにするためです。 次に、状態オブジェクトがファクトリになっていることに気付くでしょう(たとえば、解析時に作成されたのではなく、関数から返されます)。 そして、突然変異のセクションはもうありません。 最後に、アクション内で、 this
ポインターの単なるプロパティとして状態にアクセスしていることがわかります。 状態を渡し、アクションにコミットする必要はもうありません。 これは、開発を簡素化するだけでなく、TypeScriptの型を推測しやすくします。
Vuexをアプリケーションに登録するには、グローバルストアの代わりにVuexを登録します。
import { createVuex } from 'vuex' createApp(App) .use(createVuex()) .use(router) .mount('#app')
最後に、ストアを使用するには、ストアをインポートしてから、そのインスタンスを作成します。
import bookStore from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const store = bookStore(); // Generate the wrapper // ...
ストアから返されるのは、ファクトリを何度呼び出しても、ストアのインスタンスを返すファクトリオブジェクトであることに注意してください。 返されるオブジェクトは、アクション、状態、およびゲッターがファーストクラスシチズン(タイプ情報付き)であるオブジェクトです。
onMounted(async () => await store.loadBooks()); const incrementPage = () => store.currentPage++; const decrementPage = () => store.currentPage--;
ここに表示されるのは、状態(たとえば、 currentPage
)が単なるプロパティであるということです。 そして、アクション(例: loadBooks
)は単なる関数です。 ここでストアを使用しているという事実は副作用です。 Vuexオブジェクトを単なるオブジェクトとして扱い、作業を進めることができます。 これはAPIの大幅な改善です。
指摘しておくべきもう1つの変更点は、CompositionAPIのような構文を使用してストアを生成することもできるということです。
export default defineStore("another", () => { // State const isBusy = ref(false); const books = reactive(new Array≷Work>()); // Actions async function loadBooks() { try { this.isBusy = true; const response = await bookService.getBooks(this.currentTopic, this.currentPage); if (response.status === 200) { this.books = response.data.works; } } finally { this.isBusy = false; } } findBook(key: string): Work | undefined { return this.books.find(b => b.key === key); } // Getters const bookCount = computed(() => this.books.length); return { isBusy, books, loadBooks, findBook, bookCount } });
これにより、Composition APIを使用したビューと同じように、Vuexオブジェクトを作成でき、間違いなく簡単になります。
この新しい設計の主な欠点の1つは、状態の非可変性が失われることです。 これを有効にできることについて議論が行われていますが(Vuex 4と同様に、開発のみ)、これがどれほど重要であるかについてのコンセンサスはありません。 個人的にはVuexにとって重要なメリットだと思いますが、これがどのように機能するかを確認する必要があります。
ここはどこ?
シングルページアプリケーションで共有状態を管理することは、ほとんどのアプリの開発において重要な部分です。 Vueでどのように実行したいかについてゲームプランを立てることは、ソリューションを設計する上で重要なステップです。 この記事では、Vuex 5の今後の予定など、共有状態を管理するためのいくつかのパターンを紹介しました。これで、自分のプロジェクトに適切な決定を下すための知識が得られることを願っています。