前端高频面试题集锦
代码输出结果
输出结果: 2 1 1
解析:
obj.foo(),foo 的 this 指向 obj 对象,所以 a 会输出 2;
obj.bar(),printA 在 bar 方法中执行,所以此时 printA 的 this 指向的是 window,所以会输出 1;
foo(),foo 是在全局对象中执行的,所以其 this 指向的是 window,所以会输出 1;
对 Service Worker 的理解
Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker 的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。
Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install
事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。以下是这个步骤的实现:
打开页面,可以在开发者工具中的 Application
看到 Service Worker 已经启动了: 在 Cache 中也可以发现所需的文件已被缓存:
为什么需要浏览器缓存?
对于浏览器的缓存,主要针对的是前端的静态资源,最好的效果就是,在发起请求之后,拉取相应的静态资源,并保存在本地。如果服务器的静态资源没有更新,那么在下次请求的时候,就直接从本地读取即可,如果服务器的静态资源已经更新,那么我们再次请求的时候,就到服务器拉取新的资源,并保存在本地。这样就大大的减少了请求的次数,提高了网站的性能。这就要用到浏览器的缓存策略了。
所谓的浏览器缓存指的是浏览器将用户请求过的静态资源,存储到电脑本地磁盘中,当浏览器再次访问时,就可以直接从本地加载,不需要再去服务端请求了。
使用浏览器缓存,有以下优点:
减少了服务器的负担,提高了网站的性能
加快了客户端网页的加载速度
减少了多余网络数据传输
connect 组件原理分析
1. connect 用法
作用:连接
React
组件与Redux store
这个函数的第一个参数就是
Redux
的store
,我们从中摘取了count
属性。你不必将state
中的数据原封不动地传入组件,可以根据state
中的数据,动态地输出组件需要的(最小)属性函数的第二个参数
ownProps
,是组件自己的props
当
state
变化,或者ownProps
变化的时候,mapStateToProps
都会被调用,计算出一个新的stateProps
,(在与ownProps merge
后)更新给组件
connect
的第二个参数是mapDispatchToProps
,它的功能是,将action
作为props
绑定到组件上,也会成为MyComp
的 `props
2. 原理解析
首先
connect
之所以会成功,是因为Provider
组件
在原应用组件上包裹一层,使原来整个应用成为
Provider
的子组件接收
Redux
的store
作为props
,通过context
对象传递给子孙组件上的connect
connect 做了些什么
它真正连接
Redux
和React
,它包在我们的容器组件的外一层,它接收上面Provider
提供的store
里面的state
和dispatch
,传给一个构造函数,返回一个对象,以属性形式传给我们的容器组件
3. 源码
connect
是一个高阶函数,首先传入mapStateToProps
、mapDispatchToProps
,然后返回一个生产Component
的函数(wrapWithConnect
),然后再将真正的Component
作为参数传入wrapWithConnect
,这样就生产出一个经过包裹的Connect
组件,该组件具有如下特点
通过
props.store
获取祖先Component
的store props
包括stateProps
、dispatchProps
、parentProps
,合并在一起得到nextState
,作为props
传给真正的Component
componentDidMount
时,添加事件this.store.subscribe(this.handleChange)
,实现页面交互shouldComponentUpdate
时判断是否有避免进行渲染,提升页面性能,并得到nextState
componentWillUnmount
时移除注册的事件this.handleChange
事件流
事件流是网页元素接收事件的顺序,"DOM2 级事件"规定的事件流包括三个阶段:事件捕获阶段、处于目标阶段、事件冒泡阶段。首先发生的事件捕获,为截获事件提供机会。然后是实际的目标接受事件。最后一个阶段是时间冒泡阶段,可以在这个阶段对事件做出响应。虽然捕获阶段在规范中规定不允许响应事件,但是实际上还是会执行,所以有两次机会获取到目标对象。
当容器元素及嵌套元素,即在捕获阶段
又在冒泡阶段
调用事件处理程序时:事件按 DOM 事件流的顺序执行事件处理程序:
父级捕获
子级捕获
子级冒泡
父级冒泡
且当事件处于目标阶段时,事件调用顺序决定于绑定事件的书写顺序,按上面的例子为,先调用冒泡阶段的事件处理程序,再调用捕获阶段的事件处理程序。依次 alert 出“子集冒泡”,“子集捕获”。
对浏览器内核的理解
浏览器内核主要分成两部分:
渲染引擎的职责就是渲染,即在浏览器窗口中显示所请求的内容。默认情况下,渲染引擎可以显示 html、xml 文档及图片,它也可以借助插件显示其他类型数据,例如使用 PDF 阅读器插件,可以显示 PDF 格式。
JS 引擎:解析和执行 javascript 来实现网页的动态效果。
最开始渲染引擎和 JS 引擎并没有区分的很明确,后来 JS 引擎越来越独立,内核就倾向于只指渲染引擎。
参考 前端进阶面试题详细解答
什么是作用域链?
首先要了解作用域链,当访问一个变量时,编译器在执行这段代码时,会首先从当前的作用域中查找是否有这个标识符,如果没有找到,就会去父作用域查找,如果父作用域还没找到继续向上查找,直到全局作用域为止,,而作用域链,就是有当前作用域与上层作用域的一系列变量对象组成,它保证了当前执行的作用域对符合访问权限的变量和函数的有序访问。
内存泄露
意外的全局变量: 无法被回收
定时器: 未被正确关闭,导致所引用的外部变量无法被释放
事件监听: 没有正确销毁 (低版本浏览器可能出现)
闭包
第一种情况是我们由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收。
第二种情况是我们设置了 setInterval 定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收。
第三种情况是我们获取一个 DOM 元素的引用,而后面这个元素被删除,由于我们一直保留了对这个元素的引用,所以它也无法被回收。
第四种情况是不合理的使用闭包,从而导致某些变量一直被留在内存当中。
dom
引用:dom
元素被删除时,内存中的引用未被正确清空控制台
console.log
打印的东西
可用
chrome
中的timeline
进行内存标记,可视化查看内存的变化情况,找出异常点。
代码输出结果
输出结果如下:
可以看到。catch 捕获到了第一个错误,在这道题目中最先的错误就是runReject(2)
的结果。如果一组异步操作中有一个异常都不会进入.then()
的第一个回调函数参数中。会被.then()
的第二个回调函数捕获。
上下垂直居中方案
定高:
margin
,position + margin
(负值)不定高:
position
+transform
,flex
,IFC + vertical-align:middle
Number() 的存储空间是多大?如果后台发送了一个超过最大自己的数字怎么办
Math.pow(2, 53) ,53 为有效数字,会发生截断,等于 JS 能支持的最大数字。
类型及检测方式
1. JS 内置类型
JavaScript 的数据类型有下图所示
其中,前 7 种类型为基础类型,最后
1 种(Object)为引用类型
,也是你需要重点关注的,因为它在日常工作中是使用得最频繁,也是需要关注最多技术细节的数据类型
JavaScript
一共有 8 种数据类型,其中有 7 种基本数据类型:Undefined
、Null
、Boolean
、Number
、String
、Symbol
(es6
新增,表示独一无二的值)和BigInt
(es10
新增);1 种引用数据类型——
Object
(Object 本质上是由一组无序的名值对组成的)。里面包含function、Array、Date
等。JavaScript 不支持任何创建自定义类型的机制,而所有值最终都将是上述 8 种数据类型之一。引用数据类型: 对象
Object
(包含普通对象-Object
,数组对象-Array
,正则对象-RegExp
,日期对象-Date
,数学函数-Math
,函数对象-Function
)
在这里,我想先请你重点了解下面两点,因为各种 JavaScript 的数据类型最后都会在初始化之后放在不同的内存中,因此上面的数据类型大致可以分成两类来进行存储:
原始数据类型 :基础类型存储在栈内存,被引用或拷贝时,会创建一个完全相等的变量;占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储。
引用数据类型 :引用类型存储在堆内存,存储的是地址,多个引用指向同一个地址,这里会涉及一个“共享”的概念;占据空间大、大小不固定。引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
JavaScript 中的数据是如何存储在内存中的?
在 JavaScript 中,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。
在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间
、栈空间
、堆空间
。其中的代码空间主要是存储可执行代码的,原始类型(Number、String、Null、Undefined、Boolean、Symbol、BigInt
)的数据值都是直接保存在“栈”中的,引用类型(Object)的值是存放在“堆”中的。因此在栈空间中(执行上下文),原始类型存储的是变量的值,而引用类型存储的是其在"堆空间"中的地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程。
在编译过程中,如果 JavaScript 引擎判断到一个闭包,也会在堆空间创建换一个“closure(fn)”
的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存闭包中的变量。所以闭包中的变量是存储在“堆空间”中的。
JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。因此需要“栈”和“堆”两种空间。
题目一:初出茅庐
这道题比较简单,我们可以看到第一个 console 打出来 name 是 'lee',这应该没什么疑问;但是在执行了 b.name='son' 之后,结果你会发现 a 和 b 的属性 name 都是 'son',第二个和第三个打印结果是一样的,这里就体现了引用类型的“共享”的特性,即这两个值都存在同一块内存中共享,一个发生了改变,另外一个也随之跟着变化。
你可以直接在 Chrome 控制台敲一遍,深入理解一下这部分概念。下面我们再看一段代码,它是比题目一稍复杂一些的对象属性变化问题。
题目二:渐入佳境
这道题涉及了 function
,你通过上述代码可以看到第一个 console
的结果是 30
,b
最后打印结果是 {name: "Kath", age: 30}
;第二个 console
的返回结果是 24
,而 a
最后的打印结果是 {name: "Julia", age: 24}
。
是不是和你预想的有些区别?你要注意的是,这里的 function
和 return
带来了不一样的东西。
原因在于:函数传参进来的
o
,传递的是对象在堆中的内存地址值,通过调用o.age = 24
(第 7 行代码)确实改变了a
对象的age
属性;但是第 12 行代码的return
却又把o
变成了另一个内存地址,将{name: "Kath", age: 30}
存入其中,最后返回b
的值就变成了{name: "Kath", age: 30}
。而如果把第 12 行去掉,那么b
就会返回undefined
2. 数据类型检测
(1)typeof
typeof 对于原始类型来说,除了 null 都可以显示正确的类型
typeof
对于对象来说,除了函数都会显示object
,所以说typeof
并不能准确判断变量到底是什么类型,所以想判断一个对象的正确类型,这时候可以考虑使用instanceof
(2)instanceof
instanceof
可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的prototype
instanceof
可以准确地判断复杂引用数据类型,但是不能正确判断基础数据类型;而
typeof
也存在弊端,它虽然可以判断基础数据类型(null
除外),但是引用数据类型中,除了function
类型以外,其他的也无法判断
(3)constructor
这里有一个坑,如果我创建一个对象,更改它的原型,
constructor
就会变得不可靠了
(4)Object.prototype.toString.call()
toString()
是Object
的原型方法,调用该方法,可以统一返回格式为“[object Xxx]”
的字符串,其中Xxx
就是对象的类型。对于Object
对象,直接调用toString()
就能返回[object Object]
;而对于其他对象,则需要通过call
来调用,才能返回正确的类型信息。我们来看一下代码。
实现一个全局通用的数据类型判断方法,来加深你的理解,代码如下
小结
typeof
直接在计算机底层基于数据类型的值(二进制)进行检测
typeof null
为object
原因是对象存在在计算机中,都是以000
开始的二进制存储,所以检测出来的结果是对象typeof
普通对象/数组对象/正则对象/日期对象 都是object
typeof NaN === 'number'
instanceof
检测当前实例是否属于这个类的
底层机制:只要当前类出现在实例的原型上,结果都是 true
不能检测基本数据类型
constructor
支持基本类型
constructor 可以随便改,也不准
Object.prototype.toString.call([val])
返回当前实例所属类信息
判断
Target
的类型,单单用typeof
并无法完全满足,这其实并不是bug
,本质原因是JS
的万物皆对象的理论。因此要真正完美判断时,我们需要区分对待:
基本类型(
null
): 使用String(null)
基本类型(
string / number / boolean / undefined
) +function
: - 直接使用typeof
即可其余引用类型(
Array / Date / RegExp Error
): 调用toString
后根据[object XXX]
进行判断
3. 数据类型转换
我们先看一段代码,了解下大致的情况。
首先我们要知道,在
JS
中类型转换只有三种情况,分别是:
转换为布尔值
转换为数字
转换为字符串
转 Boolean
在条件判断时,除了
undefined
,null
,false
,NaN
,''
,0
,-0
,其他所有值都转为true
,包括所有对象
对象转原始类型
对象在转换类型的时候,会调用内置的
[[ToPrimitive]]
函数,对于该函数来说,算法逻辑一般来说如下
如果已经是原始类型了,那就不需要转换了
调用
x.valueOf()
,如果转换为基础类型,就返回转换的值调用
x.toString()
,如果转换为基础类型,就返回转换的值如果都没有返回原始类型,就会报错
当然你也可以重写
Symbol.toPrimitive
,该方法在转原始类型时调用优先级最高。
四则运算符
它有以下几个特点:
运算中其中一方为字符串,那么就会把另一方也转换为字符串
如果一方不是字符串或者数字,那么会将它转换为数字或者字符串
对于第一行代码来说,触发特点一,所以将数字
1
转换为字符串,得到结果'11'
对于第二行代码来说,触发特点二,所以将
true
转为数字1
对于第三行代码来说,触发特点二,所以将数组通过
toString
转为字符串1,2,3
,得到结果41,2,3
另外对于加法还需要注意这个表达式
'a' + + 'b'
因为
+ 'b'
等于NaN
,所以结果为"aNaN"
,你可能也会在一些代码中看到过+ '1'
的形式来快速获取number
类型。那么对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字
比较运算符
如果是对象,就通过
toPrimitive
转换对象如果是字符串,就通过
unicode
字符索引来比较
在以上代码中,因为
a
是对象,所以会通过valueOf
转换为原始类型再比较值。
强制类型转换
强制类型转换方式包括
Number()
、parseInt()
、parseFloat()
、toString()
、String()
、Boolean()
,这几种方法都比较类似
Number()
方法的强制转换规则如果是布尔值,
true
和false
分别被转换为1
和0
;如果是数字,返回自身;
如果是
null
,返回0
;如果是
undefined
,返回NaN
;如果是字符串,遵循以下规则:如果字符串中只包含数字(或者是
0X / 0x
开头的十六进制数字字符串,允许包含正负号),则将其转换为十进制;如果字符串中包含有效的浮点格式,将其转换为浮点数值;如果是空字符串,将其转换为0
;如果不是以上格式的字符串,均返回 NaN;如果是
Symbol
,抛出错误;如果是对象,并且部署了
[Symbol.toPrimitive]
,那么调用此方法,否则调用对象的valueOf()
方法,然后依据前面的规则转换返回的值;如果转换的结果是NaN
,则调用对象的toString()
方法,再次依照前面的顺序转换返回对应的值。
Object 的转换规则
对象转换的规则,会先调用内置的
[ToPrimitive]
函数,其规则逻辑如下:
如果部署了
Symbol.toPrimitive
方法,优先调用再返回;调用
valueOf()
,如果转换为基础类型,则返回;调用
toString()
,如果转换为基础类型,则返回;如果都没有返回基础类型,会报错。
'==' 的隐式类型转换规则
如果类型相同,无须进行类型转换;
如果其中一个操作值是
null
或者undefined
,那么另一个操作符必须为null
或者undefined
,才会返回true
,否则都返回false
;如果其中一个是
Symbol
类型,那么返回false
;两个操作值如果为
string
和 number 类型,那么就会将字符串转换为number
;如果一个操作值是
boolean
,那么转换成number
;如果一个操作值为
object
且另一方为string
、number
或者symbol
,就会把object
转为原始类型再进行判断(调用object
的valueOf/toString
方法进行转换)。
'+' 的隐式类型转换规则
'+' 号操作符,不仅可以用作数字相加,还可以用作字符串拼接。仅当 '+' 号两边都是数字时,进行的是加法运算;如果两边都是字符串,则直接拼接,无须进行隐式类型转换。
如果其中有一个是字符串,另外一个是
undefined
、null
或布尔型,则调用toString()
方法进行字符串拼接;如果是纯对象、数组、正则等,则默认调用对象的转换方法会存在优先级,然后再进行拼接。如果其中有一个是数字,另外一个是
undefined
、null
、布尔型或数字,则会将其转换成数字进行加法运算,对象的情况还是参考上一条规则。如果其中一个是字符串、一个是数字,则按照字符串规则进行拼接
整体来看,如果数据中有字符串,JavaScript 类型转换还是更倾向于转换成字符串,因为第三条规则中可以看到,在字符串和数字相加的过程中最后返回的还是字符串,这里需要关注一下
null 和 undefined 的区别?
首先
Undefined
和Null
都是基本数据类型,这两个基本数据类型分别都只有一个值,就是undefined
和null
。undefined
代表的含义是未定义,null
代表的含义是空对象(其实不是真的对象,请看下面的注意!)。一般变量声明了但还没有定义的时候会返回undefined
,null
主要用于赋值给一些可能会返回对象的变量,作为初始化。
其实 null 不是对象,虽然 typeof null 会输出 object,但是这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。
undefined 在 js 中不是一个保留字,这意味着我们可以使用
undefined
来作为一个变量名,这样的做法是非常危险的,它会影响我们对 undefined 值的判断。但是我们可以通过一些方法获得安全的undefined
值,比如说void 0
。当我们对两种类型使用 typeof 进行判断的时候,Null 类型化会返回 “object”,这是一个历史遗留的问题。当我们使用双等号对两种类型的值进行比较时会返回 true,使用三个等号时会返回 false。
浏览器
浏览器架构
单进程浏览器时代
单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。其实早在 2007 年之前,市面上浏览器都是单进程的
缺点
不稳定:一个插件的意外崩溃会引起整个浏览器的崩溃
不流畅:所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行
不安全:可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题
以上这些就是当时浏览器的特点,
不稳定,不流畅,而且不安全
多进程浏览器时代
由于
进程是相互隔离的
,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题JavaScript 也是运行在渲染进程中的,所以即使 JavaScript 阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中的
Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。
最新的 Chrome 浏览器包括:
1个浏览器(Browser)主进程
、1个 GPU 进程
、1个网络(NetWork)进程
、多个渲染进程
和多个插件进程
浏览器进程 。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
渲染进程 。核心任务是将
HTML、CSS
和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。GPU 进程 。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
网络进程 。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
插件进程 。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响
如何避免 React 生命周期中的坑
16.3 版本
>=16.4 版本
在线查看 :https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram(opens new window)
避免生命周期中的坑需要做好两件事:不在恰当的时候调用了不该调用的代码;在需要调用时,不要忘了调用。
那么主要有这么 7 种情况容易造成生命周期的坑
getDerivedStateFromProps
容易编写反模式代码,使受控组件与非受控组件区分模糊componentWillMount
在 React 中已被标记弃用,不推荐使用,主要原因是新的异步渲染架构会导致它被多次调用
。所以网络请求及事件绑定代码应移至componentDidMount
中。componentWillReceiveProps
同样被标记弃用,被getDerivedStateFromProps
所取代,主要原因是性能问题shouldComponentUpdate
通过返回true
或者false
来确定是否需要触发新的渲染。主要用于性能优化componentWillUpdate
同样是由于新的异步渲染机制,而被标记废弃,不推荐使用,原先的逻辑可结合getSnapshotBeforeUpdate
与componentDidUpdate
改造使用。如果在
componentWillUnmount
函数中忘记解除事件绑定,取消定时器等清理操作,容易引发 bug如果没有添加错误边界处理,当渲染发生异常时,用户将会看到一个无法操作的白屏,所以一定要添加
“React 的请求应该放在哪里,为什么?” 这也是经常会被追问的问题。你可以这样回答。
对于异步请求,应该放在 componentDidMount
中去操作。从时间顺序来看,除了 componentDidMount
还可以有以下选择:
constructor:可以放,但从设计上而言不推荐。constructor 主要用于初始化 state 与函数绑定,并不承载业务逻辑。而且随着类属性的流行,constructor 已经很少使用了
componentWillMount:已被标记废弃,在新的异步渲染架构下会触发多次渲染,容易引发 Bug,不利于未来 React 升级后的代码维护。
所以 React 的请求放在
componentDidMount 里是最好的选择
。
透过现象看本质:React 16 缘何两次求变?
Fiber 架构简析
Fiber 是 React 16 对 React 核心算法的一次重写。你只需要 get 到这一个点:
Fiber 会使原本同步的渲染过程变成异步的
。
在 React 16 之前,每当我们触发一次组件的更新,React 都会构建一棵新的虚拟 DOM 树,通过与上一次的虚拟 DOM 树进行 diff,实现对 DOM 的定向更新。这个过程,是一个递归的过程。下面这张图形象地展示了这个过程的特征:
如图所示,同步渲染的递归调用栈是非常深的,只有最底层的调用返回了,整个渲染过程才会开始逐层返回
。这个漫长且不可打断的更新过程,将会带来用户体验层面的巨大风险:同步渲染一旦开始,便会牢牢抓住主线程不放,直到递归彻底完成
。在这个过程中,浏览器没有办法处理任何渲染之外的事情,会进入一种无法处理用户交互的状态。因此若渲染时间稍微长一点,页面就会面临卡顿甚至卡死的风险。
而 React 16 引入的 Fiber 架构,恰好能够解决掉这个风险:Fiber 会将一个大的更新任务拆解为许多个小任务
。每当执行完一个小任务时,渲染线程都会把主线程交回去
,看看有没有优先级更高的工作要处理,确保不会出现其他任务被“饿死”的情况,进而避免同步渲染带来的卡顿。在这个过程中,渲染线程不再“一去不回头”,而是可以被打断的,这就是所谓的“异步渲染”,它的执行过程如下图所示:
换个角度看生命周期工作流
Fiber 架构的重要特征就是可以被打断的异步渲染模式。但这个“打断”是有原则的,根据“能否被打断”这一标准,React 16 的生命周期被划分为了 render 和 commit 两个阶段
,而 commit 阶段又被细分为了 pre-commit 和 commit
。每个阶段所涵盖的生命周期如下图所示:
我们先来看下三个阶段各自有哪些特征
render 阶段
:纯净且没有副作用,可能会被 React 暂停、终止或重新启动。pre-commit 阶段
:可以读取 DOM。commit 阶段
:可以使用 DOM,运行副作用,安排更新。
总的来说,render 阶段在执行过程中允许被打断,而 commit 阶段则总是同步执行的。
为什么这样设计呢?简单来说,
由于 render 阶段的操作对用户来说其实是“不可见”的,所以就算打断再重启,对用户来说也是零感知
。而commit 阶段的操作则涉及真实 DOM 的渲染
,所以这个过程必须用同步渲染来求稳
。
说一说正向代理和反向代理
正向代理
我们常说的代理也就是指正向代理,正向代理的过程,它隐藏了真实的请求客户端,服务端不知道真实的客户端是谁,客户端请求的服务都被代理服务器代替来请求。
反向代理
这种代理模式下,它隐藏了真实的服务端,当我们向一个网站发起请求的时候,背后可能有成千上万台服务器为我们服务,具体是哪一台,我们不清楚,我们只需要知道反向代理服务器是谁就行,而且反向代理服务器会帮我们把请求转发到真实的服务器那里去,一般而言反向代理服务器一般用来实现负载平衡。
负载平衡的两种实现方式?
一种是使用反向代理的方式,用户的请求都发送到反向代理服务上,然后由反向代理服务器来转发请求到真实的服务器上,以此来实现集群的负载平衡。
另一种是 DNS 的方式,DNS 可以用于在冗余的服务器上实现负载平衡。因为现在一般的大型网站使用多台服务器提供服务,因此一个域名可能会对应多个服务器地址。当用户向网站域名请求的时候,DNS 服务器返回这个域名所对应的服务器 IP 地址的集合,但在每个回答中,会循环这些 IP 地址的顺序,用户一般会选择排在前面的地址发送请求。以此将用户的请求均衡的分配到各个不同的服务器上,这样来实现负载均衡。这种方式有一个缺点就是,由于 DNS 服务器中存在缓存,所以有可能一个服务器出现故障后,域名解析仍然返回的是那个 IP 地址,就会造成访问的问题。
定时器与 requestAnimationFrame、requestIdleCallback
1. setTimeout
setTimeout 的运行机制:执行该语句时,是立即把当前定时器代码推入事件队列,当定时器在事件列表中满足设置的时间值时将传入的函数加入任务队列,之后的执行就交给任务队列负责。但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间
输出 2, 1;
setTimeout
的第二个参数表示在执行代码前等待的毫秒数。上面代码中,设置为 0,表面意思为 执行代码前等待的毫秒数为 0,即立即执行。但实际上的运行结果我们也看到了,并不是表面上看起来的样子,千万不要被欺骗了。
实际上,上面的代码并不是立即执行的,这是因为setTimeout
有一个最小执行时间,HTML5 标准规定了setTimeout()
的第二个参数的最小值(最短间隔)不得低于4毫秒
。 当指定的时间低于该时间时,浏览器会用最小允许的时间作为setTimeout
的时间间隔,也就是说即使我们把setTimeout
的延迟时间设置为 0,实际上可能为 4毫秒后才事件推入任务队列
。
定时器代码在被推送到任务队列前,会先被推入到事件列表中,当定时器在事件列表中满足设置的时间值时会被推到任务队列,但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间
上面代码表示100ms
后执行console.log(111)
,但实际上实行的时间肯定是大于 100ms 后的, 100ms 只是表示 100ms 后将任务加入到"任务队列"中,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()
指定的时间执行。
2. setTimeout 和 setInterval 区别
setTimeout
: 指定延期后调用函数,每次setTimeout
计时到后就会去执行,然后执行一段时间后才继续setTimeout
,中间就多了误差,(误差多少与代码的执行时间有关)。setInterval
:以指定周期调用函数,而setInterval
则是每次都精确的隔一段时间推入一个事件(但是,事件的执行时间不一定就不准确,还有可能是这个事件还没执行完毕,下一个事件就来了).
击该按钮后,首先将
onclick
事件处理程序加入队列。该程序执行后才设置定时器,再有250ms
后,指定的代码才被添加到队列中等待执行。 如果上面代码中的onclick
事件处理程序执行了300ms
,那么定时器的代码至少要在定时器设置之后的300ms
后才会被执行。队列中所有的代码都要等到 javascript 进程空闲之后才能执行,而不管它们是如何添加到队列中的。
如图所示,尽管在255ms
处添加了定时器代码,但这时候还不能执行,因为onclick
事件处理程序仍在运行。定时器代码最早能执行的时机是在300ms
处,即onclick
事件处理程序结束之后。
3. setInterval 存在的一些问题:
JavaScript 中使用 setInterval
开启轮询。定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。而 javascript 引擎对这个问题的解决是:当使用setInterval()
时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。
但是,这样会导致两个问题:
某些间隔被跳过;
多个定时器的代码执行之间的间隔可能比预期的小
假设,某个onclick
事件处理程序使用setInterval()
设置了200ms
间隔的定时器。如果事件处理程序花了300ms
多一点时间完成,同时定时器代码也花了差不多的时间,就会同时出现跳过某间隔的情况
例子中的第一个定时器是在205ms
处添加到队列中的,但是直到过了300ms
处才能执行。当执行这个定时器代码时,在 405ms 处又给队列添加了另一个副本。在下一个间隔,即 605ms 处,第一个定时器代码仍在运行,同时在队列中已经有了一个定时器代码的实例。结果是,在这个时间点上的定时器代码不会被添加到队列中
使用setTimeout
构造轮询能保证每次轮询的间隔。
callee
是arguments
对象的一个属性。它可以用于引用该函数的函数体内当前正在执行的函数。在严格模式下,第 5 版 ECMAScript (ES5) 禁止使用arguments.callee()
。当一个函数必须调用自身的时候, 避免使用arguments.callee()
, 通过要么给函数表达式一个名字,要么使用一个函数声明.
这个模式链式调用了setTimeout()
,每次函数执行的时候都会创建一个新的定时器。第二个setTimeout()
调用当前执行的函数,并为其设置另外一个定时器。这样做的好处是,在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。
4. requestAnimationFrame
4.1 60fps
与设备刷新率
目前大多数设备的屏幕刷新率为60次/秒
,如果在页面中有一个动画或者渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。
卡顿:其中每个帧的预算时间仅比16毫秒
多一点(1秒/ 60 = 16.6毫秒
)。但实际上,浏览器有整理工作要做,因此您的所有工作是需要在10毫秒
内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。此现象通常称为卡顿,会对用户体验产生负面影响。
跳帧: 假如动画切换在 16ms, 32ms, 48ms 时分别切换,跳帧就是假如到了 32ms,其他任务还未执行完成,没有去执行动画切帧,等到开始进行动画的切帧,已经到了该执行 48ms 的切帧。就好比你玩游戏的时候卡了,过了一会,你再看画面,它不会停留你卡的地方,或者这时你的角色已经挂掉了。必须在下一帧开始之前就已经绘制完毕;
Chrome devtool 查看实时 FPS, 打开 More tools => Rendering, 勾选 FPS meter
4.2 requestAnimationFrame
实现动画
requestAnimationFrame
是浏览器用于定时循环操作的一个接口,类似于 setTimeout,主要用途是按帧对网页进行重绘。
在 requestAnimationFrame
之前,主要借助 setTimeout/ setInterval
来编写 JS 动画,而动画的关键在于动画帧之间的时间间隔设置,这个时间间隔的设置有讲究,一方面要足够小,这样动画帧之间才有连贯性,动画效果才显得平滑流畅;另一方面要足够大,确保浏览器有足够的时间及时完成渲染。
显示器有固定的刷新频率(60Hz 或 75Hz),也就是说,每秒最多只能重绘 60 次或 75 次,requestAnimationFrame
的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个 API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了 CPU、GPU 和电力。
requestAnimationFrame
是在主线程上完成。这意味着,如果主线程非常繁忙,requestAnimationFrame
的动画效果会大打折扣。
requestAnimationFrame
使用一个回调函数作为参数。这个回调函数会在浏览器重绘之前调用。
上面的代码按照 1 秒钟 60 次(大约每 16.7 毫秒一次),来模拟requestAnimationFrame
。
5. requestIdleCallback()
MDN 上的解释:
requestIdleCallback()
方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。
requestAnimationFrame
会在每次屏幕刷新的时候被调用,而requestIdleCallback
则会在每次屏幕刷新时,判断当前帧是否还有多余的时间,如果有,则会调用requestAnimationFrame
的回调函数,
图片中是两个连续的执行帧,大致可以理解为两个帧的持续时间大概为 16.67,图中黄色部分就是空闲时间。所以,requestIdleCallback
中的回调函数仅会在每次屏幕刷新并且有空闲时间时才会被调用.
利用这个特性,我们可以在动画执行的期间,利用每帧的空闲时间来进行数据发送的操作,或者一些优先级比较低的操作,此时不会使影响到动画的性能,或者和requestAnimationFrame
搭配,可以实现一些页面性能方面的的优化,
react 的
fiber
架构也是基于requestIdleCallback
实现的, 并且在不支持的浏览器中提供了polyfill
总结
从
单线程模型和任务队列
出发理解setTimeout(fn, 0)
,并不是立即执行。JS 动画, 用
requestAnimationFrame
会比setInterval
效果更好requestIdleCallback()
常用来切割长任务,利用空闲时间执行,避免主线程长时间阻塞
谈谈你对 React 的理解
React 是一个网页 UI 框架,通过组件化的方式解决视图层开发复用的问题,本质是一个组件化框架。
它的核心设计思路有三点,分别是
声明式、组件化与 通用性
。声明式的优势在于直观与组合。
组件化的优势在于视图的拆分与模块复用,可以更容易做到高内聚低耦合。
通用性在于一次学习,随处编写。比如 React Native,React 360 等, 这里主要靠虚拟 DOM 来保证实现。
这使得 React 的适用范围变得足够广,无论是 Web、Native、VR,甚至 Shell 应用都可以进行开发。这也是 React 的优势。
但作为一个视图层的框架,React 的劣势也十分明显。它并没有提供完整的一揽子解决方 案,在开发大型前端应用时,需要向社区寻找并整合解决方案。虽然一定程度上促进了社区的繁荣,但也为开发者在技术选型和学习适用上造成了一定的成本。
承接在优势后,可以再谈一下自己对于 React 优化的看法、对虚拟 DOM 的看法
数组去重
使用 indexOf/includes 实现
使用 filter(forEach) + indexOf/includes 实现
非 API 版本(原生)实现
ES6 使用 Set + 扩展运算符(...)/Array.from() 实现
说一下 HTTP 和 HTTPS 协议的区别?
0.1 + 0.2 === 0.3 嘛?为什么?
JavaScript 使用 Number 类型来表示数字(整数或浮点数),遵循 IEEE 754 标准,通过 64 位来表示一个数字(1 + 11 + 52)
1 符号位,0 表示正数,1 表示负数 s
11 指数位(e)
52 尾数,小数部分(即有效数字)
最大安全数字:Number.MAX_SAFE_INTEGER = Math.pow(2, 53) - 1,转换成整数就是 16 位,所以 0.1 === 0.1,是因为通过 toPrecision(16) 去有效位之后,两者是相等的。
在两数相加时,会先转换成二进制,0.1 和 0.2 转换成二进制的时候尾数会发生无限循环,然后进行对阶运算,JS 引擎对二进制进行截断,所以造成精度丢失。
所以总结:精度丢失可能出现在进制转换和对阶运算中
评论