[譯] ES6+ 中的 JavaScript 工廠函式(第八部分)

Pui發表於2017-09-25

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)
Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)

注意:這是“軟體編寫”系列文章的第八部分,該系列主要闡述如何在 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 }) => ({複製程式碼

這一行裡,大括號 ({, }) 表示物件解構。這個函式有一個引數(即一個物件),但是從這個引數中,卻解構出了兩個形參,userNameavatar。這些形參可以作為函式體內的變數使用。解構還可以用於陣列:

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 函式支援預設引數值,給我們帶來以下優勢:

  1. 使用者可以通過適當的預設值省略引數。
  2. 函式自我描述性更高,因為預設值提供預期的輸入例子。
  3. 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(更多的時候,連我們自己)都沒有足夠的資訊來判斷函式預期的引數型別。

沒有預設值, `userName` 的型別未知。
沒有預設值, `userName` 的型別未知。

有了預設值,IDE (更多的時候,我們自己) 可以從程式碼中推斷出型別。

有預設值,IDE 可以提示 `userName` 的型別應該是字串。
有預設值,IDE 可以提示 `userName` 的型別應該是字串。

將引數限制為固定型別(這會使通用函式和高階函式更加受限)是不怎麼合理的。但要說這種方法什麼時候有意義的話,使用預設引數通常就是,即使你已經在使用 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 SystemsZumba FitnessThe Wall Street JournalESPNBBC 等 , 也是很多機構的頂級藝術家,包括但不限於 UsherFrank Ocean 以及 Metallica

大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一起。

感謝 JS_Cheerleader.


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章