前端面试基础知识总结(三):JavaScript
JavaScript ( JS ) 是一种具有头等函数
的轻量级,解释型
或即时编译型
的编程语言。虽然它是作为开发 Web 页面的脚本语言而出名的,但是它也被用到了很多非浏览器环境中,例如 Node.js、 Apache CouchDB 和 Adobe Acrobat。JavaScript 是一种基于原型编程
、多范式
、单线程的动态脚本语言
,并且支持面向对象、命令式和声明式(如函数式编程)风格。
开头来自MDN,如果有疑问可以参考下面的关键词解释:
- 头等函数(First-class Function):当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数。例如,在 JavaScript 中,函数可以被当作参数传递给其他函数,可以作为另一个函数的返回值,还可以被赋值给一个变量。
- 解释型(interpreted)编程语言:会将代码一句一句直接运行,不需要像编译语言(如 C++)一样,经过编译器先行编译为机器代码,之后再运行。需要要利用解释器在运行期,动态将代码逐句解释为机器代码,或是已经预先编译为机器代码的子程序,之后再运行。
- 即时编译型(just-in-time compiled):即时编译器(JIT compiler)可以理解为解释器(Interpreter)中的一个组件,对编译器进行效率优化,运行已经预先编译为机器代码的子程序。
- 基于原型编程(prototype-based):原型编程是一种面向对象编程的风格。通过向其它类的实例(对象)中添加属性和方法来创建类,甚至偶尔使用空对象创建类。
- 多范式(multi-paradigm):多种编程范式,常见的有命令式编程(Imperative programming)、函数式编程(Functional programming)、面向对象编程(Object-oriented programming)等。
- 动态脚本语言(dynamic language):动态语言是指在运行时才确定数据类型的语言,变量使用之前不需要类型声明。脚本语言是一种解释性的语言,脚本通常以文本来保存,只要在被调用时进行解释和编译。
总结一下 JavaScript 的特点:
- 解释型语言,动态脚本语言
- 弱类型(不需要类型声明)
- 单线程(异步事件通过事件循环机制处理)
- 跨平台(浏览器、服务器端)
大纲
数据类型
在 JavaScript 规范中,共定义了七种数据类型:
- 基本数据类型:Number、String、Boolean、Undefined、Null、Symbol
- 复杂数据类型:Object
1.String 类型
String 类型用于表示由 n 个 16 位 Unicode
字符组成的字符序列,即字符串。
Unicode 符号集采用 2 个字节(即 16 位 bit)表示一个字符,即最多可以表示 65535 个字符,这样基本上可以覆盖世界上常用的文字。实现了把所有的语言都统一到一套编码中。
1.扩展:UTF-8 和 GBK 的关系?
1.UTF-8 是 Unicode 的实现之一,定义 Unicode 符号集如何存储,是为了提高 16 位编码的使用效率。英文使用一个字节,中文使用三个字节来存储。
2.GBK 英文和中文都使用两个字节来存储。
2.常用操作方法(详见菜鸟教程)
- 字符操作:
charAt(index)
,charCodeAt,fromCharCode - 字符串提取:
substr(start[,length])
,substring ,slice(start[,end])
- 位置索引:
indexOf(searchvalue[,start])
,lastIndexOf - 大小写转换:toLowerCase,toUpperCase
- 模式匹配:
match(regexp)
,search(searchVal)
,replace(searchVal,newVal)
,split([separator[,limit]])
- 其他操作:
concat(string1, string2, ..., stringX)
,trim,localeCompare
3.基本包装类型(String、Number、Boolean 都是)
为了方便的对字符串进行操作,ECMAScript 提供了一个基本包装类型:String 对象 。它是一种特殊的引用类型,JS 引擎每当读取一个字符串的时候,就会在内部创建一个对应的 String 对象,该对象提供了很多操作字符的方法,这就是为什么能对字符串调用方法的原因。
在访问字符串时,JS 引擎内部会自动完成下列处理:
- 创建 String 类型的一个实例
- 在实例上调用指定的方法
- 销毁这个实例
2.Number 类型
双精度 IEEE754 64 位浮点数。JavaScript 能够准确表示的整数范围在 -2^53 到 2^53 之间(不含两个端点),超过这个范围,无法精确表示这个整数。
1.存储结构
2.为什么浮点数运算会有误差?(比如0.1 + 0.2 !== 0.3
)
事实上,这个问题并不只是在 JavaScript 中才会出现,任何使用二进制浮点数的编程语言都会有这个问题。因为十进制转换成二进制再进行运算会丢失精度,计算结果从二进制转换成十进制也会丢失精度。
解决方案:可以把小数转化成整数运算之后再变回小数来解决。
3.数值转换
Number(value)
和 + value
只能对字符串进行整体转换
parseInt(string[, radix])
和parseFloat(string)
可以对字符串进行部分转换,即只转换第一个无效字符之前的字符
4.位运算
ECMAScript 中的数值以 64 位双精度浮点数存储,但位运算只能作用于整数,因此要先将 64 位的浮点数转换成 32 位的整数,然后再进行位运算,最后再将计算结果转换成 64 位浮点数存储。常见的位运算有以下几种:
&
与|
或~
非^
异或<<
左移>>
算数右移(有符号右移) 移位的时候高位补的是其符号位,整数则补 0,负数则补 1>>>
逻辑右移(无符号右移) 右移的时候高位始终补 0
5.四舍五入
- 向上取整:
Math.ceil
- 向下取整:
Math.floor
- 四舍五入:
Math.round
- 固定精度(小数点后 x 位):
toFixed(x)
- 固定长度:
toPrecision(x)
- 取整:
parseInt
、位运算
3.Boolean 类型
Boolean 类型只有两个字面值:true 和 false。在 JavaScript 中,所有类型的值都可以转化为与 Boolean 等价的值。
JavaScript 按照如下规则将变量转换成布尔类型:
- false、0、空字符串(””)、NaN、null 和 undefined 被转换为 false
- 所有其他值被转换为 true
除了使用Boolean()
强制类型转换,下面四种操作也返回 Boolean 值。
1.关系操作符:>,>=,<,<=
当关系操作符的操作数使用了非数值时,要进行数据转换:
- 如果两个操作数都是数值,则执行数值比较。
- 如果两个操作数都是字符串,则逐个比较两者对应的字符编码(charCode),直到分出大小为止 。
- 如果操作数是其他基本类型,则调用 Number() 将其转化为数值,然后进行比较。
- NaN 与任何值比较,均返回 false 。
- 如果操作数是对象,则调用对象的 valueOf 方法(如果没有 valueOf ,就调用 toString 方法),最后用得到的结果,根据前面的规则执行比较。
2.相等操作符: ==,!=,===,!==
=== 和 !== 操作符最大的特点是,在比较之前不转换操作数。不同类型的返回false
,相同类型进行如下比较:
- String:true 仅当两个操作数具有相同顺序的相同字符时才返回。
- Number:true 仅当两个操作数具有相同的值时才返回。+0 并被-0 视为相同的值。如果任一操作数为 NaN,则返回 false。
- Boolean:true 仅当操作数为两个 true 或两个 false 时才返回 true。
== 和 != 操作符都会先转换操作数,然后再比较它们的相等性。在转换不同的数据类型时,需要遵循抽象相等比较算法。大致概况如下:
- 如果两个操作数都是对象,则仅当两个操作数都引用同一个对象时才返回 true。
- 如果一个操作数是 null,另一个操作数是 undefined,则返回 true。
- 如果两个操作数是不同类型的,就会尝试在比较之前将它们转换为相同类型:
- 当数字与字符串进行比较时,会尝试将字符串转换为数字值。
- 如果操作数之一是 Boolean,则将布尔操作数转换为 1 或 0。
- 如果操作数之一是对象,另一个是数字或字符串,会尝试使用对象的 valueOf()和 toString()方法将对象转换为原始值。
- 如果操作数具有相同的类型,则将它们按上面===一样的规则进行比较。
3.布尔操作符:!
同 Boolean()
的规则
PS:利用 ! 的取反的特点,使用 !! 可以很方便的将一个任意类型值转换为布尔值。
4.条件语句:if,while,?
同 Boolean()
的规则
4.Undefined 类型
Undefined 是 Javascript 中特殊的原始数据类型,它只有一个值,即 undefined,字面意思是:未定义的值。它的语义是,希望表示一个变量最原始的状态,而非人为操作的结果。这种原始状态会在以下 4 种场景中出现:
- 声明了一个变量,但没有赋值
- 访问对象上不存在的属性
- 函数定义了形参,但没有传递实参
- 使用 void 对表达式求值:ECMAScript 明确规定 void 操作符 对任何表达式求值都返回 undefined
5.Null 类型
Null 是 Javascript 中特殊的原始数据类型,它只有一个值,即 null,字面意思是:“空值”。它的语义是,希望表示一个对象被人为的重置为空对象,而非一个变量最原始的状态。
6.Symbol 类型
Symbol 是 ES6 新增的一种原始数据类型,它的字面意思是:符号、标记,代表独一无二的值。
Symbol 作为基本类型,没有对应的包装类型,也就是说 Symbol 本身不是对象,而是一个函数。因此,在生成 Symbol 类型值的时候,不能使用 new 操作符 。
7.Object 类型
1.存储方式:堆存储
2.内部特性
ECMA-262 第 5 版定义了一些内部特性(attribute),用以描述对象属性(property)的各种特征。ECMA-262 定义这些特性是为了实现 JavaScript 引擎用的,因此在 JavaScript 中不能直接访问它们。为了表示特性是内部值,该规范把它们放在了两对儿方括号中,例如[[Enumerable]]
。 这些内部特性可以分为两种:数据属性和访问器属性。
- 数据属性
[[Configurable]]
:能否通过 delete 删除属性从而重新定义属性,或者能否把属性修改为访问器属性。该默认值为 true。[[Enumerable]]
:表示能否通过 for-in 循环返回属性。默认值为 true。[[Writable]]
:能否修改属性的值。默认值为 true。[[Value]]
:包含这个属性的数据值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。默认值为 undefined 。
- 访问器属性
[[Get]]
:在读取属性时调用的函数。默认值为 undefined[[Set]]
:在写入属性时调用的函数。默认值为 undefined
要修改属性默认的特性,必须使用 ECMAScript 5 的 Object.defineProperty() 方法
3.对象创建方式:
- 对象自变量、new Object()
- 工厂模式、构造函数、原型模式
- 组合使用构造函数模式和原型模式:使用这种模式创建对象,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存
1.工厂模式
1 |
|
2.构造函数
1 |
|
3.原型模式
1 |
|
4.深拷贝/浅拷贝
深拷贝:对目标的完全拷贝,不像浅拷贝那样只是复制了一层引用,就连值也都复制了。
使用 JSON.stringify 和 JSON.parse(但不可以拷贝函数、正则等特殊属性)
注:如果对象中含有一个函数时(很常见),就不能用这个方法进行深拷贝
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15var obj1 = {
name: "cat",
show: function () {
console.log(this.name);
},
};
var obj2 = JSON.parse(JSON.stringify(obj1));
obj2.name = "pig";
console.log(obj1.name);
console.log(obj2.name);
obj1.show();
obj2.show(); //函数被丢失写一个递归函数去拷贝
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//对象
var obj1 = {
name: "cat",
show: function () {
console.log(this.name);
},
};
//这种深拷贝函数不会丢失
function deepClone(obj) {
let result = obj;
if (typeof obj == "object") {
result = Array.isArray(obj) ? [] : {};
for (let key in obj) {
result[key] =
typeof obj[key] == "object" ? deepClone(obj[key]) : obj[key];
}
}
return result;
}
var obj3 = deepClone(obj1);
//输出cat
obj3.show();
浅拷贝:浅拷贝就只是复制对象的引用。
Object.assign()
arr.slice()
,arr.concat()
1 |
|
数据类型的比较
==
如果数值不相等,会进行类型转换===
不进行类型转换Object.is
和===
类似,但是对于 NaN,+0,-0 会有特殊处理Object.is(NaN, NaN)
trueObject.is(+0, -0)
false
fun1.toString() === fun2.toString()
判断两个函数相等
判断类型的方法
- typeof 能判断出基本数据类型(包括 es6 中的 symbol,除了 null)和 object、function
- null 被检测为 object:JS 在底层存储变量的时候,会在变量的机器码的低位 1-3 位存储其类型信息。前三位都为 0 的话会被判断为 object 类型,null 的二进制表示全是 0,自然前三位也是 0,所以执行 typeof 时返回”object”,这是 JS 的历史遗留 bug。
- instanceof 检测某个实例对象的原型链上是否能找到构造函数的 prototype 属性:
[object] instanceof [constructor]
- 例如
f instanceof Foo
的逻辑是:f 的__proto__
一层一层往上,能否对应到Foo.prototype
的 - 对于number,string,boolean这三种类型,只有通过构造函数定义才能检测出,详见下面代码。
- 例如
- constructor:
[object].constructor === [constructor]
- 弊端就是 constructor 所指向的的构造函数是可以被修改的
Object.prototype.toString.call()
- 使用 API:如
Array.isArray()
注意一下:
1 |
|
原型
原型是什么?
JavaScript 常被描述为一种基于原型的语言——每个对象拥有一个原型对象。
下面举个例子:
1 |
|
控制台输出:
f1.__proto__
和Foo.prototype
指向的是同一个东西,即:
1 |
|
总结:
- 对象
__proto__
属性,指向原型对象constructor
属性,指向构造函数
- 函数
- 也是对象,也有
__proto__
和constructor
属性- 函数的
__proto__
指向Function.prototype
- 函数的
constructor
指向Function
- 函数的
- 还有
prototype
属性,指向原型对象(而这个原型对象的constructor属性又指向该函数)
- 也是对象,也有
原型链
当访问一个对象的某个属性时,会先在这个对象本身属性上查找,如果没有找到,则会去它的原型对象上查找,这样的链式结构叫做原型链。
每个对象的__proto__都是指向它的构造函数的原型对象的:
1 |
|
构造函数是一个函数对象,是通过 Function 构造器产生的:
1 |
|
原型对象本身是一个普通对象,而普通对象的构造函数都是Object:
1 |
|
所有的构造函数都是函数对象(包括Object),函数对象都是 Function 构造产生的:
1 |
|
Object的原型对象也有__proto__属性指向null,null是原型链的顶端:
1 |
|
总而言之,原型链的尽头:
- 对象继承自构造函数的原型对象
- 构造函数的原型对象本身是一个对象,继承的是Object.prototype
- Object.prototype的__proto__最终指向null,null是原型链的顶端。
当我们new一个实例的时候,new做了什么?
- 为实例开辟内存空间
- 把构造函数体内的
this
指向1中开辟的内存空间 - 将新实例的
__proto__
这个属性指向对应构造函数的prototype
属性 - 构造函数会帮你把你创建的实例return出来
作用域
作用域:本质上就是程序存储和访问变量的规则。作用域就是在这个规则约束下的一个变量、函数、标识符可以被访问的区域。
三种作用域
- 全局作用域
- 块级作用域
- 函数不是块,在语法中的block是指if/else/for/while语句里2个大括号之间的部分。
- let和const是块级作用域
- 函数作用域
- var是全局或某个函数作用域
var、let和const的区别
- 使用var声明的变量,函数内部作用域,能重复声明覆盖,且存在变量提升现象;
- 使用let声明的变量,块级作用域,不能重复声明覆盖,不存在变量提升;
- 使用const声明的是常量,块级作用域,在后面出现的代码中不能再修改该常量的值;
使用总结:
- 函数内部用var和let都可以,块级作用域最好用let。
- 在绝大多数情况下,let是可以代替var的,但是如果需要用到一些特殊功能,比如需要变量提升,需要在外部能引用内部定义的变量( 比如 try{ … }catch{ … } 语句块),需要把变量定义为全局的属性的时候,就需要使用var。所以let不能完全替换var。
作用域链和两种工作模式
作用域链:
如果当前作用域中找不到变量,会探出头到外面的作用域中找。在这个查找过程中,层层递进的作用域,就形成了一条作用域链。
两种工作模型:
- 词法作用域:在代码书写的时候完成划分,作用域链沿着它定义的位置往外延伸(JS等)
- 动态作用域:在代码运行时完成划分,作用域链沿着它的调用栈往外延伸(Bash、Perl)
JS中词法作用域的例子:
1 |
|
修改词法作用域
eval能修改词法作用域:
eval 函数的入参是一个字符串。当 eval 拿到一个字符串入参后,它会把这段字符串的内容当做一段 js 代码(不管它是不是一段 js 代码),插入自己被调用的那个位置。看下面这个例子:
1 |
|
eval (str) 这行代码被执行后作用域内 name 的值就从 ‘aa’ 变成了 ‘bb’,它成功地 “修改” 了词法作用域规则约束下在书写阶段就划分好的作用域。
闭包
闭包是为了:保证局部变量常驻在内存中,只能通过固定的方式访问,不可以被所有人访问。
闭包的应用
- 模拟私有变量的实现
- 柯里化和偏函数
- 防抖节流
应用1:模拟私有变量的实现
1 |
|
应用2:柯里化和偏函数
柯里化:将一个 n 个参数的函数转换成 n 个单参数函数,例如把fn(a,b,c)
变成fn(a)(b)(c)
。
偏函数:固定你函数的某一个或几个参数,然后返回一个新的函数(这个函数用于接收剩下的参数)。偏函数应用是不强调 “单参数” 这个概念的,它的目标仅仅是把函数的入参拆解为两部分。
原有的函数形式与调用方法:
1 |
|
偏函数应用改造:
1 |
|
柯里化应用改造:
1 |
|
应用3:防抖节流
防抖:如果短时间内大量触发同一事件,只会执行最后一次函数
1 |
|
节流:如果短时间内大量触发同一事件,那么在函数执行一次之后,该函数在指定的时间期限内不再工作,直至过了这段时间才重新生效。
1 |
|
闭包的问题
由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。
this
this是当前执行上下文的一个属性。执行上下文有:全局上下文、函数上下文、Eval上下文。
this的指向
- 指向调用它所在方法的那个对象(在调用时决定的)。
当调用方法没有明确对象时,this 就指向全局对象。在浏览器中,指向 window;在 Node 中,指向 Global。(严格模式下,指向 undefined)
- 箭头函数this由书写位置决定(继承执行上下文的this)。
- 在立即指向函数,setTimeout/setInterval,this指向window。
严格模式下,立即执行函数中this=undefined,setTimeout和setInterval中this=window。
1 |
|
稍微改动一下:
1 |
|
乍一看,会输出you,而不是bb。但为什么会输出bb呢?
看我们例题中的 targetFunc 这个方法,大家之所以第一直觉会认为它的 this 应该指向 you 这个对象,其实还是因为把 “声明位置” 和 “调用位置” 混淆了。我们看到虽然 targetFunc 是在 you 对象的 hello 方法里声明的,但是在调用它的时候,我们是不是没有给 targetFunc 指明任何一个对象作为它前缀? 所以 you 对象的 this 并不会神奇地自动传入 targetFunc 里,js 引擎仍然会认为 targetFunc 是一个挂载在 window 上的方法,进而把 this 指向 window 对象。
其实原理很简单,无论调用函数包了多少层,this是看最后一层调用函数,这个函数被哪个对象调用。
改变this的指向
this绑定的优先级:
- 构造函数 new 实例时绑定
- 显式绑定
- 仅改变:
bind
- 改变后直接调用:
call
、apply
fn.call(target,arg1,arg2, ...)
fn.apply(target, [arg1, ...])
内存管理
内存的数据结构
- 栈(Stack):函数调用会在内存形成一个”调用记录”,又称”调用帧”(call frame)。所有的调用记录,就形成一个”调用栈”(call stack)。先进后出。
- 堆(Heap):对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
- 队列(Queue):一个 JavaScript 运行时包含了一个待处理消息的消息队列。先进先出。
内存的生命周期
不管什么程序语言,内存生命周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放\归还
所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像JavaScript这些高级语言中,大部分都是隐含的。
1 |
|
JS在初始化变量时就会自动分配内存空间(1、2),那么我们主要要探究的就是如何垃圾回收(3)。
垃圾回收机制
垃圾回收算法的中心思想是判断内存是否不再需要。
引用计数垃圾收集
这是最初级的垃圾收集算法。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。
1
2
3
4
5
6
7
8
9
10//但是,对象被循环引用时内存发生泄漏
//下面这个例子:即使这个DOM元素从DOM树中删除了,所占用的内存空间也永远无法释放
var div;
window.onload = function(){
//循环引用
div = document.getElementById("myDivElement");
div.circularReference = div;
//占据大量内存空间
div.lotsOfData = new Array(10000).join("*");
};标记-清除算法
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。
- 标记阶段:在此阶段,垃圾回收器会从根对象开始遍历,标识可到达对象
- 清处阶段:会对堆内存从头到尾进行线性遍历,清处没被标识为可到达对象的对象
循环引用将不再是问题,因为两个对象都无法获取的情况下,将会被垃圾回收器回收。从2012年起,所有现代浏览器都使用了标记-清除垃圾回收算法。
JS中的堆内存和栈内存
在js引擎中对变量的存储主要有两种位置,堆内存和栈内存。
栈内存主要用于存储各种基本类型的变量,包括Boolean、Number、String、Undefined、Null以及对象变量的指针,这时候栈内存给人的感觉就像一个线性排列的空间,每个小单元大小基本相等。
而堆内存主要负责像对象Object这种变量类型的存储,如下图:
面试题
有几种方法可以判断是否是数组
首先 typeof 肯定不行,判读一些引用类型时,不能具体到具体哪一种类型。
使用
instanceof
或constructor
1 |
|
- 使用 Array.isArray
- 使用 Object.prototype.toString.call()
参考
- MDN:JavaScript
- What is the Difference Between Interpreter and JIT Compiler
- 细说 JavaScript 七种数据类型
- MDN:相等(==)
- 帮你彻底搞懂JS中的prototype、__proto__与constructor(图解)
- 慕课网专栏:解锁前端面试体系核心攻略
- 浅析JS中的堆内存与栈内存
- yangbo5207的文章:内存空间
- 由JavaScript堆栈溢出引出的函数式编程思想
- MDN:内存管理
- MDN:并发模型与时间循环
- MDN let/var/const
- 比较var和let的区别,理解函数作用域与块级作用域
- JavaScript 里的闭包是什么?应用场景有哪些? - tianlu 简单直白的例子
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!