跳到主要内容

世界上只有一种真正的英雄主义,那就是看清生活的真相之后,依然热爱生活。 -- 罗曼罗兰

大家好,我是柒八九。

在如今的互联网大环境下,每天都充斥着各种负能量。有可能,你上午还在工位摸鱼,下午HR已经给你单独开小灶,很煞有介事的通知你,提前毕业了。在这个浮躁的互联网环境下,总有一种我们永远不知道明天和意外哪个先到的感觉。

《古兰经》中有一句很契合的话,山不过来,我就过去。

既然,外部环境我们无法去改变,那就从我们内部改变。所以,我又重新总结了一套,2023年最新的面试集锦,以便大家一起度过寒冬,拥抱更好的未来。

note:

  • 其中有些知识点,在前面的文章中,有过涉猎,为了行文的方便和资料的完整性,我就又拿来主义了,免去大家去翻找。但是,前面的文章有更深的解读,如果想更深的学习,可以移步到对应文章中。
  • 如果在行文中,有技术披露和考虑不周的地方,不吝赐教。

你能所学到的知识点​

  1. JS 执行流程 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  2. 基本数据类型 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  3. ES6 的新特性有哪些 推荐阅读指数⭐️⭐️⭐️
  4. 箭头函数和普通函数的区别 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  5. Promise VS async/await 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  6. ES6 迭代器 推荐阅读指数⭐️⭐️⭐️
  7. 设计模式的分类 推荐阅读指数⭐️⭐️⭐️⭐️
  8. WebGL 和 canvas 的关系 推荐阅读指数⭐️⭐️
  9. CommonJS 和 ES6 Module 的区别 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  10. 声明变量的方式 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  11. Object/Map/WeakMap 的区别 推荐阅读指数⭐️⭐️⭐️⭐️
  12. JS 深浅复制 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  13. 闭包 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  14. Event Loop 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  15. 垃圾回收机制 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  16. 内存问题 推荐阅读指数⭐️⭐️⭐️
  17. 作用域的产生 推荐阅读指数⭐️⭐️⭐️⭐️
  18. this 指向 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  19. 图片懒加载 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
  20. PromiseQueue 推荐阅读指数⭐️⭐️⭐️⭐️
  21. 数组常用方法 推荐阅读指数⭐️⭐️⭐️⭐️

好了,天不早了,干点正事哇。 img

JS 执行流程

img

准备工作​

需要准备执行 JS 时所需要的一些基础环境

  • 初始化了内存中的堆和栈结构

  • JS 全局执行上下文

    • 包含了执行过程中的全局信息, 比如一些内置函数,全局变量等信息
  • 全局作用域

    • 包含了一些全局变量, 在执行过程中的数据都需要存放在内存中
  • 初始化

    消息循环系统

    • 消息驱动器
    • 消息队列
  • img


执行流程​

img

  1. V8

    接收到要执行的

    JS 源代码
    • 源代码对 V8 来说只是一堆字符串,V8 并不能直接理解这段字符串的含义
  2. V8结构化这段字符串,生成了{抽象语法树|AST},同时还会生成相关的作用域

  3. 生成

    字节码

    (介于

    AST

    和

    机器代码

    的中间代码)

    • 与特定类型的机器代码无关
  4. 解释器(ignition),按照顺序解释执行字节码,并输出执行结果。

从图中得出一个结论:执行 JS 代码核心流程

  1. 先编译
  2. 后执行

通过V8将js转换为字节码然后经过解释器执行输出结果的方式执行JS,有一个弊端就是,如果在浏览器中再次打开相同的页面,当页面中的 JavaScript 文件没有被修改,再次编译之后的二进制代码也会保持不变,意味着编译这一步浪费了 CPU 资源。

为了,更好的利用 CPU 资源,V8 采用JIT(Just In Time)技术提升效率:而是混合编译执行和解释执行这两种手段。

  1. 解释执行的启动速度快,但是执行时的速度慢
  2. 编译执行的启动速度慢,但是执行时的速度快

Just-in-time 编译器:综合了解释器和编译器的优点​

为了解决解释器的低效问题,后来的浏览器把编译器也引入进来,形成混合模式。

