JavaScript 異步編程小結

JavaScript 是單線程的,除了你的 JS 代码,其它操作都是并行执行的(everything runs in parallel except your code)。

在 JS 執行線程中進行的行為被稱作同步(Synchronous)操作,非 JS 執行線程執行的行為則被稱呼為異步(Asynchronous)操作。
諸如 Ajax/HTTP 請求、I/O 操作等行為均與 JS 執行線程無關(由自己獨立的線程進行運作),這些行為在執行完成之後會將結果通知到 JS 執行線程;
因此,JS 執行線程中會有個類似while(true)的循環,以觀察者的姿態監聽(轮询)是否有其它線程傳遞消息過來,一旦捕獲到則執行本 JS 執行線程中相應的函數塊(回調)。

JavaScript 事件循環不是本文的重點(JavaScript Event Loop),本文僅對前端異步編程進行些許總結。個人的理解是 JavaScript 異步編程方式只有兩種方式:回調和觀察者模式。需要注意的是:

  • Promises/A+ 是如何優雅地使用回調而設計的一種編程規範,本質依舊是回調
  • 事件監聽和觀察者模式(發佈/訂閱模式)完全可以理解成是“一個孩子的不同暱稱”
  • Generators 是一種特性,實現函數在執行過程中暫停、並在將來的某個時刻恢復執行的功能
  • Generators+Promises 可以搭配漂亮的語法糖,將異步源碼寫得像同步源碼

Callback Functions

函數式編程中有個概念叫做高階函數(Higher-order Functions),其有個特性是一個函數可以作為另外一個函數的參數。通常我們將那個作為另外一個函數參數的函數稱呼為回調函數。

為方便描述和解釋,此處模擬一個具體的業務場景:通過 Ajax 方式請求**/api/v1.0/user/{id}接口獲取某個用戶的信息(Asynchronous behavior),然後針對拿到的用戶信息進行後續的處理。
典型的做法是將Ajax異步請求之後進行的操作封裝成callback()函數,在接口訪問成功得到用戶信息之後再執行該函數:

function getUserInfoCallback(id, callback) {
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: data => callback(null, data),
error: (xhr, textStatus, errorThrown)
=> callback(new Error(textStatus), errorThrown),
})
}
getUserInfoCallback('10086', handleUserInfo)

Promises/A+

An open standard for sound, interoperable JavaScript promises—by implementers, for implementers.

濫用回調帶來的問題是代碼邏輯耦合度很高,面臨回調災難。Promises/A+是種合理使用回調的規範,避免回調的濫用。

特點一:提供好看的 API,由嵌套回調(callback hell)轉向鏈式語法

首先將請求用戶信息的 Ajax 異步操作包裝成一個 Promise 實例,後續的同步行為通過該實例對象的then()方法調用。

function getUserInfoPromise(id) {
return new Promise((fulfill, reject) => {
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: fulfill,
error: reject,
}) // end $.ajax
}) // end return
} // end getUserInfoPromise

getUserInfoPromise('10086')
.then((userInfo) => handleUserInfo)
.catch(console.log)

特點二:Promises 不會與回調綁定耦合,可緩存異步操作結果

假設存在這樣的一個業務場景:獲取用戶 id 為10086的用戶信息,然後在不同的兩個階段對其異步操作獲取的用戶信息進行兩種不同的操作(分別為handleUserInfo()console.log())。
在兩個階段中,都需要異步操作獲取得到的userInfo數據,如果採用傳統回調方式,一般採用閉包的方式緩存userInfo或者暴力點重複進行一次 Ajax 異步請求。

但是採用 Promise 方式,則無需這些很複雜的實現方式,因為可以重複使用 Promise 對象。

// 閉包緩存
let globUserInfo = null
getUserInfoCallback('10086', (userInfo) => (globUserInfo = userInfo))
// 難以保證 globUserInfo 已經更新
handleUserInfo(globUserInfo)
console.log(globUserInfo)

// 進行了兩次異步操作
getUserInfoCallback('10086', handleUserInfo)
getUserInfoCallback('10086', console.log)

