一道面试题引发的对 JS 类型转换的思考

最近群里有人发了下面这题:

实现一个函数,运算结果可以满足如下预期结果:


add(1)(2) // 3

add(1, 2, 3)(10) // 16

add(1)(2)(3)(4)(5) // 15


对于一个好奇的切图仔来说,忍不住动手尝试了一下,看到题目首先想到的是会用到高阶函数以及 Array.prototype.reduce()。


高阶函数(Higher-order function):高阶函数的意思是它接收另一个函数作为参数。在 javascript 中,函数是一等公民,允许函数作为参数或者返回值传递。

得到了下面这个解法:


function add() {

    var args = Array.prototype.slice.call(arguments);

 

    return function() {

        var arg2 = Array.prototype.slice.call(arguments);

        return args.concat(arg2).reduce(function(a, b){

            return a + b;

        });

    }

}


验证了一下,发现错了:


add(1)(2) // 3

add(1, 2)(3) // 6

add(1)(2)(3) // Uncaught TypeError: add(...)(...) is not a function(…)


上面的解法,只有在 add()() 情形下是正确的。而当链式操作的参数多于两个或者少于两个的时候,无法返回结果。


而这个也是这题的一个难点所在,add()的时候,如何既返回一个值又返回一个函数以供后续继续调用?


后来经过高人指点,通过重写函数的 valueOf 方法或者 toString 方法,可以得到其中一种解法:


function add () {

    var args = Array.prototype.slice.call(arguments);

 

    var fn = function () {

        var arg_fn = Array.prototype.slice.call(arguments);

        return add.apply(null, args.concat(arg_fn));

    }

 

    fn.valueOf = function () {

        return args.reduce(function(a, b) {

            return a + b;

        })

    }

 

    return fn;

}


嗯?第一眼看到这个解法的时候,我是懵逼的。因为我感觉 fn.valueOf() 从头到尾都没有被调用过,但是验证了下结果:


add(1) // 1

add(1,2)(3) //6

add(1)(2)(3)(4)(5) // 15


神奇的对了!那么玄机必然是在上面的 fn.valueOf = function() {} 内了。为何会是这样呢?这个方法是在函数的什么时刻执行的?且听我一步一步道来。


valueOf 和 toString


先来简单了解下这两个方法:


Object.prototype.valueOf()


用 MDN 的话来说,valueOf() 方法返回指定对象的原始值。


JavaScript 调用 valueOf() 方法用来把对象转换成原始类型的值(数值、字符串和布尔值)。但是我们很少需要自己调用此函数,valueOf 方法一般都会被 JavaScript 自动调用。


记住上面这句话,下面我们会细说所谓的自动调用是什么意思。


Object.prototype.toString()


toString() 方法返回一个表示该对象的字符串。


每个对象都有一个 toString() 方法,当对象被表示为文本值时或者当以期望字符串的方式引用对象时,该方法被自动调用。


这里先记住,valueOf() 和 toString() 在特定的场合下会自行调用。


原始类型


好,铺垫一下,先了解下 javascript 的几种原始类型,除去 Object 和 Symbol,有如下几种原始类型:


  • Number

  • String

  • Boolean

  • Undefined

  • Null


在 JavaScript 进行对比或者各种运算的时候会把对象转换成这些类型,从而进行后续的操作,下面逐一说明:


String 类型转换


在某个操作或者运算需要字符串而该对象又不是字符串的时候,会触发该对象的 String 转换,会将非字符串的类型尝试自动转为 String 类型。系统内部会自动调用 toString 函数。举个例子:


var obj = {name'Coco'};

var str = '123' + obj;

console.log(str);  // 123[object Object]


转换规则:


  1. 如果 toString 方法存在并且返回原始类型,返回 toString 的结果。

  2. 如果 toString 方法不存在或者返回的不是原始类型,调用 valueOf 方法,如果 valueOf 方法存在,并且返回原始类型数据,返回 valueOf 的结果。

  3. 其他情况,抛出错误。


上面的例子实际上是:


var obj = {name'Coco'};

var str = '123' + obj.toString();


其中,obj.toString() 的值为 "[object Object]"。


