使用 JS Timer 常發生的錯誤

摘要:使用 JS Timer 常發生的錯誤

Timer 在 JS 裡是很常被使用到的功能, 其主要的 API 就是 setTimeout 和 setInterval 這兩種, 前者是一次性的 Timer, 也就是經過一段時間後觸發一次便停止; 後者則是重複性的 Timer, 會不斷的觸發而不停止, 當然可以利用 clearInterval 來取消 Timer 的活動. 相關細節用法可以參考 [1-2], 不過注意各瀏覽器支援 Timer API 的不同處, 一般慣例 setTimeout  和 setInterval 只餵入前兩個參數, 即 callback 和 timeout.

使用 Timer 最容易發生的錯誤, 一般是 object scope 上的認知問題, 可以先參考這篇 Function Object Scope in JavaScript  的內容. 這種錯誤認知在此篇文章採用兩種不同的角度來觀察.
 
在下面的範例中, 有一個全域變數 keyValue 和 key 函式物件; 接著在調用程序中, 生成了 key 物件, 並使用 setInterval 於每 1.5 秒在 div view 中顯示 keyValue. 

keyValue = 5;    

var key = function () {
    this.keyValue = 10;
    this.show = function () {
        var view = $('#view');
        if (view && view.jquery) {
            view.text(this.keyValue);
        }
    };
};

keyObj = new key();
setInterval(keyObj.show, 1500); // 5
setInterval('keyObj.show()', 1500); // 10
程式碼中總共有兩個地方保留了 keyValue 值, 一個是在 window, 另一個是在 keyObj 中, 而 setInterval 使用了兩種不同餵入參數的方式, 其顯示結果有出乎你的意料之外嗎? setInterval 的第一個參數接受餵入函式物件 (func) 或是要執行的程式片斷. 如果是餵入函式物件, 則該函式本文 (context) 會在 Timer 觸發時被執行, 所以 show 函式被執行, 顯示 this.keyValue, 但在這邊我們需要非常注意, 這裡的 this 是代表什麼? 以顯示 5 這個結果來看, 當執行函式本文時, this 會是 window!
 
這在之前 function object scope 那篇文章中就有提到過, 函式的 scope 會在定義時決定, setInterval 的 scope 是在 window 下, 當 Timer 觸發後去執行 callback 時, 其中的 this 會指涉到 window, 而不是 keyObj, 這一點是 JS 和其他語言相差非常大的部份. 如果想要讓 show 函式的 this.keyValue 反射回 keyObj 是不可能的事, 因為當初在餵入 keyObj.show 時就沒有指定某個 AO (activation object), 所以 this 會有變化; 所以一般的做法是讓 show 和 key 函式物件的關係更明確, 也就是不採用 this 來指涉, 因為 this 會隨著目前的 activation object 變化. 那麼把 show 函式中的 this 改成 me 就能避免這個問題, 修改後如下:

var key = function () {
    this.keyValue = 10;
    this.show = function () {
        var me = this;
        var view = $('#view');
        if (view && view.jquery) {
            view.text(me.keyValue);
        }
    };
};
那為什麼直接輸入程式碼片段的方式, show 函式中的 this 就能指涉到 keyObj 呢? 這是因為程式碼片斷中有指定 keyValue 所屬的 scope. Timer 觸發後, 程式碼片斷以 eval 的方式執行, 執行的內容是 keyObj.show(), 注意 show 函式被執行的前面是 keyObj, 這是指定 AO 最簡單的方式, 這與餵入函式物件時不同, keyObj.show 僅描述了要執行函式, 但並沒有指定執行時的 AO. 為了方便了解, 下面採用不同的 scope 去執行 show 函式.

keyObj.show(); // 10
keyObj.show.call(window); // 5
keyObj.show.call(keyObj); // 10
keyObj.show.call({keyValue: 30}); // 30
call 可以用不同的 AO 去執行某函式 (可參考 [3]), 一般函式執行的 scope (也就是加一對圓括號, ()) 是被預設為它所屬的物件, 這裡也就是指 keyObj, 利用 call 我們可以切換 show 的 scope, 所以與 this 指涉有關的值全部都被更換了! 上面我們可以觀察 AO 切換到 window, keyObj 和匿名物件之間的差別. 當 eval 執行字串 ’keyObj.show()’ 時, scope 仍是 window, 但 show 的執行 scope 是有被指定成 keyObj, 所以也就是去執行 window.keyObj.show(), 這樣有比較清楚的了解這個容易發生的錯誤嗎? :)
 
除了上述的兩種餵入方式, 函式物件常常被餵入匿名函式, 整個型式如下:

setInterval(function() {
    keyObj.show(); // 10
    keyObj.show.call(this); // 5
}, 1500);
由上面的輸出顯示, setInterval 中的 AO 真的是 window.
 
Timer 很少單獨的被使用, 它很常與物件包裝在一起. 在第二個實驗中, 我把 Timer 的功能整合進 key 物件中來觀察 keyValue 的輸出, 程式碼如下: 

keyValue = 5;    