// 保存Promise對象
const userInfoPromise = getUserInfoPromise('10086')
userInfoPromise.then(handleUserInfo)
// 可以再次使用`userInfoPromise`對象
userInfoPromise.then(console.log)
  • 這種策略和函數式編程中lazy evaluation概念是類似的,強調call-by-need
  • 也可以對異步操作進行柯裡化(Curring)暫存異步操作的結果(類似的概念還有 thunk,參考node-thunkify)。

特點三:可組合,復用

類似於函數式編程中推廣的從已有的函數中創建新函數,也可以通過已有的 Promise 對象生成新的 Promise 對象。
比如獲取多個用戶信息,可使用Promise.all()方法實現異步操作的組合:

const promises = ['10010', '10086', '10000'].map(
(id) =>
new Promise((fulfill, reject) =>
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: fulfill,
error: reject,
})
)
)
const userInfosPromise = Promise.all(promises).then(console.log).catch(console.log)

感覺上 Promise/A+規範是函數式編程概念在前端領域的一次最佳實踐(回調的語法糖)。更多詳細的內容待補充。

Event Emitters

事件監聽式異步編程本質上還是依賴於回調函數實現的,區別在於回調函數並不執行異步行為完成後需要的操作,而是發佈一個通知去觸發執行相應的函數。

import EventEmitter from 'events'
const emitter = new EventEmitter()
// 註冊
emitter.on('event', handleUserInfo)
$.ajax({
url: `**/api/v1.0/user/10086`,
success: (data) => emitter.emit('event', data), // 觸發:異步操作這個行為帶來的影響
error: console.log,
})

事件監聽其實是觀察者模式的一種實現:當一個對象發生變化時,所有依賴他的相關操作都會得到通知,只不過事件監聽弱化了對象的變化而強調行為(對象數據變更也是一種行為)。
比如上面的代碼段強調的是 Ajax 操作這個行為,一旦完成就通知handleUserInfo()函數的調用,並攜帶參數變更對象數據。

如果採用觀察者模式的話,一般這樣直接處理數據(強調數據變化帶來的影響,造成數據變化的場景可能存在多處),然後觸發數據變動後的行為:

let userInfo = null
emitter.on('event', () => handleUserInfo(userInfo))
const updateUserInfo = data => {
userInfo = data // userInfo對象方式變更
emitter.emit('event') // 通知相關依賴的操作:數據變更帶來的影響
}
$.ajax(
url: `**/api/v1.0/user/10086`,
success: updateUserInfo, // 觸發
error: console.log,
})

很明顯,觀察者模式要比事件監聽方式擴充性更強(雖然本質一致,但是強調側重點不同)。

陷入`emit`死循環

事件監聽式異步編程無異於goto語句,稍有不慎形如on()emit()subscribe()publish()等方法摻雜在各處,“剪不清,理還亂”;如果不是“約定”化編程不建議採用。比如下面這段源碼,稍不慎就陷入如圖 1 所示場景。

const emitter = new EventEmitter()
const foo = () => emitter.emit('bar')
const bar = () => emitter.emit('foo')
emitter.on('foo', foo)
emitter.on('bar', bar)
foo() // 陷入死循環

和回調式異步編程(包括 Promises/A+規範)相比,事件監聽式異步編程的軟肋在於需要手動註冊(Manual)。
原本可以通過數據綁定(Data binding)Object.observe()方法來實現觀察者模式,很可惜該方法已被deprecated掉;目前推薦的是getset+Proxy方式實現(相關討論:36258502)。

但是手動維護這些on()emit()get()set()等方法在項目是很折騰的,通過一些第三方工具包可以實現由ManualAutomatic轉變。
比如採用MobX可以實現得更加優雅:

import { observable, autorun } from 'mobx'
const store = observable({userInfo: null})
// 只要變動`store`對象,就會自動觸發`handleUserInfo()`函數
autorun(() => handleUserInfo(store.userInfo))
$.ajax(
url: `**/api/v1.0/user/10086`,
success: data => store.userInfo = data,
error: console.log,
})

Generator

Coroutine 協程 (a.k.a. co-operative routines)

