【面试题】2024前端面试真题之JS篇

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

既然,外部环境我们无法去改变,那就从我们内部改变。所以,我又重新总结了一套,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. 数组常用方法 推荐阅读指数⭐️⭐️⭐️⭐️

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

JS执行流程

准备工作

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

  • 初始化了内存中的堆和栈结构
  • JS全局执行上下文
    • 包含了执行过程中的全局信息, 比如一些内置函数全局变量等信息
  • 全局作用域
    • 包含了一些全局变量, 在执行过程中的数据都需要存放在内存中
  • 初始化消息循环系统
    • 消息驱动器
    • 消息队列

执行流程

  1. V8 接收到要执行的 JS 源代码
    • 源代码对 V8 来说只是一堆字符串V8 并不能直接理解这段字符串的含义
  2. V8结构化这段字符串,生成了{抽象语法树|AST},同时还会生成相关的作用域
  3. 生成字节码(介于 AST 和机器代码的中间代码)
    • 与特定类型的机器代码无关
  4. 解释器(ignition),按照顺序解释执行字节码,并输出执行结果。

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

  1. 先编译
  2. 后执行

通过V8js转换为字节码然后经过解释器执行输出结果的方式执行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 在选择一个桩之前,会进行多分枝选择,类似于决策树,问自己很多问题才会确定最终选择哪个,见下图:


基本数据类型

数据类型分类(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 是否有血缘关系,而不是仅仅根据是否是父子关系。 
    • 在ES6中 instanceof 操作符会使用 Symbol.hasInstance 函数来确定关系。 
  4. constructor

    • 只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。
    • 默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数
    • 每次调用构造函数创建一个新实例,实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象
    • 实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有
    • 通过实例和构造函数原型对象的关系,来判断是否实例类型。

    • null/undefined是一个假值,没有对应包装对象(无法进行装箱操作),也不是任何构造函数的实例。所以,不存在原型,即,无法使用 constructor 判断类型。

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. SetMap
  9. Promise
  10. Iteratorfor...of
    • 为各种数据提供统一的,简便的访问接口
  11. Generatorasync 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(生成器)的封装,是一个语法糖

*/yieldasync/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/awaitPromise一样,是非阻塞的。

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 发布订阅模式

  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.jsPIXI 等

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结构类似,也是用于生成键值对的集合。

WeakMapMap的区别有两点。

  • 首先,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所能识别的keyvalue。对于不支持的类型,会被直接忽略掉。

手动实现

递归函数实现深复制

实现逻辑就是(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 属性:表示函数代码,以字符串的形式存储在内存

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编译阶段被创建)


闭包是如何产生的?

产生闭包的核心两步:

  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

{事件循环|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. 第一个是大部分对象都是朝生夕死的
  • 大部分对象在内存中存活的时间很短
  • 比如函数内部声明的变量,或者块级作用域中的变量
  1. 第二个是不死的对象,会活得更久
  • 比如全局的 windowDOMWeb API 等对象

堆空间

在 V8 中,会把分为

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


{副垃圾回收器| Minor GC}

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

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

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

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

  1. 首先要对对象区域中的垃圾做标记,

  2. 标记完成之后,就进入垃圾清理阶段,

    • 把这些存活的对象复制到空闲区域中,把这些对象有序地排列起来
  3. 完成复制后,对象区域与空闲区域进行角色翻转

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


{主垃圾回收器| Major GC}

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

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

  1. 对象占用空间大
  2. 对象存活时间长
{标记 - 清除|Mark-Sweep}算法
  1. 标记过程阶段
    • 从一组根元素开始,递归遍历这组根元素
    • 这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据
  2. 垃圾的清除过程
    • 主垃圾回收器会直接将标记为垃圾的数据清理掉
{标记 - 整理|Mark-Compact}
  1. 标记可回收对象
  2. 垃圾清除
    • 不是直接对可回收对象进行清理
    • 而是让所有存活的对象都向一端移动
    • 直接清理掉这一端之外的内存

内存问题

内存泄漏 (Memory leak)

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

污染全局(window)
function foo() {
    //创建一个临时的temp_array
    temp_array = new Array(200000)
   /**
    * 使用temp_array
    */
}

函数体内的对象没有被 varletconst 这些关键字声明。

最后

全网独播-价值千万金融项目前端架构实战

从两道网易面试题-分析JavaScript底层机制

RESTful架构在Nodejs下的最佳实践

一线互联网企业如何初始化项目-做一个自己的vue-cli

思维无价,看我用Nodejs实现MVC

代码优雅的秘诀-用观察者模式深度解耦模块

前端高级实战,如何封装属于自己的JS库

VUE组件库级组件封装-高复用弹窗组件

  • 15
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值