【轻聊前端】程序运行的指挥官

现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(十六)篇,旨在系统地、逻辑性地把原生JavaScript知识分享给大家,帮助各位较为轻松地理清知识体系,更好地理解和记忆,我尽力而为,望不负期待。

前面的文章,我们聊了各种知识点,每个点都不同,它们各司其职,保证程序正确进行,形成整个应用。

学习知识点所展示的demo都比较小巧,而实际当中,90%的程序最终都是要走向庞大的。

为什么?因为一款Web应用需要实现很多不同的功能,每个功能在不同情况下,随着用户身份、操作、设备的不同,表现也不同。

比如:

1、有些人看到的界面是红的,有些人是蓝的

2、有些人会看到广告,有些人没有

3、有些人有优惠券,有些人没有

这些“不同”是怎么来的,该如何区分?

在系列文章的第一篇中就举过例子,这里再举个简单的例子:

我们每天上班都会选择交通工具。假设,距离上班时间还有N分钟,有如下几种可能:

1
2
3
4
5
6
7
8
N>100
走路
N>50N<100
坐公交
N>10N<50
打车
N<0
请假

这就是一个流程控制的标准范式:“如果…那样…否则…这样”。

每个项目都有很多类似的执行路径,它们使得庞大的程序有迹可循。

JavaScript中,担当这些职责的角色有很多,甚至能力有所重叠,我们的工作,就是为它们找到最合适的位置。一旦代码拥有了清晰的执行流程,就是可读的、高效的、易维护的,不易出错的。看似最不起眼的工作,却很重要。

下面,我们分为“同步”和“异步”,由简到繁地介绍一下。

同步

同步的意思即是,当前动作执行完才执行下面的动作,不会颠倒。

上面说了,条件判断就是“如果…那么…”,而对这个语义最直接的程序翻译是if-else

if-else

完整语法如下:

1
2
3
4
5
6
7
if(condition){
//条件成立时执行
statement1
} else {
//条件不成立时执行
statement2
}

这里的condition可以是任何表达式,并且求值结果不一定是布尔值。ECMAScript会自动调用Boolean()函数将表达式的值转换为布尔值。

如果值为true,执行statement1,值为false,执行语句statement2。这里的语句可能是一行代码,也可能是代码块。

注意,这里有一个小“坑”大家可能掉进去,即else是个包含范围很广的分支,所有if之外的情况都走else,当你对if的判断出了疏漏,就会有问题,这时就要多加一些判断来处理。即”if-else if-else“。

虽然“if-else”是一种很合理的逻辑和写法,但有两种情况是可以优化的。

1、分支简单

2、分支过多

至于怎么优化,往下看。

三元表达式

由三部分组成,每部分为”一元“。

形式如下:

1
condition? a:b

第一部分是条件,条件成立,取a,不成立,取b。什么是成立呢?跟“if-else”当中的类似,condition会是布尔值或被转化为布尔值。

通常,第一部分是变量或者表达式,如果是字面量,就没有判断的必要了。

后两部分,可以是变量、运算表达式或字面量。

1
2
3
4
5
6
7
8
9
10
11
//变量
let tag = true
tag?a:b

//算术运算表达式
let m,n;
m - n > 1 ? a + b:a - b

//逻辑运算表达式
let m = true,n=false
m && n ?12

可以看到,三元运算符最大的特点是简洁,当判断条件和要执行的代码都很简单时,可考虑使用三元运算。

短路运算符

三元运算看起来够简洁了,没有代码段,一行搞定。但短路运算是一种比三元运算更简洁的方式。它通常用于变量赋值。如默认值。

逻辑或(||)

假设有个角色叫xiaoming,获取到个人信息之后需要更新昵称数据,但如果这个数据没有填,为空,则默认展示为‘visitor’,

1
2
3
4
5
6
7
8
// if-else实现
if(nickName!==''){
xiaoming.nickName = nickName
} else {
xiaoming.nickName = ‘visitor’
}
// 短路实现
const xiaoming.nickName = nickName || ‘visitor’

怎么样,是不是短路更加简洁明了?

逻辑与(&&)

”&&“的运用与”||“有所不同,如果像上面那样使用,它会在第一个变量为false时返回false,第一个为true时返回第二个,这样会有什么问题?

如果为true还好说,直接赋第二个值,但为false的时候,意味着前后的数据类型可能是不同的,相同的话,就不需要使用短路,直接赋值就行了。

所以,”&&“运算符常用场景有如下两种:

1、判空

相信大家碰到过这样的报错。

Uncaught TypeError: Cannot read properties of undefined (reading ‘toString’)

这属于我们在空值上调用了toString()方法,导致错误。如果写成这样:

1
x && x.toString()

虽然同样不能保证逻辑正常往下走,但空值报错不会有了,如果前面为空,就是false,不会执行后面的语句。