在 JavaScript 引擎中增加一个监视器(也叫分析器)。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息。

如果同一行代码运行了几次,这个代码段就被标记成了 warm,如果运行了很多次,则被标记成 hot。


基线编译器​

如果一段代码变成了 warm,那么 JIT 就把它送到编译器去编译,并且把编译结果存储起来。

代码段的每一行都会被编译成一个“桩”(stub),同时给这个桩分配一个以行号 + 变量类型的索引。如果监视器监视到了执行同样的代码和同样的变量类型,那么就直接把这个已编译的版本 push 出来给浏览器。


优化编译器​

如果一个代码段变得 very hot,监视器会把它发送到优化编译器中。生成一个更快速和高效的代码版本出来,并且存储之。

为了生成一个更快速的代码版本,优化编译器必须做一些假设。

例如,它会假设由同一个构造函数生成的实例都有相同的形状

就是说所有的实例

  • 都有相同的属性名
  • 并且都以同样的顺序初始化

那么就可以针对这一模式进行优化。

整个优化器起作用的链条是这样的

  1. 监视器从他所监视代码的执行情况做出自己的判断
  2. 接下来把它所整理的信息传递给优化器进行优化
  3. 如果某个循环中先前每次迭代的对象都有相同的形状,那么就可以认为它以后迭代的对象的形状都是相同的。

可是对于 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 在选择一个桩之前,会进行多分枝选择,类似于决策树,问自己很多问题才会确定最终选择哪个,见下图:

img


基本数据类型

数据类型分类(7+1)​

  1. undefined

  2. null

  3. Boolean

  4. String

  5. Number

  6. Symbol(es6)

  7. BigInt(es2020)

  8. Object
    1. {常规对象|Ordinary Object}
    2. {异质对象|Exotic Object}

存储位置不同

  • (1 - 7) :栈内存 (基本 primary 数据类型)
  • (8): 堆内存

判断数据类型的方式 (TTIC)​

  1. typeof
    • 判断基本数据类型
    • typeof null 特例,返回的是"object"
  2. Object.prototype.toString.call(xx)
    • 判断基本数据类型
    • 实现原理:
      • 若参数(xx)不为 null 或 undefined,则将参数转为对象,再作判断
      • 转为对象后,取得该对象的 [Symbol.toStringTag] 属性值(可能会遍历原型链)作为 tag,然后返回 "[object " + tag + "]" 形式的字符串。
  3. instanceof
    • a instanceof B判断的是 a 和 B 是否有血缘关系,而不是仅仅根据是否是父子关系。 img
    • 在 ES6 中 instanceof 操作符会使用 Symbol.hasInstance 函数来确定关系。 img
  4. constructor
    • 只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。
    • 默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。
    • 每次调用构造函数创建一个新实例,实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。
    • 实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有
    • 通过实例和构造函数原型对象的关系,来判断是否实例类型。 img
    • null/undefined是一个假值,没有对应包装对象(无法进行装箱操作),也不是任何构造函数的实例。所以,不存在原型,即,无法使用 constructor 判断类型。
    • img

ES6 的新特性有哪些

  1. const 和 let

  2. 解构赋值

  3. 模板字符串

  4. 函数的扩展

    • 函数的默认值
    • rest参数
    • 函头函数
  5. 数组的扩展

    • Array.from()将类数组转为数组
    • find()、findIndex()找出第一个符合条件的成员/下标
    • entries()、keys()、values() 用于遍历数组。(配合for...of)
    • includes() 是否存在指定无素(返回布尔值)
  6. 对象的扩展

    • 属性名可使用表达式
    • Object.assign()
    • Object.keys(), Object.values(), Object.entries()
  7. Symbol

  8. Set、Map

  9. Promise

  10. Iterator

    和

    for...of
    • 为各种数据提供统一的,简便的访问接口
  11. Generator与async await


