JavaScript 是 ECMAScript 规范的一种实现,本小节重点梳理下 ECMAScript 中的常考知识点,然后就一些容易出现的题目进行解析。
JavaScript 是一种弱类型脚本语言,所谓弱类型指的是定义变量时,不需要什么类型,在程序运行过程中会自动判断类型。
ECMAScript 中定义了 6 种原始类型:
注意:原始类型不包含 Object。
题目:类型判断用到哪些方法?
typeof
typeof xxx
得到的值有以下几种类型:undefined
boolean
number
string
object
function
、symbol
,比较简单,不再一一演示了。这里需要注意的有三点:
typeof null
结果是object
,实际这是typeof
的一个bug,null是原始值,非引用类型typeof [1, 2]
结果是object
,结果中没有array
这一项,引用类型除了function
其他的全部都是object
typeof Symbol()
用typeof
获取symbol
类型的值得到的是symbol
,这是 ES6 新增的知识点instanceof
用于实例和构造函数的对应。例如判断一个变量是否是数组,使用typeof
无法判断,但可以使用[1, 2] instanceof Array
来判断。因为,[1, 2]
是数组,它的构造函数就是Array
。同理:
function Foo(name) {
this.name = name
}
var foo = new Foo('bar')
console.log(foo instanceof Foo) // true
题目:值类型和引用类型的区别
除了原始类型,ES 还有引用类型,上文提到的typeof
识别出来的类型中,只有object
和function
是引用类型,其他都是值类型。
根据 JavaScript 中的变量类型传递方式,又分为值类型和引用类型,值类型变量包括 Boolean、String、Number、Undefined、Null,引用类型包括了 Object 类的所有,如 Date、Array、Function 等。在参数传递方式上,值类型是按值传递,引用类型是按共享传递。
下面通过一个小题目,来看下两者的主要区别,以及实际开发中需要注意的地方。
// 值类型
var a = 10
var b = a
b = 20
console.log(a) // 10
console.log(b) // 20
上述代码中,a
b
都是值类型,两者分别修改赋值,相互之间没有任何影响。再看引用类型的例子:
// 引用类型
var a = {x: 10, y: 20}
var b = a
b.x = 100
b.y = 200
console.log(a) // {x: 100, y: 200}
console.log(b) // {x: 100, y: 200}
上述代码中,a
b
都是引用类型。在执行了b = a
之后,修改b
的属性值,a
的也跟着变化。因为a
和b
都是引用类型,指向了同一个内存地址,即两者引用的是同一个值,因此b
修改属性时,a
的值随之改动。
再借助题目进一步讲解一下。
说出下面代码的执行结果,并分析其原因。
function foo(a){
a = a * 10;
}
function bar(b){
b.value = 'new';
}
var a = 1;
var b = {value: 'old'};
foo(a);
bar(b);
console.log(a); // 1
console.log(b); // value: new
通过代码执行,会发现:
a
的值没有发生改变b
的值发生了改变这就是因为Number
类型的a
是按值传递的,而Object
类型的b
是按共享传递的。
JS 中这种设计的原因是:按值传递的类型,复制一份存入栈内存,这类类型一般不占用太多内存,而且按值传递保证了其访问速度。按共享传递的类型,是复制其引用,而不是整个复制其值(C 语言中的指针),保证过大的对象等不会因为不停复制内容而造成内存的浪费。
引用类型经常会在代码中按照下面的写法使用,或者说容易不知不觉中造成错误!
var obj = {
a: 1,
b: [1,2,3]
}
var a = obj.a
var b = obj.b
a = 2
b.push(4)
console.log(obj, a, b)
虽然obj
本身是个引用类型的变量(对象),但是内部的a
和b
一个是值类型一个是引用类型,a
的赋值不会改变obj.a
,但是b
的操作却会反映到obj
对象上。
JavaScript 是基于原型的语言,原型理解起来非常简单,但却特别重要,下面还是通过题目来理解下JavaScript 的原型概念。
题目:如何理解 JavaScript 的原型
对于这个问题,可以从下面这几个要点来理解和回答,下面几条必须记住并且理解
null
除外)__proto__
属性,属性值是一个普通的对象prototype
属性,属性值也是一个普通的对象__proto__
属性值指向它的构造函数的prototype
属性值通过代码解释一下,大家可自行运行以下代码,看结果。
// 要点一:自由扩展属性
var obj = {}; obj.a = 100;
var arr = []; arr.a = 100;
function fn () {}
fn.a = 100;
// 要点二:__proto__
console.log(obj.__proto__);
console.log(arr.__proto__);
console.log(fn.__proto__);
// 要点三:函数有 prototype
console.log(fn.prototype)
// 要点四:引用类型的 __proto__ 属性值指向它的构造函数的 prototype 属性值
console.log(obj.__proto__ === Object.prototype)
先写一个简单的代码示例。
// 构造函数
function Foo(name, age) {
this.name = name
}
Foo.prototype.alertName = function () {
alert(this.name)
}
// 创建示例
var f = new Foo('zhangsan')
f.printName = function () {
console.log(this.name)
}
// 测试
f.printName()
f.alertName()
执行printName
时很好理解,但是执行alertName
时发生了什么?这里再记住一个重点 当试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么会去它的__proto__
(即它的构造函数的prototype
)中寻找,因此f.alertName
就会找到Foo.prototype.alertName
。
那么如何判断这个属性是不是对象本身的属性呢?使用hasOwnProperty
,常用的地方是遍历一个对象的时候。
var item
for (item in f) {
// 高级浏览器已经在 for in 中屏蔽了来自原型的属性,但是这里建议大家还是加上这个判断,保证程序的健壮性
if (f.hasOwnProperty(item)) {
console.log(item)
}
}
题目:如何理解 JS 的原型链
还是接着上面的示例,如果执行f.toString()
时,又发生了什么?
// 省略 N 行
// 测试
f.printName()
f.alertName()
f.toString()
因为f
本身没有toString()
,并且f.__proto__
(即Foo.prototype
)中也没有toString
。这个问题还是得拿出刚才那句话——当试图得到一个对象的某个属性时,如果这个对象本身没有这个属性,那么会去它的__proto__
(即它的构造函数的prototype
)中寻找。
如果在f.__proto__
中没有找到toString
,那么就继续去f.__proto__.__proto__
中寻找,因为f.__proto__
就是一个普通的对象而已嘛!
f.__proto__
即Foo.prototype
,没有找到toString
,继续往上找f.__proto__.__proto__
即Foo.prototype.__proto__
。Foo.prototype
就是一个普通的对象,因此Foo.prototype.__proto__
就是Object.prototype
,在这里可以找到toString
f.toString
最终对应到了Object.prototype.toString
这样一直往上找,你会发现是一个链式的结构,所以叫做“原型链”。如果一直找到最上层都没有找到,那么就宣告失败,返回undefined
。最上层是什么 —— Object.prototype.__proto__ === null
this
所有从原型或更高级原型中得到、执行的方法,其中的this
在执行时,就指向了当前这个触发事件执行的对象。因此printName
和alertName
中的this
都是f
。
作用域和闭包是前端面试中,最可能考查的知识点。例如下面的题目:
题目:现在有个 HTML 片段,要求编写代码,点击编号为几的链接就
alert
弹出其编号
<ul>
<li>编号1,点击我请弹出1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
一般不知道这个题目用闭包的话,会写出下面的代码:
var list = document.getElementsByTagName('li');
for (var i = 0; i < list.length; i++) {
list[i].addEventListener('click', function(){
alert(i + 1)
}, true)
}
实际上执行才会发现始终弹出的是6
,这时候就应该通过闭包来解决:
var list = document.getElementsByTagName('li');
for (var i = 0; i < list.length; i++) {
list[i].addEventListener('click', function(i){
return function(){
alert(i + 1)
}
}(i), true)
}
要理解闭包,就需要我们从「执行上下文」开始讲起。
先讲一个关于 变量提升 的知识点,面试中可能会遇见下面的问题,很多候选人都回答错误:
题目:说出下面执行的结果(这里笔者直接注释输出了)
console.log(a) // undefined
var a = 100
fn('zhangsan') // 'zhangsan' 20
function fn(name) {
age = 20
console.log(name, age)
var age
}
console.log(b); // 这里报错
// Uncaught ReferenceError: b is not defined
b = 100;
在一段 JS 脚本(即一个标签中)执行之前,要先解析代码(所以说 JS 是解释执行的脚本语言),解析的时候会先创建一个 全局执行上下文 环境,先把代码中即将执行的(内部函数的不算,因为你不知道函数何时执行)变量、函数声明都拿出来。变量先暂时赋值为
undefined
,函数则先声明好可使用。这一步做完了,然后再开始正式执行程序。再次强调,这是在代码执行之前才开始的工作。
我们来看下上面的面试小题目,为什么a
是undefined
,而b
却报错了,实际 JS 在代码执行之前,要「全文解析」,发现var a
,知道有个a
的变量,存入了执行上下文,而b
没有找到var
关键字,这时候没有在执行上下文提前「占位」,所以代码执行的时候,提前报到的a
是有记录的,只不过值暂时还没有赋值,即为undefined
,而b
在执行上下文没有找到,自然会报错(没有找到b
的引用)。
另外,一个函数在执行之前,也会创建一个 函数执行上下文 环境,跟 全局上下文 差不多,不过 函数执行上下文 中会多出this
arguments
和函数的参数。参数和arguments
好理解,这里的this
咱们需要专门讲解。
总结一下:
、js 文件或者一个函数this
,arguments
this
先搞明白一个很重要的概念 —— this
的值是在执行的时候才能确认,定义的时候不能确认! 为什么呢 —— 因为this
是执行上下文环境的一部分,而执行上下文需要在代码执行之前确定,而不是定义的时候。看如下例子
var a = {
name: 'A',
fn: function () {
console.log(this.name)
}
}
a.fn() // this === a
a.fn.call({name: 'B'}) // this === {name: 'B'}
var fn1 = a.fn
fn1() // this === window
this
执行会有不同,主要集中在这几个场景中
a.fn()
fn1()
call
apply
bind
,上述代码中a.fn.call({name: 'B'})
下面再来讲解下什么是作用域和作用域链,作用域链和作用域也是常考的题目。
题目:如何理解 JS 的作用域和作用域链
ES6 之前 JS 没有块级作用域。例如
if (true) {
var name = 'zhangsan'
}
console.log(name)
从上面的例子可以体会到作用域的概念,作用域就是一个独立的地盘,让变量不会外泄、暴露出去。上面的name
就被暴露出去了,因此,JS 没有块级作用域,只有全局作用域和函数作用域。
var a = 100
function fn() {
var a = 200
console.log('fn', a)
}
console.log('global', a)
fn()
全局作用域就是最外层的作用域,如果我们写了很多行 JS 代码,变量定义都没有用函数包括,那么它们就全部都在全局作用域中。这样的坏处就是很容易撞车、冲突。
// 张三写的代码中
var data = {a: 100}
// 李四写的代码中
var data = {x: true}
这就是为何 jQuery、Zepto 等库的源码,所有的代码都会放在(function(){....})()
中。因为放在里面的所有变量,都不会被外泄和暴露,不会污染到外面,不会对其他的库或者 JS 脚本造成影响。这是函数作用域的一个体现。
附:ES6 中开始加入了块级作用域,使用let
定义变量即可,如下:
if (true) {
let name = 'zhangsan'
}
console.log(name) // 报错,因为let定义的name是在if这个块级作用域
首先认识一下什么叫做 自由变量 。如下代码中,console.log(a)
要得到a
变量,但是在当前的作用域中没有定义a
(可对比一下b
)。当前作用域没有定义的变量,这成为 自由变量 。自由变量如何得到 —— 向父级作用域寻找。
var a = 100
function fn() {
var b = 200
console.log(a)
console.log(b)
}
fn()
如果父级也没呢?再一层一层向上寻找,直到找到全局作用域还是没找到,就宣布放弃。这种一层一层的关系,就是 作用域链 。
var a = 100
function F1() {
var b = 200
function F2() {
var c = 300
console.log(a) // 自由变量,顺作用域链向父作用域找
console.log(b) // 自由变量,顺作用域链向父作用域找
console.log(c) // 本作用域的变量
}
F2()
}
F1()
讲完这些内容,我们再来看一个例子,通过例子来理解闭包。
function F1() {
var a = 100
return function () {
console.log(a)
}
}
var f1 = F1()
var a = 200
f1()
自由变量将从作用域链中去寻找,但是 依据的是函数定义时的作用域链,而不是函数执行时,以上这个例子就是闭包。闭包主要有两个应用场景:
function F1() {
var a = 100
return function () {
console.log(a)
}
}
function F2(f1) {
var a = 200
console.log(f1())
}
var f1 = F1()
F2(f1)
至此,对应着「作用域和闭包」这部分一开始的点击弹出alert
的代码再看闭包,就很好理解了。
异步和同步也是面试中常考的内容,下面笔者来讲解下同步和异步的区别。
先看下面的 demo,根据程序阅读起来表达的意思,应该是先打印100
,1秒钟之后打印200
,最后打印300
。但是实际运行根本不是那么回事。
console.log(100)
setTimeout(function () {
console.log(200)
}, 1000)
console.log(300)
再对比以下程序。先打印100
,再弹出200
(等待用户确认),最后打印300
。这个运行效果就符合预期要求。
console.log(100)
alert(200) // 1秒钟之后点击确认
console.log(300)
这俩到底有何区别?—— 第一个示例中间的步骤根本没有阻塞接下来程序的运行,而第二个示例却阻塞了后面程序的运行。前面这种表现就叫做 异步(后面这个叫做 同步 ),即不会阻塞后面程序的运行。
JS 需要异步的根本原因是 JS 是单线程运行的,即在同一时间只能做一件事,不能“一心二用”。
一个 Ajax 请求由于网络比较慢,请求需要 5 秒钟。如果是同步,这 5 秒钟页面就卡死在这里啥也干不了了。异步的话,就好很多了,5 秒等待就等待了,其他事情不耽误做,至于那 5 秒钟等待是网速太慢,不是因为 JS 的原因。
讲到单线程,我们再来看个真题:
题目:讲解下面代码的执行过程和结果
var a = true;
setTimeout(function(){
a = false;
}, 100)
while(a){
console.log('while执行了')
}
这是一个很有迷惑性的题目,不少候选人认为100ms
之后,由于a
变成了false
,所以while
就中止了,实际不是这样,因为JS是单线程的,所以进入while
循环之后,没有「时间」(线程)去跑定时器了,所以这个代码跑起来是个死循环!
setTimeout
setInterval
Ajax
![]()
加载Ajax 代码示例
console.log('start')
$.get('./data1.json', function (data1) {
console.log(data1)
})
console.log('end')
img 代码示例(常用于打点统计)
console.log('start')
var img = document.createElement('img')
// 或者 img = new Image()
img.onload = function () {
console.log('loaded')
img.onload = null
}
img.src = '/xxx.png'
console.log('end')
题目:ES6 箭头函数中的
this
和普通函数中的有什么不同
箭头函数是 ES6 中新的函数定义形式,function name(arg1, arg2) {...}
可以使用(arg1, arg2) => {...}
来定义。示例如下:
// JS 普通函数
var arr = [1, 2, 3]
arr.map(function (item) {
console.log(index)
return item + 1
})
// ES6 箭头函数
const arr = [1, 2, 3]
arr.map((item, index) => {
console.log(index)
return item + 1
})
箭头函数存在的意义,第一写起来更加简洁,第二可以解决 ES6 之前函数执行中this
是全局变量的问题,看如下代码
function fn() {
console.log('real', this) // {a: 100} ,该作用域下的 this 的真实的值
var arr = [1, 2, 3]
// 普通 JS
arr.map(function (item) {
console.log('js', this) // window 。普通函数,这里打印出来的是全局变量,令人费解
return item + 1
})
// 箭头函数
arr.map(item => {
console.log('es6', this) // {a: 100} 。箭头函数,这里打印的就是父作用域的 this
return item + 1
})
}
fn.call({a: 100})
题目:ES6 模块化如何使用?
ES6 中模块化语法更加简洁,直接看示例。
如果只是输出一个唯一的对象,使用export default
即可,代码如下
// 创建 util1.js 文件,内容如
export default {
a: 100
}
// 创建 index.js 文件,内容如
import obj from './util1.js'
console.log(obj)
如果想要输出许多个对象,就不能用default
了,且import
时候要加{...}
,代码如下
// 创建 util2.js 文件,内容如
export function fn1() {
alert('fn1')
}
export function fn2() {
alert('fn2')
}
// 创建 index.js 文件,内容如
import { fn1, fn2 } from './util2.js'
fn1()
fn2()
题目:ES6 class 和普通构造函数的区别
class 其实一直是 JS 的关键字(保留字),但是一直没有正式使用,直到 ES6 。 ES6 的 class 就是取代之前构造函数初始化对象的形式,从语法上更加符合面向对象的写法。例如:
JS 构造函数的写法
function MathHandle(x, y) {
this.x = x;
this.y = y;
}
MathHandle.prototype.add = function () {
return this.x + this.y;
};
var m = new MathHandle(1, 2);
console.log(m.add())
用 ES6 class 的写法
class MathHandle {
constructor(x, y) {
this.x = x;
this.y = y;
}
add() {
return this.x + this.y;
}
}
const m = new MathHandle(1, 2);
console.log(m.add())
注意以下几点,全都是关于 class 语法的:
class Name {...}
这种形式,和函数的写法完全不一样constructor
函数中,constructor
即构造器,初始化实例时默认执行add() {...}
这种形式,并没有function
关键字使用 class 来实现继承就更加简单了,至少比构造函数实现继承简单很多。看下面例子
JS 构造函数实现继承
// 动物
function Animal() {
this.eat = function () {
console.log('animal eat')
}
}
// 狗
function Dog() {
this.bark = function () {
console.log('dog bark')
}
}
Dog.prototype = new Animal()
// 哈士奇
var hashiqi = new Dog()
ES6 class 实现继承
class Animal {
constructor(name) {
this.name = name
}
eat() {
console.log(`${this.name} eat`)
}
}
class Dog extends Animal {
constructor(name) {
super(name)
this.name = name
}
say() {
console.log(`${this.name} say`)
}
}
const dog = new Dog('哈士奇')
dog.say()
dog.eat()
注意以下两点:
extends
即可实现继承,更加符合经典面向对象语言的写法,如 Javaconstructor
一定要执行super()
,以调用父类的constructor
题目:ES6 中新增的数据类型有哪些?
Set 和 Map 都是 ES6 中新增的数据结构,是对当前 JS 数组和对象这两种重要数据结构的扩展。由于是新增的数据结构,目前尚未被大规模使用,但是作为前端程序员,提前了解是必须做到的。先总结一下两者最关键的地方:
Set
Set 实例不允许元素有重复,可以通过以下示例证明。可以通过一个数组初始化一个 Set 实例,或者通过add
添加元素,元素不能重复,重复的会被忽略。
// 例1
const set = new Set([1, 2, 3, 4, 4]);
console.log(set) // Set(4) {1, 2, 3, 4}
// 例2
const set = new Set();
[2, 3, 5, 4, 5, 8, 8].forEach(item => set.add(item));
for (let item of set) {
console.log(item);
}
// 2 3 5 4 8
Set 实例的属性和方法有
size
:获取元素数量。add(value)
:添加元素,返回 Set 实例本身。delete(value)
:删除元素,返回一个布尔值,表示删除是否成功。has(value)
:返回一个布尔值,表示该值是否是 Set 实例的元素。clear()
:清除所有元素,没有返回值。const s = new Set();
s.add(1).add(2).add(2); // 添加元素
s.size // 2
s.has(1) // true
s.has(2) // true
s.has(3) // false
s.delete(2);
s.has(2) // false
s.clear();
console.log(s); // Set(0) {}
Set 实例的遍历,可使用如下方法
keys()
:返回键名的遍历器。values()
:返回键值的遍历器。不过由于 Set 结构没有键名,只有键值(或者说键名和键值是同一个值),所以keys()
和values()
返回结果一致。entries()
:返回键值对的遍历器。forEach()
:使用回调函数遍历每个成员。let set = new Set(['aaa', 'bbb', 'ccc']);
for (let item of set.keys()) {
console.log(item);
}
// aaa
// bbb
// ccc
for (let item of set.values()) {
console.log(item);
}
// aaa
// bbb
// ccc
for (let item of set.entries()) {
console.log(item);
}
// ["aaa", "aaa"]
// ["bbb", "bbb"]
// ["ccc", "ccc"]
set.forEach((value, key) => console.log(key + ' : ' + value))
// aaa : aaa
// bbb : bbb
// ccc : ccc
Map
Map 的用法和普通对象基本一致,先看一下它能用非字符串或者数字作为 key 的特性。
const map = new Map();
const obj = {p: 'Hello World'};
map.set(obj, 'OK')
map.get(obj) // "OK"
map.has(obj) // true
map.delete(obj) // true
map.has(obj) // false
需要使用new Map()
初始化一个实例,下面代码中set
get
has
delete
顾名即可思义(下文也会演示)。其中,map.set(obj, 'OK')
就是用对象作为的 key (不光可以是对象,任何数据类型都可以),并且后面通过map.get(obj)
正确获取了。
Map 实例的属性和方法如下:
size
:获取成员的数量set
:设置成员 key 和 valueget
:获取成员属性值has
:判断成员是否存在delete
:删除成员clear
:清空所有const map = new Map();
map.set('aaa', 100);
map.set('bbb', 200);
map.size // 2
map.get('aaa') // 100
map.has('aaa') // true
map.delete('aaa')
map.has('aaa') // false
map.clear()
Map 实例的遍历方法有:
keys()
:返回键名的遍历器。values()
:返回键值的遍历器。entries()
:返回所有成员的遍历器。forEach()
:遍历 Map 的所有成员。const map = new Map();
map.set('aaa', 100);
map.set('bbb', 200);
for (let key of map.keys()) {
console.log(key);
}
// "aaa"
// "bbb"
for (let value of map.values()) {
console.log(value);
}
// 100
// 200
for (let item of map.entries()) {
console.log(item[0], item[1]);
}
// aaa 100
// bbb 200
// 或者
for (let [key, value] of map.entries()) {
console.log(key, value);
}
// aaa 100
// bbb 200
Promise
是 CommonJS 提出来的这一种规范,有多个版本,在 ES6 当中已经纳入规范,原生支持 Promise 对象,非 ES6 环境可以用类似 Bluebird、Q 这类库来支持。
Promise
可以将回调变成链式调用写法,流程更加清晰,代码更加优雅。
简单归纳下 Promise:三个状态、两个过程、一个方法,快速记忆方法:3-2-1
三个状态:pending
、fulfilled
、rejected
两个过程:
一个方法:then
当然还有其他概念,如catch
、 Promise.all/race
,这里就不展开了。
关于 ES6/7 的考查内容还有很多,本小节就不逐一介绍了,如果想继续深入学习,可以在线看《ES6入门》。
本文主要总结了 ES 基础语法中面试经常考查的知识点,包括之前就考查较多的原型、异步、作用域,以及 ES6 的一些新内容,这些知识点希望大家都要掌握。