整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

如何使用高阶函数编程提升代码的简洁性

函数是 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 方法,会发现这些方法其实就有两点不同:

  • 返回值类型不一样;
  • TableName 不一样。

如果我们能解决这两个问题,那我们就能够使用一个方法解决所有表的查询。首先对于第一点返回值不一致的问题,可以参考 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://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html
  • https://coolshell.cn/articles/21146.html

加入我们

我们是字节直播中台创作管理团队,专注于直播创作与管理端的业务研发,为主播、工会、用户运营提供一站式的创作管理及创作激励平台和运营工具,并为各行业直播提供通用的解决方案和基础能力,持续为直播业务创造价值。

内推链接: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. 调用秘钥接口,获取key
  2. 携带key调用登录接口,获取token和userId
  3. 携带token和userId调用业务接口,获取数据

可能上述步骤和实际业务中的有些出入,但是却可以用来说明问题,请大家谅解。

我们写一段代码来实现上述需求:

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(…),也就是你交付回调函数的第三方不是你编写的代码,也不在你的直接控制之下,它是某个第三方提供的工具。

这种情况称为控制反转,也就是把自己程序一部分的执行控制交给某个第三方,在你的代码和第三方工具直接有一份并没有明确表达的契约。

既然是无法控制的第三方在执行你的回调函数,那么就有可能存在以下问题,当然通常情况下是不会发生的:

  1. 调用回调过早
  2. 调用回调过晚
  3. 调用回调次数太多或者太少
  4. 未能把所需的参数成功传给你的回调函数
  5. 吞掉可能出现的错误或异常
  6. ……

这种控制反转会导致信任链的完全断裂,如果你没有采取行动来解决这些控制反转导致的信任问题,那么你的代码已经有了隐藏的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:

  • 在JavaScript事件队列的当前运行完成之前,回调函数永远不会被调用
  • 通过 .then 形式添加的回调函数,甚至都在异步操作完成之后才被添加的函数,都会被调用
  • 通过多次调用 .then,可以添加多个回调函数,它们会按照插入顺序并且独立运行

下面我们针对前面提过的回调函数处理异步导致的一系列信任问题来讨论,如果是用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中,我们确定了用回调表达异步流程的两个关键问题:

  1. 基于回调的异步不符合大脑对任务步骤的规范方式
  2. 由于控制反转,回调并不是可信任的

在Step2中,我们详细介绍了Promise是如何把回调的控制反转又反转过来,恢复了可信任性。

现在,我们把注意力转移到一种顺序、看似同步的异步流程控制表达风格,这就是ES6中的生成器(Gererator)

可迭代协议和迭代器协议

了解Generator之前,必须先了解ES6新增的两个协议:可迭代协议迭代器协议

可迭代协议

可迭代协议运行JavaScript对象去定义或定制它们的迭代行为,例如(定义)在一个for…of结构中什么值可以被循环(得到)。以下内置类型都是内置的可迭代对象并且有默认的迭代行为:

  1. Array
  2. Map
  3. Set
  4. String
  5. TypedArray
  6. 函数的Arguments对象
  7. NodeList对象

注意,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异步编程的发展历程:

  1. 第一个阶段 - 回调函数,但会导致两个问题:
  • 缺乏顺序性: 回调地狱导致的调试困难,和大脑的思维方式不符
  • 缺乏可信任性: 控制反转导致的一系列信任问题
  1. 第二个阶段 - Promise,Promise是基于PromiseA+规范的实现,它很好的解决了控制反转导致的信任问题,将代码执行的主动权重新拿了回来。
  2. 第三个阶段 - 生成器函数Generator,使用Generator,可以让我们用同步的方式来书写代码,解决了顺序性的问题,但是需要手动去控制next(…),将回调成功返回的数据送回JavaScript主流程中。
  3. 第四个阶段 - Async/Await,Async/Await结合了Promise和Generator,在await后面跟一个Promise,它会自动等待Promise的决议值,解决了Generator需要手动控制next(…)执行的问题,真正实现了用同步的方式书写异步代码

我们可以看到,每项技术的突破都是为了解决现有技术存在的一些问题,它是循序渐进的,我们在学习的过程中,要真正去理解这项技术解决了哪些痛点,它为什么会存在,这样会有益于我们构建体系化的知识,同时也会更好的去理解这门技术。

在之前的一篇文章《》中,我们讲解了如何通过CSS实现心形图案。

今天这篇文章我们换一个思路去实现这个心形,并且让这个心形可以跳动,正如你看到女神那怦然心动的心跳一般。

文章的代码已经放到github上了,感兴趣的可以自取。

https://github.com/zhouxiongking/article-pages/blob/master/articles/border/heartBeat.html

CSS3

实现效果

首先我们来看看需要实现的效果图。

实现效果

接下来我们一步步分析,这个效果是如何得到的。

心形图案

我们将整个图案拆开来看,主要是两个圆+正方形。为了更好的展示拆分的效果,我们对不同区域设置不同的颜色和透明度。

拆分图案

从上面的图案可以看出,主要由以下三个图形组成。

  • 旋转过的正方形。

  • 左上方的圆形。

  • 右上方的圆形。

正方形

正方形的CSS属性很简单,设置好宽度和高度,然后旋转45度即可,为了让心跳的效果更加明显,我们通过filter属性来设置图案四周的阴影效果。

正方形CSS属性

这里给animation设置了heartbeat动画,放在后面细讲。

圆形CSS属性

因为左上方和右上方两个圆形是相同大小,它们有很多相似之处,这里可以把它们公共的CSS属性抽出来。最重要的就是设置border-radius。

公共CSS属性

左右两个CSS属性只是在定位上有所差异,由于旋转带来的不同,左侧的圆形需要使用left属性来调整位置,而右侧的圆形需要通过top属性来调整位置。最终确定的定位信息如下。

位置CSS属性

当我们将以上信息都写完后,就可以得到以下的图案了。

心形图案

动画效果

为了让整个图案有心跳的效果,不可避免的需要使用动画。

通过@keyframes定义一个动画,动画需要有以下几点:

  • 主要是对图案有放大和缩小的效果,这个可以通过scale属性实现。

  • 因为主图案的旋转,动画中也必须填写rotate属性,保持和主图案旋转角度一样。

  • 因为心跳不是平稳进行,动画效果在前面会进行的快一点,在后面会进行的慢一点,因此不是将动画效果在50%时进行划分,而是往前推移,我选择的是25%

  • 在不同时间段设置不同的透明度,让心跳感更加真实。

通过以上的分析,得到的代码如下。

动画CSS属性

至此所有部分的代码都讲解完毕,顺利运行后就可以得到文章一开始的心跳效果了。

结束语

今天这篇文章换了一个思路去实现心形图案,并且完成了心跳的效果,大家也可以动手尝试下。