var key = function () {
    var me = this;
    this.keyValue = 10;
    this.show = function () {
        var view = $('#view');
        if (view && view.jquery) {
            view.text(this.keyValue);
        }
    };
    this.showWithTimer = function () {
        var showFunc = this.show;
        setInterval(function () {
            this.show();
            me.show(); // 10
            showFunc(); // 5
        }, 1500);
    };
};
上述的 key 物件中多了 showWithTimer 函式, 它直接在函式中使用 setInterval 來顯示 keyValue, 顯示的方式一樣透過 show 函式, 那 setInterval 被包進 key 物件後會有什麼不一樣呢? 
 
其實還是老問題, 得要多注意 scope 的狀態! 在餵入 setInterval 的匿名函式中, 使用了三種不同的方式去執行 show 函式. 第一種是直接由 this 去指涉目前的 AO 中的 show, 但別忘了 setInterval 的 AO 是 window, 所以 this.show() 這行可是會發生錯誤的唷! 第二種方法不採用 this 去指涉, 而是換成 closure 中的變數 me, 這個 me 存放著原本 key 的 this 指涉, 在執行 show 時指定 scope 為 me, 所以顯示結果是 keyObj 的 keyValue, 這也是最常被使用到的方法. 第三種是趁著在 showWithTimer 函式中 this 還是指涉到 key 時, 先把 show 存起來, 到 setInterval 再執行, 但要注意 JS 和其他語言不一樣, 函式的 context 執行與執行環境 AO 很容易就分開, 那是因為以 function 為調用主體, 和 OOP 使用 object 為調用主體不一樣 (當然很多 OOP 語言有支援函式性的調用, 如匿名函式.), 所以指派 show 函式給 showFunc 僅能確定它要執行的內容, 而不能確定它的調用對象, 即 this 的指涉對象 (不過 closure 中的內容是已固定的), 其顯示結果則依 setInterval 所屬的 scope 而定. 另外, showFunc() 本身等同於 me.show.call(this).
 
或許你會覺得 call 和 apply 這類可以切換函式 activation object 的 API 為什麼不直接套用在 Timer API 上就好了? 
 
這是個好想法, 事實上我也有試用, 不過很可惜 JS 限定 setTimeout 和 setInterval 的 AO 是 window, 所以 setInterval.call(window, keyObj.show, 1500); 之類的語法是正確的, 一旦 thisArg (第一位參數) 不是 window 則會發生錯誤. 
 
不過可以自己利用 apply 來包裝不就好了. :p
我自己寫了一個簡單的 Timer, 可以啟動和停止 Timer 的觸發, 還可以很容易的切換 callback 的 scope, 程式碼如下: 

Timer = {
    create: function (cb, t, context, args) {
        var inst = {
            paras: {
                tid: -1,
                callback: cb,
                timeout: t || 1000,
                context: context || window,
                args: args
            },
            start: function () {
                var p = inst.paras;
                if (p.tid < 0 && p.callback) {
                    p.tid = setInterval(function () {
                        p.callback.apply(p.context, p.args);
                    }, p.timeout);
                }
            },
            stop: function () {
                var p = inst.paras;
                if (p.tid >= 0) {
                    clearInterval(p.tid);
                    p.tid = -1;
                }
            }
        };

        return inst;
    },
    bind: function (context, cb, t, args) {
        if(context) context.timer = this.create(cb, t, context, args);
    }
};
使用 Timer.create 靜態函式來生成 Timer, timer 綁定的一些參數位於 inst.paras 中, 包括 callback function, timeout, context 和 callback parameters. 如果沒有輸入綁定的 context, 則預設為 window, 即 Timer 觸發後執行的 callback 的 context 會是 window. 函式 bind 除了生成新的 Timer 之外還把它放置於該 context 下的 timer 物件. 先來看看如何使用 create 來產生 Timer 吧!

$(function() {
    keyValue = 5;
    timer = Timer.create(function () {
        var view = $('#view');
        view.text(this.keyValue += 1); // 5, 6, 7 ...
    });

    $('#btnStart').click(timer.start);
    $('#btnStop').click(timer.stop);
});
上面的範例僅導入 callback, 其他參數就使用預設值. 網頁上新增兩個按鈕, 使用 jQuery 去綁定各別的 click 事件, 很容易吧! 不過這個例子沒有切換 context 的效果, 來看看下面程式碼, 利用了 bind 和一個 change 按鈕來切換 window 和 myContext 這兩種 context.

$(function() {
    keyValue = 5;

    myContext = { keyValue: 50 };
    Timer.bind(myContext, function () {
        var view = $('#view');
        view.text(this.keyValue += 1);
    });

    ind = 1;
    $('#btnChange').click(function () {
        switch (ind) {
            case 1:
                myContext.timer.paras.context = window;
                break;
            case 0:
                myContext.timer.paras.context = myContext;
                break;
        }
        ind = (ind + 1) % 2;
    }); 
    $('#btnStart').click(myContext.timer.start);
    $('#btnStop').click(myContext.timer.stop);
});
看完這篇有沒有更了解 function scope 在 Timer 觸發時所會發生的問題呢?:)
 
Ref.