函數是 Go 語言的一等公民,本文采用一種高階函數的方式,抽象了使用 gorm 查詢 DB 的查詢條件,將多個表的各種復雜的組合查詢抽象成了一個統一的方法和一個配置類,提升了代碼的簡潔和優雅,同時可以提升開發人員的效率。
有一張 DB 表,業務上需要按照這個表里的不同字段做篩選查詢,這是一個非常普遍的需求,我相信這種需求對于每個做業務開發的人都是繞不開的。比如我們有一張存儲用戶信息的表,簡化之后的表結構如下:
CREATE TABLE `user_info` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
`user_id` bigint NOT NULL COMMENT '用戶id',
`user_name` varchar NOT NULL COMMENT '用戶姓名',
`role` int NOT NULL DEFAULT '0' COMMENT '角色',
`status` int NOT NULL DEFAULT '0' COMMENT '狀態',
PRIMARY KEY (`id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用戶信息表';
這個表里有幾個關鍵字段,user_id、user_name 、 role、status。如果我們想按照 user_id 來做篩選,那我們一般是在 dao 層寫一個這樣的方法(為了示例代碼的簡潔,這里所有示例代碼都省去了錯誤處理部分):
func GetUserInfoByUid(ctx context.Context, userID int64) ([]*resource.UserInfo) {
db := GetDB(ctx)
db = db.Table(resource.UserInfo{}.TableName())
var infos []*resource.UserInfo
db = db.Where("user_id = ?", userID)
db.Find(&infos)
return infos
}
如果業務上又需要按照 user_name 來查詢,那我們就需要再寫一個類似的方法按照 user_name 來查詢:
func GetUserInfoByName(ctx context.Context, name string) ([]*resource.UserInfo) {
db := GetDB(ctx)
db = db.Table(resource.UserInfo{}.TableName())
var infos []*resource.UserInfo
db = db.Where("user_name = ?", name)
db.Find(&infos)
return infos
}
可以看到,兩個方法的代碼極度相似,如果再需要按照 role 或者 status 查詢,那不得不再來幾個方法,導致相似的方法非常多。當然很容易想到,我們可以用一個方法,多幾個入參的形式來解決這個問題,于是,我們把上面兩個方法合并成下面這種方法,能夠支持按照多個字段篩選查詢:
func GetUserInfo(ctx context.Context, userID int64, name string, role int, status int) ([]*resource.UserInfo) {
db := GetDB(ctx)
db = db.Table(resource.UserInfo{}.TableName())
var infos []*resource.UserInfo
if userID > 0 {
db = db.Where("user_id = ?", userID)
}
if name != "" {
db = db.Where("user_name = ?", name)
}
if role > 0 {
db = db.Where("role = ?", role)
}
if status > 0 {
db = db.Where("status = ?", status)
}
db.Find(&infos)
return infos
}
相應地,調用該方法的代碼也需要做出改變:
//只根據UserID查詢
infos := GetUserInfo(ctx, userID, "", 0, 0)
//只根據UserName查詢
infos := GetUserInfo(ctx, 0, name, 0, 0)
//只根據Role查詢
infos := GetUserInfo(ctx, 0, "", role, 0)
//只根據Status查詢
infos := GetUserInfo(ctx, 0, "", 0, status)
這種代碼無論是寫代碼的人還是讀代碼的人,都會感覺非常難受。我們這里只列舉了四個參數,可以想想這個表里如果有十幾個到二十個字段都需要做篩選查詢,這種代碼看上去是一種什么樣的感覺。首先,GetUserInfo 方法本身入參非常多,里面充斥著各種 != 0 和 != ""的判斷,并且需要注意的是,0 一定不能作為字段的有效值,否則 != 0 這種判斷就會有問題。其次,作為調用方,明明只是根據一個字段篩選查詢,卻不得不為其他參數填充一個 0 或者""來占位,而且調用者要特別謹慎,因為一不小心,就可能會把 role 填到了 status 的位置上去,因為他們的類型都一樣,編譯器不會檢查出任何錯誤,很容易搞出業務 bug。
如果說解決這種問題有段位,那么以上的寫法只能算是青銅,接下來我們看看白銀、黃金和王者。
解決這種問題,一種比較常見的方案是,新建一個結構體,把各種查詢的字段都放在這個結構體中,然后把這個結構體作為入參傳入到 dao 層的查詢方法中。而在調用 dao 方法的地方,根據各自的需要,構建包含不同字段的結構體。在這個例子中,我們可以構建一個 UserInfo 的結構體如下:
type UserInfo struct {
UserID int64
Name string
Role int32
Status int32
}
把 UserInfo 作為入參傳給 GetUserInfo 方法,于是 GetUserInfo 方法變成了這樣:
func GetUserInfo(ctx context.Context, info *UserInfo) ([]*resource.UserInfo) {
db := GetDB(ctx)
db = db.Table(resource.UserInfo{}.TableName())
var infos []*resource.UserInfo
if info.UserID > 0 {
db = db.Where("user_id = ?", info.UserID)
}
if info.Name != "" {
db = db.Where("user_name = ?", info.Name)
}
if info.Role > 0 {
db = db.Where("role = ?", info.Role)
}
if info.Status > 0 {
db = db.Where("status = ?", info.Status)
}
db.Find(&infos)
return infos
}
相應地,調用該方法的代碼也需要變動:
//只根據userD查詢
info := &UserInfo{
UserID: userID,
}
infos := GetUserInfo(ctx, info)
//只根據name查詢
info := &UserInfo{
Name: name,
}
infos := GetUserInfo(ctx, info)
這個代碼寫到這里,相比最開始的方法其實已經好了不少,至少 dao 層的方法從很多個入參變成了一個,調用方的代碼也可以根據自己的需要構建參數,不需要很多空占位符。但是存在的問題也比較明顯:仍然有很多判空不說,還引入了一個多余的結構體。如果我們就到此結束的話,多少有點遺憾。
另外,如果我們再擴展一下業務場景,我們使用的不是等值查詢,而是多值查詢或者區間查詢,比如查詢 status in (a, b),那上面的代碼又怎么擴展呢?是不是又要引入一個方法,方法繁瑣暫且不說,方法名叫啥都會讓我們糾結很久;或許可以嘗試把每個參數都從單值擴展成數組,然后賦值的地方從 = 改為 in()的方式,所有參數查詢都使用 in 顯然對性能不是那么友好。
接下來我們看看黃金的解法。在上面的方法中,我們引入了一個多余的結構體,并且無法避免在 dao 層的方法中做了很多判空賦值。那么我們能不能不引入 UserInfo 這個多余的結構體,并且也避免這些丑陋的判空?答案是可以的,函數式編程可以很好地解決這個問題,首先我們需要定義一個函數類型:
type Option func(*gorm.DB)
定義 Option 是一個函數,這個函數的入參類型是*gorm.DB,返回值為空。
然后針對 DB 表中每個需要篩選查詢的字段定義一個函數,為這個字段賦值,像下面這樣:
func UserID(userID int64) Option {
return func(db *gorm.DB) {
db.Where("`user_id` = ?", userID)
}
}
func UserName(name string) Option {
return func(db *gorm.DB) {
db.Where("`user_name` = ?", name)
}
}
func Role(role int32) Option {
return func(db *gorm.DB) {
db.Where("`role` = ?", role)
}
}
func Status(status int32) Option {
return func(db *gorm.DB) {
db.Where("`status` = ?", status)
}
}
上面這組代碼中,入參是一個字段的篩選值,返回的是一個 Option 函數,而這個函數的功能是把入參賦值給當前的【db *gorm.DB】對象。這也就是我們在文章一開始就提到的高階函數,跟我們普通的函數不太一樣,普通的函數返回的是一個簡單類型的值或者一個封裝類型的結構體,而這種高階函數返回的是一個具備某種功能的函數。這里多說一句,雖然 go 語言很好地支持了函數式編程,但是由于其目前缺少對泛型的支持,導致高階函數編程的使用并沒有給開發者帶來更多的便利,因此在平時業務代碼中寫高階函數還是略為少見。而熟悉 JAVA 的同學都知道,JAVA 中的 Map、Reduce、Filter 等高階函數使用起來非常的舒服。
好,有了這一組函數之后,我們來看看 dao 層的查詢方法怎么寫:
func GetUserInfo(ctx context.Context, options ...func(option *gorm.DB)) ([]*resource.UserInfo) {
db := GetDB(ctx)
db = db.Table(resource.UserInfo{}.TableName())
for _, option := range options {
option(db)
}
var infos []*resource.UserInfo
db.Find(&infos)
return infos
}
沒有對比就沒有傷害,通過和最開始的方法比較,可以看到方法的入參由多個不同類型的參數變成了一組相同類型的函數,因此在處理這些參數的時候,也無需一個一個的判空,而是直接使用一個 for 循環就搞定,相比之前已經簡潔了很多。
那么調用該方法的代碼怎么寫呢,這里直接給出來:
//只使用userID查詢
infos := GetUserInfo(ctx, UserID(userID))
//只使用userName查詢
infos := GetUserInfo(ctx, UserName(name))
//使用role和status同時查詢
infos := GetUserInfo(ctx, Role(role), Status(status))
無論是使用任意的單個參數還是使用多個參數組合查詢,我們都隨便寫,不用關注參數順序,簡潔又清晰,可讀性也是非常好。
再來考慮上面提到的擴展場景,如果我們需要多值查詢,比如查詢多個 status,那么我們只需要在 Option 中增加一個小小的函數即可:
func StatusIn(status []int32) Option {
return func(db *gorm.DB) {
db.Where("`status` in ?", status)
}
}
對于其他字段或者等值查詢也是同理,代碼的簡潔不言而喻。
能優化到上面黃金的階段,其實已經很簡潔了,如果止步于此的話,也是完全可以的。但是如果還想進一步追求極致,那么請繼續往下看!
在上面方法中,我們通過高階函數已經很好地解決了對于一張表中多字段組合查詢的代碼繁瑣問題,但是對于不同的表查詢,仍然要針對每個表都寫一個查詢方法,那么還有沒有進一步優化的空間呢?我們發現,在 Option 中定義的這一組高階函數,壓根與某張表沒關系,他只是簡單地給 gorm.DB 賦值。因此,如果我們有多張表,每個表里都有 user_id、is_deleted、create_time、update_time 這些公共的字段,那么我們完全不用再重復定義一次,只需要在 Option 中定義一個就夠了,每張表的查詢都可以復用這些函數。進一步思考,我們發現,Option 中維護的是一些傻瓜式的代碼,根本不需要我們每次手動去寫,可以使用腳本生成,掃描一遍 DB 的表,為每個不重復的字段生成一個 Equal 方法、In 方法、Greater 方法、Less 方法,就可以解決所有表中按照不同字段做等值查詢、多值查詢、區間查詢。
解決了 Option 的問題之后,對于每個表的各種組合查詢,就只需要寫一個很簡單的 Get 方法了,為了方便看,我們在這里再貼一次:
func GetUserInfo(ctx context.Context, options ...func(option *gorm.DB)) ([]*resource.UserInfo) {
db := GetDB(ctx)
db = db.Table(resource.UserInfo{}.TableName())
for _, option := range options {
option(db)
}
var infos []*resource.UserInfo
db.Find(&infos)
return infos
}
上面這個查詢方法是針對 user_info 這個表寫的,如果還有其他表,我們還需要為每個表都寫一個和這個類似的 Get 方法。如果我們仔細觀察每個表的 Get 方法,會發現這些方法其實就有兩點不同:
如果我們能解決這兩個問題,那我們就能夠使用一個方法解決所有表的查詢。首先對于第一點返回值不一致的問題,可以參考 json.unmarshal 的做法,把返回類型以一個參數的形式傳進來,因為傳入的是指針類型,所以就不用再給返回值了;而對于 tableName 不一致的問題,其實可以和上面處理不同參數的方式一樣,增加一個 Option 方法來解決:
func TableName(tableName string) Option {
return func(db *gorm.DB) {
db.Table(tableName)
}
}
這樣改造之后,我們的 dao 層查詢方法就變成了這樣:
func GetRecord(ctx context.Context, in interface{}, options ...func(option *gorm.DB)) {
db := GetDB(ctx)
for _, option := range options {
option(db)
}
db.Find(in)
return
}
注意,我們把方法名從之前的 GetUserInfo 變成了GetRecord,因為這個方法不僅能支持對于 user_info 表的查詢,而且能夠支持對一個庫中所有表的查詢。也就是說從最開始為每個表建一個類,每個類下面又寫很多個查詢方法,現在變成了所有表所有查詢適用一個方法。
然后我們看看調用這個方法的代碼怎么寫:
//根據userID和userName查詢
var infos []*resource.UserInfo
GetRecord(ctx, &infos, TableName(resource.UserInfo{}.TableName()), UserID(userID), UserName(name))
這里還是給出了查詢 user_info 表的示例,在調用的地方指定 tableName 和返回類型。
經過這樣的改造之后,我們最終實現了用一個簡單的方法【GetRecord】 + 一個可自動生成的配置類【Option】對一個庫中所有表的多種組合查詢。代碼的簡潔和優雅又有了一些提升。美中不足的是,在調用查詢方法的地方多傳了兩個參數,一個是返回值變量,一個是 tableName,多少顯得有點不那么美觀。
這里通過對 grom 查詢條件的抽象,大大簡化了對 DB 組合查詢的寫法,提升了代碼的簡潔。對于其他 update、insert、delete 三種操作,也可以借用這種思想做一定程度的簡化,因為篇幅關系我們不在這里贅述。如果大家還有其他想法,歡迎留言討論!
我們是字節直播中臺創作管理團隊,專注于直播創作與管理端的業務研發,為主播、工會、用戶運營提供一站式的創作管理及創作激勵平臺和運營工具,并為各行業直播提供通用的解決方案和基礎能力,持續為直播業務創造價值。
內推鏈接:https://job.toutiao.com/s/Lts3xLP
內推郵箱:liuzhibing.buaa@bytedance.com
前言
從我們一開始學習JavaScript的時候就聽到過一段話:JS是單線程的,天生異步,適合IO密集型,不適合CPU密集型。但是,多數JavaScript開發者從來沒有認真思考過自己程序中的異步到底是怎么出現的,以及為什么會出現,也沒有探索過處理異步的其他方法。到目前為止,還有很多人堅持認為回調函數就完全夠用了。
但是,隨著JavaScript面臨的需求越來越多,它可以運行在瀏覽器、服務器、甚至是嵌入式設備上,為了滿足這些需求,JavaScript的規模和復雜性也在持續增長,使用回調函數來管理異步也越來越讓人痛苦,這一切,都需要更強大、更合理的異步方法,通過這篇文章,我想對目前已有JavaScript異步的處理方式做一個總結,同時試著去解釋為什么會出現這些技術,讓大家對JavaScript異步編程有一個更宏觀的理解,讓知識變得更體系化一些。
#正文
Step1 - 回調函數
回調函數大家肯定都不陌生,從我們寫一段最簡單的定時器開始:
1
2
3
setTimeout(function () {
console.log('Time out');
}, 1000);
定時器里面的匿名函數就是一個回調函數,因為在JS中函數是一等公民,所以它可以像其他變量一樣作為參數進行傳遞。這樣看來,通過回調函數來處理異步挺好的,寫著也順手,為什么要用別的方法呢?
我們來看這樣一個需求:
上面是微信小程序的登錄時序圖,我們的需求和它類似但又有些差別,想要獲取一段業務數據,整個過程分為3步:
可能上述步驟和實際業務中的有些出入,但是卻可以用來說明問題,請大家諒解。
我們寫一段代碼來實現上述需求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
let key, token, userId;
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
key = data;
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {
key: key
},
success: function (data) {
token = data.token;
userId = data.userId;
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: token,
userId: userId
},
success: function (data) {
console.log('業務數據:', data);
},
error: function (err) {
console.log(err);
}
});
},
error: function (err) {
console.log(err);
}
});
},
error: function (err) {
console.log(err);
}
});
可以看到,整段代碼充滿了回調嵌套,代碼不僅在縱向擴展,橫向也在擴展。我相信,對于任何人來說,調試起來都會很困難,我們不得不從一個函數跳到下一個,再跳到下一個,在整個代碼中跳來跳去以查看流程,而最終的結果藏在整段代碼的中間位置。真實的JavaScript程序代碼可能要混亂的多,使得這種追蹤難度會成倍增加。這就是我們常說的回調地獄(Callback Hell)。
為什么會出現這種現象?
如果某個業務,依賴于上層業務的數據,上層業務又依賴于更上一層的數據,我們還采用回調的方式來處理異步的話,就會出現回調地獄。
大腦對于事情的計劃方式是線性的、阻塞的、單線程的語義,但是回調表達異步流程的方式是非線性的、非順序的,這使得正確推導這樣的代碼的難度很大,很容易產生Bug。
這里我們引出了回調函數解決異步的第1個問題:回調地獄。
回調函數還會存在別的問題嗎?
讓我們再深入思考一下回調的概念:
1
2
3
4
5
6
7
8
// A
$.ajax({
...
success: function (...) {
// C
}
});
// B
A和B發生于現在,在JavaScript主程序的直接控制之下,而C會延遲到將來發生,并且是在第三方的控制下,在本例中就是函數$.ajax(…)。從根本上來說,這種控制的轉移通常不會給程序帶來很多問題。
但是,請不要被這個小概率迷惑而認為這種控制切換不是什么大問題。實際上,這是回調驅動設計最嚴重(也是最微妙)的問題。它以這樣一個思路為中心:有時候ajax(…),也就是你交付回調函數的第三方不是你編寫的代碼,也不在你的直接控制之下,它是某個第三方提供的工具。
這種情況稱為控制反轉,也就是把自己程序一部分的執行控制交給某個第三方,在你的代碼和第三方工具直接有一份并沒有明確表達的契約。
既然是無法控制的第三方在執行你的回調函數,那么就有可能存在以下問題,當然通常情況下是不會發生的:
這種控制反轉會導致信任鏈的完全斷裂,如果你沒有采取行動來解決這些控制反轉導致的信任問題,那么你的代碼已經有了隱藏的Bug,盡管我們大多數人都沒有這樣做。
這里,我們引出了回調函數處理異步的第二個問題:控制反轉。
綜上,回調函數處理異步流程存在2個問題:
1. 缺乏順序性: 回調地獄導致的調試困難,和大腦的思維方式不符
2. 缺乏可信任性: 控制反轉導致的一系列信任問題
那么如何來解決這兩個問題,先驅者們開始了探索之路……
Step2 - Promise
開門見山,Promise解決的是回調函數處理異步的第2個問題:控制反轉。
至于Promise是什么,大家肯定都有所了解,這里是PromiseA+規范,ES6的Promise也好,jQuery的Promise也好,不同的庫有不同的實現,但是大家遵循的都是同一套規范,所以,Promise并不指特定的某個實現,它是一種規范,是一套處理JavaScript異步的機制。
我們把上面那個多層回調嵌套的例子用Promise的方式重構:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
let getKeyPromise = function () {
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
let key = data;
resolve(key);
},
error: function (err) {
reject(err);
}
});
});
};
let getTokenPromise = function (key) {
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {
key: key
},
success: function (data) {
resolve(data);
},
error: function (err) {
reject(err);
}
});
});
};
let getDataPromise = function (data) {
let token = data.token;
let userId = data.userId;
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: token,
userId: userId
},
success: function (data) {
resolve(data);
},
error: function (err) {
reject(err);
}
});
});
};
getKeyPromise()
.then(function (key) {
return getTokenPromise(key);
})
.then(function (data) {
return getDataPromise(data);
})
.then(function (data) {
console.log('業務數據:', data);
})
.catch(function (err) {
console.log(err);
});
可以看到,Promise在一定程度上其實改善了回調函數的書寫方式,最明顯的一點就是去除了橫向擴展,無論有再多的業務依賴,通過多個then(…)來獲取數據,讓代碼只在縱向進行擴展;另外一點就是邏輯性更明顯了,將異步業務提取成單個函數,整個流程可以看到是一步步向下執行的,依賴層級也很清晰,最后需要的數據是在整個代碼的最后一步獲得。
所以,Promise在一定程度上解決了回調函數的書寫結構問題,但回調函數依然在主流程上存在,只不過都放到了then(…)里面,和我們大腦順序線性的思維邏輯還是有出入的。
這里我想主要討論的是,Promise是如何解決控制反轉帶來的信任缺失問題。
首先明確一點,Promise可以保證以下情況,引用自JavaScript | MDN:
下面我們針對前面提過的回調函數處理異步導致的一系列信任問題來討論,如果是用Promise來處理,是否還會存在這些問題,當然前提是實現的Promise完全遵循PromiseA+規范。
調用過早
當使用回調函數的時候,我們無法保證或者不知道第三方對于回調函數的調用是何種形式的,如果它在某種情況下是立即完成以同步的方式來調用,那可能就會導致我們代碼中的邏輯錯誤。
但是,根據PromiseA+規范,Promise就不必擔心這種問題,因為即使是立即完成的Promise(類似于new Promise(function (resolve, reject) {resolve(2);})),也無法被同步觀察到。
也就是說,對一個Promise調用then(…)的時候,即使這個Promise已經決議,提供給then(…)的回調也總會在JavaScript事件隊列的當前運行完成后,再被調用,即異步調用。
調用過晚
當Promise創建對象調用resolve(…)或reject(…)時,這個Promise通過then(…)注冊的回調函數就會在下一個異步時間點上被觸發。
并且,這個Promise上的多個通過then(…)注冊的回調都會在下一個異步時間點上被依次調用,這些回調中的任意一個都無法影響或延誤對其他回調的調用。
舉例如下:
1
2
3
4
5
6
7
8
9
10
11
p.then(function () {
p.then(function () {
console.log('C');
});
console.log('A');
})
.then(funtion () {
console.log('B');
});
// 打印 A B C
通過這個例子可以看到,C無法打斷或搶占B,所以Promise沒有調用過晚的現象,只要你注冊了then(…),就肯定會按順序依次調用,因為這就是Promise的運作方式。
回調未調用
沒有任何東西(甚至JavaScript錯誤)能阻止Promise向你通知它的決議(如果它決議了的話)。如果你對一個Promise注冊了一個成功回調和拒絕回調,那么Promise在決議的時候總會調用其中一個。
當然,如果你的回調函數本身包含JavaScript錯誤,那可能就會看不到你期望的結果,但實際上回調還是被調用了。
1
2
3
4
5
6
p.then(function (data) {
console.log(data);
foo.bar(); // 這里沒有定義foo,所以這里會報Type Error, foo is not defined
}, function (err) {
});
調用次數太多或者太少
根據PromiseA+規范,回調被調用的正確次數應該是1次。“太少”就是不調用,前面已經解釋過了。
“太多”的情況很容易解釋,Promise的定義方式使得它只能被決議一次。如果處于多種原因,Promise創建代碼試圖調用多次resolve(…)或reject(…),或者試圖兩者都調用,那么這個Promise將只會接受第一次決議,并默默忽略任何后續調用。
由于Promise只能被決議一次,所以任何通過then(…)注冊的回調就只會被調用一次。
未能傳遞參數值
如果你沒有把任何值傳遞給resolve(…)或reject(…),那么這個值就是undefined。但不管這個值是什么,它都會被傳給所有注冊在then(…)中的回調函數。
如果使用多個參數調用resolve(…)或reject(…),那么第一個參數之后的所有參數都會被忽略。如果要傳遞多個值,你就必須把它們封裝在單個值中進行傳遞,比如一個數組或對象。
吞掉可能出現的錯誤或異常
如果在Promise的創建過程中或在查看其決議結果的過程中的任何時間點上,出現了一個JavaScript異常錯誤,比如一個TypeError或ReferenceError,這個異常都會被捕捉,并且會使這個Promise被拒絕。
舉例如下:
1
2
3
4
5
6
7
8
9
10
var p = new Promise(function (resolve, reject) {
foo.bar(); // foo未定義
resolve(2);
});
p.then(function (data) {
console.log(data); // 永遠也不會到達這里
}, function (err) {
console.log(err); // err將會是一個TypeError異常對象來自foo.bar()這一行
});
foo.bar()中發生的JavaScript異常導致了Promise的拒絕,你可以捕捉并對其作出響應。
不是所有的thenable都可以信任
到目前為止,我們討論了使用Promise可以避免上述多種由控制反轉導致的信任問題。但是,你肯定也注意到了,Promise并沒有完全擺脫回調,它只是改變了傳遞回調的位置。我們并不是把回調傳遞給foo(…)讓第三方去執行,而是從foo(…)得到某個東西(Promise對象),然后把回調傳遞給這個東西。
但是,為什么這就比單純使用回調更值得信任呢?如何能夠確定返回的這個東西實際上就是一個可信任的Promise呢?
Promise對于這個問題已經有了解決方案,ES6實現的Promise的解決方案就是Promise.resolve(…)。
如果向Promise.resolve(…)傳遞一個非Promise,非thenable得立即值,就會得到一個用這個值填充的Promise。
舉例如下:
1
2
3
4
5
6
7
var p1 = new Promise(function (resolve, reject) {
resolve(2);
});
var p2 = Promise.resolve(2);
// 這里p1和p2的效果是一樣的
而如果向Promise.resolve(…)傳遞一個真正的Promise,就只會返回同一個Promise。
1
2
3
4
var p1 = Promise.resolve(2);
var p2 = Promise.resolve(p1);
p1 === p2; // true
更重要的是,如果向Promise.resolve(…)傳遞了一個非Promise的thenable值,前者就會試圖展開這個值,而且展開過程中會持續到提取出一個具體的非類Promise的最終值。
舉例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
var p = {
then: function (cb, errCb) {
cb(2);
errCb('haha');
}
};
// 這可以工作,因為函數是一等公民,可以當做參數進行傳遞
p.then(function (data) {
console.log(data); // 2
}, function (err) {
console.log(err); // haha
});
這個p是一個thenable,但不是一個真正的Promise,其行為和Promise并不完全一致,它同時觸發了成功回調和拒絕回調,它是不可信任的。
盡管如此,我們還是都可以把這樣的p傳給Promise.resolve(…),然后就會得到期望中的規范化后的安全結果:
1
2
3
4
5
6
Promise.resolve(p)
.then(function (data) {
console.log(data); // 2
}, function (err) {
console.log(err); // 永遠不會到達這里
});
因為前面討論過,一個Promise只接受一次決議,如果多次調用resolve(…)或reject(…),后面的會被自動忽略。
Promise.resolve(…)可以接受任何thenable,將其解封為它的非thenable值。從Promise.resolve(…)得到的是一個真正的Promise,是一個可以信任的值。如果你傳入的已經是真正的Promise,那么你得到的就是它本身,所以通過Promise.resolve(…)過濾來獲得可信任性完全沒有壞處。
綜上,我們明確了,使用Promise處理異步可以解決回調函數控制反轉帶來的一系列信任問題。
很好,我們又向前邁了一步。
Step3 - 生成器Gererator
在Step1中,我們確定了用回調表達異步流程的兩個關鍵問題:
在Step2中,我們詳細介紹了Promise是如何把回調的控制反轉又反轉過來,恢復了可信任性。
現在,我們把注意力轉移到一種順序、看似同步的異步流程控制表達風格,這就是ES6中的生成器(Gererator)。
可迭代協議和迭代器協議
了解Generator之前,必須先了解ES6新增的兩個協議:可迭代協議和迭代器協議。
可迭代協議
可迭代協議運行JavaScript對象去定義或定制它們的迭代行為,例如(定義)在一個for…of結構中什么值可以被循環(得到)。以下內置類型都是內置的可迭代對象并且有默認的迭代行為:
注意,Object不符合可迭代協議。
為了變成可迭代對象,一個對象必須實現@@iterator方法,意思是這個對象(或者它原型鏈prototype chain上的某個對象)必須有一個名字是Symbol.iterator的屬性:
屬性值[Symbol.iterator]返回一個對象的無參函數,被返回對象符合迭代器協議
當一個對象需要被迭代的時候(比如開始用于一個for…of循環中),它的@@iterator方法被調用并且無參數,然后返回一個用于在迭代中獲得值的迭代器。
迭代器協議
迭代器協議定義了一種標準的方式來產生一個有限或無限序列的值。
當一個對象被認為是一個迭代器時,它實現了一個next()的方法并且擁有以下含義:
| 屬性 | 值 |
|——|—|
| next | 返回一個對象的無參函數,被返回對象擁有兩個屬性:
1. done(boolean)
- 如果迭代器已經經過了被迭代序列時為true。這時value可能描述了該迭代器的返回值
- 如果迭代器可以產生序列中的下一個值,則為false。這等效于連同done屬性也不指定。
2. value - 迭代器返回的任何JavaScript值。done為true時可以忽略。 |
使用可迭代協議和迭代器協議的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var str = 'hello';
// 可迭代協議使用for...of訪問
typeof str[Symbol.iterator]; // 'function'
for (var s of str) {
console.log(s); // 分別打印 'h'、'e'、'l'、'l'、'o'
}
// 迭代器協議next方法
var iterator = str[Symbol.iterator]();
iterator.next(); // {value: "h", done: false}
iterator.next(); // {value: "e", done: false}
iterator.next(); // {value: "l", done: false}
iterator.next(); // {value: "l", done: false}
iterator.next(); // {value: "o", done: false}
iterator.next(); // {value: undefined, done: true}
我們自己實現一個對象,讓其符合可迭代協議和迭代器協議:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var something = (function () {
var nextVal;
return {
// 可迭代協議,供for...of消費
[Symbol.iterator]: function () {
return this;
},
// 迭代器協議,實現next()方法
next: function () {
if (nextVal === undefined) {
nextVal = 1;
} else {
nextVal = (3 * nextVal) + 6;
}
return {value: nextVal, done: false};
}
};
})();
something.next().value; // 1
something.next().value; // 9
something.next().value; // 33
something.next().value; // 105
用Generator實現異步
如果我們用Generator改寫上面回調嵌套的例子會是什么樣的呢?見代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
function getKey () {
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
key = data;
it.next(key);
}
error: function (err) {
console.log(err);
}
});
}
function getToken (key) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {
key: key
},
success: function (data) {
loginData = data;
it.next(loginData);
}
error: function (err) {
console.log(err);
}
});
}
function getData (loginData) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: loginData.token,
userId: loginData.userId
},
success: function (busiData) {
it.next(busiData);
}
error: function (err) {
console.log(err);
}
});
}
function *main () {
let key = yield getKey();
let LoginData = yield getToken(key);
let busiData = yield getData(loginData);
console.log('業務數據:', busiData);
}
// 生成迭代器實例
var it = main();
// 運行第一步
it.next();
console.log('不影響主線程執行');
我們注意*main()生成器內部的代碼,不看yield關鍵字的話,是完全符合大腦思維習慣的同步書寫形式,把異步的流程封裝到外面,在成功的回調函數里面調用it.next(),將傳回的數據放到任務隊列里進行排隊,當JavaScript主線程空閑的時候會從任務隊列里依次取出回調任務執行。
如果我們一直占用JavaScript主線程的話,是沒有時間去執行任務隊列中的任務:
1
2
3
4
5
// 運行第一步
it.next();
// 持續占用JavaScript主線程
while(1) {}; // 這里是拿不到異步數據的,因為沒有機會去任務隊列里取任務執行
綜上,生成器Generator解決了回調函數處理異步流程的第一個問題:不符合大腦順序、線性的思維方式。。
Step4 - Async/Await
上面我們介紹了Promise和Generator,把這兩者結合起來,就是Async/Await。
Generator的缺點是還需要我們手動控制next()執行,使用Async/Await的時候,只要await后面跟著一個Promise,它會自動等到Promise決議以后的返回值,resolve(…)或者reject(…)都可以。
我們把最開始的例子用Async/Await的方式改寫:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
let getKeyPromise = function () {
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/apiKey',
success: function (data) {
let key = data;
resolve(key);
},
error: function (err) {
reject(err);
}
});
});
};
let getTokenPromise = function (key) {
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getToken',
data: {
key: key
},
success: function (data) {
resolve(data);
},
error: function (err) {
reject(err);
}
});
});
};
let getDataPromise = function (data) {
let token = data.token;
let userId = data.userId;
return new Promsie(function (resolve, reject) {
$.ajax({
type: 'get',
url: 'http://localhost:3000/getData',
data: {
token: token,
userId: userId
},
success: function (data) {
resolve(data);
},
error: function (err) {
reject(err);
}
});
});
};
async function main () {
let key = await getKeyPromise();
let loginData = await getTokenPromise(key);
let busiData = await getDataPromise(loginData);
console.log('業務數據:', busiData);
}
main();
console.log('不影響主線程執行');
可以看到,使用Async/Await,完全就是同步的書寫方式,邏輯和數據依賴都非常清楚,只需要把異步的東西用Promise封裝出去,然后使用await調用就可以了,也不需要像Generator一樣需要手動控制next()執行。
Async/Await是Generator和Promise的組合,完全解決了基于回調的異步流程存在的兩個問題,可能是現在最好的JavaScript處理異步的方式了。
總結
本文通過四個階段來講述JavaScript異步編程的發展歷程:
我們可以看到,每項技術的突破都是為了解決現有技術存在的一些問題,它是循序漸進的,我們在學習的過程中,要真正去理解這項技術解決了哪些痛點,它為什么會存在,這樣會有益于我們構建體系化的知識,同時也會更好的去理解這門技術。
在之前的一篇文章《》中,我們講解了如何通過CSS實現心形圖案。
今天這篇文章我們換一個思路去實現這個心形,并且讓這個心形可以跳動,正如你看到女神那怦然心動的心跳一般。
文章的代碼已經放到github上了,感興趣的可以自取。
https://github.com/zhouxiongking/article-pages/blob/master/articles/border/heartBeat.html
CSS3
首先我們來看看需要實現的效果圖。
實現效果
接下來我們一步步分析,這個效果是如何得到的。
我們將整個圖案拆開來看,主要是兩個圓+正方形。為了更好的展示拆分的效果,我們對不同區域設置不同的顏色和透明度。
拆分圖案
從上面的圖案可以看出,主要由以下三個圖形組成。
旋轉過的正方形。
左上方的圓形。
右上方的圓形。
正方形的CSS屬性很簡單,設置好寬度和高度,然后旋轉45度即可,為了讓心跳的效果更加明顯,我們通過filter屬性來設置圖案四周的陰影效果。
正方形CSS屬性
這里給animation設置了heartbeat動畫,放在后面細講。
因為左上方和右上方兩個圓形是相同大小,它們有很多相似之處,這里可以把它們公共的CSS屬性抽出來。最重要的就是設置border-radius。
公共CSS屬性
左右兩個CSS屬性只是在定位上有所差異,由于旋轉帶來的不同,左側的圓形需要使用left屬性來調整位置,而右側的圓形需要通過top屬性來調整位置。最終確定的定位信息如下。
位置CSS屬性
當我們將以上信息都寫完后,就可以得到以下的圖案了。
心形圖案
為了讓整個圖案有心跳的效果,不可避免的需要使用動畫。
通過@keyframes定義一個動畫,動畫需要有以下幾點:
主要是對圖案有放大和縮小的效果,這個可以通過scale屬性實現。
因為主圖案的旋轉,動畫中也必須填寫rotate屬性,保持和主圖案旋轉角度一樣。
因為心跳不是平穩進行,動畫效果在前面會進行的快一點,在后面會進行的慢一點,因此不是將動畫效果在50%時進行劃分,而是往前推移,我選擇的是25%
在不同時間段設置不同的透明度,讓心跳感更加真實。
通過以上的分析,得到的代碼如下。
動畫CSS屬性
至此所有部分的代碼都講解完畢,順利運行后就可以得到文章一開始的心跳效果了。
今天這篇文章換了一個思路去實現心形圖案,并且完成了心跳的效果,大家也可以動手嘗試下。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。