9、原型继承和 Class 继承
涉及⾯试题:原型如何实现继承? Class 如何实现继承? Class 本质是什么? ⾸先先来讲下 class ,其实在 JS 中并不存在类, class 只是语法糖,本质还是函数
class Person {}
Person instanceof Function // true
组合继承
组合继承是最常⽤的继承⽅式
function Parent(value) {
this.val = value;
}
Parent.prototype.getValue = function () {
console.log(this.val);
};
function Child(value) {
Parent.call(this, value);
}
Child.prototype = new Parent();
const child = new Child(1);
child.getValue(); // 1
child instanceof Parent; // true
- 以上继承的⽅式核⼼是在⼦类的构造函数中通过 Parent.call(this) 继承⽗类的属性,然后改变⼦类的原型为 new Parent() 来继承⽗类的函数。
- 这种继承⽅式优点在于构造函数可以传参,不会与⽗类引⽤属性共享,可以复⽤⽗类的函数,但是也存在⼀个缺点就是在继承⽗类函数的时候调⽤了⽗类构造函数,导致⼦类的原型上多了不需要的⽗类属性,存在内存上的浪费 寄⽣组合继承
这种继承⽅式对组合继承进⾏了优化,组合继承缺点在于继承⽗类函数时调⽤了构造函数,我们只需要优化掉这点就⾏了
function Parent(value) {
this.val = value;
}
Parent.prototype.getValue = function () {
console.log(this.val);
};
function Child(value) {
Parent.call(this, value);
}
Child.prototype = Object.create(Parent.prototype, {
constructor: {
value: Child,
enumerable: false,
writable: true,
configurable: true,
},
});
const child = new Child(1);
child.getValue(); // 1
child instanceof Parent; // true
以上继承实现的核⼼就是将⽗类的原型赋值给了⼦类,并且将构造函数设置为 ⼦类,这样既解决了⽆⽤的⽗类属性问题,还能正确的找到⼦类的构造函数。 Class 继承 以上两种继承⽅式都是通过原型去解决的,在 ES6 中,我们可以使⽤ class 去 实现继承,并且实现起来很简单
class Parent {
constructor(value) {
this.val = value;
}
getValue() {
console.log(this.val);
}
}
class Child extends Parent {
constructor(value) {
super(value);
this.val = value;
}
}
let child = new Child(1);
child.getValue(); // 1
child instanceof Parent; // true
class 实现继承的核⼼在于使⽤ extends 表明继承⾃哪个⽗类,并且在⼦ 类构造函数中必须调⽤ super ,因为这段代码可以看成 Parent.call(this, value) 。
10、模块化
涉及⾯试题:为什么要使⽤模块化?都有哪⼏种⽅式可以实现模块化,各有什么特点? 使⽤⼀个技术肯定是有原因的,那么使⽤模块化可以给我们带来以下好处
- 解决命名冲突
- 提供复⽤性
- 提⾼代码可维护性 ⽴即执⾏函数
在早期,使⽤⽴即执⾏函数实现模块化是常⻅的⼿段,通过函数作⽤域解决了命名冲突、污染全局作⽤域的问题
(function(globalVariable){
globalVariable.test = function() {}
// ... 声明各种变量、函数都不会污染全局作⽤域
})(globalVariable)
AMD 和 CMD
鉴于⽬前这两种实现⽅式已经很少⻅到,所以不再对具体特性细聊,只需要了解这两者是如何使⽤的。
// AMD
define(['./a', './b'], function(a, b) {
// 加载模块完毕可以使⽤
a.do()
b.do()
})
// CMD
define(function(require, exports, module) {
// 加载模块
// 可以把 require 写在函数体的任意地⽅实现延迟加载
var a = require('./a')
a.doSomething()
})
CommonJS
CommonJS 最早是 Node 在使⽤,⽬前也仍然⼴泛使⽤,⽐如在 Webpack中你就能⻅到它,当然⽬前在 Node 中的模块管理已经和 CommonJS 有⼀些区别了
// a.js
module.exports = {
a: 1,
};
// or
exports.a = 1;
// b.js
var module = require("./a.js");
module.a; // -> log 1
var module = require("./a.js");
module.a;
// 这⾥其实就是包装了⼀层⽴即执⾏函数,这样就不会污染全局变量了,
// 重要的是 module 这⾥,module 是 Node 独有的⼀个变量
module.exports = {
a: 1,
};
// module 基本实现
var module = {
id: "xxxx", // 我总得知道怎么去找到他吧
exports: {}, // exports 就是个空对象
};
// 这个是为什么 exports 和 module.exports ⽤法相似的原因
var exports = module.exports;
var load = function (module) {
// 导出的东⻄
var a = 1;
module.exports = a;
return module.exports;
};
// 然后当我 require 的时候去找到独特的
// id,然后将要使⽤的东⻄⽤⽴即执⾏函数包装下,over
另外虽然
exports和module.exports⽤法相似,但是不能对exports直接赋值。因为var exports = module.exports这句代码表明了exports和module.exports享有相同地址,通过改变对象的属性值会对两者都起效,但是如果直接对exports赋值就会导致两者不再指向同⼀个内存地址,修改并不会module.exports起效 ES ModuleES Module是原⽣实现的模块化⽅案,与CommonJS有以下⼏个区别
CommonJS⽀持动态导⼊,也就是 require(${path}/xx.js) ,后者⽬前不⽀持,但是已有提案CommonJS是同步导⼊,因为⽤于服务端,⽂件都在本地,同步导⼊即使卡住主线程影响也不⼤。⽽后者是异步导⼊,因为⽤于浏览器,需要下载⽂件,如果也采⽤同步导⼊会对渲染有很⼤影响CommonJS在导出时都是值拷⻉,就算导出的值变了,导⼊的值也不会改变,所以如果想更新值,必须重新导⼊⼀次。但是ES Module采⽤实时绑定的⽅式,导⼊导出的值都指向同⼀个内存地址,所以导⼊值会跟随导出值变化ES Module会编译成require/exports来执⾏的
// 引⼊模块 API
import XXX from './a.js'
import { XXX } from './a.js'
// 导出模块 API
export function a() {}
export default function() {}
11、实现⼀个简洁版的promise
// 三个常量⽤于表示状态
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";
function MyPromise(fn) {
const that = this;
this.state = PENDING;
// value 变量⽤于保存 resolve 或者 reject 中传⼊的值
this.value = null;
// ⽤于保存 then 中的回调,因为当执⾏完 Promise 时状态可能还是等待中,这时候应该把
that.resolvedCallbacks = [];
that.rejectedCallbacks = [];
function resolve(value) {
// ⾸先两个函数都得判断当前状态是否为等待中
if (that.state === PENDING) {
that.state = RESOLVED;
that.value = value;
// 遍历回调数组并执⾏
that.resolvedCallbacks.map((cb) => cb(that.value));
}
}
function reject(value) {
if (that.state === PENDING) {
that.state = REJECTED;
that.value = value;
that.rejectedCallbacks.map((cb) => cb(that.value));
}
}
// 完成以上两个函数以后,我们就该实现如何执⾏ Promise 中传⼊的函数了
try {
fn(resolve, reject);
} catch (e) {
reject(e);
}
}
// 最后我们来实现较为复杂的 then 函数
MyPromise.prototype.then = function (onFulfilled, onRejected) {
const that = this;
// 判断两个参数是否为函数类型,因为这两个参数是可选参数
onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
onRejected =
typeof onRejected === "function"
? onRejected
: (e) => {
throw e;
};
// 当状态不是等待态时,就去执⾏相对应的函数。如果状态是等待态的话,就往回调函数中 push
if (this.state === PENDING) {
this.resolvedCallbacks.push(onFulfilled);
this.rejectedCallbacks.push(onRejected);
}
if (this.state === RESOLVED) {
onFulfilled(that.value);
}
if (this.state === REJECTED) {
onRejected(that.value);
}
};
12、Event Loop
12.1 进程与线程
涉及⾯试题:进程与线程区别? JS 单线程带来的好处?
- JS 是单线程执⾏的,但是你是否疑惑过什么是线程?
- 讲到线程,那么肯定也得说⼀下进程。本质上来说,两个名词都是 CPU ⼯作时间⽚的⼀个描述。
- 进程描述了 CPU 在运⾏指令及加载和保存上下⽂所需的时间,放在应⽤上来说就代表了⼀个程序。线程是进程中的更⼩单位,描述了执⾏⼀段指令所需的时间
把这些概念拿到浏览器中来说,当你打开⼀个 Tab ⻚时,其实就是创建了⼀ 个进程,⼀个进程中可以有多个线程,⽐如渲染线程、 JS 引擎线程、 HTTP 请求线程等等。当你发起⼀个请求时,其实就是创建了⼀个线程,当请 求结束后,该线程可能就会被销毁
- 上⽂说到了 JS 引擎线程和渲染线程,⼤家应该都知道,在 JS 运⾏的时候可能会阻⽌UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM ,如果在 JS 执⾏的时候 UI 线程还在⼯作,就可能导致不能安全的渲染 UI 。这其实也是⼀个单线程的好处,得益于 JS 是单线程运⾏的,可以达到节省内存,节约上下⽂切换时间,没有锁的问题的好处
12.2 执⾏栈
涉及⾯试题:什么是执⾏栈? 可以把执⾏栈认为是⼀个存储函数调⽤的栈结构,遵循先进后出的原则 当开始执⾏ JS 代码时,⾸先会执⾏⼀个 main 函数,然后执⾏我们的代码。根据先进后出的原则,后执⾏的函数会先弹出栈,在图中我们也可以发 现, foo 函数后执⾏,当执⾏完毕后就从栈中弹出了 在开发中,⼤家也可以在报错中找到执⾏栈的痕迹
function foo() {
throw new Error("error");
}
function bar() {
foo();
}
bar();
⼤家可以在上图清晰的看到报错在 foo 函数, foo 函数⼜是在 bar 函数中调⽤的 当我们使⽤递归的时候,因为栈可存放的函数是有限制的,⼀旦存放了过多的函数且没有得到 释放的话,就会出现爆栈的问题
function bar() {
bar()
}
bar()
12.3 浏览器中的 Event Loop
涉及⾯试题:异步代码执⾏顺序?解释⼀下什么是
Event Loop?
众所周知 JS 是⻔⾮阻塞单线程语⾔,因为在最初 JS 就是为了和浏览器交 互⽽诞⽣的。如果 JS 是⻔多线程的语⾔话,我们在多个线程中处理 DOM 就可能会发⽣问题(⼀个线程中新加节点,另⼀个线程中删除节点)
- JS 在执⾏的过程中会产⽣执⾏环境,这些执⾏环境会被顺序的加⼊到执⾏栈中。如果遇到异步的代码,会被挂起并加⼊到 Task (有多种 task ) 队列中。⼀旦执⾏栈为空,Event Loop 就会从 Task 队列中拿出需要执⾏的代码并放⼊执⾏栈中执⾏,所以本质上来说 JS 中的异步还是同步⾏为
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
console.log("script end");
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务( microtask ) 和 宏任务( macrotask )。在 ES6 规范中,microtask 称为 jobs , macrotask 称为 task
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise((resolve) => {
console.log('Promise')
resolve()
}).then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTime
以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务⽽ setTimeout 属于宏任务 微任务
process.nextTickpromiseObject.observeMutationObserver宏任务scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering
宏任务中包括了 script ,浏览器会先执⾏⼀个宏任务,接下来有异步代码的话就先执⾏微任务
所以正确的⼀次 Event loop 顺序是这样的
- 执⾏同步代码,这属于宏任务
- 执⾏栈为空,查询是否有微任务需要执⾏
- 执⾏所有微任务
- 必要的话渲染 UI
- 然后开始下⼀轮
Event loop,执⾏宏任务中的异步代码
通过上述的
Event loop顺序可知,如果宏任务中的异步代码有⼤量的计算并且需要操作 DOM 的话,为了更快的响应界⾯响应,我们可以把操作 DOM放⼊微任务中
13、⼿写 call、apply 及 bind 函数
⾸先从以下⼏点来考虑如何实现这⼏个函数
- 不传⼊第⼀个参数,那么上下⽂默认为
window - 改变了
this指向,让新的对象可以执⾏该函数,并能接受参数 实现 call - ⾸先
context为可选参数,如果不传的话默认上下⽂为window - 接下来给
context创建⼀个 fn 属性,并将值设置为需要调⽤的函数 - 因为
call可以传⼊多个参数作为调⽤函数的参数,所以需要将参数剥离出来 - 然后调⽤函数并将对象上的函数删除
Function.prototype.myCall = function(context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
context = context || window
context.fn = this
const args = [...arguments].slice(1)
const result = context.fn(...args)
delete context.fn
return result
}
apply实现
apply 的实现也类似,区别在于对参数的处理
Function.prototype.myApply = function (context) {
if (typeof this !== "function") {
throw new TypeError("Error");
}
context = context || window;
context.fn = this;
let result;
// 处理参数和 call 有区别
if (arguments[1]) {
result = context.fn(...arguments[1]);
} else {
result = context.fn();
}
delete context.fn;
return result;
};
bind 的实现
bind的实现对⽐其他两个函数略微地复杂了⼀点,因为bind需要返回⼀个函数,需要判断⼀些边界问题,以下是bind的实现
bind返回了⼀个函数,对于函数来说有两种⽅式调⽤,⼀种是直接调⽤,⼀种是通过new的⽅式,我们先来说直接调⽤的⽅式- 对于直接调⽤来说,这⾥选择了
apply的⽅式实现,但是对于参数需要注意以下情况:因为bind可以实现类似这样的代码f.bind(obj, 1)(2),所以我们需要将两边的参数拼接起来,于是就有了这样的实现args.concat(...arguments) - 最后来说通过
new的⽅式,在之前的章节中我们学习过如何判断this,对于new的情况来说,不会被任何⽅式改变this,所以对于这种情况我们需要忽略传⼊的this
Function.prototype.myBind = function (context) {
if (typeof this !== "function") {
throw new TypeError("Error");
}
const _this = this;
const args = [...arguments].slice(1);
// 返回⼀个函数
return function F() {
// 因为返回了⼀个函数,我们可以 new F(),所以需要判断
if (this instanceof F) {
return new _this(...args, ...arguments);
}
return _this.apply(context, args.concat(...arguments));
};
};
14、new
涉及⾯试题: new 的原理是什么?通过 new 的⽅式创建对象和通过字⾯量创建有什么区别? 在调⽤ new 的过程中会发⽣四件事情
- 新⽣成了⼀个对象
- 链接到原型
- 绑定 this
- 返回新对象
根据以上⼏个过程,我们也可以试着来⾃⼰实现⼀个 new
- 创建⼀个空对象
- 获取构造函数
- 设置空对象的原型
- 绑定 this 并执⾏构造函数
- 确保返回值为对象
function create() {
let obj = {};
let Con = [].shift.call(arguments);
obj.__proto__ = Con.prototype;
let result = Con.apply(obj, arguments);
return result instanceof Object ? result : obj;
}
- 对于对象来说,其实都是通过
new产⽣的,⽆论是function Foo()还是let a = {b : 1 }。 - 对于创建⼀个对象来说,更推荐使⽤字⾯量的⽅式创建对象(⽆论性能上还是可读性)。因为你使⽤
new Object()的⽅式创建对象需要通过作⽤域链⼀层层找到Object,但是你使⽤字⾯量的⽅式就没这个问题
function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字⾯量内部也是使⽤了 new Object()
15、instanceof 的原理
涉及⾯试题: instanceof 的原理是什么?
instanceof可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型prototype
实现⼀下 instanceof
- ⾸先获取类型的原型
- 然后获得对象的原型
- 然后⼀直循环判断对象的原型是否等于类型的原型,直到对象原型为
null,因为原型链最终为null
function myInstanceof(left, right) {
let prototype = right.prototype;
left = left.__proto__;
while (true) {
if (left === null || left === undefined) return false;
if (prototype === left) return true;
left = left.__proto__;
}
}
16、为什么 0.1 + 0.2 != 0.3
涉及⾯试题:为什么 0.1 + 0.2 != 0.3 ?如何解决这个问题? 原因,因为 JS 采⽤ IEEE 754 双精度版本( 64 位),并且只要采⽤ IEEE 754 的语⾔都有该问题
我们都知道计算机是通过⼆进制来存储东⻄的,那么 0.1 在⼆进制中会表示为
// (0011) 表示循环
0.1 = 2^-4 * 1.10011(0011)
我们可以发现,
0.1在⼆进制中是⽆限循环的⼀些数字,其实不只是0.1,其实很多⼗进制⼩数⽤⼆进制表示都是⽆限循环的。这样其实没什么问题,但是JS采⽤的浮点数标准却会裁剪掉我们的数字。 IEEE 754 双精度版本(64位)将 64 位分为了三段
- 第⼀位⽤来表示符号
- 接下去的
11位⽤来表示指数 - 其他的位数⽤来表示有效位,也就是⽤⼆进制表示
0.1中的10011(0011)
那么这些循环的数字被裁剪了,就会出现精度丢失的问题,也就造成了
0.1不再是0.1了,⽽是变成了0.100000000000000002
0.100000000000000002 === 0.1 // true
那么同样的,
0.2在⼆进制也是⽆限循环的,被裁剪后也失去了精度变成了0.200000000000000002
0.200000000000000002 === 0.2 // true
所以这两者相加不等于
0.3⽽是0.300000000000000004
0.1 + 0.2 === 0.30000000000000004 // true
那么可能你⼜会有⼀个疑问,既然
0.1不是0.1,那为什么console.log(0.1)却是正确的呢?
因为在输⼊内容的时候,⼆进制被转换为了⼗进制,⼗进制⼜被转换为了字符串,在这个转换的过程中发⽣了取近似值的过程,所以打印出来的其实是⼀个近似值,你也可以通过以下代码来验证
console.log(0.100000000000000002) // 0.1