Redux, normalised entities and lodash merge

2019-07-10 03:34发布

问题:

I'm using Redux, React and Lodash with a fairly standard normalized entities store.

When I merge in new entities in a redux reducer, the references to all my existing entities change (despite not being modified), causing any pure components to re-render.

Is there an alternative to lodash's merge that can merge whilst maintaining the existing references to values that are not in the object being merged in?

let entities = { 
  [1]: {a: true },
  [2]: {a: true, b: true },
}
let response = { 
  [2]: {a: false }
}
let newEntities = _.merge({}, entities, response)

console.log(entities[1] === newEntities[1]) // false

I can't use Object.assign/ES6 Spread here as then newEntities[2].b will be deleted.

I do realise there are alternative solutions such as custom sCU and reselect, however it would be much cleaner to take care of this at the reducer level rather than having to modify every single component that does an equality reference check on its props.

回答1:

Use mergeWith with a customizer:

let keepRef = (objValue, srcValue) => (
  objValue === undefined ? srcValue : _.mergeWith({}, objValue, srcValue, keepRef)
)
let newEntities = _.mergeWith({}, entities, response, keepRef)


回答2:

I expanded on @Pavlo's awesome answer. I added support for arrays, and collections. I define a collection as an array of object's, where each object has an id key. This is very common in react/redux and normalized data.

import { mergeWith, isPlainObject, isEmpty, keyBy } from 'lodash'

// https://stackoverflow.com/a/49437903/1828637
// mergeWith customizer.
// by default mergeWith keeps refs to everything,
// this customizer makes it so that ref is only kept if unchanged
// and a shallow copy is made if changed. this shallow copy continues deeply.
// supports arrays of collections (by id).
function keepUnchangedRefsOnly(objValue, srcValue) {
    if (objValue === undefined) { // do i need this?
        return srcValue;
    } else if (srcValue === undefined) { // do i need this?
        return objValue;
    } else if (isPlainObject(objValue)) {
        return mergeWith({}, objValue, srcValue, keepUnchangedRefsOnly);
    } else if (Array.isArray(objValue)) {
        if (isEmpty(objValue) && !isEmpty(srcValue))return [...srcValue];
        else if (!isEmpty(objValue) && isEmpty(srcValue)) return objValue;
        else if (isEmpty(objValue) && isEmpty(srcValue)) return objValue; // both empty
        else {
            // if array is array of objects, then assume each object has id, and merge based on id
            // so create new array, based objValue. id should match in each spot

            if (isPlainObject(objValue[0]) && objValue[0].hasOwnProperty('id')) {
                const srcCollection = keyBy(srcValue, 'id');

                const aligned = objValue.map(el => {
                    const { id } = el;
                    if (srcCollection.hasOwnProperty(id)) {
                        const srcEl = srcCollection[id];
                        delete srcCollection[id];
                        return mergeWith({}, el, srcEl, keepUnchangedRefsOnly);
                    } else {
                        return el;
                    }
                });

                aligned.push(...Object.values(srcCollection));

                return aligned;
            } else {
                return [ ...objValue, ...srcValue ];
            }
        }
    }
}

Usage:

const state = {
    chars: ['a', 'b'],
    messages: [
        {
            id: 1,
            text: 'one'
        },
        {
            id: 2,
            text: 'ref to this entry will be unchanged'
        }
    ]
}

const response = {
    chars: ['c', 'd'],
    messages: [
        {
            id: 1,
            text: 'changed ref text one'
        },
        {
            id: 3,
            text: 'three'
        }
    ]
}

const stateNext = mergeWith({}, state, response, keepUnchangedRefsOnly)

Resulting stateNext is:

{
    chars: [
        'a',
        'b',
        'c',
        'd'
    ],
    messages: [
        {
            id: 1,
            text: 'changed ref text one'
        },
        {
            'id': 2,
            text: 'ref to this entry will be unchanged'
        },
        {
            'id': 3,
            text: 'three'
        }
    ]
}

If you want to keep undefined values, then replace mergeWith in customizer and your use case with assignWith. Example - https://stackoverflow.com/a/49455981/1828637