إدارة الحالة المشتركة في Vue 3
نشرت: 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 }; },
عندما يكون مشروعك عبارة عن صفحة واحدة لعرض البيانات (ربما لفرزها أو تصفيتها) ، فقد يكون هذا مقنعًا. لكن في هذه الحالة ، سيحصل هذا المكون على بيانات عند كل طلب. ماذا لو كنت تريد الاحتفاظ بها؟ وهنا يأتي دور إدارة الدولة. نظرًا لأن اتصالات الشبكة غالبًا ما تكون باهظة الثمن وغير موثوق بها في بعض الأحيان ، فمن الأفضل الاحتفاظ بهذه الحالة أثناء التنقل عبر أحد التطبيقات.
قضية أخرى هي التواصل بين المكونات. بينما يمكنك استخدام الأحداث والدعائم للتواصل مع أولياء أمور الأطفال المباشرين ، فإن التعامل مع المواقف البسيطة مثل معالجة الأخطاء والعلامات المشغولة قد يكون صعبًا عندما تكون كل من طرق العرض / الصفحات الخاصة بك مستقلة. على سبيل المثال ، تخيل أن لديك عنصر تحكم عالي المستوى تم توصيله سلكيًا لإظهار الخطأ وتحميل الرسوم المتحركة:
// 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.
الحالة المشتركة في Vue 3
منذ الانتقال إلى Vue 3 ، انتقلت تمامًا إلى استخدام Composition API. بالنسبة للمقال ، أنا أستخدم TypeScript أيضًا على الرغم من أن ذلك ليس مطلوبًا للأمثلة التي أعرضها عليك. بينما يمكنك مشاركة الحالة بالطريقة التي تريدها ، سأعرض لك العديد من الأساليب التي أجدها أكثر الأنماط شيوعًا. لكل منها إيجابياته وسلبياته ، لذا لا تأخذ أي شيء أتحدث عنه هنا كعقيدة.
تشمل التقنيات ما يلي:
- المصانع
- الفردي المشترك ،
- Vuex 4 ،
- Vuex 5.
ملحوظة : Vuex 5 ، اعتبارًا من كتابة هذه المقالة ، فهي في مرحلة RFC (طلب التعليقات) ، لذا أريد أن أجهزك إلى حيث تتجه Vuex ، ولكن في الوقت الحالي لا توجد نسخة صالحة للعمل من هذا الخيار.
دعونا نحفر ...
المصانع
ملاحظة : رمز هذا القسم موجود في فرع "المصانع" من نموذج المشروع على GitHub.
نمط المصنع يدور حول إنشاء مثيل للحالة التي تهتم بها. في هذا النمط ، تقوم بإرجاع دالة تشبه إلى حد كبير وظيفة البدء في Composition API. ستنشئ نطاقًا وتبني مكونات ما تبحث عنه. علي سبيل المثال:
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()
بدلاً من تخصيص الكتب).
هناك طريقة أخرى للتعامل مع هذا وهي استخدام المثيلات المشتركة.
مثيلات مشتركة
رمز هذا القسم موجود في فرع "SharedState" من مثال المشروع على GitHub.
إذا كنت ستشارك الدولة ، فقد تكون واضحًا أيضًا بشأن حقيقة أن الدولة هي حالة فردية. في هذه الحالة ، يمكن استيراده فقط ككائن ثابت. على سبيل المثال ، أحب إنشاء كائن يمكن استيراده ككائن تفاعلي:
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
رمز هذا القسم موجود في فرع "Vuex4" من مثال المشروع على GitHub.
Vuex هو مدير الدولة لـ Vue. تم بناؤه من قبل الفريق الأساسي على الرغم من إدارته كمشروع منفصل. الغرض من Vuex هو فصل الحالة عن الإجراءات التي تريد القيام بها للدولة. يجب أن تمر جميع التغييرات في الحالة عبر Vuex مما يعني أنها أكثر تعقيدًا ، لكنك تحصل على الحماية من تغيير الحالة العرضي.
فكرة Vuex هي توفير تدفق يمكن التنبؤ به لإدارة الدولة. تتدفق المشاهدات إلى الإجراءات التي بدورها تستخدم الطفرات لتغيير الحالة والتي بدورها تقوم بتحديث العرض. من خلال الحد من تدفق تغيير الحالة ، يجب أن يكون لديك عدد أقل من الآثار الجانبية التي تغير حالة تطبيقاتك ؛ لذلك يكون من الأسهل إنشاء تطبيقات أكبر. لدى Vuex منحنى تعليمي ، ولكن مع هذا التعقيد تحصل على القدرة على التنبؤ.
بالإضافة إلى ذلك ، تدعم Vuex أدوات وقت التطوير (عبر أدوات Vue) للعمل مع إدارة الحالة بما في ذلك ميزة تسمى السفر عبر الزمن. يتيح لك ذلك عرض محفوظات الحالة والعودة إلى الخلف والأمام لمعرفة كيفية تأثيرها على التطبيق.
هناك أيضًا أوقات يكون فيها Vuex مهمًا أيضًا.
لإضافته إلى مشروع Vue 3 الخاص بك ، يمكنك إما إضافة الحزمة إلى المشروع:
> npm i vuex
أو ، بدلاً من ذلك ، يمكنك إضافته باستخدام Vue CLI:
> vue add vuex
باستخدام CLI ، ستنشئ نقطة انطلاق لمتجر Vuex الخاص بك ، وإلا ستحتاج إلى توصيلها يدويًا بالمشروع. دعنا نتعرف على كيفية عمل هذا.
أولاً ، ستحتاج إلى كائن حالة تم إنشاؤه باستخدام وظيفة createStore في Vuex:
import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });
كما ترى ، يتطلب المتجر تحديد العديد من الخصائص. الولاية هي مجرد قائمة بالبيانات التي تريد منح تطبيقك حق الوصول إليها:
import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });
لاحظ أن الحالة لا ينبغي أن تستخدم أغلفة مرجعية أو تفاعلية . هذه البيانات هي نفس نوع بيانات المشاركة التي استخدمناها مع مثيلات أو مصانع مشتركة. سيكون هذا المتجر منفردًا في تطبيقك ، وبالتالي ستتم أيضًا مشاركة البيانات الموجودة في الحالة.
بعد ذلك ، دعونا نلقي نظرة على الإجراءات. الإجراءات هي العمليات التي تريد تمكينها والتي تشمل الدولة. علي سبيل المثال:
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); } },
تمرر وظائف الطفرة دائمًا في كائن الحالة بحيث يمكنك تغيير هذه الحالة. في المثالين الأولين ، يمكنك أن ترى أننا نعيّن الحالة صراحةً. لكن في المثال الثالث ، نجتاز الحالة لنحددها. تأخذ الطفرات دائمًا معلمتين: الحالة والحجة عند استدعاء الطفرة.
لاستدعاء طفرة ، يمكنك استخدام وظيفة الالتزام في المتجر. في حالتنا ، سأضيفها إلى التدمير:
actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },
ما ستراه هنا هو كيف يتطلب الالتزام اسم الإجراء. هناك حيل لجعل هذا لا يقتصر على استخدام الأوتار السحرية ، لكنني سأتخطى ذلك الآن. يعد استخدام الأوتار السحرية أحد قيود استخدام Vuex.
أثناء استخدام الالتزام قد يبدو وكأنه غلاف غير ضروري ، تذكر أن Vuex لن يسمح لك بتغيير الحالة إلا داخل الطفرة ، وبالتالي فقط المكالمات من خلال الالتزام بالإرادة.
يمكنك أيضًا أن ترى أن استدعاء setBooks يأخذ وسيطة ثانية. هذه هي الحجة الثانية التي تدعو إلى الطفرة. إذا كنت بحاجة إلى مزيد من المعلومات ، فستحتاج إلى تجميعها في وسيطة واحدة (قيد آخر لـ Vuex حاليًا). بافتراض أنك بحاجة إلى إدراج كتاب في قائمة الكتب ، يمكنك تسميته على النحو التالي:
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
ثم يمكنك فقط تدمير القطع التي تحتاجها:
mutations: { insertBook(state, { book, place }) => // ... }
هل هذا أنيق؟ ليس حقًا ، لكنه يعمل.
الآن بعد أن عملنا على التعامل مع الطفرات ، نحتاج إلى أن نكون قادرين على استخدام متجر Vuex في التعليمات البرمجية الخاصة بنا. هناك طريقتان للوصول إلى المتجر. أولاً ، من خلال تسجيل المتجر باستخدام التطبيق (على سبيل المثال 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 }; }, });
يمكن أن تحتوي الإجراءات على معلمات تضيفها بعد اسم الطريقة. أخيرًا ، لتغيير الحالة ، ستحتاج إلى استدعاء الالتزام تمامًا كما فعلنا داخل الإجراءات. على سبيل المثال ، لدي خاصية ترحيل في المتجر ، وبعد ذلك يمكنني تغيير الحالة من خلال الالتزام :
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 يترك الكثير مما هو مرغوب فيه. يمكنك بالتأكيد إنشاء أنواع TypeScript للمساعدة في التطوير والبناء ، ولكنها تتطلب الكثير من القطع المتحركة.
هذا هو المكان الذي يهدف فيه Vuex 5 إلى تبسيط كيفية عمل Vuex في TypeScript (وفي مشاريع JavaScript بشكل عام). دعونا نرى كيف سيعمل ذلك بمجرد إصداره بعد ذلك.
Vuex 5
ملاحظة : رمز هذا القسم موجود في فرع "Vuex5" من مثال المشروع على GitHub.
في وقت كتابة هذا المقال ، Vuex 5 ليس حقيقيًا. إنها RFC (طلب التعليقات). إنها خطة. إنها نقطة انطلاق للمناقشة. لذا من المحتمل أن يتغير الكثير مما قد أشرحه هنا إلى حد ما. ولكن لتحضيرك للتغيير في 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.
هناك تغيير آخر مهم يجب الإشارة إليه وهو أنه يمكنك أيضًا إنشاء متجرك باستخدام بنية تشبه Composition API:
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 } });
يتيح لك هذا إنشاء كائن Vuex تمامًا كما تفعل مع وجهات نظرك باستخدام Composition API ويمكن القول إنه أبسط.
أحد العوائق الرئيسية في هذا التصميم الجديد هو أنك تفقد عدم قابلية تغيير الحالة. هناك مناقشات تدور حول القدرة على تمكين هذا (من أجل التطوير فقط ، تمامًا مثل Vuex 4) ولكن لا يوجد إجماع على مدى أهمية ذلك. أنا شخصياً أعتقد أنها ميزة أساسية لـ Vuex ، لكن علينا أن نرى كيف يتم ذلك.
أين نحن؟
تعد إدارة الحالة المشتركة في تطبيقات الصفحة الواحدة جزءًا مهمًا من التطوير لمعظم التطبيقات. يعد وجود خطة لعبة حول الطريقة التي تريد القيام بها في Vue خطوة مهمة في تصميم الحل الخاص بك. في هذه المقالة ، لقد أوضحت لك عدة أنماط لإدارة الحالة المشتركة بما في ذلك ما هو قادم لـ Vuex 5. آمل أن يكون لديك الآن المعرفة لاتخاذ القرار الصحيح لمشاريعك الخاصة.