一般程序中,函數調用一定是從頭到尾執行直到遇到return或執行完;
而 coroutine 則容許函數執行到一半時就中斷(yield),中斷時函數內部上下文環境(context)會被緩存下來。
程序主體可以隨時恢復(resume)這個被緩存的 coroutine,繼續從剛才被中斷處執行後續內容。

function* foo() {
console.log('hello')
yield 10086 // 在此處中斷 coroutine
console.log('world')
}

const bar = foo() // 保存 coroutine 內部狀態的變量
bar.next() // 調用`foo()`函數,遇到 yield 中斷程序調用
console.log('main, not in `foo()`') // 已經從`foo()`函數中跳出來了,可以幹些其它事情
bar.next() // 恢復`foo()`的調用,從 yield 中斷處繼續執行

Thread VS Coroutine

With threads, the operating system switches running threads preemptively according to its scheduler,
which is an algorithm in the operating system kernel.
With coroutines, the programmer and programming language determine when to switch coroutines;
in other words, tasks are cooperatively multitasked by pausing and resuming functions at set points,
typically (but not necessarily) within a single thread.

—— stackoverflow: difference-between-a-coroutine-and-a-thread

Generator (a.k.a. semicoroutines) VS Coroutine

Generator 與 Coroutine 的區別是 Generator 只能從上次中斷處繼續執行,而 Coroutine 則沒有這樣的限制(可以指定從哪裡繼續執行)。
因此,Generator 可以視作是 Coroutine 的一種特殊情況,上文涉及的源碼例子其實就是 Generator 的應用舉例。
其中,Generator 涉及bar.next()自動流程管理的解決方案可以參考cothunks等。

async/await “語法糖”

聲明的async函數就是將 Generator 函數和自動執行器包裝在一個函數裡面(參考async2generator()),
以達到異步編碼編程模式與同步編碼一致。

const run = async () => {
const userInfo = await new Promise((fulfill, reject) => {
$.ajax({
url: `**/api/v1.0/user/${id}`,
success: fulfill,
error: reject,
}) // end $.ajax
}) // end return
handleUserInfo(userInfo)
}

不是總結的總結

  • 函數式編程領域的知識還是要多多接觸的。
  • 有些前端領域的新鮮事物在其他領域可能就是些習以為常的東西,擴充知識面很重要。

References

函数式编程之纯函数

数学上的函数指的是两个集合间的一种特殊的映射关系。这个特殊体现在什么地方呢?

我们将集合A的元素称呼为输入值,集合B的元素称呼为输出值,且集合AB存在这样的映射关系:每个输入值只会映射一个输出值,不同的输入值可以映射相同的输出值,不会出现同一个输入值映射不同的输出值

比如,下图集合A和集合B的映射关系即符合数学函数的定义。

containing block
fn:除以5的余数

在函数式编程语言中,满足这种数学意义上的函数即为纯函数(Pure Function):相同的输入(参数),永远得到的是相同的输出(返回值),并且没有任何可观察的"副作用"。
自然,与纯函数的概念相反的函数(即相同参数的函数在不同环境或时机调用得到的返回值不一致)叫做非纯函数(Impure Function)。

关于函数副作用(side-effect)

函数副作用指当调用函数时,在计算返回值数值的过程中,对主调用函数产生附加的影响。

更高作用域的变量"悄悄"发生变更

let glob = 1
function foo(x) {
return ++glob + x
}
console.log(foo(1)) // => 3

变量glob的值随着foo()的调用发生变化,表现得很不明显。

"隐晦"地修改了引用参数

let glob = 1
const obj = { glob }
function foo(x) {
return ++x.glob
}
foo(obj)
console.log(glob) // => 2

虽然对象obj定义为const,但是修改了间接引用的变量glob;这种场景引发的 bug 其实是很难捕获的(尤其是具备指针概念的 C/C++语言)。

函数副作用确实是滋生 Bug 的"温床",造成的问题一般都很"隐晦";有些开发场景中,我们其实也无法避免函数的副作用(典型的例子是读写数据库操作的函数)。最好的做法是,要将这些副作用限制在可控的范围内。

