【譯】JavaScript的GETTER-SETTER金字塔模型

RichardoLu發表於2019-03-28

檢視原文

函式是JavaScript的基石。它是一種靈活的抽象,可以作為其他抽象的基礎,例如Promises,Iterables,Observables等。我一直在會議和研討會上教授這些概念,隨著時間的推移,我發現了一個金字塔模型,可以對這些抽象做一個優雅的總結。在這篇部落格中,我將為大家介紹這個金字塔的各個層級。

FUNCTIONS

X => Y

function

一等公民是JavaScript的基礎,如number,string,object,boolean等。儘管你可以只用值和控制流寫出一個程式,但很快你就會需要寫一個函式來改進你的程式。

函式是JavaScript中不可避免的抽象,它們通常用回撥實現非同步的I/O。“函式”這個詞在JavaScript中並不像在函數語言程式設計中那樣代表“純函式”。因為它們只是惰性的可複用程式碼塊,具有可選的輸入(引數)和輸出(返回值),把它們理解為簡單的“過程”會更好。

與硬編碼的程式碼塊相比,函式有兩個很重要的優勢:

  • 惰性/可複用
    • 函式體中的程式碼為了可複用,一定是惰性的(即呼叫時才執行)
  • 實現靈活
    • 函式的使用者不關心函式的內部是如何實現的,這意味著函式的實現方式靈活多變。

GETTERS

() => X

getter是一個沒有輸入引數並輸出X的函式

getter

getter是一種函式,它不需要傳遞引數但可以返回一個期望值。在JavaScript的執行時中有非常多這樣的getter,如Math.random()Date.now()等。getter作為值的抽象也非常有用。請比較下面的usergetUser

const user = {name: 'Alice', age: 30};
console.log(user.name); // Alice


function getUser() {
  return {name: 'Alice', age: 30};
}
console.log(getUser().name); // Alice

複製程式碼

通過使用getter表示一個值,我們繼承了函式的優點,如惰性:如果我們不呼叫getUser(),那麼user物件就不會被建立出來。

因為我們可以用多種不同的方式(建立一個普通的物件,或者返回一個類的例項,又或者使用原型上的屬性等等)來計算返回的物件,所以我們也獲得了實現的靈活性。採用硬編碼的話就做不到這麼靈活。

getter還允許我們使用副作用鉤子。無論getter在什麼時候被執行,我們都能觸發一個有用的副作用,像一個console.log或者觸發一個分析事件,下面是一個例子:

function getUser() {
  Analytics.sendEvent('User object is now being accessed');
  return {name: 'Alice', age: 30};
}

複製程式碼

getter上的計算也可以是抽象的,因為函式在JavaScript中可以被當作一等公民進行傳遞。舉個例子,看下面這個求和函式,它用getter作為引數並返回一個number型的getter,而不是直接返回一個number型別的值。

function add(getX, getY) {
  return function getZ() {
    const x = getX();
    const y = getY();
    return x + y;
  }
}

複製程式碼

當getter需要返回一個不可預測的值時,這種抽象計算的好處是很明顯的,例如使用Math.random作為引數:

const getTen = () => 10;
const getTenPlusRandom = add(getTen, Math.random);

console.log(getTenPlusRandom()); // 10.948117215055046
console.log(getTenPlusRandom()); // 10.796721274448556
console.log(getTenPlusRandom()); // 10.15350303918338
console.log(getTenPlusRandom()); // 10.829703269933633

複製程式碼

getter與Promise一同使用也是很常見的,由於Promise被認為是不可複用的計算,所以將Promise構造器包在getter(也被稱為“工廠”或“形式轉換”)中使其可複用。

SETTERS

X => ()

setter是一個接受X作為引數而沒有輸出的函式

setter

setter是一種接收引數但沒有返回值的函式。JavaScript執行時和DOM中有許多原生的setter,例如console.log(x)document.write(x)等。

與getter不同,setter通常不是抽象,因為函式沒有返回值意味著函式只能在JavaScript執行時中傳送資料或命令。舉個例子,名為getTen的getter是一個對數字10的抽象並且我們可以把它當作一個值進行傳遞,而將setTen作為值進行傳遞則沒有任何意義,因為你不能通過呼叫它來獲得任何數字。