这种情况,在ES2020版本中有了新的方法——链判断(?.)

1
x?.toString()

是等效的。

2、做另外的操作

除了在原有对象上做操作,还可以做其他操作。比如:

1
x && (y = 100)

当 x 为真,给y赋值100。

以上两种表达式,可用于替代”分支代码简单“的情况。

而下面这种可以替代部分分支过多的情况。

switch

什么是分支过多呢?

1
2
3
4
5
6
7
8
9
10
11
12
let a;
if(a == 100){

} else if(a == 200){

} else if(a == 300){

}
...
else if(a == 1000){

}

分支多并不是业务的问题,属于正常现象,但代码的编写是可以有选择的。

上面的代码可以使用switch改成这样:

1
2
3
4
5
6
7
8
9
10
11
switch(a){
case 100:
// do something
break;
case 200:
// do something
break;
...
default:
// do something
}

调整之后,代码的执行和判断均没有发生改变,但写法上更加直观,只是,这不代表所有的多分支if-else都需要或者适合用switch改造,它更适用于上面这种单值匹配判断。

注:switch也不一定是最佳选择,有时候我们可以采用映射表来维护,也可大大简化代码。

上面的代码可称为普通逻辑判断类的流程控制,下面介绍几种循环迭代类控制:

for/for-in/for-of

什么时候会用循环?这涉及到数据类型,必然是具有多个成员的类型才需要,如数组、普通对象,简单类型、函数以及Date这样的特殊对象通常是不需要或者做不到的。

对于循环来说,类型也并不是必要的,只需要一个起点、一个终点,和步幅,就能实现循环。

1
2
3
for(let i = 0;i < 100;i++){

}

但这种用法较为少见,更多的是对真实数据的处理:

1
2
3
4
let arr = [1,2,3,4,5]
for(let i = 0;i < arr.length;i++){
arr[i] = arr[i] * 2
}

这段代码的执行使得数组每一项变为原来的两倍。

循环的想象空间是很大的,它是一种机制,你可以在里面做任何想做的事。

值得一提的是,现在for循环已经在很大程度上被前面”数组“章节提到的map、forEach、filter等所替代,但掌握还是必要的。

除了for,for-in和for-of是新版ES增加的成员。

通常使用它们的时候不再需要i这样一个索引值了,也不限于数组,只要具备可迭代的特性都可以。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 输出字符串的每一个字符
let str = 'abcdef'
for(value of str){
console.log(value) // 'a' 'b' 'c' 'd' 'e' 'f'
}

// 输出对象的每个属性
let obj = {
name:'idea',
age:18
}
for(item in obj){
console.log(item) // name age
}

看到这,你可能有疑问,它们看起来很像,有什么区别呢?区别大致如下:

  1. 推荐在遍历对象的时候使用for in ,在遍历数组的时候使用for of 。
  2. for in 循环出的是key,并且key的类型是string,for of 循环出的是value。
  3. for of 不能循环普通的对象,需要通过Object.keys搭配使用。

到这里,你应该还想知道,JavaScript中有哪些可迭代对象:

  • Set
  • Map
  • String
  • Array
  • Arguments
  • NodeList

另外,如果不确定,可用下面的方法判断是否可迭代:

1
Array.prototype.hasOwnProperty(Symbol.iterator)   // true

至此,我们又了解了两个强大的循环迭代方式,而不只是前面数组部分介绍的那些了。

while/do-while

聊完最常见的for循环,看看次常见的while。

while的意思是”当…就怎样“。

你可能觉得,这跟if是一个意思,但跟if的不同是,它会一直沿着当前的逻辑走下去,直到条件不符合,而if是一次性的。

看下面示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// if 
let a = 10,
b = 0
if(a > 0){
a--
b += 1
}
console.log(a,b) // 9 1

// while
let a = 10,
b = 0
while(a > 0){
a--
b += 1
}
console.log(a,b) // 0 10

能看出明显的区别吧?

这个特点使得while在很多算法问题的解决中很常用。

而do-while语句是while的变形,是一种后测试循环语句,循环体内的代码至少执行一次,然后才会对退出条件进行求值,不再举例。

return、continue、break

循环固然强大,但很多时候我们并不需要从头遍历到尾,或者只对其中部分项进行处理,其他项不处理。这就需要合适的退出/中断方法了。这个小功能有时很重要。

return

三者当中,return是最常见的,比如函数中的返回值,也可用于拦截其后面的语句执行。比如这样一段代码。

1
2
3
4
5
6
7
let testTag = true
function returnTest(){
if(testTag){
return
}
console.log('testTag',testTag)
}

当 testTag 为true时,if语句直接return,就不会看到console语句的执行结果了。

这种通常用于在执行一段代码的时候排除前置条件,比如文章开头提到的”有没有广告“的问题,就可以是这样:

1
2
3
4
5
let isVip = false
function showAds(){
if(isVip) return
// 播广告
}

break/continue

break和continue语句常用于循环类代码中。其中,break代表立即退出循环,执行循环后的下一条语句。而continue同样是立即退出循环,但会再次从循环顶部开始执行

中断循环的场景是比较少的,提一个最近碰到的吧,即用户通过某种行为触发了一系列的请求,这些请求是按顺序执行的,需要过程,在过程中,一旦用户选择取消,即需要中止循环。

示例如下:

1
2
3
4
5
6
let cancelRequest = false,
taskList = [];
for(let i = 0;i < taskList.length;i++){
if(cancelRequest) break;
taskList[i]
}

这段代码执行的时候,用户无操作,会一直执行,一旦用户点击取消,将cancelRequest置为true,就会中断。

而continue可用于筛选部分符合条件的执行,不符合的不执行。

示例如下:

1
2
3
4
for(let i = 0;i < taskList.length;i++){
if(i!== 0 && i % 2 === 0) continue;
// ...
}

这段代码就将数组中索引位是偶数的排除在外了。

聊完了”同步“,下面聊”异步“。

异步

可能有些读者不知道二者的区别是什么。

简单描述,异步就是——“你走你的,不影响,过会儿来看结果”。

如果还不理解:”老板,饼你先做着,我去买点东西,过会儿来拿“。

这里表明了两点:

  • 不需要停下来等
  • 指令已经发出,等的不是动作,是结果

至于“过会儿”是多久,有时由你决定,有时由要做的事决定。来看看究竟。

定时器

”定时器“就是一种由你决定的异步,是最直接的等待,给个时间,告诉它等多久。

setTimeout

可以传入一个回调函数或者一个字符串,加上一个delay(延迟的毫秒数)

1
2
3
4
5
setTimeout(fn,delay)
//比如
const timeoutID = setTimeout(()=>{
// 执行动作
},1000)

上面这段代码,意思就是,等1秒后“执行动作”。

简单的一段代码,其实藏有两个玄机:

  • 如果只执行一次,可以直接调用setTimeout,timeoutID是不必要的,它的用途是在清除定时器,如果需要的话。
  • 它并不会严格地在1秒后执行动作,1秒只代表尽可能快的执行时间(哪怕是写成0,也可能不会立即执行)。

至于为什么,稍微了解浏览器运行机制的人应该知道,有很多不同种类的任务在按照一定规则执行着,比如:脚本、渲染、异步程序等,定时器也有专门的”管家“,当优先级更高的任务尚未执行完的时候,即使到了指定时间,”动作“仍不会被执行。

setInterval

跟setTimeout很像,区别是,setTimeout只会执行一次,而setInterval执行多次,不再赘述。

值得注意的是,随着现代JavaScript广泛的应用,定时器逐渐被认为是过时的、不被信任的。

为什么“过时”,因为以前的工具较少,需要过一段时间执行的动作都由定时器来负责,随着新的特性逐一被添加进来,适合的应用场景就变少了,有更新更好的方案替代,如CSS动画,requestAnimationFrame()等。

“不被信任”体现在,少量开发者会将它用在揣测代码的执行时间上,比如,一段代码执行后好像没拿到结果,那么多久之后能拿到呢,应该在1秒后?于是写了个时间间隔为1秒钟的定时器来执行后续代码,这显然有问题。

虽然如此,学习它是必要的,在需要固定时间间隔的场景,如:防抖、节流等,还是会用。

XMLHttpRequest

XMLHttpRequest也属于老牌王者一类了,曾引领了web开发的新潮流,虽然现在有了fetch,但不论是直接用,还是Axios,仍有很多项目在用它。至于它的异步特性应该没人有疑问吧,因为它本身就是用来向服务端拉取数据的,很符合“发指令,等待结果”的定义。

关于它的使用,【轻聊前端】网页动态化,前后通信的发展历程中有介绍,细节不再赘述。

但它在实际运用有一个比较明显的缺点,当请求结果需要相互依赖的时候,代码会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ajax({
type:'',
url:'',
success:function(){
$ajax({
type:'',
url:'',
success:function(){
$ajax({
type:'',
url:'',
success:function(){
// 欢迎来到回调地狱!~
}
})
}
})
}
})

这就是臭名昭著的“回调地狱”。你可能会说,ajax太罪恶了。

慢着,这个锅ajax可能不背,因为好像不是ajax导致的?

我们完全可以把三个请求都抽出来单独执行,对吧?这是代码组织的问题,但那样会带来什么新的问题呢?代码关系、逻辑联系的梳理,而且会变成硬编码,万一有变化…

所以,这个问题的归因是:缺少一种结果反馈机制为我们带来回调的延续性

而这,就是promise。

promise

从本意上讲,它是承诺,承诺过一段时间会给结果。

