前端面试题库 (面试必备) 推荐:★★★★★
地址:前端面试题库
表妹一键制作自己的五星红旗国庆头像,超好看
世界上只有一种真正的英雄主义,那就是看清生活的真相之后,依然热爱生活。 -- 罗曼罗兰
大家好,我是柒八九。
在如今的互联网大环境下,每天都充斥着各种负能量。有可能,你上午还在工位摸鱼,下午HR
已经给你单独开小灶,很煞有介事的通知你,提前毕业了。在这个浮躁的互联网环境下,总有一种我们永远不知道明天和意外哪个先到的感觉。
《古兰经》中有一句很契合的话,山不过来,我就过去。
既然,外部环境我们无法去改变,那就从我们内部改变。所以,我又重新总结了一套,2023
年最新的面试集锦,以便大家一起度过寒冬,拥抱更好的未来。
note:
- 其中有些知识点,在前面的文章中,有过涉猎,为了行文的方便和资料的完整性,我就又拿来主义了,免去大家去翻找。但是,前面的文章有更深的解读,如果想更深的学习,可以移步到对应文章中。
- 如果在行文中,有技术披露和考虑不周的地方,不吝赐教。
你能所学到的知识点
- JS执行流程 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 基本数据类型 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- ES6的新特性有哪些 推荐阅读指数⭐️⭐️⭐️
- 箭头函数和普通函数的区别 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- Promise VS async/await 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- ES6迭代器 推荐阅读指数⭐️⭐️⭐️
- 设计模式的分类 推荐阅读指数⭐️⭐️⭐️⭐️
- WebGL和canvas的关系 推荐阅读指数⭐️⭐️
- CommonJS和ES6 Module的区别 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 声明变量的方式 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- Object/Map/WeakMap的区别 推荐阅读指数⭐️⭐️⭐️⭐️
- JS 深浅复制 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 闭包 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- Event Loop 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 垃圾回收机制 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 内存问题 推荐阅读指数⭐️⭐️⭐️
- 作用域的产生 推荐阅读指数⭐️⭐️⭐️⭐️
- this指向 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 图片懒加载 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- PromiseQueue 推荐阅读指数⭐️⭐️⭐️⭐️
- 数组常用方法 推荐阅读指数⭐️⭐️⭐️⭐️
好了,天不早了,干点正事哇。
JS执行流程
准备工作
需要准备执行 JS
时所需要的一些基础环境
- 初始化了内存中的
堆和栈
结构 - JS全局执行上下文
- 包含了执行过程中的全局信息, 比如一些
内置函数
,全局变量
等信息
- 包含了执行过程中的全局信息, 比如一些
- 全局作用域
- 包含了一些
全局变量
, 在执行过程中的数据都需要存放在内存中
- 包含了一些
- 初始化消息循环系统
- 消息驱动器
- 消息队列
执行流程
V8
接收到要执行的JS 源代码
源代码
对V8
来说只是一堆字符串,V8
并不能直接理解这段字符串的含义
V8
结构化这段字符串,生成了{抽象语法树|AST},同时还会生成相关的作用域- 生成字节码(介于
AST
和机器代码
的中间代码)- 与特定类型的机器代码无关
- 解释器(
ignition
),按照顺序解释执行字节码,并输出执行结果。
从图中得出一个结论:执行JS代码核心流程
- 先编译
- 后执行
通过V8
将js
转换为字节码
然后经过解释器
执行输出结果的方式执行JS
,有一个弊端就是,如果在浏览器中再次打开相同的页面,当页面中的 JavaScript
文件没有被修改,再次编译之后的二进制代码也会保持不变,意味着编译这一步浪费了 CPU 资源。
为了,更好的利用CPU资源,V8采用JIT(Just In Time)技术提升效率:而是混合编译执行和解释执行这两种手段。
- 解释执行的启动速度快,但是执行时的速度慢
- 编译执行的启动速度慢,但是执行时的速度快
Just-in-time 编译器:综合了解释器和编译器的优点
为了解决解释器的低效问题,后来的浏览器把编译器也引入进来,形成混合模式。
在 JavaScript
引擎中增加一个监视器(也叫分析器)。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息。
如果同一行代码运行了几次,这个代码段就被标记成了
warm
,如果运行了很多次,则被标记成hot
。
基线编译器
如果一段代码变成了 warm
,那么 JIT
就把它送到编译器去编译,并且把编译结果存储起来。
代码段的每一行都会被编译成一个“桩”(stub
),同时给这个桩分配一个以行号 + 变量类型
的索引。如果监视器监视到了执行同样的代码和同样的变量类型,那么就直接把这个已编译的版本 push
出来给浏览器。
优化编译器
如果一个代码段变得 very hot
,监视器
会把它发送到优化编译器中。生成一个更快速和高效的代码版本出来,并且存储之。
为了生成一个更快速的代码版本,优化编译器必须做一些假设。
例如,它会假设由同一个构造函数生成的实例都有相同的形状
就是说所有的实例
- 都有
相同的属性名
- 并且都以
同样的顺序初始化
那么就可以针对这一模式进行优化。
整个优化器
起作用的链条是这样的
- 监视器从他所监视代码的执行情况做出自己的判断
- 接下来把它所整理的信息传递给优化器进行优化
- 如果某个循环中先前每次迭代的对象都有相同的形状,那么就可以认为它以后迭代的对象的形状都是相同的。
可是对于 JavaScript
从来就没有保证这么一说,前 99 个对象保持着形状,可能第 100 个就少了某个属性。
正是由于这样的情况,所以编译代码需要在运行之前检查其假设是不是合理的。
- 如果合理,那么优化的编译代码会运行
- 如果不合理,那么
JIT
会认为做了一个错误的假设,并且把优化代码丢掉- 这时(发生优化代码丢弃的情况)执行过程将会回到解释器或者基线编译器,这一过程叫做去优化。
{类型特化|Type specialization}
优化编译器
最成功一个特点叫做类型特化。
JavaScript
所使用的动态类型体系在运行时需要进行额外的解释工作,例如下面代码:
function arraySum(arr) {var sum = 0;for (var i = 0; i < arr.length; i++) {sum += arr[i];}
}
我们假设 arr
是一个有 100 个整数的数组。当代码被标记为 “warm” 时,基线编译器就为函数中的每一个操作生成一个桩。sum += arr[i]
会有一个相应的桩,并且把里面的 +=
操作当成整数加法。
但是,sum
和 arr[i]
两个数并不保证都是整数。因为在 JavaScript
中类型都是动态类型,在接下来的循环当中,arr[i]
很有可能变成了 string
类型。整数加法和字符串连接是完全不同的两个操作,会被编译成不同的机器码。
JIT
处理这个问题的方法是编译多基线桩。
- 如果一个代码段是单一形态的(即总是以同一类型被调用),则只生成一个桩。
- 如果是多形态的(即调用的过程中,类型不断变化),则会为操作所调用的每一个类型组合生成一个桩。
这就是说 JIT
在选择一个桩之前,会进行多分枝选择,类似于决策树
,问自己很多问题才会确定最终选择哪个,见下图:
基本数据类型
数据类型分类(7+1)
undefined
null
Boolean
String
Number
Symbol
(es6
)BigInt
(es2020
)Object
- {常规对象|Ordinary Object}
- {异质对象|Exotic Object}
存储位置不同
- (1 - 7) :栈内存 (基本primary数据类型)
- (8): 堆内存
判断数据类型的方式 (TTIC)
-
typeof
- 判断基本数据类型
typeof null
特例,返回的是"object"
-
Object.prototype.toString.call(xx)
- 判断基本数据类型
- 实现原理:
- 若参数(xx)不为
null
或undefined
,则将参数转为对象,再作判断 - 转为对象后,取得该对象的
[Symbol.toStringTag]
属性值(可能会遍历原型链)作为tag
,然后返回"[object " + tag + "]"
形式的字符串。
- 若参数(xx)不为
-
instanceof
a instanceof B
判断的是a
和B
是否有血缘关系,而不是仅仅根据是否是父子关系。- 在ES6中
instanceof
操作符会使用Symbol.hasInstance
函数来确定关系。
-
constructor
-
只要创建一个函数,就会按照特定的规则为这个函数创建一个
prototype
属性(指向原型对象)。 -
默认情况下,所有原型对象自动获得一个名为
constructor
的属性,指回与之关联的构造函数。 -
每次调用构造函数创建一个新实例,实例的内部
[[Prototype]]
指针就会被赋值为构造函数的原型对象。 -
实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有
-
通过实例和构造函数原型对象的关系,来判断是否实例类型。
-
null/undefined
是一个假值,没有对应包装对象(无法进行装箱操作),也不是任何构造函数的实例。所以,不存在原型,即,无法使用constructor
判断类型。
-
ES6的新特性有哪些
const
和let
- 解构赋值
- 模板字符串
- 函数的扩展
- 函数的默认值
rest
参数- 函头函数
- 数组的扩展
Array.from()
将类数组转为数组find()
、findIndex()
找出第一个符合条件的成员/下标entries()
、keys()
、values()
用于遍历数组。(配合for...of
)includes()
是否存在指定无素(返回布尔值)
- 对象的扩展
- 属性名可使用表达式
Object.assign()
Object.keys()
,Object.values()
,Object.entries()
Symbol
Set
、Map
Promise
Iterator
和for...of
- 为各种数据提供统一的,简便的访问接口
Generator
与async await
箭头函数和普通函数的区别
- 语法更加简洁、清晰
- 箭头函数没有
prototype
(原型),所以箭头函数本身没有this
- 箭头函数不会创建自己的
this
- 箭头函数没有自己的
this
,箭头函数的this
指向在定义的时候继承自外层第一个普通函数的this
- 箭头函数没有自己的
call
|apply
|bind
无法改变箭头函数中this
的指向- 箭头函数不能作为构造函数使用
- 箭头函数不绑定
arguments
,取而代之用rest
参数...
代替arguments
对象,来访问箭头函数的参数列表 - 箭头函数不能用作
Generator
函数,不能使用yield
关键字
Promise VS async/await
Promise
Promise
对象就是为了解决回调地狱而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。
分析 Promise
的调用流程:
Promise
的构造方法接收一个executor()
,在new Promise()
时就立刻执行这个executor
回调executor()
内部的异步任务被放入宏/微任务队列,等待执行then()
被执行,收集成功/失败回调,放入成功/失败队列executor()
的异步任务被执行,触发resolve/reject
,从成功/失败队列中取出回调依次执行
其实熟悉设计模式,很容易就能意识到这是个观察者模式,这种
- 收集依赖
- 触发通知
- 取出依赖执行
的方式,被广泛运用于观察者模式的实现,
在
Promise
里,执行顺序是
then
收集依赖- 异步触发
resolve
resolve
执行依赖。
手写一个Promise
//Promise/A+规范的三种状态
const PENDING = 'pending'
const FULFILLED = 'fulfilled'
const REJECTED = 'rejected'class MyPromise {// 构造方法接收一个回调constructor(executor) {this._status = PENDING // Promise状态this._resolveQueue = [] // 成功队列, resolve时触发this._rejectQueue = [] // 失败队列, reject时触发// 由于resolve/reject是在executor内部被调用, 因此需要使用箭头函数固定this指向, 否则找不到this._resolveQueuelet _resolve = (val) => {if(this._status !== PENDING) return// 对应规范中的"状态只能由pending到fulfilled或rejected"this._status = FULFILLED // 变更状态// 这里之所以使用一个队列来储存回调,是为了实现规范要求的 "then 方法可以被同一个 promise 调用多次"// 如果使用一个变量而非队列来储存回调,那么即使多次p1.then()也只会执行一次回调while(this._resolveQueue.length) {const callback = this._resolveQueue.shift()callback(val)}}// 实现同resolvelet _reject = (val) => {if(this._status !== PENDING) return// 对应规范中的"状态只能由pending到fulfilled或rejected"this._status = REJECTED // 变更状态while(this._rejectQueue.length) {const callback = this._rejectQueue.shift()callback(val)}}// new Promise()时立即执行executor,并传入resolve和rejectexecutor(_resolve, _reject)}// then方法,接收一个成功的回调和一个失败的回调then(resolveFn, rejectFn) {this._resolveQueue.push(resolveFn)this._rejectQueue.push(rejectFn)}
}
代码测试
const p1 = new MyPromise((resolve, reject) => {setTimeout(() => {resolve('result')}, 1000);
})
p1.then(res =>console.log(res))
//一秒后输出result
async await
async/await
实际上是对Generator
(生成器)的封装,是一个语法糖。
*/yield
和async/await
看起来其实已经很相似了,它们都提供了暂停执行的功能,但二者又有三点不同:
async/await
自带执行器,不需要手动调用next()
就能自动执行下一步async
函数返回值是Promise
对象,而Generator
返回的是生成器对象await
能够返回Promise
的resolve/reject
的值
不管
await
后面跟着的是什么,await
都会阻塞后面的代码
Generator
Generator
实现的核心在于上下文的保存,函数并没有真的被挂起,每一次 yield
,其实都执行了一遍传入的生成器函数,只是在这个过程中间用了一个 context 对象储存上下文,使得每次执行生成器函数的时候,都可以从上一个执行结果开始执行,看起来就像函数被挂起了一样。
用babel
编译后生成regeneratorRuntime
mark()
方法为生成器函数绑定了一系列原型wrap()
相当于是给generator
增加了一个_invoke
方法
两者的区别
Promise
的出现解决了传统callback
函数导致的地域回调问题,但它的语法导致了它向纵向发展行成了一个回调链,遇到复杂的业务场景,这样的语法显然也是不美观的。
而async await
代码看起来会简洁些,使得异步代码看起来像同步代码,await
的本质是可以提供等同于”同步效果“的等待异步返回能力的语法糖,只有这一句代码执行完,才会执行下一句。
async/await
与Promise
一样,是非阻塞的。
async/await
是基于Promise
实现的,可以说是改良版的Promise
,它不能用于普通的回调函数。
ES6迭代器
迭代器模式
可以把有些结构称为{可迭代对象|iterable},它们实现了正式的
Iterable
接口 而且可以通过{迭代器|Iterator}消费
{迭代器|Iterator}是按需创建的一次性对象
每个迭代器都会关联一个可迭代对象
可迭代协议
实现 Iterable
接口(可迭代协议)要求同时具备两种能力
- 支持迭代的自我识别能力
- 创建实现
Iterator
接口的对象的能力
这意味着必须暴露一个属性作为默认迭代器,这个属性必须使用特殊的 Symbol.iterator
作为键,这个默认迭代器属性必须引用一个迭代器工厂函数。调用这个工厂函数必须返回一个新迭代器
内置类型都实现了 Iterable
接口
- 字符串
- 数组
Map
Set
arguments
对象NodeList
等 DOM 集合类型
接收可迭代对象的原生语言特性包括
for-of
循环- 数组解构
- 扩展操作符
Array.from()
- 创建
Set
- 创建
Map
Promise.all()
接收由Promise
组成的可迭代对象Promise.race()
接收由Promise
组成的可迭代对象yield*
操作符,在生成器中使用
迭代器协议
迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象
迭代器 API 使用 next()
方法在可迭代对象中遍历数据,每次成功调用 next()
,都会返回一个 IteratorResult
对象,其中包含迭代器返回的下一个值。
next()
方法返回的迭代器对象 IteratorResult
包含两个属性
done
- 一个布尔值,表示是否还可以再次调用
next()
取得下一个值
- 一个布尔值,表示是否还可以再次调用
value
- 包含可迭代对象的下一个值
每个迭代器都表示对可迭代对象的一次性有序遍历
手写一个迭代器
function makeIterator(array) {var nextIndex = 0;return {next: function() {return nextIndex < array.length? { value: array[nextIndex++], done: false }: { value: undefined, done: true };},};
}
代码测试
var it = makeIterator(["a", "b"]);it.next(); // { value: "a", done: false }
it.next(); // { value: "b", done: false }
it.next(); // { value: undefined, done: true }
设计模式的分类
总体来说设计模式分为三大类:(C5S7B11
)
-
创建型模式,共五种:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式。
-
结构型模式,共七种:适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式。
-
行为型模式,共十一种:策略模式、模板方法模式、观察者模式/发布订阅模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。
手写单例模式(创建模式)
let CreateSingleton = (function(){let instance;return function(name) {if (instance) {return instance;}this.name = name;return instance = this;}
})();
CreateSingleton.prototype.getName = function() {console.log(this.name);
}
代码测试
let Winner = new CreateSingleton('Winner');
let Looser = new CreateSingleton('Looser');console.log(Winner === Looser); // true
console.log(Winner.getName()); // 'Winner'
console.log(Looser.getName()); // 'Winner'
手写观察者模式(行为模式)
// 定义observe
const queuedObservers = new Set();
const observe = fn => queuedObservers.add(fn);const observable = obj => new Proxy(obj, {set(target, key, value, receiver) {const result = Reflect.set(target, key, value, receiver);// notifyqueuedObservers.forEach(observer => observer());return result;}
});
代码测试
obj = observable({name:'789'
})observe(function test(){console.log('触发了')
})obj.name ="前端柒八九"
// 触发了
// 前端柒八九
手写发布订阅 (行为模式)
class Observer {caches = {}; // 事件中心// eventName事件名-独一无二, fn订阅后执行的自定义行为on (eventName, fn){ this.caches[eventName] = this.caches[eventName] || [];this.caches[eventName].push(fn);}// 发布 => 将订阅的事件进行统一执行emit (eventName, data) { if (this.caches[eventName]) {this.caches[eventName].forEach(fn => fn(data));}}// 取消订阅 => 若fn不传, 直接取消该事件所有订阅信息off (eventName, fn) { if (this.caches[eventName]) {const newCaches = fn ? this.caches[eventName].filter(e => e !== fn) : [];this.caches[eventName] = newCaches;}}}
代码测试
ob = new Observer();l1 = (data) => console.log(`l1_${data}`)
l2 = (data) => console.log(`l2_${data}`)ob.on('event1',l1)
ob.on('event1',l2)//发布订阅
ob.emit('event1',789)
// l1_789
// l2_789// 取消,订阅l1
ob.off('event1',l1)ob.emit('event1',567)
//l2_567
观察者模式 VS 发布订阅模式
- 从表面上看:
- 观察者模式里,只有两个角色 —— 观察者 + 被观察者
- 而发布订阅模式里,却不仅仅只有发布者和订阅者两个角色,还有一个经常被我们忽略的 —— {经纪人|Broker}
- 往更深层次讲:
- 观察者和被观察者,是
松耦合
的关系 - 发布者和订阅者,则完全不存在耦合
- 观察者和被观察者,是
- 从使用层面上讲:
- 观察者模式,多用于单个应用内部
- 发布订阅模式,则更多的是一种{跨应用的模式|cross-application pattern} ,比如我们常用的消息中间件
WebGL和canvas的关系
Canvas
就是画布,只要浏览器支持,可以在canvas
上获取2D上下文
和3D上下文
,其中3D上下文一般就是WebGL,当然WebGL
也能用于2D绘制,并且WebGL
提供硬件渲染加速,性能更好。- 但是
WEBGL
的支持性caniuse
还不是特别好,所以在不支持WebGL
的情况下,只能使用Canvas 2D api
,注意这里的降级不是降到Canvas
,它只是一个画布元素,而是降级使用 浏览器提供的Canvas 2D Api
,这就是很多库的兜底策略,如Three.js
,PIXI
等
CommonJS和ES6 Module的区别
CommonJS
是同步加载模块,ES6
是异步加载模块CommonJS
规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。由于Node.js
主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS
规范比较适用。- 浏览器加载
ES6
模块是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本
CommonJS
模块输出的是一个值的拷贝,ES6
模块输出的是值的引用。CommonJS
模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值ES6
模块的运行机制与CommonJS
不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
CommonJS
模块是运行时加载,ES6 模块是编译时输出接口。
是否可以在浏览器端使用 CommonJS
CommonJS
不适用于浏览器环境
声明变量的方式(2 + 4 )
ES5
var
命令function
命令
ES6
let
const
import
class
函数的声明
function
命令function fn(s) {}
- 函数表达式
var fn = function(s) {}
Function
构造函数new Function('x','y','return x + y' )
Object/Map/WeakMap的区别
ES6
提供了 Map
数据结构。它类似于对象
,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。
也就是说,
Object
结构提供了字符串—值的对应,Map
结构提供了值—值的对应,是一种更完善的Hash
结构实现。
WeakMap
结构与Map
结构类似,也是用于生成键值对的集合。
WeakMap
与Map
的区别有两点。
- 首先,
WeakMap
只接受对象作为键名(null除外),不接受其他类型的值作为键名。 - 其次,
WeakMap
的键名所指向的对象,不计入垃圾回收机制。
总之,WeakMap
的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap
结构有助于防止内存泄漏。
WeakMap
弱引用的只是键名,而不是键值。键值依然是正常引用。
JS 深浅复制
JS在语言层面仅支持浅复制,深复制需要手动实现
浅复制(3个)
- 扩展运算符
Object.assign()
Object.getOwnPropertyDescriptors()
+Object.defineProperties()
扩展运算符(...)复制对象和数组
const copyOfObject = {...originalObject};
const copyOfArray = [...originalArray];
扩展运算符不足和特性。
不足&特性 |
---|
不能复制普通对象的prototype 属性 |
不能复制内置对象的特殊属性(internal slots) |
只复制对象的本身的属性(非继承) |
只复制对象的可枚举属性(enumerable) |
复制的数据属性都是可写的(writable)和可配置的(configurable) |
Object.assign()
Object.assign()
的工作方式和扩展运算符类似。
const copy1 = {...original};
const copy2 = Object.assign({}, original);
Object.assign()
并非完全和扩展运算符等同,他们之间存在一些细微的差别。
- 扩展运算符在副本中直接定义新的属性
Object.assign()
通过赋值的方式来处理副本中对应属性
Object.getOwnPropertyDescriptors()
和Object.defineProperties()
JavaScript
允许我们通过属性描述符来创建属性。
function copyAllOwnProperties(original) {return Object.defineProperties({}, Object.getOwnPropertyDescriptors(original));
}
- 能够复制所有自有属性
- 能够复制非枚举属性
深复制
通过嵌套扩展运算符实现深复制
const original = {name: '789', work: {address: 'BeiJing'}};
const copy = {name: original.name, work: {...original.work}};original.work !== copy.work // 指向不同的引用地址
使用JSON实现数据的深复制
先将普通对象,
- 先转换为
JSON
串(stringify
) - 然后再解析(
parse
)该串
function jsonDeepCopy(original) {return JSON.parse(JSON.stringify(original));
}
而通过这种方式有一个很明显的缺点就是:
只能处理
JSON
所能识别的key
和value
。对于不支持的类型,会被直接忽略掉。
手动实现
递归函数实现深复制
实现逻辑就是(FHT
)
- 利用
for-in
对对象的属性进行遍历(自身属性+继承属性) source.hasOwnProperty(i)
判断是否是非继承的可枚举属性typeof source[i] === 'object'
判断值的类型,如果是对象,递归处理
function clone(source) {let target = {};for(let i in source) {if (source.hasOwnProperty(i)) {if (typeof source[i] === 'object') {target[i] = clone(source[i]); // 递归处理} else {target[i] = source[i];}}}return target;
}
闭包
函数即对象
在JS中,一切皆对象。那从语言的设计层面来讲,函数是一种特殊的对象。
函数和对象一样可以拥有属性和值。
function foo(){var test = 1return test;
}
foo.myName = 1
foo.obj = { x: 1 }
foo.fun = function(){return 0;
}
根据对象的数据特性:foo
函数拥有myName
/obj
/fun
的属性
但是函数和普通对象不同的是,函数可以被调用。
从V8内部来看看函数是如何实现可调用特性
在 V8 内部,会为函数对象添加了两个隐藏属性
name
属性:属性的值就是函数名称code
属性:表示函数代码,以字符串的形式存储在内存中
code 属性
当执行到,一个函数调用语句时,V8
便会从函数对象中取出 code
属性值(也就是函数代码),然后再解释执行这段函数代码。
在解释执行函数代码的时候,又会生成该函数对应的执行上下文,并被推入到调用栈里。
闭包
在 JS 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量。
当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了。但是内部函数引用外部函数的变量依然保存在内存中,就把这些变量的集合称为闭包。
function test() {var myName = "fn_outer"let age = 78;var innerObj = {getName:function(){console.log(age);return myName},setName:function(newName){myName = newName}}return innerObj
}
var t = test();
console.log(t.getName());//fn_outer
t.setName("global")
console.log(t.getName())//global
- 根据词法作用域的规则,内部函数
getName
和setName
总是可以访问它们的外部函数test
中的变量- 在执行
test
时,调用栈的情况
- 在执行
test
函数执行完成之后,其执行上下文从栈顶弹出了- 但是由于返回
innerObj
对象中的setName
和getName
方法中使用了test
函数内部的变量myName
和age
所以这两个变量依然保存在内存中(Closure (test)
)
- 但是由于返回
- 当执行到
t.setName
方法的时,调用栈如下: - 利用debugger来查看对应的作用链和调用栈信息
通过上面分析,然后参考作用域的概念和使用方式,我们可以做一个简单的结论
闭包和词法环境的强相关
而JS的作用域由词法环境决定,并且作用域是静态的。
所以,我们可以得出一个结论:
闭包在每次创建函数时创建(闭包在JS编译阶段被创建)
闭包是如何产生的?
产生闭包的核心两步:
- 预扫描内部函数
- 把
内部函数
引用的外部变量保存到堆中
function test() {var myName = "fn_outer"let age = 78;var innerObj = {getName:function(){console.log(age);return myName},setName:function(newName){myName = newName}}return innerObj
}
var t = test();
当 V8
执行到 test
函数时
-
首先会编译,并创建一个空执行上下文。
- 在
编译过程
中,遇到内部函数setName
,V8
还要对内部函数做一次快速的词法扫描(预扫描) 发现该内部函数引用了 外部函数(test
)中的myName
变量 - 由于是内部函数引用了外部函数的变量,所以 V8 判断这是一个闭包
- 于是在堆空间创建换一个
closure(test)
的对象 (这是一个内部对象,JavaScript
是无法访问的),用来保存myName
变量
- 在
-
当
test
函数执行结束之后,返回的getName
和setName
方法都引用clourse(test)
对象。- 即使
test
函数退出了,clourse(test)
依然被其内部的getName
和setName
方法引用。
- 即使
-
所以在下次调用
t.setName
或者t.getName
时,在进行变量查找时候,根据作用域链来查找。
Event Loop
{事件循环|Event Loop}
事件循环是一个不停的从 宏任务队列/微任务队列中取出对应任务的循环函数。
在一定条件下,你可以将其类比成一个永不停歇的永动机。 它从宏/微任务队列中取出任务并将其推送到调用栈中被执行。
事件循环包含了四个重要的步骤:
- 执行Script:以同步的方式执行
script
里面的代码,直到调用栈为空才停下来。- 其实,在该阶段,JS还会进行一些预编译等操作。(例如,变量提升等)。
- 执行一个宏任务:从宏任务队列中挑选最老的任务并将其推入到调用栈中运行,直到调用栈为空。
- 执行所有微任务:从微任务队列中挑选最老的任务并将其推入到调用栈中运行,直到调用栈为空。
- 但是,但是,但是(转折来了),继续从微任务队列中挑选最老的任务并执行。直到微任务队列为空。
- UI渲染:渲染UI,然后,跳到第二步,继续从宏任务队列中挑选任务执行。(这步只适用浏览器环境,不适用Node环境)
事件循环的单次迭代过程被称为tick
{宏任务队列|Task Queue}
也可以称为{回调队列| Callback queue}。
调用栈是用于跟踪正在被执行函数的机制,而宏任务队列是用于跟踪将要被执行函数的机制。
事件循环不知疲倦的运行着,并且按照一定的规则从宏任务队列中不停的取出任务对象。
宏任务队列是一个FIFO(先进先出)的队列结构。结构中存储的宏任务会被事件循环探查到。并且,这些任务是同步阻塞的。当一个任务被执行,其他任务是被挂起的(按顺序排队)。
{微任务队列|Microtask Queue}
微任务队列也是一个FIFO(先进先出)的队列结构。并且,结构中存储的微任务也会被事件循环探查到。微任务队列和宏任务队列很像。作为ES6的一部分,它被添加到JS的执行模型中,以处理Promise回调。
微任务和宏任务也很像。它也是一个同步阻塞代码,运行时也会霸占调用栈。像宏任务一样,在运行期间,也会触发新的微任务,并且将新任务提交到微任务队列中,按照队列排队顺序,将任务进行合理安置。
- 宏任务是在循环中被执行,并且UI渲染穿插在宏任务中。
- 微任务是在一个宏任务完成之后,在UI渲染之前被触发。
微任务队列是ES6新增的专门用于处理Promise
调用的数据结构。它和宏任务队列很像,它们最大的不同就是微任务队列是专门处理微任务的相关处理逻辑的。
{垃圾回收机制|Garbage Collecation}
垃圾回收算法
- 通过
GC Root
标记空间中活动对象和非活动对象- V8 采用的{可访问性| reachability}算法,来判断堆中的对象是否是活动对象
- 这个算法是将一些
GC Root
作为初始存活的对象的集合 - 从
GC Roots
对象出发,遍历GC Root
中的所有对象 - 通过
GC Roots
遍历到的对象,认为该对象是{可访问的| reachable},也称可访问的对象为活动对象 - 通过
GC Roots
没有遍历到的对象,是{不可访问的| unreachable},不可访问的对象为非活动对象 - 浏览器环境中,
GC Root
包括1.全局的window
对象,2.文档DOM
树,由可以通过遍历文档到达的所有原生DOM
节点组成,3.存放栈上变量
- 回收非活动对象所占据的内存
- 内存整理
- 频繁回收对象后,内存中就会存在大量不连续空间
- 这些不连续的内存空间称为内存碎片
代际假说
代际假说是垃圾回收领域中一个重要的术语
两个特点
- 第一个是大部分对象都是朝生夕死的
- 大部分对象在内存中存活的时间很短
- 比如函数内部声明的变量,或者块级作用域中的变量
- 第二个是不死的对象,会活得更久
- 比如全局的
window
、DOM
、Web API
等对象
堆空间
在 V8
中,会把堆
分为
- 新生代
- 存放的是生存时间短的对象
- 新生代通常只支持 1~8M 的容量
- {副垃圾回收器| Minor GC} (Scavenger)
- 负责新生代的垃圾回收
- 老生代
- 存放生存时间久的对象
- {主垃圾回收器| Major GC}
- 负责老生代的垃圾回收
{副垃圾回收器| Minor GC}
新生代中的垃圾数据用 Scavenge
算法来处理。
所谓 Scavenge
算法,把新生代空间对半划分为两个区域:
- 一半是对象区域 (
from-space
) - 一半是空闲区域 (
to-space
)
当对象区域快被写满时,就需要执行一次垃圾清理操作,
-
首先要对对象区域中的垃圾做标记,
-
标记完成之后,就进入垃圾清理阶段,
- 把这些存活的对象复制到空闲区域中,把这些对象有序地排列起来
-
完成复制后,对象区域与空闲区域进行角色翻转
副垃圾回收器采用对象晋升策略:移动那些经过两次垃圾回收依然还存活的对象到老生代中。
{主垃圾回收器| Major GC}
负责老生代中的垃圾回收,除了新生代中晋升的对象,大的对象会直接被分配到老生代里。
老生代中的对象有两个特点
- 对象占用空间大
- 对象存活时间长
{标记 - 清除|Mark-Sweep}算法
- 标记过程阶段
- 从一组根元素开始,递归遍历这组根元素
- 这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据
- 垃圾的清除过程
-
主垃圾回收器会直接将标记为垃圾的数据清理掉
-
{标记 - 整理|Mark-Compact}
- 标记可回收对象
- 垃圾清除
- 不是直接对可回收对象进行清理
- 而是让所有存活的对象都向一端移动
- 直接清理掉这一端之外的内存
内存问题
内存泄漏 (Memory leak)
不再需要 (没有作用) 的内存数据依然被其他对象引用着。
污染全局(window)
function foo() {//创建一个临时的temp_arraytemp_array = new Array(200000)/*** 使用temp_array*/
}
函数体内的对象没有被 var
、let
、const
这些关键字声明。
V8
就会使用 this.temp_array
替换 temp_array
在浏览器,默认情况下,
this
是指向window
对象的
闭包
function foo(){ var temp_object = new Object()temp_object.x = 1temp_object.y = 2temp_object.array = new Array(200000)/*** 使用temp_object*/return function(){console.log(temp_object.x);}
}
闭包会引用父级函数中定义的变量。
如果引用了不被需要的变量,那么也会造成内存泄漏。
detached 节点
let detachedTree;
function create() {var ul = document.createElement('ul');for (var i = 0; i < 100; i++) {var li = document.createElement('li');ul.appendChild(li);}detachedTree = ul;}create()
只有同时满足
DOM
树和JavaScript
代码都不引用某个DOM
节点,该节点才会被作为垃圾进行回收。
“detached ”节点:如果某个节点已从 DOM
树移除,但 JavaScript
仍然引用它
作用域的产生
作用域被分为3大类
- 声明式作用域
- 函数作用域
- module作用域
- 对象作用域
- 全局作用域
声明式作用域
声明式ER可以通过 var/const/let/class/module/import/function
生成。
常说的ES6块级作用域和函数作用域属于同一大类(声明式作用域)。
根据实现层级,还有一个更准确的结论:
ES6块级作用域是函数作用域的子集
全局作用域
全局作用域是最外面的作用域,它没有外部作用域。即全局环境的OuterEnv
为null
。
全局ER使用两个ER来管理其变量:
- 对象ER
- 将变量存储在全局对象中
- 顶层作用域下,
var
和function
声明的变量被绑定在对象ER里(在浏览器环境下,window
指向全局对象)
- 声明式ER
- 使用内部对象来存储变量
- 顶层作用域下,
const/let/class
声明的变量被绑定在声明ER
当声明式ER和对象ER有共同的变量,声明式优先级高。
this指向
{执行上下文 |Execution context} 中包含了
- {变量环境 |Viriable Environment}
- {词法环境 |Lexical Environment}
- {外部环境 |outer}
this
this
是和执行上下文绑定的,也就是说每个执行上下文中都有一个this
执行上下文主要分为三种
- 全局执行上下文
- 函数执行上下文
eval
执行上下文
全局执行上下文
全局执行上下文中的 this
是指向 window
对象的
这也是 this
和作用域链的唯一交点
- 作用域链的最底端包含了
window
对象 - 全局执行上下文中的
this
也是指向window
对象
函数执行上下文
默认情况下调用一个函数,其执行上下文中的
this
也是指向window
对象的
设置函数执行上下文中的 this 值
通过函数的 call/bind/apply
方法设置
let bar = {myName : " 北宸 ",test1 : 1
}
function foo(){this.myName = " 南蓁 "
}
foo.call(bar)
console.log(bar) // 南蓁
console.log(myName) // myName is not defined
通过对象调用方法设置
var myObj = {name : " 北宸", showThis: function(){console.log(this)}
}
myObj.showThis()
使用对象来调用其内部的一个方法,该方法的 this
是指向对象本身的
可以认为
JavaScript 引擎
在执行myObject.showThis()
时,将其转化为了:myObj.showThis.call(myObj)
把 showThis
赋给一个全局对象,然后再调用该对象
var myObj = {name : " 北宸 ",showThis: function(){this.name = " 南蓁 "console.log(this)}
}
var foo = myObj.showThis
foo()
this
又指向了全局 window
对象
- 在全局环境中调用一个函数,函数内部的
this
指向的是全局变量window
- 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的
this
指向对象本身
通过构造函数中设置
function CreateObj(){this.name = " 北宸南蓁 "
}
var myObj = new CreateObj()
此时,this
指向实例对象
this 的设计缺陷以及应对方案
嵌套函数中的 内部函数this
不会从外层函数中继承
var myObj = {name : " 北宸南蓁 ", showThis: function(){console.log(this)function inner(){console.log(this)}inner()}
}
myObj.showThis()
- 函数
inner
中的this
指向的是全局window
对象 - 函数
showThis
中的this
指向的是myObj
对象
解决方案
把 this 体系转换为了作用域的体系
var myObj = {name : " 北宸 ", showThis: function(){console.log(this)var self = thisfunction inner(){self.name = " 南蓁 "}inner()}
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
在 showThis
函数中声明一个变量 self
用来保存 this
,然后在 inner
函数中使用 self
使用 ES6 中的箭头函数
var myObj = {name : " 北宸 ", showThis: function(){console.log(this)var inner = ()=>{this.name = " 南蓁 "console.log(this)}inner()}
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)
ES6
中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this
取决于它的外部函数
普通函数中的 this
默认指向全局对象 window
通过设置 JavaScript
的“严格模式”来解决
在严格模式下,默认执行一个函数,其函数的执行上下文中的 this
值是 undefined
函数式编程,柯里化,redux 中间件
const curry = (fn,arity=fn.length,...args) => arity<=args.length? fn(...args): curry.bind(null,fn,arity,...args)
测试函数
const add = (a,b,c) => a+b+c;
curry(add)(1,2,3) // 结果为6
applyMiddleware
function applyMiddleware(...middlewares){return function(createStore){return function(reducer,initialState){var store = createStore(reducer,initialState);var dispatch = store.dispatch;var chain = [];var middlewareAPI = {getState: store.getState,dispatch: (action) => dispatch(action)};chain = middlewares.map(middleware => middleware(middlewareAPI));dispatch = compose(...chain)(store.dispatch);return { ...store, dispatch };}}
}
applyMiddleware
函数是一个三级柯里化函数
图片懒加载
利用JavaScript
实现懒加载的3种方式,原理都是判断图片是否出现在可视区后给图片赋值src属性。
利用HTML提供的 data-
属性来嵌入自定义数据
自定义数据存放这个标签原本的图片地址。
利用offsetTop计算位置
用JavaScript
实现当滚动滚动条时,如果图片出现在可视区,那么加载图片。加载图片其实就是给img标签src属性赋值为本来的地址,那么此时图片便会请求加载渲染出来
//获取全部img标签
var images = document.getElementsByTagName("img");window.addEventListener("scroll", (e) => {//当发生滚动事件时调用loadPic事件loadPic();});function loadPic() {// 遍历每一张图for (let i of images) {//判断当前图片是否在可视区内if (i.offsetTop <= window.innerHeight + window.scrollY) {//获取自定义data-src属性的值let trueSrc = i.getAttribute("data-src");//把值赋值给图片的src属性i.setAttribute("src", trueSrc);}}
}
//没发生滚动事件时也要先执行一次
loadPic();
offsetTop
为元素距离顶部的距离;window.innerHeight
为当前窗口的高度;window.scrollY
为滚动距离
当 i.offsetTop <= window.innerHeight + window.scrollY
时图片就处于窗口可视区了。
利用getBoundingClientRect().top
计算位置
window.addEventListener("scroll", (e) => {loadPic();});function loadPic() {for (let i of images) {//计算方式和第一种方式不同if (i.getBoundingClientRect().top < window.innerHeight) {let trueSrc = i.getAttribute("data-src");i.setAttribute("src", trueSrc);}}
}loadPic();
getBoundingClientRect().top
为元素相对于窗口的位置;window.innerHeight
为当前窗口的高度;
当元素对于窗口的位置小于当前窗口的高度时,那自然处于了窗口可视区了。
Intersection Observer
Intersection Observer
构造函数的作用是它能够观察可视窗口与目标元素产生的交叉区域。简单来说就是当用它观察我们的图片时,当图片出现或者消失在可视窗口,它都能知道并且会执行一个特殊的回调函数,我们就利用这个回调函数实现我们的操作
var images = document.getElementsByTagName("img");
function callback(entries) {for (let i of entries) {if (i.isIntersecting) {let img = i.target;let trueSrc = img.getAttribute("data-src");img.setAttribute("src", trueSrc);observer.unobserve(img);}}
}const observer = new IntersectionObserver(callback);for (let i of images) {observer.observe(i);
}
PromiseQueue
class PromiseQueue{constructor(tasks,concurrentCount=1){this.totals = tasks.length;this.todo =tasks;this.count = concurrentCount;this.running =[];this.complete =[];}runNext(){return (this.running.length < this.count&& this.todo.length)}run(){while(this.runNext()){let promise = this.todo.shift();promise.then(()=>{this.complete.push(this.running.shift());this.run();})this.running.push(promise)}}
}
测试用例
// 接收一个promise数组,定义窗口大小为3
const taskQueue = new PromiseQueue(tasks, 3);
taskQueue.run();
数组常用方法
改变原数组(7)
push
pop
shift
unshift
reverse
sort
splice
不会改变(7)
concat
join
slice
filter
reduce
find
findIndex
后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。
前端面试题库 (面试必备) 推荐:★★★★★
地址:前端面试题库
表妹一键制作自己的五星红旗国庆头像,超好看