箭头函数和普通函数的区别

  1. 语法更加简洁、清晰
  2. 箭头函数没有 prototype (原型),所以箭头函数本身没有this
  3. 箭头函数不会创建自己的this
    • 箭头函数没有自己的this,箭头函数的this指向在定义的时候继承自外层第一个普通函数的 this
  4. call | apply | bind 无法改变箭头函数中this的指向
  5. 箭头函数不能作为构造函数使用
  6. 箭头函数不绑定arguments,取而代之用rest参数...代替arguments对象,来访问箭头函数的参数列表
  7. 箭头函数不能用作Generator函数,不能使用yield关键字

Promise VS async/await

Promise​

Promise 对象就是为了解决回调地狱而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。

分析 Promise 的调用流程:

  1. Promise 的构造方法接收一个executor(),在new Promise()时就立刻执行这个 executor 回调
  2. executor()内部的异步任务被放入宏/微任务队列,等待执行
  3. then()被执行,收集成功/失败回调,放入成功/失败队列
  4. executor() 的异步任务被执行,触发resolve/reject,从成功/失败队列中取出回调依次执行

其实熟悉设计模式,很容易就能意识到这是个观察者模式,这种

  1. 收集依赖
  2. 触发通知
  3. 取出依赖执行

的方式,被广泛运用于观察者模式的实现,

在 Promise 里,执行顺序是

  1. then收集依赖
  2. 异步触发resolve
  3. 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._resolveQueue
let _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);
}
};
// 实现同resolve
let _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和reject
executor(_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看起来其实已经很相似了,它们都提供了暂停执行的功能,但二者又有三点不同:

  1. async/await自带执行器,不需要手动调用 next()就能自动执行下一步
  2. async 函数返回值是 Promise 对象,而 Generator 返回的是生成器对象
  3. await 能够返回 Promise 的 resolve/reject 的值

不管await后面跟着的是什么,await都会阻塞后面的代码

Generator​

Generator 实现的核心在于上下文的保存,函数并没有真的被挂起,每一次 yield,其实都执行了一遍传入的生成器函数,只是在这个过程中间用了一个 context 对象储存上下文,使得每次执行生成器函数的时候,都可以从上一个执行结果开始执行,看起来就像函数被挂起了一样。

用babel编译后生成regeneratorRuntime

  1. mark()方法为生成器函数绑定了一系列原型
  2. wrap()相当于是给 generator 增加了一个_invoke 方法

两者的区别​

Promise的出现解决了传统callback函数导致的地域回调问题,但它的语法导致了它向纵向发展行成了一个回调链,遇到复杂的业务场景,这样的语法显然也是不美观的。

而async await代码看起来会简洁些,使得异步代码看起来像同步代码,await的本质是可以提供等同于”同步效果“的等待异步返回能力的语法糖,只有这一句代码执行完,才会执行下一句。

async/await与Promise一样,是非阻塞的。

async/await是基于Promise实现的,可以说是改良版的Promise,它不能用于普通的回调函数。


ES6 迭代器

迭代器模式​

可以把有些结构称为{可迭代对象|iterable},它们实现了正式的 Iterable 接口 而且可以通过{迭代器|Iterator}消费

{迭代器|Iterator}是按需创建的一次性对象

每个迭代器都会关联一个可迭代对象

可迭代协议​

实现 Iterable 接口(可迭代协议)要求同时具备两种能力

  1. 支持迭代的自我识别能力
  2. 创建实现 Iterator 接口的对象的能力

这意味着必须暴露一个属性作为默认迭代器,这个属性必须使用特殊的 Symbol.iterator 作为键,这个默认迭代器属性必须引用一个迭代器工厂函数。调用这个工厂函数必须返回一个新迭代器

内置类型都实现了 Iterable 接口​

  1. 字符串
  2. 数组
  3. Map
  4. Set
  5. arguments 对象
  6. NodeList 等 DOM 集合类型

接收可迭代对象的原生语言特性包括​

  1. for-of 循环
  2. 数组解构
  3. 扩展操作符
  4. Array.from()
  5. 创建Set
  6. 创建Map
  7. Promise.all()接收由Promise组成的可迭代对象
  8. Promise.race()接收由Promise组成的可迭代对象
  9. yield*操作符,在生成器中使用

迭代器协议​

迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象

迭代器 API 使用 next()方法在可迭代对象中遍历数据,每次成功调用 next(),都会返回一个 IteratorResult 对象,其中包含迭代器返回的下一个值。

next()方法返回的迭代器对象 IteratorResult 包含两个属性

  1. done
    • 一个布尔值,表示是否还可以再次调用 next()取得下一个值
  2. 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)

  1. 创建型模式,共五种:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式。
  2. 结构型模式,共七种:适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式。
  3. 行为型模式,共十一种:策略模式、模板方法模式、观察者模式/发布订阅模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。

