今天要介紹的是命名空間化模式🧐
《JavaScript 設計模式學習手冊 第二版》作者 Addy Osmani 在書中敘述命名空間(namespace)是「唯一識別符之下的程式碼單元的邏輯分組」,乍聽之下不知道什麼意思,其實白話來講就是變數和函式的一個容器,命名空間作為一個包裝物/容器,可用來區隔變數或函式的作用域。
一句話簡介,命名空間是一種將相關的變數、函式和物件封裝起來的機制。
在 JavaScript 中,如果我們沒特別處理,變數和函式預設是全域作用域,因此我們需要方法來避免全域汙染與變數衝突,以下為變數衝突示意圖。
圖 1 變數衝突示意圖(資料來源:自行繪製)
而這個解決方法就是命名空間,JavaScript 命名空間的用途/優點如下:
可能會有人困惑說,要避免變數重複,那就專案的團隊成員講好就好啦,或是搭配 ESLint 工具來檢查程式碼就好(例如:no-redeclare 規則)。但在前端應用中,除了我們自己開發的程式碼,當產品上線後,常會有大量第三方程式碼(script)被注入頁面並執行,這些第三方程式碼不是開發者本身可控制的,因此我們需要方法來避免使用相同變數名稱。又例如,假設今天公司的產品就是那些要注入客戶端的第三方程式碼,那撰寫時就要更小心,以免注入程式碼後破壞了客戶端原本的應用程式功能。而這些「第三方程式碼」例如 Google Analytics、Google Tag Manager 等,這些用來追蹤或處理額外邏輯的程式碼,通常是上線後才被注入到頁面上,且撰寫這些程式碼的人,不一定是開發產品應用程式的那些工程師們。
會在這裡寫這麼多補充,是因為自己本身有些感觸(?,因為我在前公司負責的工作之一就是用 Google Tag Manager 為客戶端的前端應用注入程式碼以埋放廣告,這時就要小心,避免和客戶端應用程式的變數衝突,而且會同時注入第三方程式碼的可能還不只一間公司,所以一個頁面上可能會混雜著好幾間公司的工程師們寫的程式碼XD。舉例來說,像科技報橘這個網站底下就嵌入了很多 script,這些 script 可能是由許多非科技報橘的公司所注入的程式碼。當它們混雜在一起後,依然要能保持應用能正常、穩定運作,這就和命名空間的設計有關。
圖 2 嵌入外部 script 示意圖(資料來源:自行截圖/繪製)
另外再補充一點,以下範例程式可能還是會用到 var
來宣告變數,即使 ES6 以後我們多數會用 let
和 const
來宣告變數,但以我之前的經驗,在 Google Tag Manager 內寫的程式碼是要寫 JavaScript ES5 的,也就是說,無法在 Google Tag Manager 內寫 let
和 const
,還是得用 var
,因此看到底下的 var
請不要見怪><
以下將介紹幾種命名空間模式。
以單一全域變數作為主要參照物件,把所有方法屬性都寫在該物件裡,簡單粗暴好理解👍
範例:透過 IIFE 回傳具有函式和屬性的物件,建立唯一的命名空間
const myUniqueApplication = (() => {
function myMethod(){
//...
}
return {
myMethod
}
})();
// 用法
myUniqueApplication.myMethod();
在變數名稱前加上特定的字首(prefix)來避免和其他變數衝突,在字首後定義任何方法、變數或其他物件。
const myApplication_propertyA = {};
const myApplication_propertyB = {};
function myApplicatioon_myMethod(){
//...
}
簡單講就是讓變數名稱再更獨特一點,這裡是用加入字首的方式讓它變得更難撞名。
以物件實字(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 也不會出錯
巢狀命名空間算是上面物件實字的擴展,將屬性和方法放在多層巢狀內來降低衝突可能性。
範例如下,可不斷在該屬性下新增新屬性:
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
要確認有 utilities
、drawing
、canvas
屬性才能指派值,這樣逐層確認會讓程式碼變得複雜,寫起來也麻煩,因此有個解法是用一個函式來自動命名巢狀命名空間中的每個屬性,此函式會接受一個字串作為參數,在該屬性不存在時自動填充屬性。
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 (){/*...*/}());
在立即呼叫函式內定義的變數都只能在其內部存取,隱私性高,可避免被全域命名空間影響。
namespace
的方法和屬性
const namespace = {};
((obj) => {
obj.foobar = "foobar";
obj.sayHello = () => {
console.log("Hello");
};
})(namespace);
((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
JavaScript ES6 之後,原生就可用模組功能,可用 import
來匯入需要的模組,用 export
來匯出想公開的方法,每個模組都有自己的私有作用域,不用擔心和別人有衝突。
以下範例中,即使 moduleA
和 moduleB
檔案內都有 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
import
和 export
關鍵字,ES6 模組可清晰定義和管理模組間的依賴關係,不須開發者手動維護命名空間樹狀結構。讓程式碼更容易組織和維護使用命名空間時,保持命名空間簡單且有意義,為命名空間選用好理解、描述性的名稱,以提高可讀性,舉例來說,避免 myApp.abc
這種命名方式。另外,可混合使用不同命名空間的模式,依據專案需求來靈活組合不同命名空間,也可隨時因應新需求做調整。