纯函数带来的好处

函数调用结果可缓存

相同参数得到的返回值是相同的。如果通过参数获取返回值的过程计算量过大,我们可以缓存函数调用的结果,避免相同参数为了获取返回值进行重复计算。典型的实践是对递归函数做性能优化的memoize技术。
fibonacci(n)递归函数为例,传统的实现:

function fibonacci(n) {
if (n === 0 || n === 1) return n
return fibonacci(n - 1) + fibonacci(n - 2)
}

console.log(fibonacci(10))

计算的复杂度以参数n呈指数级增长:

f(0) = 0
f(1) = 1
f(2) = f(1) + f(0) = 1
f(3) = f(2) + f(1) = 2
f(4) = f(3) + f(2)
= f(2) + f(1) + f(2) = 3
f(5) = f(4) + f(3)
= f(3) + f(2) + f(2) + f(1)
= f(2) + f(1) + f(2) + f(2) + f(1) = 5
f(6) = f(5) + f(4)
= f(4) + f(3) + f(3) + f(2)
= f(3) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2)
= f(2) + f(1) + f(2) + f(2) + f(1) + f(2) + f(1) + f(2) = 8
... ...

为了获取fibonacci(n)的结果,我们不得不将fibonacci(n-1)fibonacci(n-2)都得计算一遍;如果我们在调用一次fibonacci(n)之后,就将其缓存起来,下次再调用时就无需重新再计算。稍加改造,添加对计算结果的缓存:

const fibonacci = (function () {
const cache = {}

return function fib(n) {
if (n in cache) return cache[n]
return (cache[n] = n === 0 || n === 1 ? n : fib(n - 1) + fib(n - 2))
}
})()
console.log(fibonacci(10))

这是典型的以空间换效率的优化思路,避免了额外计算的浪费。
这样实现的前提就是,该递归函数是纯函数,相同参数得到的返回值一定是相同的;如果不能保证相同,我们无法做缓存。

当然,我们可以实现一个memoize()函数来统一做缓存这样的工作。
JavaScript 函数式编程支持库如均提供memoize()函数,这里提供一种不太健壮(内存溢出)的实现方案。

function memoize(func) {
const memo = {}
const slice = Array.prototype.slice

return function () {
const args = slice.call(arguments)

if (args in memo) return memo[args]
return (memo[args] = func.apply(this, args))
}
}

这样函数调用的次数愈多效率会慢慢变得愈高。

便于移植和测试

纯函数是"自给自足"的,所有的函数依赖均由函数自身提供(或参数);因此,我们将一个函数移植到另外一个系统时,是无需考虑成本的
——当然,如果一个函数依赖一个全局变量,在移植该函数时必须"慎重",要将这个全局变量的逻辑一起迁移过去。

相同参数得到的函数返回值是固定的,这一特性也使纯函数更易测试——你无需模拟出一些特殊的测试环境,只要明确定义好函数参数的范围即可。

引用透明(Referential Transparent)

An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions.
—— https://en.wikipedia.org/wiki/Referential_transparency

该如何理解呢?可以拿上文提到的fibonacci()函数举例,比如存在这样一个函数:

function foo(n, fun) {
return fun(n) + fun(n)
}

foo(10, fibonacci)

调用foo(10, fibonacci)会发现fibonacci(10)被执行了两遍。因为纯函数具备引用透明性,某些表达式被替换并不会改变函数的行为;因此,对foo()进行些许变动会使其性能得到质的提升。

function foo(n, fun) {
return fun(n) * 2
}

毕竟在此场景中,一次乘法运算成本远比一次fibonacci(10)递归运算的成本来得低。

这里的由fibonacci(n) + fibonacci(n) => 2*fibonacci(n)转变完全跟数学概念中的f(x) = x + x = 2 *x函数推导一致。

因为纯函数的引用透明的特性,我们完全可以将多个函数构成的复杂程序(函数)推导成更加简单的方式。

并行代码

纯函数无副作用,同时调用两个函数或同个函数被同时调用两次都不会抢占外部公共资源的情况。