手写单例模式(创建模式)​

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);
// notify
queuedObservers.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 发布订阅模式​

img

  1. 从表面上看:
    • 观察者模式里,只有两个角色 —— 观察者 + 被观察者
    • 而发布订阅模式里,却不仅仅只有发布者和订阅者两个角色,还有一个经常被我们忽略的 —— {经纪人|Broker}
  2. 往更深层次讲:
    • 观察者和被观察者,是松耦合的关系
    • 发布者和订阅者,则完全不存在耦合
  3. 从使用层面上讲:
    • 观察者模式,多用于单个应用内部
    • 发布订阅模式,则更多的是一种{跨应用的模式|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 的区别

  1. CommonJS

    是

    同步加载

    模块,

    ES6

    是

    异步加载

    模块

    • CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。
    • 浏览器加载 ES6 模块是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本
  2. CommonJS

    模块输出的是一个

    值的拷贝

    ,

    ES6

    模块输出的是

    值的引用

    。

    • CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值
    • ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
  3. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

是否可以在浏览器端使用 CommonJS​

CommonJS不适用于浏览器环境


声明变量的方式(2 + 4 )

  • ES5
    1. var命令
    2. function命令
  • ES6
    1. let
    2. const
    3. import
    4. class

函数的声明​

  1. function

    命令

    • function fn(s) {}
  2. 函数表达式

    • var fn = function(s) {}
  3. 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 个)​

  1. 扩展运算符
  2. Object.assign()
  3. 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)
);
}
  1. 能够复制所有自有属性
  2. 能够复制非枚举属性

深复制​

通过嵌套扩展运算符实现深复制​

const original = { name: "789", work: { address: "BeiJing" } };
const copy = { name: original.name, work: { ...original.work } };

original.work !== copy.work; // 指向不同的引用地址

使用 JSON 实现数据的深复制​

先将普通对象,

  1. 先转换为JSON串(stringify)
  2. 然后再解析(parse)该串
function jsonDeepCopy(original) {
return JSON.parse(JSON.stringify(original));
}

而通过这种方式有一个很明显的缺点就是:

只能处理JSON所能识别的key和value。对于不支持的类型,会被直接忽略掉。

手动实现​

递归函数实现深复制​

实现逻辑就是(FHT)

  1. 利用 for-in对对象的属性进行遍历(自身属性+继承属性)
  2. source.hasOwnProperty(i)判断是否是非继承的可枚举属性
  3. 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 = 1;
return test;
}
foo.myName = 1;
foo.obj = { x: 1 };
foo.fun = function () {
return 0;
};

根据对象的数据特性:foo 函数拥有myName/obj/fun 的属性

但是函数和普通对象不同的是,函数可以被调用。

从V8 内部来看看函数是如何实现可调用特性​

在 V8 内部,会为函数对象添加了两个隐藏属性

  • name 属性:属性的值就是函数名称
  • code 属性:表示函数代码,以字符串的形式存储在内存中

img

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时,调用栈的情况 img
  • test

    函数执行完成之后,其执行上下文

    从栈顶弹出

    了

    • 但是由于返回innerObj对象中的 setName 和 getName 方法中使用了 test 函数内部的变量 myName 和 age 所以这两个变量依然保存在内存中(Closure (test))
    • img
  • 当执行到

    t.setName

    方法的时,调用栈如下:

    • img
  • 利用 debugger 来查看对应的作用链和调用栈信息

    • img

通过上面分析,然后参考作用域的概念和使用方式,我们可以做一个简单的结论

闭包和词法环境的强相关

而 JS 的作用域由词法环境决定,并且作用域是静态的。

