iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 7
1
Modern Web

《你的地圖會說話? WebGIS與JavaScript的情感交織》系列 第 7

[3-2] Scope Chain & IIFE 問題與解法 - 以Here Maps API展點為例

本篇文章請搭配
[3-1] 打造你的美食地圖!用Here Maps API 秀出Google API餐廳資訊


什麼是Scope Chain?

講到Scope Chain(範圍鏈),就要先來講講Scope。有別於其他程式語言,JS是以function為主體的一種程式語言。也就是說,在一個function的開始與結束,會形成一個作用域(Scope),而在function外的就是它的外部環境(Outer Environment)
讓我們來舉個例子。

        var param = 'Global';
        function Outer() {
            var param = 'Outer';
            console.log(`Scope: ${param}`);

            function Middle() {
                var param = 'Middle';
                console.log(`Scope: ${param}`);

                function Inner() {
                    var param = 'Inner'; 
                    console.log(`Scope: ${param}`);
                }

                Inner();  // 呼叫Middle時,呼叫Inner
            }
            Middle();  // 呼叫Outer時,呼叫Middle
        }

↑ 建立多個function包function,在function外部的稱為全域環境(Global),第一層為Outer,第二層為Middle,最裡層為Inner。而每層都有相同的變數param。

        console.log(`Scope: ${param}`);  // Global
        Outer();

↑ 呼叫Outer。可以預期最外層的param等於Global,因為它在全域環境。

https://ithelp.ithome.com.tw/upload/images/20200922/20130604AGFNmJSTrb.jpg
↑ 結果如預料之中,最外層為Global,Outer層的Scope為Outer,Middle層的Scope為Middle,Inner層的Scope為Inner。
現在我們把Inner中的param拿掉。

        function Outer() {
            var param = 'Outer';
            console.log(`Scope: ${param}`);

            function Middle() {
                var param = 'Middle';
                console.log(`Scope: ${param}`);

                function Inner() {
                    //var param = 'Inner'; 
                    // Inner Scope沒有param,往外層找到Middle
                    console.log(`Scope: ${param}`);  
                }

                Inner();  // 呼叫Middle時,呼叫Inner
            }
            Middle();  // 呼叫Outer時,呼叫Middle
        }

https://ithelp.ithome.com.tw/upload/images/20200922/2013060472QMM7Dxs3.jpg
↑ 當Inner層在Inner Scope找不到param的時候,就會往外找它的外部環境,於是找到了Middle層。
接者我們把Middle中的param也拿掉。

        function Outer() {
            var param = 'Outer';
            console.log(`Scope: ${param}`);

            function Middle() {
                //var param = 'Middle';
                // Middle Scope沒有param,往外層找到Outer
                console.log(`Scope: ${param}`);

                function Inner() {
                    //var param = 'Inner'; 
                    // Inner Scope沒有param,往外層找到Middle,Middle Scope也沒有param,於是再往外層找到Outer Scope層
                    console.log(`Scope: ${param}`);  
                }

                Inner();  // 呼叫Middle時,呼叫Inner
            }
            Middle();  // 呼叫Outer時,呼叫Middle
        }

https://ithelp.ithome.com.tw/upload/images/20200922/20130604JJC4APTZgY.jpg
↑ 當Inner Scope沒有param時,往外層找到Middle Scope也沒有param,於是再往外層找到Outer Scope。由此可知,作用域(Scope)會由內而外,一層一層向外尋找,直到找到為止,這由內而外尋找的過程就稱為Scope Chain(範圍鏈)。如果直到全域環境依舊沒有找到,就會回傳undefined。

        var param = 'Global';
        function Outer() {
            //var param = 'Outer';
            console.log(`Scope: ${param}`);

            function Middle() {
                //var param = 'Middle';
                console.log(`Scope: ${param}`);

                function Inner() {
                    //var param = 'Inner'; 
                    console.log(`Scope: ${param}`);
                }

                Inner();
            }
            Middle();
        }

        console.log(`Scope: ${param}`);
        Outer();

↑ 把Outer也註解。
https://ithelp.ithome.com.tw/upload/images/20200922/20130604uwbjqPzNlK.jpg
↑ 於是全部找到Global。

舉個Here Maps API展點為例

還記得昨天最後面以Here Maps API寫的展點嗎?讓我們來回顧一下function。

        var ShowMultiPoint = (dataList = [], map) => {
            if (dataList.length > 0) {
                dataList.forEach(item => {
                    let marker = new H.map.Marker({ lat: item.y, lng: item.x });
                    map.addObject(marker);
                    marker.setData(`<div class="infoWindow">
                        <h2>${item.name}</h2>
                        <p>經度: ${item.x}</p>
                        <p>緯度: ${item.y}</p>
                        <p>地址: ${item.address}</p>
                    </div>
                    `);
                    marker.addEventListener('tap', (evt) => {
                        let bubble = new H.ui.InfoBubble(evt.target.getGeometry(), {
                            content: evt.target.getData()
                        });
                        ui.addBubble(bubble);
                    });

                });
            }
        }

