2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

JavaScriptで副作用なくオブジェクトを操作したい

Posted at

関数型言語にふれてから再代入見るのが嫌になった系エンジニアです。

こんな感じのオブジェクトとがあるとします。

const family = {
    parents: {
        father: { name:"titi", age: 34 },
        mother: { name:"haha", age: 34 }
    },
    childlen: [
        { name:"musuko", age: 2 },
        { name:"musume", age: 0 }
    ]
};

そしてこう思うのです。
motherのageだけ変更したオブジェクトが欲しいな、、と。

Object.assignでやってみる

const newFamily = Object.assign({}, family)
newFamily.parents.mother.age = 26;

これでmotherの年齢だけ変更されたnewFamilyができましたね。
familyオブジェクトとnewFamilyオブジェクトを見てみましょう。

familyオブジェクト

{
     parents: {
         father: { name: "titi", age: 34 },
         mother: { name: "haha", age: 26 }
     },
     childlen: [
         { name: "musuko", age: 2 },
         { name: "musume", age: 0 }
     ]
}

newFamilyオブジェクト

{
     parents: {
         father: { name: "titi", age: 34 },
         mother: { name: "haha", age: 26 }
     },
     childlen: [
         { name: "musuko", age: 2 },
         { name: "musume", age: 0 }
     ]
}

おおっと!?
familyの奥さんも若くなっとるやん。

調べてみたらObject.assignはプリミティブ値以外はシャローコピーになるみたいでした。
そもそもnewFamilyとして定義した後にプロパティを代入するのがなんか嫌ですね...
どうせならnewFamilyの宣言と値の更新を同時にやりたい....

作ってみました

const cloneDeeply = (src) => JSON.parse(JSON.stringify(src));

const replaceObjNode = (obj, properties, newNode)=> {
    const objTree = properties.reduce(
        (acc, prop) => [...acc, cloneDeeply(acc.slice(-1)[0][prop])],
        [cloneDeeply(obj)]
    );
    return objTree
        .slice(0, -1)
        .reduceRight(
            (acc, cur, i) => Object.assign(cur, {[properties[i]]: acc }),
            newNode
        );
};

解説していきます。

cloneDeeply

const cloneDeeply = (src) => JSON.parse(JSON.stringify(src));

まんまです、プリミティブ値以外もdeepに複製します。
実はObject.asssignのドキュメントに記載されていたものをパクっただけです。。。。

replaceObjNode

・変更元となるオブジェクト
・変更するプロパティまでのパスとして文字列配列
・変更したい値

を引数に取り値変更後の新しいオブジェクトを生成します。

const replaceObjNode = (obj: , properties, newNode)=> {
    const objTree = properties.reduce(
        (acc, prop) => [...acc, cloneDeeply(acc.slice(-1)[0][prop])],
        [cloneDeeply(obj)]
    );
    return objTree
        .slice(0, -1)
        .reduceRight(
            (acc, cur, i) => Object.assign(cur, {[properties[i]]: acc }),
            newNode
        );
};

objTree配列から見ていきますと。
まずreduceのコールバック内でproperties配列の各要素propが変更元オブジェクトのプロパティに該当します。
acc.slice(-1)[0][prop]アキュムレータ配列の最終要素のオブジェクトからプロパティを指定して要素を取り出します。

こんな感じで呼び出してみますと

const family = {
    parents: {
        father: { name:"titi", age: 34 },
        mother: { name:"haha", age: 34 }
    },
    childlen: [
        { name:"musuko", age: 2 },
        { name:"musume", age: 0 }
    ]
};

const newFamily = replaceObjNode(family, ["parents", "mother", "age"], 26);

デフォルトでは[cloneDeeply(obj)]を指定しているので下記配列の

[
    {
        "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } },
        "childlen": [{ "name": "musuko", "age": 2 }, { "name": "musume", "age": 0 }]
    }
]

最終要素オブジェクト内のparentプロパティが追加されます。

{ "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } }

これを繰り返すことでproperties配列をobjectのツリー状配列に変換します。
最終的にこんな感じで展開されます

[
    {
        "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } },
        "childlen": [{ "name": "musuko", "age": 2 }, { "name": "musume", "age": 0 }]
    },
    {
        "father": { "name": "titi", "age": 34 },
        "mother": { "name": "haha", "age": 34 }
    },
    { "name": "haha", "age": 34 },
    34
]

obJTree配列作成後slice(0, -1)で差し替えたい値である最終要素を詰めた配列を再作成します。
その後差し替え先の値であるnewNodeをアキュムレータ初期値に指定し、右から畳み込んでいきます。

reduceRight内は下記のようにloopします。

//1loop
Object.assign({ "name": "haha", "age": 34}, { age: 26 })

//2loop
Object.assign(
    {
        "father": { "name": "titi", "age": 34 },
        "mother": { "name": "haha", "age": 34 }
    },
    { "mother": { "name": "haha", "age": 26 } }
)
//3loop
Object.assign(
    {
        "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 34 } },
        "childlen": [{ "name": "musuko", "age": 2 }, { "name": "musume", "age": 0 }]
    },
    { "parents": { "father": { "name": "titi", "age": 34 }, "mother": { "name": "haha", "age": 26 } } }
)

//result
{
     parents: {
         father: { name: "titi", age: 34 },
         mother: { name: "haha", age: 26 }
     },
     childlen: [
         { name: "musuko", age: 2 },
         { name: "musume", age: 0 }
     ]
}

これで再代入なく一発で値更新後のオブジェクトを生成する関数の出来上がりです。
ただそこそこコストかかりそうですね。。。。。。

#型もつけてみた

const cloneDeeply = <T>(src: T): T => JSON.parse(JSON.stringify(src));

const replaceObjNode = <T extends { [key: string]: any }, U>(obj: T, properties: [keyof T, ...string[]], newNode: U): T => {
    const objTree = (properties as string[]).reduce(
        (acc: { [key: string]: any }[], prop: string) => [...acc, cloneDeeply(acc.slice(-1)[0][prop])],
        [cloneDeeply(obj)]
    );
    return objTree
        .slice(0, -1)
        .reduceRight(
            (acc: { [key: string]: any } | any, cur: any, i: number) => Object.assign(cur, { [properties[i]]: acc }),
            newNode
        );
};

propertiesの一要素目はobjの一段目のプロパティ以外受け付けない文字列配列として制限しました。
propertiesがとりうる全パターンのユニオンタイプを作りたかったのですが力不足でできず。。。
というかそんなことできるんでしょうか???

newNodeも制限ゆるゆるなのでいろいろ突っ込みどころはありますが大目に見ていただけたら幸いです。。。。

#おわり
これ書いているときにはじめてreduceRight使ったのですが、
reducerの第三引数のindexってちゃんと逆順になってるんですね。びっくりしました。

ライブラリ入れるほどじゃないけど似たようなことをしたい方がいましたら良ければ使ってみてください。
アドバイス等々ありましたらコメントいただけるととてもうれしいです。

最後までお読みいただきありがとうございました。

2
2
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?