所以,我们可以得出一个结论:

闭包在每次创建函数时创建(闭包在 JS 编译阶段被创建)


闭包是如何产生的?​

产生闭包的核心两步:

  1. 预扫描内部函数
  2. 把内部函数引用的外部变量保存到堆中
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

img

{事件循环|Event Loop}​

事件循环是一个不停的从 宏任务队列/微任务队列中取出对应任务的循环函数。 在一定条件下,你可以将其类比成一个永不停歇的永动机。 它从宏/微任务队列中取出任务并将其推送到调用栈中被执行。

事件循环包含了四个重要的步骤:

  1. 执行 Script

    :以

    同步的方式

    执行

    script

    里面的代码,直到

    调用栈为空

    才停下来。

    • 其实,在该阶段,JS 还会进行一些预编译等操作。(例如,变量提升等)。
  2. 执行一个宏任务:从宏任务队列中挑选最老的任务并将其推入到调用栈中运行,直到调用栈为空。

  3. 执行

    所有

    微任务:从微任务队列中挑选

    最老

    的任务并将其推入到调用栈中运行,直到调用栈为空。

    • 但是,但是,但是(转折来了),继续从微任务队列中挑选最老的任务并执行。直到微任务队列为空。
  4. UI 渲染:渲染 UI,然后,跳到第二步,继续从宏任务队列中挑选任务执行。(这步只适用浏览器环境,不适用 Node 环境)

事件循环的单次迭代过程被称为 tick

{宏任务队列|Task Queue}​

也可以称为{回调队列| Callback queue}。

调用栈是用于跟踪正在被执行函数的机制,而宏任务队列是用于跟踪将要被执行函数的机制。

事件循环不知疲倦的运行着,并且按照一定的规则从宏任务队列中不停的取出任务对象。

宏任务队列是一个FIFO(先进先出)的队列结构。结构中存储的宏任务会被事件循环探查到。并且,这些任务是同步阻塞的。当一个任务被执行,其他任务是被挂起的(按顺序排队)。

{微任务队列|Microtask Queue}​

微任务队列也是一个FIFO(先进先出)的队列结构。并且,结构中存储的微任务也会被事件循环探查到。微任务队列和宏任务队列很像。作为 ES6 的一部分,它被添加到 JS 的执行模型中,以处理 Promise 回调。

微任务和宏任务也很像。它也是一个同步阻塞代码,运行时也会霸占调用栈。像宏任务一样,在运行期间,也会触发新的微任务,并且将新任务提交到微任务队列中,按照队列排队顺序,将任务进行合理安置。

  • 宏任务是在循环中被执行,并且 UI 渲染穿插在宏任务中。
  • 微任务是在一个宏任务完成之后,在 UI 渲染之前被触发。

微任务队列是 ES6 新增的专门用于处理Promise调用的数据结构。它和宏任务队列很像,它们最大的不同就是微任务队列是专门处理微任务的相关处理逻辑的。


{垃圾回收机制|Garbage Collecation}

垃圾回收算法​

  1. 通过

    GC Root

    标记空间中

    活动对象

    和

    非活动对象

    • V8 采用的{可访问性| reachability}算法,来判断堆中的对象是否是活动对象
    • 这个算法是将一些 GC Root 作为初始存活的对象的集合
    • 从 GC Roots 对象出发,遍历 GC Root 中的所有对象
    • 通过 GC Roots 遍历到的对象,认为该对象是{可访问的| reachable},也称可访问的对象为活动对象
    • 通过 GC Roots 没有遍历到的对象,是{不可访问的| unreachable},不可访问的对象为非活动对象
    • 浏览器环境中,GC Root 包括 1.全局的 window 对象,2.文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成,3.存放栈上变量
  2. 回收非活动对象所占据的内存

  3. 内存整理

    • 频繁回收对象后,内存中就会存在大量不连续空间
    • 这些不连续的内存空间称为内存碎片

代际假说​

代际假说是垃圾回收领域中一个重要的术语

