JS高階技巧

人人網FED發表於2017-09-03

本篇是看的《JS高階程式設計》第23章《高階技巧》做的讀書分享。本篇按照書裡的思路根據自己的理解和經驗,進行擴充套件延伸,同時指出書裡的一些問題。將會討論安全的型別檢測、惰性載入函式、凍結物件、定時器等話題。

1. 安全的型別檢測

這個問題是怎麼安全地檢測一個變數的型別,例如判斷一個變數是否為一個陣列。通常的做法是使用instanceof,如下程式碼所示:

let data = [1, 2, 3];
console.log(data instanceof Array); //true複製程式碼

但是上面的判斷在一定條件下會失敗——就是在iframe裡面判斷一個父視窗的變數的時候。寫個demo驗證一下,如下主頁面的main.html:

<script>
    window.global = {
        arrayData: [1, 2, 3]
    }
    console.log("parent arrayData installof Array: " + 
          (window.global.arrayData instanceof Array));
</script>
<iframe src="iframe.html"></iframe>複製程式碼

在iframe.html判斷一下父視窗的變數型別:

<script>
    console.log("iframe window.parent.global.arrayData instanceof Array: " + 
        (window.parent.global.arrayData instanceof Array));
</script>
複製程式碼

在iframe裡面使用window.parent得到父視窗的全域性window物件,這個不管跨不跨域都沒有問題,進而可以得到父視窗的變數,然後用instanceof判斷。最後執行結果如下:

可以看到父視窗的判斷是正確的,而子視窗的判斷是false,因此一個變數明明是Array,但卻不是Array,這是為什麼呢?既然這個是父子視窗才會有的問題,於是試一下把Array改成父視窗的Array,即window.parent.Array,如下圖所示:

這次返回了true,然後再變換一下其它的判斷,如上圖,最後可以知道根本原因是上圖最後一個判斷:

Array !== window.parent.Array

它們分別是兩個函式,父視窗定義了一個,子視窗又定義了一個,記憶體地址不一樣,記憶體地址不一樣的Object等式判斷不成立,而window.parent.arrayData.constructor返回的是父視窗的Array,比較的時候是在子視窗,使用的是子視窗的Array,這兩個Array不相等,所以導致判斷不成立。

那怎麼辦呢?

由於不能使用Object的記憶體地址判斷,可以使用字串的方式,因為字串是基本型別,字串比較只要每個字元都相等就好了。ES5提供了這麼一個方法Object.prototype.toString,我們先小試牛刀,試一下不同變數的返回值:

可以看到如果是陣列返回"[object Array]",ES5對這個函式是這麼規定的:

