js简单实现immutable方法

immutable即在不改变原先元素的情况下返回一个新值,目前虽然有成熟的immutable.js,但其实在大多数情况下并不需要这么复杂。如果能用一句话实现不是更好么?

需求

实现对象和数组的set/delete方法

比如a = [1,2,3],最好的情况就是运行a.set(0,4)后可以输出[4,2,3]同时原对象不变。

最简单的实现可以是

function set(a, i, v) {  
    return a.map((vv, ii) => ii === i ? v : vv);
}

或者写到Array.prototype

Array.prototype.set = function (i, v) {  
    return this.map((vv, ii) => ii === i ? v : vv);
}

这很简单,但如果我们需要对Object进行操作呢?

这时候我们就需要用到一个函数叫做Object.assign(obj, ...otherobjs)。这是ES6的标准函数,他会将第二个及之后的参数对象可遍历属性都附加到第一个参数对象上。而且这个函数对数组也是通用的,我们只需要根据this的类型来选择[]还是{}作为初始值即可。这里的实现如下:

Object.prototype.set = (function (i, v) {  
    return Object.assign(this instanceof Array ? [] : {}, this, { [i]: v })
});
a = [1, 2, 3, 4], b = { src: "1" };  
console.log(a.set(0, 5)) // [5, 2, 3, 4]  
console.log(a) // [1, 2, 3, 4]  
console.log(b.set("src", "233")) // {src: "233"}  
console.log(b) // {src: "1"}  

至此我们便实现了immutable数组和对象的set函数。

这里还有一个问题,如果我们直接通过Object.prototype来添加函数,那么在使用for .. in时就会输出额外的项目。比如以下代码:

Object.prototype.set = function () { /* ... */ }  
for (var i in [1]) console.log(i)  
// 输出:
// 1
// set

因为Object.prototype会注入整个原型链,从而可能导致所有使用for .. in的代码工作不正常,虽然可以使用Object.prototype.hasOwnProperty()for .. in内部判断来过滤这些不必要的属性,但是有没有更好的解决方案呢?

确实是有的,我们可以用ES6中定义的函数Object.defineProperty(obj, propname, descriptor)来实现,其中的descriptor参数可以接受一个叫做enumerable的选项:

当且仅当该属性的enumerable为true时,该属性才能够出现在对象的枚举属性中。默认为 false。

因此enumerable为false或undefined时通过Object.defineProperty定义的属性就是不可枚举的,这样就不会污染对象for .. in的结果。修改后的代码如下:

Object.defineProperty(Object.prototype, "set", {  
    value: function (i, v) {
        return Object.assign(this instanceof Array ? [] : {}, this, { [i]: v })
    }
});
for (var i in [1]) console.log(i)  
// 输出:
// 1

这里还有一个问题,如果对象是自定义类型,则返回的对象不会包含该对象的原型方法(因为我们之前的代码使用的是{}),考虑以下情况:

class A {  
    constructor(v) { this.a = v }
    doSome() { console.log(this.a) }
}
let a = new A(1);  
a.doSome() // 1  
a.set("a", 2).doSome() // Uncaught TypeError: a.set(...).doSome is not a function  

这里就需要用到另一个特性,即Object.prototype.constructor,这是一个对象实例指向其类构造函数的一个访问器:

返回创建实例对象的 Object 构造函数的引用。注意,此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串。

以及Object.create(proto, [propertiesObject]),通过指定的原型对象(prototype object)来创建一个对象的实例(在不清楚一个类的构造函数需要什么参数时我们只能这么做):

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。

现在我们有了这两个函数,于是这个问题我们可以这么解决:

Object.defineProperty(Object.prototype, "set", {  
    value: function (i, v) {
        return Object.assign(this instanceof Array ? [] :
            Object.create(this.constructor.prototype), this, { [i]: v })
    }
});

Object.create(this.constructor.prototype)代替{},我们就可以得到一个与原先的this类型一致但是属性为空的对象。再来试试:

class A {  
    constructor(v) { this.a = v }
    doSome() { console.log(this.a) }
}
let a = new A(1);  
a.doSome() // 1  
a.set("a", 2).doSome() // 2  
a.doSome() // 1  

有了set再来考虑delete,对于数组,可以像这样简单的通过filter函数实现:

function delete(a, i) {  
    return a.filter((_, ii) => ii !== i)
}

那么如果是Object呢?似乎就没有这么容易了。

思路一样是使用Object.create(this.constructor.prototype)创建对象,然后通过Object.keys(this)获取所有属性key值后通过filter过滤之后再通过reduce合并成{}类型,最后通过Object.assign合并。代码如下:

Object.defineProperty(Object.prototype, "delete", {  
    value: function (i) {
        return this instanceof Array ? this.filter((_, ii) => ii !== i) :
            Object.assign(Object.create(this.constructor.prototype),
                Object.keys(this)
                    .filter(v => v !== i)
                    .reduce((a, b) => (a[b] = this[b], a), {})
            )
    }
});

再将filter和reduce合并,并且使用Object.defineProperties(obj, props)同时定义setdelete之后最终的代码如下:

Object.defineProperties(Object.prototype, {  
    set: {
        value: function (i, v) {
            return Object.assign(this instanceof Array ? [] :
                Object.create(this.constructor.prototype), this, { [i]: v })
        }
    },
    delete: {
        value: function (i) {
            return this instanceof Array ? this.filter((_, ii) => ii !== i) :
                Object.assign(Object.create(this.constructor.prototype),
                    Object.keys(this)
                        .reduce((a, b) => (v !== i && a[b] = this[b], a), {})
                )
        }
    }
});
// 测试
[1, 2, 3].set(0, 4).delete(1) // [4, 3]
({ a: 1, b: 2 }).set("b", 3).delete("a") // {b: 3}

到这里基本的immutable方法就实现了,至于更高级的元素复用(diff)、嵌套以及异常处理等这里就不做展开了。

pa001024

继续阅读此作者的更多文章