一些有趣的Javascript技巧

迷路的約翰發表於2015-11-30

整理一些刷題時學會的小技巧……

目錄:

  1. 即大於0又小於0的變數
  2. String.split() 與 正規表示式
  3. 快取的幾種方法
  4. 初始化一個陣列

即大於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的程式碼複雜度檢測的(比如我),放棄這些想法吧……

 

篇幅有點長了……就此打住……

 

相關文章