也就是說這個函式的返回值是“[object ”開頭,後面帶上變數型別的名稱和右括號。因此既然它是一個標準語法規範,所以可以用這個函式安全地判斷變數是不是陣列。

可以這麼寫:

Object.prototype.toString.call([1, 2, 3]) ===
    "[object Array]"複製程式碼

注意要使用call,而不是直接呼叫,call的第一個引數是context執行上下文,把陣列傳給它作為執行上下文。

有一個比較有趣的現象是ES6的class也是返回function:

所以可以知道class也是用function實現的原型,也就是說class和function本質上是一樣的,只是寫法上不一樣。

那是不是說不能再使用instanceof判斷變數型別了?不是的,當你需要檢測父頁面的變數型別就得使用這種方法,本頁面的變數還是可以使用instanceof或者constructor的方法判斷,只要你能確保這個變數不會跨頁面。因為對於大多數人來說,很少會寫iframe的程式碼,所以沒有必要搞一個比較麻煩的方式,還是用簡單的方式就好了。

2. 惰性載入函式

有時候需要在程式碼裡面做一些相容性判斷,或者是做一些UA的判斷,如下程式碼所示:

//UA的型別
getUAType: function() {
    let ua = window.navigator.userAgent;
    if (ua.match(/renren/i)) {
        return 0;
    }
    else if (ua.match(/MicroMessenger/i)) {
        return 1;
    }
    else if (ua.match(/weibo/i)) {
        return 2;
    }
    return -1;
}複製程式碼

這個函式的作用是判斷使用者是在哪個環境開啟的網頁,以便於統計哪個渠道的效果比較好。

這種型別的判斷都有一個特點,就是它的結果是死的,不管執行判斷多少次,都會返回相同的結果,例如使用者的UA在這個網頁不可能會發生變化(除了除錯設定的之外)。所以為了優化,才有了惰性函式一說,上面的程式碼可以改成:

//UA的型別
getUAType: function() {
    let ua = window.navigator.userAgent;
    if(ua.match(/renren/i)) {
        pageData.getUAType = () => 0;
        return 0;
    }
    else if(ua.match(/MicroMessenger/i)) {
        pageData.getUAType = () => 1;
        return 1;
    }
    else if(ua.match(/weibo/i)) {
        pageData.getUAType = () => 2;
        return 2;
    }
    return -1;
}
複製程式碼

在每次判斷之後,把getUAType這個函式重新賦值,變成一個新的function,而這個function直接返回一個確定的變數,這樣以後的每次獲取都不用再判斷了,這就是惰性函式的作用。你可能會說這麼幾個判斷能優化多少時間呢,這麼點時間對於使用者來說幾乎是沒有區別的呀。確實如此,但是作為一個有追求的碼農,還是會想辦法儘可能優化自己的程式碼,而不是隻是為了完成需求完成功能。並且當你的這些優化累積到一個量的時候就會發生質變。我上大學的時候C++的老師舉了一個例子,說有個系統比較慢找她去看一下,其中她做的一個優化是把小數的雙精度改成單精度,最後是快了不少。

但其實上面的例子我們有一個更簡單的實現,那就是直接搞個變數存起來就好了:

let ua = window.navigator.userAgent;
let UAType = ua.match(/renren/i) ? 0 :
                ua.match(/MicroMessenger/i) ? 1 :
                ua.match(/weibo/i) ? 2 : -1;複製程式碼

連函式都不用寫了,缺點是即使沒有使用到UAType這個變數,也會執行一次判斷,但是我們認為這個變數被用到的概率還是很高的。

我們再舉一個比較有用的例子,由於Safari的無痕瀏覽會禁掉本地儲存,因此需要搞一個相容性判斷:

Data.localStorageEnabled = true;
// Safari的無痕瀏覽會禁用localStorage
try{
    window.localStorage.trySetData = 1;
} catch(e) {
    Data.localStorageEnabled = false;
}

setLocalData: function(key, value) { 
    if (Data.localStorageEnabled) {
        window.localStorage[key] = value;
    }
    else {   
        util.setCookie("_L_" + key, value, 1000);
    }
}
複製程式碼

在設定本地資料的時候,需要判斷一下是不是支援本地儲存,如果是的話就用localStorage,否則改用cookie。可以用惰性函式改造一下:

setLocalData: function(key, value) {
    if(Data.localStorageEnabled) {
        util.setLocalData = function(key, value){
            return window.localStorage[key];
        }
    } else {
        util.setLocalData = function(key, value){
            return util.getCookie("_L_" + key);
        }
    }
    return util.setLocalData(key, value);
}
複製程式碼

這裡可以減少一次if/else的判斷,但好像不是特別實惠,畢竟為了減少一次判斷,引入了一個惰性函式的概念,所以你可能要權衡一下這種引入是否值得,如果有三五個判斷應該還是比較好的。

3. 函式繫結

有時候要把一個函式當作引數傳遞給另一個函式執行,此時函式的執行上下文往往會發生變化,如下程式碼:

class DrawTool {
    constructor() {
        this.points = [];
    }
    handleMouseClick(event) {
        this.points.push(event.latLng);
    }
    init() {
        $map.on('click', this.handleMouseClick);
    }
}
複製程式碼

click事件的執行回撥裡面this不是指向了DrawTool的例項了,所以裡面的this.points將會返回undefined。第一種解決方法是使用閉包,先把this快取一下,變成that:

class DrawTool {
    constructor() {
        this.points = [];
    }
    handleMouseClick(event) {
        this.points.push(event.latLng);
    }
    init() {
        let that = this;
        $map.on('click', event => that.handleMouseClick(event));
    }
}複製程式碼

由於回撥函式是用that執行的,而that是指向DrawTool的例項子,因此就沒有問題了。相反如果沒有that它就用的this,所以就要看this指向哪裡了。

因為我們用了箭頭函式,而箭頭函式的this還是指向父級的上下文,因此這裡不用自己建立一個閉包,直接用this就可以:

init() {
    $map.on('click', 
            event => this.handleMouseClick(event));
}複製程式碼

這種方式更加簡單,第二種方法是使用ES5的bind函式繫結,如下程式碼:

init() {
    $map.on('click', 
            this.handleMouseClick.bind(this));
}複製程式碼

這個bind看起來好像很神奇,但其實只要一行程式碼就可以實現一個bind函式:

Function.prototype.bind = function(context) {
    return () => this.call(context);
}複製程式碼

就是返回一個函式,這個函式的this是指向的原始函式,然後讓它call(context)繫結一下執行上下文就可以了。

4. 柯里化

柯里化就是函式和引數值結合產生一個新的函式,如下程式碼,假設有一個curry的函式:

function add(a, b) {
    return a + b;
}

let add1 = add.curry(1);
console.log(add1(5)); // 6
console.log(add1(2)); // 3複製程式碼

怎麼實現這樣一個curry的函式?它的重點是要返回一個函式,這個函式有一些閉包的變數記錄了建立時的預設引數,然後執行這個返回函式的時候,把新傳進來的引數和預設引數拼一下變成完整引數列表去調原本的函式,所以有了以下程式碼:

Function.prototype.curry = function() {
    let defaultArgs = arguments;
    let that = this;
    return function() {
        return that.apply(this, 
                          defaultArgs.concat(arguments));    }};
複製程式碼

但是由於引數不是一個陣列,沒有concat函式,所以需要把偽陣列轉成一個偽陣列,可以用Array.prototype.slice:

Function.prototype.curry = function() {
    let slice = Array.prototype.slice;
    let defaultArgs = slice.call(arguments);
    let that = this;
    return function() {
        return that.apply(this, 
                          defaultArgs.concat(slice.call(arguments)));    }};
複製程式碼

現在舉一下柯里化一個有用的例子,當需要把一個陣列降序排序的時候,需要這樣寫:

let data = [1,5,2,3,10];
data.sort((a, b) => b - a); // [10, 5, 3, 2, 1]複製程式碼

給sort傳一個函式的引數,但是如果你的降序操作比較多,每次都寫一個函式引數還是有點煩的,因此可以用柯里化把這個引數固化起來:

Array.prototype.sortDescending = 
                 Array.prototype.sort.curry((a, b) => b - a);
複製程式碼

這樣就方便多了:

let data = [1,5,2,3,10];
data.sortDescending();

console.log(data); // [10, 5, 3, 2, 1]複製程式碼

5. 防止篡改物件

有時候你可能怕你的物件被誤改了,所以需要把它保護起來。

(1)Object.seal防止新增和刪除屬性

如下程式碼,當把一個物件seal之後,將不能新增和刪除屬性:

當使用嚴格模式將會拋異常:

(2)Object.freeze凍結物件

這個是不能改屬性值,如下圖所示:

同時可以使用Object.isFrozen、Object.isSealed、Object.isExtensible判斷當前物件的狀態。

(3)defineProperty凍結單個屬性

如下圖所示,設定enumable/writable為false,那麼這個屬性將不可遍歷和寫:

6. 定時器

怎麼實現一個JS版的sleep函式?因為在C/C++/Java等語言是有sleep函式,但是JS沒有。sleep函式的作用是讓執行緒進入休眠,當到了指定時間後再重新喚起。你不能寫個while迴圈然後不斷地判斷當前時間和開始時間的差值是不是到了指定時間了,因為這樣會佔用CPU,就不是休眠了。

這個實現比較簡單,我們可以使用setTimeout + 回撥:

function sleep(millionSeconds, callback) {
    setTimeout(callback, millionSeconds);
}
// sleep 2秒
sleep(2000, () => console.log("sleep recover"));複製程式碼

但是使用回撥讓我的程式碼不能夠和平常的程式碼一樣像瀑布流一樣寫下來,我得搞一個回撥函式當作引數傳值。於是想到了Promise,現在用Promise改寫一下:

function sleep(millionSeconds) {
    return new Promise(resolve => 
                             setTimeout(resolve, millionSeconds));
}
sleep(2000).then(() => console.log("sleep recover"));複製程式碼

但好像還是沒有辦法解決上面的問題,仍然需要傳遞一個函式引數。

雖然使用Promise本質上是一樣的,但是它有一個resolve的引數,方便你告訴它什麼時候非同步結束,然後它就可以執行then了,特別是在回撥比較複雜的時候,使用Promise還是會更加的方便。

ES7新增了兩個新的屬性async/await用於處理的非同步的情況,讓非同步程式碼的寫法就像同步程式碼一樣,如下async版本的sleep:

function sleep(millionSeconds) {
    return new Promise(resolve => 
                           setTimeout(resolve, millionSeconds));
}

async function init() {
    await sleep(2000);
    console.log("sleep recover");
}

init();複製程式碼

相對於簡單的Promise版本,sleep的實現還是沒變。不過在呼叫sleep的前面加一個await,這樣只有sleep這個非同步完成了,才會接著執行下面的程式碼。同時需要把程式碼邏輯包在一個async標記的函式裡面,這個函式會返回一個Promise物件,當裡面的非同步都執行完了就可以then了:

init().then(() => console.log("init finished"));複製程式碼

ES7的新屬性讓我們的程式碼更加地簡潔優雅。

關於定時器還有一個很重要的話題,那就是setTimeout和setInterval的區別。如下圖所示:

setTimeout是在當前執行單元都執行完才開始計時,而setInterval是在設定完計時器後就立馬計時。可以用一個實際的例子做說明,這個例子我在《JS與多執行緒》這篇文章裡面提到過,這裡用程式碼實際地執行一下,如下程式碼所示:

let scriptBegin = Date.now();
fun1();
fun2();

// 需要執行20ms的工作單元
function act(functionName) {
    console.log(functionName, Date.now() - scriptBegin);
    let begin = Date.now();
    while(Date.now() - begin < 20);
}
function fun1() {
    let fun3 = () => act("fun3");
    setTimeout(fun3, 0);
    act("fun1");
}
function fun2() {
    act("fun2 - 1");
    var fun4 = () => act("fun4");
    setInterval(fun4, 20);
    act("fun2 - 2");
}
複製程式碼

這個程式碼的執行模型是這樣的:

控制檯輸出:

與上面的模型分析一致。

接著再討論最後一個話題,函式節流

7. 函式節流throttling

節流的目的是為了不想觸發執行得太快,如:

  • 監聽input觸發搜尋
  • 監聽resize做響應式調整
  • 監聽mousemove調整位置

我們先看一下,resize/mousemove事件1s種能觸發多少次,於是寫了以下驅動程式碼:

let begin = 0;
let count = 0;
window.onresize = function() {
    count++;
    let now = Date.now();
    if (!begin) {
        begin = now;
        return;
    }
    if((now - begin) % 3000 < 60) {
        console.log(now - begin,
           count / (now - begin) * 1000);
    }
};複製程式碼

當把視窗拉得比較快的時候,resize事件大概是1s觸發40次:

需要注意的是,並不是說你拉得越快,觸發得就越快。實際情況是,拉得越快觸發得越慢,因為拉動的時候頁面需要重繪,變化得越快,重繪的次數也就越多,所以導致觸發得更少了。

mousemove事件在我的電腦的Chrome上1s大概觸發60次:

如果你需要監聽resize事件做DOM調整的話,這個調整比較費時,1s要調整40次,這樣可能會響應不過來,並且不需要調整得這麼頻繁,所以要節流。

怎麼實現一個節流呢,書裡是這麼實現的:

function throttle(method, context) {
    clearTimeout(method.tId);
    method.tId = setTimeout(function() {
        method.call(context);
    }, 100);
}複製程式碼

每次執行都要setTimeout一下,如果觸發得很快就把上一次的setTimeout清掉重新setTimeout,這樣就不會執行很快了。但是這樣有個問題,就是這個回撥函式可能永遠不會執行,因為它一直在觸發,一直在清掉tId,這樣就有點尷尬,上面程式碼的本意應該是100ms內最多觸發一次,而實際情況是可能永遠不會執行。這種實現應該叫防抖,不是節流。

把上面的程式碼稍微改造一下:

function throttle(method, context) {
    if (method.tId) {
        return;
    }
    method.tId = setTimeout(function() {
        method.call(context);
        method.tId = 0;
    }, 100);
}
複製程式碼

這個實現就是正確的,每100ms最多執行一次回撥,原理是在setTimeout裡面把tId給置成0,這樣能讓下一次的觸發執行。實際實驗一下:

大概每100ms就執行一次,這樣就達到我們的目的。

但是這樣有一個小問題,就是每次執行都是要延遲100ms,有時候使用者可能就是最大化了視窗,只觸發了一次resize事件,但是這次還是得延遲100ms才能執行,假設你的時間是500ms,那就得延遲半秒,因此這個實現不太理想。

需要優化,如下程式碼所示:

function throttle(method, context) {
    // 如果是第一次觸發,立刻執行
    if (typeof method.tId === "undefined") {
        method.call(context);
    }
    if (method.tId) {
        return;
    }
    method.tId = setTimeout(function() {
        method.call(context);
        method.tId = 0;
    }, 100);
}複製程式碼

先判斷是否為第一次觸發,如果是的話立刻執行。這樣就解決了上面提到的問題,但是這個實現還是有問題,因為它只是全域性的第一次,使用者最大化之後,隔了一會又取消最大化了就又有延遲了,並且第一次觸發會執行兩次。那怎麼辦呢?

筆者想到了一個方法:

function throttle(method, context) {
    if (!method.tId) {
        method.call(context);
        method.tId = 1;
        setTimeout(() => method.tId = 0, 100);
    }
}複製程式碼

每次觸發的時候立刻執行,然後再設定一個計時器,把tId置成0,實際的效果如下:

這個實現比之前的實現還要簡潔,並且能夠解決延遲的問題。

所以通過節流,把執行次數降到了1s執行10次,節流時間也可以控制,但同時失去了靈敏度,如果你需要高靈敏度就不應該使用節流,例如做一個拖拽的應用。如果拖拽節流了會怎麼樣?使用者會發現拖起來一卡一卡的。


筆者重新看了高程的《高階技巧》的章節結合自己的理解和實踐總結了這麼一篇文章,我的體會是如果看書看部落格只是當作睡前讀物看一看其實收穫不是很大,沒有實際地把書裡的程式碼實踐一下,沒有結合自己的編碼經驗,就不能用自己的理解去融入這個知識點,從而轉化為自己的知識。你可能會說我看了之後就會印象啊,有印象還是好的,但是你花了那麼多時間看了那本書只是得到了一個印象,你自己都沒有實踐過的印象,這個印象又有多靠譜呢。如果別人問到了這個印象,你可能會回答出一些連不起來的碎片,就會給人一種背書的感覺。還有有時候書裡可能會有一些錯誤或者過時的東西,只有實踐了才能出真知。


相關文章