根据StackOverflow调查, 自2014年以来,JavaScript是最流行的编程语言。当然,这也在情理之中,毕竟1/3的开发工作都需要一些JavaScript知识。因此,如果你希望在成为一个开发者,你应该学会这门语言。
01、类型和类型转换
在JavaScript中有7个内置类型:null,undefined,boolean,number,string,object,和symbol(ES6)。
除了object以外,其它都叫做基本类型。
typeof 0 // number typeof true // boolean typeof 'Hello' // string typeof Math // object typeof null // object !! typeof Symbol('Hi') // symbol (New ES6)➤Null vs. Undefined
Undefined表示未定义。对于没有初始化的变量、函数调用时候未提供的函数参数、缺失的对象属性,它们的默认值就是undefined。如果一个函数没有返回语句,那么默认的返回值也是undefined。
NUll表示值为空。一个变量我们可以将其赋值为null,表示当前的没有值。
➤隐式转换
请看下面的例子:
var name = 'Joey'; if (name) { con(name + " doesn't share food!") // Joey doesn’t share food! }在if语句的条件判断中,name从字符串转换为布尔型。在if的代码块中,在控制台将name原原本本打印出来。你知道在什么情况下字符串会转换为真,什么时候为假么?
"",0, null,undefined, NaN, false 会自动转换为false。其它的都会转换为真:
Boolean(null) // false Boolean('hello') // true Boolean('0') // true Boolean(' ') // true Boolean([]) // true Boolean(function(){}) // true空数组、对象、函数定义都会自动转换为真。
➤String & Number之间的转换
第一个你要非常小心的是+操作符。因为它同时用于数字相加和字符串拼接。
*,/,-只用于数字运算,当这些操作符和字符串一起使用,那么字符串会被强制转换为数字。
1 + "2" = "12" "" + 1 + 0 = "10" "" - 1 + 0 = -1 "-9\n" + 5 = "-9\n5" "-9\n" - 5 = -14 "2" * "3" = 6 4 + 5 + "px" = "9px" "$" + 4 + 5 = "$45" "4" - 2 = 2 "4px" - 2 = NaN null + 1 = 1➤== vs. ===
一个广泛被接受的认知就是:==判断值是否相等,===同时判断值是否相等和类型是否相同。但是,这里有些误解。
实际上,==在验证相等性的时候,会对类型不同的值做一个类型转换。===对要判断的值不做类型转换。
2 == '2' // True 2 === '2' // False undefined == null // True undefined === null // False类型转换有很多取巧的地方,要注意:
let a = '0'; con(Boolean(a)); // True let b = false; con(Boolean(b)); // False ``` 你认为下面的相等判断会输出什么值呢? ```js con(a == b);实际上会返回true。知道为什么吗?
如果你将一个布尔类型的和非布尔类型的判断,JavaScript会将布尔类型的转换为数字然后再比对。
执行过程如下:
'0' == false (1) '0' == 0 (2) 0 == 0 (3)所以,最终变成了0==0,当然返回true啦。
如果你想看完整的资料,请查看ES5的官方文档。
如果想看cheat sheet, 点击这里。
一些比较容易掉坑的比较,我在这里列出来:
false == "" // true false == [] // true false == {} // false "" == 0 // true "" == [] // true "" == {} // false 0 == [] // true 0 == {} // false 0 == null // false02、值 vs. 引用
对于基本类型的值,赋值是通过值拷贝的形式;比如:null,undefined,boolean,number,string和ES6的symbol。对于复杂类型的值,通过引用拷贝的形式赋值。比如:对象、对象包括数组和函数。
var a = 2; // 'a' hold a copy of the value 2. var b = a; // 'b' is always a copy of the value in 'a' b++; con(a); // 2 con(b); // 3 var c = [1,2,3]; var d = c; // 'd' is a reference to the shared value d.push( 4 ); // Mutates the referenced value (object) con(c); // [1,2,3,4] con(d); // [1,2,3,4] /* Compound values are equal by reference */ var e = [1,2,3,4]; con(c === d); // true con(c === e); // false如果想对复杂类型的值进行值拷贝,你需要自己去对所有子元素进行拷贝。
const copy = c.slice() // 'copy' 即使copy和c相同,但是copy指向新的值 con(c); // [1,2,3,4] con(copy); // [1,2,3,4] con(c === copy); // falseFundebug提供实时、专业的错误监控服务,为您的线上代码保驾护航,欢迎大家免费使用!
03、作用域(Scope)
作用域值程序的执行环境,它包含了在当前位置可访问的变量和函数。
全局作用域是最外层的作用域,在函数外面定义的变量属于全局作用域,可以被任何其他子作用域访问。在浏览器中,window对象就是全局作用域。
局部作用域是在函数内部的作用域。在局部作用域定义的变量只能在该作用域以及其子作用域被访问。
function outer() { let a = 1; function inner() { let b = 2; function innermost() { let c = 3; con(a, b, c); // 1 2 3 } innermost(); con(a, b); // 1 2 — 'c' is not defined } inner(); con(a); // 1 — 'b' and 'c' are not defined } outer();你可以将作用域想象成一系列不断变小的门。如果一个个子不高的人可以穿过最小的门(局部最小作用域),那么必然可以穿过任何比它大的门(外部作用域)。
04、提升(Hoisting)
在编译过程中,将var和function的定义移动到他们作用域最前面的行为叫做提升。
整个函数定义会被提升。所以,你可以在函数还未定义之前调用它,而不用担心找不到该函数。
con(toSquare(3)); // 9 function toSquare(n){ return n*n; }变量只会被部分提升。而且只有变量的声明会被提升,赋值不会动。
let和const不会被提升。
{ /* Original code */ con(i); // undefined var i = 10 con(i); // 10 } { /* Compilation phase */ var i; con(i); // undefined i = 10 con(i); // 10 } // ES6 let & const { con(i); // ReferenceError: i is not defined const i = 10 con(i); // 10 } { con(i); // ReferenceError: i is not defined let i = 10 con(i); // 10 }05、函数表达式和函数声明
➤函数表达式
一个函数表达式是在函数执行到函数表达式定义的位置才开始创建,并被使用。它不会被提升。
var sum = function(a, b) { return a + b; }➤函数声明
函数声明的函数可以在文件中任意位置调用,因为它会被提升。
function sum(a, b) { return a + b; }06、变量:var,let和const
在ES6之前,只能使用var来声明变量。在一个函数体中声明的变量和函数,周围的作用域内无法访问。在块作用域if和for中声明的变量,可以在if和for的外部被访问。
注意:如果没有使用var,let或则const关键字声明的变量将会绑定到全局作用域上。
function greeting() { con(s) // undefined if(true) { var s = 'Hi'; undeclaredVar = 'I am automatically created in global scope'; } con(s) // 'Hi' } con(s); // Error — ReferenceError: s is not defined greeting(); con(undeclaredVar) // 'I am automatically created in global scope'ES6的let和const都是新引入的关键字。它们不会被提升,而且是块作用域。也就是说被大括号包围起来的区域声明的变量外部将不可访问。
let g1 = 'global 1' let g2 = 'global 2' { /* Creating a new block scope */ g1 = 'new global 1' let g2 = 'local global 2' con(g1) // 'new global 1' con(g2) // 'local global 2' con(g3) // ReferenceError: g3 is not defined let g3 = 'I am not hoisted'; } con(g1) // 'new global 1' con(g2) // 'global 2'一个常见的误解是:使用const声明的变量,其值不可更改。准确地说它不可以被重新赋值,但是可以更改。
const tryMe = 'initial assignment'; tryMe = 'this has been reassigned'; // TypeError: Assignment to constant variable. // You cannot reassign but you can change it… const array = ['Ted', 'is', 'awesome!']; array[0] = 'Barney'; array[3] = 'Suit up!'; con(array); // [“Barney”, “is”, “awesome!”, “Suit up!”] const airplane = {}; air = 2; air = 200; con(airplane); // {passengers: 200, wings: 2}07、闭包
闭包由一个函数以及该函数定义是所在的环境组成。我们通过例子来形象解释它。
function sayHi(name){ var message = `Hi ${name}!`; function greeting() { con(message) } return greeting } var sayHiToJon = sayHi('Jon'); con(sayHiToJon) // ƒ() { con(message) } con(sayHiToJon()) // 'Hi Jon!'请理解var sayHiToJon = sayHi('Jon');这行代码的执行过程,sayHi函数执行,首先将message的值计算出来;然后定义了greeting函数,函数中引用了message变量;最后,返回greeting函数。
如果按照C/Java语言的思路,sayHiToJon就等价于greeting函数,那么会报错:message未定义。
但是在JavaScript中不一样,这里的sayHiToJon函数等于greeting函数以及一个环境,该环境中包含了message。因此,当我们调用sayHiToJon函数,可以成功地将message打印出来。因此,这里的闭包就是greeting函数和一个包含message变量的环境。(备注: 为了便于理解,此段落未按照原文翻译。)
闭包的一个优势在于数据隔离。我们同样用一个例子来说明:
function SpringfieldSchool() { let staff = ['Seymour Skinner', 'Edna Krabappel']; return { getStaff: function() { con(staff) }, addStaff: function(name) { (name) } } } let elementary = SpringfieldSchool() con(elementary) // { getStaff: ƒ, addStaff: ƒ } con(staff) // ReferenceError: staff is not defined /* Closure allows access to the staff variable */ elemen() // ["Seymour Skinner", "Edna Krabappel"] elemen('Otto Mann') elemen() // ["Seymour Skinner", "Edna Krabappel", "Otto Mann"]在elementary被创建的时候,SpringfieldSchool已经返回。也就是说staff无法被外部访问。唯一可以访问的方式就是里面的闭包函数getStaff和addStaff。
我们来看一个面试题:下面的代码有什么问题,如何修复?
const arr = [10, 12, 15, 21]; for (var i = 0; i < arr.length; i++) { setTimeout(function() { con(`The value ${arr[i]} is at index: ${i}`); }, (i+1) * 1000); }上面的代码输出的结果全部都一样:”The value undefined is at index: 4”。因为所有在setTimeout中定义的匿名函数都引用了同一个外部变量i。当匿名函数执行的时候,i的值为4。
这个问题可以改用IIFE(后面会介绍)方法来解决,通过对每一个匿名函数构建独立的外部作用域来实现。
const arr = [10, 12, 15, 21]; for (var i = 0; i < arr.length; i++) { (function(j) { setTimeout(function() { con (`The value ${arr[j]} is at index: ${j}`); }, j * 1000); })(i) }当然,还有一个方法,使用let来声明i。
const arr = [10, 12, 15, 21]; for (let i = 0; i < arr.length; i++) { setTimeout(function() { con(`The value ${arr[i]} is at index: ${i}`); }, (i) * 1000); }立即调用的函数表达式(Immediate Invoked Function Expression)(IIFE)
一个IIFE是一个函数表达式在定义之后立即被调用。常用在你想对一个新声明的变量创建一个隔离的作用域。
它的格式为: (function(){....})()。前面的大括号用于告诉编译器这里不仅仅是函数定义,后面的大括号用于执行该函数。
var result = []; for (var i=0; i < 5; i++) { re( function() { return i } ); } con( result[1]() ); // 5 con( result[3]() ); // 5 result = []; for (var i=0; i < 5; i++) { (function () { var j = i; // copy current value of i re( function() { return j } ); })(); } con( result[1]() ); // 1 con( result[3]() ); // 3使用IIFE可以:
➤为函数绑定私有数据
➤创建一个新的环境
➤避免污染全局命名空间
08、环境(Context)
我们往往容易将环境(Context)和作用域(Scope)搞混,我来简单解释一下:
➤环境(Context): 由函数如何被调用而决定,往往指this。
➤作用域(Scope): 可访问的变量。
09、函数调用:call, apply, bind
这三个方法都是为了将this绑定到函数,区别在于调用的方式。
➤.call()会立即执行函数,你需要把参数按顺序传入;
➤.apply()会立即执行函数,你需要把所有的参数组合为一个数组传入;
.call()和.apply()几乎相同。哪个传入参数方便,你就选择哪个。
const Snow = {surename: 'Snow'} const char = { surename: 'Stark', knows: function(arg, name) { con( `You know ${arg}, ${name} ${}`); } } c('something', 'Bran'); // You know something, Bran Stark c.call(Snow, 'nothing', 'Jon'); // You know nothing, Jon Snow c.apply(Snow, ['nothing', 'Jon']); // You know nothing, Jon Snow注意:如果你将数组传入call函数,它会认为只有一个参数。
ES6允许使用新的操作符将数组变换为一个序列。
c.call(Snow, ...["nothing", "Jon"]); // You know nothing, Jon Snow.bind()返回一个新的函数,以及相应的环境和参数。如果你想该函数稍后调用,那么推荐使用bind。
.bind()函数的优点在于它可以记录一个执行环境,对于异步调用和事件驱动的编程很有用。
.bind()传参数的方式和call相同。
const Snow = {surename: 'Snow'} const char = { surename: 'Stark', knows: function(arg, name) { con(`You know ${arg}, ${name} ${}`);} } const whoKnowsNothing = c.bind(Snow, 'nothing'); whoKnowsNothing('Jon'); // You know nothing, Jon Snow09、this关键字
要理解JavaScript中this关键字,特别是它指向谁,有时候相当地复杂。this的值通常由函数的执行环境决定。简单的说,执行环境指函数如何被调用的。this像是一个占位符(placeholder),它指向当方法被调用时,调用对应的方法的对象。
下面有序地列出了判断this指向的规则。如果第一条匹配,那么就不用去检查第二条了。
➤new绑定 - 当使用new关键字调用函数的时候,this指向新构建的对象。
function Person(name, age) { = name; =age; con(this); } const Rachel = new Person('Rachel', 30); // { age: 30, name: 'Rachel' }➤显示绑定(Explicit binding) - 当使用call或则apply的时候,我们显示的传入一个对象参数,该参数会绑定到this。 注意:.bind()函数不一样。用bind定义一个新的函数,但是依然绑定到原来的对象。
function fn() { con(this); } var agent = {id: '007'}; (agent); // { id: '007' } (agent); // { id: '007' } var boundFn = (agent); boundFn(); // { id: '007' }➤隐式绑定 - 当一个函数在某个环境下调用(在某个对象里),this指向该对象。也就是说该函数是对象的一个方法。
var building = { floors: 5, printThis: function() { con(this); } } building.printThis(); // { floors: 5, printThis: function() {…} }➤默认绑定 - 如果上面所有的规则都不满足,那么this指向全局对象(在浏览器中,就是window对象)。当函数没有绑定到某个对象,而单独定义的时候,该函数默认绑定到全局对象。
function printWindow() { con(this) } printWindow(); // window object注意:下面的情况中,inner函数中的this指向全局。
function Dinosaur(name) { = name; var self = this; inner(); function inner() { alert(this); // window object — the function has overwritten the 'this' context con(self); // {name: 'Dino'} — referencing the stored value from the outer context } } var myDinosaur = new Dinosaur('Dino');➤词法(Lexical) this - 当是使用=>来定义函数时,this指向定义该函数时候外层的this。 备注:大概是和定义的词法(=>)有关,把它称作Lexical this。
function Cat(name) { = name; con(this); // { name: 'Garfield' } ( () => con(this) )(); // { name: 'Garfield' } } var myCat = new Cat('Garfield');10、严格(Strict)模式
如果你使用了"use strict"指令,那么JavaScript代码会在严格模式下执行。在严格模式下,对于词法分析和错误处理都有特定的规则。在这里我列出它的一些优点:
➤使得Debug更容易:以前会被忽略的错误现在会显示报错,比如赋值给一个不可写的全局变量或则属性;
➤避免不小心声明了全局变量:赋值给一个未定义的变量会报错;
➤避免无效使用delete:尝试去删除变量、函数或则不可删除的属性会抛出错误;
➤避免重复的属性名和参数值:对象上重复的属性和函数参数会抛出错误(在ES6中不再是这样);
➤使得eval()更加安全:在eval()中定义的变量和函数在外部作用域不可见;
➤“安全”的消除JavaScript中this的转换:如果this是null或则undefined不在转换到全局对象。也就是说在浏览器中使用this去指向全局对象不再可行。
11、new关键字
如果使用new关键字来调用函数式很特别的形式。我们把那些用new调用的函数叫做构造函数(constructor function)。
使用了new的函数到底做了什么事情呢?
➤创建一个新的对象
➤将对象的prototype设置为构造函数的prototype
➤执行构造函数,this执行新构造的对象
➤返回该对象。如果构造函数返回对象,那么返回该构造对象。
// 为了更好地理解底层, 我们来定义new关键字 function myNew(constructor, ...arguments) { var obj = {} Object.setPrototypeOf(obj, con); return con(obj, arguments) || obj }使用new和不使用的区别在哪里呢?
function Bird() { = 2; } /* 普通的函数调用 */ let fakeBird = Bird(); con(fakeBird); // undefined /* 使用new调用 */ let realBird= new Bird(); con(realBird) // { wings: 2 }为了便于对比理解,译者额外增加了测试了一种情况:
function MBird(){ =2; return "hello"; } let realMBrid = new MBird(); con(realMBird) // { wings: 2 }你会发现,这一句return "hello"并没有生效!
12、原型和继承
原型(Prototype)是JavaScript中最容易搞混的概念,其中一个原因是prototype可以用在两个不同的情形下。
➤原型关系
每一个对象都有一个prototype对象,里面包含了所有它的原型的属性。
.__proto__是一个不正规的机制(ES6中提供),用来获取一个对象的prototype。你可以理解为它指向对象的parent。
所有普通的对象都继承.constructor属性,它指向该对象的构造函数。当一个对象通过构造函数实现的时候,__proto__属性指向构造函数的构造函数的.prototype。Object.getPrototypeOf()是ES5的标准函数,用来获取一个对象的原型。
➤原型属性
每一个函数都有一个.prototype属性,它包含了所有可以被继承的属性。该对象默认包含了指向原构造函数的.constructor属性。每一个使用构造函数创建的对象都有一个构造函数属性。
接下来通过例子来帮助理解:
function Dog(breed, name){ = breed, = name } Dog. = function() { con(`${} is a ${}`) } const rusty = new Dog('Beagle', 'Rusty'); /* .prototype 属性包含了构造函数以 及构造函数中在prototype上定义的属性。*/ con) // { describe: ƒ , constructor: ƒ } /* 使用Dog构造函数构造的对象 */ con(rusty) // { breed: "Beagle", name: "Rusty" } /* 从构造函数的原型中继承 下来的属性或函数 */ con()) // "Rusty is a Beagle" /* .__proto__ 属性指向构造函数的 .prototype属性 */ con) // { describe: ƒ , constructor: ƒ } /* .constructor 属性指向构造函数 */ con) // ƒ Dog(breed, name) { ... }JavaScript的使用可以说相当灵活,为了避免出bug了不知道,不妨接入Fundebug线上实时监控。
13、原型链
原型链是指对象之间通过prototype链接起来,形成一个有向的链条。当访问一个对象的某个属性的时候,JavaScript引擎会首先查看该对象是否包含该属性。如果没有,就去查找对象的prototype中是否包含。以此类推,直到找到该属性或则找到最后一个对象。最后一个对象的prototype默认为null。
14、拥有 vs 继承
一个对象有两种属性,分别是它自身定义的和继承的。
function Car() { } Car. = 4; Car. = 1; var myCar = new Car(); myCar.color = 'black'; /* 原型链中的属性也可以通过in来查看: */ con('airbags' in myCar) // true con) // 4 con) // undefined /* 通过hasOwnProperty来查看是否拥有该属性: */ con('airbags')) // false — Inherited con('color')) // trueObject.create(obj) 创建一个新的对象,prototype指向obj。
var dog = { legs: 4 }; var myDog = Object.create(dog); con('legs')) // false con) // 4 con === dog) // true15、继承是引用传值
继承属性都是通过引用的形式。我们通过例子来形象理解:
var objProt = { text: 'original' }; var objAttachedToProt = Object.create(objProt); con) // original // 我们更改objProt的text属性, objAttachedToProt的text属性同样更改了 objProt.text = 'prototype property changed'; con) // prototype property changed // 但是如果我们讲一个新的对象赋值给objProt, 那么objAttachedToProt的text属性不受影响 objProt = { text: 'replacing property' }; con) // prototype property changed16、经典继承 vs 原型继承
Eric Elliott的文章有非常详细的介绍:Master the JavaScript Interview: What’s the Difference Between Class & Prototypal Inheritance?
作者认为原型继承是优于经典的继承的,并提供了一个视频介绍:;feature=you
17、异步JavaScript
JavaScript是一个单线程程序语言,也就是说JavaScript引擎一次只能执行某一段代码。它导致的问题就是:如果有一段代码需要耗费很长的时间执行,其它的操作就被卡住了。
JavaScript使用Call Stack来记录函数的调用。
一个Call Stack可以看成是一摞书。最后一本书放在最上面,也最先被移走。最先放的书在最底层,最后被移走。
为了避免复杂代码占用CPU太长时间,一个解法就是定义异步回调函数。我们自己来定义一个异步函数看看:
function greetingAsync(name, callback){ let greeting = "hello, " + name ; setTimeout(_ => callback(greeting),0); } greetingAsync("fundebug", con); con("start greeting");我们在greetingAsync中构造了greeting语句,然后通过setTimeout定义了异步,callback函数,是为了让用户自己去定义greeting的具体方式。为方便起见,我们时候直接使用con。
上面代码执行首先会打印start greeting,然后才是hello, fundebug。也就是说,greetingAsync的回调函数后执行。
在网站开发中,和服务器交互的时候需要不断地发送各种请求,而一个页面可能有几十个请求。
如果我们一个一个按照顺序来请求并等待结果,串行的执行会使得网页加载很慢。通过异步的方式,我们可以先发请求,然后在回调中处理请求结果,高效低并发处理。
下面通过一个例子来描述整个执行过程:
const first = function () { con('First message') } const second = function () { con('Second message') } const third = function() { con('Third message') } first(); setTimeout(second, 0); third(); // 输出: // First message // Third message // Second message- 初始状态下,浏览器控制台没有输出,并且事件管理器(Event Manager)是空的;
- first()被添加到调用栈
- 将con("First message")加到调用栈
- con("First message")执行并输出“First message”到控制台
- con("First message")从调用栈中移除
- first()从调用栈中移除
- setTimeout(second, 0)加到调用栈
- setTimeout(second, 0)执行,0ms之后,second()被加到回调队列
- setTimeout(second, 0)从调用栈中移除
- third()加到调用栈
- con("Third message")加到调用栈
- con("Third message")执行并输出“Third message”到控制台
- con("Third message")从调用栈中移除
- third()从调用栈中移除
- Event Loop 将second()从回调队列移到调用栈
- con("Second message")加到调用栈
- con("Second message")Second message”到控制台
- con("Second message")从调用栈中移除
- Second()从调用栈中移除
特别注意的是:second()函数在0ms之后并没有立即执行,你传入到setTimeout()函数的时间和second()延迟执行的时间并不一定直接相关。
事件管理器等到setTimeout()设置的时间到期才会将其加入回调队列,而回调队列中它执行的时间和它在队列中的位置已经它前面的函数的执行时间有关。