Gestión de estado compartido en Vue 3
Publicado: 2022-03-10El estado puede ser difícil. Cuando comenzamos un proyecto Vue simple, puede ser simple mantener nuestro estado de trabajo en un componente en particular:
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 }; },
Cuando su proyecto es una sola página que muestra datos (quizás para ordenarlos o filtrarlos), esto puede ser convincente. Pero en este caso, este componente obtendrá datos sobre cada solicitud. ¿Qué pasa si quieres mantenerlo cerca? Ahí es donde entra en juego la gestión estatal. Dado que las conexiones de red suelen ser costosas y ocasionalmente poco confiables, sería mejor mantener este estado mientras navega por una aplicación.
Otro problema es la comunicación entre los componentes. Si bien puede usar eventos y accesorios para comunicarse directamente con los niños y los padres, el manejo de situaciones simples como el manejo de errores y las banderas ocupadas puede ser difícil cuando cada una de sus vistas/páginas es independiente. Por ejemplo, imagina que tienes un control de nivel superior conectado para mostrar el error y la animación de carga:
// 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>
Sin una forma efectiva de manejar este estado, podría sugerir un sistema de publicación/suscripción, pero de hecho, compartir datos es más sencillo en muchos casos. Si quieres tener un estado compartido, ¿cómo lo haces? Veamos algunas formas comunes de hacer esto.
Nota : encontrará el código para esta sección en la rama "principal" del proyecto de ejemplo en GitHub.
Estado compartido en Vue 3
Desde que me cambié a Vue 3, migré por completo a usar la API de composición. Para el artículo, también estoy usando TypeScript, aunque no es necesario para los ejemplos que les muestro. Si bien puede compartir el estado de la forma que desee, le mostraré varias técnicas que encuentro que son los patrones más utilizados. Cada uno tiene sus propios pros y contras, así que no tome nada de lo que hable aquí como dogma.
Las técnicas incluyen:
- Suerte,
- Singletons compartidos,
- vuex 4,
- Vuex 5.
Nota : Vuex 5, al momento de escribir este artículo, se encuentra en la etapa RFC (Solicitud de comentarios), por lo que quiero que esté listo para saber hacia dónde se dirige Vuex, pero en este momento no hay una versión funcional de esta opción.
Vamos a profundizar en…
Suerte
Nota : el código de esta sección se encuentra en la rama "Fábricas" del proyecto de ejemplo en GitHub.
El patrón de fábrica se trata solo de crear una instancia del estado que le interesa. En este patrón, devuelve una función que se parece mucho a la función de inicio en la API de composición. Crearía un alcance y construiría los componentes de lo que está buscando. Por ejemplo:
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 }; }
Puede solicitar solo las partes de los objetos creados en fábrica que necesita de la siguiente manera:
// In Home.vue const { books, loadBooks } = BookFactory();
Si agregamos un indicador isBusy
para mostrar cuándo ocurre la solicitud de red, el código anterior no cambia, pero puede decidir dónde va a mostrar 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 }; }
En otra vista (¿vue?), podría simplemente solicitar el indicador isBusy sin tener que saber cómo funciona el resto de la fábrica:
// App.vue export default defineComponent({ setup() { const { isBusy } = BookFactory(); return { isBusy } }, })
Pero es posible que haya notado un problema; cada vez que llamamos a la fábrica, obtenemos una nueva instancia de todos los objetos. Hay momentos en los que desea que una fábrica devuelva nuevas instancias, pero en nuestro caso estamos hablando de compartir el estado, por lo que debemos mover la creación fuera de la fábrica:
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 }; }
Ahora la fábrica nos está dando una instancia compartida, o un singleton si lo prefiere. Si bien este patrón funciona, puede resultar confuso devolver una función que no crea una nueva instancia cada vez.
Debido a que los objetos subyacentes están marcados como const
, no debería poder reemplazarlos (y romper la naturaleza de singleton). Entonces este código debería quejarse:
// In Home.vue const { books, loadBooks } = BookFactory(); books = []; // Error, books is defined as const
Por lo tanto, puede ser importante asegurarse de que el estado mutable se pueda actualizar (por ejemplo, usando books.splice()
en lugar de asignar los libros).
Otra forma de manejar esto es usar instancias compartidas.
Instancias compartidas
El código de esta sección se encuentra en la rama "SharedState" del proyecto de ejemplo en GitHub.
Si va a compartir el estado, también podría ser claro sobre el hecho de que el estado es un singleton. En este caso, solo se puede importar como un objeto estático. Por ejemplo, me gusta crear un objeto que se pueda importar como objeto reactivo:
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; } });
En este caso, solo importa el objeto (al que llamo tienda en este ejemplo):
// Home.vue import state from "@/state"; export default defineComponent({ setup() { // ... onMounted(async () => { if (state.books.length === 0) state.loadBooks(); }); return { state, bookTopics, }; }, });
Entonces se vuelve fácil vincular al estado:
<!-- 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>
Al igual que los otros patrones, obtiene el beneficio de poder compartir esta instancia entre vistas:
// App.vue import state from "@/state"; export default defineComponent({ setup() { return { state }; }, })
Luego, esto puede vincularse a lo que es el mismo objeto (ya sea un padre de Home.vue
u otra página en el enrutador):
<!-- 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>
Ya sea que use el patrón de fábrica o la instancia compartida, ambos tienen un problema común: el estado mutable. Puede tener efectos secundarios accidentales de enlaces o cambios de estado del código cuando no lo desea. En un ejemplo trivial como el que estoy usando aquí, no es lo suficientemente complejo como para preocuparse. Pero a medida que crea aplicaciones cada vez más grandes, querrá pensar más detenidamente en la mutación de estado. Ahí es donde Vuex puede venir al rescate.
Vuex 4
El código de esta sección se encuentra en la rama "Vuex4" del proyecto de ejemplo en GitHub.
Vuex es gerente estatal de Vue. Fue construido por el equipo central, aunque se administra como un proyecto separado. El propósito de Vuex es separar el estado de las acciones que desea realizar en el estado. Todos los cambios de estado deben pasar por Vuex, lo que significa que es más complejo, pero obtienes protección contra cambios de estado accidentales.
La idea de Vuex es proporcionar un flujo predecible de gestión del estado. Las vistas fluyen hacia las acciones que, a su vez, usan mutaciones para cambiar el estado que, a su vez, actualiza la vista. Al limitar el flujo de cambio de estado, debería tener menos efectos secundarios que cambien el estado de sus aplicaciones; por lo tanto, será más fácil construir aplicaciones más grandes. Vuex tiene una curva de aprendizaje, pero con esa complejidad obtienes previsibilidad.
Además, Vuex admite herramientas de tiempo de desarrollo (a través de Vue Tools) para trabajar con la administración del estado, incluida una característica llamada viaje en el tiempo. Esto le permite ver un historial del estado y retroceder y avanzar para ver cómo afecta la aplicación.
También hay momentos en los que Vuex también es importante.
Para agregarlo a su proyecto Vue 3, puede agregar el paquete al proyecto:
> npm i vuex
O, alternativamente, puede agregarlo usando la CLI de Vue:
> vue add vuex
Al usar la CLI, creará un punto de partida para su tienda Vuex; de lo contrario, deberá conectarlo manualmente al proyecto. Veamos cómo funciona esto.
Primero, necesitará un objeto de estado creado con la función createStore de Vuex:
import { createStore } from 'vuex' export default createStore({ state: {}, mutations: {}, actions: {}, getters: {} });
Como puede ver, la tienda requiere que se definan varias propiedades. El estado es solo una lista de los datos a los que desea dar acceso a su aplicación:
import { createStore } from 'vuex' export default createStore({ state: { books: [], isBusy: false }, mutations: {}, actions: {} });
Tenga en cuenta que el estado no debe usar envolturas ref o reactivas . Estos datos son del mismo tipo de datos compartidos que usamos con instancias compartidas o fábricas. Esta tienda será un singleton en su aplicación, por lo tanto, los datos en estado también se compartirán.
A continuación, veamos las acciones. Las acciones son operaciones que desea habilitar que involucran al estado. Por ejemplo:
actions: { async loadBooks(store) { const response = await bookService.getBooks(store.state.currentTopic, if (response.status === 200) { // ... } } },
Las acciones se pasan a una instancia de la tienda para que pueda obtener el estado y otras operaciones. Normalmente, desestructuraríamos solo las partes que necesitamos:
actions: { async loadBooks({ state }) { const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { // ... } } },
La última pieza de esto son las Mutaciones. Las mutaciones son funciones que pueden cambiar de estado. Solo las mutaciones pueden afectar el estado. Entonces, para este ejemplo, necesitamos mutaciones que cambien el estado:
mutations: { setBusy: (state) => state.isBusy = true, clearBusy: (state) => state.isBusy = false, setBooks(state, books) { state.books.splice(0, state.books.length, ...books); } },
Las funciones de mutación siempre pasan en el objeto de estado para que pueda mutar ese estado. En los primeros dos ejemplos, puede ver que estamos configurando explícitamente el estado. Pero en el tercer ejemplo, estamos pasando el estado a set. Las mutaciones siempre toman dos parámetros: el estado y el argumento al llamar a la mutación.
Para llamar a una mutación, usaría la función de confirmación en la tienda. En nuestro caso, solo lo agregaré a la desestructuración:
actions: { async loadBooks({ state, commit }) { commit("setBusy"); const response = await bookService.getBooks(state.currentTopic, if (response.status === 200) { commit("setBooks", response.data); } commit("clearBusy"); } },
Lo que verá aquí es cómo commit requiere el nombre de la acción. Hay trucos para hacer que esto no solo use cuerdas mágicas, pero voy a omitir eso por ahora. Este uso de cadenas mágicas es una de las limitaciones del uso de Vuex.
Si bien usar commit puede parecer un envoltorio innecesario, recuerde que Vuex no le permitirá mutar el estado excepto dentro de la mutación, por lo tanto, solo las llamadas a través de commit lo harán.
También puede ver que la llamada a setBooks toma un segundo argumento. Este es el segundo argumento que está llamando a la mutación. Si necesitara más información, necesitaría empaquetarla en un solo argumento (otra limitación de Vuex actualmente). Suponiendo que necesita insertar un libro en la lista de libros, podría llamarlo así:
commit("insertBook", { book, place: 4 }); // object, tuple, etc.
Entonces podría desestructurarse en las piezas que necesita:
mutations: { insertBook(state, { book, place }) => // ... }
¿Es esto elegante? En realidad no, pero funciona.
Ahora que nuestra acción funciona con mutaciones, debemos poder usar la tienda Vuex en nuestro código. Realmente hay dos maneras de llegar a la tienda. Primero, al registrar la tienda con la aplicación (p. ej., main.ts/js), tendrá acceso a una tienda centralizada a la que tendrá acceso en todas partes de su aplicación:
// main.ts import store from './store' createApp(App) .use(store) .use(router) .mount('#app')
Tenga en cuenta que esto no es agregar Vuex, sino su tienda real que está creando. Una vez que se agrega esto, puede simplemente llamar a useStore
para obtener el objeto de la tienda:
import { useStore } from "vuex"; export default defineComponent({ components: { BookInfo, }, setup() { const store = useStore(); const books = computed(() => store.state.books); // ...
Esto funciona bien, pero prefiero importar la tienda directamente:
import store from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const books = computed(() => store.state.books); // ...
Ahora que tiene acceso al objeto de la tienda, ¿cómo lo usa? Para el estado, deberá envolverlos con funciones calculadas para que los cambios se propaguen a sus enlaces:
export default defineComponent({ setup() { const books = computed(() => store.state.books); return { books }; }, });
Para llamar a acciones, deberá llamar al método de envío :
export default defineComponent({ setup() { const books = computed(() => store.state.books); onMounted(async () => await store.dispatch("loadBooks")); return { books }; }, });
Las acciones pueden tener parámetros que agrega después del nombre del método. Por último, para cambiar el estado, deberá llamar a commit tal como lo hicimos dentro de las Acciones. Por ejemplo, tengo una propiedad de paginación en la tienda y luego puedo cambiar el estado con confirmación :
const incrementPage = () => store.commit("setPage", store.state.currentPage + 1); const decrementPage = () => store.commit("setPage", store.state.currentPage - 1);
Tenga en cuenta que llamarlo así arrojaría un error (porque no puede cambiar el estado manualmente):
const incrementPage = () => store.state.currentPage++; const decrementPage = () => store.state.currentPage--;
Este es el poder real aquí, nos gustaría controlar dónde se cambia el estado y no tener efectos secundarios que produzcan errores más adelante en el desarrollo.
Puede sentirse abrumado con la cantidad de piezas en movimiento en Vuex, pero realmente puede ayudar a administrar el estado en proyectos más grandes y complejos. No diría que lo necesita en todos los casos, pero habrá grandes proyectos en los que lo ayudará en general.
El gran problema de Vuex 4 es que trabajar con él en un proyecto TypeScript deja mucho que desear. Sin duda, puede crear tipos de TypeScript para ayudar en el desarrollo y las compilaciones, pero requiere muchas piezas en movimiento.
Ahí es donde Vuex 5 pretende simplificar cómo funciona Vuex en TypeScript (y en proyectos de JavaScript en general). Veamos cómo funcionará una vez que se lance a continuación.
Vuex 5
Nota : El código de esta sección se encuentra en la rama "Vuex5" del proyecto de ejemplo en GitHub.
En el momento de este artículo, Vuex 5 no es real. Es un RFC (Solicitud de comentarios). es un plan Es un punto de partida para la discusión. Entonces, mucho de lo que puedo explicar aquí probablemente cambiará un poco. Pero para prepararte para el cambio en Vuex, quería darte una idea de hacia dónde se dirige. Debido a esto, el código asociado con este ejemplo no se compila.
Los conceptos básicos de cómo funciona Vuex no han cambiado desde su inicio. Con la introducción de Vue 3, Vuex 4 se creó principalmente para permitir que Vuex trabajara en nuevos proyectos. Pero el equipo está tratando de ver los puntos débiles reales con Vuex y resolverlos. Para ello están planeando algunos cambios importantes:
- No más mutaciones: las acciones pueden mutar de estado (y posiblemente de cualquiera).
- Mejor compatibilidad con TypeScript.
- Mejor funcionalidad multitienda.
Entonces, ¿cómo funcionaría esto? Comencemos con la creación de la tienda:
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); } } });
El primer cambio a ver es que cada tienda ahora necesita su propia llave. Esto es para permitirle recuperar varias tiendas. A continuación, notará que el objeto de estado ahora es una fábrica (por ejemplo, regresa de una función, no creada en el análisis). Y ya no hay sección de mutaciones. Por último, dentro de las acciones, puede ver que estamos accediendo al estado como solo propiedades en el puntero this
. No más tener que pasar de estado y comprometerse con las acciones. Esto ayuda no solo a simplificar el desarrollo, sino que también facilita la inferencia de tipos para TypeScript.
Para registrar Vuex en su aplicación, registrará Vuex en lugar de su tienda global:
import { createVuex } from 'vuex' createApp(App) .use(createVuex()) .use(router) .mount('#app')
Finalmente, para usar la tienda, importará la tienda y luego creará una instancia de ella:
import bookStore from "@/store"; export default defineComponent({ components: { BookInfo, }, setup() { const store = bookStore(); // Generate the wrapper // ...
Tenga en cuenta que lo que se devuelve de la tienda es un objeto de fábrica que devuelve esta instancia de la tienda, sin importar cuántas veces llame a la fábrica. El objeto devuelto es solo un objeto con las acciones, el estado y los captadores como ciudadanos de primera clase (con información de tipo):
onMounted(async () => await store.loadBooks()); const incrementPage = () => store.currentPage++; const decrementPage = () => store.currentPage--;
Lo que verá aquí es que el estado (por ejemplo currentPage
) son solo propiedades simples. Y las acciones (por ejemplo loadBooks
) son solo funciones. El hecho de que estés usando una tienda aquí es un efecto secundario. Puede tratar el objeto Vuex como solo un objeto y continuar con su trabajo. Esta es una mejora significativa en la API.
Otro cambio que es importante señalar es que también podría generar su tienda utilizando una sintaxis similar a la API de composición:
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 } });
Esto le permite construir su objeto Vuex tal como lo haría con sus vistas con la API de Composición y podría decirse que es más simple.
Uno de los principales inconvenientes de este nuevo diseño es que se pierde la no mutabilidad del estado. Hay debates sobre la posibilidad de habilitar esto (solo para desarrollo, al igual que Vuex 4), pero no hay consenso sobre la importancia de esto. Personalmente, creo que es un beneficio clave para Vuex, pero tendremos que ver cómo se desarrolla esto.
¿Dónde estamos?
Administrar el estado compartido en aplicaciones de una sola página es una parte crucial del desarrollo para la mayoría de las aplicaciones. Tener un plan de juego sobre cómo quiere hacerlo en Vue es un paso importante en el diseño de su solución. En este artículo, le mostré varios patrones para administrar el estado compartido, incluido lo que viene para Vuex 5. Con suerte, ahora tendrá el conocimiento para tomar la decisión correcta para sus propios proyectos.