vue-mobx-finegrained
Version:
Vue 3 composable for a complete MobX state management integration.
444 lines (375 loc) • 13.9 kB
text/typescript
import { markRaw, reactive, watch, UnwrapNestedRefs, toRaw } from "vue";
import { tryOnScopeDispose } from '@vueuse/core'
import { reaction, ObservableMap, ObservableSet, action, observable, computed } from 'mobx'
import { deepObserve } from "mobx-utils";
import get from 'lodash/get'
import cloneDeep from 'lodash/cloneDeep'
import clone from 'lodash/clone'
/**
* Notify MobX reactivity system of changes to propagate Vue reactive changes, by reassigning values.
*
* @param obj
* @param prop
* @param key
*/
export function notify (obj) {
return clone(obj)
}
/**
* Get setters and getters of a class instance.
* Also gets all magic methods (getters and setters) stored in 'all' param.
*
* @param instance
* @return { get: [...], set: [...], all: [...] }
*/
function getMagicProps (instance) {
let proto = Object.getPrototypeOf(instance)
let props = []
while (proto && proto.constructor.name !== 'Object') {
props.push.apply(props, Object.entries(
Object.getOwnPropertyDescriptors(proto)
))
proto = Object.getPrototypeOf(proto.constructor.prototype)
}
const magicProps = { get: {}, set: {}, all: {} }
for (let i = 0; i < props.length; i++) {
if (props[i][0] !== '__proto__') {
if (typeof props[i][1].get === 'function') {
// getters
magicProps.get[props[i][0]] = true
magicProps.all[props[i][0]] = true
}
if (typeof props[i][1].set === 'function') {
// setters
magicProps.set[props[i][0]] = true
magicProps.all[props[i][0]] = true
}
}
}
return magicProps
}
/**
* Setup MobX observable and Vue Reactive state on an object.
*
* @param observers
* @param raw
* @param annotations
*/
export function reactiveObservable<TStore extends Record<string | number, any>> (
observers: TStore,
raw: Array<any> | Boolean = [],
): UnwrapNestedRefs<TStore> {
const obj = Object.assign({}, observers)
if (Array.isArray(raw)) {
raw.forEach(prop => {
if (typeof obj[prop] === 'object') {
if (Array.isArray(obj[prop])) {
obj[prop] = obj[prop].map(el => markRaw(el))
}
else {
obj[prop] = markRaw(obj[prop])
}
}
})
}
else if (raw === true) {
for (const prop in observers) {
if (typeof observers[prop] === 'object') {
if (Array.isArray(obj[prop])) {
obj[prop] = obj[prop].map(el => typeof el === 'object' ? markRaw(el) : el)
}
else {
obj[prop] = markRaw(obj[prop])
}
}
}
}
return reactive(obj)
}
function isObservableMap (obj) {
return obj instanceof ObservableMap
}
function isObservableSet (obj) {
return obj instanceof ObservableSet
}
function isObservableMapOrSet (obj) {
return isObservableMap(obj) || isObservableSet(obj)
}
/**
* Create shadow prop name.
*
* @param prop
*/
export function shadowProp (prop): string {
return '__' + prop
}
/**
* Create a Vue reactive object that is a shadow of MobX state.
*
* @param obj
* @param observables
* @param options
*/
export function useMobX<Store extends Record<string, any>> (obj, observables, options = {}): UnwrapNestedRefs<Store | any> {
function shallowUpdateVue (state, observable, newValue, opts) {
// Set property of shadow state.
// If option opts.raw === true, Vues markRaw() will tag all objects as raw objects
// preventing huge reactivity objects from being created (use opts.raw = true)
// If option opts.raw is an array of properties, then markRaw()
// will be applied only to those object properties
if (opts.raw === true || typeof rawMap[observable] !== 'undefined') {
// Handle raw
if (typeof state[observable] === 'object') {
if (Array.isArray(newValue)) {
// Handle array
state[observable] = newValue.map(el => typeof el === 'object' ? markRaw(cloneDeep(el)) : cloneDeep(el))
}
else {
// Handle object
state[observable] =
// If it's an MobX's ObservbaleMap or ObservableSet, copy reference only!
// Otherwise the reactivity gets lost
isObservableMapOrSet(newValue)
? markRaw(newValue)
: markRaw(cloneDeep(newValue))
}
}
else {
// Primitives values are copied by value, no need to markRaw() or clone them
state[observable] = newValue
}
}
else {
state[observable] = isObservableMapOrSet(newValue)
// If it's an MobX's ObservbaleMap or ObservableSet, copy reference only!
// Otherwise the reactivity Observable values get lost
? newValue
// Full clone the property without marking it raw
: cloneDeep(newValue)
}
}
function deepUpdateVue (state, observable, change, path) {
path = path.replaceAll('/', '.')
const ref = path === '' ? state[observable] : get(state[observable], path)
switch (change.type) {
case 'add':
case 'update':
if (change.observableKind === 'map') {
ref.set(change.name, change.newValue)
}
else if (change.observableKind === 'set') {
ref.add(change.newValue)
}
else {
ref[change.name] = change.newValue
}
break;
case 'remove':
delete ref[change.name]
break;
case 'delete':
ref.delete(change.name)
break;
case 'splice':
if (change.added.length) {
ref.splice(change.index, change.removedCount, ...change.added)
}
else {
ref.splice(change.index, change.removedCount)
}
break;
}
}
// Init options
const opts = {
raw: false, // true for all observables or [] of items that must be markRaw()
attach: 'vm', // property name to attach to obj
auto: false,
...options
}
const shadowAttach = shadowProp(opts.attach)
// If singleton is already instantiated
// return state property
if (opts.attach && shadowAttach in obj) {
return obj[shadowAttach]
}
// Get all the magic setters and getters from the object
const magicProps = getMagicProps(obj)
// Get all the props from the source object & its prototype
let proto = Object.getPrototypeOf(obj)
let allProps = Object.getOwnPropertyNames(obj)
while (proto && proto.constructor.name !== 'Object') {
allProps.push.apply(allProps, Object.getOwnPropertyNames(proto)
.filter(el => !['caller', 'callee', 'arguments'].includes(el)))
proto = Object.getPrototypeOf(proto.constructor.prototype)
}
if (observables === 'auto') {
observables = {}
for (let i = 0; i < allProps.length; i++) {
const prop = allProps[i]
if ((prop in opts.auto && opts.auto[prop] === false)
|| (prop === opts.attach && prop === shadowAttach)
) {
continue;
}
observables[prop] = true
}
}
// Collect all observable definitions
const observers = {}
for (const observable in observables) {
observers[observable] = typeof obj[observable] === 'function'
? async function (...args) {
await setTimeout(() => {});
return obj[observable].bind(obj)(...args)
}
: (isObservableMapOrSet(obj[observable])
? (
isObservableMap(obj[observable])
? new Map(cloneDeep(obj[observable]))
: new Set(cloneDeep(obj[observable]))
)
: cloneDeep(obj[observable]))
}
// Setup the base combined MobX observable and Vue reactive state
const state = reactiveObservable(observers, opts.raw)
// Create a raw map for fast access
const rawMap = {}
if (Array.isArray(opts.raw)) {
opts.raw.forEach(prop => rawMap[prop] = true)
}
// Determine if property should be watched by Vue & MobX
function shouldWatch (obj, magicProps, observables, observable) {
return typeof magicProps.get[observable] !== 'undefined' || typeof obj[observable] !== 'function'
}
// Create shadow of all the props that are not observables
allProps.forEach(prop => {
if (typeof observables[prop] === 'undefined' && prop !== opts.attach && prop !== shadowAttach) {
let getSet = {
get: typeof obj[prop] === 'function'
? function () {
return async function (...args) {
await setTimeout(() => {});
return obj[prop].bind(obj)(...args)
}
} : function () {
return obj[prop]
},
set: function (value) {
obj[prop] = value;
},
enumerable: true
}
Object.defineProperty(state, prop, getSet)
}
})
// MobX reactions
const createMobXObserver = {}
// MobX reaction disposers
const mobxDisposer = {}
// Vue watchers
const createVueWatcher = {}
// Vue watch disposers
const vueDisposer = {}
// Iterate over all the defined observables
for (let observable in observables) {
if (shouldWatch(obj, magicProps, observables, observable)) {
// Create a Vue Watcher to set values on the original MobX observable object
// Watches the values change in the Vue shadow state and sets
// them back to the MobX observable
createVueWatcher[observable] = () => {
return watch(() => state[observable], function (newValue) {
// Dispose the MobX reaction before setting value
// to prevent infinite reactive recursion
mobxDisposer[observable]()
// Set the value of the original object property
// Set to raw object instead of reactive() object
// By calling toRaw() on the new value
obj[observable] = toRaw(newValue)
// Recreate MobX reaction and its disposer function after setting a new value
mobxDisposer[observable] = createMobXObserver[observable]()
}, { deep: true })
}
// Create Vue watcher and store the watch disposer
vueDisposer[observable] = createVueWatcher[observable]()
// Create MobX reactions that will change the Vue shadow state reactive() props
createMobXObserver[observable] = () => {
const isComputedGetter = typeof magicProps.get[observable] !== 'undefined'
if (isComputedGetter) {
// Handle computed getters
// Computed getters should be updated shallowly
const computedHandler = newValue => {
// Dispose the Vue watcher before setting a value.
// This is to prevent infinite recursion
vueDisposer[observable]()
// Update Vue reactive state by reassignment
shallowUpdateVue(state, observable, newValue, opts)
// Create Vue watcher and its disposer
vueDisposer[observable] = createVueWatcher[observable]()
}
const computedObserveDisposer = reaction(() => obj[observable], computedHandler)
return () => {
computedObserveDisposer()
}
}
else {
// Handle regular primitives, objects & arrays that are not computed
// Reaction handler will run only on primitives and when
// the array/objects are reassigned via = operator, otherwise
// the deep changes are handled by deepObserveHandler()
const reactionHandler = (newValue, oldValue) => {
if (oldValue !== newValue) {
// Dispose the Vue watcher before setting a value.
// This is to prevent infinite recursion
vueDisposer[observable]()
// Dispose MobX observers as well, because
// They have to be recreated
mobxDisposer[observable]()
// Set property of shadow state.
shallowUpdateVue(state, observable, newValue, opts)
// Recreate MobX shallow and deep watchers
mobxDisposer[observable] = createMobXObserver[observable]()
// Recreate Vue watcher and its disposer
vueDisposer[observable] = createVueWatcher[observable]()
}
}
// Create shallow MobX reaction observer
const shallowObserveDisposer = reaction(() => obj[observable], reactionHandler)
// Handle deep object changes in MobX and mirror them into the Vue reactive state
// based on those changes. More: https://github.com/mobxjs/mobx-utils#deepobserve
// https://github.com/mobxjs/mobx/issues/214
const deepObserveHandler = (change, path, root) => {
// console.log('change', change, 'path', path, 'root', root)
// Dispose the Vue watcher before setting a value.
// This is to prevent infinite recursion
vueDisposer[observable]()
// Get updated values from MobX and change them in Vue object
deepUpdateVue(state, observable, change, path)
// Recreate Vue watcher and its disposer
vueDisposer[observable] = createVueWatcher[observable]()
}
const deepObserveDisposer = deepObserve(obj[observable], deepObserveHandler)
return () => {
// MobX shallow and deep disposers
shallowObserveDisposer()
deepObserveDisposer()
}
}
}
// Create MobX reaction and store the reaction disposer
mobxDisposer[observable] = createMobXObserver[observable]()
}
}
// Dispose MobX reactions and Vue watchers on
// scope destruction to prevent memory leaks
tryOnScopeDispose(() => {
for (let observable in mobxDisposer) mobxDisposer[observable]()
for (let observable in vueDisposer) vueDisposer[observable]()
})
// Attach to the source object
// if it is not attached already
if (opts.attach && !(shadowAttach in obj)) {
obj[shadowAttach] = state
}
return state
}