现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(十六)篇,旨在系统地、逻辑性地把原生JavaScript知识分享给大家,帮助各位较为轻松地理清知识体系,更好地理解和记忆,我尽力而为,望不负期待。
前面的文章,我们聊了各种知识点,每个点都不同,它们各司其职,保证程序正确进行,形成整个应用。
学习知识点所展示的demo都比较小巧,而实际当中,90%的程序最终都是要走向庞大的。
为什么?因为一款Web应用需要实现很多不同的功能,每个功能在不同情况下,随着用户身份、操作、设备的不同,表现也不同。
比如:
1、有些人看到的界面是红的,有些人是蓝的
2、有些人会看到广告,有些人没有
3、有些人有优惠券,有些人没有
这些“不同”是怎么来的,该如何区分?
在系列文章的第一篇中就举过例子,这里再举个简单的例子:
我们每天上班都会选择交通工具。假设,距离上班时间还有N分钟,有如下几种可能:
1 | N>100 |
这就是一个流程控制的标准范式:“如果…那样…否则…这样”。
每个项目都有很多类似的执行路径,它们使得庞大的程序有迹可循。
JavaScript中,担当这些职责的角色有很多,甚至能力有所重叠,我们的工作,就是为它们找到最合适的位置。一旦代码拥有了清晰的执行流程,就是可读的、高效的、易维护的,不易出错的。看似最不起眼的工作,却很重要。
下面,我们分为“同步”和“异步”,由简到繁地介绍一下。
同步
同步的意思即是,当前动作执行完才执行下面的动作,不会颠倒。
上面说了,条件判断就是“如果…那么…”,而对这个语义最直接的程序翻译是if-else
if-else
完整语法如下:
1 | if(condition){ |
这里的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 | //变量 |
可以看到,三元运算符最大的特点是简洁,当判断条件和要执行的代码都很简单时,可考虑使用三元运算。
短路运算符
三元运算看起来够简洁了,没有代码段,一行搞定。但短路运算是一种比三元运算更简洁的方式。它通常用于变量赋值。如默认值。
逻辑或(||)
假设有个角色叫xiaoming,获取到个人信息之后需要更新昵称数据,但如果这个数据没有填,为空,则默认展示为‘visitor’,
1 | // if-else实现 |
怎么样,是不是短路更加简洁明了?
逻辑与(&&)
”&&“的运用与”||“有所不同,如果像上面那样使用,它会在第一个变量为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 |
当 x 为真,给y赋值100。
以上两种表达式,可用于替代”分支代码简单“的情况。
而下面这种可以替代部分分支过多的情况。
switch
什么是分支过多呢?
1 | let a; |
分支多并不是业务的问题,属于正常现象,但代码的编写是可以有选择的。
上面的代码可以使用switch改成这样:
1 | switch(a){ |
调整之后,代码的执行和判断均没有发生改变,但写法上更加直观,只是,这不代表所有的多分支if-else都需要或者适合用switch改造,它更适用于上面这种单值匹配判断。
注:switch也不一定是最佳选择,有时候我们可以采用映射表来维护,也可大大简化代码。
上面的代码可称为普通逻辑判断类的流程控制,下面介绍几种循环迭代类控制:
for/for-in/for-of
什么时候会用循环?这涉及到数据类型,必然是具有多个成员的类型才需要,如数组、普通对象,简单类型、函数以及Date这样的特殊对象通常是不需要或者做不到的。
对于循环来说,类型也并不是必要的,只需要一个起点、一个终点,和步幅,就能实现循环。
1 | for(let i = 0;i < 100;i++){ |
但这种用法较为少见,更多的是对真实数据的处理:
1 | let arr = [1,2,3,4,5] |
这段代码的执行使得数组每一项变为原来的两倍。
循环的想象空间是很大的,它是一种机制,你可以在里面做任何想做的事。
值得一提的是,现在for循环已经在很大程度上被前面”数组“章节提到的map、forEach、filter等所替代,但掌握还是必要的。
除了for,for-in和for-of是新版ES增加的成员。
通常使用它们的时候不再需要i这样一个索引值了,也不限于数组,只要具备可迭代的特性都可以。比如:
1 | // 输出字符串的每一个字符 |
看到这,你可能有疑问,它们看起来很像,有什么区别呢?区别大致如下:
- 推荐在遍历对象的时候使用for in ,在遍历数组的时候使用for of 。
- for in 循环出的是key,并且key的类型是string,for of 循环出的是value。
- 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 | // if |
能看出明显的区别吧?
这个特点使得while在很多算法问题的解决中很常用。
而do-while语句是while的变形,是一种后测试循环语句,循环体内的代码至少执行一次,然后才会对退出条件进行求值,不再举例。
return、continue、break
循环固然强大,但很多时候我们并不需要从头遍历到尾,或者只对其中部分项进行处理,其他项不处理。这就需要合适的退出/中断方法了。这个小功能有时很重要。
return
三者当中,return是最常见的,比如函数中的返回值,也可用于拦截其后面的语句执行。比如这样一段代码。
1 | let testTag = true |
当 testTag 为true时,if语句直接return,就不会看到console语句的执行结果了。
这种通常用于在执行一段代码的时候排除前置条件,比如文章开头提到的”有没有广告“的问题,就可以是这样:
1 | let isVip = false |
break/continue
break和continue语句常用于循环类代码中。其中,break代表立即退出循环,执行循环后的下一条语句。而continue同样是立即退出循环,但会再次从循环顶部开始执行。
中断循环的场景是比较少的,提一个最近碰到的吧,即用户通过某种行为触发了一系列的请求,这些请求是按顺序执行的,需要过程,在过程中,一旦用户选择取消,即需要中止循环。
示例如下:1
2
3
4
5
6let cancelRequest = false,
taskList = [];
for(let i = 0;i < taskList.length;i++){
if(cancelRequest) break;
taskList[i]
}
这段代码执行的时候,用户无操作,会一直执行,一旦用户点击取消,将cancelRequest置为true,就会中断。
而continue可用于筛选部分符合条件的执行,不符合的不执行。
示例如下:1
2
3
4for(let i = 0;i < taskList.length;i++){
if(i!== 0 && i % 2 === 0) continue;
// ...
}
这段代码就将数组中索引位是偶数的排除在外了。
聊完了”同步“,下面聊”异步“。
异步
可能有些读者不知道二者的区别是什么。
简单描述,异步就是——“你走你的,不影响,过会儿来看结果”。
如果还不理解:”老板,饼你先做着,我去买点东西,过会儿来拿“。
这里表明了两点:
- 不需要停下来等
- 指令已经发出,等的不是动作,是结果
至于“过会儿”是多久,有时由你决定,有时由要做的事决定。来看看究竟。
定时器
”定时器“就是一种由你决定的异步,是最直接的等待,给个时间,告诉它等多久。
setTimeout
可以传入一个回调函数或者一个字符串,加上一个delay(延迟的毫秒数)
1 | setTimeout(fn,delay) |
上面这段代码,意思就是,等1秒后“执行动作”。
简单的一段代码,其实藏有两个玄机:
- 如果只执行一次,可以直接调用setTimeout,timeoutID是不必要的,它的用途是在清除定时器,如果需要的话。
- 它并不会严格地在1秒后执行动作,1秒只代表尽可能快的执行时间(哪怕是写成0,也可能不会立即执行)。
至于为什么,稍微了解浏览器运行机制的人应该知道,有很多不同种类的任务在按照一定规则执行着,比如:脚本、渲染、异步程序等,定时器也有专门的”管家“,当优先级更高的任务尚未执行完的时候,即使到了指定时间,”动作“仍不会被执行。
setInterval
跟setTimeout很像,区别是,setTimeout只会执行一次,而setInterval执行多次,不再赘述。
值得注意的是,随着现代JavaScript广泛的应用,定时器逐渐被认为是过时的、不被信任的。
为什么“过时”,因为以前的工具较少,需要过一段时间执行的动作都由定时器来负责,随着新的特性逐一被添加进来,适合的应用场景就变少了,有更新更好的方案替代,如CSS动画,requestAnimationFrame()等。
“不被信任”体现在,少量开发者会将它用在揣测代码的执行时间上,比如,一段代码执行后好像没拿到结果,那么多久之后能拿到呢,应该在1秒后?于是写了个时间间隔为1秒钟的定时器来执行后续代码,这显然有问题。
虽然如此,学习它是必要的,在需要固定时间间隔的场景,如:防抖、节流等,还是会用。
XMLHttpRequest
XMLHttpRequest也属于老牌王者一类了,曾引领了web开发的新潮流,虽然现在有了fetch,但不论是直接用,还是Axios,仍有很多项目在用它。至于它的异步特性应该没人有疑问吧,因为它本身就是用来向服务端拉取数据的,很符合“发指令,等待结果”的定义。
关于它的使用,【轻聊前端】网页动态化,前后通信的发展历程中有介绍,细节不再赘述。
但它在实际运用有一个比较明显的缺点,当请求结果需要相互依赖的时候,代码会变成这样:
1 | $ajax({ |
这就是臭名昭著的“回调地狱”。你可能会说,ajax太罪恶了。
慢着,这个锅ajax可能不背,因为好像不是ajax导致的?
我们完全可以把三个请求都抽出来单独执行,对吧?这是代码组织的问题,但那样会带来什么新的问题呢?代码关系、逻辑联系的梳理,而且会变成硬编码,万一有变化…
所以,这个问题的归因是:缺少一种结果反馈机制为我们带来回调的延续性。
而这,就是promise。
promise
从本意上讲,它是承诺,承诺过一段时间会给结果。
promise有三种状态:pending(等待态),fulfiled(成功态),rejected(失败态)。跟需求完全契合。
关于proimse,足够单开一篇文介绍,这里只举两个简单示例。
- 使用方式之一
用promise包裹异步代码,用resolve(成功态)通知执行结果。
1 | let p = new Promise((resolve,reject)=>{ |
- 解决回调地狱
1 | function requestFunc(url,type){ |
这样以来,不仅解决了回调地狱的问题,也无需花太多功夫去梳理它们的关系。
关于promise的介绍先到此,需要提示的是,promise不仅是一种需要定义的东西,而是一种机制,你可能会在很多地方看到它的身影,比如:“xxx返回一个promise”,大多数新加入JS的异步API 都是建立在Promise 之上的,包括下面介绍的这位。
async/await
async:异步,await:等待。
二者结合,即等待异步代码的执行结果。最常见的用途就是,用同步代码的写法来执行异步。
示例代码:
1 | function resolveAfter2Seconds() { |
如果这是一段老代码,没有使用await,result的打印值会是undefined,因为resolveAfter2Seconds中的代码异步执行,在执行console的时候结果并没有得到,但因为用了async/await,对代码进行了阻塞,使得result返回结果后才执行console,就能得到期望的结果。这便是它的威力所在了。
事件
把事件放到这个话题,好像有点乱入,其实并没有,事件也是流程控制的重要组成部分,想想吧,“点击事件、滚动事件、加载事件、打开、关闭事件”,我们的程序中遍布着事件。
如果没有这些事件,我们无从知道一些代码的正确执行时机到底在哪里,事件本身可看做是“发布订阅”的模式,先给某元素绑定事件(订阅),然后在动作发生的时候执行回调(发布)。知道这一点很重要,因为很多时候,都应该在回调中进行下一步操作,而不是在可能引起错误的其他地方。
下面就来看看“错误”。
错误处理
我们希望自己写的代码都能顺利执行,请求也成功返回,但总有异常发生,如果我们不知道,没有做任何处理,给用户的反馈就是生硬的、突兀的,使体验打折扣,更严重的,会造成线上bug。
前面说的条件判断、短路运算,是规避代码异常的机制之一,但更多时候,我们需要其他的方法。
首先要有捕获机制,常用的是这几种:
- error事件:原生捕获事件,可以拿到报错代码的相关信息(框架里也有类似的方法)。
- Promise.catch:promise的捕获回调,可进行请求异常的处理。
- try/catch:也是较为通用的捕获机制,可以将运行的代码段放在try{}里,使用catch捕获。
- 响应拦截器:使用axios时会用到,用于对请求的通用性错误做全局处理。
需要注意的是,并不是所有错误都会被程序抛出,当不能捕获时,就需要手动抛出错误。
总结
一份好代码,不仅要实现强大/炫酷的功能,更需要讲求”组织清晰,杂而不乱“,最基本的代码组织,便是本文所介绍的流程控制了。写好了人人叫好,写得不好只能被吐槽。
文中用了不少生活中的例子,这进一步说明编程并未脱离生活,仅仅是使用编程语言来解决“数字化”的问题罢了。
最后,摘一段《华罗庚》中的内容结束此文:
“事实上,’统筹法’在日常生活中就有很多应用。譬如,早晨起来煮牛奶喝,火已经生了,牛奶也拿来了,大家说应该怎样安排省时间?”
第一种办法是先洗好锅,便煮奶,此时一边刷牙、洗脸,一边在旁边等候,等奶煮好了,便可享用。
第二种办法是先刷牙、洗脸,等这些做完了,再洗锅、煮奶,等候奶煮好。哪一种办法省时间?
大家不约而同地说道:“第一种办法省时间。”华罗庚满意地点点头,接着问道:“大家想一想,能不能把第一种办法再改进一下,让它变得更有效呢?华罗庚说道:“咱们按着第一种办法,牛奶煮好了,太热,需要再放上一会儿,晾凉点再喝。我们可以趁着这个机会去准备上班用的物品,当一切都收拾妥当后,奶也凉了。我们喝完奶后,就可以高高兴兴地上班了。”