Comme vous le savez en JavaScript, les objets sont copiés par référence. Mais parfois, vous devez effectuer un clonage en profondeur d'un objet. De nombreuses bibliothèques js proposent leur implémentation de la fonction deepClone pour ce cas. Mais, malheureusement, la plupart des bibliothèques ne prennent pas en compte plusieurs éléments importants:
- Les tableaux peuvent se trouver dans l'objet et il est préférable de les copier en tant que tableaux
- L'objet peut avoir des champs avec un symbole comme clé
- Les champs d'objet ont des descripteurs autres que ceux par défaut
- Les fonctions peuvent se trouver dans les champs de l'objet et doivent également être clonées.
- Un objet a enfin un prototype différent de Object.prototype
Qui l'a cassé, j'ai placé le code complet sous le spoilerfunction deepClone(source) { return ({ 'object': cloneObject, 'function': cloneFunction }[typeof source] || clonePrimitive)(source)(); } function cloneObject(source) { return (Array.isArray(source) ? () => source.map(deepClone) : clonePrototype(source, cloneFields(source, simpleFunctor({}))) ); } function cloneFunction(source) { return cloneFields(source, simpleFunctor(function() { return source.apply(this, arguments); })); } function clonePrimitive(source) { return () => source; } function simpleFunctor(value) { return mapper => mapper ? simpleFunctor(mapper(value)) : value; } function makeCloneFieldReducer(source) { return (destinationFunctor, field) => { const descriptor = Object.getOwnPropertyDescriptor(source, field); return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? { ...descriptor, value: deepClone(descriptor.value) } : descriptor)); }; } function cloneFields(source, destinationFunctor) { return (Object.getOwnPropertyNames(source) .concat(Object.getOwnPropertySymbols(source)) .reduce(makeCloneFieldReducer(source), destinationFunctor) ); } function clonePrototype(source, destinationFunctor) { return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source))); }
Mon implémentation est écrite dans un style fonctionnel qui me procure fiabilité, stabilité et simplicité. Mais comme, malheureusement, beaucoup ne peuvent toujours pas reconstruire leur pensée avec le procéduralisme et la pseudo-POO, je vais expliquer chaque brique de construction de mon implémentation:
La fonction deepClone elle-même prendra 1 argument source - la source à partir de laquelle nous clonerons, et son clone profond avec toutes les fonctionnalités ci-dessus sera retourné:
function deepClone(source) { return ({ 'object': cloneObject, 'function': cloneFunction }[typeof source] || clonePrimitive)(source)(); }
Tout est simple ici, selon le type de données dans la source, une fonction est sélectionnée qui peut la cloner, et la source elle-même lui est transférée.
Vous pouvez également remarquer que le résultat renvoyé est appelé en tant que fonction sans paramètres avant d'être renvoyé à l'utilisateur. Ceci est nécessaire, car j'encapsule la valeur dans laquelle je clone, dans le foncteur le plus simple, afin de pouvoir le muter sans violer la pureté des fonctions auxiliaires. Voici l'implémentation de ce foncteur:
function simpleFunctor(value) { return mapper => mapper ? simpleFunctor(mapper(value)) : value; }
Il peut faire 2 choses - mapper (si la fonction mapper lui est transmise) et extraire (si rien n'est passé).
Nous allons maintenant analyser les fonctions auxiliaires cloneObject, cloneFunction et clonePrimitive. Chacun d'eux prend 1 argument de source d'un type spécifique et retourne son clone.
La mise en œuvre de
cloneObject doit tenir compte du fait que les tableaux sont également de type objet, eh bien, dans d'autres cas, ils doivent cloner les champs et le prototype. Voici sa mise en œuvre:
function cloneObject(source) { return (Array.isArray(source) ? () => source.map(deepClone) : clonePrototype(source, cloneFields(source, simpleFunctor({}))) ); }
Le tableau peut être copié à l'aide de la méthode slice, mais comme nous avons un clonage profond et que le tableau peut contenir non seulement des valeurs primitives, la méthode map est utilisée avec le deepClone décrit ci-dessus comme argument.
Pour les autres objets, nous créons un nouvel objet et l'enveloppons dans notre foncteur décrit ci-dessus, clonons les champs (ainsi que les descripteurs) à l'aide de la fonction d'assistance cloneFields, puis clonons le prototype à l'aide de clonePrototype.
Fonctions d'assistance que je décrirai ci-dessous. En attendant, envisagez la mise en œuvre de
cloneFunction :
function cloneFunction(source) { return cloneFields(source, simpleFunctor(function() { return source.apply(this, arguments); })); }
Vous ne pouvez tout simplement pas cloner une fonction avec toute la logique. Mais vous pouvez l'encapsuler dans une autre fonction qui appelle l'original avec tous les arguments et le contexte et renvoie son résultat. Un tel «clone» gardera certainement la fonction d'origine en mémoire, mais il «pèsera» un peu et reproduira pleinement la logique d'origine. Nous enveloppons la fonction clonée dans un foncteur et en utilisant cloneFields nous copions tous les champs de la fonction d'origine dedans, car la fonction dans JS est aussi un objet, juste appelé, et peut donc y stocker des champs.
Potentiellement, une fonction peut avoir un prototype différent de Function.prototype, mais je n'ai pas considéré ce cas extrême. L'un des charmes de FP est que nous pouvons facilement ajouter un nouveau wrapper sur une fonction existante afin d'implémenter les fonctionnalités nécessaires.
La dernière brique de construction clonePrimitive sert à cloner des valeurs primitives. Mais comme les valeurs primitives sont copiées par valeur (ou par référence, mais sont immuables dans certaines implémentations des moteurs JS), nous pouvons simplement les copier. Mais puisque nous ne sommes pas censés obtenir une valeur pure, mais une valeur enveloppée dans un foncteur que l'extraction peut appeler sans arguments, nous encapsulerons notre valeur dans une fonction:
function clonePrimitive(source) { return () => source; }
Maintenant, nous implémentons les fonctions auxiliaires qui ont été utilisées ci-dessus - clonePrototype et cloneFields
Pour cloner un prototype,
clonePrototype extrait simplement le prototype de l'objet source et, en effectuant une opération de mappage sur le foncteur résultant, le définit sur l'objet cible:
function clonePrototype(source, destinationFunctor) { return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source))); }
Le clonage de champs est un peu plus compliqué, j'ai donc divisé la fonction
cloneFields en deux. La fonction externe prend la concaténation de tous les champs nommés et de tous les champs de symboles, en recevant absolument tous les champs, et les exécute via le réducteur créé par la fonction auxiliaire:
function cloneFields(source, destinationFunctor) { return (Object.getOwnPropertyNames(source) .concat(Object.getOwnPropertySymbols(source)) .reduce(makeCloneFieldReducer(source), destinationFunctor) ); }
makeCloneFieldReducer devrait créer pour nous une fonction de réduction qui pourrait être passée à la méthode de réduction sur un tableau de tous les champs de l'objet source. En tant que batterie, notre foncteur qui stocke la cible sera utilisé. Le réducteur doit extraire la poignée du champ de l'objet source et l'affecter au champ de l'objet cible. Mais ici, il est important de considérer qu'il existe deux types de descripteurs - avec valeur et avec get / set. Évidemment, la valeur doit être clonée, mais avec get / set il n'y a pas un tel besoin, un tel descripteur peut être retourné tel quel:
function makeCloneFieldReducer(source) { return (destinationFunctor, field) => { const descriptor = Object.getOwnPropertyDescriptor(source, field); return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? { ...descriptor, value: deepClone(descriptor.value) } : descriptor)); }; }
C’est tout. Une telle implémentation de deepClone résout tous les problèmes posés au début de l'article. De plus, il est construit sur des fonctions pures et un foncteur, ce qui donne toutes les garanties inhérentes au calcul lambda.
Je note également que je n'ai pas mis en œuvre un excellent comportement pour les collections autres qu'un tableau qui mériterait d'être cloné individuellement, comme Map ou Set. Bien que dans certains cas, cela puisse être nécessaire.