也就是說,setter可以是對其他setter的簡單封裝,看下面對console.log這個setter的封裝:

function fancyConsoleLog(str) {
  console.log('⭐ ' + str + ' ⭐');
}

複製程式碼

GETTER GETTERS

() => ( () => X )

getter-getter是一個不需要輸入引數並輸出一個getter的函式

getter-getter

有一類特殊的getter可以返回另一個getter,所以它是一個getter的getter。對getter-getter的需求源於使用getter迭代序列。舉個例子,如果我們想要顯示2的冪的數字序列,我們可以使用getNextPowerOfTwo()這個getter:

let i = 2;
function getNextPowerOfTwo() {
  const next = i;
  i = i * 2;
  return next;
}

console.log(getNextPowerOfTwo()); // 2
console.log(getNextPowerOfTwo()); // 4
console.log(getNextPowerOfTwo()); // 8
console.log(getNextPowerOfTwo()); // 16
console.log(getNextPowerOfTwo()); // 32
console.log(getNextPowerOfTwo()); // 64
console.log(getNextPowerOfTwo()); // 128

複製程式碼

這段程式碼的問題是變數i是一個全域性變數,如果我們想重啟這個序列,就必須以正確的方式操作這個變數,從而暴露了這個getter的實現細節。

想要這段程式碼有更高的可複用性並且不依賴全域性變數,我們需要做的是用一個函式封裝這個getter。而這個包裝函式也是一個getter。

function getGetNext() {
  let i = 2;
  return function getNext() {
    const next = i;
    i = i * 2;
    return next;
  }
}

let getNext = getGetNext();
console.log(getNext()); // 2
console.log(getNext()); // 4
console.log(getNext()); // 8
getNext = getGetNext(); // ? restart!
console.log(getNext()); // 2
console.log(getNext()); // 4
console.log(getNext()); // 8
console.log(getNext()); // 16
console.log(getNext()); // 32

複製程式碼

因為getter-getter是一類特殊的getter,它們繼承了getter所有的優點,比如:

  1. 靈活的實現
  2. 副作用鉤子
  3. 惰性

在這裡惰性反映在初始化的步驟。外層函式支援惰性初始化,與此同時內層函式支援惰性的值迭代:

function getGetNext() {
  // ? LAZY INITIALIZATION
  let i = 2;

  return function getNext() {
    // ? LAZY ITERATION
    const next = i;
    i = i * 2;
    return next;
  }
}

複製程式碼

SETTER SETTERS


( X => () ) => ()

setter-setter是接收一個setter作為輸入且沒有輸出的函式

setter-setter

setter-setter是一種特別的setter函式,其引數也是一個setter。儘管基礎的setter不是抽象,但setter-setter是抽象,它能夠表示可以在程式碼中進行傳遞的值。

例如,請思考是否可能借助下面的setter-setter表示數字10:

function setSetTen(setTen) {
  setTen(10)
}

複製程式碼

要注意缺少返回值,因為setter從來沒有返回值。通過對引數進行簡單的重新命名可以使上面的例子更具有可讀性。

function setTenListener(cb) {
  cb(10)
}

複製程式碼

顧名思義,cb代表“回撥(callback)”,表明了在有大量回撥用例時setter-setter在JavaScript中是多麼常見。將setter-setter表示的抽象值反過來用其實就得到了getter。

setSetTen(console.log);

// compare with...

console.log(getTen())
複製程式碼

setter-setter的好處與getter相同——惰性,靈活的實現,副作用鉤子——但有兩個getter沒有的新屬性:控制反轉和非同步性。

在上面的例子中,使用getter的程式碼決定何時將getter與console.log一起使用。然而,使用setter-setter時,由setter-setter自己決定何時呼叫console.log。責任倒置使setter-setter比getter更加強大,下面的例子中傳送了多個值給消費者:

function setSetTen(setTen) {
  setTen(10)
  setTen(10)
  setTen(10)
  setTen(10)
}

