Qué es Vuex en esencia
Vuex es un patrón de gestión de estado pensado para Vue.js. Expone un almacén global (store) donde viven los datos compartidos, y define tres operadores principales: actions para la lógica y las operaciones asíncronas, mutations como únicos modificadores del estado, y getters para lecturas derivadas. La regla fundamental es que el estado solo cambia a través de mutaciones síncronas, lo que permite auditar cada transformación.
Uso básico
Antes de adentrarnos en el código, recordemos cómo se configura un store. En el archivo principal del almacén se declaran estado, mutaciones, acciones y getters:
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
total: 0
},
getters: {
doubled: state => state.total * 2
},
mutations: {
increment(state, amount) {
state.total += amount
}
},
actions: {
incrementAsync({ commit }, amount) {
setTimeout(() => commit('increment', amount), 400)
}
}
})
En el punto de entrada de la aplicación se inyecta el almacén en la instancia raíz:
// main.js
import Vue from 'vue'
import App from './App.vue'
import store from './store'
new Vue({
el: '#app',
store,
render: h => h(App)
})
Desde cualquier componente se puede disparar una acción y leer el estado:
export default {
computed: {
total() {
return this.$store.state.total
},
doubled() {
return this.$store.getters.doubled
}
},
methods: {
add(value) {
this.$store.dispatch('incrementAsync', value)
}
}
}
Flujo central
El ciclo de vida de un cambio en Vuex sigue estos pasos:
- El componente recibe una interacción del usuario y ejecuta
dispatchcon un tipo de acción. - La acción procesa la petición, puede ser síncrona o asíncrona, y finalmente invoca
commit. commitejecuta la mutación correspondiente, que modifica el estado.- El estado es reactivo, por lo que los componentes suscritos se actualizan automáticamente.
Este diseño unidireccional permite rastrear quién y cuándo alteró los datos.
Instalación del plugin
Al lamar Vue.use(Vuex), Vue invoca el método install de Vuex. Para evitar incluir Vue dentro del paquete de Vuex, el plugin recibe la constructora como parámetro:
let VueRef
export function install(_Vue) {
if (VueRef) {
console.error('[vuex] already installed')
return
}
VueRef = _Vue
applyMixin(VueRef)
}
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue)
}
applyMixin inyecta el almacén en cada instancia de Vue. En versiones 2.x utiliza un mixin; en versiones anteriores reemplaza el método _init:
const major = Number(VueRef.version.split('.')[0])
if (major >= 2) {
VueRef.mixin({ beforeCreate: vuexInit })
} else {
const originalInit = VueRef.prototype._init
VueRef.prototype._init = function(options = {}) {
options.init = options.init
? [vuexInit].concat(options.init)
: vuexInit
originalInit.call(this, options)
}
}
function vuexInit() {
const opts = this.$options
if (opts.store) {
this.$store = typeof opts.store === 'function'
? opts.store()
: opts.store
} else if (opts.parent && opts.parent.$store) {
this.$store = opts.parent.$store
}
}
La instancia raíz recibe el store directamente desde sus opciones. Los componentes hijos heredan la referencia desde el padre, garantizando que cualquier nivel del árbol pueda acceder a this.$store.
Construcción del store
El constructor de Store valida el entorno e inicializa las estructuras internas:
constructor(options = {}) {
assert(VueRef, 'Vue.use(Vuex) must be called first')
assert(typeof Promise !== 'undefined', 'Promise polyfill required')
const {
plugins = [],
strict = false
} = options
let {
state = {}
} = options
if (typeof state === 'function') {
state = state()
}
this._committing = false
this._actions = Object.create(null)
this._mutations = Object.create(null)
this._wrappedGetters = Object.create(null)
this._modules = new ModuleCollection(options)
this._modulesNamespaceMap = Object.create(null)
this._subscribers = []
this._watcherVM = new VueRef()
const store = this
const { dispatch, commit } = this
this.dispatch = function(type, payload) {
return dispatch.call(store, type, payload)
}
this.commit = function(type, payload, options) {
return commit.call(store, type, payload, options)
}
installModule(this, state, [], this._modules.root)
resetStoreVM(this, state)
plugins.forEach(plugin => plugin(this))
}
El constructor prepara el almacén, construye el árbol de módulos, instala cada módulo, crea la instancia reactiva y ejecuta los plugins.
ModuleCollection y el árbol de módulos
La clase ModuleCollection convierte el objeto de opciones en un árbol de módulos, registrando cada rama de forma recursiva:
class ModuleCollection {
constructor(rawRootModule) {
this.register([], rawRootModule, false)
}
register(path, rawModule, runtime = true) {
const newModule = new Module(rawModule, runtime)
if (path.length === 0) {
this.root = newModule
} else {
const parent = this.get(path.slice(0, -1))
parent.addChild(path[path.length - 1], newModule)
}
if (rawModule.modules) {
Object.keys(rawModule.modules).forEach(key => {
this.register([...path, key], rawModule.modules[key], runtime)
})
}
}
get(path) {
return path.reduce((module, key) => module.getChild(key), this.root)
}
getNamespace(path) {
let module = this.root
return path.reduce((acc, key) => {
module = module.getChild(key)
return acc + (module.namespaced ? key + '/' : '')
}, '')
}
}
Cada nodo es una instancia de Module, que encapsula su estado, sus hijos y sus definiciones crudas:
class Module {
constructor(rawModule, runtime) {
this.runtime = runtime
this._children = Object.create(null)
this._rawModule = rawModule
const rawState = rawModule.state
this.state = typeof rawState === 'function'
? rawState()
: rawState || {}
this.namespaced = !!rawModule.namespaced
}
addChild(key, module) {
this._children[key] = module
}
getChild(key) {
return this._children[key]
}
forEachMutation(fn) {
if (this._rawModule.mutations) {
Object.keys(this._rawModule.mutations).forEach(key => {
fn(this._rawModule.mutations[key], key)
})
}
}
forEachAction(fn) { /* ... */ }
forEachGetter(fn) { /* ... */ }
forEachChild(fn) { /* ... */ }
}
Esta estructura de árbol permite soportar módulos anidados con espacios de nombres, manteniendo una única fuente de verdad global.
commit y dispatch
Dentro del store, commit ejecuta las mutaciones y notifica a los suscriptores:
commit(_type, _payload, _options) {
const { type, payload, options } = unifyObjectStyle(
_type, _payload, _options
)
const mutation = { type, payload }
const handlers = this._mutations[type]
if (!handlers) {
console.error(`[vuex] unknown mutation type: ${type}`)
return
}
this._withCommit(() => {
handlers.forEach(handler => handler(payload))
})
this._subscribers.forEach(sub => sub(mutation, this.state))
}
dispatch localiza las acciones registradas y las ejecuta, devolviendo una promesa cuando hay múltiples manejadores:
dispatch(_type, _payload) {
const { type, payload } = unifyObjectStyle(_type, _payload)
const handlers = this._actions[type]
if (!handlers) {
console.error(`[vuex] unknown action type: ${type}`)
return
}
return handlers.length > 1
? Promise.all(handlers.map(handler => handler(payload)))
: handlers[0](payload)
}
Ambos métodos envuelven los parámetros con unifyObjectStyle, que permite llamarlos tanto con una cadena como con un objeto.
Control del modo estricto
Toda mutación se envuelve con _withCommit para marcar el inicio y fin del cambio:
_withCommit(fn) {
const previous = this._committing
this._committing = true
fn()
this._committing = previous
}
En modo estricto, Vuex vigila el estado mediante un watcher. Si detecta una modificación fuera de una mutación, lanza un error:
function enableStrictMode(store) {
store._vm.$watch(
function() { return this._data.$$state },
() => {
assert(
store._committing,
'Do not mutate vuex store state outside mutation handlers.'
)
},
{ deep: true, sync: true }
)
}
Esto explica por qué las mutaciones deben ser síncronas: si una mutación fuera asíncrona, la bandera _committing ya habría vuelto a false cuando el callback modifique el estado.
Instalación de módulos
installModule recorre el árbol y registra mutaciones, acciones y getters en el store. También enlaza el estado de los módulos hijos al estado del padre usando Vue.set, lo que conserva la reactividad:
function installModule(store, rootState, path, module, hot) {
const isRoot = !path.length
const namespace = store._modules.getNamespace(path)
if (module.namespaced) {
store._modulesNamespaceMap[namespace] = module
}
if (!isRoot && !hot) {
const parentState = getNestedState(rootState, path.slice(0, -1))
const moduleName = path[path.length - 1]
store._withCommit(() => {
Vue.set(parentState, moduleName, module.state)
})
}
const local = module.context = makeLocalContext(store, namespace, path)
module.forEachMutation((mutation, key) => {
registerMutation(store, namespace + key, mutation, local)
})
module.forEachAction((action, key) => {
registerAction(store, namespace + key, action, local)
})
module.forEachGetter((getter, key) => {
registerGetter(store, namespace + key, getter, local)
})
module.forEachChild((child, key) => {
installModule(store, rootState, [...path, key], child, hot)
})
}
Las funciones de registro almacenan los manejadores en listas indexadas por tipo. Por ejemplo, el registro de mutaciones se parece a esto:
function registerMutation(store, type, handler, local) {
const entry = store._mutations[type] || (store._mutations[type] = [])
entry.push(function wrapped(payload) {
handler.call(store, local.state, payload)
})
}
De este modo, varios módulos pueden declarar mutaciones con el mismo nombre y todas se ejecutan en orden.
La instancia reactiva _vm
Para aprovechar el sistema de reactividad de Vue, el store crea una instancia interna de Vue llamada _vm. El estado global se guarda en $$state y los getters se montan como propiedades computadas:
function resetStoreVM(store, state, hot) {
const oldVm = store._vm
store.getters = {}
const computed = {}
Object.keys(store._wrappedGetters).forEach(key => {
const fn = store._wrappedGetters[key]
computed[key] = () => fn(store)
Object.defineProperty(store.getters, key, {
get: () => store._vm[key],
enumerable: true
})
})
const silent = VueRef.config.silent
VueRef.config.silent = true
store._vm = new VueRef({
data: {
$$state: state
},
computed
})
VueRef.config.silent = silent
if (store.strict) {
enableStrictMode(store)
}
if (oldVm) {
if (hot) {
store._withCommit(() => {
oldVm._data.$$state = null
})
}
VueRef.nextTick(() => oldVm.$destroy())
}
}
Cuando una mutación cambia el estado, el watcher de Vue detecta la variación en $$state y actualiza los componentes que dependen de él.
Plugins y devtools
Los plugins reciben el store como argumento y pueden suscribirse a cada mutación. El logger incluido guarda una copia profunda del estado anterior y posterior para mostrar la diferencia en la consola:
function createLogger(options = {}) {
const {
collapsed = true,
transformer = state => state,
mutationTransformer = mut => mut,
filter = () => true
} = options
return store => {
let prevState = deepCopy(store.state)
store.subscribe((mutation, state) => {
const nextState = deepCopy(state)
if (!filter(mutation, prevState, nextState)) return
const now = new Date()
const time = ` @ ${pad(now.getHours(), 2)}:${pad(now.getMinutes(), 2)}:${pad(now.getSeconds(), 2)}`
console.groupCollapsed(`mutation ${mutation.type}${time}`)
console.log('%c prev state', 'color: #9E9E9E; font-weight: bold', transformer(prevState))
console.log('%c mutation', 'color: #03A9F4; font-weight: bold', mutationTransformer(mutation))
console.log('%c next state', 'color: #4CAF50; font-weight: bold', transformer(nextState))
console.groupEnd()
prevState = nextState
})
}
}
Además, si Vue DevTools está disponible, Vuex se registra como un plugin adicional para exponer mutaciones, viajess en el teimpo y depuración de estado.
Funciones auxiliares: mapState, mapMutations, mapActions y mapGetters
Vuex ofrece atajos para evitar escribir repetidamente this.$store.state o this.$store.dispatch. Internamente normalizan el espacio de nombres y el mapa de claves:
function normalizeMap(map) {
return Array.isArray(map)
? map.map(key => ({ key, val: key }))
: Object.keys(map).map(key => ({ key, val: map[key] }))
}
function normalizeNamespace(fn) {
return (namespace, map) => {
if (typeof namespace !== 'string') {
map = namespace
namespace = ''
} else if (namespace.charAt(namespace.length - 1) !== '/') {
namespace += '/'
}
return fn(namespace, map)
}
}
Tomando mapActions como ejemplo, la función genera métodos que llaman a dispatch con el nombre completo de la acción:
export const mapActions = normalizeNamespace((namespace, actions) => {
const res = {}
normalizeMap(actions).forEach(({ key, val }) => {
const fullName = namespace + val
res[key] = function mappedAction(...args) {
if (namespace && !getModuleByNamespace(this.$store, 'mapActions', namespace)) {
return
}
return this.$store.dispatch.apply(this.$store, [fullName].concat(args))
}
})
return res
})
De esta forma, el desarrollador puede desestructurar métodos locales mientras internamente todo se resuelve contra el store global, respetando los espacios de nombres de los módulos.