iT邦幫忙

2024 iThome 鐵人賽

DAY 18
0

https://ithelp.ithome.com.tw/upload/images/20241002/20168201lXriNRjZGH.png

今天要介紹的是命名空間化模式🧐

什麼是命名空間

《JavaScript 設計模式學習手冊 第二版》作者 Addy Osmani 在書中敘述命名空間(namespace)是「唯一識別符之下的程式碼單元的邏輯分組」,乍聽之下不知道什麼意思,其實白話來講就是變數和函式的一個容器,命名空間作為一個包裝物/容器,可用來區隔變數或函式的作用域。

一句話簡介,命名空間是一種將相關的變數、函式和物件封裝起來的機制。

在 JavaScript 中,如果我們沒特別處理,變數和函式預設是全域作用域,因此我們需要方法來避免全域汙染與變數衝突,以下為變數衝突示意圖。
https://ithelp.ithome.com.tw/upload/images/20241002/20168201D1VGJlsM9l.jpg
圖 1 變數衝突示意圖(資料來源:自行繪製)

而這個解決方法就是命名空間,JavaScript 命名空間的用途/優點如下:

  • 避免和全域命名空間中的其他變數產生衝突(collision)
  • 方便組織程式碼中的相關功能(同類型、相關功能的變數可封裝在一起)
  • 保護程式碼不會因其它程式碼用了相同變數名稱而出錯、導致中斷。

