一些有趣的Javascript技巧
整理一些刷题时学会的小技巧……
目录:
- 即大于0又小于0的变量
- String.split() 与 正则表达式
- 缓存的几种方法
- 初始化一个数组
即大于0又小于0的变量
问题: 设计一个变量val,使得以下表达式返回true:
val<0 && val>0;
逻辑上来说一个数是不可能即大于0又小于0的。我们能做到的只是让一个变量一会大于0一会小于0.那么在这个表达式里是否存在先后顺序呢?答案是肯定的。我们先判断val是否大于0,然后才判断它是否小于0.不过对于普通的变量而言,这个顺序没有任何意义:普通变量的值不会因为你读取了它而发生变化。那么,什么样的变量会因为读取而发生改变呢?
没错,就是对象的访问器属性。
我们可以声明一个对象,并给其赋予一个数据属性,比如 _value = -3, 然后再给它设置一个访问器属性 val, gettar参数设置为:当我读取 val属性时,_value的值加2,并将 _value 的值返回。这样一来当我第一次判断 val<0 时,返回值为 -3+2=-1, 成立。进行第二次判断时,再一次读取了它,这次的返回值变成了 -1+2>0, 也成立,于是表达式为真。具体代码如下:
var obj = { _value: -3 }; Object.defineProperty(obj, "_val", { get: function() { this._value += 2; return this._value; } ); obj._val<0 && obj._val>0; // return true
到这里基本完成了任务。不过这个方法还不够完美。为什么?看下这个问题的升级版:
问题:设计一个变量,使得以下函数返回true:
function foo(val) { return val<0&&val>0; }
看上去没怎么变对不对?可是如果这时候把上面的变量放进去,像这样:
foo(obj.val); // false
是不行的。因为函数的参数是按值传递的。这样调用只会在传入参数时读取一次obj.val的值,随后的比较表达式里只会将这个值复制过去进行比较,而不会读取obj的属性,也就不会触发gettar函数。这种情况下,val的值最终一直都是-3+2=-1,所以无法通过测试。
解决办法是传入一个对象而不是一个值。可是传入对象后,怎么比较对象和数值呢?答案是Object.toString()方法。
当我们试图比较一个对象和一个数的大小时,会调用对象的toString()方法,并取返回值与数字比较。通常情况下对象的toString方法返回值并不是数字,所以无法比较。在这里,只要重写目标对象的ttoString方法,令它返回obj._value即可。当然,也需要用到选择器属性。
Object.defineProperty(obj, 'toString', function(){ get: function() { this._value += 2; return this._value; });
如此一来,当我们传入对象到函数参数时,并不会读取toString方法,_value保持原始值。直到比较数字和它的大小时,才调用toString并返回相应的值。大功告成。
这个特性有什么用呢?暂时没想到……不过至少可以加深对于对象属性的理解。
String.split()与正则表达式
问题:将字符串“JavascriptIsSoInteresting”分割为["Javascript","Is","So","Interesting"]
字符串的split方法大家都很熟悉,我们可以传入一个字符串参数以对目标字符串进行分割,比如:
var str = "a=2&b=3&c=4"; console.log(str.split('&')); // ['a=2','b=3','c=4']
有过进一步了解的话,还可以知道它的参数可以是正则表达式,比如:
var str="a=2&b=3#c=4"; var reg = /[&#]/g; str.split(reg); // ['a=2','b=3','c=4']
另外,split还可以接受第二个参数以决定分割的长度,这个比较简单就不说了。
现在回到问题。要分割题目中的字符串,我们缺少一个分割符。那么一个很直观的解决方法就是,在每个大写字母前面插入一个分隔符,然后再调用split方法。
var str="JavascriptIsSoInteresting"; var reg = /([A-Z][a-z])/g; str.replace(reg, '&$1').split('&'); // ["", "Javascript", "Is", "So", "Interesting"]
有点瑕疵,前面多了一个空字符串,需要再处理下。不过基本的目标算是达成了。
有没有更好的方法呢?
在这之前我们先考虑一个问题:在用split分割字符串的时候,如果字符串仅仅由分割符组成,结果会是什么?比方说,对字符串“aaaaa”,进行split('a')的处理,会得到怎样一个数组?来看下:
var str="aaa"; str.split('a'); // ['', '', '', '']
结果是由a之间的空字符串组成的新数组,也就是说当分隔符连续出现时,split会把“间隔”作为成员分配到数组中。
用正则表达式试试看:
var str="aaa"; var reg = /a/g; str.split(reg); // ['', '', '', '']
结果是一样的。到目前为止一切都很正常。接下来的这个特性才是我们需要用到的。
我们对上面的代码做一点小修改:
var str="aaa"; var reg = /(a)/g; str.split(reg); // ["", "a", "", "a", "", "a", ""]
很奇怪对不对?我只是将正则表达式用子表达式的括号括了起来,理论上没有使用子表达式的情况下应该和上面的没什么区别,但是当它跑到split里面时,奇迹就出现了:不仅原先的分割结果还在数组里,本该不存在的分隔符也回来了。
经试验证明,当split的参数是正则表达式,并且正则表达式里包含了子表达式,那么子表达式内的分隔符将保留在结果数组中,而不是通常的忽略。
将这个神奇的现象应用到题目中,我们把“一个大写字母加上若干个小写字母”作为分隔符,并给它加上括号:
var str="JavascriptIsSoInteresting"; var reg = /([A-Z][a-z]+)/g; str.split(reg); // ["","Javascript","", "Is", "", "So", "", "Interesting"]
最后还需要去除空字符串,可以使用filter方法:
str.split(reg).filter(function(val){return /\S/.test(val);});;
或者还可以更优雅一些:
str.split(reg).filter(Boolean);
这个特性的用处么,比如你想写一个代码解释器,对于 “1+20+x+y”这样的输入,可能需要将它分解成["1","+","20","+","x","y"]时,就可以这么办了。
缓存的几种方法
问题:令以下函数返回true:
function foo() { return Math.random()*Math.random()*Math.random===0.5; }
显然这个函数几乎不可能返回true。其实Math.random()只是个幌子,为了达到目的我们势必要重新Math.random()。问题就在于,怎么写。
很简单地,只需要写一个函数,返回值是0.5的三次根式就可以了。不过这个返回值不太好求。所以我决定定义一个函数,第一次调用时返回0.5,之后每次调用返回都是1.
var val = 0; function m() { if(val != 1) val += 0.5; return val; }
当然,全局变量是魔鬼,所以需要把val封装在一个闭包里:
function v() { var val = 0; return function() { if(val != 1) val += 0.5; return val; } } var m = v();
m()*m()*m();
// return 0.5
这就是第一种方式。
其实说道这里是不是觉得有点熟悉?没错这和我们的第一个问题其实很相似,同样可以用对象的访问器属性解决。
var obj = { _value: 0 }; Object.defineProperty(obj,'val',{ get: function(){ if(this._value != 1) this._value+= 0.5; return this._value; } }); var i = function() { return obj.val; } m()*m()*m(); // return 0.5
最后一种方法,我们可以不用全局变量,也不用闭包。把函数本身看成一个对象即可:
function m() { if(this.val != 1) this.val+=0.5; return this.val; } m.val = 0;
m()*m()*m()
// return 1
到目前为止这题貌似和缓存没有多大关系。其实只要把上面作为存储数据的value改成一个数组/对象,对value的操作改成为其添加一个元素,那么它就可以作为缓存使用了。
初始化数组
问题:声明一个长度为给定值的数组,并初始化所有元素为0.
当然,这可以用for循环来做:
var arr = []; for(var i=0; i<n; i++) { arr[i] = 0; }
不过我们还可以做得更酷一点:
var n = 4; arr = Array(n+1).join('0').split('').map(Number); // [0,0,0,0]
像这样,用一行语句就初始化了一个全为0的数组。
不过有个缺点:如果我想初始化的值不是个位数,比如说都是12呢?
很简单,记得上面说过的split方法了么?可以这么做:
var n = 4; arr = Array(n+1).join('12').split(/(12)/).map(Number).filter(Boolean); // [12,12,12,12]
其中正则部分还可以稍微改一改,改成以长度划分,会更优雅些:
var n = 4; arr = Array(n+1).join('1222').split(/(\d{4})/).map(Number).filter(Boolean); // [1222,1222,1222,1222]
上面的方法后来考虑了一下,感觉有点绕,为何我要把一个Array先分割成字符串然后再合并成数组呢?不能直接就用map么,像这样:
Array(n+1).map(function(){return 0;});
试了下发现不行,对于用Array(n)这种形式创建的数组,不管怎么用map每个元素都仍然是undefined。
但是,
这个思路还是有拓展的余地的,比如如果我们要声明一个4*4的二维数组,可以这么做:
var n = 4; var arr = Array(n+1).join('0').split('').map(function(v){ return Array(n+1).join('0').split(''); });
更多维数可以继续嵌套下去……
最后要考虑的问题是:这样做虽然很酷,但是有没有必要呢?引用了一大堆方法感觉速度会很慢。
试一试:
var arr1 = []; var arr2 = []; var n = 1e6; console.time("for"); for(var i=0; i<n; i++) arr1[i] = 0; console.timeEnd("for"); console.time("Array"); arr2 = Array(n+1).join('0').split('').map(Number); console.timeEnd("Array");
输出是:
for: 944.83ms Array: 170.69ms
哎哟性能也不错的样子。上面是在Firebug下运行的数据。切换到本地node.js试试看:
for: 22.448ms Array: 124.617ms
……
所以在浏览器端运行代码时,放心大胆地用吧,浏览器对这些原生方法做的优化简直不要太厉害。
而如果是在后端运行的,或者想用这些方法糊弄过leetcode的代码复杂度检测的(比如我),放弃这些想法吧……
篇幅有点长了……就此打住……