promise有三种状态:pending(等待态),fulfiled(成功态),rejected(失败态)。跟需求完全契合。

关于proimse,足够单开一篇文介绍,这里只举两个简单示例。

  • 使用方式之一

用promise包裹异步代码,用resolve(成功态)通知执行结果。

1
2
3
4
5
6
let p = new Promise((resolve,reject)=>{
setTimeout(()=>{
// 执行任务
resolve('执行结果')
},1000)
})
  • 解决回调地狱
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function requestFunc(url,type){
return new Promise((resolve,reject)=>{
$ajax({
type:'',
url:'',
success:resolve,
error:reject
})
})
}

// 第一个请求
requestFunc(url,type).then(()=>{
// 第二个请求
return requestFunc(url,type)
}).then(()=>{
// 第三个请求
return requestFunc(url,type)
}).then(()=>{
// 执行完成
})

这样以来,不仅解决了回调地狱的问题,也无需花太多功夫去梳理它们的关系。

关于promise的介绍先到此,需要提示的是,promise不仅是一种需要定义的东西,而是一种机制,你可能会在很多地方看到它的身影,比如:“xxx返回一个promise”,大多数新加入JS的异步API 都是建立在Promise 之上的,包括下面介绍的这位。

async/await

async:异步,await:等待。

二者结合,即等待异步代码的执行结果。最常见的用途就是,用同步代码的写法来执行异步。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function resolveAfter2Seconds() {
return new Promise(resolve => {
setTimeout(() => {
resolve('resolved');
}, 2000);
});
}

async function asyncCall() {
console.log('calling');
const result = await resolveAfter2Seconds();
console.log(result);
// expected output: "resolved"
}

如果这是一段老代码,没有使用await,result的打印值会是undefined,因为resolveAfter2Seconds中的代码异步执行,在执行console的时候结果并没有得到,但因为用了async/await,对代码进行了阻塞,使得result返回结果后才执行console,就能得到期望的结果。这便是它的威力所在了。

事件

把事件放到这个话题,好像有点乱入,其实并没有,事件也是流程控制的重要组成部分,想想吧,“点击事件、滚动事件、加载事件、打开、关闭事件”,我们的程序中遍布着事件。

如果没有这些事件,我们无从知道一些代码的正确执行时机到底在哪里,事件本身可看做是“发布订阅”的模式,先给某元素绑定事件(订阅),然后在动作发生的时候执行回调(发布)。知道这一点很重要,因为很多时候,都应该在回调中进行下一步操作,而不是在可能引起错误的其他地方。

下面就来看看“错误”。

错误处理

我们希望自己写的代码都能顺利执行,请求也成功返回,但总有异常发生,如果我们不知道,没有做任何处理,给用户的反馈就是生硬的、突兀的,使体验打折扣,更严重的,会造成线上bug。

前面说的条件判断、短路运算,是规避代码异常的机制之一,但更多时候,我们需要其他的方法。

首先要有捕获机制,常用的是这几种:

  • error事件:原生捕获事件,可以拿到报错代码的相关信息(框架里也有类似的方法)。
  • Promise.catch:promise的捕获回调,可进行请求异常的处理。
  • try/catch:也是较为通用的捕获机制,可以将运行的代码段放在try{}里,使用catch捕获。
  • 响应拦截器:使用axios时会用到,用于对请求的通用性错误做全局处理。

需要注意的是,并不是所有错误都会被程序抛出,当不能捕获时,就需要手动抛出错误。

总结

一份好代码,不仅要实现强大/炫酷的功能,更需要讲求”组织清晰,杂而不乱“,最基本的代码组织,便是本文所介绍的流程控制了。写好了人人叫好,写得不好只能被吐槽。

文中用了不少生活中的例子,这进一步说明编程并未脱离生活,仅仅是使用编程语言来解决“数字化”的问题罢了。

最后,摘一段《华罗庚》中的内容结束此文:

“事实上,’统筹法’在日常生活中就有很多应用。譬如,早晨起来煮牛奶喝,火已经生了,牛奶也拿来了,大家说应该怎样安排省时间?”

第一种办法是先洗好锅,便煮奶,此时一边刷牙、洗脸,一边在旁边等候,等奶煮好了,便可享用。

第二种办法是先刷牙、洗脸,等这些做完了,再洗锅、煮奶,等候奶煮好。哪一种办法省时间?

大家不约而同地说道:“第一种办法省时间。”华罗庚满意地点点头,接着问道:“大家想一想,能不能把第一种办法再改进一下,让它变得更有效呢?华罗庚说道:“咱们按着第一种办法,牛奶煮好了,太热,需要再放上一会儿,晾凉点再喝。我们可以趁着这个机会去准备上班用的物品,当一切都收拾妥当后,奶也凉了。我们喝完奶后,就可以高高兴兴地上班了。”