总结

  • 程序设计中的大部分 Bug 都是有函数副作用引入的,实际开发中必须鼓励纯函数的编写。
  • 在函数式编程范畴中,欲想以函数为基础生成新的函数,那纯函数是这些新函数的基石。
  • 多尝试使用memoize技术对递归函数进行性能优化。

Web 頁面上的那些圖標

一個網頁不會是由純字符組成的,需要些些訏訏的圖標去點綴;最早的前端的工作主要是多數人不屑的切圖,這與編程耦合太弱。
不過話說要是絕大多數的網頁沒有那些圖標的點綴會變得多麼地慘白。

在一個 HTML 結構的頁面中,使用圖標最常接觸的是標籤<img>和 css 屬性background-image<img>純粹是為了顯示圖片而添加的標籤,適用於尺寸大的圖片,強調圖片的信息,不屬於頁面圖標的範疇(在 web 設計中,圖標和圖片是兩種概念:圖標在某種程度可有可惡,起到修飾點綴的效果,本身沒有什麼信息量;而圖片不同,圖片也是頁面欲展示給用戶的信息);因此,依賴<img>標籤實現的點綴圖標的作用的,都是不那麼合理的,因為<img>不是幹這種事情的,對搜索引擎亦是不友好的。

下面討論下,如何給一個 web 頁面添加修飾點綴用途的圖標的方式。

方式一:css 屬性background-image

background-image主要用來設定塊級標籤的背景圖片,一般的使用形式如下:

.selector {
background-image: url('/* 要顯示的圖片網址 */');
background-repeat: no-repeat;
background-color: /* 背景顏色 */ ;
}

這種方式不會將圖片的信息放在 HTML 結構中,而是通過 css 來維護管理的;實現方式最大的缺陷是如果一個頁面中存在好多些類似的圖標,那麼用戶客戶端的每次訪問就必須為了那些點綴增加許許多多的 HTTP 請求。

當然,最好的方式是將多個小圖標軿湊成一張大圖片來避免不必要的 HTTP 請求。

方式二:依賴background-position實現的 Sprite 圖

將多張小圖標合併成一張大圖片,頁面元素使用時只選擇其中的一部分顯示,這樣一堆小圖標合成的大圖片一般稱作 Sprite 圖(精靈圖,雪碧圖等)。
除了使用 css 屬性background-image之外,還要利用background-position來定位大圖中小圖標的坐標位置;通常情況下,還要指定小圖標的長寬信息,即widthheight屬性。一般的使用形式如下:

.selector {
background-image: url(要顯示的圖片網址);
background-repeat: no-repeat;
background-position: 0 -63px;
height: 10px;
width: 20px;
}

Sprite 圖避免了多次 HTTP 請求問題,但是難點在於 Sprite 圖的手動生成是一件極其繁瑣的事情,每次更新圖標都需要重新繪製 Sprite 圖;
小圖標在 Sprite 圖中的坐標位置在寫入 cssbackground-position屬性中時也要注意。

Sprite 手動生成的確繁瑣,但是 Sprite 圖的自動化生成方面的技術也趨於成熟,典型的有Spriting with Compassglue

如果你的 css 框架是基於 Compass(sass)的話,Sprite 圖的合併並不是什麼要耗費經歷的事情;倘若不是,善用glue也會讓你從在折騰圖像處理軟件的非編程工作中解脫出來。

Compass 在使用 Sprite 圖時直接通過@include icon-sprite('/* 小圖標路徑 */')即可,最後編譯成 css 文件時也會自動編譯生成對應的 Sprite 圖,你不必考慮坐標關係;即使要換個圖標,也只是更換圖標後重新編譯即可。

使用glue則更加強大了,不僅可以生成 CSS 也可以生成 SCSS,甚至更底層地你可以生成一系列的 hash 映射數據自己動手來處理 Sprite 圖的使用邏輯;Sprite 圖中的圖標的坐標位置全部在一個 hash 表中,完全可以自由定製。

最後,Sprite 圖的軿湊還有個比較費神的問題就是:那麼多的小圖標,有些頁面在用而有些頁面不用那怎麼進行軿湊 Sprite 圖呢?

