- 原文地址:JavaScript Factory Functions with ES6+
- 原文作者:Eric Elliott
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:lampui
- 校對者:IridescentMia、sunui
注意:這是“軟體編寫”系列文章的第八部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數語言程式設計和組合化軟體(compositional software)技術(譯註:關於軟體可組合性的概念,參見維基百科 Composability)。後續還有更多精彩內容,敬請期待!
< 上一篇 | << 第一篇 | 下一篇 >
工廠函式是一個能返回物件的函式,它既不是類也不是建構函式。在 JavaScript 中,任何函式都可以返回一個物件,如果函式前面沒有使用 new
關鍵字,卻又返回一個物件,那這個函式就是一個工廠函式。
因為工廠函式提供了輕鬆生成物件例項的能力,且無需深入學習類和 new
關鍵字的複雜性,所以工廠函式在 JavaScript 中一直很具吸引力。
JavaScript 提供了非常方便的物件字面量語法,程式碼如下:
const user = {
userName: 'echo',
avatar: 'echo.png'
};複製程式碼
就像 JSON 的語法(JSON 就是基於 JavaScript 的物件字面量語法),:
(冒號)左邊是屬性名,右邊是屬性值。你可以使用點運算子訪問變數:
console.log(user.userName); // "echo"複製程式碼
或者使用方括號及屬性名訪問變數:
const key = 'avatar';
console.log( user[key] ); // "echo.png"複製程式碼
如果在作用域內還有變數和你的屬性名相同,那你可以直接在物件字面量中使用這個變數,這樣就省去了冒號和屬性值:
const userName = 'echo';
const avatar = 'echo.png';
const user = {
userName,
avatar
};
console.log(user);
// { "avatar": "echo.png", "userName": "echo" }複製程式碼
物件字面量支援簡潔表示法。我們可以新增一個 .setUserName()
的方法:
const userName = 'echo';
const avatar = 'echo.png';
const user = {
userName,
avatar,
setUserName (userName) {
this.userName = userName;
return this;
}
};
console.log(user.setUserName('Foo').userName); // "Foo"複製程式碼
在簡潔表示法中,this
指向的是呼叫該方法的物件,要呼叫一個物件的方法,只需要簡單地使用點運算子訪問方法並使用圓括號呼叫即可,例如 game.play()
就是在 game
這一物件上呼叫 .play()
。要使用點運算子呼叫方法,這個方法必須是物件屬性。你也可以使用函式原型方法 .call()
、.apply()
或 .bind()
把一個方法應用於一個物件上。
本例中,user.setUserName('Foo')
是在 user
物件上呼叫 .setUserName()
,因此 this === user
。在.setUserName()
方法中,我們通過 this
這個引用修改了 .userName
的值,然後返回了相同的物件例項,以便於後續方法鏈式呼叫。
字面量偏向單一物件,工廠方法適用眾多物件
如果你需要建立多個物件,你應該考慮把物件字面量和工廠函式結合使用。
使用工廠函式,你可以根據需要建立任意數量的使用者物件。假如你正在開發一個聊天應用,你會用一個使用者物件表示當前使用者,以及用很多個使用者物件表示其他已登入和在聊天的使用者,以便顯示他們的名字和頭像等等。
讓我們把 user
物件轉換為一個 createUser()
工廠方法:
const createUser = ({ userName, avatar }) => ({
userName,
avatar,
setUserName (userName) {
this.userName = userName;
return this;
}
});
console.log(createUser({ userName: 'echo', avatar: 'echo.png' }));
/*
{
"avatar": "echo.png",
"userName": "echo",
"setUserName": [Function setUserName]
}
*/複製程式碼
返回物件
箭頭函式(=>
)具有隱式返回的特性:如果函式體由單個表示式組成,則可以省略 return
關鍵字。()=>'foo'
是一個沒有引數的函式,並返回字串 "foo"
。
返回物件字面量時要小心。當使用大括號時,JavaScript 預設你建立的是一個函式體,例如 { broken: true }
。如果你需要返回一個明確的物件字面量,那你就需要通過使用圓括號將物件字面量包起來以消除歧義,如下所示:
const noop = () => { foo: 'bar' };
console.log(noop()); // undefined
const createFoo = () => ({ foo: 'bar' });
console.log(createFoo()); // { foo: "bar" }複製程式碼
在第一個例子中,foo:
被解釋為一個標籤,bar
被解釋為一個沒有被賦值或者返回的表示式,因此函式返回 undefined
。
在 createFoo()
例子中,圓括號強制著大括號,使其被解釋為要求值的表示式,而不是一個函式體。
解構
請特別注意函式宣告:
const createUser = ({ userName, avatar }) => ({複製程式碼
這一行裡,大括號 ({, }
) 表示物件解構。這個函式有一個引數(即一個物件),但是從這個引數中,卻解構出了兩個形參,userName
和 avatar
。這些形參可以作為函式體內的變數使用。解構還可以用於陣列:
const swap = ([first, second]) => [second, first];
console.log( swap([1, 2]) ); // [2, 1]複製程式碼
你可以使用擴充套件語法 (...varName
) 獲取陣列(或引數列表)餘下的值,然後將這些值回傳成單個元素:
const rotate = ([first, ...rest]) => [...rest, first];
console.log( rotate([1, 2, 3]) ); // [2, 3, 1]複製程式碼
計算屬性值
前面我們使用方括號的方法動態訪問物件的屬性值:
const key = 'avatar';
console.log( user[key] ); // "echo.png"複製程式碼
我們也可以計算屬性值來賦值:
const arrToObj = ([key, value]) => ({ [key]: value });
console.log( arrToObj([ 'foo', 'bar' ]) ); // { "foo": "bar" }複製程式碼
本例中,arrToObj
接受一個包含鍵值對(又稱元組
)的陣列,並將其轉化成一個物件。因為我們並不知道屬性名,因此我們需要計算屬性名以便在物件上設定屬性值。為了做到這一點,我們使用了方括號表示法,來設定屬性名,並將其放在物件字面量的上下文中來建立物件:
{ [key]: value }複製程式碼
在賦值完成後,我們就能得到像下面這樣的物件:
{ "foo": "bar" }複製程式碼
預設引數
JavaScript 函式支援預設引數值,給我們帶來以下優勢:
- 使用者可以通過適當的預設值省略引數。
- 函式自我描述性更高,因為預設值提供預期的輸入例子。
- IDE 和靜態分析工具可以利用預設值推斷引數的型別。例如,一個預設值
1
表示引數可以接受的資料型別為Number
。
使用預設引數,我們可以為我們的 createUser
工廠函式描述預期的介面,此外,如果使用者沒有提供資訊,可以自動地補充某些
細節:
const createUser = ({
userName = 'Anonymous',
avatar = 'anon.png'
} = {}) => ({
userName,
avatar
});
console.log(
// { userName: "echo", avatar: 'anon.png' }
createUser({ userName: 'echo' }),
// { userName: "Anonymous", avatar: 'anon.png' }
createUser()
);複製程式碼
函式簽名的最後一部分可能看起來有點搞笑:
} = {}) => ({複製程式碼
在引數宣告最後那部分的 = {}
表示:如果傳進來的實參不符合要求,則將使用一個空物件作為預設引數。當你嘗試從空物件解構賦值的時候,屬性的預設值會被自動填充,因為這就是預設值所做的工作:用預先定義好的值替換 undefined
。
如果沒有 = {}
這個預設值,且沒有向 createUser()
傳遞有效的實參,則將會丟擲錯誤,因為你不能從 undefined
中訪問屬性。
型別判斷
在寫這篇文章的時候,JavaScript 都還沒有內建的型別註解,但是近幾年湧現了一批格式化工具或者框架來填補這一空白,包括 JSDoc(由於出現了更好的選擇其呈現出下降趨勢)、Facebook 的 Flow、還有微軟的 TypeScript。我個人使用 rtype,因為我覺得它在函數語言程式設計方面比 TypeScript 可讀性更強。
直至寫這篇文章,各種型別註解方案其實都不相上下。沒有一個獲得 JavaScript 規範的庇護,而且每個方案都有它明顯的不足。
型別推斷是基於變數所在的上下文推斷其型別的一個過程,在 JavaScript 中,這是對型別註解非常好的一個替代。
如果你在標準的 JavaScript 函式中提供足夠的線索去推斷,你就能獲得型別註解的大部分好處,且不用擔心任何額外成本或風險。
即使你決定使用像 TypeScript 或 Flow 這樣的工具,你也應該儘可能利用型別推斷的好處,並儲存在型別推斷抽風時的型別註解。例如,原生 JavaScript 是不支援定義共享介面的。但使用 TypeScript 或 rtype 都可以方便有效地定義介面。
Tern.js 是一個流行的 JavaScript 型別推斷工具,它在很多程式碼編輯器或 IDE 上都有外掛。
微軟的 Visual Studio Code 不需要 Tern,因為它把 TypeScript 的型別推斷功能附帶到了 JavaScript 程式碼的編寫中。
當你在 JavaScript 函式中指定預設引數值時,很多諸如 Tern.js、TypeScript 和 Flow 的型別推斷工具就可以在 IDE 中給予提示以幫助開發者正確地使用 API。
沒有預設值,各種 IDE(更多的時候,連我們自己)都沒有足夠的資訊來判斷函式預期的引數型別。
有了預設值,IDE (更多的時候,我們自己) 可以從程式碼中推斷出型別。
將引數限制為固定型別(這會使通用函式和高階函式更加受限)是不怎麼合理的。但要說這種方法什麼時候有意義的話,使用預設引數通常就是,即使你已經在使用 TypeScript 或 Flow 做型別推斷。
Mixin 結構的工廠函式
工廠函式擅於利用一個優秀的 API 建立物件。通常來說,它們能滿足基本需求,但不久之後,你就會遇到這樣的情況,總會把類似的功能構建到不同型別的物件中,所以你需要把這些功能抽象為 mixin 函式,以便輕鬆重用。
mixin 的工廠函式就要大顯身手了。我們來構建一個 withConstructor
的 mixin 函式,把 .constructor
屬性新增到所有的物件例項中。
with-constructor.js:
const withConstructor = constructor => o => {
const proto = Object.assign({},
Object.getPrototypeOf(o),
{ constructor }
);
return Object.assign(Object.create(proto), o);
};複製程式碼
現在你可以匯入和使用其他 mixins:
import withConstructor from `./with-constructor';
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
// 或者 `import pipe from 'lodash/fp/flow';`
// 設定一些 mixin 的功能
const withFlying = o => {
let isFlying = false;
return {
...o,
fly () {
isFlying = true;
return this;
},
land () {
isFlying = false;
return this;
},
isFlying: () => isFlying
}
};
const withBattery = ({ capacity }) => o => {
let percentCharged = 100;
return {
...o,
draw (percent) {
const remaining = percentCharged - percent;
percentCharged = remaining > 0 ? remaining : 0;
return this;
},
getCharge: () => percentCharged,
get capacity () {
return capacity
}
};
};
const createDrone = ({ capacity = '3000mAh' }) => pipe(
withFlying,
withBattery({ capacity }),
withConstructor(createDrone)
)({});
const myDrone = createDrone({ capacity: '5500mAh' });
console.log(`
can fly: ${ myDrone.fly().isFlying() === true }
can land: ${ myDrone.land().isFlying() === false }
battery capacity: ${ myDrone.capacity }
battery status: ${ myDrone.draw(50).getCharge() }%
battery drained: ${ myDrone.draw(75).getCharge() }%
`);
console.log(`
constructor linked: ${ myDrone.constructor === createDrone }
`);複製程式碼
正如你所見,可重用的 withConstructor()
mixin 與其他 mixin 一起被簡單地放入 pipeline
中。withBattery()
可以被其他型別的物件使用,如機器人、電動滑板或行動式裝置充電器等等。withFlying()
可以被用來模型飛行汽車、火箭或氣球。
物件組合更多的是一種思維方式,而不是寫程式碼的某一特定技巧。你可以在很多地方用到它。功能組合只是從頭開始構建你思維方式的最簡單方法,工廠函式就是將物件組合有關實現細節包裝成一個友好 API 的簡單方法。
結論
對於物件的建立和工廠函式,ES6 提供了一種方便的語法,大多數時候,這樣就足夠了,但因為這是 JavaScript,所以還有一種更方便並更像 Java 的語法:class
關鍵字。
在 JavaScript 中,類比工廠更冗長和受限,當進行程式碼重構時更容易出現問題,但也被像是 React 和 Angular 等主流前端框架所採納使用,而且還有一些少見的用例,使得類更有存在意義。
“有時,最優雅的實現僅僅是一個函式。不是方法,不是類,不是框架。僅僅只是一個函式。” ~ John Carmack
最後,你還要切記,不要把事情搞複雜,工廠函式不是必需的,對於某個問題,你的解決思路應當是:
純函式 > 工廠函式 > 函式式 Mixin > 類
Next: Why Composition is Harder with Classes >
接下來
想更深入學習關於 JavaScript 的物件組合?
跟著 Eric Elliott 學 Javacript,機不可失時不再來!
Eric Elliott 是 “編寫 JavaScript 應用” (O’Reilly) 以及 “跟著 Eric Elliott 學 Javascript” 兩書的作者。他為許多公司和組織作過貢獻,例如 Adobe Systems、Zumba Fitness、The Wall Street Journal、ESPN 和 BBC 等 , 也是很多機構的頂級藝術家,包括但不限於 Usher、Frank Ocean 以及 Metallica。
大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一起。
感謝 JS_Cheerleader.
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。