複製程式碼

控制反轉還允許setter-setter決定何時將值傳遞給回撥,例如非同步。假設把setSetTen的名字改為setTenListener

function setTenListener(cb) {
  setTimeout(() => { cb(10); }, 1000);
}

複製程式碼

儘管setter-setter在JavaScript中常用於非同步程式設計,但回撥中的程式碼不一定是非同步的。在下面的這個setSetTen的例子中,它與getter一樣是同步的:

function setSetTen(setTen) {
  setTen(10)
}

console.log('before');
setSetTen(console.log);
console.log('after');

// (Log shows:)
// before
// 10
// after

複製程式碼

ITERABLES

() => ( () => ({done, value}) )

可迭代物件(忽略了一些細節)是一個getter-getter,它返回一個描述了值和完成狀態的物件

iterables

getter-getter能夠表示一個可重啟的值序列,但沒有約定用什麼標記序列的結束。可迭代物件是一類特殊的getter-getter,它的值總是一個有兩個屬性的物件:done(指示是否結束的布林值)和valuedone不為true時實際被傳遞的值)。

結束標記讓使用可迭代物件的消費者知道序列將返回無效的資料,所以消費者能夠知道何時停止迭代。

在下面的例子中,我們可以根據完成指示器(completion indicator)生成一個有限的getter-getter,其值為40-48之間的偶數:

function getGetNext() {
  let i = 40;
  return function getNext() {
    if (i <= 48) {
      const next = i;
      i += 2;
      return {done: false, value: next};
    } else {
      return {done: true};
    }
  }
}

let getNext = getGetNext();
for (let result = getNext(); !result.done; result = getNext()) {
  console.log(result.value);
}

複製程式碼

相比簡單的() => ( () => ({done, value}) )模式,ES6的可迭代物件有更深入的約定,它們在每個getter上新增了一個包裝器物件:

  • 外層的getter f 變成了物件{[Symbol.iterator]: f}
  • 內層的getter g 變成了物件{next: g}

這裡是一個有效的ES6可迭代物件,程式碼的功能與之前的例子相一致:

const oddNums = {
  [Symbol.iterator]: () => {
    let i = 40;
    return {
      next: () => {
        if (i <= 48) {
          const next = i;
          i += 2;
          return {done: false, value: next};
        } else {
          return {done: true};
        }
      }
    }
  }
}

let iterator = oddNums[Symbol.iterator]();
for (let result = iterator.next(); !result.done; result = iterator.next()) {
  console.log(result.value);
}

複製程式碼

請注意兩個例子之間的不同點:

-function getGetNext() {
+const oddNums = {
+  [Symbol.iterator]: () => {
     let i = 40;
-  return function getNext() {
+    return {
+      next: () => {
         if (i <= 48) {
           const next = i;
           i += 2;
           return {done: false, value: next};
         } else {
           return {done: true};
         }
       }
+    }
   }
+}

-let getNext = getGetNext();
-for (let result = getNext(); !result.done; result = getNext()) {
+let iterator = oddNums[Symbol.iterator]();
+for (let result = iterator.next(); !result.done; result = iterator.next()) {
  console.log(result.value);
}
複製程式碼

ES6提供了方便使用可迭代物件的語法糖for-let-of

for (let x of oddNums) {
  console.log(x);
}

複製程式碼

ES6還提供了生成器函式的語法糖function*以簡化建立可迭代物件:

function* oddNums() {
  let i = 40;
  while (true) {
    if (i <= 48) {
      const next = i;
      i += 2;
      yield next;
    } else {
      return;
    }
  }
}

複製程式碼

從2015年開始,配合生產端消費端的語法糖,JavaScript中的可迭代物件是一種易於使用的對可完成的值序列的抽象。注意生成器函式自身不是一個可迭代物件,但呼叫生成器函式會返回一個可迭代物件:

function* oddNums() {
  let i = 40;
  while (true) {
    if (i <= 48) {
      yield i;
      i += 2;
    } else {
      return;
    }
  }
}

for (let x of oddNums()) {
  console.log(x);
}

複製程式碼

PROMISES

( X => (), Err => () ) => ()

Promise(忽略了一些細節)是有附加保證的,含有兩個setter的setter

Promise

儘管setter-setter已經很強大,但由於控制反轉,它們可能會非常不可預測。它們可能是同步的,也可能是非同步的,並且可以隨著時間推移傳遞零或一個或多個值。Promise是一種特別的setter-setter,它可以在傳遞值時提供一些保證:

  • 內層的setter(回撥)一定不會被同步呼叫
  • 內層的setter最多被呼叫一次
  • 提供一個可選的額外的setter來處理丟擲錯誤的情況

將下面的setter-setter與等效的Promise進行對比。Promise將只會傳一次值,並且不在兩個console.log之間,因為值的傳遞是非同步的:

function setSetTen(setTen) {
  setTen(10)
  setTen(10)
}

console.log('before setSetTen');
setSetTen(console.log);
console.log('after setSetTen');

// (Log shows:)
// before setSetTen
// 10
// 10
// after setSetTen

複製程式碼

與之相比:

const tenPromise = new Promise(function setSetTen(setTen) {
  setTen(10);
  setTen(10);
});

console.log('before Promise.then');
tenPromise.then(console.log);
console.log('after Promise.then');

// (Log shows:)
// before Promise.then
// after Promise.then
// 10

複製程式碼

Promise方便地表示了一個非同步且不可複用的值,此外ES2017提供了生產和消費的語法糖:async-await。只能在有async字首的函式中使用await來消費Promise的值:

async function main() {
  console.log('before await');
  const ten = await new Promise(function setSetTen(setTen) {
    setTen(10);
  });
  console.log(ten);
  console.log('after await');
}

main();

// (Log shows:)
// before await
// 10
// after await

複製程式碼

async-await語法糖可以用來建立一個Promise,因為async function返回一個Promise,它包著函式中被返回的值。

async function getTenPromise() {
  return 10;
}
const tenPromise = getTenPromise();

console.log('before Promise.then');
tenPromise.then(console.log);
console.log('after Promise.then');

// (Log shows:)
// before Promise.then
// after Promise.then
// 10

複製程式碼

OBSERVABLES

可觀察物件(忽略了一些細節)是有附加保證的,含有三個setter的setter

Observable

就像可迭代物件是一類特別的getter-getter,能夠標記完成的狀態。可觀察物件是一類能夠新增完成狀態的setter-setter。JavaScript中典型的setter-setter,像element.addEventListener,不會通知事件流是否已完成,所以連線事件流或執行其他的與完成狀態相關的邏輯會很困難。

與可迭代物件已經在JavaScript規範中被標準化不同,可觀察物件是RxJSmost.jsxstreamBacon.js等庫之間達成的鬆散約定。儘管Observable被考慮為TC39的提案,但是該提案一直在變動,所以在這篇文章中讓我們假定一個Fantasy Observable規範,像RxJS,most.js和xstream這樣的庫都遵循這個規範。

可觀察物件是可迭代物件的另一面,這可以通過一些對稱性看出來:

  • 可迭代物件
    • 是一個物件
    • 有“迭代”方法,即Symbol.iterator
    • “迭代”方法是一個迭代器物件的getter
    • 迭代器物件有一個next方法,是一個getter
  • 可觀察物件
    • 是一個物件
    • 有“觀察”方法,即subscribe
    • “觀察”方法是一個觀察者物件的setter
    • 觀察者物件有一個next方法,是一個setter

觀察者物件還有兩個方法,completeerror,分別表示成功完成和失敗。

complete setter相當於可迭代物件裡的done指示符,而error setter相當於從迭代器getter中丟擲一個例外。

與Promise一樣,可觀察物件在傳遞值的時候增加了一些保證:

  • 一旦complete setter被呼叫,error setter將不會被呼叫
  • 一旦error setter被呼叫,complete setter將不會被呼叫
  • 一旦complete setter或error setter被呼叫,next setter將不會被呼叫

在下面的例子中,可觀察物件表示一個非同步有限的數值序列:

const oddNums = {
  subscribe: (observer) => {
    let x = 40;
    let clock = setInterval(() => {
      if (x <= 48) {
        observer.next(x);
        x += 2;
      } else {
        observer.complete();
        clearInterval(clock);
      }
    }, 1000);
  }
};

oddNums.subscribe({
  next: x => console.log(x),
  complete: () => console.log('done'),
});

// (Log shows:)
// 40
// 42
// 44
// 46
// 48
// done

複製程式碼

與setter-setter一樣,可觀察物件導致控制反轉,所以消費端(oddNums.subscribe)沒有辦法暫停或取消進入的資料流。大多數可觀察物件的實現新增了一個重要的細節——允許消費者傳送取消訊號給生產者:訂閱者。

subscribe函式可以返回一個物件——訂閱者——擁有一個方法:unsubscribe,消費端可以使用這個方法中止進入的資料流。subscribe是一個既有輸入(觀察者)又有輸出(訂閱者)的函式,因此它不再是一個setter。下面,我們將一個訂閱者物件新增到我們之前的例子中:

const oddNums = {
  subscribe: (observer) => {
    let x = 40;
    let clock = setInterval(() => {
      if (x <= 48) {
        observer.next(x);
        x += 2;
      } else {
        observer.complete();
        clearInterval(clock);
      }
    }, 1000);
    // ? Subscription:
    return {
      unsubscribe: () => {
        clearInterval(clock);
      }
    };
  }
};

const subscription = oddNums.subscribe({
  next: x => console.log(x),
  complete: () => console.log('done'),
});

// ? Cancel the incoming flow of data after 2.5 seconds
setTimeout(() => {
  subscription.unsubscribe();
}, 2500);

// (Log shows:)
// 40
// 42

複製程式碼

ASYNC ITERABLES

**() => ( () => Promise<{done, value}>) **

非同步可迭代物件(忽略一些細節)是一個生成Promise的可迭代物件,值在Promise中

async iterable

可迭代物件可以表示任何無限或有限的值序列,但它有一個限制:在消費者呼叫next()方法時值必須可以同步被使用。非同步可迭代物件擴充了可迭代物件的能力,允許值被非同步傳遞而不是在被請求時立即返回。

非同步可迭代物件通過使用Promise實現了值的非同步傳遞。每一次迭代器的next()(內層的getter函式)被呼叫,建立並返回一個Promise。

下面的例子中,我們採用了oddNums可迭代物件的例子並使它生成延遲resolve的Promise:

function slowResolve(val) {
  return new Promise(resolve => {
    setTimeout(() => resolve(val), 1000);
  });
}

function* oddNums() {
  let i = 40;
  while (true) {
    if (i <= 48) {
      yield slowResolve(i); // ? yield a Promise
      i += 2;
    } else {
      return;
    }
  }
}

複製程式碼

為了使用非同步可迭代物件,我們要在請求下一個Promise前等待當前的Promise:

async function main() {
  for (let promise of oddNums()) {
    const x = await promise;
    console.log(x);
  }
  console.log('done');
}

main();

// (Log shows:)
// 40
// 42
// 44
// 46
// 48
// done

複製程式碼

上面的例子很符合直覺,但它並不是一個有效的ES2018非同步可迭代物件。我們在上面構造的是一個包含Promise的ES6可迭代物件,但ES2018非同步可迭代物件是包著Promise的getter-getter,Promise返回的值是done, value物件。將兩者進行對比:

  • 基於Promise的可迭代物件:() => ( () => {done, value: Promise} )
  • ES2018非同步可迭代物件:() => ( () => Promise<{done, value}> )

ES2018可迭代物件不是可迭代物件,它們只是基於Promise的getter-getter,在許多方面類似可迭代物件而已,這是反直覺的。這個細節上的差異是因為非同步可迭代物件還需要非同步地傳送完成狀態(done),所以Promise必須包著整個{done, value}物件。

因為非同步可迭代物件不是可迭代物件,所以使用了不同的Symbol。可迭代物件依賴Symbol.iterator,而非同步可迭代物件使用Symbol.asyncIterator。我們用了一個與前面類似的例子,實現了一個有效的ES2018非同步可迭代物件:

const oddNums = {
  [Symbol.asyncIterator]: () => {
    let i = 40;
    return {
      next: () => {
        if (i <= 48) {
          const next = i;
          i += 2;
          return slowResolve({done: false, value: next});
        } else {
          return slowResolve({done: true});
        }
      }
    };
  }
};

async function main() {
  let iter = oddNums[Symbol.asyncIterator]();
  let done = false;
  for (let promise = iter.next(); !done; promise = iter.next()) {
    const result = await promise;
    done = result.done;
    if (!done) console.log(result.value);
  }
  console.log('done');
}

main();

複製程式碼

可迭代物件有function*for-let-of語法糖,Promise有async-await語法糖,ES2018中的非同步可迭代物件同樣有兩個語法糖:

  • 生產端:async function*
  • 消費端:for-await-let-of

在下面的示例中,我們使用這兩個特性來建立非同步數字序列,並在for-await迴圈中使用它們:

function sleep(period) {
  return new Promise(resolve => {
    setTimeout(() => resolve(true), period);
  });
}

// ? Production side can use both `await` and `yield`
async function* oddNums() {
  let i = 40;
  while (true) {
    if (i <= 48) {
      await sleep(1000);
      yield i;
      i += 2;
    } else {
      await sleep(1000);
      return;
    }
  }
}

async function main() {
  // ? Consumption side uses the new syntax `for await`
  for await (let x of oddNums()) {
    console.log(x);
  }
  console.log('done');
}

main();

複製程式碼

儘管它們是新的特性,但非同步可迭代物件的語法糖已被Babel,TypeScript,Firefox,Chrome,Safari以及Node.js支援。非同步可迭代物件可以十分方便地與基於Promise的API相結合(例如fetch)以建立非同步序列,如一次請求一個使用者並列舉資料庫中的使用者:

async function* users(from, to) {
  for (let x = from; x <= to; x++) {
    const res = await fetch('http://jsonplaceholder.typicode.com/users/' + x);
    const json = await res.json();
    yield json;
  }
}

async function main() {
  for await (let x of users(1, 10)) {
    console.log(x);
  }
}

main();

複製程式碼

操作符

這篇文章中所列舉的抽象只是JavaScript函式的簡單特列。從定義上來說,它們不會比函式更加強大,這使得函式成為最強大和靈活的抽象。完全靈活的缺點是不可預測。這些抽象提供的是保證,基於保證你可以寫出更易組織和更可預測的程式碼。

從另一方面來說,函式是一個JavaScript值,這允許在JavaScript中傳遞和修改它們。把函式當作值傳遞的能力還能被用於我們在這篇文章中看到的抽象。我們能將可迭代物件或可觀察物件或非同步可迭代物件作為值傳遞並在這個過程中操作它們。

最常見的操作之一就是在陣列中很流行的map,但也可用於抽象中。下面的例子裡,我們為非同步可迭代物件建立了map操作符,並使用它建立一個包含使用者名稱稱的非同步可迭代物件:

async function* users(from, to) {
  for (let i = from; i <= to; i++) {
    const res = await fetch('http://jsonplaceholder.typicode.com/users/' + i);
    const json = await res.json();
    yield json;
  }
}

// ? Map operator for AsyncIterables
async function* map(inputAsyncIter, f) {
  for await (let x of inputAsyncIter) {
    yield f(x);
  }
}

async function main() {
  const allUsers = users(1, 10);
  // ? Pass `allUsers` around, create a new AsyncIterable `names`
  const names = map(allUsers, user => user.name);
  for await (let name of names) {
    console.log(name);
  }
}

main();

複製程式碼

在沒有Getter-Setter金字塔中的抽象的情況下編寫上面的程式碼示例需要更多的程式碼,也更難閱讀。如何利用這些函式特例的優點,以更少的程式碼完成更多功能,而不犧牲可讀性?請使用運算子和新語法糖特性。

pyramid

相關文章