[JS] tc39 新提案 iterator helpers
分享一下最近才得知的一個提案 iterator helpers。
此提案已經到了 stage3,且 Chrome 已經實裝可以使用了。
該提案可以解決 JavaScript 中,Array 的 method chaining 較為耗資源的問題。相信這種寫法,在處理 Array data 時,就算沒用過,也應該多少都看過。
;[1, 2, 3, 4, 5]
.map((num) => num * num)
.filter((num) => num % 2 === 1)
這種 chaining 作法,利用了 method name 的語義,本身具有不錯的易讀性,可以很快速的知道這段 code 在做些甚麼。
但這樣的寫法是把 1 個迴圈就能做完的事情,分成了多個迴圈,且 map() 與 filter() 這些方法本身,都會產生一組新的 Array,增加記憶體消耗。
所以在意效能的人,會更傾向使用傳統 for 迴圈、forEach(),或是 reduce()、flatMap() 這類方法,透過一個迴圈去處理掉。可是,這樣勢必得添加一些 if 條件判斷,手法不好的人,可能會寫出易讀性欠佳的 code。
那有沒有辦法做到 method chaining 的易讀性,同時又兼具效能呢?
這就是 iterator helpers 想解決的問題。
關於 iterator helpers
先來看一段範例 code,該提案已經在 Chrome 上面實裝,所以較新版的 Chrome 是可以直接運行的。
// 給定一個 array 將其轉成 generator
function* toIterator(arr) {
for (const val of arr) {
yield val
}
}
// 然後跟 Array 的 method chaining 一樣的寫法
// 但屁股需要多加個 toArray()
const result = toIterator([1, 2, 3, 4, 5])
.map((n) => n * n)
.filter((n) => n % 2 === 1)
.toArray()
console.log(result) // [1, 9, 25]
這個作法是利用了帶星號的 function*,也就是 Generator 去產生一個 Iterator 迭代器。這玩意平常寫 JavaScript 很少會用到,它可以簡單理解成一種有別於 for 迴圈的特殊形式 each 方法。
function* generator() {
yield 1
yield 2
yield 3
}
const gen = generator()
gen.next() // { value: 1, done: false }
gen.next() // { value: 2, done: false }
gen.next() // { value: 3, done: false }
gen.next() // { value: undefined, done: true}
在我們執行 next() 之前,generator 就會彷彿時間暫停一樣,不會動作。當執行了 next() 之後,就會 run 到有 yield 的地方停下,並回傳右邊的 value。
詳細可以參考 MDN。
這個 iterator helpers 就是利用 generator 的可操作性,去把 method chaining 的 callback 給串到同一個迴圈裡去做掉。
至於為什麼要叫做 iterator helper 而不是 generator helper?我想應該是因為 Generator Class 是繼承自 Iterator Class 的。
function* generator() {}
const gen = generator()
gen instanceof Iterator // true
附帶一提,在瀏覽器中 Array Class 裡面都有一個 Symbol.iterator 屬性,它的值是一個用來製作 iterator 的 function。JavaScript 的 for 迴圈就是靠這個屬性去運作的。
可以看到,這玩意在瀏覽器中跟 generator 是一樣的。
const iter = [1, 2, 3][Symbol.iterator]()
iter.next() // { value: 1, done: false }
所以前面的 iterator helpers 那段範例也可以寫成這樣。
;[1, 2, 3, 4, 5][Symbol.iterator]()
.map((n) => n * n)
.filter(n => n % 2 === 1)
.toArray()
// [1, 9, 25]
為什麼要強調瀏覽器呢?因為 Node.js 的實現方式不太一樣,它並不存在一個全域的 Iterator Class。
簡單實作 iterator helpers
接下來實作看看 iterator 的 map() 與 filter() 方法,以便了解它是怎麼實現的。
再來看一下這段範例 code。
function* toIterator(arr) {
for (const val of arr) {
yield val
}
}
toIterator([1, 2, 3, 4, 5])
.map((n) => n * n)
.filter((n) => n % 2 === 1)
.toArray()
直接在瀏覽器中觀察,toIterator() 會回傳一個 genteror,為了實現 method chaining,勢必 map() 與 filter() 會回傳 this,也就是 genteror。
實際去看 map() 的回傳值,會發現 Chrome 顯示的是 iterator,而我們知道它是 genteror 的父層,推測成立。
也就是說,map() 與 filter() 這兩個方法是直接擴充到 Iterator Class 上的,這樣只要對 Iterator.prototype 實作新方法就可以了。
為了避免跟 Chrome 已經實作的 map() 撞名,來改個名字叫做 myMap()。
// 對 Iterator 原型添加新方法
window.Iterator.prototype.myMap = function* myMap(cb) {
let index = 0
while (true) {
const { done, value } = this.next()
if (done) break
yield cb(value, index++)
}
}
// 也可以用 Object.defineProperty() 來做
Object.defineProperty(window.Iterator.prototype, 'myMap', {
get() {
return function* (cb) {
let index = 0
while (true) {
const { done, value } = this.next()
if (done) break
yield cb(value, index++)
}
}
},
})
對 prototype 直接添加方法之後,所有新 new 出來的 iterator instance 都能夠享有該方法,且由於是同一個 instance,所以 this 就能拿到前一個方法所回傳的 iterator,也就能夠透過 next() 去 trigger 迭代器往下走,拿到要 each 的目標值。
最後就是比照 map() 該有的邏輯去寫就好,callback 回傳甚麼就回傳甚麼,map() 的概念是相對簡單的。
另外加一個 index 去記數,才能還原 Array.prototype.map 的第二參數 index。
;[1, 2, 3].map((val, index) => { ... })
至於第三參數先跳過,畢竟那不是重點,就沒有多去研究。
再來 filter() 也是一樣,另取一個名字 myFilter()。
window.Iterator.prototype.myFilter = function* myFilter(cb) {
let index = 0
while (true) {
const { done, value } = this.next()
if (done) break
if (cb(value, index++)) yield value
}
}
做到這邊應該多少能 get 到,為什麼 iterator helper 屁股需要接一個 toArray(),因為它 chaining 回傳的都是一個個 iterator,所以需要有一個把結果轉回 Array 的方法。
toArray() 這邊一樣另取一個名字,就叫 run() 吧。
window.Iterator.prototype.run = function run() {
let array = []
while (true) {
const { done, value } = this.next()
if (done) return array
array.push(value)
}
}
它很單純就是蒐集前面的 iterator 一路傳下來的 item,宣一個 Array 去把他們裝起來,最後 return 回去。
都做完了之後就可以來跑看看了,大功告成!!
;[1, 2, 3, 4, 5][Symbol.iterator]()
.myMap((v) => v * v)
.myFilter((v) => v & 1)
.run()
// [1, 9, 25]
如何在 Node.js 實作
最後稍微提一下,如果想在 Node.js 實作的話會比較麻煩。如同前面所提,Node.js 的 globalThis 是沒有 Iterator 的。所以要用比較 hack 的方式去取得。
參考這篇討論:
可以利用 Object.getPrototypeOf() 這個方法來強行取得 Generator Class。
const Generator = Object.getPrototypeOf(function* () {})
接下來就跟瀏覽器一樣,對它的 prototype 添加新方法就可以了。
Generator.prototype.map = function* map(cb) {
// ...
}
目前也有人已經把 iterator helper 的 polyfill 給做出來了,有興趣的話可以在還沒實裝的環境下搶先使用喔。