全部圖標都軿湊成一張大圖片?沒有必要吧,因為有些圖標在這個頁面中沒有使用到憑什麼要拼在一起呢?一般情況下 Sprite 圖的軿湊邏輯如下:

  • 頁面區分:軿湊的 Sprite 圖涉及的小圖只在某種類型的頁面(模塊使用)。
  • 類型區分:同種類型的圖標軿湊在一塊組成 Sprite 圖。

方式三:圖片數字化 BASE64

Sprite 圖是使用圖標點綴頁面最好的解決方案之一,接近完美,但還是有一個問題需要解決:
對圖標的重複性不友好,即不太兼容background-repeat屬性(通常情況下都設定為no-repeat);
典型的如評分五角星,如果有五顆五角星來表示 100%,但要表示 80%時,就必須依賴repeatwidth:80%

還有就是電商網站熱衷使用的newhot等促銷提示小圖標。這些圖標是微型的,而且需出現的時機無規律;拼在 Sprite 圖中總是讓人覺得彆扭

此外,Sprite 圖的使用 CSS 要依賴外部的圖片,要是圖片信息直接在 CSS 文件中就好了。而 BASE64 格式的圖片可以以字符串的形式嵌入到 CSS 文件中。
因此,復用一個 CSS 文件直接拷貝 CSS 文件即可,無需再考慮外部依賴的圖標數據。

BASE64 的解碼和編碼算法也是很容易的,如 https://docs.python.org/2/library/base64.html 。通過 Compass 實現 BASE 編碼直接使用@include inline-image(/* 圖標路徑 */),和前面提到的生成 Sprite 圖一樣簡單。

總之,前面提到的重複的評分五角星和電商網站熱衷使用的newhot小圖標均可以採用 BASE64 的格式。可惜的,在低端瀏覽器(IE6)是不支持這種寫法的。

方式四:圖標也是字體 webfont

前面提到的圖標都是位圖,在手機屏幕動不動就是 1080 像素的瀏覽器來說位圖在高分辨率情況下容易出現鋸齒。如果使用svg矢量圖的話,就無法進行 Sprite 化處理。

webfont就是一種將圖標當作字體來使用(在某種程度上也可以理解成矢量圖標的 Sprite 化);將一系列的矢量圖標轉換成矢量字體集文件(如woff格式)和正常字體一樣使用。

不過目前讓人頭疼的地方是不是所有瀏覽器都支持webfont,即使支持了還只能使用純色扁平的圖標,而且瀏覽器對字體的過渡優化偶爾也會造成圖標的顯示效果失真。

如果一個網站的設計風格是純色調,扁平化,那麼大氛圍的使用webfont是個很好的選擇。

方式五:css3 自己畫圖標

CSS3 上有許多讓人欣喜的特性,比如transormtranition這連個變換和過渡的屬性值,在設計頁面元素背景圖時特別有效;再撮合些 CSS 動畫效果會得到通過圖片無法得到的交互效果。

不過這樣的功能目前也只僅僅侷限與頁面元素的背景圖而已。

另外一種情況是使用border屬性值的處理以很hack的方式繪製一些集合圖形。
如三角形的繪製,一般情況下兼容性最強大的 CSS 源碼如下:

.triangle {
position: absolute;
top: 11px;
right: 7px; /* 絕對定位 */
width: 0;
height: 0;
font-size: 0;
border: 4px dashed transparent;
border-top: 4px solid #2bb8aa;
*display: none;
}

當然,總是有人喜歡使用 CSS 來繪製那些原本使用圖片展示的圖標;個人覺得這是耗費精力沒有必要的工作。為什麼要把那麼簡單的工作複雜化呢?CSS 畢竟是用來點綴元素的,而非用來繪圖的。

最後,大部分網站圖標的使用都是上面提到的五種方式相結合進行使用的。

電商網站上面的奇怪三角形

  • 实心三角形 "▲"
  • 脱字号[即"^"]

這兩種圖標一般跟導航相關(如頂部導航);用戶點擊後圖標的方向會反轉(會摻雜一些反轉動畫的效果)。