两个特点

  1. 第一个是大部分对象都是

    朝生夕死

    的

    • 大部分对象在内存中存活的时间很短
    • 比如函数内部声明的变量,或者块级作用域中的变量
  2. 第二个是不死的对象,会活得更久

    • 比如全局的 window、DOM、Web API 等对象

堆空间​

在 V8 中,会把堆分为

  1. 新生代
    • 存放的是生存时间短的对象
    • 新生代通常只支持 1 ~ 8M 的容量
    • {副垃圾回收器| Minor GC} (Scavenger)
    • 负责新生代的垃圾回收
  2. 老生代
    • 存放生存时间久的对象
    • {主垃圾回收器| Major GC}
    • 负责老生代的垃圾回收

img


{副垃圾回收器| Minor GC}​

新生代中的垃圾数据用 Scavenge 算法来处理。

所谓 Scavenge 算法,把新生代空间对半划分为两个区域:

  • 一半是对象区域 (from-space)
  • 一半是空闲区域 (to-space)

当对象区域快被写满时,就需要执行一次垃圾清理操作,

  1. 首先要对对象区域中的垃圾做标记,
  2. 标记完成之后,就进入垃圾清理阶段,
    • 把这些存活的对象复制到空闲区域中,把这些对象有序地排列起来
    • img
  3. 完成复制后,对象区域与空闲区域进行角色翻转
    • img

副垃圾回收器采用对象晋升策略:移动那些经过两次垃圾回收依然还存活的对象到老生代中。


{主垃圾回收器| Major GC}​

负责老生代中的垃圾回收,除了新生代中晋升的对象,大的对象会直接被分配到老生代里。

老生代中的对象有两个特点

  1. 对象占用空间大
  2. 对象存活时间长

{标记 - 清除|Mark-Sweep}算法​

  1. 标记过程阶段
    • 从一组根元素开始,递归遍历这组根元素
    • 这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据
  2. 垃圾的清除过程
    • 主垃圾回收器会直接将标记为垃圾的数据清理掉
    • img

{标记 - 整理|Mark-Compact}​

  1. 标记可回收对象
  2. 垃圾清除
    • 不是直接对可回收对象进行清理
    • 而是让所有存活的对象都向一端移动
    • 直接清理掉这一端之外的内存
  • img

内存问题

内存泄漏 (Memory leak)​

不再需要 (没有作用) 的内存数据依然被其他对象引用着。

污染全局(window)​

function foo() {
//创建一个临时的temp_array
temp_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 = 1;
temp_object.y = 2;
temp_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 大类

  1. 声明式作用域
    • 函数作用域
    • module 作用域
  2. 对象作用域
  3. 全局作用域

声明式作用域​

声明式 ER 可以通过 var/const/let/class/module/import/function生成。

常说的ES6 块级作用域和函数作用域属于同一大类(声明式作用域)。

根据实现层级,还有一个更准确的结论:

ES6 块级作用域是函数作用域的子集

全局作用域​

全局作用域是最外面的作用域,它没有外部作用域。即全局环境的OuterEnv为null。

全局 ER 使用两个 ER 来管理其变量:

  1. 对象 ER
    • 将变量存储在全局对象中
    • 顶层作用域下,var 和 function 声明的变量被绑定在对象 ER 里(在浏览器环境下, window 指向全局对象)
  2. 声明式 ER
    • 使用内部对象来存储变量
    • 顶层作用域下,const/let/class声明的变量被绑定在声明 ER

当声明式 ER 和对象 ER 有共同的变量,声明式优先级高。


this 指向

img {执行上下文 |Execution context} 中包含了

  1. {变量环境 |Viriable Environment}
  2. {词法环境 |Lexical Environment}
  3. {外部环境 |outer}
  4. this
this` 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 `this

执行上下文主要分为三种​

  1. 全局执行上下文
  2. 函数执行上下文
  3. 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 = this;
function 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)​

  1. push
  2. pop
  3. shift
  4. unshift
  5. reverse
  6. sort
  7. splice

不会改变(7)​

  1. concat
  2. join
  3. slice
  4. filter
  5. reduce
  6. find
  7. findIndex

后记

分享是一种态度。

全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。

信息

作者:前端小魔女 链接:https://juejin.cn/post/7202904269535887418 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。