假设是数组:


var arr = [1, 2];

var str = '123' + arr;

 

console.log(str); // 1231,2


上面 + arr ,由于这里是个字符串加操作,后面的 arr 需要转化为一个字符串类型,所以其实是调用了 + arr.toString() 。


但是,我们可以自己改写对象的 toString,valueOf 方法:


var obj = {

    toStringfunction() {

        console.log('调用了 obj.toString');

        return {};

    },

    valueOffunction() {

        console.log('调用了 obj.valueOf')

        return '110';

    }

}

 

alert(obj);

// 调用了 obj.toString

// 调用了 obj.valueOf

// 110


上面 alert(obj + '1') ,obj 会自动调用自己的 obj.toString() 方法转化为原始类型,如果我们不重写它的 toString 方法,将输出 [object Object]1 ,这里我们重写了 toString ,而且返回了一个原始类型字符串 111 ,所以最终 alert 出了 1111。


上面的转化规则写了,toString 方法需要存在并且返回原始类型,那么如果返回的不是一个原始类型,则会去继续寻找对象的 valueOf 方法:


下面我们尝试证明如果在一个对象尝试转换为字符串的过程中,如果 toString() 方法不可用的时候,会发生什么。


这个时候系统会再去调用 valueOf() 方法,下面我们改写对象的 toString 和 valueOf:


var obj = {

    toStringfunction() {

        console.log('调用了 obj.toString');

        return {};

    },

    valueOffunction() {

        console.log('调用了 obj.valueOf')

        return '110';

    }

}

 

alert(obj);

// 调用了 obj.toString

// 调用了 obj.valueOf

// 110


从结果可以看到,当 toString 不可用的时候,系统会再尝试 valueOf 方法,如果 valueOf 方法存在,并且返回原始类型(String、Number、Boolean)数据,返回valueOf的结果。


那么如果,toString 和 valueOf 返回的都不是原始类型呢?看下面这个例子:


var obj = {

    toStringfunction() {

        console.log('调用了 obj.toString');

        return {};

    },

    valueOffunction() {

        console.log('调用了 obj.valueOf')

        return {};

    }

}

 

alert(obj);

// 调用了 obj.toString

// 调用了 obj.valueOf

// Uncaught TypeError: Cannot convert object to primitive value


可以发现,如果 toString 和 valueOf 方法均不可用的情况下,系统会直接返回一个错误。


在查证了 ECMAScript5 官方文档后,发现上面的描述有一点问题,Object 类型转换为 String 类型的转换规则远比上面复杂。转换规则为:1.设原始值为调用 ToPrimitive 的结果;2.返回 ToString(原始值) 。关于 ToPrimitive 和 ToString 的规则可以看看官方文档:ECMAScript5 — ToString


Number 类型转换


上面描述的是 String 类型的转换,很多时候也会发生 Number 类型的转换:


  • 调用 Number() 函数,强制进行 Number 类型转换

  • 调用 Math.sqrt() 这类参数需要 Number 类型的方法

  • obj == 1 ,进行对比的时候

  • obj + 1 , 进行运算的时候


与 String 类型转换相似,但是 Number 类型刚好反过来,先查询自身的 valueOf 方法,再查询自己 toString 方法:


  1. 如果 valueOf 存在,且返回原始类型数据,返回 valueOf 的结果。

  2. 如果 toString 存在,且返回原始类型数据,返回 toString 的结果。

  3. 其他情况,抛出错误。


按照上述步骤,分别尝试一下:


var obj = {

    valueOffunction() {

        console.log('调用 valueOf');

        return 5;

    }

}

 

console.log(obj + 1);

// 调用 valueOf

// 6


var obj = {

    valueOffunction() {

        console.log('调用 valueOf');

        return {};

    },

    toStringfunction() {

        console.log('调用 toString');

        return 10;

    }

}

 

console.log(obj + 1);

// 调用 valueOf

// 调用 toString

// 11


var obj = {

    valueOffunction() {

        console.log('调用 valueOf');

        return {};

    },

    toStringfunction() {

        console.log('调用 toString');

        return {};

    }

}

 