可能會有人困惑說,要避免變數重複,那就專案的團隊成員講好就好啦,或是搭配 ESLint 工具來檢查程式碼就好(例如:no-redeclare 規則)。但在前端應用中,除了我們自己開發的程式碼,當產品上線後,常會有大量第三方程式碼(script)被注入頁面並執行,這些第三方程式碼不是開發者本身可控制的,因此我們需要方法來避免使用相同變數名稱。又例如,假設今天公司的產品就是那些要注入客戶端的第三方程式碼,那撰寫時就要更小心,以免注入程式碼後破壞了客戶端原本的應用程式功能。而這些「第三方程式碼」例如 Google Analytics、Google Tag Manager 等,這些用來追蹤或處理額外邏輯的程式碼,通常是上線後才被注入到頁面上,且撰寫這些程式碼的人,不一定是開發產品應用程式的那些工程師們。
會在這裡寫這麼多補充,是因為自己本身有些感觸(?,因為我在前公司負責的工作之一就是用 Google Tag Manager 為客戶端的前端應用注入程式碼以埋放廣告,這時就要小心,避免和客戶端應用程式的變數衝突,而且會同時注入第三方程式碼的可能還不只一間公司,所以一個頁面上可能會混雜著好幾間公司的工程師們寫的程式碼XD。舉例來說,像科技報橘這個網站底下就嵌入了很多 script,這些 script 可能是由許多非科技報橘的公司所注入的程式碼。當它們混雜在一起後,依然要能保持應用能正常、穩定運作,這就和命名空間的設計有關。
https://ithelp.ithome.com.tw/upload/images/20241002/20168201WTmnI6owKg.jpg
圖 2 嵌入外部 script 示意圖(資料來源:自行截圖/繪製)

另外再補充一點,以下範例程式可能還是會用到 var 來宣告變數,即使 ES6 以後我們多數會用 letconst 來宣告變數,但以我之前的經驗,在 Google Tag Manager 內寫的程式碼是要寫 JavaScript ES5 的,也就是說,無法在 Google Tag Manager 內寫 letconst,還是得用 var,因此看到底下的 var 請不要見怪><

以下將介紹幾種命名空間模式。

單一全域變數

以單一全域變數作為主要參照物件,把所有方法屬性都寫在該物件裡,簡單粗暴好理解👍

範例:透過 IIFE 回傳具有函式和屬性的物件,建立唯一的命名空間

const myUniqueApplication = (() => {
    function myMethod(){
      //...
    }
    
    return {
        myMethod
    }
})();

// 用法
myUniqueApplication.myMethod();

優點

  • 使用方式簡單,要找任何屬性和方法就去唯一的變數裡找就好

缺點

  • 要確保其他人沒有使用一樣的全域變數名稱,否則還是會有衝突

字首命名空間

在變數名稱前加上特定的字首(prefix)來避免和其他變數衝突,在字首後定義任何方法、變數或其他物件。

const myApplication_propertyA = {};
const myApplication_propertyB = {};
function myApplicatioon_myMethod(){
    //...
}

簡單講就是讓變數名稱再更獨特一點,這裡是用加入字首的方式讓它變得更難撞名。

優點

  • 比起單一全域物件,加上字首讓變數名稱更有獨特性,降低衝突可能性

缺點

  • 當應用程式變龐大後,會產生很多全域物件,較難維護管理
  • 還是需要確認全域命名空間中沒有相同的字首,否則還是會有衝突

物件實字(Object Literal)

以物件實字(object literal)的方式來表達命名空間,可以用 key(鍵)來表達新的命名空間。

const myApplication = {
    getInfo() {
        console.log('this is myApplication info');
    },
    models: {
        //...
    },
    views: {
        //...
    },
    collections: {}
};

以物件定義命名空間,可以增加屬性/方法到命名空間、刪除特定屬性、修改特定方法,並且可以隨著應用程式邏輯動態的新增刪改,靈活性、可擴展性高。

// 新增屬性
myApplication.newVariable = 'newValue';

// 新增方法
myApplication.newMethod = function() {
    console.log('this is newMethod');
}

// 修改特定方法
myApplication.getInfo = function() {
    console.log('this is new getInfo');
}

另外,用物件的方式也方便我們組織同類型的變數,將其彙整在同一個 key 之下。

const myConfig = {
    language: 'english',
    defaults: {
        //...
    },
    theme: {
        skin: 'a',
        toolbars: {
        //...
        }
    }
    
}

先檢查是否有同名變數

以物件定義命名空間和單一全域變數有什麼差別嗎? 和簡單單一全域變數不同之處在於,物件實字可以先確認是否有同名變數,降低衝突可能性,例如以下範例,如果已存在就使用存在的那個,不存在時再定義它。

// 以下有幾種檢查同名變數的方式,如果已存在就使用存在的那個,不存在時則指派一個空物件
var myApplication = myApplication || {}; // 選項1.
if(!myApplication) { myApplication = {}}; // 選項2.
window.myApplication || (window.myApplication = {}); // 選項3.
var myApplication = myApplication === undefined ? {} : myApplication; // 選項4.

開發者多數會使用選項 1 或 2 的寫法,選項 3 的寫法也可以寫成 myApplication || (myApplication = {});,適合用在函式參數,如果沒有傳入參數也不會出錯,例如以下:

function foo(myApplication){
    myApplication || (myApplication = {});
    console.log(myApplication)
}

foo(); // 即使沒傳入 myApplication 也不會出錯

不過在 ES6 以後,有了函式預設參數的功能,也可以這樣寫:

function foo(myApplication = {}){
    console.log(myApplication)
}

foo(); // 即使沒傳入 myApplication 也不會出錯

優點

  • 能邏輯性組織程式碼:可將同類型的變數歸類到同個 key 底下,方便組織、歸類
  • 可先檢查相同變數名稱是否存在,避免直接覆蓋現有變數
  • 能依據需求靈活增加、修改或刪除屬性與方法
  • 將各類型命名空間都放在 key 底下,避免宣告多個全域變數,可避免污染全域命名空間

缺點

  • 不穩定性較高:物件沒有真正的封裝機制,屬性和方法都是公開的,上面有提過我們可隨意增加、修改或刪除物件屬性,靈活性高是優點同時也是缺點,因為其他程式碼可以隨意修改,代表程式碼相對不穩定、不安全

巢狀命名空間(nested namespacing)

巢狀命名空間算是上面物件實字的擴展,將屬性和方法放在多層巢狀內來降低衝突可能性。
範例如下,可不斷在該屬性下新增新屬性:

const myApp = myApp || {};

// 使用巢狀時,要先確認是否已存在該嵌套的子級
myApp.utilities = myApp.utilities || {};
myApp.model = myApp.model || {};
myApp.model.special = myApp.model.special || {};

// 巢狀命名空間可依需要而變複雜
myApp.utilities.charting.html5.plotGraph(/*...*/);
myApp.model.special.financePlanner.getSummary();

此方法需要 JavaScript 引擎先定位到 myApp 物件,再往下挖掘到實際希望使用的屬性或方法,乍看之下可能會覺得需要花費較多效能,不過這種多層嵌套的查找操作在現代 JavaScript 引擎中的效能開銷非常小且可以忽略不計。《JavaScript 設計模式學習手冊 第二版》書中也有提到,Juriy Zaytsev 開發者已測試過,單一物件命名空間和「巢套」方法間的效能差異可忽略不計。具體來說,JavaScript 引擎會使用內部優化技術來加速物件屬性的查找。因此,即使在巢狀命名空間中查找深層次的屬性,實際的查找時間並不會顯著增加。不必擔心使用巢狀命名空間會影響應用程式的效能。

自動化巢套命名空間

巢套命名空間的方法有個問題,就是需要確認每一層都有該屬性才能繼續指派子層級,例如 application.utilities.drawing.canvas.2d 要確認有 utilitiesdrawingcanvas 屬性才能指派值,這樣逐層確認會讓程式碼變得複雜,寫起來也麻煩,因此有個解法是用一個函式來自動命名巢狀命名空間中的每個屬性,此函式會接受一個字串作為參數,在該屬性不存在時自動填充屬性。

function extend(ns, nsString) {// ns 是根物件,nsString 則是一個字串,用來表示要擴展的子級屬性
    const parts = nsString.split(".");
    let parent = ns;
    let pl;

    pl = parts.length;

    for (let i = 0; i < pl; i++) {
        // 若屬性不存在,則建立它
        if (typeof parent[parts[i]] === "undefined") {
            parent[parts[i]] = {};
        }

        parent = parent[parts[i]];
    }

    return parent;
}

// 用法
consy myApp = {};
const mod = extend(myApp, "modules.module2"); // 擴展屬性,如果不存在就建立該屬性
console.log(myApp);//成功建立 modules.module2 屬性

優化層次

一般存取巢狀命名空間,會一整串從根物件開始寫起:

myApp.utilities.math.sin(56);
myApp.utilities.drawing.plot(98, 50, 60);

我們可以將頻繁使用的屬性或方法暫存到另一個變數中,以減少查找深層屬性的次數,也提升程式碼的可讀性。

const utils = myApp.utilities;
const math = utils.math;
const drawing = utils.drawing;

// 更容易存取,可讀性更高
math.sin(56);
drawing.plot(98, 50, 60);

優點

  • 能避免名稱衝突:能有組織性的管理全域變數,避免變數衝突,尤其是在有大型專案或與第三方函式庫整合時
  • 程式碼組織性高:將相關功能分組,提高程式碼的可讀性和維護性,讓程式碼邏輯更清晰
  • 便於擴展:方便新增屬性和方法,不須擔心影響程式碼中的其他部分,適合不斷擴展的應用程式

缺點

  • 增加複雜性:如果巢狀結構過多,會讓程式碼變得過於冗長,反而降低可讀性
  • 重構困難:隨著專案擴大,巢狀命名空間可能需要重構以適應新的需求,而重構巢狀命名空間的程式碼可能比較繁瑣,需要小心處理以避免出錯

立即呼叫函式

立即呼叫函式(Immediately Invoked Functions Expressions, IIFE)在定義後會立即被呼叫,以下為立即呼叫函式簡單範例,可以是匿名的函式、也可以是有命名的函式。

// 匿名的立即呼叫函式運算式
(() => {/*...*/})();

// 命名的立即呼叫函式運算式
(function foobar (){/*...*/}());

在立即呼叫函式內定義的變數都只能在其內部存取,隱私性高,可避免被全域命名空間影響。

  • 範例一:傳遞參數,在 IIFE 內指派 namespace 的方法和屬性
    const namespace = {};
    ((obj) => {
        obj.foobar = "foobar";
        obj.sayHello = () => {
            console.log("Hello");
        };
    })(namespace);
    
    
  • 範例二:擴展現有命名空間,並且可以在 IIFE 內定義私有屬性,外部無法存取內部私有變數
    ((namespace) => {
        // 私有變數
        const foo = "foo"; 
        const bar = "bar";
    
        // 擴展命名空間屬性和方法
        namespace.foobar = "foobar";
        namespace.sayHello = () => {
            speak("Hello World");
        };
        namespace.sayGoodbye = () => {
            speak("Goodbye peeps");
        };
        function speak(msg) {
            console.log(`You said: ${msg}`);
        }
    })(window.namespace2 = window.namespace2 || {}); // 假設已有 namespace2 變數,就傳入已存在的,否則新建一個新的
    
    console.log(namespace2); // namespace2 就會有 {foobar: 'foobar', sayHello: ƒ, sayGoodbye: ƒ}
    console.log(foo); // Uncaught ReferenceError: foo is not defined,因為外部無法存取內部私有變數
    
  • 範例三:立即執行函式可避免變數衝突
    // 兩個變數名稱都是 namespace,但兩者不會相互干擾,local 跟 global 抓到的資料會完全分開
    const namespace = 'this is global namespace';
    
    (function() {
        const namespace = 'this is local namespace';
        console.log(namespace); // this is local namespace
    })();
    
    console.log(namespace); // this is global namespace
    

優點

  • 私有變數和方法:可以建立私有作用域,將內部變數和方法封裝,避免被外部直接存取,增加程式碼安全性
  • 避免全域污染:可以有效封裝變數和函式,從而避免全域變數污染,保持全域命名空間的整潔

缺點

  • 無法重複呼叫:IIFE 會立即執行且執行一次,若需要重複執行相同邏輯,必須重寫或重新定義,缺乏靈活性
  • 可讀性較低:使用 IIFE 可能使程式碼顯得更加複雜和不直觀,對於不熟悉這種模式的開發者而言,理解程式碼作用域可能需要更多時間
  • 擴展困難:IIFE 創建的命名空間是封閉的,如果需要擴展或修改 IIFE 內的內容,必須調整函式本身或重新定義,靈活性較低

ES6 模組

JavaScript ES6 之後,原生就可用模組功能,可用 import 來匯入需要的模組,用 export 來匯出想公開的方法,每個模組都有自己的私有作用域,不用擔心和別人有衝突。
以下範例中,即使 moduleAmoduleB 檔案內都有 privateValue 變數,但兩者都在各自的私有作用域,因此不會產生衝突。

// moduleA.js
const privateValue = 'JavaScript';

export function publicMethodA(){
    console.log(privateValue);
};

// moduleB.js
const privateValue = 'React';

export function publicMethodB(){
    console.log(privateValue);
};

// app.js
import { publicMethodA } from 'moduleA.js'
import { publicMethodB } from 'moduleB.js'
publicMethodA(); // JavaScript
publicMethodB(); // React

優點

  • 避免全域污染和名稱衝突:ES6 模組自帶作用域隔離,所有模組內的變數和函式都是私有的,不會汙染全域範圍,有效避免全域變數污染和名稱衝突的問題
  • 有組織性的管理:使用原生的 importexport 關鍵字,ES6 模組可清晰定義和管理模組間的依賴關係,不須開發者手動維護命名空間樹狀結構。讓程式碼更容易組織和維護
  • 可讀性和維護性高:可直觀看到模組的輸入和輸出,讓程式碼的結構更清晰。開發者可明確了解模組提供什麼功能、依賴哪些外部模組,提高程式碼可讀性和可維護性

缺點

  • 瀏覽器兼容性:雖然現代瀏覽器和 Node.js 都已經支持 ES6 模組,但對於一些舊版瀏覽器或不支援 ES6 模組的環境,仍需使用建構工具(如 Babel、Webpack)來轉譯程式碼,以確保兼容性

小結

使用命名空間時,保持命名空間簡單且有意義,為命名空間選用好理解、描述性的名稱,以提高可讀性,舉例來說,避免 myApp.abc 這種命名方式。另外,可混合使用不同命名空間的模式,依據專案需求來靈活組合不同命名空間,也可隨時因應新需求做調整。

Reference


上一篇
[Day 17] Promise 模式
下一篇
[Day 19] HOC 模式
系列文
30天的 JavaScript 設計模式之旅22
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言