↑ 這是我們昨天寫的函式,傳入點資料陣列以及地圖,跑一個迴圈把每個點資料建立成marker,並且給每個marker監聽事件,當它們點擊的時候,會秀出他們的資訊視窗。
這個function是用ES6的寫法,我們先把它們轉成ES5,比較好說明。

        function ShowMultiPoint(dataList, map) {
            dataList = dataList || [];

            if (dataList.length > 0) {
                for (var i = 0; i < dataList.length; i++) {
                    var marker = new H.map.Marker({ lat: dataList[i].y, lng: dataList[i].x });
                    var content = `<div class="infoWindow">  
                        <h2>${dataList[i].name}</h2>
                        <p>經度: ${dataList[i].x}</p>
                        <p>緯度: ${dataList[i].y}</p>
                        <p>地址: ${dataList[i].address}</p>
                        </div>
                        `;  // ES6樣板語法太好用了,繼續寫
                    map.addObject(marker);

                    marker.addEventListener('tap', function (evt) {
                        var bubble = new H.ui.InfoBubble(evt.target.getGeometry(), {
                            content: content
                        });

                        ui.addBubble(bubble);
                    });
                }
            }
        }
  • 除了轉ES5以外,原本的寫法是用marker.setData('內容');在每個marker物件綁上資訊視窗的內容,在監聽事件觸發時,用evt.target.getData()從marker取出來,算是一種很巧妙的儲存方法,但並不是每一種圖台API都有這樣子的設計,因此我們先不用,把它改為直接讀取變數content。
  • 另外,我還把forEach改成for迴圈,大家會不會有個疑問:「奇怪!這兩個不是一樣的東西嗎?」它們究竟有什麼差別?讓我們~繼續~看~下~去~

https://ithelp.ithome.com.tw/upload/images/20200922/20130604Aq8AIh9sSb.jpg
↑ 結果全部的資訊視窗都變成貳樓餐廳了,怎麼有這種事?/images/emoticon/emoticon04.gif 該不會被貳樓駭客入侵
用chrome F12開發者工具下中斷點偵錯就會發現,for迴圈會依照資料的筆數,假如有20筆就會跑20次,並解建立20個點以及20個監聽事件。可是監聽事件只是被建立,而不是被觸發。當監聽事件被觸發時i已經變成最大值,因此怎麼抓都只抓到最後一筆的資訊。
https://ithelp.ithome.com.tw/upload/images/20200922/20130604mXyB76qrt2.jpg
↑ i已經變成最大值19,永遠都抓到最後一筆貳樓餐廳。
那究竟該怎麼辦呢?別擔心,這裡提供大家四種解法!/images/emoticon/emoticon08.gif

  • 解法一: 使用JS forEach取代迴圈

為什麼要用forEach呢?因為Array.prototype.forEach()方法,裡面接的參數為callback函式,讓我們看看mdn的定義。
https://ithelp.ithome.com.tw/upload/images/20200922/20130604MoF8SfEGkf.jpg
↑ 可以看到,forEach的內容其實是提供陣列每次迭帶的回呼函式,而每個回呼函式都是一個function,無意間形成了一個作用域(scope),讓每一個item值都被保存在這個scope中。

        function ShowMultiPoint(dataList, map) {
            dataList = dataList || [];

            if (dataList.length > 0) {
                dataList.forEach(function (item) {  // Scope起始,item在Scope內被保存
                    var marker = new H.map.Marker({ lat: item.y, lng: item.x });
                    var content = `<div class="infoWindow">
                        <h2>${item.name}</h2>
                        <p>經度: ${item.x}</p>
                        <p>緯度: ${item.y}</p>
                        <p>地址: ${item.address}</p>
                    </div>
                    `;
                    map.addObject(marker);
                    marker.addEventListener('tap', function (evt) {
                        let bubble = new H.ui.InfoBubble(evt.target.getGeometry(), {
                            content: content
                        });
                        ui.addBubble(bubble);
                    });
                });  // Scope結束
            }
        }

↑ forEach後面的回調函式,形成一個scope,並且為外層監聽事件提供Outer Environment,每個item將會被每一個迭帶的scope分別保存。Javascript有回收機制,當偵測到function內容沒有重複再使用時,會因記憶體的考量而進行回收。如果該function會再繼續使用,並且它的外部環境依舊存在變數參考時,外部環境的作用域也不會回收,雖然可以保存特定資訊,但會對記憶體有負擔。
https://ithelp.ithome.com.tw/upload/images/20200922/20130604TKyDgxxHFZ.jpg
↑ 成功保存每個資訊視窗的內容

  • 解法二: 在function內建構具名函式形成閉包

        function ShowMultiPoint(dataList, map) {
            dataList = dataList || [];
            console.log(content)
            if (dataList.length > 0) {
                for (var i = 0; i < dataList.length; i++) {
                    var marker = new H.map.Marker({ lat: dataList[i].y, lng: dataList[i].x });
                    var content = `<div class="infoWindow">
                        <h2>${dataList[i].name}</h2>
                        <p>經度: ${dataList[i].x}</p>
                        <p>緯度: ${dataList[i].y}</p>
                        <p>地址: ${dataList[i].address}</p>
                        </div>
                        `;
                    map.addObject(marker);
                    function AddListener(content) {  // Scope起始,content在Scope內被保存
                        marker.addEventListener('tap', function (evt) {
                            var bubble = new H.ui.InfoBubble(evt.target.getGeometry(), {
                                content: content
                            });

                            ui.addBubble(bubble);
                        });
                    }  // Scope結束
                    AddListener(content);
                }
            }
        }