console.log(obj + 1);

// 调用 valueOf

// 调用 toString

// Uncaught TypeError: Cannot convert object to primitive value


Boolean 转换


什么时候会进行布尔转换呢:


  • 布尔比较时

  • if(obj) , while(obj) 等判断时


简单来说,除了下述 6 个值转换结果为 false,其他全部为 true:


  • undefined

  • null

  • -0

  • 0或+0

  • NaN

  • ”(空字符串)


Boolean(undefined) // false

Boolean(null) // false

Boolean(0) // false

Boolean(NaN) // false

Boolean('') // false


Function 转换


好,最后回到我们一开始的题目,来讲讲函数的转换。


我们定义一个函数如下:


function test() {

    var a = 1;

    console.log(1);

}


如果我们仅仅是调用 test 而不是 test() ,看看会发生什么?



可以看到,这里把我们定义的 test 函数的重新打印了一遍,其实,这里自行调用了函数的 valueOf 方法:



我们改写一下 test 函数的 valueOf 方法。


test.valueOf = function() {

    console.log('调用 valueOf 方法');

    return 2;

}

 

test;

// 输出如下:

// 调用 valueOf 方法

// 2


与 Number 转换类似,如果函数的 valueOf 方法返回的不是一个原始类型,会继续找到它的 toString 方法:


test.valueOf = function() {

    console.log('调用 valueOf 方法');

    return {};

}

 

test.toStringfunction() {

    console.log('调用 toString 方法');

    return 3;

}

 

test;

// 输出如下:

// 调用 valueOf 方法

// 调用 toString 方法

// 3


破题


再看回我正文开头那题的答案,正是运用了函数会自行调用 valueOf 方法这个技巧,并改写了该方法。我们稍作改变,变形如下:


function add () {

    console.log('进入add');

    var args = Array.prototype.slice.call(arguments);

 

    var fn = function () {

        var arg_fn = Array.prototype.slice.call(arguments);

        console.log('调用fn');

        return add.apply(null, args.concat(arg_fn));

    }

 

    fn.valueOf = function () {

        console.log('调用valueOf');

        return args.reduce(function(a, b) {

            return a + b;

        })

    }

 

    return fn;

}


当调用一次 add 的时候,实际是是返回 fn 这个 function,实际是也就是返回 fn.valueOf();


add(1);

// 输出如下:

// 进入add

// 调用valueOf

// 1


其实也就是相当于:


[1].reduce(function(a, b) {

    return a + b;

})

// 1


当链式调用两次的时候:


add(1)(2);

// 输出如下:

// 进入add

// 调用fn

// 进入add

// 调用valueOf

// 3


当链式调用三次的时候:


add(1)(2)(3);

// 输出如下:

// 进入add

// 调用fn

// 进入add

// 调用fn

// 进入add

// 调用valueOf

// 6


可以看到,这里其实有一种循环。只有最后一次调用才真正调用到 valueOf,而之前的操作都是合并参数,递归调用本身,由于最后一次调用返回的是一个 fn 函数,所以最终调用了函数的 fn.valueOf,并且利用了 reduce 方法对所有参数求和。


除了改写 valueOf 方法,也可以改写 toString 方法,所以,如果你喜欢,下面这样也可以:


function add () {

    var args = Array.prototype.slice.call(arguments);

 

    var fn = function () {

        var arg_fn = Array.prototype.slice.call(arguments);

        return add.apply(null, args.concat(arg_fn));

    }

 

    fn.toString = function() {

        return args.reduce(function(a, b) {

            return a + b;

        })

    }

 

    return fn;

}


这里有个规律,如果只改写 valueOf() 或是 toString() 其中一个,会优先调用被改写了的方法,而如果两个同时改写,则会像 Number 类型转换规则一样,优先查询 valueOf() 方法,在 valueOf() 方法返回的是非原始类型的情况下再查询 toString() 方法。


后记


在尝试了更多的浏览器之后,发现了上述解法的诸多问题,在 chrome 56 55 下,结果正常。在更新到最新的 chrome57 ,控制台下,结果都会带上 function 字段,在 firefox 下,直接不生效

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值