【面试题】高频考点JavaScript语言特性必知必会
1 typeof类型判断
typeof
是否能正确判断类型?instanceof
能正确判断对象的原理是什么
typeof
对于原始类型来说,除了null
都可以显示正确的类型
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof
对于对象来说,除了函数都会显示object
,所以说typeof
并不能准确判断变量到底是什么类型
typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'
如果我们想判断一个对象的正确类型,这时候可以考虑使用
instanceof
,因为内部机制是通过原型链来判断的
const Person = function() {}
const p1 = new Person()
p1 instanceof Person // true
var str = 'hello world'
str instanceof String // false
var str1 = new String('hello world')
str1 instanceof String // true
对于原始类型来说,你想直接通过
instanceof
来判断类型是不行的。
更多判断数据类型的方法请点这里 【史上最全】JavaScript数据类型判断
2 类型转换
首先我们要知道,在
JS
中类型转换只有三种情况,分别是:
- 转换为布尔值
- 转换为数字
- 转换为字符串
转Boolean
在条件判断时,除了
undefined
,null
,false
,NaN
,''
,0
,-0
,其他所有值都转为true
,包括所有对象
对象转原始类型
对象在转换类型的时候,会调用内置的
[[ToPrimitive]]
函数,对于该函数来说,算法逻辑一般来说如下
- 如果已经是原始类型了,那就不需要转换了
- 调用
x.valueOf()
,如果转换为基础类型,就返回转换的值 - 调用
x.toString()
,如果转换为基础类型,就返回转换的值 - 如果都没有返回原始类型,就会报错
当然你也可以重写
Symbol.toPrimitive
,该方法在转原始类型时调用优先级最高。
let a = {
valueOf() {
return 0
},
toString() {
return '1'
},
[Symbol.toPrimitive]() {
return 2
}
}
1 + a // => 3
四则运算符
它有以下几个特点:
- 运算中其中一方为字符串,那么就会把另一方也转换为字符串
- 如果一方不是字符串或者数字,那么会将它转换为数字或者字符串
1 + '1' // '11'
true + true // 2
4 + [1,2,3] // "41,2,3"
- 对于第一行代码来说,触发特点一,所以将数字
1
转换为字符串,得到结果'11'
- 对于第二行代码来说,触发特点二,所以将
true
转为数字1
- 对于第三行代码来说,触发特点二,所以将数组通过
toString
转为字符串1,2,3
,得到结果41,2,3
另外对于加法还需要注意这个表达式
'a' + + 'b'
'a' + + 'b' // -> "aNaN"
- 因为
+ 'b'
等于NaN
,所以结果为"aNaN"
,你可能也会在一些代码中看到过+ '1'
的形式来快速获取number
类型。 - 那么对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字
4 * '3' // 12
4 * [] // 0
4 * [1, 2] // NaN
比较运算符
- 如果是对象,就通过
toPrimitive
转换对象 - 如果是字符串,就通过
unicode
字符索引来比较
let a = {
valueOf() {
return 0
},
toString() {
return '1'
}
}
a > -1 // true
在以上代码中,因为
a
是对象,所以会通过valueOf
转换为原始类型再比较值。
3 This
我们先来看几个函数调用的场景
function foo() {
console.log(this.a)
}
var a = 1
foo()
const obj = {
a: 2,
foo: foo
}
obj.foo()
const c = new foo()
- 对于直接调用
foo
来说,不管foo
函数被放在了什么地方,this
一定是window
- 对于
obj.foo()
来说,我们只需要记住,谁调用了函数,谁就是this
,所以在这个场景下foo
函数中的this
就是obj
对象 - 对于
new
的方式来说,this
被永远绑定在了c
上面,不会被任何方式改变this
说完了以上几种情况,其实很多代码中的
this
应该就没什么问题了,下面让我们看看箭头函数中的this
function a() {
return () => {
return () => {
console.log(this)
}
}
}
console.log(a()()())
- 首先箭头函数其实是没有
this
的,箭头函数中的this
只取决包裹箭头函数的第一个普通函数的this
。在这个例子中,因为包裹箭头函数的第一个普通函数是a
,所以此时的this
是window
。另外对箭头函数使用bind
这类函数是无效的。 - 最后种情况也就是
bind
这些改变上下文的API
了,对于这些函数来说,this
取决于第一个参数,如果第一个参数为空,那么就是window
。 - 那么说到
bind
,不知道大家是否考虑过,如果对一个函数进行多次bind
,那么上下文会是什么呢?
let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)() // => ?
如果你认为输出结果是
a
,那么你就错了,其实我们可以把上述代码转换成另一种形式
// fn.bind().bind(a) 等于
let fn2 = function fn1() {
return function() {
return fn.apply()
}.apply(a)
}
fn2()
可以从上述代码中发现,不管我们给函数
bind
几次,fn
中的this
永远由第一次bind
决定,所以结果永远是window
let a = { name: 'poetries' }
function foo() {
console.log(this.name)
}
foo.bind(a)() // => 'poetries'
以上就是
this
的规则了,但是可能会发生多个规则同时出现的情况,这时候不同的规则之间会根据优先级最高的来决定this
最终指向哪里。
首先,
new
的方式优先级最高,接下来是bind
这些函数,然后是obj.foo()
这种调用方式,最后是foo
这种调用方式,同时,箭头函数的this
一旦被绑定,就不会再被任何方式所改变。
4 == 和 === 有什么区别
对于
==
来说,如果对比双方的类型不一样的话,就会进行类型转换
假如我们需要对比 x 和 y 是否相同,就会进行如下判断流程
- 首先会判断两者类型是否相同。相同的话就是比大小了
- 类型不相同的话,那么就会进行类型转换
- 会先判断是否在对比
null
和undefined
,是的话就会返回true
- 判断两者类型是否为
string
和number
,是的话就会将字符串转换为number
1 == '1'
↓
1 == 1
- 判断其中一方是否为
boolean
,是的话就会把boolean
转为number
再进行判断
'1' == true
↓
'1' == 1
↓
1 == 1
- 判断其中一方是否为
object
且另一方为string
、number
或者symbol
,是的话就会把object
转为原始类型再进行判断
'1' == { name: 'yck' }
↓
'1' == '[object Object]'
对于
===
来说就简单多了,就是判断两者类型和值是否相同
5 闭包
闭包的定义其实很简单:函数
A
内部有一个函数B
,函数B
可以访问到函数A
中的变量,那么函数B
就是闭包
function A() {
let a = 1
window.B = function () {
console.log(a)
}
}
A()
B() // 1
闭包存在的意义就是让我们可以间接访问函数内部的变量
经典面试题,循环中使用闭包解决
var
定义函数的问题
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}
首先因为
setTimeout
是个异步函数,所以会先把循环全部执行完毕,这时候i
就是6
了,所以会输出一堆6
解决办法有三种
- 第一种是使用闭包的方式
for (var i = 1; i <= 5; i++) {
;(function(j) {
setTimeout(function timer() {
console.log(j)
}, j * 1000)
})(i)
}
在上述代码中,我们首先使用了立即执行函数将
i
传入函数内部,这个时候值就被固定在了参数j
上面不会改变,当下次执行timer
这个闭包的时候,就可以使用外部函数的变量j
,从而达到目的
- 第二种就是使用
setTimeout
的第三个参数,这个参数会被当成timer
函数的参数传入
for (var i = 1; i <= 5; i++) {
setTimeout(
function timer(j) {
console.log(j)
},
i * 1000,
i
)
}
- 第三种就是使用
let
定义i
了来解决问题了,这个也是最为推荐的方式
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}
6 深浅拷贝
浅拷贝
首先可以通过
Object.assign
来解决这个问题,很多人认为这个函数是用来深拷贝的。其实并不是,Object.assign
只会拷贝所有的属性值到新的对象中,如果属性值是对象的话,拷贝的是地址,所以并不是深拷贝
let a = {
age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1
另外我们还可以通过展开运算符
...
来实现浅拷贝
let a = {
age: 1
}
let b = { ...a }
a.age = 2
console.log(b.age) // 1
通常浅拷贝就能解决大部分问题了,但是当我们遇到如下情况就可能需要使用到深拷贝了
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = { ...a }
a.jobs.first = 'native'
console.log(b.jobs.first) // native
浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到最开始的话题了,两者享有相同的地址。要解决这个问题,我们就得使用深拷贝了。
深拷贝
这个问题通常可以通过
JSON.parse(JSON.stringify(object))
来解决。
let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE
但是该方法也是有局限性的:
- 会忽略
undefined
- 会忽略
symbol
- 不能序列化函数
- 不能解决循环引用的对象
let obj = {
a: 1,
b: {
c: 2,
d: 3,
},
}
obj.c = obj.b
obj.e = obj.a
obj.b.c = obj.c
obj.b.d = obj.b
obj.b.e = obj.b.c
let newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)
7 原型
原型链就是多个对象通过
__proto__
的方式连接了起来。为什么obj
可以访问到valueOf
函数,就是因为obj
通过原型链找到了valueOf
函数
Object
是所有对象的爸爸,所有对象都可以通过__proto__
找到它Function
是所有函数的爸爸,所有函数都可以通过__proto__
找到它- 函数的
prototype
是一个对象 - 对象的
__proto__
属性指向原型,__proto__
将对象和原型连接起来组成了原型链
8 var、let 及 const 区别
涉及面试题:什么是提升?什么是暂时性死区?var、let 及 const 区别?
- 函数提升优先于变量提升,函数提升会把整个函数挪到作用域顶部,变量提升只会把声明挪到作用域顶部
var
存在提升,我们能在声明之前使用。let
、const
因为暂时性死区的原因,不能在声明前使用var
在全局作用域下声明变量会导致变量挂载在window
上,其他两者不会let
和const
作用基本一致,但是后者声明的变量不能再次赋值
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
ar 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 Module
ES 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 时状态可能还是等待中,这时候应该把 then 中的回调保存起来用于状态改变时使用
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)
}cach(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 => setTimeout
以上代码虽然
setTimeout
写在Promise
之前,但是因为Promise
属于微任务而setTimeout
属于宏任务
微任务
process.nextTick
promise
Object.observe
MutationObserver
宏任务
script
setTimeout
setInterval
setImmediate
I/O
UI rendering
宏任务中包括了
script
,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务
所以正确的一次 Event loop 顺序是这样的
- 执行同步代码,这属于宏任务
- 执行栈为空,查询是否有微任务需要执行
- 执行所有微任务
- 必要的话渲染 UI
- 然后开始下一轮
Event loop
,执行宏任务中的异步代码
通过上述的
Event loop
顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作DOM
的话,为了更快的响应界面响应,我们可以把操作DOM
放入微任务中
12.4 Node 中的 Event loop
Node
中的Event loop
和浏览器中的不相同。Node
的Event loop
分为6
个阶段,它们会按照顺序反复运行
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
timer
timers
阶段会执行setTimeout
和setInterval
- 一个 timer 指定的时间并不是准确时间,而是在达到这个时间后尽快执行回调,可能会因为系统正在执行别的事务而延迟
I/O
I/O
阶段会执行除了close
事件,定时器和setImmediate
的回调
poll
poll
阶段很重要,这一阶段中,系统会做两件事情- 执行到点的定时器
- 执行
poll
队列中的事件
并且当
poll
中没有定时器的情况下,会发现以下两件事情- 如果
poll
队列不为空,会遍历回调队列并同步执行,直到队列为空或者系统限制 - 如果
poll
队列为空,会有两件事发生 - 如果有
setImmediate
需要执行,poll
阶段会停止并且进入到check
阶段执行setImmediate
- 如果没有
setImmediate
需要执行,会等待回调被加入到队列中并立即执行回调 - 如果有别的定时器需要被执行,会回到
timer
阶段执行回调。
- 如果
check
check
阶段执行setImmediate
close callbacks
close callbacks
阶段执行close
事件- 并且在
Node
中,有些情况下的定时器执行顺序是随机的
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
})
// 这里可能会输出 setTimeout,setImmediate
// 可能也会相反的输出,这取决于性能
// 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate
// 否则会执行 setTimeout
上面介绍的都是
macrotask
的执行情况,microtask
会在以上每个阶段完成后立即执行
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
// 以上代码在浏览器和 node 中打印情况是不同的
// 浏览器中一定打印 timer1, promise1, timer2, promise2
// node 中可能打印 timer1, timer2, promise1, promise2
// 也可能打印 timer1, promise1, timer2, promise2
Node
中的process.nextTick
会先于其他microtask
执行
setTimeout(() => {
console.log("timer1");
Promise.resolve().then(function() {
console.log("promise1");
});
}, 0);
process.nextTick(() => {
console.log("nextTick");
});
// nextTick, timer1, promise1
对于
microtask
来说,它会在以上每个阶段完成前清空microtask
队列,下图中的Tick
就代表了microtask
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
解决
parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true
17 事件机制
涉及面试题:事件的触发过程是怎么样的?知道什么是事件代理嘛?
17.1 事件触发三阶段
事件触发有三个阶段:
window
往事件触发处传播,遇到注册的捕获事件会触发- 传播到事件触发处时触发注册的事件
- 从事件触发处往
window
传播,遇到注册的冒泡事件会触发
事件触发一般来说会按照上面的顺序进行,但是也有特例,如果给一个
body
中的子节点同时注册冒泡和捕获事件,事件触发会按照注册的顺序执行
// 以下会先打印冒泡然后是捕获
node.addEventListener(
'click',
event => {
console.log('冒泡')
},
false
)
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
17.2 注册事件
通常我们使用
addEventListener
注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值useCapture
参数来说,该参数默认值为false
,useCapture
决定了注册的事件是捕获事件还是冒泡事件。对于对象参数来说,可以使用以下几个属性
capture
:布尔值,和useCapture
作用一样once
:布尔值,值为true
表示该回调只会调用一次,调用后会移除监听passive
:布尔值,表示永远不会调用preventDefault
一般来说,如果我们只希望事件只触发在目标上,这时候可以使用
stopPropagation
来阻止事件的进一步传播。通常我们认为stopPropagation
是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagation
同样也能实现阻止事件,但是还能阻止该事件目标执行别的注册事件。
node.addEventListener(
'click',
event => {
event.stopImmediatePropagation()
console.log('冒泡')
},
false
)
// 点击 node 只会执行上面的函数,该函数不会执行
node.addEventListener(
'click',
event => {
console.log('捕获 ')
},
true
)
17.3 事件代理
如果一个节点中的子节点是动态生成的,那么子节点需要注册事件的话应该注册在父节点上
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
</ul>
<script>
let ul = document.querySelector('#ul')
ul.addEventListener('click', (event) => {
console.log(event.target);
})
</script>
事件代理的方式相较于直接给目标注册事件来说,有以下优点:
- 节省内存
- 不需要给子节点注销事件
暂无评论