↑ 在function內建構一個名為AddListener的具名函式,形成function包function的現象,稱為閉包(closure)。呼叫AddListener時傳入想要保存的參數,就會在AddListener的scope內儲存。閉包運用可以讓外部環境透過function部分存取內部環境的變數,卻不能任意更動它,可以有效達成封裝的效果。

  • 解法三: IIFE 立即函式

IIFE全名為Immediately invoked function expression,稱為立即函式。

        (function () {
            console.log("我是立即函式!");
        })();

↑ 立即函式也就是只當下立刻執行,會用兩個小括弧去觸發。第一個小括弧放入要執行的函式,第二個小括弧讓它立刻執行。特別注意!立即函式中的函式只能放匿名函式,不能放具名函式。

        function ShowMultiPoint(dataList, map) {
            dataList = dataList || [];

            if (dataList.length > 0) {

                for (var i = 0; i < dataList.length; i++) {
                    var marker = new H.map.Marker({ lat: dataList[i].y, lng: dataList[i].x });
                    var content = `<div class="infoWindow">
                        <h2>${dataList[i].name}</h2>
                        <p>經度: ${dataList[i].x}</p>
                        <p>緯度: ${dataList[i].y}</p>
                        <p>地址: ${dataList[i].address}</p>
                        </div>
                        `;
                    map.addObject(marker);
                    (function (content) {  // 內部匿名函式接取的parameter
                        marker.addEventListener('tap', function (evt) {
                            var bubble = new H.ui.InfoBubble(evt.target.getGeometry(), {
                                content: content
                            });

                            ui.addBubble(bubble);
                        });
                    })(content);  // 外部傳入立即函式的參數
                }

            }
        }

把監聽事件外層披上一層IIFE的外衣,並且把content傳入,也可以在立即函式中形成一個scope,保存content。

  • 解法四: ES6 let

在ES6新增了let跟const宣告變數的方式,而不僅僅只有var,const很直覺就是常數,無法任意改變及取代。那麼let跟var有什麼區別呢?
var的作用域為function,而let的作用域卻是大括號。

        function ShowMultiPoint(dataList, map) {
            dataList = dataList || [];
            //console.log(content);  // content is not defined
            if (dataList.length > 0) {
                for (let i = 0; i < dataList.length; i++) {  // Scope起始
                    let marker = new H.map.Marker({ lat: dataList[i].y, lng: dataList[i].x });
                    let content = `<div class="infoWindow">
                        <h2>${dataList[i].name}</h2>
                        <p>經度: ${dataList[i].x}</p>
                        <p>緯度: ${dataList[i].y}</p>
                        <p>地址: ${dataList[i].address}</p>
                        </div>
                        `;
                    map.addObject(marker);

                    marker.addEventListener('tap', function (evt) {
                        var bubble = new H.ui.InfoBubble(evt.target.getGeometry(), {
                            content: content
                        });

                        ui.addBubble(bubble);
                    });
                }  // Scope結束
            }
        }

↑ 把變數都改成以let宣告
因為let作用域為大括弧,因此作用域範圍就變成在for迴圈之內。
不知道大家會不會有個疑問?作用域變成大括弧,這樣不就完全顛覆了剛剛講的以function為主體的Scope Chain了嗎?倒也不然,ES6的let其實也是個語法糖,它雖然看似只會在大括弧內作用,但因為hoisting(提升)的關係,它其實在function作用域內的最上面就已經被宣告了,只是形成了暫時性死區(TDZ)。
https://ithelp.ithome.com.tw/upload/images/20200922/20130604zrLY0qeO3j.jpg
↑ 暫時性死區(TDZ),全名為Temporal Dead Zone,是let宣告的變數從宣告位置到function作用域起始中間的區域。


小結

今日延續昨天Here Maps API監聽事件可能會碰到的問題及解法,幫大家整理如下:

  • 範圍鏈(Scope Chain) 會以function為主體,一層一層由內而外向外查找。
  • 使用JS forEach取代迴圈,可以有效在回調函式中形成一個Scope。
  • 在function內建構具名函式形成閉包,利用閉包的特性,達到外部環境部分存取function內部值的效果。
  • 用IIFE立即函式形成作用域,有效保存當下環境。
  • ES6 let宣告方式讓作用域活動範圍變成大括弧,並且在大括弧外形成暫時性死區(TDZ)。

明日再戰! /images/emoticon/emoticon61.gif


上一篇
[3-1] 打造你的美食地圖!用Here Maps API 秀出Google API餐廳資訊
下一篇
[4-1] 線、面資料圖徵 - 以行政區定位及導航為例
系列文
《你的地圖會說話? WebGIS與JavaScript的情感交織》30

尚未有邦友留言

立即登入留言