1、全局对象与立即执行函数
全局对象
在 JS 的运行环境中存在一个全局对象,在浏览器中是 Window,在 Node 中是 Global。
全局对象有以下几个特性:
- 属性可以直接访问;
- 在代码中给未声明的变量赋值将会被理解为给全局对象的属性赋值;
- 代码中的所有全局变量、全局函数都会被添加到全局对象上。
其中第 3 点被称为全局污染/全局暴露。如果不希望发生污染,那么可以使用立即执行函数来改变其作用域。
代码示例:
(function () {
var a = 1;
function fnc () {}
})();
这样一来,原本应为全局变量、全局函数的 a 和 fnc 的作用域变成了这个立即执行函数。同时这个立即执行函数是一个函数表达式直接执行,被称为匿名函数,也不会被添加到全局对象上。
React 通过 webpack 打包出来的 js 文件也是通过这种方式避免全局污染的。
如果同时希望有部分暴露到全局的变量或函数,可以通过 return 的方式实现。
代码示例:
var global = (function () {
var a = 1;
var b = 2;
function fnc () {}
function globalFnc () {}
return {
b,
globalFnc
};
})();
如果有多个变量或函数,可以将其包裹成一个对象。在外部用一个变量接收返回值,即可将其暴露在全局对象上。
2、构造函数
代码示例:
function Person (name, age) {
this.name = name;
this.age = age;
}
const person = new Person("Toby", 18); // { name: "Toby", age: 18 }
- 构造函数的写法与普通函数并无二致,区别在于构造函数需要通过
new关键字调用; - 构造函数会自动创建一个空对象,并且改变
this的指向为该对象,最终隐式地return该对象,除非有显式的return返回其它对象(注意这里必须是其它对象,原始类型并不会改变return的结果); - JS 中所有的对象都是通过构造函数创建的。
3、原型
- 每一个函数都自带一个属性
prototype,它的值是一个普通对象,被称为原型对象; - 原型对象上存在一个属性
constructor指向构造函数; - 通过构造函数创建出来的对象被称为实例;
- 每一个实例都有一个特殊属性
__proto__,被称为隐式原型,它指向构造的函数的原型对象; - 访问实例的属性时,先在自身寻找,随后会向上到其隐式原型上寻找;
- 函数比较特殊,它作为一个函数既有
prototype属性,同时又作为Funtion构造函数的实例有__proto__属性;
新创建的函数的原型是一个空对象,我们可以将所有实例都会用到的公有属性/方法写在原型中而不是在每一个实例中,这样可以节省内存空间。
代码示例:
function Person (name, age) {
this.name = name;
this.age = age;
}
Person.prototype.speak = function (words) {
console.log(" say: ", words);
}
const person = new Person("Toby", 18);
person.speak(" Hello "); // " say: Hello "
console.log(Person.prototype === person.__proto__); // true
hanOwnProperty
由于上述第 5 点的原因,在遍历对象的 key 值时,会将其隐式原型上的属性也遍历到。
而这个方法用于判断属性是否存在于实例本身,而非其隐式原型上。
4、this
函数中的 this 指向完全取决于函数由谁调用,与函数的声明毫无关系。
代码示例:
function fnc () {
console.log(this);
}
fnc(); // Window
const obj = {
method: fnc
}
obj.method(); // obj
const f = obj.method;
f(); // Window
改变 this 指向
call/apply/bind 可以改变函数中 this 的指向,它们是 Function 原型对象上的方法。
自行实现简单的 call 方法
Function.prototype.myCall = function (context, ...args) { // call 方法由函数实例调用,所以定义在其构造函数的原型对象上
const fnc = this; // 同理,call 方法由函数实例调用,谁调用 call 方法,call 中的 this 就指向谁,由此可获取需要调用的函数
context.__proto__.fnc = fnc; // 改变 this 指向需要由 context 来调用函数,但 context 属性上并没有该函数,所以先添加该属性至其隐式原型上
context.fnc(...args); // 调用该函数,实现 this 指向的改变
delete context.__proto__.fnc; // 调用完毕后删除,避免造成污染
}
5、原型链
两点特殊规则:
Object.prototype.__proto__ -> nullFunction.__proto__ -> Function.prototype
instanceof
语法: a instanceof b
原理: 去 a 的原型链上寻找有没有 constructor 为 b 的属性
代码示例:
const arr = [1, 2, 3];
console.log(arr instanceof Array); // true
console.log(arr instanceof Function); // false
console.log(arr instanceof Object); // true
constructor 属性在构造函数的原型对象上,值为构造函数。
关于 __proto__
JS 代码中不建议直接使用 __proto__,因此提供了几个方法以供我们使用 __proto__。
Object.create()
创建一个对象,接收一个参数作为创建对象的隐式原型。
通过该方法可以创建一个很干净的对象,没有原型链的对象。
也可以通过传入 Array.prototype,使创建对象的隐式原型指向 Array 的原型对象,此时对象就成为了 Array 的实例。
Object.getPrototypeof()
获取一个对象实例的隐式原型,这个方法也可以用来判断一个对象的类型:
Object.getPrototypeof(a) === Array.prototype
通过判断隐式原型与构造函数的原型对象是否相等即可。用该方法无法判断数组为对象,因为它不会顺着原型链向上寻找。
Object.setPrototype()
设置一个对象实例的隐式原型。只接收 Object 和 null。
6、继承
类的继承 extends 关键字是 ES6 的新语法,在这之前,JS 中的继承需要我们自行实现。继承本质上是原型链的改变。
代码示例:
function User (username, password) {
this.username = username;
this.password = password;
}
User.prototype.playFreeVedio = function () {};
function VipUser (username, password, expiredTime) {
User.call(this, username, password);
this.expiredTime = expiredTime;
}
VipUser.prototype.playPayVedio = function () {};
Object.setPropertyOf(VipUser.prototype, User.prototype);
在上面这个例子中,VipUser 继承于 User,User 派生出 VipUser。
首先,在 VipUser 中通过 call 改变 this 指向,使 VipUser 中的对象赋上 User 中的属性。
然后,将 VipUser 的原型对象的隐式原型(原本应指向 Object 的原型对象)指向 User 的原型对象,这样 VipUser 的实例的原型链上就多了 User 原型对象上的属性和方法。
即使 VipUser 原型对象上重写 playFreeVedio 方法也没有问题,原型链会优先调用 VipUser.prototype.playFreeVedio 方法。
7、包装类
当你尝试着把原始类型(number, string, boolean)当作对象使用时,JS 会将其转换为对应包装类(Number, String, Boolean)的实例。
代码示例:
const num = 1.23;
console.log(num.toFixed(1)); // 1.2
toFixed 方法就是在 Number.prototype 上的,JS 将 num 转换为了 new Number(num)。
像 toFixed 这样在构造函数原型对象上的方法被称为实例方法。其它的,像 Number.parseInt 这样直接在构造函数 Number 上(或者说是在对象实例上的方法)的方法被称为静态方法。(属性也是如此)
8、标准库方法
String.prototype.padStart
String.prototype.padStart(x, y)
将字符串在开始位置用 y 补充到 x 位,与之对应的 padEnd 是从结束位置开始。
可用于时间的补零逻辑。
Array.prototype.slice
slice 方法会返回一个新数组,除了对数组使用外,还可以对伪数组使用,将其转换为真正的数组。
代码示例:
const fakeArr = {
0: 1,
1: 2,
length:2
};
const realArr = Array.prototype.slice.call(fakeArr); // [1, 2]
伪数组是指上述这样每一项的表达都和真正的数组一样,甚至包括 length 属性的对象,但不能调用 Array 原型对象上的方法。
伪数组通过调用 slice 方法会返回一个真正的数组。
9、WebAPI
WebAPI 是由浏览器提供的一套 API,用于操作浏览器窗口和界面,与标准库不同。
WebAPI 分为两个部分:
- BOM(Browser Object Model)浏览器模型,提供和浏览器操相关的操作;
- DOM(Document Object Model)文档模型,提供和页面相关的操作。
10、DOM
document.getElementsByTagName, document.getElementsByClassName得到的结果是伪数组;- 上述的两种方法已经不常用了,功能更强大的方法是:
document.querySelector, document.querySelectorAll,可以直接使用 CSS 选择器获取元素。同时它也取代了jQuery; dom.children和dom.childNodes的区别:前者获取子元素,后者获取子节点。DOM 结构中的文本属于节点,因此只有dom.childNodes可以获取到。
DOM 标签上的属性
DOM 标签上的属性分为两种:标准属性和自定义属性。
标准属性可以直接通过 dom.xxx 获取,dom.xxx 也可以直接赋值。
标准属性的获取规则:
- 布尔属性会被自动转换为 boolean;
- 路径类的属性会自动转换为绝对路径;
- 标准属性始终存在,不管是否在标签中书写。
自定义属性通过 dom.setAttribute 和 dom.getAttribute 获取和赋值,也可以直接在标签中书写。
如果使用 dom.getAttribute 获取标准属性将以代码中的标签属性为准,上述的获取规则也不再生效。
DOM 监听事件
- 事件处理函数中的
this指向注册监听事件的 DOM 元素。
11、DOM 事件的默认行为
a 标签的跳转,form 表单提交的自动刷新页面,input 输入内容等都是 DOM 元素的默认行为。
e.preventDefault() 可以阻止默认行为。
12、事件传播机制
JS 中的事件传播过程分为两个阶段:捕获阶段和冒泡阶段。
捕获阶段是从根节点开始依次向下传递,冒泡阶段是从目标节点开始依次向上传递。
不管我们有没有注册监听事件,这两个阶段都会发生。注册监听事件只是让我们能够介入这两个阶段的某一部分。
举个例子:
页面有如上的层级结构。在 div 上注册监听点击事件,然后点击 button。此时的冒泡阶段顺序为:button 触发点击事件 -> 向上冒泡 -> div 触发点击事件 -> 向上冒泡 -> html 触发点击事件。
我们做的只是在 div 触发点击事件时监听事件的发生。
div.addEventListener("click", e => {
console.log(this); // div
console.log(e.target); // button
});
捕获阶段
一般注册事件都是在冒泡阶段进行监听,如果需要在捕获阶段监听事件,将 addEventListener 的第 3 个参数设置为 true 即可(默认值是 false)。
e.target 和 e.stopPropagation
之前说过:事件处理函数中的 this 指向注册监听事件的 DOM 元素。
即使此时我们点击的是 button,但监听事件中的 this 仍指向 div。
但可以通过监听事件中的 e.target 获取 button。e.target 表示的是事件目标节点。
而 e.stopPropagation 是用来阻止冒泡的,如果在上面的监听事件中增加此代码,则继续向上的冒泡将被阻止,html 将不会触发点击事件。
事件委托
通过上述逻辑,我们可以实现一个叫做事件委托的机制。
假如上面的 div 下不止一个 button,但要监听每一个 button 的点击事件。如果在每一个 button 上都注册监听点击事件,那么将造成不小的开销。
我们可以将监听事件注册在 div 上,通过 e.target 就可以获取到每一次点击的不同 button,这样可以大大减少开销。
这样的逻辑就相当于将 button 的监听事件委托给了父级 div,进行统一管理。
使用场景:
- 大量的子级元素需要重复注册监听事件时用以减少开销;
- 子级元素是动态变化的,需要重复注册或移除监听事件。
13、for 循环中的作用域
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
上述代码打印的结果为:2, 2, 2。
这是因为 var 声明变量的作用域是全局作用域,setTimeout 回调函数执行时寻找 i 的值就会到全局去寻找,所以 i 的值是相同的。
要解决这个问题,就得让 i 的作用域各不相同。
第一种方法是用 let 代替 var,let 声明变量的作用域是块级作用域,每次循环声明的 i 的作用域只在当次循环有效。
第二种方法是创建一个函数,利用函数作用域改变 i 的作用域。
for (var i = 0; i < 3; i++) {
(function (a) {
setTimeout(function () {
console.log(a);
}, 1000);
})(i);
}
改造之后,a 的值沿着作用域链向上寻找,到自执行函数的作用域中寻找,就达到了每次循环都能传递不同的值的目的。
14、防抖
防抖 —— Debounce,当函数频繁执行,且除最后一次执行外,前面的执行都无意义,也即是以最后一次执行为准时,为了减少函数的执行开销,需要对函数进行防抖处理。
实现逻辑:
- 防抖函数接收两个参数:要执行的函数和延迟执行的延时。返回一个代执行的函数。
function debounce (fn, delay) {
return function () {
}
}
- 延迟执行函数逻辑,每当函数触发时更新这个延时,直至函数不再触发。
function debounce (fn, delay) {
let timerId;
return function () {
timerId && clearTimeout(timerId);
timerId = setTimeout(function () {
fn();
}, delay);
}
}
- 处理函数中的
this指向,最终执行的是返回出去的匿名函数,所以将fn的this指向匿名函数的this。
function debounce (fn, delay) {
let timerId;
return function () {
timerId && clearTimeout(timerId);
const that = this;
timerId = setTimeout(function () {
fn.apply(that);
}, delay);
}
}
- 传递参数。
function debounce (fn, delay) {
let timerId;
return function () {
timerId && clearTimeout(timerId);
const that = this;
const args = Array.prototype.slice(arguments);
timerId = setTimeout(function () {
fn.apply(that, args);
}, delay);
}
}
这里注意:arguments 是一个伪数组,我们用 slice 将其转换为数组后才可传递给 apply 方法。
15、浏览器渲染
- 获取
HTML文件,从上至下进行解析; - 遇到
js, css文件会阻塞解析,直至文件加载完成; - 解析过程中同时生成 DOM 树和渲染树;
- 计算每个元素的尺寸和位置,准备渲染到页面上,这个步骤称为
reflow; - 渲染到页面,这个步骤称为
repaint。
- 监听加载完成有两个常用的事件:
DOMContentLoaded和load。前者在 DOM 元素加载并解析完毕后触发。后者在前者的基础上,并且所依赖的资源也全部加载完毕后触发。 - 渲染树是指 CSS 样式属性的计算过程。
reflow比repaint更加耗费性能。- 获取元素的尺寸和位置,直接或间接改变元素的尺寸和位置会触发
reflow。 - js 代码中有连续的会触发
reflow的代码,浏览器会将reflow延后到该部分代码执行完毕后触发。 - 所有导致
reflow触发的代码都会触发repaint。 - 仅影响元素外观的代码只会触发
repaint。
16、数组
new Array()
通过 new Array(x) 构造的数组只有长度,但没有实际元素,被称为稀松数组。
所谓稀松数组,是指元素之间有空隙的数组。例如:[1, , 3, 4, , 6],输出数组时,空白项为 empty。
要正常使用数组的 API,需要使用 fill 将数组填充上元素:new Array(x).fill(y)。
17、对象
属性描述符
对象的每一个属性都有对应的属性描述符,ES5 提供了方法让我们得以操作属性描述符。
举个例子:
const obj = { name: "Toby", age: 18 };
// 获取属性的描述符
Object.getOwnPropertyDescriptor(obj, "name");
// name 的属性描述符:
{
value: "Toby", // 该属性的值
configurable: true, // 该属性的描述符是否允许修改
enumerable: true, // 该属性是否允许遍历(影响 for-in 循环)
writable: true // 该属性是否允许修改
}
// 设置属性的描述符
Object.defineProperty(obj, "name", { ...... });
getter 和 setter
属性的描述符中,除了上述的属性,还有两个特殊的函数:get 和 set,通过它们可以把属性的取值和赋值变成函数调用。
const obj = {};
Object.defineProperty(obj, "name", {
get () {
return "Toby";
},
set () {
......
}
});
obj.name = "pp"; // 调用 set 方法
console.log(obj.name); // "Toby"(调用 get 方法)
get 和 set 方法中的 this 指向 obj 对象。
如果不通过 defineProperty,也可以直接写 get 和 set 方法:
const obj = {
name: "",
age: 18,
get name () {
if (this.age > 18) {
return "Toby";
} else {
return "pp";
}
},
set name (value) {
this.name = value;
}
};
Object.is()
对两个变量进行严格相等的比较,但比严格相等更加精确。
NaN === NaN; // false
Object.is(NaN, NaN); // true
+0 === -0; // true
Object.is(+0, -0); // false
Set
Set 是一种数据集合,用于保存一系列唯一的值。
基本用法
- 使用
Set.prototype.size获取集合长度。 - 使用
Set.prototype.add()和Set.prototype.delete()添加或删除元素,参数直接传递value,在集合中没有键的概念。 Set.prototype.entries()返回包含每个元素[value, value]的迭代器对象,通过for of循环可遍历该对象。Set.prototype.keys()和Set.prototype.values()一样,返回包含每个元素值的迭代器对象。
与数组的关系
// 数组转换为集合
new Set(array);
// 集合转换为数组
[...set]
// 数组去重
[...new Set(array)]
与字符串的关系
会将字符串的每一个字符作为元素保存在集合中,并进行去重。
Map
Map 是一种数据集合,与 Object 类似,用于保存一系列键值对。
与 Object 的区别:
- Object 的键只能为
String或Symbol类型,而 Map 可以为任何类型。 - Map 的键按照插入的顺序排列,普通 Object 的键是有序的,但并非一定如此,Object 不适合依赖键的顺序。
- Map 可以通过
Map.prototype.size()直接获取元素数量,而 Object 不行。 - Map 是可迭代对象,可以直接迭代。
- 在添加/删除键值对方面,Map 具有更好的性能。
- Object 支持序列化(
JSON),Map 没有原生的支持。
18、函数
箭头函数
- 不能使用
new调用。 - 没有
prototype属性。 - 没有
arguments。 - 没有
this,箭头函数中的this根据作用域链向外寻找。
类
以前的类是这样写的:
// 构造函数
function User () {
......
}
// 静态方法
User.checkin () {
......
}
// 实例方法
User.prototype.checkout () {
......
}
使用 class 关键字后类是这样的:
class User {
// 构造函数
constructor () {
......
}
// static 关键字,静态方法
static checkin () {
......
}
// 实例方法
checkout() {
......
}
}
19、事件循环
浏览器的进程与线程
浏览器是一个多进程多线程的应用程序,内部工作极其复杂,为避免相互影响,导致连环崩溃,故会启动多个进程,每一个进程都有独立的内存空间。
浏览器中的主要进程有以下 3 个:
- 浏览器进程
- 网络进程
- 渲染进程
浏览器进程
浏览器进程主要负责浏览器界面显示、用户交互、管理其它子进程。
网络进程
网络进程主要负责加载网络资源。
渲染进程(重点)
默认情况下,每一个网页标签都会启动一个渲染进程(也可以配置为每一个站点对应一个渲染进程),以保证不同的标签页之间不会相互影响。
渲染进程会启动一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。
渲染主线程是浏览器中最繁忙的线程,它要处理的任务包括但不限于:
- 解析 HTML、CSS,计算元素样式、布局。
- 执行 JS 代码,包括事件处理函数,计时器的回调函数。
渲染主线程是如何工作的:
渲染主线程会进入一个无限循环,每一次循环会去消息队列中检查是否有需要执行的任务,有则执行,无则休眠。浏览器中的其它线程也可以向消息队列中添加任务,例如处理用户交互的线程,处理计时器事件的线程等。
这个过程被称为事件循环(消息循环)。
异步
由于 JS 在浏览器的渲染主线程中运行,所以它是一门单线程的语言。同步会导致主线程的堵塞,所以需要异步来保证单线程的流畅运行。
当某些任务发生时,例如计时器、网络请求、事件监听,主线程将任务交由其它线程处理,自身继续执行后续的代码。当其它线程完成时,会将先前传递的回调函数包装成任务,加入消息队列,交由主线程处理。
总结:
- 单线程是异步产生的原因;
- 事件循环是异步的实现方式。
JS 阻塞渲染
JS 代码的执行和渲染在同一线程中,当主线程执行到会引起页面重渲染的代码后,会添加一个任务到消息队列中,然后等待主线程的 JS 代码执行完毕后执行。
故如果 JS 代码的执行耗时很长,会造成页面渲染的卡顿。
优先级
任务没有优先级,但消息队列有优先级。
浏览器中有很多队列,同一类型的任务必须在同一个队列中。在一次事件循环中,浏览器可以根据实际情况从不同的队列取出任务执行。
但浏览器中必须有一条微任务队列,这条队列的优先级是最高的。
随着浏览器的复杂度急剧提升,W3C 不再使用过去笼统的宏任务队列。
例如目前 Chrome 的实现,至少包含:
- 延时队列:用于存放计时器结束后的回调任务,优先级中。
- 网络队列:用于处理网络活动产生的任务,优先级中。
- 交互队列:用于存放用户操作后产生的事件处理任务,优先级高。
- 微队列:优先级最高。
在优先级的基础上,由浏览器自行决定从哪一个队列获取任务执行。
20、Promise
then方法会返回一个新的Promise对象,所以Promise可以通过then实现链式调用。then方法接收一个函数,如果传递的是其它东西,那么返回的Promise对象和上一个Promise一致。- 新的 Promise 对象没有
resolve和reject方法,它的状态取决于后续处理:
- 没有对先前
Promise结果进行对应的处理,继承先前Promise的状态和数据。 - 对先前
Promise结果进行了对应的处理,处理过程无报错的,状态为完成,数据为return的返回值;处理过程报错的,状态为失败,数据为异常对象。 - 对先前
Promise结果进行了对应的处理,return了一个新的Promise对象,继承这个新Promise对象的状态和数据。
举个例子:
request().then(res => {
}).catch(error => {
});
假设在进行网络请求时,接口返回异常。之所以能被 catch 捕获到,是因为 then 没有对接口请求的失败进行处理,因此继承了接口请求的状态和数据,被后续的 catch 处理。catch 并不能直接捕获到接口请求的异常。
async 和 await
这两个关键字可以更加优雅地表达 Promise。
async
async 用于修饰函数,被修饰的函数必定返回一个 Promise 对象。
正常情况下,该 Promise 对象的状态为 Fulfilled,数据为函数 return 的值。但如果函数执行过程中抛出了异常则状态为 Rejected,原因为抛出的异常。
await
await 用于等待一个 Promise 对象完成,获取 Promise 对象成功的数据,必须在 async 修饰的函数中使用。
- 如果在
await后跟着的数据不是Promise对象,那么会被转换为Promise对象。 await只能获取Promise对象成功的数据,如果Promise对象失败了,那么需要用try...catch来捕获异常。
async 和 await 在事件循环中的表现
await 代码后续的代码可以理解为 Promise.then 中的代码,需要等 Promise 对象的状态确定,并且没有报错才会被添加至微队列中。
注意:Promise 成功或失败的后续处理是在 Promise 对象状态确定之后才被添加到微队列中的。