- 原文地址:JavaScript Monads Made Simple
- 原文做者:
Eric Elliott- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:yoyoyohamapi
- 校對者:IridescentMia WJoan
(譯註:該圖是用 PS 將煙霧處理成方塊狀後獲得的效果,參見 flickr。)javascript
這是 「軟件編寫」 系列文章的第十一部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數式編程和組合化軟件(compositional software)技術(譯註:關於軟件可組合性的概念,參見維基百科
< 上一篇 | << 返回第一篇前端
在開始學習 Monad 以前,你應當瞭解過:java
compose(f, g)(x) = (f ∘ g)(x) = f(g(x))
Array.map()
操做有清晰的理解Gilad Bracha 曾說過,「一旦你明白了 monad,你反而就無法向其餘人解釋什麼是 monad 了」,這就好像 Lady Mondegreen 空耳詛咒同樣,咱們均可以稱其爲 Lady Monadgreen 詛咒了。(Gilad Bracha 這段話最著名的引用者你不會陌生,他是 Douglas Crockford)。react
譯註:Mondegreen 指空耳,Lady Modegreen 是該詞的來源,當年一個小女孩把 「and laid him on the green」 錯聽成了 「and Lady Mondegreen」。android
Kurt Vonnegut's 在其小說 Cat's Cradle 中寫到:「Hoenikker 博士常說,任何沒法對一個 8 歲大的孩子解釋清楚他是作什麼的科學家都是騙子」。ios
若是你在網上搜索 「Monad」,你會被各類範疇學理論搞得頭皮發麻,不少人也貌似 「頗有幫助地」 用各類術語去解釋它。git
可是,別被那些專業術語給唬住了,Monad 其實很簡單。咱們看一下 Monad 的本質。github
一個 Monad 是一種組合函數的方式,它除了返回值之外,還須要一個 context。常見的 Monad 有計算任務,分支任務,或者 I/O 操做。Monad 的 type lift(類型提高),flatten(展平)以及 map(映射)操做使得數據類型統一,從而實現了,即使組合鏈中存在 a => M(b)
這樣的類型提高,函數仍然可組合。a => M(b)
是一個伴隨着某個計算 context 的映射過程,Monad 經過 type lift,flatten 及 map 完成,可是用戶不須要關心實現細節:編程
a => b
Functor(a) => Functor(b)
Monad(Monad(a)) => Monad(b)
可是,「flatten」、「map」 和 「context」 究竟意味着什麼?後端
a => b
,而新返回的 b
又被包裹了相同的 context。若是 a
的 context 是 Observable,那麼 b
的 context 就也是 Observable,即 Observable(a) => Observable(b)
。同理有,Array(a) => Array(b)
。a => F(a)
。(Monad 也是一種 Functor,因此這裏咱們用了 F
表示 Monad)F(a) => a
。上面的說明仍是有些抽象,如今看個例子:
const x = 20; // `a` 數據類型的 `x`
const f = n => n * 2; // 將 `a` 映射爲 `b` 的函數
const arr = Array.of(x); // 提高 `x` 的類型爲 Array
// JavaScript 中對於數組類型的提高可使用語法糖:`[x]`
// `Array.prototype.map()` 在 `x` 上應用了 map 函數 `f`,
// map 發生的 context 正是數組
const result = arr.map(f); // [40]複製代碼
在這個例子中,Array
就是 context,x
是進行 map 的值。
這個例子沒有涉及嵌套數組,可是在 JavaScript 中,你能夠經過 .concat()
展開數組:
[].concat.apply([], [[1], [2, 3], [4]]); // [1, 2, 3, 4]複製代碼
不管你對範疇學知道多少,使用 Monad 都會優化你的代碼。不知道利用 Monad 的好處的代碼就可能讓人頭疼,如回調地獄,嵌套的條件分支,冗餘代碼等。
本系列已經不厭其煩的說過,軟件開發的本質便是組合,而 Monad 使得組合更加容易。再回顧下 Monad 的實質:
a => b
Functor(a) => Functor(b)
Monad(Monad(a)) => Monad(b)
這些都是描述函數組合的不一樣方式。函數存在的真正目的就是讓你去組合他們,編寫應用。函數幫助你將複雜問題劃分爲若干簡單問題,從而可以分而治之的處理這些小問題,在應用中,不一樣的函數組合,就帶來了解決不一樣問題的方式,從而讓你不管面對什麼大的問題,都能經過組合進行解決。
理解函數及如何正確使用函數的關鍵在於更深入地認識函數組合。
函數組合是爲數據流建立一個包含有若干函數的管道。在管道入口,你導入數據,在管道出口,你得到了加工好的數據。但爲了讓管道工做,管道上的每一個函數接受的輸入應當與上一步函數的輸出擁有一樣的數據類型。
組合簡單函數很是容易,由於函數的輸入輸出都有整齊劃一的類型。只須要匹配輸出類型 b
爲 輸入類型 b
便可:
g: a => b
f: b => c
h = f(g(a)): a => c複製代碼
若是你的映射是 F(a) => F(b)
,使用 Functor 的組合也很容易完成,由於這個組合中的數據類型也是整齊劃一的:
g: F(a) => F(b)
f: F(b) => F(c)
h = f(g(Fa)): F(a) => F(c)複製代碼
可是若是你想要從 a => F(b)
,b => F(c)
這樣的形式進行函數組合,你就須要 Monad。咱們把 F()
換爲 M()
從而讓你知道 Monad 該出場了:
g: a => M(b)
f: b => M(c)
h = composeM(f, g): a => M(c)複製代碼
等等,在這個例子中,管道中流通在函數之間的數據類型沒有整齊劃一。函數 f
接收的輸入是類型 b
,可是上一步中,f
從 g
處拿到的類型倒是 M(b)
(裝有 b
的 Monad)。因爲這一不對稱性,composeM()
須要展開 g
輸出的 M(b)
,把得到的 b
傳給 f
,由於 f
想要的類型是 b
而不是 M(b)
。這一過程(一般稱爲 .bind()
或者 .chain()
) 就是 flatten 和 map 發生的地方。
下面的例子中展示了 flatten 的過程:從 M(b)
中取出 b
並傳遞給下一個函數:
g: a => M(b) flattens to => b
f: b maps to => M(c)
h composeM(f, g):
a flatten(M(b)) => b => map(b => M(c)) => M(c)複製代碼
Monad 使得類型整齊劃一,從而使 a => M(b)
這樣,發生了類型提高的函數也可被組合。
在上面的圖示中,M(b) => b
的 flatten 操做及 b => M(c)
的 map 操做都在 chain
方法內部完成了。chain
的調用發生在了 composeM()
內部。在應用層面,你不須要關注內在的實現,你只須要用和組合通常函數相同的手段組合返回 Monad 的函數便可。
因爲大多數函數都不是簡單的 a => b
映射,所以 Monad 是須要的。一些函數須要處理反作用(如 Promise,Stream),一些函數須要操縱分支(Maybe),一些函數須要處理異常(Either),等等。
這兒有一個更加具體的例子。假如你須要從某個異步的 API 中取得某用戶,以後又將該用戶傳給另外一個異步 API 以執行某個計算:
getUserById(id: String) => Promise(User)
hasPermision(User) => Promise(Boolean)複製代碼
讓咱們撰寫一些函數來驗證 Monad 的必要性。首先,建立兩個工具函數,compose()
和 trace()
:
const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);
const trace = label => value => {
console.log(`${ label }: ${ value }`);
return value;
};複製代碼
以後,嘗試進行函數組合解決問題(根據 Id 得到用戶,進而判斷用戶是否具備某個權限):
{
const label = 'API call composition';
// a => Promise(b)
const getUserById = id => id === 3 ?
Promise.resolve({ name: 'Kurt', role: 'Author' }) :
undefined
;
// b => Promise(c)
const hasPermission = ({ role }) => (
Promise.resolve(role === 'Author')
);
// 嘗試組合上面兩個任務,注意:這個例子會失敗
const authUser = compose(hasPermission, getUserById);
// 老是輸出 false
authUser(3).then(trace(label));
}複製代碼
當咱們嘗試組合 hasPermission()
和 getUserById()
爲 authUser()
時,咱們遇到了一個大問題,因爲 hasPermission()
接收一個 User
對象做爲輸入,但卻獲得的是 Promise(User)
。爲了解決這個問題,咱們須要建立一個特別地組合函數 composePromises()
來替換掉原來的 compose()
,這個組合函數知道使用 .then()
去完成函數組合:
{
const composeM = chainMethod => (...ms) => (
ms.reduce((f, g) => x => g(x)[chainMethod](f))
);
const composePromises = composeM('then');
const label = 'API call composition';
// a => Promise(b)
const getUserById = id => id === 3 ?
Promise.resolve({ name: 'Kurt', role: 'Author' }) :
undefined
;
// b => Promise(c)
const hasPermission = ({ role }) => (
Promise.resolve(role === 'Author')
);
// 組合函數,此次大功告成了!
const authUser = composePromises(hasPermission, getUserById);
authUser(3).then(trace(label)); // true
}複製代碼
稍後咱們會討論 composeM()
的細節。
再次牢記 Monad 的實質:
a => b
Functor(a) => Functor(b)
Monad(Monad(a)) => Monad(b)
在這個例子中,咱們的 Monad 是 Promise,因此當咱們組合這些返回 Promise 的函數時,對於 hasPermission()
函數,它獲得的是 Promise(User)
而不是 Promise 中裝有的 User
。注意到,若是你去除了 Monad(Monad(a))
中外層 Monad()
的包裹,就剩下了 Monad(a) => Monad(b)
,這就是 Functor 中的 .map()
。若是咱們再有某種手段可以展開 Monad(x) => x
的話,就走上正軌了。
每一個 Monad 都是基於一種簡單的對稱性 -- 一個將值包裹到 context 的方式,以及一個取消 context 包裹,將值取出的方式:
a => M(a)
M(a) => a
因爲 Monad 也是 Functor,所以它們可以進行 map 操做:
M(a) -> M(b)
組合 flatten 以及 map,你就能獲得 chain -- 這是一個用於 monad-lifting 函數的函數組合,也稱之爲 Kleisli 組合,名稱來自 Heinrich Kleisli:
M(M(a)) => M(b)
對於 Monad 來講,.map()
方法一般從公共 API 中省略了。type lift 和 flatten 不會顯示地要求 .map()
調用,但你已經有了 .map()
所須要的所有。若是你可以 lift(也稱爲 of/unit) 以及 chain(也稱爲 bind/flatMap),你就能完成 .map()
,即完成 Monad 中值的映射:
const MyMonad = value => ({
// <... 這裏能夠插入任意的 chain 和 of ...>
map (f) {
return this.chain(a => this.constructor.of(f(a)));
}
});複製代碼
因此,若是你爲 Monad 定義了 .of()
和 .chain()
或者 .join()
,你就能夠推導出 .map()
的定義。
lift 能夠由工廠函數、構造方法或者 constructor.of()
完成。在範疇學中,lift 叫作 「unit」。list 完成的是將某個類型提高到 Monad context。它將某個 a
轉換到了一個包裹着 a
的 Monad。
在 Haskell 中,很使人困惑的是,lift 被叫作 return
,通常咱們認爲的 return 指的都是函數返回。我仍有意將它稱之爲 「lift」 或者 「type lift」,並在代碼中使用 .of()
完成 lift,這樣更符合咱們的理解。
flatten 過程一般被叫作 flatten()
或者 join()
。多數時候,咱們用不上 flatten()
或者 join()
,由於它們內聯到了 .chain()
或者 .flatMap()
中。flatten 一般會配合上 map 操做在組合中使用,由於去除 context 包裹以及 map 都是組合中 a => M(a)
須要的。
去除某類 Monad 多是很是簡單的。例如 Identity Monad,Identity Monad 的 flatten 過程相似它的 .map()
方法,只不過你不用將返回的值提高回 Monad context。Identity Monad 去除一層包裹的例子以下:
{ // Identity monad
const Id = value => ({
// Functor Maping
// 經過將被 map 的值傳入到 type lift 方法 .of() 中
// 使得 .map() 維持住了 Monand context 包裹:
map: f => Id.of(f(value)),
// Monad chaining
// 經過省略 .of() 進行的類型提高
// 去除了 context 包裹,並完成 map
chain: f => f(value),
// 一個簡便方法來審查 context 包裹的值:
toString: () => `Id(${ value })`
});
// 對於 Identity Monad 來講,type lift 函數只是這個 Monad 工廠的引用
Id.of = Id;複製代碼
可是去除 context 包裹也會與諸如反作用,錯誤分支,異步 IO 這些怪傢伙打交道。在軟件開發過程當中,組合是真正有意思的事兒發生的地方。
例如,對於 Promise 對象來講,.chain()
被稱爲 .then()
。調用 promise.then(f)
不會當即 f()
。取而代之的是,then(f)
會等到 Promise 對象被 resolve 後,才調用 f()
進行 map,這也是 then 命名的來由:
{
const x = 20; // 值
const p = Promise.resolve(x); // context
const f = n =>
Promise.resolve(n * 2); // 函數
const result = p.then(f); // 應用程序
result.then(
r => console.log(r) // 結果:40
);
}複製代碼
對於 Promise 對象,.then()
就用來替代 .chain()
,但其實兩者完成的是同一件事兒。
可能你聽到說 Promise 不是嚴格意義上的 Monad,這是由於只有 Promise 包裹的值是 Promise 對象時,.then()
纔會去除外層 Promise 的包裹,不然它會直接作 .map()
,而不須要 flatten。
可是因爲 .then()
對 Promise 類型的值和其餘類型的值處理不一樣,所以,它不會嚴格遵照數學上 Functor 和 Monad 對任何值都必須遵照的定律。實際上,只要你知道 .then()
在處理不一樣數據類型上的差別,你也能夠把它當作是 Monad。只須要留意一些通用組合工具可能沒法工做在 Promise 對象上。
讓咱們深刻到 composeM
函數裏面看看,這個函數咱們用來組合 promise-lifting 的函數:
const composeM = method => (...ms) => (
ms.reduce((f, g) => x => g(x)[method](f))
);複製代碼
藏在古怪 reducer 裏面的是函數組合的代數定義:f(g(x))
。若是咱們想要更好地理解 composeM
,先看看下面的代碼:
{
// 函數組合的算數定義:
// (f ∘ g)(x) = f(g(x))
const compose = (f, g) => x => f(g(x));
const x = 20; // 值
const arr = [x]; // 值的容器
// 待組合的函數
const g = n => n + 1;
const f = n => n * 2;
// 下面代碼證實了 .map() 完成了函數組合
// 對 map 的鏈式調用完成了函數組合
trace('map composes')([
arr.map(g).map(f),
arr.map(compose(f, g))
]);
// => [42], [42]
}複製代碼
這段代碼意味着咱們能夠撰寫一個泛化的組合工具來服務於任何可以應用 .map()
方法的 Fucntor,例如數組等:
const composeMap = (...ms) => (
ms.reduce((f, g) => x => g(x).map(f))
);複製代碼
這個函數是 f(g(x))
另外一個表述形式。給定任意數量的、發生類型提高的函數 a -> Functor(b)
,迭代待組合的函數,它們接受輸入 x
,並經過 .map(f)
完成 map 和 type lift。.reduce()
方法接受一個兩參數函數:一個參數是累加器(本例中是 f
,表示組合後的函數),另外一個參數是當前值(本例中是當前函數 g
)。
每次迭代都返回了一個新的函數 x => g(x).map(f)
,這個新函數也是下一次迭代中的 f
。咱們已經證實 x => g(x).map(f)
等同於將 compose(f, g)(x)
的值提高到 Functor 的 context 中。換言之,即等同於對 Functor 中的值應用 f(g(x))
,在本例中,這指的是對原數組中的值應用組合後的函數進行 map。
性能警告:我不建議對數組這麼作。以這種方式組合函數將要求對整個數組進行多重迭代,假如數組規模很大,這樣作的時間開銷很大。對於數組進行 map,要麼進行簡單函數組合
a -> b
,再在數組上一次性應用組合後的函數,要麼優化.reducer()
的迭代過程,要麼直接使用一個 transducer。譯註:transducer 是一個函數,其名稱複合了 transform 和 reducer。transducer 即爲每次迭代指明瞭 tramsform 的 reducer:
const increment = x => x + 1 const square = x => x * x const transducer = R.map(R.compose(square, increment)) const data = [1, 2, 3] const initialData = [0] const accumulator = R.flip(R.append) R.transduce(transducer, accumulator, initialData, data) // => [0, 4, 9, 16]複製代碼
上述代碼至關於:
const increment = x => x + 1 const square = x => x * x const transform = R.compose(square, increment) const data = [1, 2, 3] const initialData = [0] data.reduce((acc, curr) => acc.concat([transform(curr)]), initialData) // => [0, 4, 9, 16]複製代碼
參考資料: ramda
.transduce()
。
對於同步任務,數組的映射函數都是當即執行的,所以須要關注性能。然而,多數的異步任務都是延遲執行的,而且這部分任務一般須要應對異常或者空值這樣的使人頭痛分支情況。
這樣的場景對 Monad 再合適不過了。在組合鏈中,當前 Monad 須要的值須要上一步異步任務或者分支完成時才能得到。在這些情景下,你沒法在組合外部拿到值,由於它們被一個 context 包裹住了,組合過程是 a => Monad(b)
而不是 a => b
。
不管什麼時候你的一個函數接收了一些數據,觸發了一個 API,返回了對應的值,另外一個函數接收了這些值,觸發了另外一個 API,而且返回了這些數據的計算結果,你會想要使用 a => Monad(b)
來組合這些函數。因爲 API 調用是異步的,你會須要將返回值包上相似 Promise 或者 Observable 這樣的 context。換句話說,這些函數的簽名會是 a -> Monad(b)
以及 b -> Monad(c)
。
組合 g: a -> b
, f: b -> c
類型的函數是很簡單的,由於輸入輸出是整齊劃一的。h: a -> c
這個變化只須要 a => f(g(a))
。
組合 g: a -> Monad(b)
, f: b -> Monad(c)
就稍微有些困難。h: a -> Monad(c)
這個變化不能經過 a => f(g(a))
完成,由於 f()
須要的是 b
,而不是 Monad(b)
。
讓咱們看一個更具體的例子,咱們組合了一系列異步任務,它們都返回 Promise 對象:
{
const label = 'Promise composition';
const g = n => Promise.resolve(n + 1);
const f = n => Promise.resolve(n * 2);
const h = composePromises(f, g);
h(20)
.then(trace(label))
;
// Promise composition: 42
}複製代碼
怎麼才能寫一個 composePromises()
對異步任務進行組合,並得到預期輸出呢?提示:你以前可能見到過。
對的,就是咱們提到過的 composeMap()
函數?如今,你只須要將其內部使用的 .map()
換成 .then()
便可,Promise.then()
至關於異步的 .map()
。
{
const composePromises = (...ms) => (
ms.reduce((f, g) => x => g(x).then(f))
);
const label = 'Promise composition';
const g = n => Promise.resolve(n + 1);
const f = n => Promise.resolve(n * 2);
const h = composePromises(f, g);
h(20)
.then(trace(label))
;
// Promise composition: 42
}複製代碼
稍微有些古怪的地方在於,當你觸發了第二個函數 f
,傳給 f
的不是它想要的 b
,而是 Promise(b)
,所以 f
須要去除 Promise 包裹,拿到 b
。接下來該怎麼作呢?
幸運的是,在 .then()
內部,已經擁有了一個將 Promise(b)
展平爲 b
的過程了,這個過程一般稱之爲 join
或者 flatten
。
也許你已經留意到了 composeMap()
和 composePromise()
的實現幾乎同樣。所以咱們建立一個高階函數來爲不一樣的 Monad 建立組合函數。咱們只須要將鏈式調用須要的函數混入一個柯里化函數便可,以後,使用方括號包裹這個鏈式調用須要的方法名:
const composeM = method => (...ms) => (
ms.reduce((f, g) => x => g(x)[method](f))
);複製代碼
如今,咱們能針對性地爲不一樣的 Monad 建立組合函數:
const composePromises = composeM('then');
const composeMap = composeM('map');
const composeFlatMap = composeM('flatMap');複製代碼
在你開始建立你的 Monad 以前,你須要知道全部的 Monad 都要知足的一些定律:
unit(x).chain(f) ==== f(x)
(譯註:將 x
提高到 Monad context 後,使用 f()
進行 map,等同於直接對 x
直接使用 f
進行 map)m.chain(unit) ==== m
(譯註:Monad 對象進行 map 操做的結果等於原對象 )m.chain(f).chain(g) ==== m.chain(x => f(x).chain(g))
一個 Monad 也是一個 Functor。一個 Functor 是兩個範疇之間一個態射(morphism):A -> B
,其中箭頭符號即描述了態射。除了對象間顯式的態射,每個範疇中的對象也擁有一個指向本身的箭頭。換言之,對於範疇中的每個對象 X
,存在着一個箭頭 X -> X
。該箭頭稱之爲同一(identity)箭頭,一般使用一個從自身出發並指回自身的弧形箭頭表示。
結合律意味着咱們不須要關心咱們組合時在哪裏放置括號。若是咱們是在作加法,加法有結合律: a + (b + c)
等同於 (a + b) + c
。這對於函數組合也一樣適用: (f ∘ g) ∘ h = f ∘ (g ∘ h)
。
而且,這對於 Kleisli 組合仍然適用。對於這種組合,你應該從前日後地看,把組合運算 chain
看成是 after
便可:
h(x).chain(x => g(x).chain(f)) ==== (h(x).chain(g)).chain(f)複製代碼
接下來咱們證實同一 Monad 知足 Monad 定律:
{ // Identity monad
const Id = value => ({
// Functor Maping
// 經過將被 map 的值傳入到 type lift 方法 .of() 中
// 使得 .map() 維持住了 Monand context 包裹:
map: f => Id.of(f(value)),
// Monad chaining
// 經過省略 .of() 進行的類型提高
// 去除了 context 包裹,並完成 map
chain: f => f(value),
// 一個簡便方法來審查 context 包裹的值:
toString: () => `Id(${ value })`
});
// 對於 Identity Monad 來講,type lift 函數只是這個 Monad 工廠的引用
Id.of = Id;
const g = n => Id(n + 1);
const f = n => Id(n * 2);
// 左同一概
// unit(x).chain(f) ==== f(x)
trace('Id monad left identity')([
Id(x).chain(f),
f(x)
]);
// Id Monad 左同一概: Id(40), Id(40)
// 右同一概
// m.chain(unit) ==== m
trace('Id monad right identity')([
Id(x).chain(Id.of),
Id(x)
]);
// Id Monad right identity: Id(20), Id(20)
// 結合律
// m.chain(f).chain(g) ====
// m.chain(x => f(x).chain(g)
trace('Id monad associativity')([
Id(x).chain(g).chain(f),
Id(x).chain(x => g(x).chain(f))
]);
// Id monad associativity: Id(42), Id(42)
}複製代碼
Monad 是組合類型提高函數的方式:g: a => M(b)
, f: b => M(c)
。爲了作到,Monad 必須在應用函數 f()
以前,展平 M(b)
取出 b
交給 f()
。換言之,Functor 是你能夠進行 map 操做的對象,而 Monad 是你能夠進行 flatMap 操做的對象:
a => b
Functor(a) => Functor(b)
Monad(Monad(a)) => Monad(b)
每一個 Monad 都是基於一種簡單的對稱性 -- 一個將值包裹到 context 的方式,以及一個取消 context 包裹,將值取出的方式:
a => M(a)
M(a) => a
因爲 Monad 也是 Functor,所以它們可以進行 map 操做:
M(a) -> M(b)
組合 flatten 以及 map,你就能獲得 chain -- 這是一個用於 monad-lifting 函數的函數組合,也稱之爲 Kleisli 組合。
M(M(a)) => M(b)
Monads 必須知足三個定律(公理),合在一塊兒稱之爲 Monad 定律:
unit(x).chain(f) ==== f(x)
m.chain(unit) ==== m
m.chain(f).chain(g) ==== m.chain(x => f(x).chain(g)
天天撰寫 JavaScript 代碼的時候,你或多或少已經在使用 Monad 或者 Monad 相似的東西了,例如 Promise 和 Observable。Kleisli 組合容許你組合數據流邏輯時不用操心組合中的數據類型,也不用擔憂可能發生的反作用,條件分支,以及其餘一些組合中去除 context 包裹時的細節,這些細節所有都藏在了 .chain()
操做中。
這一切都讓 Monad 在簡化代碼中扮演了重要角色。在閱讀文本以前,興許你還不明白 Monad 內部到底作了什麼就已經從 Monad 中受益頗豐,如今,你對 Monad 底層細節也有了必定認識,這些細節也並不可怕。
回到開頭,咱們不用再害怕 Lady Monadgreen 的詛咒了。
DevAnyWhere 能幫助你最快進階你的 JavaScript 能力:
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,同這世上最美麗的女子在一塊兒。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。